diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fe3e2ec49a..0dade6da5d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -67,12 +67,17 @@ android { } dependencies { - implementation(projects.feature.interests) - implementation(projects.feature.foryou) - implementation(projects.feature.bookmarks) - implementation(projects.feature.topic) - implementation(projects.feature.search) - implementation(projects.feature.settings) + implementation(projects.feature.interests.api) + implementation(projects.feature.interests.impl) + implementation(projects.feature.foryou.api) + implementation(projects.feature.foryou.impl) + implementation(projects.feature.bookmarks.api) + implementation(projects.feature.bookmarks.impl) + implementation(projects.feature.topic.api) + implementation(projects.feature.topic.impl) + implementation(projects.feature.search.api) + implementation(projects.feature.search.impl) + implementation(projects.feature.settings.api) implementation(projects.core.common) implementation(projects.core.ui) @@ -84,16 +89,17 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.material3.adaptive.layout) implementation(libs.androidx.compose.material3.adaptive.navigation) + implementation(libs.androidx.compose.material3.adaptive.navigation3) implementation(libs.androidx.compose.material3.windowSizeClass) implementation(libs.androidx.compose.runtime.tracing) implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.splashscreen) - implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.lifecycle.runtimeCompose) - implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.lifecycle.viewModel.navigation3) implementation(libs.androidx.profileinstaller) implementation(libs.androidx.tracing.ktx) implementation(libs.androidx.window.core) diff --git a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index 54053a1bbf..14454a3601 100644 --- a/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.ui import androidx.compose.ui.semantics.SemanticsActions.ScrollBy import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.hasTestTag @@ -39,18 +40,20 @@ import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPermissionRule +import com.google.samples.apps.nowinandroid.feature.interests.impl.LIST_PANE_TEST_TAG import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.Before +import org.junit.Ignore import org.junit.Rule import org.junit.Test import javax.inject.Inject -import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR -import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR -import com.google.samples.apps.nowinandroid.feature.search.R as FeatureSearchR -import com.google.samples.apps.nowinandroid.feature.settings.R as SettingsR +import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R as BookmarksR +import com.google.samples.apps.nowinandroid.feature.foryou.api.R as FeatureForyouR +import com.google.samples.apps.nowinandroid.feature.search.api.R as FeatureSearchR +import com.google.samples.apps.nowinandroid.feature.settings.api.R as SettingsR /** * Tests all the navigation flows that are handled by the navigation library. @@ -83,12 +86,12 @@ class NavigationTest { lateinit var newsRepository: NewsRepository // The strings used for matching in these tests - private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_navigate_up) - private val forYou by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_title) - private val interests by composeTestRule.stringResource(FeatureSearchR.string.feature_search_interests) + private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_api_navigate_up) + private val forYou by composeTestRule.stringResource(FeatureForyouR.string.feature_foryou_api_title) + private val interests by composeTestRule.stringResource(FeatureSearchR.string.feature_search_api_interests) private val sampleTopic = "Headlines" private val appName by composeTestRule.stringResource(R.string.app_name) - private val saved by composeTestRule.stringResource(BookmarksR.string.feature_bookmarks_title) + private val saved by composeTestRule.stringResource(BookmarksR.string.feature_bookmarks_api_title) private val settings by composeTestRule.stringResource(SettingsR.string.feature_settings_top_app_bar_action_icon_description) private val brand by composeTestRule.stringResource(SettingsR.string.feature_settings_brand_android) private val ok by composeTestRule.stringResource(SettingsR.string.feature_settings_dismiss_dialog_button_text) @@ -252,6 +255,9 @@ class NavigationTest { } } + // TODO decide if backStack should preserve previous stacks when navigating back to home tab (ForYou) + // https://github.com/android/nowinandroid/issues/1937 + @Ignore @Test fun navigationBar_multipleBackStackInterests() { composeTestRule.apply { @@ -261,12 +267,14 @@ class NavigationTest { val topic = runBlocking { topicsRepository.getTopics().first().sortedBy(Topic::name).last() } - onNodeWithTag("interests:topics").performScrollToNode(hasText(topic.name)) + onNodeWithTag(LIST_PANE_TEST_TAG).performScrollToNode(hasText(topic.name)) onNodeWithText(topic.name).performClick() + // Verify the topic is still shown + onNodeWithTag("topic:${topic.id}").assertIsDisplayed() + // Switch tab onNodeWithText(forYou).performClick() - // Come back to Interests onNodeWithText(interests).performClick() diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt index ecc23d80e0..71b50e7999 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/MainActivity.kt @@ -32,6 +32,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.metrics.performance.JankStats +import androidx.navigation3.runtime.EntryProviderBuilder import androidx.tracing.trace import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper @@ -40,6 +41,8 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourc import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackViewModel +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey import com.google.samples.apps.nowinandroid.core.ui.LocalTimeZone import com.google.samples.apps.nowinandroid.ui.NiaApp import com.google.samples.apps.nowinandroid.ui.rememberNiaAppState @@ -72,9 +75,13 @@ class MainActivity : ComponentActivity() { @Inject lateinit var userNewsResourceRepository: UserNewsResourceRepository - private val viewModel: MainActivityViewModel by viewModels() + private val backStackViewModel: NiaBackStackViewModel by viewModels() + + @Inject + lateinit var entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder.() -> Unit> + override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() super.onCreate(savedInstanceState) @@ -137,6 +144,7 @@ class MainActivity : ComponentActivity() { networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, + niaBackStack = backStackViewModel.niaBackStack, ) val currentTimeZone by appState.currentTimeZone.collectAsStateWithLifecycle() @@ -150,7 +158,10 @@ class MainActivity : ComponentActivity() { androidTheme = themeSettings.androidTheme, disableDynamicTheming = themeSettings.disableDynamicTheming, ) { - NiaApp(appState) + NiaApp( + appState, + entryProviderBuilders, + ) } } } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/di/BackStackProvider.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/di/BackStackProvider.kt new file mode 100644 index 0000000000..998d60ce9d --- /dev/null +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/di/BackStackProvider.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.di + +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.serialization.modules.PolymorphicModuleBuilder +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object BackStackProvider { + @Provides + @Singleton + fun provideNiaBackStack(): NiaBackStack = + NiaBackStack(startKey = TopLevelDestination.FOR_YOU.key) + + /** + * Registers feature modules' polymorphic serializers to support + * feature keys' save and restore by savedstate + * in [com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackViewModel]. + */ + @Provides + @Singleton + fun provideSerializersModule( + polymorphicModuleBuilders: Set<@JvmSuppressWildcards PolymorphicModuleBuilder.() -> Unit>, + ): SerializersModule = SerializersModule { + polymorphic(NiaNavKey::class) { + polymorphicModuleBuilders.forEach { it() } + } + } +} diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavDisplay.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavDisplay.kt new file mode 100644 index 0000000000..da4461a846 --- /dev/null +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavDisplay.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.navigation + +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy +import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberSavedStateNavEntryDecorator +import androidx.navigation3.ui.NavDisplay +import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey + +@OptIn(ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun NiaNavDisplay( + niaBackStack: NiaBackStack, + entryProviderBuilders: Set.() -> Unit>, +) { + val listDetailStrategy = rememberListDetailSceneStrategy() + + NavDisplay( + backStack = niaBackStack.backStack, + sceneStrategy = listDetailStrategy, + onBack = { count -> niaBackStack.popLast(count) }, + entryDecorators = listOf( + rememberSceneSetupNavEntryDecorator(), + rememberSavedStateNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + entryProvider = entryProvider { + entryProviderBuilders.forEach { builder -> + builder() + } + }, + ) +} diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt deleted file mode 100644 index e079c98f45..0000000000 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.navigation - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.navigation.compose.NavHost -import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen -import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouBaseRoute -import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouSection -import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests -import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen -import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic -import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen -import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS -import com.google.samples.apps.nowinandroid.ui.NiaAppState -import com.google.samples.apps.nowinandroid.ui.interests2pane.interestsListDetailScreen - -/** - * Top-level navigation graph. Navigation is organized as explained at - * https://d.android.com/jetpack/compose/nav-adaptive - * - * The navigation graph defined in this file defines the different top level routes. Navigation - * within each route is handled using state and Back Handlers. - */ -@Composable -fun NiaNavHost( - appState: NiaAppState, - onShowSnackbar: suspend (String, String?) -> Boolean, - modifier: Modifier = Modifier, -) { - val navController = appState.navController - NavHost( - navController = navController, - startDestination = ForYouBaseRoute, - modifier = modifier, - ) { - forYouSection( - onTopicClick = navController::navigateToTopic, - ) { - topicScreen( - showBackButton = true, - onBackClick = navController::popBackStack, - onTopicClick = navController::navigateToTopic, - ) - } - bookmarksScreen( - onTopicClick = navController::navigateToInterests, - onShowSnackbar = onShowSnackbar, - ) - searchScreen( - onBackClick = navController::popBackStack, - onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) }, - onTopicClick = navController::navigateToInterests, - ) - interestsListDetailScreen() - } -} diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt index 429e626ffb..ea5fdb3458 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt @@ -20,14 +20,14 @@ import androidx.annotation.StringRes import androidx.compose.ui.graphics.vector.ImageVector import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons -import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.BookmarksRoute -import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouBaseRoute -import com.google.samples.apps.nowinandroid.feature.foryou.navigation.ForYouRoute -import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksRoute +import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute +import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute import kotlin.reflect.KClass -import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR -import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR -import com.google.samples.apps.nowinandroid.feature.search.R as searchR +import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R as bookmarksR +import com.google.samples.apps.nowinandroid.feature.foryou.api.R as forYouR +import com.google.samples.apps.nowinandroid.feature.search.api.R as searchR /** * Type for the top level destinations in the application. Contains metadata about the destination @@ -49,28 +49,32 @@ enum class TopLevelDestination( @StringRes val iconTextId: Int, @StringRes val titleTextId: Int, val route: KClass<*>, - val baseRoute: KClass<*> = route, + val key: NiaNavKey, ) { FOR_YOU( selectedIcon = NiaIcons.Upcoming, unselectedIcon = NiaIcons.UpcomingBorder, - iconTextId = forYouR.string.feature_foryou_title, + iconTextId = forYouR.string.feature_foryou_api_title, titleTextId = R.string.app_name, route = ForYouRoute::class, - baseRoute = ForYouBaseRoute::class, + key = ForYouRoute, ), BOOKMARKS( selectedIcon = NiaIcons.Bookmarks, unselectedIcon = NiaIcons.BookmarksBorder, - iconTextId = bookmarksR.string.feature_bookmarks_title, - titleTextId = bookmarksR.string.feature_bookmarks_title, + iconTextId = bookmarksR.string.feature_bookmarks_api_title, + titleTextId = bookmarksR.string.feature_bookmarks_api_title, route = BookmarksRoute::class, + key = BookmarksRoute, ), INTERESTS( selectedIcon = NiaIcons.Grid3x3, unselectedIcon = NiaIcons.Grid3x3, - iconTextId = searchR.string.feature_search_interests, - titleTextId = searchR.string.feature_search_interests, + iconTextId = searchR.string.feature_search_api_interests, + titleTextId = searchR.string.feature_search_api_interests, route = InterestsRoute::class, + key = InterestsRoute(null), ), } + +internal val TopLevelDestinations = TopLevelDestination.entries.associateBy { dest -> dest.key } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index b237684ef2..c157978798 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -33,15 +33,14 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration.Indefinite -import androidx.compose.material3.SnackbarDuration.Short import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult.ActionPerformed import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -60,9 +59,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavDestination -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation3.runtime.EntryProviderBuilder import com.google.samples.apps.nowinandroid.R import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground @@ -71,15 +68,18 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAp import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors -import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog -import com.google.samples.apps.nowinandroid.navigation.NiaNavHost +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState +import com.google.samples.apps.nowinandroid.feature.search.api.navigation.navigateToSearch +import com.google.samples.apps.nowinandroid.feature.settings.api.SettingsDialog +import com.google.samples.apps.nowinandroid.navigation.NiaNavDisplay import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination -import kotlin.reflect.KClass -import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR +import com.google.samples.apps.nowinandroid.feature.settings.api.R as settingsR @Composable fun NiaApp( appState: NiaAppState, + entryProviderBuilders: Set.() -> Unit>, modifier: Modifier = Modifier, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), ) { @@ -109,15 +109,16 @@ fun NiaApp( ) } } - - NiaApp( - appState = appState, - snackbarHostState = snackbarHostState, - showSettingsDialog = showSettingsDialog, - onSettingsDismissed = { showSettingsDialog = false }, - onTopAppBarActionClick = { showSettingsDialog = true }, - windowAdaptiveInfo = windowAdaptiveInfo, - ) + CompositionLocalProvider(LocalSnackbarHostState provides snackbarHostState) { + NiaApp( + appState = appState, + entryProviderBuilders = entryProviderBuilders, + showSettingsDialog = showSettingsDialog, + onSettingsDismissed = { showSettingsDialog = false }, + onTopAppBarActionClick = { showSettingsDialog = true }, + windowAdaptiveInfo = windowAdaptiveInfo, + ) + } } } } @@ -129,7 +130,7 @@ fun NiaApp( ) internal fun NiaApp( appState: NiaAppState, - snackbarHostState: SnackbarHostState, + entryProviderBuilders: Set.() -> Unit>, showSettingsDialog: Boolean, onSettingsDismissed: () -> Unit, onTopAppBarActionClick: () -> Unit, @@ -138,7 +139,7 @@ internal fun NiaApp( ) { val unreadDestinations by appState.topLevelDestinationsWithUnreadResources .collectAsStateWithLifecycle() - val currentDestination = appState.currentDestination + val currentTopLevelKey = appState.currentTopLevelDestination!!.key if (showSettingsDialog) { SettingsDialog( @@ -146,15 +147,16 @@ internal fun NiaApp( ) } + val snackbarHostState = LocalSnackbarHostState.current + NiaNavigationSuiteScaffold( navigationSuiteItems = { appState.topLevelDestinations.forEach { destination -> val hasUnread = unreadDestinations.contains(destination) - val selected = currentDestination - .isRouteInHierarchy(destination.baseRoute) + val selected = destination.key == currentTopLevelKey item( selected = selected, - onClick = { appState.navigateToTopLevelDestination(destination) }, + onClick = { appState.niaBackStack.navigate(destination.key) }, icon = { Icon( imageVector = destination.unselectedIcon, @@ -225,7 +227,7 @@ internal fun NiaApp( containerColor = Color.Transparent, ), onActionClick = { onTopAppBarActionClick() }, - onNavigationClick = { appState.navigateToSearch() }, + onNavigationClick = { appState.niaBackStack.navigateToSearch() }, ) } @@ -239,15 +241,9 @@ internal fun NiaApp( }, ), ) { - NiaNavHost( - appState = appState, - onShowSnackbar = { message, action -> - snackbarHostState.showSnackbar( - message = message, - actionLabel = action, - duration = Short, - ) == ActionPerformed - }, + NiaNavDisplay( + niaBackStack = appState.niaBackStack, + entryProviderBuilders, ) } @@ -276,8 +272,3 @@ private fun Modifier.notificationDot(): Modifier = ) } } - -private fun NavDestination?.isRouteInHierarchy(route: KClass<*>) = - this?.hierarchy?.any { - it.hasRoute(route) - } ?: false diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index 7c892c854e..7e3d8eb68c 100644 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -18,30 +18,18 @@ package com.google.samples.apps.nowinandroid.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.navigation.NavController -import androidx.navigation.NavDestination -import androidx.navigation.NavDestination.Companion.hasRoute -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.NavHostController -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navOptions -import androidx.tracing.trace +import androidx.compose.runtime.snapshotFlow import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank -import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.navigateToBookmarks -import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou -import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterests -import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU -import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS +import com.google.samples.apps.nowinandroid.navigation.TopLevelDestinations import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -55,19 +43,19 @@ fun rememberNiaAppState( networkMonitor: NetworkMonitor, userNewsResourceRepository: UserNewsResourceRepository, timeZoneMonitor: TimeZoneMonitor, + niaBackStack: NiaBackStack, coroutineScope: CoroutineScope = rememberCoroutineScope(), - navController: NavHostController = rememberNavController(), ): NiaAppState { - NavigationTrackingSideEffect(navController) + NavigationTrackingSideEffect(niaBackStack) return remember( - navController, + niaBackStack, coroutineScope, networkMonitor, userNewsResourceRepository, timeZoneMonitor, ) { NiaAppState( - navController = navController, + niaBackStack = niaBackStack, coroutineScope = coroutineScope, networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, @@ -78,34 +66,14 @@ fun rememberNiaAppState( @Stable class NiaAppState( - val navController: NavHostController, + val niaBackStack: NiaBackStack, coroutineScope: CoroutineScope, networkMonitor: NetworkMonitor, userNewsResourceRepository: UserNewsResourceRepository, timeZoneMonitor: TimeZoneMonitor, ) { - private val previousDestination = mutableStateOf(null) - - val currentDestination: NavDestination? - @Composable get() { - // Collect the currentBackStackEntryFlow as a state - val currentEntry = navController.currentBackStackEntryFlow - .collectAsState(initial = null) - - // Fallback to previousDestination if currentEntry is null - return currentEntry.value?.destination.also { destination -> - if (destination != null) { - previousDestination.value = destination - } - } ?: previousDestination.value - } - val currentTopLevelDestination: TopLevelDestination? - @Composable get() { - return TopLevelDestination.entries.firstOrNull { topLevelDestination -> - currentDestination?.hasRoute(route = topLevelDestination.route) == true - } - } + @Composable get() = TopLevelDestinations[niaBackStack.currentTopLevelKey] val isOffline = networkMonitor.isOnline .map(Boolean::not) @@ -144,55 +112,18 @@ class NiaAppState( SharingStarted.WhileSubscribed(5_000), TimeZone.currentSystemDefault(), ) - - /** - * UI logic for navigating to a top level destination in the app. Top level destinations have - * only one copy of the destination of the back stack, and save and restore state whenever you - * navigate to and from it. - * - * @param topLevelDestination: The destination the app needs to navigate to. - */ - fun navigateToTopLevelDestination(topLevelDestination: TopLevelDestination) { - trace("Navigation: ${topLevelDestination.name}") { - val topLevelNavOptions = navOptions { - // Pop up to the start destination of the graph to - // avoid building up a large stack of destinations - // on the back stack as users select items - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - // Avoid multiple copies of the same destination when - // reselecting the same item - launchSingleTop = true - // Restore state when reselecting a previously selected item - restoreState = true - } - - when (topLevelDestination) { - FOR_YOU -> navController.navigateToForYou(topLevelNavOptions) - BOOKMARKS -> navController.navigateToBookmarks(topLevelNavOptions) - INTERESTS -> navController.navigateToInterests(null, topLevelNavOptions) - } - } - } - - fun navigateToSearch() = navController.navigateToSearch() } /** * Stores information about navigation events to be used with JankStats */ @Composable -private fun NavigationTrackingSideEffect(navController: NavHostController) { - TrackDisposableJank(navController) { metricsHolder -> - val listener = NavController.OnDestinationChangedListener { _, destination, _ -> - metricsHolder.state?.putState("Navigation", destination.route.toString()) - } - - navController.addOnDestinationChangedListener(listener) - - onDispose { - navController.removeOnDestinationChangedListener(listener) +private fun NavigationTrackingSideEffect(niaBackStack: NiaBackStack) { + TrackDisposableJank(niaBackStack) { metricsHolder -> + snapshotFlow { + val stack = niaBackStack.backStack.toList() + metricsHolder.state?.putState("Navigation", stack.lastOrNull().toString()) } + onDispose { } } } diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt deleted file mode 100644 index 3d37f34179..0000000000 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/Interests2PaneViewModel.kt +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.ui.interests2pane - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.navigation.toRoute -import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.StateFlow -import javax.inject.Inject - -const val TOPIC_ID_KEY = "selectedTopicId" - -@HiltViewModel -class Interests2PaneViewModel @Inject constructor( - private val savedStateHandle: SavedStateHandle, -) : ViewModel() { - - val route = savedStateHandle.toRoute() - val selectedTopicId: StateFlow = savedStateHandle.getStateFlow( - key = TOPIC_ID_KEY, - initialValue = route.initialTopicId, - ) - - fun onTopicClick(topicId: String?) { - savedStateHandle[TOPIC_ID_KEY] = topicId - } -} diff --git a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt b/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt deleted file mode 100644 index c0f425c65a..0000000000 --- a/app/src/main/kotlin/com/google/samples/apps/nowinandroid/ui/interests2pane/InterestsListDetailScreen.kt +++ /dev/null @@ -1,244 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.ui.interests2pane - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedContent -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.material3.LocalMinimumInteractiveComponentSize -import androidx.compose.material3.VerticalDragHandle -import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.WindowAdaptiveInfo -import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo -import androidx.compose.material3.adaptive.layout.AnimatedPane -import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole -import androidx.compose.material3.adaptive.layout.PaneAdaptedValue -import androidx.compose.material3.adaptive.layout.PaneExpansionAnchor -import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldDestinationItem -import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective -import androidx.compose.material3.adaptive.layout.defaultDragHandleSemantics -import androidx.compose.material3.adaptive.layout.rememberPaneExpansionState -import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior -import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold -import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator -import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldPredictiveBackHandler -import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.layout.layout -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavGraphBuilder -import androidx.navigation.compose.composable -import com.google.samples.apps.nowinandroid.feature.interests.InterestsRoute -import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute -import com.google.samples.apps.nowinandroid.feature.topic.TopicDetailPlaceholder -import com.google.samples.apps.nowinandroid.feature.topic.TopicScreen -import com.google.samples.apps.nowinandroid.feature.topic.TopicViewModel -import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicRoute -import kotlinx.coroutines.launch -import kotlinx.serialization.Serializable -import kotlin.math.max - -@Serializable internal object TopicPlaceholderRoute - -fun NavGraphBuilder.interestsListDetailScreen() { - composable { - InterestsListDetailScreen() - } -} - -@Composable -internal fun InterestsListDetailScreen( - viewModel: Interests2PaneViewModel = hiltViewModel(), - windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), -) { - val selectedTopicId by viewModel.selectedTopicId.collectAsStateWithLifecycle() - InterestsListDetailScreen( - selectedTopicId = selectedTopicId, - onTopicClick = viewModel::onTopicClick, - windowAdaptiveInfo = windowAdaptiveInfo, - ) -} - -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -@Composable -internal fun InterestsListDetailScreen( - selectedTopicId: String?, - onTopicClick: (String) -> Unit, - windowAdaptiveInfo: WindowAdaptiveInfo, -) { - val listDetailNavigator = rememberListDetailPaneScaffoldNavigator( - scaffoldDirective = calculatePaneScaffoldDirective(windowAdaptiveInfo), - initialDestinationHistory = listOfNotNull( - ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.List), - ThreePaneScaffoldDestinationItem(ListDetailPaneScaffoldRole.Detail).takeIf { - selectedTopicId != null - }, - ), - ) - val coroutineScope = rememberCoroutineScope() - - val paneExpansionState = rememberPaneExpansionState( - anchors = listOf( - PaneExpansionAnchor.Proportion(0f), - PaneExpansionAnchor.Proportion(0.5f), - PaneExpansionAnchor.Proportion(1f), - ), - ) - - ThreePaneScaffoldPredictiveBackHandler( - listDetailNavigator, - BackNavigationBehavior.PopUntilScaffoldValueChange, - ) - BackHandler( - paneExpansionState.currentAnchor == PaneExpansionAnchor.Proportion(0f) && - listDetailNavigator.isListPaneVisible() && - listDetailNavigator.isDetailPaneVisible(), - ) { - coroutineScope.launch { - paneExpansionState.animateTo(PaneExpansionAnchor.Proportion(1f)) - } - } - - var topicRoute by remember { - val route = selectedTopicId?.let { TopicRoute(id = it) } ?: TopicPlaceholderRoute - mutableStateOf(route) - } - - fun onTopicClickShowDetailPane(topicId: String) { - onTopicClick(topicId) - topicRoute = TopicRoute(id = topicId) - coroutineScope.launch { - listDetailNavigator.navigateTo(ListDetailPaneScaffoldRole.Detail) - } - if (paneExpansionState.currentAnchor == PaneExpansionAnchor.Proportion(1f)) { - coroutineScope.launch { - paneExpansionState.animateTo(PaneExpansionAnchor.Proportion(0f)) - } - } - } - - val mutableInteractionSource = remember { MutableInteractionSource() } - val minPaneWidth = 300.dp - - NavigableListDetailPaneScaffold( - navigator = listDetailNavigator, - listPane = { - AnimatedPane { - Box( - modifier = Modifier.clipToBounds() - .layout { measurable, constraints -> - val width = max(minPaneWidth.roundToPx(), constraints.maxWidth) - val placeable = measurable.measure( - constraints.copy( - minWidth = minPaneWidth.roundToPx(), - maxWidth = width, - ), - ) - layout(constraints.maxWidth, placeable.height) { - placeable.placeRelative( - x = 0, - y = 0, - ) - } - }, - ) { - InterestsRoute( - onTopicClick = ::onTopicClickShowDetailPane, - shouldHighlightSelectedTopic = listDetailNavigator.isDetailPaneVisible(), - ) - } - } - }, - detailPane = { - AnimatedPane { - Box( - modifier = Modifier.clipToBounds() - .layout { measurable, constraints -> - val width = max(minPaneWidth.roundToPx(), constraints.maxWidth) - val placeable = measurable.measure( - constraints.copy( - minWidth = minPaneWidth.roundToPx(), - maxWidth = width, - ), - ) - layout(constraints.maxWidth, placeable.height) { - placeable.placeRelative( - x = constraints.maxWidth - - max(constraints.maxWidth, placeable.width), - y = 0, - ) - } - }, - ) { - AnimatedContent(topicRoute) { route -> - when (route) { - is TopicRoute -> { - TopicScreen( - showBackButton = !listDetailNavigator.isListPaneVisible(), - onBackClick = { - coroutineScope.launch { - listDetailNavigator.navigateBack() - } - }, - onTopicClick = ::onTopicClickShowDetailPane, - viewModel = hiltViewModel( - key = route.id, - ) { factory -> - factory.create(route.id) - }, - ) - } - is TopicPlaceholderRoute -> { - TopicDetailPlaceholder() - } - } - } - } - } - }, - paneExpansionState = paneExpansionState, - paneExpansionDragHandle = { - VerticalDragHandle( - modifier = Modifier.paneExpansionDraggable( - state = paneExpansionState, - minTouchTargetSize = LocalMinimumInteractiveComponentSize.current, - interactionSource = mutableInteractionSource, - semanticsProperties = paneExpansionState.defaultDragHandleSemantics(), - ), - interactionSource = mutableInteractionSource, - ) - }, - ) -} - -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -private fun ThreePaneScaffoldNavigator.isListPaneVisible(): Boolean = - scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded - -@OptIn(ExperimentalMaterial3AdaptiveApi::class) -private fun ThreePaneScaffoldNavigator.isDetailPaneVisible(): Boolean = - scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/InterestsListDetailScreenTest.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/InterestsListDetailScreenTest.kt deleted file mode 100644 index 1062c7e569..0000000000 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/InterestsListDetailScreenTest.kt +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Copyright 2024 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.ui - -import androidx.activity.compose.BackHandler -import androidx.annotation.StringRes -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotDisplayed -import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick -import androidx.test.espresso.Espresso -import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository -import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.core.model.data.Topic -import com.google.samples.apps.nowinandroid.ui.interests2pane.InterestsListDetailScreen -import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import dagger.hilt.android.testing.HiltTestApplication -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import javax.inject.Inject -import kotlin.properties.ReadOnlyProperty -import kotlin.test.assertTrue -import com.google.samples.apps.nowinandroid.feature.topic.R as FeatureTopicR - -private const val EXPANDED_WIDTH = "w1200dp-h840dp" -private const val COMPACT_WIDTH = "w412dp-h915dp" - -@HiltAndroidTest -@RunWith(RobolectricTestRunner::class) -@Config(application = HiltTestApplication::class) -class InterestsListDetailScreenTest { - - @get:Rule(order = 0) - val hiltRule = HiltAndroidRule(this) - - @get:Rule(order = 1) - val composeTestRule = createAndroidComposeRule() - - @Inject - lateinit var topicsRepository: TopicsRepository - - /** Convenience function for getting all topics during tests, */ - private fun getTopics(): List = runBlocking { - topicsRepository.getTopics().first().sortedBy { it.name } - } - - // The strings used for matching in these tests. - private val placeholderText by composeTestRule.stringResource(FeatureTopicR.string.feature_topic_select_an_interest) - private val listPaneTag = "interests:topics" - - private val Topic.testTag - get() = "topic:${this.id}" - - @Before - fun setup() { - hiltRule.inject() - } - - @Test - @Config(qualifiers = EXPANDED_WIDTH) - fun expandedWidth_initialState_showsTwoPanesWithPlaceholder() { - composeTestRule.apply { - setContent { - NiaTheme { - InterestsListDetailScreen() - } - } - - onNodeWithTag(listPaneTag).assertIsDisplayed() - onNodeWithText(placeholderText).assertIsDisplayed() - } - } - - @Test - @Config(qualifiers = COMPACT_WIDTH) - fun compactWidth_initialState_showsListPane() { - composeTestRule.apply { - setContent { - NiaTheme { - InterestsListDetailScreen() - } - } - - onNodeWithTag(listPaneTag).assertIsDisplayed() - onNodeWithText(placeholderText).assertIsNotDisplayed() - } - } - - @Test - @Config(qualifiers = EXPANDED_WIDTH) - fun expandedWidth_topicSelected_updatesDetailPane() { - composeTestRule.apply { - setContent { - NiaTheme { - InterestsListDetailScreen() - } - } - - val firstTopic = getTopics().first() - onNodeWithText(firstTopic.name).performClick() - - onNodeWithTag(listPaneTag).assertIsDisplayed() - onNodeWithText(placeholderText).assertIsNotDisplayed() - onNodeWithTag(firstTopic.testTag).assertIsDisplayed() - } - } - - @Test - @Config(qualifiers = COMPACT_WIDTH) - fun compactWidth_topicSelected_showsTopicDetailPane() { - composeTestRule.apply { - setContent { - NiaTheme { - InterestsListDetailScreen() - } - } - - val firstTopic = getTopics().first() - onNodeWithText(firstTopic.name).performClick() - - onNodeWithTag(listPaneTag).assertIsNotDisplayed() - onNodeWithText(placeholderText).assertIsNotDisplayed() - onNodeWithTag(firstTopic.testTag).assertIsDisplayed() - } - } - - @Test - @Config(qualifiers = EXPANDED_WIDTH) - fun expandedWidth_backPressFromTopicDetail_leavesInterests() { - var unhandledBackPress = false - composeTestRule.apply { - setContent { - NiaTheme { - // Back press should not be handled by the two pane layout, and thus - // "fall through" to this BackHandler. - BackHandler { - unhandledBackPress = true - } - InterestsListDetailScreen() - } - } - - val firstTopic = getTopics().first() - onNodeWithText(firstTopic.name).performClick() - - waitForIdle() - Espresso.pressBack() - - assertTrue(unhandledBackPress) - } - } - - @Test - @Config(qualifiers = COMPACT_WIDTH) - fun compactWidth_backPressFromTopicDetail_showsListPane() { - composeTestRule.apply { - setContent { - NiaTheme { - InterestsListDetailScreen() - } - } - - val firstTopic = getTopics().first() - onNodeWithText(firstTopic.name).performClick() - - waitForIdle() - Espresso.pressBack() - - onNodeWithTag(listPaneTag).assertIsDisplayed() - onNodeWithText(placeholderText).assertIsNotDisplayed() - onNodeWithTag(firstTopic.testTag).assertIsNotDisplayed() - } - } -} - -private fun AndroidComposeTestRule<*, *>.stringResource( - @StringRes resId: Int, -): ReadOnlyProperty = - ReadOnlyProperty { _, _ -> activity.getString(resId) } diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt index 9c9488fde4..e463fb439f 100644 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt @@ -125,9 +125,11 @@ class NiaAppScreenSizesScreenshotTests { networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, + niaBackStack = mockNiaBackStack(), ) NiaApp( fakeAppState, + entryProviderBuilders = MockEntryProvider, windowAdaptiveInfo = WindowAdaptiveInfo( windowSizeClass = WindowSizeClass.compute( width.value, diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt index c6ddb54fbe..2c67c1dc1f 100644 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt @@ -16,21 +16,15 @@ package com.google.samples.apps.nowinandroid.ui -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.test.junit4.createComposeRule -import androidx.navigation.NavHostController -import androidx.navigation.compose.ComposeNavigator -import androidx.navigation.compose.composable -import androidx.navigation.createGraph -import androidx.navigation.testing.TestNavHostController import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor import com.google.samples.apps.nowinandroid.core.testing.util.TestTimeZoneMonitor +import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksRoute +import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.HiltTestApplication import kotlinx.coroutines.flow.collect @@ -70,30 +64,29 @@ class NiaAppStateTest { @Test fun niaAppState_currentDestination() = runTest { - var currentDestination: String? = null - + val niaBackStack = mockNiaBackStack() composeTestRule.setContent { - val navController = rememberTestNavController() - state = remember(navController) { + state = remember(niaBackStack) { NiaAppState( - navController = navController, + niaBackStack = niaBackStack, coroutineScope = backgroundScope, networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, ) } + } - // Update currentDestination whenever it changes - currentDestination = state.currentDestination?.route + assertEquals(ForYouRoute, state.niaBackStack.currentTopLevelKey) + assertEquals(ForYouRoute, state.niaBackStack.currentKey) - // Navigate to destination b once - LaunchedEffect(Unit) { - navController.setCurrentDestination("b") - } - } + // Navigate to another destination once + niaBackStack.navigate(BookmarksRoute) + + composeTestRule.waitForIdle() - assertEquals("b", currentDestination) + assertEquals(BookmarksRoute, state.niaBackStack.currentTopLevelKey) + assertEquals(BookmarksRoute, state.niaBackStack.currentKey) } @Test @@ -103,6 +96,7 @@ class NiaAppStateTest { networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, + niaBackStack = mockNiaBackStack(), ) } @@ -116,11 +110,11 @@ class NiaAppStateTest { fun niaAppState_whenNetworkMonitorIsOffline_StateIsOffline() = runTest(UnconfinedTestDispatcher()) { composeTestRule.setContent { state = NiaAppState( - navController = NavHostController(LocalContext.current), coroutineScope = backgroundScope, networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, + niaBackStack = mockNiaBackStack(), ) } @@ -136,11 +130,11 @@ class NiaAppStateTest { fun niaAppState_differentTZ_withTimeZoneMonitorChange() = runTest(UnconfinedTestDispatcher()) { composeTestRule.setContent { state = NiaAppState( - navController = NavHostController(LocalContext.current), coroutineScope = backgroundScope, networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, + niaBackStack = mockNiaBackStack(), ) } val changedTz = TimeZone.of("Europe/Prague") @@ -152,18 +146,3 @@ class NiaAppStateTest { ) } } - -@Composable -private fun rememberTestNavController(): TestNavHostController { - val context = LocalContext.current - return remember { - TestNavHostController(context).apply { - navigatorProvider.addNavigator(ComposeNavigator()) - graph = createGraph(startDestination = "a") { - composable("a") { } - composable("b") { } - composable("c") { } - } - } - } -} diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt index 78f568e039..b20a8e5a67 100644 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarInsetsScreenshotTests.kt @@ -67,6 +67,7 @@ import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions +import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -147,9 +148,7 @@ class SnackbarInsetsScreenshotTests { @Test fun phone_noSnackbar() { - val snackbarHostState = SnackbarHostState() testSnackbarScreenshotWithSize( - snackbarHostState, 400.dp, 500.dp, "insets_snackbar_compact_medium_noSnackbar", @@ -159,13 +158,11 @@ class SnackbarInsetsScreenshotTests { @Test fun snackbarShown_phone() { - val snackbarHostState = SnackbarHostState() testSnackbarScreenshotWithSize( - snackbarHostState, 400.dp, 500.dp, "insets_snackbar_compact_medium", - ) { + ) { snackbarHostState -> snackbarHostState.showSnackbar( "This is a test snackbar message", actionLabel = "Action Label", @@ -176,13 +173,11 @@ class SnackbarInsetsScreenshotTests { @Test fun snackbarShown_foldable() { - val snackbarHostState = SnackbarHostState() testSnackbarScreenshotWithSize( - snackbarHostState, 600.dp, 600.dp, "insets_snackbar_medium_medium", - ) { + ) { snackbarHostState -> snackbarHostState.showSnackbar( "This is a test snackbar message", actionLabel = "Action Label", @@ -193,13 +188,11 @@ class SnackbarInsetsScreenshotTests { @Test fun snackbarShown_tablet() { - val snackbarHostState = SnackbarHostState() testSnackbarScreenshotWithSize( - snackbarHostState, 900.dp, 900.dp, "insets_snackbar_expanded_expanded", - ) { + ) { snackbarHostState -> snackbarHostState.showSnackbar( "This is a test snackbar message", actionLabel = "Action Label", @@ -209,17 +202,18 @@ class SnackbarInsetsScreenshotTests { } private fun testSnackbarScreenshotWithSize( - snackbarHostState: SnackbarHostState, width: Dp, height: Dp, screenshotName: String, - action: suspend () -> Unit, + action: suspend (snackbarHostState: SnackbarHostState) -> Unit, ) { lateinit var scope: CoroutineScope + val snackbarHostState = SnackbarHostState() composeTestRule.setContent { CompositionLocalProvider( // Replaces images with placeholders LocalInspectionMode provides true, + LocalSnackbarHostState provides snackbarHostState, ) { scope = rememberCoroutineScope() @@ -256,10 +250,11 @@ class SnackbarInsetsScreenshotTests { networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, + niaBackStack = mockNiaBackStack(), ) NiaApp( appState = appState, - snackbarHostState = snackbarHostState, + entryProviderBuilders = MockEntryProvider, showSettingsDialog = false, onSettingsDismissed = {}, onTopAppBarActionClick = {}, @@ -280,7 +275,7 @@ class SnackbarInsetsScreenshotTests { } scope.launch { - action() + action(snackbarHostState) } composeTestRule.onNodeWithTag("root") diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarScreenshotTests.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarScreenshotTests.kt index b9b1047c10..bc538d494f 100644 --- a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarScreenshotTests.kt +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/SnackbarScreenshotTests.kt @@ -40,6 +40,7 @@ import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions +import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation.LocalSnackbarHostState import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest @@ -120,9 +121,7 @@ class SnackbarScreenshotTests { @Test fun phone_noSnackbar() { - val snackbarHostState = SnackbarHostState() testSnackbarScreenshotWithSize( - snackbarHostState, 400.dp, 500.dp, "snackbar_compact_medium_noSnackbar", @@ -132,13 +131,11 @@ class SnackbarScreenshotTests { @Test fun snackbarShown_phone() { - val snackbarHostState = SnackbarHostState() testSnackbarScreenshotWithSize( - snackbarHostState, 400.dp, 500.dp, "snackbar_compact_medium", - ) { + ) { snackbarHostState -> snackbarHostState.showSnackbar( "This is a test snackbar message", actionLabel = "Action Label", @@ -149,13 +146,11 @@ class SnackbarScreenshotTests { @Test fun snackbarShown_foldable() { - val snackbarHostState = SnackbarHostState() testSnackbarScreenshotWithSize( - snackbarHostState, 600.dp, 600.dp, "snackbar_medium_medium", - ) { + ) { snackbarHostState -> snackbarHostState.showSnackbar( "This is a test snackbar message", actionLabel = "Action Label", @@ -166,13 +161,11 @@ class SnackbarScreenshotTests { @Test fun snackbarShown_tablet() { - val snackbarHostState = SnackbarHostState() testSnackbarScreenshotWithSize( - snackbarHostState, 900.dp, 900.dp, "snackbar_expanded_expanded", - ) { + ) { snackbarHostState -> snackbarHostState.showSnackbar( "This is a test snackbar message", actionLabel = "Action Label", @@ -182,17 +175,19 @@ class SnackbarScreenshotTests { } private fun testSnackbarScreenshotWithSize( - snackbarHostState: SnackbarHostState, width: Dp, height: Dp, screenshotName: String, - action: suspend () -> Unit, + action: suspend (snackbarHostState: SnackbarHostState) -> Unit, ) { lateinit var scope: CoroutineScope + val snackbarHostState = SnackbarHostState() composeTestRule.setContent { CompositionLocalProvider( // Replaces images with placeholders LocalInspectionMode provides true, + LocalSnackbarHostState provides snackbarHostState, + ) { scope = rememberCoroutineScope() @@ -205,10 +200,11 @@ class SnackbarScreenshotTests { networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, + niaBackStack = mockNiaBackStack(), ) NiaApp( appState = appState, - snackbarHostState = snackbarHostState, + entryProviderBuilders = MockEntryProvider, showSettingsDialog = false, onSettingsDismissed = {}, onTopAppBarActionClick = {}, @@ -227,7 +223,7 @@ class SnackbarScreenshotTests { } scope.launch { - action() + action(snackbarHostState) } composeTestRule.onRoot() diff --git a/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/TestUtil.kt b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/TestUtil.kt new file mode 100644 index 0000000000..a92b5a387d --- /dev/null +++ b/app/src/testDemo/kotlin/com/google/samples/apps/nowinandroid/ui/TestUtil.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.ui + +import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.navigation3.runtime.entry +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute +import com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen + +val MockEntryProvider: Set.() -> Unit> = + setOf( + { + entry { + ForYouScreen({}) + } + }, + ) + +private val startKey = ForYouRoute + +fun mockNiaBackStack() = NiaBackStack(startKey) diff --git a/app/src/testDemo/resources/robolectric.properties b/app/src/testDemo/resources/robolectric.properties new file mode 100644 index 0000000000..ca82be1534 --- /dev/null +++ b/app/src/testDemo/resources/robolectric.properties @@ -0,0 +1,17 @@ +# +# Copyright 2025 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +sdk = 35 \ No newline at end of file diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000000..4551a1e9c0 --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,3 @@ +# :benchmarks module +## Dependency graph +![Dependency graph](../docs/images/graphs/dep_graph_benchmarks.svg) diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 6d02370106..3044d476cb 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -78,9 +78,13 @@ gradlePlugin { id = libs.plugins.nowinandroid.android.library.asProvider().get().pluginId implementationClass = "AndroidLibraryConventionPlugin" } - register("androidFeature") { - id = libs.plugins.nowinandroid.android.feature.get().pluginId - implementationClass = "AndroidFeatureConventionPlugin" + register("androidFeatureImpl") { + id = libs.plugins.nowinandroid.android.feature.impl.get().pluginId + implementationClass = "AndroidFeatureImplConventionPlugin" + } + register("androidFeatureApi") { + id = libs.plugins.nowinandroid.android.feature.api.get().pluginId + implementationClass = "AndroidFeatureApiConventionPlugin" } register("androidLibraryJacoco") { id = libs.plugins.nowinandroid.android.library.jacoco.get().pluginId diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt index a8b1b17796..0a33b719fe 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt @@ -31,5 +31,4 @@ class AndroidApplicationComposeConventionPlugin : Plugin { configureAndroidCompose(extension) } } - } diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 1ab3a2ca08..3deb87ad7d 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -37,7 +37,7 @@ class AndroidApplicationConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) - defaultConfig.targetSdk = 35 + defaultConfig.targetSdk = 36 @Suppress("UnstableApiUsage") testOptions.animationsDisabled = true configureGradleManagedDevices(this) diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureApiConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureApiConventionPlugin.kt new file mode 100644 index 0000000000..969cf96d4d --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureApiConventionPlugin.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.google.samples.apps.nowinandroid.libs +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.dependencies + +class AndroidFeatureApiConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + apply(plugin = "nowinandroid.android.library") + apply(plugin = "org.jetbrains.kotlin.plugin.serialization") + + dependencies { + "api"(project(":core:navigation")) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureImplConventionPlugin.kt similarity index 85% rename from build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt rename to build-logic/convention/src/main/kotlin/AndroidFeatureImplConventionPlugin.kt index 1af5523c56..14513908a5 100644 --- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureImplConventionPlugin.kt @@ -23,12 +23,11 @@ import org.gradle.kotlin.dsl.apply import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies -class AndroidFeatureConventionPlugin : Plugin { +class AndroidFeatureImplConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { apply(plugin = "nowinandroid.android.library") apply(plugin = "nowinandroid.hilt") - apply(plugin = "org.jetbrains.kotlin.plugin.serialization") extensions.configure { testOptions.animationsDisabled = true @@ -39,14 +38,12 @@ class AndroidFeatureConventionPlugin : Plugin { "implementation"(project(":core:ui")) "implementation"(project(":core:designsystem")) - "implementation"(libs.findLibrary("androidx.hilt.navigation.compose").get()) "implementation"(libs.findLibrary("androidx.lifecycle.runtimeCompose").get()) "implementation"(libs.findLibrary("androidx.lifecycle.viewModelCompose").get()) - "implementation"(libs.findLibrary("androidx.navigation.compose").get()) + "implementation"(libs.findLibrary("androidx.hilt.lifecycle.viewModelCompose").get()) + "implementation"(libs.findLibrary("androidx.navigation3.runtime").get()) "implementation"(libs.findLibrary("androidx.tracing.ktx").get()) - "implementation"(libs.findLibrary("kotlinx.serialization.json").get()) - "testImplementation"(libs.findLibrary("androidx.navigation.testing").get()) "androidTestImplementation"( libs.findLibrary("androidx.lifecycle.runtimeTesting").get(), ) diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt index 19fabf5494..adac1ab52d 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt @@ -31,5 +31,4 @@ class AndroidLibraryComposeConventionPlugin : Plugin { configureAndroidCompose(extension) } } - } diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 3fe727410e..b677d1da26 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -37,7 +37,7 @@ class AndroidLibraryConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) - defaultConfig.targetSdk = 35 + defaultConfig.targetSdk = 36 defaultConfig.testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testOptions.animationsDisabled = true configureFlavors(this) diff --git a/build-logic/convention/src/main/kotlin/AndroidTestConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidTestConventionPlugin.kt index 67933f77d4..6e01b203ea 100644 --- a/build-logic/convention/src/main/kotlin/AndroidTestConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidTestConventionPlugin.kt @@ -30,7 +30,7 @@ class AndroidTestConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) - defaultConfig.targetSdk = 35 + defaultConfig.targetSdk = 36 configureGradleManagedDevices(this) } } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt index 3d050d86b6..709a711c26 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt @@ -19,7 +19,6 @@ package com.google.samples.apps.nowinandroid import com.android.build.api.dsl.CommonExtension import org.gradle.api.Project import org.gradle.api.provider.Provider -import org.gradle.kotlin.dsl.assign import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import org.jetbrains.kotlin.compose.compiler.gradle.ComposeCompilerGradlePluginExtension diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index 5d396d2a49..81f26e9dbe 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -35,10 +35,10 @@ internal fun Project.configureKotlinAndroid( commonExtension: CommonExtension<*, *, *, *, *, *>, ) { commonExtension.apply { - compileSdk = 35 + compileSdk = 36 defaultConfig { - minSdk = 21 + minSdk = 23 } compileOptions { diff --git a/core/designsystem/src/test/resources/robolectric.properties b/core/designsystem/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..ca82be1534 --- /dev/null +++ b/core/designsystem/src/test/resources/robolectric.properties @@ -0,0 +1,17 @@ +# +# Copyright 2025 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +sdk = 35 \ No newline at end of file diff --git a/feature/bookmarks/.gitignore b/core/navigation/.gitignore similarity index 100% rename from feature/bookmarks/.gitignore rename to core/navigation/.gitignore diff --git a/core/navigation/README.md b/core/navigation/README.md new file mode 100644 index 0000000000..7cd3b7e9f5 --- /dev/null +++ b/core/navigation/README.md @@ -0,0 +1,3 @@ +# :core:navigation module +## Dependency graph +![Dependency graph](../../docs/images/graphs/dep_graph_core_navigation.svg) diff --git a/core/navigation/build.gradle.kts b/core/navigation/build.gradle.kts new file mode 100644 index 0000000000..abc59d2394 --- /dev/null +++ b/core/navigation/build.gradle.kts @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.hilt) + alias(libs.plugins.hilt) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.compose) +} + +android { + namespace = "com.google.samples.apps.nowinandroid.core.navigation" +} + +dependencies { + api(libs.androidx.navigation3.runtime) + implementation(libs.androidx.savedstate.compose) + + testImplementation(libs.truth) + + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.test.ext) + androidTestImplementation(libs.androidx.compose.ui.testManifest) + androidTestImplementation(libs.androidx.lifecycle.viewModel.testing) + androidTestImplementation(libs.truth) +} diff --git a/core/navigation/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStackViewModelTest.kt b/core/navigation/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStackViewModelTest.kt new file mode 100644 index 0000000000..7007d1abf1 --- /dev/null +++ b/core/navigation/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStackViewModelTest.kt @@ -0,0 +1,233 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.navigation + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.testing.ViewModelScenario +import androidx.lifecycle.viewmodel.testing.viewModelScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlinx.serialization.Serializable +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NiaBackStackViewModelTest { + + @get:Rule val rule = createComposeRule() + + private val serializersModules = SerializersModule { + polymorphic(NiaNavKey::class) { + subclass(TestStartKey::class, TestStartKey.serializer()) + subclass(TestTopLevelKeyFirst::class, TestTopLevelKeyFirst.serializer()) + subclass(TestTopLevelKeySecond::class, TestTopLevelKeySecond.serializer()) + subclass(TestKeyFirst::class, TestKeyFirst.serializer()) + subclass(TestKeySecond::class, TestKeySecond.serializer()) + } + } + + private fun createViewModel() = NiaBackStackViewModel( + savedStateHandle = SavedStateHandle(), + niaBackStack = NiaBackStack(TestStartKey), + serializersModules = serializersModules, + ) + + @Test + fun testStartKeySaved() { + rule.setContent { + val viewModel = createViewModel() + assertThat(viewModel.backStackMap).containsEntry( + TestStartKey, + mutableListOf(TestStartKey), + ) + } + } + + @Test + fun testNonTopLevelKeySaved() { + val viewModel = createViewModel() + rule.setContent { + val backStack = viewModel.niaBackStack + + backStack.navigate(TestKeyFirst) + } + + assertThat(viewModel.backStackMap).containsEntry( + TestStartKey, + mutableListOf(TestStartKey, TestKeyFirst), + ) + } + + @Test + fun testTopLevelKeySaved() { + val viewModel = createViewModel() + rule.setContent { + val backStack = viewModel.niaBackStack + + backStack.navigate(TestKeyFirst) + backStack.navigate(TestTopLevelKeyFirst) + } + + assertThat(viewModel.backStackMap).containsExactly( + TestStartKey, + mutableListOf(TestStartKey, TestKeyFirst), + TestTopLevelKeyFirst, + mutableListOf(TestTopLevelKeyFirst), + ).inOrder() + } + + @Test + fun testMultiStacksSaved() { + val viewModel = createViewModel() + rule.setContent { + viewModel.niaBackStack.navigate(TestKeyFirst) + viewModel.niaBackStack.navigate(TestTopLevelKeyFirst) + viewModel.niaBackStack.navigate(TestKeySecond) + } + + assertThat(viewModel.backStackMap).containsExactly( + TestStartKey, + mutableListOf(TestStartKey, TestKeyFirst), + TestTopLevelKeyFirst, + mutableListOf(TestTopLevelKeyFirst, TestKeySecond), + ).inOrder() + } + + @Test + fun testPopSaved() { + val viewModel = createViewModel() + rule.setContent { + val backStack = viewModel.niaBackStack + + backStack.navigate(TestKeyFirst) + assertThat(viewModel.backStackMap).containsExactly( + TestStartKey, + mutableListOf(TestStartKey, TestKeyFirst), + ) + + backStack.popLast() + assertThat(viewModel.backStackMap).containsExactly( + TestStartKey, + mutableListOf(TestStartKey), + ) + } + } + + @Test + fun testRestore() { + lateinit var scenario: ViewModelScenario + rule.setContent { + scenario = viewModelScenario { + NiaBackStackViewModel( + savedStateHandle = createSavedStateHandle(), + niaBackStack = NiaBackStack(TestStartKey), + serializersModules = serializersModules, + ) + } + } + + rule.runOnIdle { + scenario.viewModel.niaBackStack.navigate(TestKeyFirst) + assertThat(scenario.viewModel.niaBackStack.backStack).containsExactly( + TestStartKey, + TestKeyFirst, + ).inOrder() + } + + scenario.recreate() + + rule.runOnIdle { + assertThat(scenario.viewModel.niaBackStack.backStack).containsExactly( + TestStartKey, + TestKeyFirst, + ).inOrder() + } + } + + @Test + fun testRestoreMultiStacks() { + lateinit var scenario: ViewModelScenario + rule.setContent { + scenario = viewModelScenario { + NiaBackStackViewModel( + savedStateHandle = createSavedStateHandle(), + niaBackStack = NiaBackStack(TestStartKey), + serializersModules = serializersModules, + ) + } + } + + rule.runOnIdle { + scenario.viewModel.niaBackStack.navigate(TestKeyFirst) + scenario.viewModel.niaBackStack.navigate(TestTopLevelKeyFirst) + scenario.viewModel.niaBackStack.navigate(TestKeySecond) + + assertThat(scenario.viewModel.niaBackStack.backStack).containsExactly( + TestStartKey, + TestKeyFirst, + TestTopLevelKeyFirst, + TestKeySecond, + ).inOrder() + } + + scenario.recreate() + + rule.runOnIdle { + assertThat(scenario.viewModel.niaBackStack.backStack).containsExactly( + TestStartKey, + TestKeyFirst, + TestTopLevelKeyFirst, + TestKeySecond, + ).inOrder() + } + } +} + +@Serializable +private object TestStartKey : NiaNavKey { + override val isTopLevel: Boolean + get() = true +} + +@Serializable +private object TestTopLevelKeyFirst : NiaNavKey { + override val isTopLevel: Boolean + get() = true +} + +@Serializable +private object TestTopLevelKeySecond : NiaNavKey { + override val isTopLevel: Boolean + get() = true +} + +@Serializable +private object TestKeyFirst : NiaNavKey { + override val isTopLevel: Boolean + get() = false +} + +@Serializable +private object TestKeySecond : NiaNavKey { + override val isTopLevel: Boolean + get() = false +} diff --git a/core/navigation/src/main/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStack.kt b/core/navigation/src/main/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStack.kt new file mode 100644 index 0000000000..82a7bb1c93 --- /dev/null +++ b/core/navigation/src/main/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStack.kt @@ -0,0 +1,127 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.navigation + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import org.jetbrains.annotations.VisibleForTesting +import kotlin.collections.mutableListOf + +// TODO refine back behavior - perhaps take a lambda so that each screen / use site can customize back behavior? +// https://github.com/android/nowinandroid/issues/1934 +class NiaBackStack( + private val startKey: NiaNavKey, +) { + internal var backStackMap: LinkedHashMap> = + linkedMapOf( + startKey to mutableListOf(startKey), + ) + + @VisibleForTesting + val backStack: SnapshotStateList = mutableStateListOf(startKey) + + var currentTopLevelKey: NiaNavKey by mutableStateOf(backStackMap.keys.last()) + private set + + @get:VisibleForTesting + val currentKey: NiaNavKey + get() = backStackMap[currentTopLevelKey]!!.last() + + fun navigate(key: NiaNavKey) { + when { + // top level singleTop -> clear substack + key == currentTopLevelKey -> backStackMap[key] = mutableListOf(key) + // top level non-singleTop + key.isTopLevel -> { + // if navigating back to start destination, pop all other top destinations and + // store start destination substack + if (key == startKey) { + val tempStack = mapOf(startKey to backStackMap[startKey]!!) + backStackMap.clear() + backStackMap.putAll(tempStack) + // else either restore an existing substack or initiate new one + } else { + backStackMap[key] = backStackMap.remove(key) ?: mutableListOf(key) + } + } + // not top level - add to current substack + else -> { + val currentStack = backStackMap.values.last() + // single top + if (currentStack.lastOrNull() == key) { + currentStack.removeLastOrNull() + } + currentStack.add(key) + } + } + updateBackStack() + } + + fun popLast(count: Int = 1) { + var popCount = count + var currentEntry = backStackMap.entries.last() + while (popCount > 0) { + val currentStack = currentEntry.value + if (currentStack.size == 1) { + // if current sub-stack only has one key, remove the sub-stack from the map + backStackMap.remove(currentEntry.key) + when { + // throw if map is empty after pop + backStackMap.isEmpty() -> error(popErrorMessage(count, currentEntry.key)) + // otherwise update currentEntry + else -> currentEntry = backStackMap.entries.last() + } + } else { + // if current sub-stack has more than one key, just pop the last key off the sub-stack + currentStack.removeLastOrNull() + } + popCount-- + } + updateBackStack() + } + + private fun updateBackStack() { + backStack.apply { + clear() + backStack.addAll( + backStackMap.flatMap { it.value }, + ) + } + + currentTopLevelKey = backStackMap.keys.last() + } + + internal fun restore(map: LinkedHashMap>?) { + map ?: return + backStackMap.clear() + backStackMap.putAll(map) + updateBackStack() + } +} + +interface NiaNavKey { + val isTopLevel: Boolean +} + +private fun popErrorMessage(count: Int, lastPopped: NiaNavKey) = + """ + Failed to pop $count entries. BackStack has been popped to an empty stack. Last + popped key is $lastPopped. + """.trimIndent() diff --git a/core/navigation/src/main/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStackViewModel.kt b/core/navigation/src/main/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStackViewModel.kt new file mode 100644 index 0000000000..097b799679 --- /dev/null +++ b/core/navigation/src/main/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStackViewModel.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.navigation + +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.snapshotFlow +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.serialization.saved +import androidx.lifecycle.viewModelScope +import androidx.savedstate.serialization.SavedStateConfiguration +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.serializer +import javax.inject.Inject + +@HiltViewModel +class NiaBackStackViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + val niaBackStack: NiaBackStack, + serializersModules: SerializersModule, +) : ViewModel() { + + private val config = SavedStateConfiguration { serializersModule = serializersModules } + + @VisibleForTesting + internal var backStackMap by savedStateHandle.saved( + serializer = getMapSerializer(), + configuration = config, + ) { + linkedMapOf() + } + + init { + if (backStackMap.isNotEmpty()) { + // Restore backstack from saved state handle if not emtpy + @Suppress("UNCHECKED_CAST") + niaBackStack.restore( + backStackMap as LinkedHashMap>, + ) + } + + // Start observing changes to the backStack and save backStack whenever it updates + viewModelScope.launch { + snapshotFlow { + niaBackStack.backStack.toList() + backStackMap = niaBackStack.backStackMap + }.collect() + } + } +} + +private inline fun getMapSerializer() = MapSerializer(serializer(), serializer>()) diff --git a/core/navigation/src/test/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStackTest.kt b/core/navigation/src/test/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStackTest.kt new file mode 100644 index 0000000000..2a87024f92 --- /dev/null +++ b/core/navigation/src/test/kotlin/com/google/samples/apps/nowinandroid/core/navigation/NiaBackStackTest.kt @@ -0,0 +1,280 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.navigation + +import com.google.common.truth.Truth.assertThat +import org.junit.Before +import org.junit.Test +import kotlin.test.assertFailsWith + +class NiaBackStackTest { + + private lateinit var niaBackStack: NiaBackStack + + @Before + fun setup() { + niaBackStack = NiaBackStack(TestStartKey) + } + + @Test + fun testStartKey() { + assertThat(niaBackStack.currentKey).isEqualTo(TestStartKey) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey) + } + + @Test + fun testNavigate() { + niaBackStack.navigate(TestKeyFirst) + + assertThat(niaBackStack.currentKey).isEqualTo(TestKeyFirst) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey) + } + + @Test + fun testNavigateTopLevel() { + niaBackStack.navigate(TestTopLevelKey) + + assertThat(niaBackStack.currentKey).isEqualTo(TestTopLevelKey) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestTopLevelKey) + } + + @Test + fun testNavigateSingleTop() { + niaBackStack.navigate(TestKeyFirst) + + assertThat(niaBackStack.backStack).containsExactly( + TestStartKey, + TestKeyFirst, + ).inOrder() + + niaBackStack.navigate(TestKeyFirst) + + assertThat(niaBackStack.backStack).containsExactly( + TestStartKey, + TestKeyFirst, + ).inOrder() + } + + @Test + fun testNavigateTopLevelSingleTop() { + niaBackStack.navigate(TestTopLevelKey) + niaBackStack.navigate(TestKeyFirst) + + assertThat(niaBackStack.backStack).containsExactly( + TestStartKey, + TestTopLevelKey, + TestKeyFirst, + ).inOrder() + + niaBackStack.navigate(TestTopLevelKey) + + assertThat(niaBackStack.backStack).containsExactly( + TestStartKey, + TestTopLevelKey, + ).inOrder() + } + + @Test + fun testSubStack() { + niaBackStack.navigate(TestKeyFirst) + + assertThat(niaBackStack.currentKey).isEqualTo(TestKeyFirst) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey) + + niaBackStack.navigate(TestKeySecond) + + assertThat(niaBackStack.currentKey).isEqualTo(TestKeySecond) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey) + } + + @Test + fun testMultiStack() { + // add to start stack + niaBackStack.navigate(TestKeyFirst) + + assertThat(niaBackStack.currentKey).isEqualTo(TestKeyFirst) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey) + + // navigate to new top level + niaBackStack.navigate(TestTopLevelKey) + + assertThat(niaBackStack.currentKey).isEqualTo(TestTopLevelKey) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestTopLevelKey) + + // add to new stack + niaBackStack.navigate(TestKeySecond) + + assertThat(niaBackStack.currentKey).isEqualTo(TestKeySecond) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestTopLevelKey) + + // go back to start stack + niaBackStack.navigate(TestStartKey) + + assertThat(niaBackStack.currentKey).isEqualTo(TestKeyFirst) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey) + } + + @Test + fun testRestore() { + assertThat(niaBackStack.backStack).containsExactly(TestStartKey) + + niaBackStack.restore( + linkedMapOf( + TestStartKey to mutableListOf(TestStartKey, TestKeyFirst), + TestTopLevelKey to mutableListOf(TestTopLevelKey, TestKeySecond), + ), + ) + + assertThat(niaBackStack.backStack).containsExactly( + TestStartKey, + TestKeyFirst, + TestTopLevelKey, + TestKeySecond, + ).inOrder() + + assertThat(niaBackStack.currentKey).isEqualTo(TestKeySecond) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestTopLevelKey) + } + + @Test + fun testPopOneNonTopLevel() { + niaBackStack.navigate(TestKeyFirst) + niaBackStack.navigate(TestKeySecond) + + assertThat(niaBackStack.backStack).containsExactly( + TestStartKey, + TestKeyFirst, + TestKeySecond, + ).inOrder() + + niaBackStack.popLast() + + assertThat(niaBackStack.backStack).containsExactly( + TestStartKey, + TestKeyFirst, + ).inOrder() + + assertThat(niaBackStack.currentKey).isEqualTo(TestKeyFirst) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey) + } + + @Test + fun testPopOneTopLevel() { + niaBackStack.navigate(TestKeyFirst) + niaBackStack.navigate(TestTopLevelKey) + + assertThat(niaBackStack.backStack).containsExactly( + TestStartKey, + TestKeyFirst, + TestTopLevelKey, + ).inOrder() + + assertThat(niaBackStack.currentKey).isEqualTo(TestTopLevelKey) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestTopLevelKey) + + // remove TopLevel + niaBackStack.popLast() + + assertThat(niaBackStack.backStack).containsExactly( + TestStartKey, + TestKeyFirst, + ).inOrder() + + assertThat(niaBackStack.currentKey).isEqualTo(TestKeyFirst) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey) + } + + @Test + fun popMultipleNonTopLevel() { + niaBackStack.navigate(TestKeyFirst) + niaBackStack.navigate(TestKeySecond) + + assertThat(niaBackStack.backStack).containsExactly( + TestStartKey, + TestKeyFirst, + TestKeySecond, + ).inOrder() + + niaBackStack.popLast(2) + + assertThat(niaBackStack.backStack).containsExactly( + TestStartKey, + ).inOrder() + + assertThat(niaBackStack.currentKey).isEqualTo(TestStartKey) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey) + } + + @Test + fun popMultipleTopLevel() { + val testTopLevelKeyTwo = object : NiaNavKey { + override val isTopLevel: Boolean + get() = true + } + + // second sub-stack + niaBackStack.navigate(TestTopLevelKey) + niaBackStack.navigate(TestKeyFirst) + // third sub-stack + niaBackStack.navigate(testTopLevelKeyTwo) + niaBackStack.navigate(TestKeySecond) + + assertThat(niaBackStack.backStack).containsExactly( + TestStartKey, + TestTopLevelKey, + TestKeyFirst, + testTopLevelKeyTwo, + TestKeySecond, + ).inOrder() + + niaBackStack.popLast(4) + + assertThat(niaBackStack.backStack).containsExactly( + TestStartKey, + ).inOrder() + + assertThat(niaBackStack.currentKey).isEqualTo(TestStartKey) + assertThat(niaBackStack.currentTopLevelKey).isEqualTo(TestStartKey) + } + + @Test + fun throwOnEmptyBackStack() { + assertFailsWith { + niaBackStack.popLast(1) + } + } +} + +private object TestStartKey : NiaNavKey { + override val isTopLevel: Boolean + get() = true +} + +private object TestTopLevelKey : NiaNavKey { + override val isTopLevel: Boolean + get() = true +} + +private object TestKeyFirst : NiaNavKey { + override val isTopLevel: Boolean + get() = false +} + +private object TestKeySecond : NiaNavKey { + override val isTopLevel: Boolean + get() = false +} diff --git a/docs/images/graphs/dep_graph_app.svg b/docs/images/graphs/dep_graph_app.svg index 8e5d9d4296..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_app.svg +++ b/docs/images/graphs/dep_graph_app.svg @@ -1,305 +0,0 @@ - - - - - - :app - - - - :feature:interests - - - - - - - - :feature:foryou - - - - - - - - :feature:bookmarks - - - - - - - - :feature:topic - - - - - - - - :feature:search - - - - - - - - :feature:settings - - - - - - - - :core:common - - - - - - - - :core:ui - - - - - - - - :core:designsystem - - - - - - - - :core:data - - - - - - - - :core:model - - - - - - - - :core:analytics - - - - - - - - :sync:work - - - - - - - - - - - - - - - - - - - - :core:domain - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - :core:database - - - - - - - - :core:datastore - - - - - - - - :core:network - - - - - - - - :core:notifications - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - :core:datastore-proto - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/images/graphs/dep_graph_app_nia_catalog.svg b/docs/images/graphs/dep_graph_app_nia_catalog.svg index 151ee63ade..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_app_nia_catalog.svg +++ b/docs/images/graphs/dep_graph_app_nia_catalog.svg @@ -1,45 +0,0 @@ - - - - - - :app-nia-catalog - - - - :core:designsystem - - - - - - - - :core:ui - - - - - - - - - - - - :core:analytics - - - - - - - - :core:model - - - - - - - diff --git a/docs/images/graphs/dep_graph_core_analytics.svg b/docs/images/graphs/dep_graph_core_analytics.svg index 45f1c1eb04..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_analytics.svg +++ b/docs/images/graphs/dep_graph_core_analytics.svg @@ -1,9 +0,0 @@ - - - - - - :core:analytics - - - diff --git a/docs/images/graphs/dep_graph_core_common.svg b/docs/images/graphs/dep_graph_core_common.svg index 91033eaa09..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_common.svg +++ b/docs/images/graphs/dep_graph_core_common.svg @@ -1,9 +0,0 @@ - - - - - - :core:common - - - diff --git a/docs/images/graphs/dep_graph_core_data.svg b/docs/images/graphs/dep_graph_core_data.svg index ab91bafb20..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_data.svg +++ b/docs/images/graphs/dep_graph_core_data.svg @@ -1,97 +0,0 @@ - - - - - - :core:data - - - - :core:common - - - - - - - - :core:database - - - - - - - - :core:datastore - - - - - - - - :core:network - - - - - - - - :core:analytics - - - - - - - - :core:notifications - - - - - - - - :core:model - - - - - - - - - - - - - - - - :core:datastore-proto - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/images/graphs/dep_graph_core_data_test.svg b/docs/images/graphs/dep_graph_core_data_test.svg index b9736c859d..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_data_test.svg +++ b/docs/images/graphs/dep_graph_core_data_test.svg @@ -1,105 +0,0 @@ - - - - - - :core:data-test - - - - :core:data - - - - - - - - :core:common - - - - - - - - :core:database - - - - - - - - :core:datastore - - - - - - - - :core:network - - - - - - - - :core:analytics - - - - - - - - :core:notifications - - - - - - - - :core:model - - - - - - - - - - - - - - - - :core:datastore-proto - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/images/graphs/dep_graph_core_database.svg b/docs/images/graphs/dep_graph_core_database.svg index e82d464365..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_database.svg +++ b/docs/images/graphs/dep_graph_core_database.svg @@ -1,17 +0,0 @@ - - - - - - :core:database - - - - :core:model - - - - - - - diff --git a/docs/images/graphs/dep_graph_core_datastore.svg b/docs/images/graphs/dep_graph_core_datastore.svg index f7502e55b5..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_datastore.svg +++ b/docs/images/graphs/dep_graph_core_datastore.svg @@ -1,33 +0,0 @@ - - - - - - :core:datastore - - - - :core:datastore-proto - - - - - - - - :core:model - - - - - - - - :core:common - - - - - - - diff --git a/docs/images/graphs/dep_graph_core_datastore_proto.svg b/docs/images/graphs/dep_graph_core_datastore_proto.svg index 7fcfb8358e..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_datastore_proto.svg +++ b/docs/images/graphs/dep_graph_core_datastore_proto.svg @@ -1,9 +0,0 @@ - - - - - - :core:datastore-proto - - - diff --git a/docs/images/graphs/dep_graph_core_datastore_test.svg b/docs/images/graphs/dep_graph_core_datastore_test.svg index 37521a05ff..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_datastore_test.svg +++ b/docs/images/graphs/dep_graph_core_datastore_test.svg @@ -1,45 +0,0 @@ - - - - - - :core:datastore-test - - - - :core:common - - - - - - - - :core:datastore - - - - - - - - - - - - :core:datastore-proto - - - - - - - - :core:model - - - - - - - diff --git a/docs/images/graphs/dep_graph_core_designsystem.svg b/docs/images/graphs/dep_graph_core_designsystem.svg index 7371408763..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_designsystem.svg +++ b/docs/images/graphs/dep_graph_core_designsystem.svg @@ -1,9 +0,0 @@ - - - - - - :core:designsystem - - - diff --git a/docs/images/graphs/dep_graph_core_domain.svg b/docs/images/graphs/dep_graph_core_domain.svg index fe3740d2fe..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_domain.svg +++ b/docs/images/graphs/dep_graph_core_domain.svg @@ -1,109 +0,0 @@ - - - - - - :core:domain - - - - :core:data - - - - - - - - :core:model - - - - - - - - :core:common - - - - - - - - :core:database - - - - - - - - :core:datastore - - - - - - - - :core:network - - - - - - - - :core:analytics - - - - - - - - :core:notifications - - - - - - - - - - - - - - - - - - - - :core:datastore-proto - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/images/graphs/dep_graph_core_model.svg b/docs/images/graphs/dep_graph_core_model.svg index 125684a08d..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_model.svg +++ b/docs/images/graphs/dep_graph_core_model.svg @@ -1,9 +0,0 @@ - - - - - - :core:model - - - diff --git a/docs/images/graphs/dep_graph_core_navigation.svg b/docs/images/graphs/dep_graph_core_navigation.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/images/graphs/dep_graph_core_network.svg b/docs/images/graphs/dep_graph_core_network.svg index 3022a86ee1..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_network.svg +++ b/docs/images/graphs/dep_graph_core_network.svg @@ -1,25 +0,0 @@ - - - - - - :core:network - - - - :core:common - - - - - - - - :core:model - - - - - - - diff --git a/docs/images/graphs/dep_graph_core_notifications.svg b/docs/images/graphs/dep_graph_core_notifications.svg index d96d287698..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_notifications.svg +++ b/docs/images/graphs/dep_graph_core_notifications.svg @@ -1,25 +0,0 @@ - - - - - - :core:notifications - - - - :core:model - - - - - - - - :core:common - - - - - - - diff --git a/docs/images/graphs/dep_graph_core_screenshot_testing.svg b/docs/images/graphs/dep_graph_core_screenshot_testing.svg index a7d58b0ea3..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_screenshot_testing.svg +++ b/docs/images/graphs/dep_graph_core_screenshot_testing.svg @@ -1,17 +0,0 @@ - - - - - - :core:screenshot-testing - - - - :core:designsystem - - - - - - - diff --git a/docs/images/graphs/dep_graph_core_testing.svg b/docs/images/graphs/dep_graph_core_testing.svg index d441858e5a..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_testing.svg +++ b/docs/images/graphs/dep_graph_core_testing.svg @@ -1,121 +0,0 @@ - - - - - - :core:testing - - - - :core:analytics - - - - - - - - :core:common - - - - - - - - :core:data - - - - - - - - :core:model - - - - - - - - :core:notifications - - - - - - - - - - - - - - - - - - - - :core:database - - - - - - - - :core:datastore - - - - - - - - :core:network - - - - - - - - - - - - - - - - - - - - - - - - - - - - :core:datastore-proto - - - - - - - - - - - - - - - diff --git a/docs/images/graphs/dep_graph_core_ui.svg b/docs/images/graphs/dep_graph_core_ui.svg index 2cd972357b..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_core_ui.svg +++ b/docs/images/graphs/dep_graph_core_ui.svg @@ -1,33 +0,0 @@ - - - - - - :core:ui - - - - :core:analytics - - - - - - - - :core:designsystem - - - - - - - - :core:model - - - - - - - diff --git a/docs/images/graphs/dep_graph_feature_bookmarks_api.svg b/docs/images/graphs/dep_graph_feature_bookmarks_api.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/images/graphs/dep_graph_feature_bookmarks_impl.svg b/docs/images/graphs/dep_graph_feature_bookmarks_impl.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/images/graphs/dep_graph_feature_foryou_api.svg b/docs/images/graphs/dep_graph_feature_foryou_api.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/images/graphs/dep_graph_feature_foryou_impl.svg b/docs/images/graphs/dep_graph_feature_foryou_impl.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/images/graphs/dep_graph_feature_interests_api.svg b/docs/images/graphs/dep_graph_feature_interests_api.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/images/graphs/dep_graph_feature_interests_impl.svg b/docs/images/graphs/dep_graph_feature_interests_impl.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/images/graphs/dep_graph_feature_search_api.svg b/docs/images/graphs/dep_graph_feature_search_api.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/images/graphs/dep_graph_feature_search_impl.svg b/docs/images/graphs/dep_graph_feature_search_impl.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/images/graphs/dep_graph_feature_settings_api.svg b/docs/images/graphs/dep_graph_feature_settings_api.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/images/graphs/dep_graph_feature_topic_api.svg b/docs/images/graphs/dep_graph_feature_topic_api.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/images/graphs/dep_graph_feature_topic_impl.svg b/docs/images/graphs/dep_graph_feature_topic_impl.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/images/graphs/dep_graph_lint.svg b/docs/images/graphs/dep_graph_lint.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/images/graphs/dep_graph_sync_sync_test.svg b/docs/images/graphs/dep_graph_sync_sync_test.svg index 7a083ba54e..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_sync_sync_test.svg +++ b/docs/images/graphs/dep_graph_sync_sync_test.svg @@ -1,121 +0,0 @@ - - - - - - :sync:sync-test - - - - :core:data - - - - - - - - :sync:work - - - - - - - - :core:common - - - - - - - - :core:database - - - - - - - - :core:datastore - - - - - - - - :core:network - - - - - - - - :core:analytics - - - - - - - - :core:notifications - - - - - - - - - - - - - - - - :core:model - - - - - - - - - - - - - - - - :core:datastore-proto - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/images/graphs/dep_graph_sync_work.svg b/docs/images/graphs/dep_graph_sync_work.svg index c649f23972..e69de29bb2 100644 --- a/docs/images/graphs/dep_graph_sync_work.svg +++ b/docs/images/graphs/dep_graph_sync_work.svg @@ -1,109 +0,0 @@ - - - - - - :sync:work - - - - :core:analytics - - - - - - - - :core:data - - - - - - - - - - - - :core:common - - - - - - - - :core:database - - - - - - - - :core:datastore - - - - - - - - :core:network - - - - - - - - :core:notifications - - - - - - - - :core:model - - - - - - - - - - - - - - - - :core:datastore-proto - - - - - - - - - - - - - - - - - - - - - - - diff --git a/docs/images/graphs/dep_graph_ui_test_hilt_manifest.svg b/docs/images/graphs/dep_graph_ui_test_hilt_manifest.svg new file mode 100644 index 0000000000..e69de29bb2 diff --git a/feature/bookmarks/README.md b/feature/bookmarks/README.md deleted file mode 100644 index 54cbf91d01..0000000000 --- a/feature/bookmarks/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# :feature:bookmarks module -## Dependency graph -![Dependency graph](../../docs/images/graphs/dep_graph_feature_bookmarks.svg) diff --git a/feature/foryou/.gitignore b/feature/bookmarks/api/.gitignore similarity index 100% rename from feature/foryou/.gitignore rename to feature/bookmarks/api/.gitignore diff --git a/feature/bookmarks/api/README.md b/feature/bookmarks/api/README.md new file mode 100644 index 0000000000..7ee6e7b860 --- /dev/null +++ b/feature/bookmarks/api/README.md @@ -0,0 +1,3 @@ +# :feature:bookmarks:api module +## Dependency graph +![Dependency graph](../../../docs/images/graphs/dep_graph_feature_bookmarks_api.svg) diff --git a/feature/bookmarks/api/build.gradle.kts b/feature/bookmarks/api/build.gradle.kts new file mode 100644 index 0000000000..a514686158 --- /dev/null +++ b/feature/bookmarks/api/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.nowinandroid.android.feature.api) +} + +android { + namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks.api" +} \ No newline at end of file diff --git a/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/navigation/BookmarksRoute.kt b/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/navigation/BookmarksRoute.kt new file mode 100644 index 0000000000..e3955176b4 --- /dev/null +++ b/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/navigation/BookmarksRoute.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation + +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import kotlinx.serialization.Serializable + +@Serializable +object BookmarksRoute : NiaNavKey { + override val isTopLevel: Boolean + get() = true +} diff --git a/feature/bookmarks/api/src/main/res/drawable/feature_bookmarks_api_mg_empty_bookmarks.xml b/feature/bookmarks/api/src/main/res/drawable/feature_bookmarks_api_mg_empty_bookmarks.xml new file mode 100644 index 0000000000..bc12d43250 --- /dev/null +++ b/feature/bookmarks/api/src/main/res/drawable/feature_bookmarks_api_mg_empty_bookmarks.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/feature/bookmarks/src/main/res/values/strings.xml b/feature/bookmarks/api/src/main/res/values/strings.xml similarity index 59% rename from feature/bookmarks/src/main/res/values/strings.xml rename to feature/bookmarks/api/src/main/res/values/strings.xml index 6e2b230431..98f4b4a8dc 100644 --- a/feature/bookmarks/src/main/res/values/strings.xml +++ b/feature/bookmarks/api/src/main/res/values/strings.xml @@ -15,10 +15,10 @@ limitations under the License. --> - Saved - Loading saved… - No saved updates - Updates you save will be stored here\nto read later - Bookmark removed - UNDO + Saved + Loading saved… + No saved updates + Updates you save will be stored here\nto read later + Bookmark removed + UNDO diff --git a/feature/interests/.gitignore b/feature/bookmarks/impl/.gitignore similarity index 100% rename from feature/interests/.gitignore rename to feature/bookmarks/impl/.gitignore diff --git a/feature/bookmarks/impl/README.md b/feature/bookmarks/impl/README.md new file mode 100644 index 0000000000..f7cc92060c --- /dev/null +++ b/feature/bookmarks/impl/README.md @@ -0,0 +1,3 @@ +# :feature:bookmarks:impl module +## Dependency graph +![Dependency graph](../../../docs/images/graphs/dep_graph_feature_bookmarks_impl.svg) diff --git a/feature/bookmarks/build.gradle.kts b/feature/bookmarks/impl/build.gradle.kts similarity index 85% rename from feature/bookmarks/build.gradle.kts rename to feature/bookmarks/impl/build.gradle.kts index 51a15ce7a0..e8162afffc 100644 --- a/feature/bookmarks/build.gradle.kts +++ b/feature/bookmarks/impl/build.gradle.kts @@ -15,17 +15,18 @@ */ plugins { - alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.feature.impl) alias(libs.plugins.nowinandroid.android.library.compose) - alias(libs.plugins.nowinandroid.android.library.jacoco) } android { - namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks" + namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks.impl" } dependencies { implementation(projects.core.data) + implementation(projects.feature.bookmarks.api) + implementation(projects.feature.topic.api) testImplementation(projects.core.testing) diff --git a/feature/bookmarks/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt b/feature/bookmarks/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreenTest.kt similarity index 96% rename from feature/bookmarks/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt rename to feature/bookmarks/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreenTest.kt index dd01b84c99..0b73a7918f 100644 --- a/feature/bookmarks/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt +++ b/feature/bookmarks/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreenTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.bookmarks +package com.google.samples.apps.nowinandroid.feature.bookmarks.impl import androidx.activity.ComponentActivity import androidx.compose.runtime.CompositionLocalProvider @@ -36,6 +36,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.testing.TestLifecycleOwner import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState +import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -64,7 +65,7 @@ class BookmarksScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.feature_bookmarks_loading), + composeTestRule.activity.resources.getString(R.string.feature_bookmarks_api_loading), ) .assertExists() } @@ -161,13 +162,13 @@ class BookmarksScreenTest { composeTestRule .onNodeWithText( - composeTestRule.activity.getString(R.string.feature_bookmarks_empty_error), + composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_error), ) .assertExists() composeTestRule .onNodeWithText( - composeTestRule.activity.getString(R.string.feature_bookmarks_empty_description), + composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_description), ) .assertExists() } diff --git a/feature/bookmarks/src/main/AndroidManifest.xml b/feature/bookmarks/impl/src/main/AndroidManifest.xml similarity index 100% rename from feature/bookmarks/src/main/AndroidManifest.xml rename to feature/bookmarks/impl/src/main/AndroidManifest.xml diff --git a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreen.kt similarity index 96% rename from feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt rename to feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreen.kt index 7c229c5ea5..65bc4acf2f 100644 --- a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreen.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.bookmarks +package com.google.samples.apps.nowinandroid.feature.bookmarks.impl import androidx.annotation.VisibleForTesting import androidx.compose.foundation.Image @@ -56,7 +56,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -74,9 +74,10 @@ import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.newsFeed +import com.google.samples.apps.nowinandroid.feature.bookmarks.api.R @Composable -internal fun BookmarksRoute( +internal fun BookmarksScreen( onTopicClick: (String) -> Unit, onShowSnackbar: suspend (String, String?) -> Boolean, modifier: Modifier = Modifier, @@ -112,8 +113,8 @@ internal fun BookmarksScreen( undoBookmarkRemoval: () -> Unit = {}, clearUndoState: () -> Unit = {}, ) { - val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_removed) - val undoText = stringResource(id = R.string.feature_bookmarks_undo) + val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_api_removed) + val undoText = stringResource(id = R.string.feature_bookmarks_api_undo) LaunchedEffect(shouldDisplayUndoBookmark) { if (shouldDisplayUndoBookmark) { @@ -155,7 +156,7 @@ private fun LoadingState(modifier: Modifier = Modifier) { .fillMaxWidth() .wrapContentSize() .testTag("forYou:loading"), - contentDesc = stringResource(id = R.string.feature_bookmarks_loading), + contentDesc = stringResource(id = R.string.feature_bookmarks_api_loading), ) } @@ -228,7 +229,7 @@ private fun EmptyState(modifier: Modifier = Modifier) { val iconTint = LocalTintTheme.current.iconTint Image( modifier = Modifier.fillMaxWidth(), - painter = painterResource(id = R.drawable.feature_bookmarks_img_empty_bookmarks), + painter = painterResource(id = R.drawable.feature_bookmarks_api_mg_empty_bookmarks), colorFilter = if (iconTint != Color.Unspecified) ColorFilter.tint(iconTint) else null, contentDescription = null, ) @@ -236,7 +237,7 @@ private fun EmptyState(modifier: Modifier = Modifier) { Spacer(modifier = Modifier.height(48.dp)) Text( - text = stringResource(id = R.string.feature_bookmarks_empty_error), + text = stringResource(id = R.string.feature_bookmarks_api_empty_error), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleMedium, @@ -246,7 +247,7 @@ private fun EmptyState(modifier: Modifier = Modifier) { Spacer(modifier = Modifier.height(8.dp)) Text( - text = stringResource(id = R.string.feature_bookmarks_empty_description), + text = stringResource(id = R.string.feature_bookmarks_api_empty_description), modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, diff --git a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModel.kt similarity index 97% rename from feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt rename to feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModel.kt index f936024855..f36c9d31fd 100644 --- a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt +++ b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModel.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.bookmarks +package com.google.samples.apps.nowinandroid.feature.bookmarks.impl import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf diff --git a/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/navigation/BookmarksEntryProvider.kt b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/navigation/BookmarksEntryProvider.kt new file mode 100644 index 0000000000..cff561bf49 --- /dev/null +++ b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/navigation/BookmarksEntryProvider.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation + +import androidx.compose.material3.SnackbarDuration.Short +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult.ActionPerformed +import androidx.compose.runtime.compositionLocalOf +import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.navigation3.runtime.entry +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksRoute +import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.BookmarksScreen +import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityComponent::class) +object BookmarksEntryProvider { + + @Provides + @IntoSet + fun provideBookmarksEntryProviderBuilder( + backStack: NiaBackStack, + ): EntryProviderBuilder.() -> Unit = { + entry { + val snackbarHostState = LocalSnackbarHostState.current + BookmarksScreen( + onTopicClick = backStack::navigateToTopic, + onShowSnackbar = { message, action -> + snackbarHostState.showSnackbar( + message = message, + actionLabel = action, + duration = Short, + ) == ActionPerformed + }, + ) + } + } +} + +val LocalSnackbarHostState = compositionLocalOf { + error("SnackbarHostState state should be initialized at runtime") +} diff --git a/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/navigation/BookmarksSerializerModule.kt b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/navigation/BookmarksSerializerModule.kt new file mode 100644 index 0000000000..b26bb646cf --- /dev/null +++ b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/navigation/BookmarksSerializerModule.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.bookmarks.impl.navigation + +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation.BookmarksRoute +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import kotlinx.serialization.modules.PolymorphicModuleBuilder + +/** + * Provides the DSL to register the route's [kotlinx.serialization.KSerializer] as a polymorphic serializer + * + */ +@Module +@InstallIn(SingletonComponent::class) +object BookmarksSerializerModule { + @Provides + @IntoSet + fun provideBookmarksPolymorphicModuleBuilder(): PolymorphicModuleBuilder<@JvmSuppressWildcards NiaNavKey>.() -> Unit = { + subclass(BookmarksRoute::class, BookmarksRoute.serializer()) + } +} diff --git a/feature/bookmarks/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt b/feature/bookmarks/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModelTest.kt similarity index 97% rename from feature/bookmarks/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt rename to feature/bookmarks/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModelTest.kt index aa42adae21..66ce0744f7 100644 --- a/feature/bookmarks/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt +++ b/feature/bookmarks/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksViewModelTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.bookmarks +package com.google.samples.apps.nowinandroid.feature.bookmarks.impl import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData @@ -23,6 +23,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success +import com.google.samples.apps.nowinandroid.feature.bookmarks.impl.BookmarksViewModel import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher diff --git a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt b/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt deleted file mode 100644 index ea8d525ab0..0000000000 --- a/feature/bookmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/navigation/BookmarksNavigation.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.feature.bookmarks.navigation - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptions -import androidx.navigation.compose.composable -import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute -import kotlinx.serialization.Serializable - -@Serializable object BookmarksRoute - -fun NavController.navigateToBookmarks(navOptions: NavOptions) = - navigate(route = BookmarksRoute, navOptions) - -fun NavGraphBuilder.bookmarksScreen( - onTopicClick: (String) -> Unit, - onShowSnackbar: suspend (String, String?) -> Boolean, -) { - composable { - BookmarksRoute(onTopicClick, onShowSnackbar) - } -} diff --git a/feature/bookmarks/src/main/res/drawable/feature_bookmarks_img_empty_bookmarks.xml b/feature/bookmarks/src/main/res/drawable/feature_bookmarks_img_empty_bookmarks.xml deleted file mode 100644 index 64bbfbd234..0000000000 --- a/feature/bookmarks/src/main/res/drawable/feature_bookmarks_img_empty_bookmarks.xml +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/feature/search/.gitignore b/feature/foryou/api/.gitignore similarity index 100% rename from feature/search/.gitignore rename to feature/foryou/api/.gitignore diff --git a/feature/foryou/api/README.md b/feature/foryou/api/README.md new file mode 100644 index 0000000000..2d3154ba2a --- /dev/null +++ b/feature/foryou/api/README.md @@ -0,0 +1,3 @@ +# :feature:foryou:api module +## Dependency graph +![Dependency graph](../../../docs/images/graphs/dep_graph_feature_foryou_api.svg) diff --git a/feature/foryou/api/build.gradle.kts b/feature/foryou/api/build.gradle.kts new file mode 100644 index 0000000000..07a7ba6633 --- /dev/null +++ b/feature/foryou/api/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.nowinandroid.android.feature.api) +} + +android { + namespace = "com.google.samples.apps.nowinandroid.feature.foryou.api" + testOptions.unitTests.isIncludeAndroidResources = true +} + +dependencies { + api(projects.core.navigation) +} diff --git a/feature/foryou/src/main/AndroidManifest.xml b/feature/foryou/api/src/main/AndroidManifest.xml similarity index 100% rename from feature/foryou/src/main/AndroidManifest.xml rename to feature/foryou/api/src/main/AndroidManifest.xml diff --git a/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/navigation/ForYouRoute.kt b/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/navigation/ForYouRoute.kt new file mode 100644 index 0000000000..2467649a4b --- /dev/null +++ b/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/navigation/ForYouRoute.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.foryou.api.navigation + +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import kotlinx.serialization.Serializable + +@Serializable +object ForYouRoute : NiaNavKey { // route to ForYou screen + override val isTopLevel: Boolean + get() = true +} diff --git a/feature/foryou/src/main/res/drawable/feature_foryou_ic_icon_placeholder.xml b/feature/foryou/api/src/main/res/drawable/feature_foryou_api_ic_icon_placeholder.xml similarity index 100% rename from feature/foryou/src/main/res/drawable/feature_foryou_ic_icon_placeholder.xml rename to feature/foryou/api/src/main/res/drawable/feature_foryou_api_ic_icon_placeholder.xml diff --git a/feature/foryou/src/main/res/values/strings.xml b/feature/foryou/api/src/main/res/values/strings.xml similarity index 56% rename from feature/foryou/src/main/res/values/strings.xml rename to feature/foryou/api/src/main/res/values/strings.xml index 1667496643..f0595944f4 100644 --- a/feature/foryou/src/main/res/values/strings.xml +++ b/feature/foryou/api/src/main/res/values/strings.xml @@ -15,11 +15,10 @@ limitations under the License. --> - For you - Done - Loading for you… - Navigate up - What are you interested in? - Updates from topics you follow will appear here. Follow some things to get started. - - + For you + Done + Loading for you… + Navigate up + What are you interested in? + Updates from topics you follow will appear here. Follow some things to get started. + \ No newline at end of file diff --git a/feature/settings/.gitignore b/feature/foryou/impl/.gitignore similarity index 100% rename from feature/settings/.gitignore rename to feature/foryou/impl/.gitignore diff --git a/feature/foryou/impl/README.md b/feature/foryou/impl/README.md new file mode 100644 index 0000000000..32d4e08450 --- /dev/null +++ b/feature/foryou/impl/README.md @@ -0,0 +1,3 @@ +# :feature:foryou:impl module +## Dependency graph +![Dependency graph](../../../docs/images/graphs/dep_graph_feature_foryou_impl.svg) diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/impl/build.gradle.kts similarity index 85% rename from feature/foryou/build.gradle.kts rename to feature/foryou/impl/build.gradle.kts index de1af7540a..5a91a19441 100644 --- a/feature/foryou/build.gradle.kts +++ b/feature/foryou/impl/build.gradle.kts @@ -15,22 +15,22 @@ */ plugins { - alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.feature.impl) alias(libs.plugins.nowinandroid.android.library.compose) - alias(libs.plugins.nowinandroid.android.library.jacoco) alias(libs.plugins.roborazzi) } android { - namespace = "com.google.samples.apps.nowinandroid.feature.foryou" - testOptions.unitTests.isIncludeAndroidResources = true + namespace = "com.google.samples.apps.nowinandroid.feature.foryou.impl" } dependencies { implementation(libs.accompanist.permissions) - implementation(projects.core.data) implementation(projects.core.domain) implementation(projects.core.notifications) + implementation(projects.feature.foryou.api) + implementation(projects.feature.topic.api) + implementation(libs.androidx.activity.compose) testImplementation(libs.hilt.android.testing) testImplementation(libs.robolectric) @@ -39,4 +39,4 @@ dependencies { androidTestImplementation(libs.bundles.androidx.compose.ui.test) androidTestImplementation(projects.core.testing) -} +} \ No newline at end of file diff --git a/feature/foryou/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt b/feature/foryou/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenTest.kt similarity index 95% rename from feature/foryou/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt rename to feature/foryou/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenTest.kt index c3ec5c5603..fc4fc02419 100644 --- a/feature/foryou/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt +++ b/feature/foryou/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.foryou +package com.google.samples.apps.nowinandroid.feature.foryou.impl import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box @@ -32,6 +32,7 @@ import com.google.samples.apps.nowinandroid.core.rules.GrantPostNotificationsPer import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState +import com.google.samples.apps.nowinandroid.feature.foryou.api.R import org.junit.Rule import org.junit.Test @@ -45,7 +46,7 @@ class ForYouScreenTest { private val doneButtonMatcher by lazy { hasText( - composeTestRule.activity.resources.getString(R.string.feature_foryou_done), + composeTestRule.activity.resources.getString(R.string.feature_foryou_api_done), ) } @@ -70,7 +71,7 @@ class ForYouScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), + composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading), ) .assertExists() } @@ -96,7 +97,7 @@ class ForYouScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), + composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading), ) .assertExists() } @@ -200,7 +201,9 @@ class ForYouScreenTest { ForYouScreen( isSyncing = false, onboardingUiState = - OnboardingUiState.Shown(topics = followableTopicTestData), + OnboardingUiState.Shown( + topics = followableTopicTestData, + ), feedState = NewsFeedUiState.Loading, deepLinkedUserNewsResource = null, onTopicCheckedChanged = { _, _ -> }, @@ -215,7 +218,7 @@ class ForYouScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), + composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading), ) .assertExists() } @@ -241,7 +244,7 @@ class ForYouScreenTest { composeTestRule .onNodeWithContentDescription( - composeTestRule.activity.resources.getString(R.string.feature_foryou_loading), + composeTestRule.activity.resources.getString(R.string.feature_foryou_api_loading), ) .assertExists() } diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreen.kt similarity index 98% rename from feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt rename to feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreen.kt index 1a3325996c..907f50d6a4 100644 --- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreen.kt @@ -14,12 +14,13 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.foryou +package com.google.samples.apps.nowinandroid.feature.foryou.impl import android.net.Uri import android.os.Build.VERSION import android.os.Build.VERSION_CODES import androidx.activity.compose.ReportDrawnWhen +import androidx.annotation.VisibleForTesting import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -80,7 +81,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus.Denied @@ -103,9 +104,11 @@ import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.launchCustomChromeTab import com.google.samples.apps.nowinandroid.core.ui.newsFeed +import com.google.samples.apps.nowinandroid.feature.foryou.api.R @Composable -internal fun ForYouScreen( +@VisibleForTesting +public fun ForYouScreen( onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, viewModel: ForYouViewModel = hiltViewModel(), @@ -215,7 +218,7 @@ internal fun ForYouScreen( targetOffsetY = { fullHeight -> -fullHeight }, ) + fadeOut(), ) { - val loadingContentDescription = stringResource(id = R.string.feature_foryou_loading) + val loadingContentDescription = stringResource(id = R.string.feature_foryou_api_loading) Box( modifier = Modifier .fillMaxWidth() @@ -270,7 +273,7 @@ private fun LazyStaggeredGridScope.onboarding( item(span = StaggeredGridItemSpan.FullLine, contentType = "onboarding") { Column(modifier = interestsItemModifier) { Text( - text = stringResource(R.string.feature_foryou_onboarding_guidance_title), + text = stringResource(R.string.feature_foryou_api_onboarding_guidance_title), textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() @@ -278,7 +281,7 @@ private fun LazyStaggeredGridScope.onboarding( style = MaterialTheme.typography.titleMedium, ) Text( - text = stringResource(R.string.feature_foryou_onboarding_guidance_subtitle), + text = stringResource(R.string.feature_foryou_api_onboarding_guidance_subtitle), modifier = Modifier .fillMaxWidth() .padding(top = 8.dp, start = 24.dp, end = 24.dp), @@ -304,7 +307,7 @@ private fun LazyStaggeredGridScope.onboarding( .fillMaxWidth(), ) { Text( - text = stringResource(R.string.feature_foryou_done), + text = stringResource(R.string.feature_foryou_api_done), ) } } @@ -433,7 +436,7 @@ fun TopicIcon( modifier: Modifier = Modifier, ) { DynamicAsyncImage( - placeholder = painterResource(R.drawable.feature_foryou_ic_icon_placeholder), + placeholder = painterResource(R.drawable.feature_foryou_api_ic_icon_placeholder), imageUrl = imageUrl, // decorative contentDescription = null, diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModel.kt similarity index 98% rename from feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt rename to feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModel.kt index 4b6cd39c94..c54551c0bb 100644 --- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt +++ b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModel.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.foryou +package com.google.samples.apps.nowinandroid.feature.foryou.impl import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/OnboardingUiState.kt similarity index 95% rename from feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt rename to feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/OnboardingUiState.kt index 70634b4c53..d31749bb5b 100644 --- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt +++ b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/OnboardingUiState.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.foryou +package com.google.samples.apps.nowinandroid.feature.foryou.impl import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic diff --git a/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/navigation/ForYouEntryProvider.kt b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/navigation/ForYouEntryProvider.kt new file mode 100644 index 0000000000..8259afa31f --- /dev/null +++ b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/navigation/ForYouEntryProvider.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.foryou.impl.navigation + +import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.navigation3.runtime.entry +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute +import com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen +import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityComponent::class) +object ForYouEntryProvider { + /** + * The ForYou composable for the app. It can also display information about topics. + * This should be supplied from a separate module. + */ + @Provides + @IntoSet + fun provideForYouEntryProviderBuilder( + backStack: NiaBackStack, + ): EntryProviderBuilder.() -> Unit = { + entry { + ForYouScreen( + onTopicClick = backStack::navigateToTopic, + ) + } + } +} diff --git a/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/navigation/ForYouRouteSerializerModule.kt b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/navigation/ForYouRouteSerializerModule.kt new file mode 100644 index 0000000000..15c010fb31 --- /dev/null +++ b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/navigation/ForYouRouteSerializerModule.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.foryou.impl.navigation + +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import kotlinx.serialization.modules.PolymorphicModuleBuilder + +/** + * Provides the DSL to register the route's [kotlinx.serialization.KSerializer] as a polymorphic serializer + * + */ +@Module +@InstallIn(SingletonComponent::class) +object ForYouRouteSerializerModule { + @Provides + @IntoSet + fun provideForYouPolymorphicModuleBuilder(): PolymorphicModuleBuilder<@JvmSuppressWildcards NiaNavKey>.() -> Unit = { + subclass(ForYouRoute::class, ForYouRoute.serializer()) + } +} diff --git a/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt b/feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenScreenshotTests.kt similarity index 94% rename from feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt rename to feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenScreenshotTests.kt index 29fc6f536f..ac42876252 100644 --- a/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt +++ b/feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenScreenshotTests.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.foryou +package com.google.samples.apps.nowinandroid.feature.foryou.impl import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable @@ -31,9 +31,8 @@ import com.google.samples.apps.nowinandroid.core.testing.util.captureMultiDevice import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider -import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.Loading -import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.NotShown -import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.Shown +import com.google.samples.apps.nowinandroid.feature.foryou.impl.OnboardingUiState.NotShown +import com.google.samples.apps.nowinandroid.feature.foryou.impl.OnboardingUiState.Shown import dagger.hilt.android.testing.HiltTestApplication import org.hamcrest.Matchers import org.junit.Before @@ -51,7 +50,7 @@ import java.util.TimeZone */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) -@Config(application = HiltTestApplication::class) +@Config(application = HiltTestApplication::class, sdk = [35]) @LooperMode(LooperMode.Mode.PAUSED) class ForYouScreenScreenshotTests { @@ -97,7 +96,7 @@ class ForYouScreenScreenshotTests { NiaTheme { ForYouScreen( isSyncing = false, - onboardingUiState = Loading, + onboardingUiState = OnboardingUiState.Loading, feedState = NewsFeedUiState.Loading, onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, @@ -194,7 +193,7 @@ class ForYouScreenScreenshotTests { NiaTheme { ForYouScreen( isSyncing = true, - onboardingUiState = Loading, + onboardingUiState = OnboardingUiState.Loading, feedState = Success( feed = userNewsResources, ), diff --git a/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt b/feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModelTest.kt similarity index 99% rename from feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt rename to feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModelTest.kt index 812544c0cb..5008b484c7 100644 --- a/feature/foryou/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt +++ b/feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouViewModelTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.foryou +package com.google.samples.apps.nowinandroid.feature.foryou.impl import androidx.lifecycle.SavedStateHandle import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent diff --git a/feature/foryou/src/test/screenshots/ForYouScreenLoading_foldable.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_foldable.png similarity index 100% rename from feature/foryou/src/test/screenshots/ForYouScreenLoading_foldable.png rename to feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_foldable.png diff --git a/feature/foryou/src/test/screenshots/ForYouScreenLoading_phone.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_phone.png similarity index 100% rename from feature/foryou/src/test/screenshots/ForYouScreenLoading_phone.png rename to feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_phone.png diff --git a/feature/foryou/src/test/screenshots/ForYouScreenLoading_tablet.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_tablet.png similarity index 100% rename from feature/foryou/src/test/screenshots/ForYouScreenLoading_tablet.png rename to feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_tablet.png diff --git a/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png new file mode 100644 index 0000000000..e4ecf6d959 Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png similarity index 100% rename from feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png rename to feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone_dark.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone_dark.png similarity index 100% rename from feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone_dark.png rename to feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone_dark.png diff --git a/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png new file mode 100644 index 0000000000..53891bdc19 Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png differ diff --git a/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png new file mode 100644 index 0000000000..ba669e8f83 Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png similarity index 100% rename from feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png rename to feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png diff --git a/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png new file mode 100644 index 0000000000..e92ae04815 Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png differ diff --git a/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_foldable.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_foldable.png new file mode 100644 index 0000000000..3ae1bee949 Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_phone.png similarity index 86% rename from feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone.png rename to feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_phone.png index b2faa3a281..0938678b24 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone.png and b/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_phone.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png similarity index 85% rename from feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png rename to feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png index 4c30995b2e..084d031a5d 100644 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png and b/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png differ diff --git a/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_tablet.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_tablet.png new file mode 100644 index 0000000000..35f59e54cf Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_tablet.png differ diff --git a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt b/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt deleted file mode 100644 index b77ce72a01..0000000000 --- a/feature/foryou/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/navigation/ForYouNavigation.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.feature.foryou.navigation - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptions -import androidx.navigation.compose.composable -import androidx.navigation.compose.navigation -import androidx.navigation.navDeepLink -import com.google.samples.apps.nowinandroid.core.notifications.DEEP_LINK_URI_PATTERN -import com.google.samples.apps.nowinandroid.feature.foryou.ForYouScreen -import kotlinx.serialization.Serializable - -@Serializable data object ForYouRoute // route to ForYou screen - -@Serializable data object ForYouBaseRoute // route to base navigation graph - -fun NavController.navigateToForYou(navOptions: NavOptions) = navigate(route = ForYouRoute, navOptions) - -/** - * The ForYou section of the app. It can also display information about topics. - * This should be supplied from a separate module. - * - * @param onTopicClick - Called when a topic is clicked, contains the ID of the topic - * @param topicDestination - Destination for topic content - */ -fun NavGraphBuilder.forYouSection( - onTopicClick: (String) -> Unit, - topicDestination: NavGraphBuilder.() -> Unit, -) { - navigation(startDestination = ForYouRoute) { - composable( - deepLinks = listOf( - navDeepLink { - /** - * This destination has a deep link that enables a specific news resource to be - * opened from a notification (@see SystemTrayNotifier for more). The news resource - * ID is sent in the URI rather than being modelled in the route type because it's - * transient data (stored in SavedStateHandle) that is cleared after the user has - * opened the news resource. - */ - uriPattern = DEEP_LINK_URI_PATTERN - }, - ), - ) { - ForYouScreen(onTopicClick) - } - topicDestination() - } -} diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png deleted file mode 100644 index 3990aee3fc..0000000000 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png and /dev/null differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png deleted file mode 100644 index 3b735462f6..0000000000 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png and /dev/null differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png deleted file mode 100644 index bc9c7dbec4..0000000000 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png and /dev/null differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png deleted file mode 100644 index 2b16ff76f4..0000000000 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png and /dev/null differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_foldable.png deleted file mode 100644 index 7ea92f8a8d..0000000000 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_foldable.png and /dev/null differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_tablet.png deleted file mode 100644 index 05f1ba1ed8..0000000000 Binary files a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_tablet.png and /dev/null differ diff --git a/feature/topic/.gitignore b/feature/interests/api/.gitignore similarity index 100% rename from feature/topic/.gitignore rename to feature/interests/api/.gitignore diff --git a/feature/interests/api/README.md b/feature/interests/api/README.md new file mode 100644 index 0000000000..95a625c89d --- /dev/null +++ b/feature/interests/api/README.md @@ -0,0 +1,3 @@ +# :feature:interests:api module +## Dependency graph +![Dependency graph](../../../docs/images/graphs/dep_graph_feature_interests_api.svg) diff --git a/feature/interests/api/build.gradle.kts b/feature/interests/api/build.gradle.kts new file mode 100644 index 0000000000..7a2dfd65ee --- /dev/null +++ b/feature/interests/api/build.gradle.kts @@ -0,0 +1,23 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.nowinandroid.android.feature.api) +} + +android { + namespace = "com.google.samples.apps.nowinandroid.feature.interests.api" +} \ No newline at end of file diff --git a/feature/interests/src/main/AndroidManifest.xml b/feature/interests/api/src/main/AndroidManifest.xml similarity index 100% rename from feature/interests/src/main/AndroidManifest.xml rename to feature/interests/api/src/main/AndroidManifest.xml diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt b/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/navigation/InterestsRoute.kt similarity index 67% rename from feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt rename to feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/navigation/InterestsRoute.kt index d83e4a9b2c..ce48a1df6a 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/navigation/InterestsNavigation.kt +++ b/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/navigation/InterestsRoute.kt @@ -14,20 +14,16 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.interests.navigation +package com.google.samples.apps.nowinandroid.feature.interests.api.navigation -import androidx.navigation.NavController -import androidx.navigation.NavOptions +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey import kotlinx.serialization.Serializable -@Serializable data class InterestsRoute( +@Serializable +data class InterestsRoute( // The ID of the topic which will be initially selected at this destination val initialTopicId: String? = null, -) - -fun NavController.navigateToInterests( - initialTopicId: String? = null, - navOptions: NavOptions? = null, -) { - navigate(route = InterestsRoute(initialTopicId), navOptions) +) : NiaNavKey { + override val isTopLevel: Boolean + get() = true } diff --git a/feature/topic/src/main/res/drawable/feature_topic_ic_topic_placeholder.xml b/feature/interests/api/src/main/res/drawable/feature_interests_api_ic_detail_placeholder.xml similarity index 99% rename from feature/topic/src/main/res/drawable/feature_topic_ic_topic_placeholder.xml rename to feature/interests/api/src/main/res/drawable/feature_interests_api_ic_detail_placeholder.xml index 0518401daa..2789b54e6e 100644 --- a/feature/topic/src/main/res/drawable/feature_topic_ic_topic_placeholder.xml +++ b/feature/interests/api/src/main/res/drawable/feature_interests_api_ic_detail_placeholder.xml @@ -52,4 +52,4 @@ android:strokeColor="#8C4190" android:strokeLineCap="round" android:strokeWidth="2" /> - + \ No newline at end of file diff --git a/feature/interests/src/main/res/values/strings.xml b/feature/interests/api/src/main/res/values/strings.xml similarity index 69% rename from feature/interests/src/main/res/values/strings.xml rename to feature/interests/api/src/main/res/values/strings.xml index 8d5322859c..b02d91bcc7 100644 --- a/feature/interests/src/main/res/values/strings.xml +++ b/feature/interests/api/src/main/res/values/strings.xml @@ -15,7 +15,8 @@ limitations under the License. --> - Interests - Loading data - "No available data" + Interests + Loading data + "No available data" + Select an Interest diff --git a/feature/interests/impl/.gitignore b/feature/interests/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/feature/interests/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/interests/impl/README.md b/feature/interests/impl/README.md new file mode 100644 index 0000000000..722f4ef411 --- /dev/null +++ b/feature/interests/impl/README.md @@ -0,0 +1,3 @@ +# :feature:interests:impl module +## Dependency graph +![Dependency graph](../../../docs/images/graphs/dep_graph_feature_interests_impl.svg) diff --git a/feature/interests/impl/build.gradle.kts b/feature/interests/impl/build.gradle.kts new file mode 100644 index 0000000000..d1a682e273 --- /dev/null +++ b/feature/interests/impl/build.gradle.kts @@ -0,0 +1,48 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.nowinandroid.android.feature.impl) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.library.jacoco) +} +android { + namespace = "com.google.samples.apps.nowinandroid.feature.interests.impl" +} + +dependencies { + implementation(projects.core.domain) + implementation(projects.feature.topic.api) + implementation(projects.feature.interests.api) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.adaptive) + implementation(libs.androidx.compose.material3.adaptive.layout) + implementation(libs.androidx.compose.material3.adaptive.navigation) + implementation(libs.androidx.compose.material3.adaptive.navigation3) + + testImplementation(projects.core.testing) + testImplementation(projects.core.dataTest) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.compose.ui.test) + testImplementation(libs.androidx.test.espresso.core) + testImplementation(libs.hilt.android.testing) + testImplementation(projects.uiTestHiltManifest) + testImplementation(projects.feature.topic.impl) + testImplementation(libs.androidx.navigation.testing) + + androidTestImplementation(libs.bundles.androidx.compose.ui.test) + androidTestImplementation(projects.core.testing) +} diff --git a/feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt b/feature/interests/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreenTest.kt similarity index 92% rename from feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt rename to feature/interests/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreenTest.kt index a441f5a9d8..8a10a478bc 100644 --- a/feature/interests/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsScreenTest.kt +++ b/feature/interests/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreenTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.interests +package com.google.samples.apps.nowinandroid.feature.interests.impl import androidx.activity.ComponentActivity import androidx.compose.runtime.Composable @@ -25,13 +25,11 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData -import com.google.samples.apps.nowinandroid.feature.interests.InterestsScreen -import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState import org.junit.Before import org.junit.Rule import org.junit.Test import com.google.samples.apps.nowinandroid.core.ui.R as CoreUiR -import com.google.samples.apps.nowinandroid.feature.interests.R as InterestsR +import com.google.samples.apps.nowinandroid.feature.interests.api.R as InterestsR /** * UI test for checking the correct behaviour of the Interests screen; @@ -51,8 +49,8 @@ class InterestsScreenTest { @Before fun setup() { composeTestRule.activity.apply { - interestsLoading = getString(InterestsR.string.feature_interests_loading) - interestsEmptyHeader = getString(InterestsR.string.feature_interests_empty_header) + interestsLoading = getString(InterestsR.string.feature_interests_api_loading) + interestsEmptyHeader = getString(InterestsR.string.feature_interests_api_empty_header) interestsTopicCardFollowButton = getString(CoreUiR.string.core_ui_interests_card_follow_button_content_desc) interestsTopicCardUnfollowButton = diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicDetailPlaceholder.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsDetailPlaceholder.kt similarity index 86% rename from feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicDetailPlaceholder.kt rename to feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsDetailPlaceholder.kt index 627fb8fb3b..2a03019d37 100644 --- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicDetailPlaceholder.kt +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsDetailPlaceholder.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.topic +package com.google.samples.apps.nowinandroid.feature.interests.impl import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -33,9 +33,10 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +import com.google.samples.apps.nowinandroid.feature.interests.api.R @Composable -fun TopicDetailPlaceholder(modifier: Modifier = Modifier) { +fun InterestsDetailPlaceholder(modifier: Modifier = Modifier) { Card( modifier = modifier, colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), @@ -50,12 +51,12 @@ fun TopicDetailPlaceholder(modifier: Modifier = Modifier) { ), ) { Icon( - painter = painterResource(id = R.drawable.feature_topic_ic_topic_placeholder), + painter = painterResource(id = R.drawable.feature_interests_api_ic_detail_placeholder), contentDescription = null, tint = MaterialTheme.colorScheme.primary, ) Text( - text = stringResource(id = R.string.feature_topic_select_an_interest), + text = stringResource(id = R.string.feature_interests_api_select_an_interest), style = MaterialTheme.typography.titleLarge, ) } @@ -66,6 +67,6 @@ fun TopicDetailPlaceholder(modifier: Modifier = Modifier) { @Composable fun TopicDetailPlaceholderPreview() { NiaTheme { - TopicDetailPlaceholder() + InterestsDetailPlaceholder() } } diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreen.kt similarity index 93% rename from feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt rename to feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreen.kt index 9b18ac89b4..000c1f4293 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreen.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.interests +package com.google.samples.apps.nowinandroid.feature.interests.impl import androidx.compose.foundation.layout.Column import androidx.compose.material3.Text @@ -24,7 +24,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel @@ -33,13 +32,14 @@ import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent +import com.google.samples.apps.nowinandroid.feature.interests.api.R @Composable -fun InterestsRoute( +fun InterestsScreen( onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, + viewModel: InterestsViewModel, shouldHighlightSelectedTopic: Boolean = false, - viewModel: InterestsViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -70,7 +70,7 @@ internal fun InterestsScreen( when (uiState) { InterestsUiState.Loading -> NiaLoadingWheel( - contentDesc = stringResource(id = R.string.feature_interests_loading), + contentDesc = stringResource(id = R.string.feature_interests_api_loading), ) is InterestsUiState.Interests -> @@ -90,7 +90,7 @@ internal fun InterestsScreen( @Composable private fun InterestsEmptyScreen() { - Text(text = stringResource(id = R.string.feature_interests_empty_header)) + Text(text = stringResource(id = R.string.feature_interests_api_empty_header)) } @DevicePreviews diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt similarity index 81% rename from feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt rename to feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt index 67cc8884f9..8f30fbe957 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt @@ -14,39 +14,40 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.interests +package com.google.samples.apps.nowinandroid.feature.interests.impl import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.navigation.toRoute import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.TopicSortField import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic -import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute +import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import javax.inject.Inject -@HiltViewModel -class InterestsViewModel @Inject constructor( +@HiltViewModel(assistedFactory = InterestsViewModel.Factory::class) +class InterestsViewModel @AssistedInject constructor( private val savedStateHandle: SavedStateHandle, val userDataRepository: UserDataRepository, getFollowableTopics: GetFollowableTopicsUseCase, + @Assisted val key: InterestsRoute, ) : ViewModel() { // Key used to save and retrieve the currently selected topic id from saved state. private val selectedTopicIdKey = "selectedTopicIdKey" - private val interestsRoute: InterestsRoute = savedStateHandle.toRoute() private val selectedTopicId = savedStateHandle.getStateFlow( key = selectedTopicIdKey, - initialValue = interestsRoute.initialTopicId, + initialValue = key.initialTopicId, ) val uiState: StateFlow = combine( @@ -68,6 +69,11 @@ class InterestsViewModel @Inject constructor( fun onTopicClick(topicId: String?) { savedStateHandle[selectedTopicIdKey] = topicId } + + @AssistedFactory + interface Factory { + fun create(key: InterestsRoute): InterestsViewModel + } } sealed interface InterestsUiState { diff --git a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/TabContent.kt similarity index 96% rename from feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt rename to feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/TabContent.kt index 133c2bedd3..d8a09c8f0a 100644 --- a/feature/interests/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/TabContent.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.interests +package com.google.samples.apps.nowinandroid.feature.interests.impl import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.Box @@ -59,7 +59,7 @@ fun TopicsTabContent( LazyColumn( modifier = Modifier .padding(horizontal = 24.dp) - .testTag("interests:topics"), + .testTag(LIST_PANE_TEST_TAG), contentPadding = PaddingValues(vertical = 16.dp), state = scrollableState, ) { @@ -103,3 +103,5 @@ fun TopicsTabContent( ) } } + +val LIST_PANE_TEST_TAG = "interests:topics" diff --git a/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/navigation/InterestsEntryProvider.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/navigation/InterestsEntryProvider.kt new file mode 100644 index 0000000000..68c0a59818 --- /dev/null +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/navigation/InterestsEntryProvider.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.interests.impl.navigation + +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.navigation3.runtime.entry +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute +import com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsDetailPlaceholder +import com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsScreen +import com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsViewModel +import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityComponent::class) +object InterestsEntryProvider { + + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + @Provides + @IntoSet + fun provideInterestsEntryProviderBuilder( + backStack: NiaBackStack, + ): EntryProviderBuilder.() -> Unit = { + entry( + metadata = ListDetailSceneStrategy.listPane { + InterestsDetailPlaceholder() + }, + ) { key -> + val viewModel = hiltViewModel { + it.create(key) + } + InterestsScreen( + onTopicClick = backStack::navigateToTopic, + shouldHighlightSelectedTopic = false, + viewModel = viewModel, + ) + } + } +} diff --git a/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/navigation/InterestsSerializerModule.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/navigation/InterestsSerializerModule.kt new file mode 100644 index 0000000000..cdc1234b8c --- /dev/null +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/navigation/InterestsSerializerModule.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.interests.impl.navigation + +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import kotlinx.serialization.modules.PolymorphicModuleBuilder + +/** + * Provides the DSL to register the route's [kotlinx.serialization.KSerializer] as a polymorphic serializer + * + */ +@Module +@InstallIn(SingletonComponent::class) +object InterestsSerializerModule { + @Provides + @IntoSet + fun provideInterestsPolymorphicModuleBuilder(): PolymorphicModuleBuilder<@JvmSuppressWildcards NiaNavKey>.() -> Unit = { + subclass(InterestsRoute::class, InterestsRoute.serializer()) + } +} diff --git a/feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsListDetailScreenTest.kt b/feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsListDetailScreenTest.kt new file mode 100644 index 0000000000..81ec9c5498 --- /dev/null +++ b/feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsListDetailScreenTest.kt @@ -0,0 +1,266 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalMaterial3AdaptiveApi::class) + +package com.google.samples.apps.nowinandroid.interests.impl + +import androidx.activity.viewModels +import androidx.annotation.StringRes +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation3.rememberListDetailSceneStrategy +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.ui.NavDisplay +import androidx.test.espresso.Espresso +import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository +import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +import com.google.samples.apps.nowinandroid.core.model.data.Topic +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackViewModel +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import com.google.samples.apps.nowinandroid.feature.interests.api.R +import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute +import com.google.samples.apps.nowinandroid.feature.interests.impl.LIST_PANE_TEST_TAG +import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity +import dagger.Module +import dagger.Provides +import dagger.hilt.EntryPoint +import dagger.hilt.EntryPoints +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.modules.PolymorphicModuleBuilder +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.getValue +import kotlin.properties.ReadOnlyProperty + +private const val EXPANDED_WIDTH = "w1200dp-h840dp" +private const val COMPACT_WIDTH = "w412dp-h915dp" + +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +@Config(application = HiltTestApplication::class, sdk = [35]) +class InterestsListDetailScreenTest { + + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + // entry point to get the features' hilt-injected EntryProviders that are installed in ActivityComponent + @EntryPoint + @InstallIn(ActivityComponent::class) + interface EntryProvidersEntryPoint { + fun getEntryProviders(): Set<@JvmSuppressWildcards EntryProviderBuilder.() -> Unit> + } + + @Inject + lateinit var topicsRepository: TopicsRepository + + /** Convenience function for getting all topics during tests, */ + private fun getTopics(): List = runBlocking { + topicsRepository.getTopics().first().sortedBy { it.name } + } + + // The strings used for matching in these tests. + private val placeholderText by composeTestRule.stringResource(R.string.feature_interests_api_select_an_interest) + + private val Topic.testTag + get() = "topic:${this.id}" + + private lateinit var entryProviderBuilders: Set.() -> Unit> + + @Before + fun setup() { + hiltRule.inject() + composeTestRule.apply { + entryProviderBuilders = EntryPoints.get(activity, EntryProvidersEntryPoint::class.java) + .getEntryProviders() + } + } + + @Test + @Config(qualifiers = EXPANDED_WIDTH) + fun expandedWidth_initialState_showsTwoPanesWithPlaceholder() { + composeTestRule.apply { + setContent { + NiaTheme { + NavDisplay( + backStack = listOf(InterestsRoute()), + sceneStrategy = rememberListDetailSceneStrategy(), + entryProvider = entryProvider { + entryProviderBuilders.forEach { it() } + }, + ) + } + } + onNodeWithTag(LIST_PANE_TEST_TAG).assertIsDisplayed() + onNodeWithText(placeholderText).assertIsDisplayed() + } + } + + @Test + @Config(qualifiers = COMPACT_WIDTH) + fun compactWidth_initialState_showsListPane() { + composeTestRule.apply { + setContent { + NiaTheme { + NavDisplay( + backStack = listOf(InterestsRoute()), + sceneStrategy = rememberListDetailSceneStrategy(), + entryProvider = entryProvider { + entryProviderBuilders.forEach { it() } + }, + ) + } + } + + onNodeWithTag(LIST_PANE_TEST_TAG).assertIsDisplayed() + onNodeWithText(placeholderText).assertIsNotDisplayed() + } + } + + @Test + @Config(qualifiers = EXPANDED_WIDTH) + fun expandedWidth_topicSelected_updatesDetailPane() { + composeTestRule.apply { + setContent { + val backStackViewModel by composeTestRule.activity.viewModels() + val backStack = backStackViewModel.niaBackStack.backStack + NiaTheme { + NavDisplay( + backStack = backStack, + sceneStrategy = rememberListDetailSceneStrategy(), + entryProvider = entryProvider { + entryProviderBuilders.forEach { it() } + }, + ) + } + } + val firstTopic = getTopics().first() + onNodeWithText(firstTopic.name).performClick() + waitForIdle() + + onNodeWithTag(LIST_PANE_TEST_TAG).assertIsDisplayed() + onNodeWithText(placeholderText).assertIsNotDisplayed() + onNodeWithTag(firstTopic.testTag).assertIsDisplayed() + } + } + + @Test + @Config(qualifiers = COMPACT_WIDTH) + fun compactWidth_topicSelected_showsTopicDetailPane() { + composeTestRule.apply { + setContent { + val backStackViewModel by composeTestRule.activity.viewModels() + val backStack = backStackViewModel.niaBackStack.backStack + NiaTheme { + NavDisplay( + backStack = backStack, + sceneStrategy = rememberListDetailSceneStrategy(), + entryProvider = entryProvider { + entryProviderBuilders.forEach { it() } + }, + ) + } + } + + val firstTopic = getTopics().first() + onNodeWithText(firstTopic.name).performClick() + + onNodeWithTag(LIST_PANE_TEST_TAG).assertIsNotDisplayed() + onNodeWithText(placeholderText).assertIsNotDisplayed() + onNodeWithTag(firstTopic.testTag).assertIsDisplayed() + } + } + + @Test + @Config(qualifiers = COMPACT_WIDTH) + fun compactWidth_backPressFromTopicDetail_showsListPane() { + composeTestRule.apply { + setContent { + val backStackViewModel by composeTestRule.activity.viewModels() + val backStack = backStackViewModel.niaBackStack.backStack + NiaTheme { + NavDisplay( + backStack = backStack, + sceneStrategy = rememberListDetailSceneStrategy(), + entryProvider = entryProvider { + entryProviderBuilders.forEach { it() } + }, + ) + } + } + + val firstTopic = getTopics().first() + onNodeWithText(firstTopic.name).performClick() + + waitForIdle() + Espresso.pressBack() + + onNodeWithTag(LIST_PANE_TEST_TAG).assertIsDisplayed() + onNodeWithText(placeholderText).assertIsNotDisplayed() + onNodeWithTag(firstTopic.testTag).assertIsNotDisplayed() + } + } +} + +private fun AndroidComposeTestRule<*, *>.stringResource( + @StringRes resId: Int, +): ReadOnlyProperty = + ReadOnlyProperty { _, _ -> activity.getString(resId) } + +@Module +@InstallIn(SingletonComponent::class) +object BackStackProvider { + @Provides + @Singleton + fun provideNiaBackStack(): NiaBackStack = + NiaBackStack(startKey = InterestsRoute()) + + @Provides + @Singleton + fun provideSerializersModule( + polymorphicModuleBuilders: Set<@JvmSuppressWildcards PolymorphicModuleBuilder.() -> Unit>, + ): SerializersModule = SerializersModule { + polymorphic(NiaNavKey::class) { + polymorphicModuleBuilders.forEach { it() } + } + } +} diff --git a/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt b/feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsViewModelTest.kt similarity index 94% rename from feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt rename to feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsViewModelTest.kt index cdf21f3259..4998e86caa 100644 --- a/feature/interests/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt +++ b/feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsViewModelTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.interests +package com.google.samples.apps.nowinandroid.interests.impl import androidx.lifecycle.SavedStateHandle import androidx.navigation.testing.invoke @@ -24,9 +24,9 @@ import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule -import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState -import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel -import com.google.samples.apps.nowinandroid.feature.interests.navigation.InterestsRoute +import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute +import com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsUiState +import com.google.samples.apps.nowinandroid.feature.interests.impl.InterestsViewModel import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -36,6 +36,7 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config import kotlin.test.assertEquals /** @@ -49,6 +50,7 @@ import kotlin.test.assertEquals * See https://issuetracker.google.com/340966212. */ @RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) class InterestsViewModelTest { @get:Rule @@ -70,6 +72,7 @@ class InterestsViewModelTest { ), userDataRepository = userDataRepository, getFollowableTopics = getFollowableTopicsUseCase, + InterestsRoute(initialTopicId = testInputTopics[0].topic.id), ) } diff --git a/feature/search/api/.gitignore b/feature/search/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/feature/search/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/search/api/README.md b/feature/search/api/README.md new file mode 100644 index 0000000000..88460cbe0c --- /dev/null +++ b/feature/search/api/README.md @@ -0,0 +1,3 @@ +# :feature:search:api module +## Dependency graph +![Dependency graph](../../../docs/images/graphs/dep_graph_feature_search_api.svg) diff --git a/feature/search/api/build.gradle.kts b/feature/search/api/build.gradle.kts new file mode 100644 index 0000000000..d7ea6fc5fe --- /dev/null +++ b/feature/search/api/build.gradle.kts @@ -0,0 +1,28 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.nowinandroid.android.feature.api) +} + +android { + namespace = "com.google.samples.apps.nowinandroid.feature.search.api" +} + +dependencies { + implementation(projects.core.domain) +} + diff --git a/feature/search/src/main/AndroidManifest.xml b/feature/search/api/src/main/AndroidManifest.xml similarity index 100% rename from feature/search/src/main/AndroidManifest.xml rename to feature/search/api/src/main/AndroidManifest.xml diff --git a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/navigation/SearchRoute.kt b/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/navigation/SearchRoute.kt new file mode 100644 index 0000000000..e45c177b38 --- /dev/null +++ b/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/navigation/SearchRoute.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search.api.navigation + +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import kotlinx.serialization.Serializable + +@Serializable +object SearchRoute : NiaNavKey { + override val isTopLevel: Boolean + get() = false +} + +fun NiaBackStack.navigateToSearch() { + navigate(SearchRoute) +} diff --git a/feature/search/api/src/main/res/values/strings.xml b/feature/search/api/src/main/res/values/strings.xml new file mode 100644 index 0000000000..d2d2184939 --- /dev/null +++ b/feature/search/api/src/main/res/values/strings.xml @@ -0,0 +1,29 @@ + + + + Search + Clear search text + Sorry, there is no content found for your search \"%1$s\" + Sorry, we are still processing the search index. Please come back later + Try another search or explorer + Interests + to browse topics + Topics + Updates + Recent searches + Clear searches + diff --git a/feature/search/impl/.gitignore b/feature/search/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/feature/search/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/search/impl/README.md b/feature/search/impl/README.md new file mode 100644 index 0000000000..d1304a4d38 --- /dev/null +++ b/feature/search/impl/README.md @@ -0,0 +1,3 @@ +# :feature:search:impl module +## Dependency graph +![Dependency graph](../../../docs/images/graphs/dep_graph_feature_search_impl.svg) diff --git a/feature/search/build.gradle.kts b/feature/search/impl/build.gradle.kts similarity index 82% rename from feature/search/build.gradle.kts rename to feature/search/impl/build.gradle.kts index 5bb659c35e..8425b29f3e 100644 --- a/feature/search/build.gradle.kts +++ b/feature/search/impl/build.gradle.kts @@ -15,22 +15,23 @@ */ plugins { - alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.feature.impl) alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.library.jacoco) } android { - namespace = "com.google.samples.apps.nowinandroid.feature.search" + namespace = "com.google.samples.apps.nowinandroid.feature.search.impl" } dependencies { - implementation(projects.core.data) implementation(projects.core.domain) + implementation(projects.feature.interests.api) + implementation(projects.feature.search.api) + implementation(projects.feature.topic.api) testImplementation(projects.core.testing) androidTestImplementation(libs.bundles.androidx.compose.ui.test) androidTestImplementation(projects.core.testing) -} - +} \ No newline at end of file diff --git a/feature/search/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt b/feature/search/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreenTest.kt similarity index 93% rename from feature/search/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt rename to feature/search/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreenTest.kt index a9e2fa98fd..3cb93530fc 100644 --- a/feature/search/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt +++ b/feature/search/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreenTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.search +package com.google.samples.apps.nowinandroid.feature.search.impl import androidx.activity.ComponentActivity import androidx.compose.ui.test.assertCountEquals @@ -36,6 +36,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData import com.google.samples.apps.nowinandroid.core.ui.R.string +import com.google.samples.apps.nowinandroid.feature.search.api.R import org.junit.Before import org.junit.Rule import org.junit.Test @@ -70,17 +71,17 @@ class SearchScreenTest { @Before fun setup() { composeTestRule.activity.apply { - clearSearchContentDesc = getString(R.string.feature_search_clear_search_text_content_desc) - clearRecentSearchesContentDesc = getString(R.string.feature_search_clear_recent_searches_content_desc) + clearSearchContentDesc = getString(R.string.feature_search_api_clear_search_text_content_desc) + clearRecentSearchesContentDesc = getString(R.string.feature_search_api_clear_recent_searches_content_desc) followButtonContentDesc = getString(string.core_ui_interests_card_follow_button_content_desc) unfollowButtonContentDesc = getString(string.core_ui_interests_card_unfollow_button_content_desc) - topicsString = getString(R.string.feature_search_topics) - updatesString = getString(R.string.feature_search_updates) - tryAnotherSearchString = getString(R.string.feature_search_try_another_search) + - " " + getString(R.string.feature_search_interests) + " " + getString(R.string.feature_search_to_browse_topics) - searchNotReadyString = getString(R.string.feature_search_not_ready) + topicsString = getString(R.string.feature_search_api_topics) + updatesString = getString(R.string.feature_search_api_updates) + tryAnotherSearchString = getString(R.string.feature_search_api_try_another_search) + + " " + getString(R.string.feature_search_api_interests) + " " + getString(R.string.feature_search_api_to_browse_topics) + searchNotReadyString = getString(R.string.feature_search_api_not_ready) } } diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/RecentSearchQueriesUiState.kt similarity index 93% rename from feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt rename to feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/RecentSearchQueriesUiState.kt index 8aa5bb3b8c..5b85166647 100644 --- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/RecentSearchQueriesUiState.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.search +package com.google.samples.apps.nowinandroid.feature.search.impl import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchResultUiState.kt similarity index 96% rename from feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt rename to feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchResultUiState.kt index aaf7dba7dd..7a6f37087c 100644 --- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchResultUiState.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.search +package com.google.samples.apps.nowinandroid.feature.search.impl import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreen.kt similarity index 96% rename from feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt rename to feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreen.kt index b617f98a9b..bb7164f2a9 100644 --- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreen.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.search +package com.google.samples.apps.nowinandroid.feature.search.impl import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation @@ -78,7 +78,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.DraggableScrollbar import com.google.samples.apps.nowinandroid.core.designsystem.component.scrollbar.rememberDraggableScroller @@ -93,10 +93,10 @@ import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success import com.google.samples.apps.nowinandroid.core.ui.R.string import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.newsFeed -import com.google.samples.apps.nowinandroid.feature.search.R as searchR +import com.google.samples.apps.nowinandroid.feature.search.api.R as searchR @Composable -internal fun SearchRoute( +internal fun SearchScreen( onBackClick: () -> Unit, onInterestsClick: () -> Unit, onTopicClick: (String) -> Unit, @@ -211,7 +211,7 @@ fun EmptySearchResultBody( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(horizontal = 48.dp), ) { - val message = stringResource(id = searchR.string.feature_search_result_not_found, searchQuery) + val message = stringResource(id = searchR.string.feature_search_api_result_not_found, searchQuery) val start = message.indexOf(searchQuery) Text( text = AnnotatedString( @@ -229,7 +229,7 @@ fun EmptySearchResultBody( modifier = Modifier.padding(vertical = 24.dp), ) val tryAnotherSearchString = buildAnnotatedString { - append(stringResource(id = searchR.string.feature_search_try_another_search)) + append(stringResource(id = searchR.string.feature_search_api_try_another_search)) append(" ") withLink( LinkAnnotation.Clickable( @@ -245,12 +245,12 @@ fun EmptySearchResultBody( fontWeight = FontWeight.Bold, ), ) { - append(stringResource(id = searchR.string.feature_search_interests)) + append(stringResource(id = searchR.string.feature_search_api_interests)) } } append(" ") - append(stringResource(id = searchR.string.feature_search_to_browse_topics)) + append(stringResource(id = searchR.string.feature_search_api_to_browse_topics)) } Text( text = tryAnotherSearchString, @@ -273,7 +273,7 @@ private fun SearchNotReadyBody() { modifier = Modifier.padding(horizontal = 48.dp), ) { Text( - text = stringResource(id = searchR.string.feature_search_not_ready), + text = stringResource(id = searchR.string.feature_search_api_not_ready), style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, modifier = Modifier.padding(vertical = 24.dp), @@ -314,7 +314,7 @@ private fun SearchResultBody( Text( text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(id = searchR.string.feature_search_topics)) + append(stringResource(id = searchR.string.feature_search_api_topics)) } }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -350,7 +350,7 @@ private fun SearchResultBody( Text( text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(id = searchR.string.feature_search_updates)) + append(stringResource(id = searchR.string.feature_search_api_updates)) } }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -402,7 +402,7 @@ private fun RecentSearchesBody( Text( text = buildAnnotatedString { withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { - append(stringResource(id = searchR.string.feature_search_recent_searches)) + append(stringResource(id = searchR.string.feature_search_api_recent_searches)) } }, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), @@ -417,7 +417,7 @@ private fun RecentSearchesBody( Icon( imageVector = NiaIcons.Close, contentDescription = stringResource( - id = searchR.string.feature_search_clear_recent_searches_content_desc, + id = searchR.string.feature_search_api_clear_recent_searches_content_desc, ), tint = MaterialTheme.colorScheme.onSurface, ) @@ -491,7 +491,7 @@ private fun SearchTextField( Icon( imageVector = NiaIcons.Search, contentDescription = stringResource( - id = searchR.string.feature_search_title, + id = searchR.string.feature_search_api_title, ), tint = MaterialTheme.colorScheme.onSurface, ) @@ -506,7 +506,7 @@ private fun SearchTextField( Icon( imageVector = NiaIcons.Close, contentDescription = stringResource( - id = searchR.string.feature_search_clear_search_text_content_desc, + id = searchR.string.feature_search_api_clear_search_text_content_desc, ), tint = MaterialTheme.colorScheme.onSurface, ) diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchUiStatePreviewParameterProvider.kt similarity index 93% rename from feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt rename to feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchUiStatePreviewParameterProvider.kt index 257d8b68e9..d2963fc7f9 100644 --- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchUiStatePreviewParameterProvider.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Android Open Source Project + * Copyright 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,7 +16,7 @@ @file:Suppress("ktlint:standard:max-line-length") -package com.google.samples.apps.nowinandroid.feature.search +package com.google.samples.apps.nowinandroid.feature.search.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModel.kt similarity index 98% rename from feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt rename to feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModel.kt index 36947880e9..13628de70f 100644 --- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModel.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.search +package com.google.samples.apps.nowinandroid.feature.search.impl import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel diff --git a/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/navigation/SearchEntryProvider.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/navigation/SearchEntryProvider.kt new file mode 100644 index 0000000000..272e4c7bac --- /dev/null +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/navigation/SearchEntryProvider.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search.impl.navigation + +import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.navigation3.runtime.entry +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.InterestsRoute +import com.google.samples.apps.nowinandroid.feature.search.api.navigation.SearchRoute +import com.google.samples.apps.nowinandroid.feature.search.impl.SearchScreen +import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityComponent::class) +object SearchEntryProvider { + + @Provides + @IntoSet + fun provideSearchEntryProviderBuilder( + backStack: NiaBackStack, + ): EntryProviderBuilder.() -> Unit = { + entry { key -> + SearchScreen( + onBackClick = backStack::popLast, + onInterestsClick = { backStack.navigate(InterestsRoute()) }, + onTopicClick = backStack::navigateToTopic, + ) + } + } +} diff --git a/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/navigation/SearchSerializerModule.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/navigation/SearchSerializerModule.kt new file mode 100644 index 0000000000..eca89cb403 --- /dev/null +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/navigation/SearchSerializerModule.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search.impl.navigation + +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import com.google.samples.apps.nowinandroid.feature.search.api.navigation.SearchRoute +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import kotlinx.serialization.modules.PolymorphicModuleBuilder + +/** + * Provides the DSL to register the route's [kotlinx.serialization.KSerializer] as a polymorphic serializer + * + */ +@Module +@InstallIn(SingletonComponent::class) +object SearchSerializerModule { + @Provides + @IntoSet + fun provideSearchPolymorphicModuleBuilder(): PolymorphicModuleBuilder<@JvmSuppressWildcards NiaNavKey>.() -> Unit = { + subclass(SearchRoute::class, SearchRoute.serializer()) + } +} diff --git a/feature/search/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt b/feature/search/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModelTest.kt similarity index 93% rename from feature/search/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt rename to feature/search/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModelTest.kt index 1b866cec20..a1f089b998 100644 --- a/feature/search/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt +++ b/feature/search/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchViewModelTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.search +package com.google.samples.apps.nowinandroid.feature.search.impl import androidx.lifecycle.SavedStateHandle import com.google.samples.apps.nowinandroid.core.analytics.NoOpAnalyticsHelper @@ -27,10 +27,10 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestSearchCo import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule -import com.google.samples.apps.nowinandroid.feature.search.RecentSearchQueriesUiState.Success -import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.EmptyQuery -import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.Loading -import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.SearchNotReady +import com.google.samples.apps.nowinandroid.feature.search.impl.RecentSearchQueriesUiState.Success +import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.EmptyQuery +import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.Loading +import com.google.samples.apps.nowinandroid.feature.search.impl.SearchResultUiState.SearchNotReady import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch diff --git a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt b/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt deleted file mode 100644 index 3b16e5f717..0000000000 --- a/feature/search/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.feature.search.navigation - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptions -import androidx.navigation.compose.composable -import com.google.samples.apps.nowinandroid.feature.search.SearchRoute -import kotlinx.serialization.Serializable - -@Serializable data object SearchRoute - -fun NavController.navigateToSearch(navOptions: NavOptions? = null) = - navigate(SearchRoute, navOptions) - -fun NavGraphBuilder.searchScreen( - onBackClick: () -> Unit, - onInterestsClick: () -> Unit, - onTopicClick: (String) -> Unit, -) { - // TODO: Handle back stack for each top-level destination. At the moment each top-level - // destination may have own search screen's back stack. - composable { - SearchRoute( - onBackClick = onBackClick, - onInterestsClick = onInterestsClick, - onTopicClick = onTopicClick, - ) - } -} diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml deleted file mode 100644 index e115767475..0000000000 --- a/feature/search/src/main/res/values/strings.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - Search - Clear search text - Sorry, there is no content found for your search \"%1$s\" - Sorry, we are still processing the search index. Please come back later - Try another search or explorer - Interests - to browse topics - Topics - Updates - Recent searches - Clear searches - diff --git a/feature/settings/api/.gitignore b/feature/settings/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/feature/settings/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/settings/api/README.md b/feature/settings/api/README.md new file mode 100644 index 0000000000..6b51d5dfd5 --- /dev/null +++ b/feature/settings/api/README.md @@ -0,0 +1,3 @@ +# :feature:settings:api module +## Dependency graph +![Dependency graph](../../../docs/images/graphs/dep_graph_feature_settings_api.svg) diff --git a/feature/settings/build.gradle.kts b/feature/settings/api/build.gradle.kts similarity index 93% rename from feature/settings/build.gradle.kts rename to feature/settings/api/build.gradle.kts index 15d65204dc..16ef6cc08f 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/api/build.gradle.kts @@ -15,13 +15,13 @@ */ plugins { - alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.feature.impl) alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.library.jacoco) } android { - namespace = "com.google.samples.apps.nowinandroid.feature.settings" + namespace = "com.google.samples.apps.nowinandroid.feature.settings.api" } dependencies { diff --git a/feature/settings/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialogTest.kt b/feature/settings/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/api/SettingsDialogTest.kt similarity index 96% rename from feature/settings/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialogTest.kt rename to feature/settings/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/api/SettingsDialogTest.kt index 790b5964d6..c92894ee08 100644 --- a/feature/settings/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialogTest.kt +++ b/feature/settings/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/settings/api/SettingsDialogTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.settings +package com.google.samples.apps.nowinandroid.feature.settings.api import androidx.activity.ComponentActivity import androidx.compose.ui.test.assertIsSelected @@ -23,8 +23,8 @@ import androidx.compose.ui.test.onNodeWithText import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT -import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading -import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success +import com.google.samples.apps.nowinandroid.feature.settings.api.SettingsUiState.Loading +import com.google.samples.apps.nowinandroid.feature.settings.api.SettingsUiState.Success import org.junit.Rule import org.junit.Test diff --git a/feature/settings/src/main/AndroidManifest.xml b/feature/settings/api/src/main/AndroidManifest.xml similarity index 100% rename from feature/settings/src/main/AndroidManifest.xml rename to feature/settings/api/src/main/AndroidManifest.xml diff --git a/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt b/feature/settings/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/api/SettingsDialog.kt similarity index 97% rename from feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt rename to feature/settings/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/api/SettingsDialog.kt index ad7f30f437..e6eef27e12 100644 --- a/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt +++ b/feature/settings/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/api/SettingsDialog.kt @@ -16,7 +16,7 @@ @file:Suppress("ktlint:standard:max-line-length") -package com.google.samples.apps.nowinandroid.feature.settings +package com.google.samples.apps.nowinandroid.feature.settings.api import android.content.Intent import androidx.compose.animation.AnimatedVisibility @@ -52,7 +52,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTextButton @@ -66,9 +66,9 @@ import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent -import com.google.samples.apps.nowinandroid.feature.settings.R.string -import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading -import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success +import com.google.samples.apps.nowinandroid.feature.settings.api.R.string +import com.google.samples.apps.nowinandroid.feature.settings.api.SettingsUiState.Loading +import com.google.samples.apps.nowinandroid.feature.settings.api.SettingsUiState.Success @Composable fun SettingsDialog( diff --git a/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt b/feature/settings/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/api/SettingsViewModel.kt similarity index 92% rename from feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt rename to feature/settings/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/api/SettingsViewModel.kt index 123c84d1c6..f34c72f0cf 100644 --- a/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt +++ b/feature/settings/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/api/SettingsViewModel.kt @@ -14,15 +14,15 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.settings +package com.google.samples.apps.nowinandroid.feature.settings.api import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand -import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading -import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success +import com.google.samples.apps.nowinandroid.feature.settings.api.SettingsUiState.Loading +import com.google.samples.apps.nowinandroid.feature.settings.api.SettingsUiState.Success import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.StateFlow diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/api/src/main/res/values/strings.xml similarity index 100% rename from feature/settings/src/main/res/values/strings.xml rename to feature/settings/api/src/main/res/values/strings.xml diff --git a/feature/settings/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModelTest.kt b/feature/settings/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/settings/api/SettingsViewModelTest.kt similarity index 90% rename from feature/settings/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModelTest.kt rename to feature/settings/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/settings/api/SettingsViewModelTest.kt index f977612a15..31b73df5bc 100644 --- a/feature/settings/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModelTest.kt +++ b/feature/settings/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/settings/api/SettingsViewModelTest.kt @@ -14,14 +14,14 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.settings +package com.google.samples.apps.nowinandroid.feature.settings.api import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule -import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading -import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success +import com.google.samples.apps.nowinandroid.feature.settings.api.SettingsUiState.Loading +import com.google.samples.apps.nowinandroid.feature.settings.api.SettingsUiState.Success import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher diff --git a/feature/topic/api/.gitignore b/feature/topic/api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/feature/topic/api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/topic/api/README.md b/feature/topic/api/README.md new file mode 100644 index 0000000000..62103d57b5 --- /dev/null +++ b/feature/topic/api/README.md @@ -0,0 +1,3 @@ +# :feature:topic:api module +## Dependency graph +![Dependency graph](../../../docs/images/graphs/dep_graph_feature_topic_api.svg) diff --git a/feature/topic/build.gradle.kts b/feature/topic/api/build.gradle.kts similarity index 86% rename from feature/topic/build.gradle.kts rename to feature/topic/api/build.gradle.kts index bd8b59ec84..20da0b65b2 100644 --- a/feature/topic/build.gradle.kts +++ b/feature/topic/api/build.gradle.kts @@ -15,18 +15,16 @@ */ plugins { - alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.feature.api) + alias(libs.plugins.nowinandroid.android.feature.impl) alias(libs.plugins.nowinandroid.android.library.compose) - alias(libs.plugins.nowinandroid.android.library.jacoco) } android { - namespace = "com.google.samples.apps.nowinandroid.feature.topic" + namespace = "com.google.samples.apps.nowinandroid.feature.topic.api" } dependencies { - implementation(projects.core.data) - testImplementation(projects.core.testing) testImplementation(libs.robolectric) diff --git a/feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt b/feature/topic/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicScreenTest.kt similarity index 97% rename from feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt rename to feature/topic/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicScreenTest.kt index 2b87baf9e8..5c80f67c31 100644 --- a/feature/topic/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt +++ b/feature/topic/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicScreenTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.topic +package com.google.samples.apps.nowinandroid.feature.topic.api import androidx.activity.ComponentActivity import androidx.compose.ui.test.hasScrollToNodeAction @@ -45,7 +45,7 @@ class TopicScreenTest { @Before fun setup() { composeTestRule.activity.apply { - topicLoading = getString(R.string.feature_topic_loading) + topicLoading = getString(R.string.feature_topic_api_loading) } } diff --git a/feature/topic/src/main/AndroidManifest.xml b/feature/topic/api/src/main/AndroidManifest.xml similarity index 100% rename from feature/topic/src/main/AndroidManifest.xml rename to feature/topic/api/src/main/AndroidManifest.xml diff --git a/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/navigation/TopicRoute.kt b/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/navigation/TopicRoute.kt new file mode 100644 index 0000000000..396887d09e --- /dev/null +++ b/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/navigation/TopicRoute.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.topic.api.navigation + +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import kotlinx.serialization.Serializable + +@Serializable +data class TopicRoute(val id: String) : NiaNavKey { + override val isTopLevel: Boolean + get() = false +} + +fun NiaBackStack.navigateToTopic( + topicId: String, +) { + navigate(TopicRoute(topicId)) +} diff --git a/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/navigation/TopicSerializerModule.kt b/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/navigation/TopicSerializerModule.kt new file mode 100644 index 0000000000..6286efdbda --- /dev/null +++ b/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/navigation/TopicSerializerModule.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.topic.api.navigation + +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import kotlinx.serialization.modules.PolymorphicModuleBuilder + +/** + * Provides the DSL to register the route's [kotlinx.serialization.KSerializer] as a polymorphic serializer + * + */ +@Module +@InstallIn(SingletonComponent::class) +object TopicSerializerModule { + @Provides + @IntoSet + fun provideTopicPolymorphicModuleBuilder(): PolymorphicModuleBuilder<@JvmSuppressWildcards NiaNavKey>.() -> Unit = { + subclass(TopicRoute::class, TopicRoute.serializer()) + } +} diff --git a/feature/topic/src/main/res/values/strings.xml b/feature/topic/api/src/main/res/values/strings.xml similarity index 82% rename from feature/topic/src/main/res/values/strings.xml rename to feature/topic/api/src/main/res/values/strings.xml index fe4a6dc290..1e3f376cf8 100644 --- a/feature/topic/src/main/res/values/strings.xml +++ b/feature/topic/api/src/main/res/values/strings.xml @@ -15,6 +15,5 @@ limitations under the License. --> - Loading topic - Select an Interest + Loading topic diff --git a/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt b/feature/topic/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicViewModelTest.kt similarity index 99% rename from feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt rename to feature/topic/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicViewModelTest.kt index 58242110d6..152e582d68 100644 --- a/feature/topic/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt +++ b/feature/topic/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicViewModelTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.topic +package com.google.samples.apps.nowinandroid.feature.topic.api import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic diff --git a/feature/topic/impl/.gitignore b/feature/topic/impl/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/feature/topic/impl/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/topic/impl/README.md b/feature/topic/impl/README.md new file mode 100644 index 0000000000..eee690ec02 --- /dev/null +++ b/feature/topic/impl/README.md @@ -0,0 +1,3 @@ +# :feature:topic:impl module +## Dependency graph +![Dependency graph](../../../docs/images/graphs/dep_graph_feature_topic_impl.svg) diff --git a/feature/interests/build.gradle.kts b/feature/topic/impl/build.gradle.kts similarity index 80% rename from feature/interests/build.gradle.kts rename to feature/topic/impl/build.gradle.kts index 2b84b135f7..fdf37c32e0 100644 --- a/feature/interests/build.gradle.kts +++ b/feature/topic/impl/build.gradle.kts @@ -15,21 +15,24 @@ */ plugins { - alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.feature.impl) alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.library.jacoco) } + android { - namespace = "com.google.samples.apps.nowinandroid.feature.interests" + namespace = "com.google.samples.apps.nowinandroid.feature.topic.impl" } dependencies { implementation(projects.core.data) - implementation(projects.core.domain) + implementation(projects.feature.topic.api) + + implementation(libs.androidx.compose.material3.adaptive.navigation3) testImplementation(projects.core.testing) testImplementation(libs.robolectric) androidTestImplementation(libs.bundles.androidx.compose.ui.test) androidTestImplementation(projects.core.testing) -} +} \ No newline at end of file diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicScreen.kt similarity index 97% rename from feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt rename to feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicScreen.kt index 8ef0d786d6..cac3355e05 100644 --- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicScreen.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.topic +package com.google.samples.apps.nowinandroid.feature.topic.impl import androidx.annotation.VisibleForTesting import androidx.compose.foundation.gestures.Orientation @@ -50,7 +50,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground @@ -64,11 +64,12 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews +import com.google.samples.apps.nowinandroid.core.ui.R import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems -import com.google.samples.apps.nowinandroid.feature.topic.R.string +import com.google.samples.apps.nowinandroid.feature.topic.api.R.string @Composable fun TopicScreen( @@ -124,7 +125,7 @@ internal fun TopicScreen( TopicUiState.Loading -> item { NiaLoadingWheel( modifier = modifier, - contentDesc = stringResource(id = string.feature_topic_loading), + contentDesc = stringResource(id = string.feature_topic_api_loading), ) } @@ -292,7 +293,7 @@ private fun TopicToolbar( Icon( imageVector = NiaIcons.ArrowBack, contentDescription = stringResource( - id = com.google.samples.apps.nowinandroid.core.ui.R.string.core_ui_back, + id = R.string.core_ui_back, ), ) } diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicViewModel.kt similarity index 98% rename from feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt rename to feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicViewModel.kt index 8865da463f..8f780f0254 100644 --- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt +++ b/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicViewModel.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.topic +package com.google.samples.apps.nowinandroid.feature.topic.impl import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope diff --git a/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/navigation/TopicEntryProvider.kt b/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/navigation/TopicEntryProvider.kt new file mode 100644 index 0000000000..4584fe93d2 --- /dev/null +++ b/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/navigation/TopicEntryProvider.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2025 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.topic.impl.navigation + +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.navigation3.runtime.EntryProviderBuilder +import androidx.navigation3.runtime.entry +import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey +import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.TopicRoute +import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic +import com.google.samples.apps.nowinandroid.feature.topic.impl.TopicScreen +import com.google.samples.apps.nowinandroid.feature.topic.impl.TopicViewModel +import com.google.samples.apps.nowinandroid.feature.topic.impl.TopicViewModel.Factory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(ActivityComponent::class) +object TopicEntryProvider { + + @OptIn(ExperimentalMaterial3AdaptiveApi::class) + @Provides + @IntoSet + fun provideTopicEntryProviderBuilder( + backStack: NiaBackStack, + ): EntryProviderBuilder.() -> Unit = { + entry( + metadata = ListDetailSceneStrategy.detailPane(), + ) { key -> + val id = key.id + TopicScreen( + showBackButton = true, + onBackClick = backStack::popLast, + onTopicClick = backStack::navigateToTopic, + viewModel = hiltViewModel( + key = id, + ) { factory -> + factory.create(id) + }, + ) + } + } +} diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt deleted file mode 100644 index 69059c81d9..0000000000 --- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.feature.topic.navigation - -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptionsBuilder -import androidx.navigation.compose.composable -import androidx.navigation.toRoute -import com.google.samples.apps.nowinandroid.feature.topic.TopicScreen -import com.google.samples.apps.nowinandroid.feature.topic.TopicViewModel -import kotlinx.serialization.Serializable - -@Serializable data class TopicRoute(val id: String) - -fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) { - navigate(route = TopicRoute(topicId)) { - navOptions() - } -} - -fun NavGraphBuilder.topicScreen( - showBackButton: Boolean, - onBackClick: () -> Unit, - onTopicClick: (String) -> Unit, -) { - composable { entry -> - val id = entry.toRoute().id - TopicScreen( - showBackButton = showBackButton, - onBackClick = onBackClick, - onTopicClick = onTopicClick, - viewModel = hiltViewModel( - key = id, - ) { factory -> - factory.create(id) - }, - ) - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 81f12c480b..cdace15167 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,26 +2,30 @@ accompanist = "0.37.0" androidDesugarJdkLibs = "2.1.4" # AGP and tools should be updated together -androidGradlePlugin = "8.9.0" -androidTools = "31.9.0" +androidGradlePlugin = "8.9.3" +androidTools = "31.9.3" androidxActivity = "1.9.3" androidxAppCompat = "1.7.0" androidxBrowser = "1.8.0" androidxComposeBom = "2025.02.00" androidxComposeFoundation = "1.8.0-alpha07" androidxComposeMaterial3Adaptive = "1.1.0-rc01" +androidxComposeMaterial3AdaptiveNavigation3 = "1.0.0-SNAPSHOT" androidxComposeRuntimeTracing = "1.7.6" androidxCore = "1.15.0" androidxCoreSplashscreen = "1.0.1" androidxDataStore = "1.1.1" androidxEspresso = "3.6.1" -androidxHiltNavigationCompose = "1.2.0" +androidxHiltLifecycleViewModelCompose = "1.3.0-alpha02" androidxLifecycle = "2.8.7" androidxLintGradle = "1.0.0-alpha03" +androidxLifecycleViewModelNavigation3 = "1.0.0-alpha03" androidxMacroBenchmark = "1.3.4" androidxMetrics = "1.0.0-beta01" androidxNavigation = "2.8.5" +androidxNavigation3 = "1.0.0-SNAPSHOT" androidxProfileinstaller = "1.4.1" +androidxSavedStateCompose = "1.3.1" androidxTestCore = "1.7.0-rc01" androidxTestExt = "1.3.0-rc01" androidxTestRules = "1.7.0-rc01" @@ -59,6 +63,7 @@ room = "2.7.2" secrets = "2.0.1" truth = "1.4.4" turbine = "1.2.0" +uiTestJunit4 = "1.9.0-rc01" [bundles] androidx-compose-ui-test = ["androidx-compose-ui-test", "androidx-compose-ui-testManifest"] @@ -79,6 +84,7 @@ androidx-compose-material3-navigationSuite = { group = "androidx.compose.materia androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "androidxComposeMaterial3Adaptive" } androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout", version.ref = "androidxComposeMaterial3Adaptive" } androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation", version.ref = "androidxComposeMaterial3Adaptive" } +androidx-compose-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3",version.ref="androidxComposeMaterial3AdaptiveNavigation3" } androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } androidx-compose-runtime-tracing = { group = "androidx.compose.runtime", name = "runtime-tracing", version.ref = "androidxComposeRuntimeTracing" } @@ -91,14 +97,18 @@ androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidxCoreSplashscreen" } androidx-dataStore = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" } androidx-dataStore-core = { group = "androidx.datastore", name = "datastore-core", version.ref = "androidxDataStore" } -androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } +androidx-hilt-lifecycle-viewModelCompose = { group = "androidx.hilt", name = "hilt-lifecycle-viewmodel-compose", version.ref = "androidxHiltLifecycleViewModelCompose" } androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-runtimeTesting = { group = "androidx.lifecycle", name = "lifecycle-runtime-testing", version.ref = "androidxLifecycle" } androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } +androidx-lifecycle-viewModel-testing = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-testing", version.ref = "androidxLifecycle" } +androidx-lifecycle-viewModel-navigation3 = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-navigation3", version.ref = "androidxLifecycleViewModelNavigation3" } androidx-lint-gradle = { group = "androidx.lint", name = "lint-gradle", version.ref = "androidxLintGradle" } androidx-metrics = { group = "androidx.metrics", name = "metrics-performance", version.ref = "androidxMetrics" } -androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "androidxNavigation" } +androidx-navigation3-runtime = { group = "androidx.navigation3", name = "navigation3-runtime", version.ref = "androidxNavigation3" } +androidx-navigation3-ui = { group = "androidx.navigation3", name = "navigation3-ui", version.ref = "androidxNavigation3" } +androidx-savedstate-compose = { group = "androidx.savedstate", name = "savedstate-compose", version.ref = "androidxSavedStateCompose" } androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "androidxProfileinstaller" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" } @@ -161,6 +171,7 @@ firebase-performance-gradlePlugin = { group = "com.google.firebase", name = "per kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } room-gradlePlugin = { group = "androidx.room", name = "room-gradle-plugin", version.ref = "room" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4", version.ref = "uiTestJunit4" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } @@ -189,7 +200,8 @@ nowinandroid-android-application-compose = { id = "nowinandroid.android.applicat nowinandroid-android-application-firebase = { id = "nowinandroid.android.application.firebase" } nowinandroid-android-application-flavors = { id = "nowinandroid.android.application.flavors" } nowinandroid-android-application-jacoco = { id = "nowinandroid.android.application.jacoco" } -nowinandroid-android-feature = { id = "nowinandroid.android.feature" } +nowinandroid-android-feature-impl = { id = "nowinandroid.android.feature.impl" } +nowinandroid-android-feature-api = { id = "nowinandroid.android.feature.api" } nowinandroid-android-library = { id = "nowinandroid.android.library" } nowinandroid-android-library-compose = { id = "nowinandroid.android.library.compose" } nowinandroid-android-library-jacoco = { id = "nowinandroid.android.library.jacoco" } diff --git a/lint/README.md b/lint/README.md new file mode 100644 index 0000000000..fc62e80f84 --- /dev/null +++ b/lint/README.md @@ -0,0 +1,3 @@ +# :lint module +## Dependency graph +![Dependency graph](../docs/images/graphs/dep_graph_lint.svg) diff --git a/settings.gradle.kts b/settings.gradle.kts index 2b8c6e45c6..f264a7b6f1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -40,6 +40,9 @@ dependencyResolutionManagement { } } mavenCentral() + maven { + url = uri("https://androidx.dev/snapshots/builds/13898898/artifacts/repository") + } } } rootProject.name = "nowinandroid" @@ -59,18 +62,24 @@ include(":core:datastore-test") include(":core:designsystem") include(":core:domain") include(":core:model") +include(":core:navigation") include(":core:network") include(":core:notifications") include(":core:screenshot-testing") include(":core:testing") include(":core:ui") -include(":feature:foryou") -include(":feature:interests") -include(":feature:bookmarks") -include(":feature:topic") -include(":feature:search") -include(":feature:settings") +include(":feature:foryou:api") +include(":feature:foryou:impl") +include(":feature:interests:api") +include(":feature:interests:impl") +include(":feature:bookmarks:api") +include(":feature:bookmarks:impl") +include(":feature:topic:api") +include(":feature:topic:impl") +include(":feature:search:api") +include(":feature:search:impl") +include(":feature:settings:api") include(":lint") include(":sync:work") include(":sync:sync-test") diff --git a/ui-test-hilt-manifest/README.md b/ui-test-hilt-manifest/README.md new file mode 100644 index 0000000000..c448e26d32 --- /dev/null +++ b/ui-test-hilt-manifest/README.md @@ -0,0 +1,3 @@ +# :ui-test-hilt-manifest module +## Dependency graph +![Dependency graph](../docs/images/graphs/dep_graph_ui_test_hilt_manifest.svg)