diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 055b87c10..819000247 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -86,7 +86,6 @@ dependencies { implementation(projects.core.data) implementation(projects.core.model) implementation(projects.core.analytics) - implementation(projects.core.navigation) implementation(projects.sync.work) implementation(libs.androidx.activity.compose) @@ -100,10 +99,8 @@ dependencies { 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.lifecycle.viewModel.navigation3) - implementation(libs.androidx.navigation.compose) 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 8d479e3b1..14454a360 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,11 +40,13 @@ 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 @@ -85,10 +88,10 @@ class NavigationTest { // The strings used for matching in these tests 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_interests) + 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 64539c4dd..71b50e799 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 @@ -41,9 +41,9 @@ 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.core.navigation.NiaBackStack -import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackKey import com.google.samples.apps.nowinandroid.ui.NiaApp import com.google.samples.apps.nowinandroid.ui.rememberNiaAppState import com.google.samples.apps.nowinandroid.util.isSystemInDarkTheme @@ -77,11 +77,10 @@ class MainActivity : ComponentActivity() { lateinit var userNewsResourceRepository: UserNewsResourceRepository private val viewModel: MainActivityViewModel by viewModels() - @Inject - lateinit var niaBackStack: NiaBackStack + private val backStackViewModel: NiaBackStackViewModel by viewModels() @Inject - lateinit var entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder.() -> Unit> + lateinit var entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder.() -> Unit> override fun onCreate(savedInstanceState: Bundle?) { val splashScreen = installSplashScreen() @@ -145,7 +144,7 @@ class MainActivity : ComponentActivity() { networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, timeZoneMonitor = timeZoneMonitor, - niaBackStack = niaBackStack, + niaBackStack = backStackViewModel.niaBackStack, ) val currentTimeZone by appState.currentTimeZone.collectAsStateWithLifecycle() @@ -161,7 +160,7 @@ class MainActivity : ComponentActivity() { ) { NiaApp( appState, - entryProviderBuilders + 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 index 174bd677f..998d60ce9 100644 --- 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 @@ -17,12 +17,16 @@ 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 javax.inject.Singleton 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) @@ -31,4 +35,19 @@ object BackStackProvider { @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 index 45f0e61b2..da4461a84 100644 --- 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 @@ -19,7 +19,6 @@ 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.compose.runtime.compositionLocalOf import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.EntryProviderBuilder import androidx.navigation3.runtime.entryProvider @@ -27,20 +26,20 @@ 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.NiaBackStackKey +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Composable fun NiaNavDisplay( niaBackStack: NiaBackStack, - entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder.() -> Unit>, + entryProviderBuilders: Set.() -> Unit>, ) { - val listDetailStrategy = rememberListDetailSceneStrategy() + val listDetailStrategy = rememberListDetailSceneStrategy() NavDisplay( backStack = niaBackStack.backStack, sceneStrategy = listDetailStrategy, - onBack = { niaBackStack.removeLast() }, + onBack = { count -> niaBackStack.popLast(count) }, entryDecorators = listOf( rememberSceneSetupNavEntryDecorator(), rememberSavedStateNavEntryDecorator(), @@ -52,4 +51,4 @@ fun NiaNavDisplay( } }, ) -} \ No newline at end of file +} 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 eb3e78b22..000000000 --- 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.api.navigation.bookmarksScreen -import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.ForYouRoute -import com.google.samples.apps.nowinandroid.feature.foryou.api.navigation.forYouSection -import com.google.samples.apps.nowinandroid.feature.interests.api.navigation.navigateToInterests -import com.google.samples.apps.nowinandroid.feature.search.api.navigation.searchScreen -import com.google.samples.apps.nowinandroid.feature.topic.api.navigation.navigateToTopic -import com.google.samples.apps.nowinandroid.feature.topic.api.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.feature.interests.impl.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 = ForYouRoute, - 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 0ee6008e7..ea5fdb345 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,12 +20,12 @@ 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.core.navigation.NiaBackStackKey +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.impl.R as bookmarksR +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 @@ -49,7 +49,7 @@ enum class TopLevelDestination( @StringRes val iconTextId: Int, @StringRes val titleTextId: Int, val route: KClass<*>, - val key: NiaBackStackKey + val key: NiaNavKey, ) { FOR_YOU( selectedIcon = NiaIcons.Upcoming, @@ -57,15 +57,15 @@ enum class TopLevelDestination( iconTextId = forYouR.string.feature_foryou_api_title, titleTextId = R.string.app_name, route = ForYouRoute::class, - key = ForYouRoute + key = ForYouRoute, ), BOOKMARKS( selectedIcon = NiaIcons.Bookmarks, unselectedIcon = NiaIcons.BookmarksBorder, - iconTextId = bookmarksR.string.feature_bookmarks_impl_title, - titleTextId = bookmarksR.string.feature_bookmarks_impl_title, + iconTextId = bookmarksR.string.feature_bookmarks_api_title, + titleTextId = bookmarksR.string.feature_bookmarks_api_title, route = BookmarksRoute::class, - key = BookmarksRoute + key = BookmarksRoute, ), INTERESTS( selectedIcon = NiaIcons.Grid3x3, @@ -73,7 +73,7 @@ enum class TopLevelDestination( iconTextId = searchR.string.feature_search_api_interests, titleTextId = searchR.string.feature_search_api_interests, route = InterestsRoute::class, - key = InterestsRoute(null) + key = InterestsRoute(null), ), } 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 78fe1e047..4f156ce38 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 @@ -59,9 +59,6 @@ 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.data.repository.NewsResourceQuery @@ -80,6 +77,7 @@ 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.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackKey +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 @@ -95,7 +93,7 @@ import com.google.samples.apps.nowinandroid.feature.settings.api.R as settingsR @Composable fun NiaApp( appState: NiaAppState, - entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder.() -> Unit>, + entryProviderBuilders: Set.() -> Unit>, modifier: Modifier = Modifier, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo(), ) { @@ -146,7 +144,7 @@ fun NiaApp( ) internal fun NiaApp( appState: NiaAppState, - entryProviderBuilders: Set<@JvmSuppressWildcards EntryProviderBuilder.() -> Unit>, + entryProviderBuilders: Set.() -> Unit>, showSettingsDialog: Boolean, onSettingsDismissed: () -> Unit, onTopAppBarActionClick: () -> Unit, @@ -157,7 +155,6 @@ internal fun NiaApp( .collectAsStateWithLifecycle() val currentTopLevelKey = appState.currentTopLevelDestination!!.key - if (showSettingsDialog) { SettingsDialog( onDismiss = { onSettingsDismissed() }, @@ -173,7 +170,7 @@ internal fun NiaApp( val selected = destination.key == currentTopLevelKey item( selected = selected, - onClick = { appState.navigateToTopLevelDestination(destination) }, + onClick = { appState.niaBackStack.navigate(destination.key) }, icon = { Icon( imageVector = destination.unselectedIcon, @@ -258,10 +255,6 @@ internal fun NiaApp( }, ), ) { -// NiaNavHost( -// appState = appState, -// onShowSnackbar = onShowSnackbar, -// ) NiaNavDisplay( niaBackStack = appState.niaBackStack, entryProviderBuilders, 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 91a0bddfb..7e3d8eb68 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,23 +18,17 @@ package com.google.samples.apps.nowinandroid.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable -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.NavHostController -import androidx.navigation.compose.rememberNavController +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.ui.TrackDisposableJank -import com.google.samples.apps.nowinandroid.feature.search.api.navigation.navigateToSearch import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack +import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank 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 @@ -51,12 +45,10 @@ fun rememberNiaAppState( timeZoneMonitor: TimeZoneMonitor, niaBackStack: NiaBackStack, coroutineScope: CoroutineScope = rememberCoroutineScope(), - navController: NavHostController = rememberNavController(), ): NiaAppState { - NavigationTrackingSideEffect(navController) + NavigationTrackingSideEffect(niaBackStack) return remember( niaBackStack, - navController, coroutineScope, networkMonitor, userNewsResourceRepository, @@ -64,7 +56,6 @@ fun rememberNiaAppState( ) { NiaAppState( niaBackStack = niaBackStack, - navController = navController, coroutineScope = coroutineScope, networkMonitor = networkMonitor, userNewsResourceRepository = userNewsResourceRepository, @@ -76,28 +67,11 @@ fun rememberNiaAppState( @Stable class NiaAppState( val niaBackStack: NiaBackStack, - val navController: NavHostController, 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() = TopLevelDestinations[niaBackStack.currentTopLevelKey] @@ -138,58 +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, - ) { - niaBackStack.navigateToTopLevelDestination(topLevelDestination.key) -// 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 { } } -} \ No newline at end of file +} 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 9c9488fde..e463fb439 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 e64a133d5..2c67c1dc1 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.niaBackStack.currentKey + 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 78f568e03..b20a8e5a6 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 b9b1047c1..bc538d494 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 000000000..a92b5a387 --- /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 000000000..ca82be153 --- /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 index 657ad5422..5387bbe5e 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD # `:benchmarks` ## Module dependency graph @@ -132,3 +133,8 @@ classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000; +======= +# :benchmarks module +## Dependency graph +![Dependency graph](../docs/images/graphs/dep_graph_benchmarks.svg) +>>>>>>> a059e426 (Update readme and build dependency graph) diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 166b54907..8000fbadf 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 a8b1b1779..0a33b719f 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/AndroidFeatureApiConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureApiConventionPlugin.kt new file mode 100644 index 000000000..969cf96d4 --- /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 83% rename from build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt rename to build-logic/convention/src/main/kotlin/AndroidFeatureImplConventionPlugin.kt index 2a20cdbf1..500e3e983 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,15 +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 63a992b05..18cd2bd7d 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/com/google/samples/apps/nowinandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt index 3d050d86b..709a711c2 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/core/navigation/README.md b/core/navigation/README.md new file mode 100644 index 000000000..7cd3b7e9f --- /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 index e2c42a274..abc59d239 100644 --- a/core/navigation/build.gradle.kts +++ b/core/navigation/build.gradle.kts @@ -1,8 +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.jvm.library) + 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 000000000..7007d1abf --- /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 index 0b0962206..82a7bb1c9 100644 --- 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 @@ -21,63 +21,107 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList -import javax.inject.Inject -import kotlin.collections.remove +import org.jetbrains.annotations.VisibleForTesting +import kotlin.collections.mutableListOf -class NiaBackStack @Inject constructor( - startKey: NiaBackStackKey, +// 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, ) { - val backStack = mutableStateListOf(startKey) + internal var backStackMap: LinkedHashMap> = + linkedMapOf( + startKey to mutableListOf(startKey), + ) - // Maintain a stack for each top level route - private var topLevelStacks : LinkedHashMap> = linkedMapOf( - startKey to mutableStateListOf(startKey) - ) + @VisibleForTesting + val backStack: SnapshotStateList = mutableStateListOf(startKey) - // Expose the current top level route for consumers - var currentTopLevelKey by mutableStateOf(startKey) + var currentTopLevelKey: NiaNavKey by mutableStateOf(backStackMap.keys.last()) private set - internal val currentKey: NiaBackStackKey - get() = topLevelStacks[currentTopLevelKey]!!.last() + @get:VisibleForTesting + val currentKey: NiaNavKey + get() = backStackMap[currentTopLevelKey]!!.last() - private fun updateBackStack() = - backStack.apply { - clear() - addAll(topLevelStacks.flatMap { it.value }) + 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 navigateToTopLevelDestination(key: NiaBackStackKey){ - // If the top level doesn't exist, add it - if (topLevelStacks[key] == null){ - topLevelStacks.put(key, mutableStateListOf(key)) - } else { - // Otherwise just move it to the end of the stacks - topLevelStacks.apply { - remove(key)?.let { - put(key, it) + 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-- } - - currentTopLevelKey = key updateBackStack() } - fun navigate(key: NiaBackStackKey){ - if (backStack.lastOrNull() != key) { - topLevelStacks[currentTopLevelKey]?.add(key) - updateBackStack() + private fun updateBackStack() { + backStack.apply { + clear() + backStack.addAll( + backStackMap.flatMap { it.value }, + ) } + + currentTopLevelKey = backStackMap.keys.last() } - fun removeLast(){ - val removedKey = topLevelStacks[currentTopLevelKey]?.removeLastOrNull() - // If the removed key was a top level key, remove the associated top level stack - topLevelStacks.remove(removedKey) - currentTopLevelKey = topLevelStacks.keys.last() + internal fun restore(map: LinkedHashMap>?) { + map ?: return + backStackMap.clear() + backStackMap.putAll(map) updateBackStack() } } -interface NiaBackStackKey \ No newline at end of file +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 000000000..097b79967 --- /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 000000000..2a87024f9 --- /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 new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_app_nia_catalog.svg b/docs/images/graphs/dep_graph_app_nia_catalog.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_analytics.svg b/docs/images/graphs/dep_graph_core_analytics.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_common.svg b/docs/images/graphs/dep_graph_core_common.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_data.svg b/docs/images/graphs/dep_graph_core_data.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_data_test.svg b/docs/images/graphs/dep_graph_core_data_test.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_database.svg b/docs/images/graphs/dep_graph_core_database.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_datastore.svg b/docs/images/graphs/dep_graph_core_datastore.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_datastore_proto.svg b/docs/images/graphs/dep_graph_core_datastore_proto.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_datastore_test.svg b/docs/images/graphs/dep_graph_core_datastore_test.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_designsystem.svg b/docs/images/graphs/dep_graph_core_designsystem.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_domain.svg b/docs/images/graphs/dep_graph_core_domain.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_model.svg b/docs/images/graphs/dep_graph_core_model.svg new file mode 100644 index 000000000..e69de29bb 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 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_network.svg b/docs/images/graphs/dep_graph_core_network.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_notifications.svg b/docs/images/graphs/dep_graph_core_notifications.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_screenshot_testing.svg b/docs/images/graphs/dep_graph_core_screenshot_testing.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_testing.svg b/docs/images/graphs/dep_graph_core_testing.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_core_ui.svg b/docs/images/graphs/dep_graph_core_ui.svg new file mode 100644 index 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_lint.svg b/docs/images/graphs/dep_graph_lint.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_sync_sync_test.svg b/docs/images/graphs/dep_graph_sync_sync_test.svg new file mode 100644 index 000000000..e69de29bb diff --git a/docs/images/graphs/dep_graph_sync_work.svg b/docs/images/graphs/dep_graph_sync_work.svg new file mode 100644 index 000000000..e69de29bb 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 000000000..e69de29bb diff --git a/feature/bookmarks/api/README.md b/feature/bookmarks/api/README.md new file mode 100644 index 000000000..7ee6e7b86 --- /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 index 2771838ef..a51468615 100644 --- a/feature/bookmarks/api/build.gradle.kts +++ b/feature/bookmarks/api/build.gradle.kts @@ -15,13 +15,9 @@ */ plugins { - alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.feature.api) } android { namespace = "com.google.samples.apps.nowinandroid.feature.bookmarks.api" -} - -dependencies { - api(projects.core.navigation) -} +} \ No newline at end of file diff --git a/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/navigation/BookmarksNavigation.kt b/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/navigation/BookmarksRoute.kt similarity index 53% rename from feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/navigation/BookmarksNavigation.kt rename to feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/navigation/BookmarksRoute.kt index eeac9c13f..e3955176b 100644 --- a/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/navigation/BookmarksNavigation.kt +++ b/feature/bookmarks/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/api/navigation/BookmarksRoute.kt @@ -16,25 +16,11 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks.api.navigation -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptions -import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackKey +import com.google.samples.apps.nowinandroid.core.navigation.NiaNavKey import kotlinx.serialization.Serializable -@Serializable object BookmarksRoute: NiaBackStackKey - -fun NavController.navigateToBookmarks(navOptions: NavOptions) = - navigate(route = BookmarksRoute, navOptions) - -fun NavGraphBuilder.bookmarksScreen( - onTopicClick: (String) -> Unit, - onShowSnackbar: suspend (String, String?) -> Boolean, -) { -// composable { -// BookmarksRoute( -// onTopicClick, -// onShowSnackbar -// ) -// } +@Serializable +object BookmarksRoute : NiaNavKey { + override val isTopLevel: Boolean + get() = true } diff --git a/feature/bookmarks/impl/src/main/res/drawable/feature_bookmarks_impl_mg_empty_bookmarks.xml b/feature/bookmarks/api/src/main/res/drawable/feature_bookmarks_api_mg_empty_bookmarks.xml similarity index 100% rename from feature/bookmarks/impl/src/main/res/drawable/feature_bookmarks_impl_mg_empty_bookmarks.xml rename to feature/bookmarks/api/src/main/res/drawable/feature_bookmarks_api_mg_empty_bookmarks.xml diff --git a/feature/bookmarks/impl/src/main/res/values/strings.xml b/feature/bookmarks/api/src/main/res/values/strings.xml similarity index 59% rename from feature/bookmarks/impl/src/main/res/values/strings.xml rename to feature/bookmarks/api/src/main/res/values/strings.xml index 6aa996a1a..98f4b4a8d 100644 --- a/feature/bookmarks/impl/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/bookmarks/impl/README.md b/feature/bookmarks/impl/README.md new file mode 100644 index 000000000..f7cc92060 --- /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/impl/build.gradle.kts b/feature/bookmarks/impl/build.gradle.kts index 738daac67..e8162afff 100644 --- a/feature/bookmarks/impl/build.gradle.kts +++ b/feature/bookmarks/impl/build.gradle.kts @@ -15,7 +15,7 @@ */ plugins { - alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.feature.impl) alias(libs.plugins.nowinandroid.android.library.compose) } diff --git a/feature/bookmarks/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreenTest.kt b/feature/bookmarks/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreenTest.kt index 84a527cd5..0b73a7918 100644 --- a/feature/bookmarks/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreenTest.kt +++ b/feature/bookmarks/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreenTest.kt @@ -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_impl_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_impl_empty_error), + composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_error), ) .assertExists() composeTestRule .onNodeWithText( - composeTestRule.activity.getString(R.string.feature_bookmarks_impl_empty_description), + composeTestRule.activity.getString(R.string.feature_bookmarks_api_empty_description), ) .assertExists() } diff --git a/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreen.kt b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreen.kt index 23fc65552..65bc4acf2 100644 --- a/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreen.kt +++ b/feature/bookmarks/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/bookmarks/impl/BookmarksScreen.kt @@ -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,6 +74,7 @@ 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 BookmarksScreen( @@ -112,8 +113,8 @@ internal fun BookmarksScreen( undoBookmarkRemoval: () -> Unit = {}, clearUndoState: () -> Unit = {}, ) { - val bookmarkRemovedMessage = stringResource(id = R.string.feature_bookmarks_impl_removed) - val undoText = stringResource(id = R.string.feature_bookmarks_impl_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_impl_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_impl_mg_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_impl_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_impl_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/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 index 2df50cbb5..cff561bf4 100644 --- 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 @@ -23,7 +23,7 @@ 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.NiaBackStackKey +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 @@ -35,13 +35,13 @@ import dagger.multibindings.IntoSet @Module @InstallIn(ActivityComponent::class) -object BookmarksModule { +object BookmarksEntryProvider { @Provides @IntoSet fun provideBookmarksEntryProviderBuilder( backStack: NiaBackStack, - ): EntryProviderBuilder.() -> Unit = { + ): EntryProviderBuilder.() -> Unit = { entry { val snackbarHostState = LocalSnackbarHostState.current BookmarksScreen( @@ -52,7 +52,7 @@ object BookmarksModule { actionLabel = action, duration = Short, ) == ActionPerformed - } + }, ) } } 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 000000000..b26bb646c --- /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/foryou/api/README.md b/feature/foryou/api/README.md new file mode 100644 index 000000000..2d3154ba2 --- /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 index d8634bcdc..dabdc49b3 100644 --- a/feature/foryou/api/build.gradle.kts +++ b/feature/foryou/api/build.gradle.kts @@ -15,7 +15,7 @@ */ plugins { - alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.feature.api) } android { diff --git a/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/navigation/ForYouNavigation.kt b/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/navigation/ForYouNavigation.kt deleted file mode 100644 index fa8c9f6d5..000000000 --- a/feature/foryou/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/api/navigation/ForYouNavigation.kt +++ /dev/null @@ -1,59 +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.api.navigation - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptions -import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackKey -import kotlinx.serialization.Serializable - -@Serializable data object ForYouRoute: NiaBackStackKey // route to ForYou screen - -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 -// }, -// ), -// ) { -// com.google.samples.apps.nowinandroid.feature.foryou.impl.ForYouScreen(onTopicClick) -// } -// topicDestination() -// } -} 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 000000000..2467649a4 --- /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/impl/README.md b/feature/foryou/impl/README.md new file mode 100644 index 000000000..32d4e0845 --- /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/impl/build.gradle.kts b/feature/foryou/impl/build.gradle.kts index 4abc2c7ac..5a91a1944 100644 --- a/feature/foryou/impl/build.gradle.kts +++ b/feature/foryou/impl/build.gradle.kts @@ -15,7 +15,7 @@ */ plugins { - alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.feature.impl) alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.roborazzi) } @@ -26,11 +26,11 @@ android { 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) diff --git a/feature/foryou/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenTest.kt b/feature/foryou/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenTest.kt index 74378b699..fc4fc0241 100644 --- a/feature/foryou/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenTest.kt +++ b/feature/foryou/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenTest.kt @@ -154,12 +154,12 @@ class ForYouScreenTest { ForYouScreen( isSyncing = false, onboardingUiState = - OnboardingUiState.Shown( - // Follow one topic - topics = followableTopicTestData.mapIndexed { index, testTopic -> - testTopic.copy(isFollowed = index == 1) - }, - ), + OnboardingUiState.Shown( + // Follow one topic + topics = followableTopicTestData.mapIndexed { index, testTopic -> + testTopic.copy(isFollowed = index == 1) + }, + ), feedState = NewsFeedUiState.Success( feed = emptyList(), ), @@ -201,9 +201,9 @@ class ForYouScreenTest { ForYouScreen( isSyncing = false, onboardingUiState = - OnboardingUiState.Shown( - topics = followableTopicTestData - ), + OnboardingUiState.Shown( + topics = followableTopicTestData, + ), feedState = NewsFeedUiState.Loading, deepLinkedUserNewsResource = null, onTopicCheckedChanged = { _, _ -> }, diff --git a/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreen.kt b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreen.kt index 6d5ebe032..f113cda3e 100644 --- a/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreen.kt +++ b/feature/foryou/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreen.kt @@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.foryou.impl 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 @@ -79,7 +80,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 @@ -105,7 +106,8 @@ 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? = if (LocalInspectionMode.current) null else hiltViewModel(), 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 index de3c6bf53..8259afa31 100644 --- 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 @@ -19,7 +19,7 @@ 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.NiaBackStackKey +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 @@ -31,17 +31,20 @@ import dagger.multibindings.IntoSet @Module @InstallIn(ActivityComponent::class) -object ForYouModule { - +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 = { + ): EntryProviderBuilder.() -> Unit = { entry { ForYouScreen( - onTopicClick = backStack::navigateToTopic + onTopicClick = backStack::navigateToTopic, ) } } -} \ No newline at end of file +} 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 000000000..15c010fb3 --- /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/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenScreenshotTests.kt b/feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenScreenshotTests.kt index d0d73860e..ac4287625 100644 --- a/feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenScreenshotTests.kt +++ b/feature/foryou/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/foryou/impl/ForYouScreenScreenshotTests.kt @@ -50,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 { diff --git a/feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_foldable.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_foldable.png new file mode 100644 index 000000000..538c9032c Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_foldable.png differ diff --git a/feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_phone.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_phone.png new file mode 100644 index 000000000..afd7dd708 Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_phone.png differ diff --git a/feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_tablet.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_tablet.png new file mode 100644 index 000000000..1c197e8f0 Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenLoading_tablet.png differ 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 000000000..e4ecf6d95 Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png differ diff --git a/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png new file mode 100644 index 000000000..a86a8232f Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png differ diff --git a/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone_dark.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone_dark.png new file mode 100644 index 000000000..d500394ef Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone_dark.png differ 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 000000000..53891bdc1 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 000000000..ba669e8f8 Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png differ diff --git a/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png new file mode 100644 index 000000000..1498f9b7b Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png differ 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 000000000..e92ae0481 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 000000000..3ae1bee94 Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_foldable.png differ diff --git a/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_phone.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_phone.png new file mode 100644 index 000000000..0938678b2 Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_phone.png differ diff --git a/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png b/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png new file mode 100644 index 000000000..084d031a5 Binary files /dev/null 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 000000000..35f59e54c Binary files /dev/null and b/feature/foryou/impl/src/test/screenshots/ForYouScreenTopicSelection_tablet.png differ diff --git a/feature/interests/api/README.md b/feature/interests/api/README.md new file mode 100644 index 000000000..95a625c89 --- /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 index 28a2f964f..7a2dfd65e 100644 --- a/feature/interests/api/build.gradle.kts +++ b/feature/interests/api/build.gradle.kts @@ -15,13 +15,9 @@ */ plugins { - alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.feature.api) } android { namespace = "com.google.samples.apps.nowinandroid.feature.interests.api" -} - -dependencies { - api(projects.core.navigation) } \ No newline at end of file diff --git a/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/navigation/InterestsNavigation.kt b/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/navigation/InterestsRoute.kt similarity index 59% rename from feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/navigation/InterestsNavigation.kt rename to feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/navigation/InterestsRoute.kt index e2c7e9059..ce48a1df6 100644 --- a/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/navigation/InterestsNavigation.kt +++ b/feature/interests/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/api/navigation/InterestsRoute.kt @@ -16,26 +16,14 @@ 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.NiaBackStack -import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackKey +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, -): NiaBackStackKey - -fun NavController.navigateToInterests( - initialTopicId: String? = null, - navOptions: NavOptions? = null, -) { - navigate(route = InterestsRoute(initialTopicId), navOptions) +) : NiaNavKey { + override val isTopLevel: Boolean + get() = true } - -fun NiaBackStack.navigateToInterests( - initialTopicId: String? = null, -) { - navigate(InterestsRoute(initialTopicId)) -} \ No newline at end of file diff --git a/feature/topic/api/src/main/res/drawable/feature_topic_api_ic_topic_placeholder.xml b/feature/interests/api/src/main/res/drawable/feature_interests_api_ic_detail_placeholder.xml similarity index 97% rename from feature/topic/api/src/main/res/drawable/feature_topic_api_ic_topic_placeholder.xml rename to feature/interests/api/src/main/res/drawable/feature_interests_api_ic_detail_placeholder.xml index 39a10c38f..2789b54e6 100644 --- a/feature/topic/api/src/main/res/drawable/feature_topic_api_ic_topic_placeholder.xml +++ b/feature/interests/api/src/main/res/drawable/feature_interests_api_ic_detail_placeholder.xml @@ -6,14 +6,14 @@ 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 + http://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. - --> +--> Interests Loading data "No available data" + Select an Interest diff --git a/feature/interests/impl/README.md b/feature/interests/impl/README.md new file mode 100644 index 000000000..722f4ef41 --- /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 index a5e6e3a06..d1a682e27 100644 --- a/feature/interests/impl/build.gradle.kts +++ b/feature/interests/impl/build.gradle.kts @@ -15,7 +15,7 @@ */ 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) } @@ -24,7 +24,6 @@ android { } dependencies { - implementation(projects.core.data) implementation(projects.core.domain) implementation(projects.feature.topic.api) implementation(projects.feature.interests.api) @@ -35,11 +34,14 @@ dependencies { 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/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/Interests2PaneViewModel.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/Interests2PaneViewModel.kt deleted file mode 100644 index 6be41d555..000000000 --- a/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/Interests2PaneViewModel.kt +++ /dev/null @@ -1,43 +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.interests.impl - -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.navigation.toRoute -import com.google.samples.apps.nowinandroid.feature.interests.api.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/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/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/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicDetailPlaceholder.kt rename to feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsDetailPlaceholder.kt index 2519fa78a..2a03019d3 100644 --- a/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/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.api +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_api_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_api_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/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsListDetailScreen.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsListDetailScreen.kt deleted file mode 100644 index 77b1c1357..000000000 --- a/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsListDetailScreen.kt +++ /dev/null @@ -1,243 +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.feature.interests.impl - -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.api.navigation.InterestsRoute -import com.google.samples.apps.nowinandroid.feature.topic.api.TopicDetailPlaceholder -import com.google.samples.apps.nowinandroid.feature.topic.api.TopicScreen -import com.google.samples.apps.nowinandroid.feature.topic.api.TopicViewModel -import com.google.samples.apps.nowinandroid.feature.topic.api.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, - ) - } - }, - ) { - InterestsScreen( - 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/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreen.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreen.kt index 225f8a7a9..000c1f429 100644 --- a/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreen.kt +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsScreen.kt @@ -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 @@ -39,8 +38,8 @@ import com.google.samples.apps.nowinandroid.feature.interests.api.R fun InterestsScreen( onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, + viewModel: InterestsViewModel, shouldHighlightSelectedTopic: Boolean = false, - viewModel: InterestsViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() diff --git a/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt index 2fe015c03..8f30fbe95 100644 --- a/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsViewModel.kt @@ -19,34 +19,35 @@ 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.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/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/TabContent.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/TabContent.kt index 5d86e8de7..d8a09c8f0 100644 --- a/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/TabContent.kt +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/TabContent.kt @@ -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/InterestsEntryProvider.kt b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/navigation/InterestsEntryProvider.kt similarity index 70% rename from feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsEntryProvider.kt rename to feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/navigation/InterestsEntryProvider.kt index f4973a3e0..68c0a5981 100644 --- a/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/InterestsEntryProvider.kt +++ b/feature/interests/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/interests/impl/navigation/InterestsEntryProvider.kt @@ -14,16 +14,19 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.interests.impl +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.NiaBackStackKey +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.topic.api.TopicDetailPlaceholder +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 @@ -33,24 +36,27 @@ import dagger.multibindings.IntoSet @Module @InstallIn(ActivityComponent::class) -object InterestsModule { +object InterestsEntryProvider { @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Provides @IntoSet fun provideInterestsEntryProviderBuilder( - backStack: NiaBackStack - ): EntryProviderBuilder.() -> Unit = { + backStack: NiaBackStack, + ): EntryProviderBuilder.() -> Unit = { entry( metadata = ListDetailSceneStrategy.listPane { - TopicDetailPlaceholder() - } + InterestsDetailPlaceholder() + }, ) { key -> -// InterestsListDetailScreen() + val viewModel = hiltViewModel { + it.create(key) + } InterestsScreen( onTopicClick = backStack::navigateToTopic, shouldHighlightSelectedTopic = false, + viewModel = viewModel, ) } } -} \ No newline at end of file +} 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 000000000..cdc1234b8 --- /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 index 1f2b039b9..81ec9c549 100644 --- 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 @@ -14,10 +14,14 @@ * limitations under the License. */ +@file:OptIn(ExperimentalMaterial3AdaptiveApi::class) + package com.google.samples.apps.nowinandroid.interests.impl -import androidx.activity.compose.BackHandler +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 @@ -25,17 +29,35 @@ 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.feature.interests.impl.InterestsListDetailScreen +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 @@ -43,16 +65,16 @@ 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 -import kotlin.test.assertTrue -import com.google.samples.apps.nowinandroid.feature.topic.api.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) +@Config(application = HiltTestApplication::class, sdk = [35]) class InterestsListDetailScreenTest { @get:Rule(order = 0) @@ -61,6 +83,13 @@ class InterestsListDetailScreenTest { @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 @@ -70,15 +99,20 @@ class InterestsListDetailScreenTest { } // The strings used for matching in these tests. - private val placeholderText by composeTestRule.stringResource(FeatureTopicR.string.feature_topic_api_select_an_interest) - private val listPaneTag = "interests:topics" + 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 @@ -87,11 +121,16 @@ class InterestsListDetailScreenTest { composeTestRule.apply { setContent { NiaTheme { - InterestsListDetailScreen() + NavDisplay( + backStack = listOf(InterestsRoute()), + sceneStrategy = rememberListDetailSceneStrategy(), + entryProvider = entryProvider { + entryProviderBuilders.forEach { it() } + }, + ) } } - - onNodeWithTag(listPaneTag).assertIsDisplayed() + onNodeWithTag(LIST_PANE_TEST_TAG).assertIsDisplayed() onNodeWithText(placeholderText).assertIsDisplayed() } } @@ -102,11 +141,17 @@ class InterestsListDetailScreenTest { composeTestRule.apply { setContent { NiaTheme { - InterestsListDetailScreen() + NavDisplay( + backStack = listOf(InterestsRoute()), + sceneStrategy = rememberListDetailSceneStrategy(), + entryProvider = entryProvider { + entryProviderBuilders.forEach { it() } + }, + ) } } - onNodeWithTag(listPaneTag).assertIsDisplayed() + onNodeWithTag(LIST_PANE_TEST_TAG).assertIsDisplayed() onNodeWithText(placeholderText).assertIsNotDisplayed() } } @@ -116,15 +161,23 @@ class InterestsListDetailScreenTest { fun expandedWidth_topicSelected_updatesDetailPane() { composeTestRule.apply { setContent { + val backStackViewModel by composeTestRule.activity.viewModels() + val backStack = backStackViewModel.niaBackStack.backStack NiaTheme { - InterestsListDetailScreen() + NavDisplay( + backStack = backStack, + sceneStrategy = rememberListDetailSceneStrategy(), + entryProvider = entryProvider { + entryProviderBuilders.forEach { it() } + }, + ) } } - val firstTopic = getTopics().first() onNodeWithText(firstTopic.name).performClick() + waitForIdle() - onNodeWithTag(listPaneTag).assertIsDisplayed() + onNodeWithTag(LIST_PANE_TEST_TAG).assertIsDisplayed() onNodeWithText(placeholderText).assertIsNotDisplayed() onNodeWithTag(firstTopic.testTag).assertIsDisplayed() } @@ -135,53 +188,43 @@ class InterestsListDetailScreenTest { fun compactWidth_topicSelected_showsTopicDetailPane() { composeTestRule.apply { setContent { + val backStackViewModel by composeTestRule.activity.viewModels() + val backStack = backStackViewModel.niaBackStack.backStack NiaTheme { - InterestsListDetailScreen() + NavDisplay( + backStack = backStack, + sceneStrategy = rememberListDetailSceneStrategy(), + entryProvider = entryProvider { + entryProviderBuilders.forEach { it() } + }, + ) } } val firstTopic = getTopics().first() onNodeWithText(firstTopic.name).performClick() - onNodeWithTag(listPaneTag).assertIsNotDisplayed() + onNodeWithTag(LIST_PANE_TEST_TAG).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 { + val backStackViewModel by composeTestRule.activity.viewModels() + val backStack = backStackViewModel.niaBackStack.backStack NiaTheme { - InterestsListDetailScreen() + NavDisplay( + backStack = backStack, + sceneStrategy = rememberListDetailSceneStrategy(), + entryProvider = entryProvider { + entryProviderBuilders.forEach { it() } + }, + ) } } @@ -191,7 +234,7 @@ class InterestsListDetailScreenTest { waitForIdle() Espresso.pressBack() - onNodeWithTag(listPaneTag).assertIsDisplayed() + onNodeWithTag(LIST_PANE_TEST_TAG).assertIsDisplayed() onNodeWithText(placeholderText).assertIsNotDisplayed() onNodeWithTag(firstTopic.testTag).assertIsNotDisplayed() } @@ -202,3 +245,22 @@ 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/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsViewModelTest.kt b/feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsViewModelTest.kt index b9c06d38b..4998e86ca 100644 --- a/feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsViewModelTest.kt +++ b/feature/interests/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/interests/impl/InterestsViewModelTest.kt @@ -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/README.md b/feature/search/api/README.md new file mode 100644 index 000000000..88460cbe0 --- /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 index 8b0bcb138..d7ea6fc5f 100644 --- a/feature/search/api/build.gradle.kts +++ b/feature/search/api/build.gradle.kts @@ -15,7 +15,7 @@ */ plugins { - alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.feature.api) } android { @@ -23,13 +23,6 @@ android { } dependencies { - implementation(projects.core.data) implementation(projects.core.domain) - api(projects.core.navigation) - - testImplementation(projects.core.testing) - - androidTestImplementation(libs.bundles.androidx.compose.ui.test) - androidTestImplementation(projects.core.testing) } diff --git a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/navigation/SearchNavigation.kt b/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/navigation/SearchNavigation.kt deleted file mode 100644 index 24d6773db..000000000 --- a/feature/search/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/api/navigation/SearchNavigation.kt +++ /dev/null @@ -1,51 +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.api.navigation - -import androidx.navigation.NavController -import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavOptions -import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStack -import com.google.samples.apps.nowinandroid.core.navigation.NiaBackStackKey -import kotlinx.serialization.Serializable - -@Serializable data object SearchRoute - -@Serializable object SearchRouteNav3: NiaBackStackKey - -fun NiaBackStack.navigateToSearch() { - navigate(SearchRouteNav3) -} - -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 { -// com.google.samples.apps.nowinandroid.feature.search.impl.SearchRoute( -// onBackClick = onBackClick, -// onInterestsClick = onInterestsClick, -// onTopicClick = onTopicClick, -// ) -// } -} 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 000000000..e45c177b3 --- /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/impl/README.md b/feature/search/impl/README.md new file mode 100644 index 000000000..d1304a4d3 --- /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/impl/build.gradle.kts b/feature/search/impl/build.gradle.kts index 1b9499af5..8425b29f3 100644 --- a/feature/search/impl/build.gradle.kts +++ b/feature/search/impl/build.gradle.kts @@ -15,7 +15,7 @@ */ 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) } @@ -25,7 +25,6 @@ android { } dependencies { - implementation(projects.core.data) implementation(projects.core.domain) implementation(projects.feature.interests.api) implementation(projects.feature.search.api) diff --git a/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreen.kt b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreen.kt index 6121e452b..bb7164f2a 100644 --- a/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreen.kt +++ b/feature/search/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/search/impl/SearchScreen.kt @@ -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 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 index 880e6abe4..272e4c7ba 100644 --- 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 @@ -19,9 +19,9 @@ 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.NiaBackStackKey +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.SearchRouteNav3 +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 @@ -32,19 +32,19 @@ import dagger.multibindings.IntoSet @Module @InstallIn(ActivityComponent::class) -object SearchModule { +object SearchEntryProvider { @Provides @IntoSet fun provideSearchEntryProviderBuilder( backStack: NiaBackStack, - ): EntryProviderBuilder.() -> Unit = { - entry { key -> + ): EntryProviderBuilder.() -> Unit = { + entry { key -> SearchScreen( - onBackClick = backStack::removeLast, - onInterestsClick = { backStack.navigateToTopLevelDestination(InterestsRoute()) }, + onBackClick = backStack::popLast, + onInterestsClick = { backStack.navigate(InterestsRoute()) }, onTopicClick = backStack::navigateToTopic, ) } } -} \ No newline at end of file +} 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 000000000..eca89cb40 --- /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/settings/api/README.md b/feature/settings/api/README.md new file mode 100644 index 000000000..6b51d5dfd --- /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/api/build.gradle.kts b/feature/settings/api/build.gradle.kts index d29e024b2..16ef6cc08 100644 --- a/feature/settings/api/build.gradle.kts +++ b/feature/settings/api/build.gradle.kts @@ -15,7 +15,7 @@ */ 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) } diff --git a/feature/settings/api/src/main/AndroidManifest.xml b/feature/settings/api/src/main/AndroidManifest.xml index 00b4c35bd..1fd9557d1 100644 --- a/feature/settings/api/src/main/AndroidManifest.xml +++ b/feature/settings/api/src/main/AndroidManifest.xml @@ -1,19 +1,19 @@ + 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 + + http://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. +--> 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/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 000000000..396887d09 --- /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 000000000..6286efdbd --- /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/api/src/main/res/values/strings.xml b/feature/topic/api/src/main/res/values/strings.xml index 25f8a7126..1e3f376cf 100644 --- a/feature/topic/api/src/main/res/values/strings.xml +++ b/feature/topic/api/src/main/res/values/strings.xml @@ -16,5 +16,4 @@ --> Loading topic - Select an Interest diff --git a/feature/topic/impl/README.md b/feature/topic/impl/README.md new file mode 100644 index 000000000..eee690ec0 --- /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/topic/impl/build.gradle.kts b/feature/topic/impl/build.gradle.kts index a37c070a5..fdf37c32e 100644 --- a/feature/topic/impl/build.gradle.kts +++ b/feature/topic/impl/build.gradle.kts @@ -1,21 +1,21 @@ /* -* 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. -*/ + * 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) + alias(libs.plugins.nowinandroid.android.feature.impl) alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.library.jacoco) } diff --git a/feature/topic/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicScreenTest.kt b/feature/topic/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicScreenTest.kt similarity index 97% rename from feature/topic/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicScreenTest.kt rename to feature/topic/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicScreenTest.kt index 5c80f67c3..5f6782160 100644 --- a/feature/topic/api/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicScreenTest.kt +++ b/feature/topic/impl/src/androidTest/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicScreenTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.topic.api +package com.google.samples.apps.nowinandroid.feature.topic.impl import androidx.activity.ComponentActivity import androidx.compose.ui.test.hasScrollToNodeAction @@ -26,6 +26,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performScrollToNode 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.feature.topic.api.R import org.junit.Before import org.junit.Rule import org.junit.Test diff --git a/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicScreen.kt b/feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicScreen.kt similarity index 96% rename from feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicScreen.kt rename to feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicScreen.kt index 4f2d490c9..7c1624ce8 100644 --- a/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/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.api +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 as UiR 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.api.R.string +import com.google.samples.apps.nowinandroid.feature.topic.api.R as TopicR @Composable fun TopicScreen( @@ -124,7 +125,7 @@ internal fun TopicScreen( TopicUiState.Loading -> item { NiaLoadingWheel( modifier = modifier, - contentDesc = stringResource(id = string.feature_topic_api_loading), + contentDesc = stringResource(id = TopicR.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 = UiR.string.core_ui_back, ), ) } diff --git a/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/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/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicViewModel.kt rename to feature/topic/impl/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicViewModel.kt index 4a8b107a6..8f780f025 100644 --- a/feature/topic/api/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/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.api +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 index 4e224115b..4584fe93d 100644 --- 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 @@ -17,19 +17,17 @@ package com.google.samples.apps.nowinandroid.feature.topic.impl.navigation import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi -import androidx.compose.material3.adaptive.layout.AnimatedPaneScope import androidx.compose.material3.adaptive.navigation3.ListDetailSceneStrategy -import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.hilt.navigation.compose.hiltViewModel +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.NiaBackStackKey +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.api.TopicScreen -import com.google.samples.apps.nowinandroid.feature.topic.api.TopicViewModel -import com.google.samples.apps.nowinandroid.feature.topic.api.TopicViewModel.Factory +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 @@ -38,21 +36,21 @@ import dagger.multibindings.IntoSet @Module @InstallIn(ActivityComponent::class) -object TopicModule { +object TopicEntryProvider { @OptIn(ExperimentalMaterial3AdaptiveApi::class) @Provides @IntoSet fun provideTopicEntryProviderBuilder( backStack: NiaBackStack, - ): EntryProviderBuilder.() -> Unit = { + ): EntryProviderBuilder.() -> Unit = { entry( - metadata = ListDetailSceneStrategy.detailPane() + metadata = ListDetailSceneStrategy.detailPane(), ) { key -> val id = key.id TopicScreen( showBackButton = true, - onBackClick = backStack::removeLast, + onBackClick = backStack::popLast, onTopicClick = backStack::navigateToTopic, viewModel = hiltViewModel( key = id, @@ -62,4 +60,4 @@ object TopicModule { ) } } -} \ No newline at end of file +} diff --git a/feature/topic/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicViewModelTest.kt b/feature/topic/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicViewModelTest.kt similarity index 99% rename from feature/topic/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicViewModelTest.kt rename to feature/topic/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicViewModelTest.kt index 152e582d6..1bbf844c1 100644 --- a/feature/topic/api/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/api/TopicViewModelTest.kt +++ b/feature/topic/impl/src/test/kotlin/com/google/samples/apps/nowinandroid/feature/topic/impl/TopicViewModelTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.feature.topic.api +package com.google.samples.apps.nowinandroid.feature.topic.impl import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cbac90f0e..ef01c4fa1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,15 +16,16 @@ 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 = "2.10.0-alpha05" androidxMacroBenchmark = "1.4.1" androidxMetrics = "1.0.0-beta01" androidxNavigation = "2.8.5" -androidxNavigation3 = "1.0.0-alpha05" +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" @@ -60,6 +61,7 @@ roborazzi = "1.51.0" room = "2.8.3" 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"] @@ -93,17 +95,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" } @@ -165,6 +168,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" } @@ -192,7 +196,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 index 24d312242..7d4c89ac0 100644 --- a/lint/README.md +++ b/lint/README.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD # `:lint` ## Module dependency graph @@ -43,3 +44,8 @@ classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000; +======= +# :lint module +## Dependency graph +![Dependency graph](../docs/images/graphs/dep_graph_lint.svg) +>>>>>>> a059e426 (Update readme and build dependency graph) diff --git a/settings.gradle.kts b/settings.gradle.kts index c15c9e488..f264a7b6f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -41,7 +41,7 @@ dependencyResolutionManagement { } mavenCentral() maven { - url = uri("https://androidx.dev/snapshots/builds/13764502/artifacts/repository") + url = uri("https://androidx.dev/snapshots/builds/13898898/artifacts/repository") } } } diff --git a/ui-test-hilt-manifest/README.md b/ui-test-hilt-manifest/README.md index eb4e4b1f7..3a53ecbfb 100644 --- a/ui-test-hilt-manifest/README.md +++ b/ui-test-hilt-manifest/README.md @@ -1,3 +1,4 @@ +<<<<<<< HEAD # `:ui-test-hilt-manifest` ## Module dependency graph @@ -43,3 +44,8 @@ classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000; +======= +# :ui-test-hilt-manifest module +## Dependency graph +![Dependency graph](../docs/images/graphs/dep_graph_ui_test_hilt_manifest.svg) +>>>>>>> a059e426 (Update readme and build dependency graph)