From e8d5f4f26a7ea964c992229e2cce52d38f0fa6b8 Mon Sep 17 00:00:00 2001 From: Ben Sagmoe Date: Wed, 4 Mar 2026 12:40:04 -0500 Subject: [PATCH 1/4] Migrate Jetnews to Navigation 3 --- JetNews/app/build.gradle.kts | 6 + .../java/com/example/jetnews/JetnewsTests.kt | 2 +- .../java/com/example/jetnews/TestHelper.kt | 5 +- JetNews/app/src/main/AndroidManifest.xml | 2 + .../jetnews/deeplink/JetnewsDeeplinks.kt | 60 +++++ .../jetnews/deeplink/util/DeepLinkMatcher.kt | 94 ++++++++ .../jetnews/deeplink/util/DeepLinkPattern.kt | 145 ++++++++++++ .../jetnews/deeplink/util/DeepLinkRequest.kt | 42 ++++ .../jetnews/deeplink/util/KeyDecoder.kt | 84 +++++++ .../java/com/example/jetnews/ui/AppDrawer.kt | 11 +- .../java/com/example/jetnews/ui/JetnewsApp.kt | 56 +++-- .../example/jetnews/ui/JetnewsNavDisplay.kt | 128 +++++++++++ .../com/example/jetnews/ui/JetnewsNavGraph.kt | 82 ------- .../example/jetnews/ui/JetnewsNavigation.kt | 58 ----- .../com/example/jetnews/ui/MainActivity.kt | 30 ++- .../jetnews/ui/article/ArticleScreen.kt | 74 +++++- .../jetnews/ui/components/AppNavRail.kt | 12 +- .../com/example/jetnews/ui/home/HomeRoute.kt | 215 +++++------------- .../example/jetnews/ui/home/HomeScreens.kt | 193 ---------------- .../example/jetnews/ui/home/HomeViewModel.kt | 58 +---- .../jetnews/ui/interests/InterestsRoute.kt | 25 ++ .../jetnews/ui/navigation/DeepLinkKey.kt | 23 ++ .../jetnews/ui/navigation/ListDetailScene.kt | 132 +++++++++++ .../jetnews/ui/navigation/NavigationState.kt | 107 +++++++++ .../jetnews/ui/navigation/Navigator.kt | 54 +++++ .../com/example/jetnews/ui/post/PostRoute.kt | 125 ++++++++++ .../example/jetnews/ui/post/PostViewModel.kt | 89 ++++++++ .../jetnews/ui/utils/NewIntentEffect.kt | 47 ++++ JetNews/gradle/libs.versions.toml | 10 +- 29 files changed, 1376 insertions(+), 593 deletions(-) create mode 100644 JetNews/app/src/main/java/com/example/jetnews/deeplink/JetnewsDeeplinks.kt create mode 100644 JetNews/app/src/main/java/com/example/jetnews/deeplink/util/DeepLinkMatcher.kt create mode 100644 JetNews/app/src/main/java/com/example/jetnews/deeplink/util/DeepLinkPattern.kt create mode 100644 JetNews/app/src/main/java/com/example/jetnews/deeplink/util/DeepLinkRequest.kt create mode 100644 JetNews/app/src/main/java/com/example/jetnews/deeplink/util/KeyDecoder.kt create mode 100644 JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavDisplay.kt delete mode 100644 JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavGraph.kt delete mode 100644 JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavigation.kt create mode 100644 JetNews/app/src/main/java/com/example/jetnews/ui/navigation/DeepLinkKey.kt create mode 100644 JetNews/app/src/main/java/com/example/jetnews/ui/navigation/ListDetailScene.kt create mode 100644 JetNews/app/src/main/java/com/example/jetnews/ui/navigation/NavigationState.kt create mode 100644 JetNews/app/src/main/java/com/example/jetnews/ui/navigation/Navigator.kt create mode 100644 JetNews/app/src/main/java/com/example/jetnews/ui/post/PostRoute.kt create mode 100644 JetNews/app/src/main/java/com/example/jetnews/ui/post/PostViewModel.kt create mode 100644 JetNews/app/src/main/java/com/example/jetnews/ui/utils/NewIntentEffect.kt diff --git a/JetNews/app/build.gradle.kts b/JetNews/app/build.gradle.kts index 6fee8c50b0..872c846bb5 100644 --- a/JetNews/app/build.gradle.kts +++ b/JetNews/app/build.gradle.kts @@ -19,6 +19,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.serialization) alias(libs.plugins.compose) } @@ -92,10 +93,12 @@ dependencies { implementation(libs.kotlin.stdlib) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.serialization.core) implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.foundation.layout) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.adaptive) implementation(libs.androidx.compose.materialWindow) implementation(libs.androidx.compose.runtime.livedata) implementation(libs.androidx.compose.ui.tooling.preview) @@ -116,7 +119,10 @@ dependencies { implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.lifecycle.viewModelCompose) implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.navigation.compose) + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) implementation(libs.androidx.window) androidTestImplementation(libs.junit) diff --git a/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt b/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt index 4f17c84f41..115ca86b91 100644 --- a/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt +++ b/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt @@ -61,7 +61,7 @@ class JetnewsTests { println(composeTestRule.onRoot().printToString()) try { - composeTestRule.onAllNodes(hasText("3 min read", substring = true))[0].assertExists() + composeTestRule.onAllNodes(hasText("It provides fully static", substring = true))[0].assertExists() } catch (e: AssertionError) { println(composeTestRule.onRoot().printToString()) throw e diff --git a/JetNews/app/src/androidTest/java/com/example/jetnews/TestHelper.kt b/JetNews/app/src/androidTest/java/com/example/jetnews/TestHelper.kt index c2efae1037..32b6357240 100644 --- a/JetNews/app/src/androidTest/java/com/example/jetnews/TestHelper.kt +++ b/JetNews/app/src/androidTest/java/com/example/jetnews/TestHelper.kt @@ -17,9 +17,9 @@ package com.example.jetnews import android.content.Context -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.ui.test.junit4.ComposeContentTestRule import com.example.jetnews.ui.JetnewsApp +import com.example.jetnews.ui.home.HomeKey /** * Launches the app from a test context @@ -28,7 +28,8 @@ fun ComposeContentTestRule.launchJetNewsApp(context: Context) { setContent { JetnewsApp( appContainer = TestAppContainer(context), - widthSizeClass = WindowWidthSizeClass.Compact, + isOpenedByDeepLink = false, + initialBackStack = listOf(HomeKey), ) } } diff --git a/JetNews/app/src/main/AndroidManifest.xml b/JetNews/app/src/main/AndroidManifest.xml index 134af83d20..1cc1d2a7b0 100644 --- a/JetNews/app/src/main/AndroidManifest.xml +++ b/JetNews/app/src/main/AndroidManifest.xml @@ -27,6 +27,7 @@ @@ -35,6 +36,7 @@ + ? { + val deepLinkRequest = DeepLinkRequest(this) + + val deepLinkMatchResult = JetnewsDeepLinkPatterns.firstNotNullOfOrNull { + DeepLinkMatcher(deepLinkRequest, it).match() + } ?: return null + + val initialKey = KeyDecoder(deepLinkMatchResult.args).decodeSerializableValue(deepLinkMatchResult.serializer) + + return generateSequence(initialKey) { (it as? DeepLinkKey)?.parent } + .toList() + .asReversed() +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/deeplink/util/DeepLinkMatcher.kt b/JetNews/app/src/main/java/com/example/jetnews/deeplink/util/DeepLinkMatcher.kt new file mode 100644 index 0000000000..19a8b38d8b --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/deeplink/util/DeepLinkMatcher.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2026 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.example.jetnews.deeplink.util + +import android.util.Log +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.KSerializer + +internal class DeepLinkMatcher(val request: DeepLinkRequest, val deepLinkPattern: DeepLinkPattern) { + /** + * Match a [DeepLinkRequest] to a [DeepLinkPattern]. + * + * Returns a [DeepLinkMatchResult] if this matches the pattern, returns null otherwise + */ + fun match(): DeepLinkMatchResult? { + if (request.uri.scheme != deepLinkPattern.uriPattern.scheme) return null + if (!request.uri.authority.equals(deepLinkPattern.uriPattern.authority, ignoreCase = true)) return null + if (request.pathSegments.size != deepLinkPattern.pathSegments.size) return null + // exact match (url does not contain any arguments) + if (request.uri == deepLinkPattern.uriPattern) + return DeepLinkMatchResult(deepLinkPattern.serializer, mapOf()) + + val args = mutableMapOf() + // match the path + request.pathSegments + .asSequence() + // zip to compare the two objects side by side, order matters here so we + // need to make sure the compared segments are at the same position within the url + .zip(deepLinkPattern.pathSegments.asSequence()) + .forEach { it -> + // retrieve the two path segments to compare + val requestedSegment = it.first + val candidateSegment = it.second + // if the potential match expects a path arg for this segment, try to parse the + // requested segment into the expected type + if (candidateSegment.isParamArg) { + val parsedValue = try { + candidateSegment.typeParser.invoke(requestedSegment) + } catch (e: IllegalArgumentException) { + Log.e(TAG_LOG_ERROR, "Failed to parse path value:[$requestedSegment].", e) + return null + } + args[candidateSegment.stringValue] = parsedValue + } else if (requestedSegment != candidateSegment.stringValue) { + // if it's path arg is not the expected type, its not a match + return null + } + } + // match queries (if any) + request.queries.forEach { query -> + val name = query.key + // If the pattern does not define this query parameter, ignore it. + // This prevents a NullPointerException. + val queryStringParser = deepLinkPattern.queryValueParsers[name] ?: return@forEach + + val queryParsedValue = try { + queryStringParser.invoke(query.value) + } catch (e: IllegalArgumentException) { + Log.e(TAG_LOG_ERROR, "Failed to parse query name:[$name] value:[${query.value}].", e) + return null + } + args[name] = queryParsedValue + } + // provide the serializer of the matching key and map of arg names to parsed arg values + return DeepLinkMatchResult(deepLinkPattern.serializer, args) + } +} + +/** + * Created when a requested deeplink matches with a supported deeplink + * + * @param [T] the backstack key associated with the deeplink that matched with the requested deeplink + * @param serializer serializer for [T] + * @param args The map of argument name to argument value. The value is expected to have already + * been parsed from the raw url string back into its proper KType as declared in [T]. + * Includes arguments for all parts of the uri - path, query, etc. + * */ +internal data class DeepLinkMatchResult(val serializer: KSerializer, val args: Map) + +const val TAG_LOG_ERROR = "Nav3RecipesDeepLink" diff --git a/JetNews/app/src/main/java/com/example/jetnews/deeplink/util/DeepLinkPattern.kt b/JetNews/app/src/main/java/com/example/jetnews/deeplink/util/DeepLinkPattern.kt new file mode 100644 index 0000000000..372e44795f --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/deeplink/util/DeepLinkPattern.kt @@ -0,0 +1,145 @@ +/* + * Copyright 2026 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.example.jetnews.deeplink.util + +import android.net.Uri +import androidx.navigation3.runtime.NavKey +import java.io.Serializable +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.SerialKind +import kotlinx.serialization.encoding.CompositeDecoder + +/** + * Parse a supported deeplink and stores its metadata as a easily readable format + * + * The following notes applies specifically to this particular sample implementation: + * + * The supported deeplink is expected to be built from a serializable backstack key [T] that + * supports deeplink. This means that if this deeplink contains any arguments (path or query), + * the argument name must match any of [T] member field name. + * + * One [DeepLinkPattern] should be created for each supported deeplink. This means if [T] + * supports two deeplink patterns: + * ``` + * val deeplink1 = www.nav3recipes.com/home + * val deeplink2 = www.nav3recipes.com/profile/{userId} + * ``` + * Then two [DeepLinkPattern] should be created + * ``` + * val parsedDeeplink1 = DeepLinkPattern(T.serializer(), deeplink1) + * val parsedDeeplink2 = DeepLinkPattern(T.serializer(), deeplink2) + * ``` + * + * This implementation assumes a few things: + * 1. all path arguments are required/non-nullable - partial path matches will be considered a non-match + * 2. all query arguments are optional by way of nullable/has default value + * + * @param T the backstack key type that supports the deeplinking of [uriPattern] + * @param serializer the serializer of [T] + * @param uriPattern the supported deeplink's uri pattern, i.e. "abc.com/home/{pathArg}" + */ +class DeepLinkPattern(val serializer: KSerializer, val uriPattern: Uri) { + /** + * Help differentiate if a path segment is an argument or a static value + */ + private val regexPatternFillIn = Regex("\\{(.+?)\\}") + + // TODO make these lazy + /** + * parse the path into a list of [PathSegment] + * + * order matters here - path segments need to match in value and order when matching + * requested deeplink to supported deeplink + */ + val pathSegments: List = buildList { + uriPattern.pathSegments.forEach { segment -> + // first, check if it is a path arg + var result = regexPatternFillIn.find(segment) + if (result != null) { + // if so, extract the path arg name (the string value within the curly braces) + val argName = result.groups[1]!!.value + // from [T], read the primitive type of this argument to get the correct type parser + val elementIndex = serializer.descriptor.getElementIndex(argName) + if (elementIndex == CompositeDecoder.UNKNOWN_NAME) { + throw IllegalArgumentException( + "Path parameter '{$argName}' defined in the DeepLink $uriPattern does not exist in the Serializable class '${serializer.descriptor.serialName}'.", + ) + } + + val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex) + // finally, add the arg name and its respective type parser to the map + add(PathSegment(argName, true, getTypeParser(elementDescriptor.kind))) + } else { + // if its not a path arg, then its just a static string path segment + add(PathSegment(segment, false, getTypeParser(PrimitiveKind.STRING))) + } + } + } + + /** + * Parse supported queries into a map of queryParameterNames to [TypeParser] + * + * This will be used later on to parse a provided query value into the correct KType + */ + val queryValueParsers: Map = buildMap { + uriPattern.queryParameterNames.forEach { paramName -> + val elementIndex = serializer.descriptor.getElementIndex(paramName) + // Ignore static query parameters that are not in the Serializable class + if (elementIndex != CompositeDecoder.UNKNOWN_NAME) { + val elementDescriptor = serializer.descriptor.getElementDescriptor(elementIndex) + this[paramName] = getTypeParser(elementDescriptor.kind) + } + } + } + + /** + * Metadata about a supported path segment + */ + class PathSegment(val stringValue: String, val isParamArg: Boolean, val typeParser: TypeParser) +} + +/** + * Parses a String into a Serializable Primitive + */ +private typealias TypeParser = (String) -> Serializable + +private fun getTypeParser(kind: SerialKind): TypeParser { + return when (kind) { + PrimitiveKind.STRING -> Any::toString + + PrimitiveKind.INT -> String::toInt + + PrimitiveKind.BOOLEAN -> String::toBoolean + + PrimitiveKind.BYTE -> String::toByte + + PrimitiveKind.CHAR -> String::toCharArray + + PrimitiveKind.DOUBLE -> String::toDouble + + PrimitiveKind.FLOAT -> String::toFloat + + PrimitiveKind.LONG -> String::toLong + + PrimitiveKind.SHORT -> String::toShort + + else -> throw IllegalArgumentException( + "Unsupported argument type of SerialKind:$kind. The argument type must be a Primitive.", + ) + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/deeplink/util/DeepLinkRequest.kt b/JetNews/app/src/main/java/com/example/jetnews/deeplink/util/DeepLinkRequest.kt new file mode 100644 index 0000000000..aa43f56578 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/deeplink/util/DeepLinkRequest.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2026 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.example.jetnews.deeplink.util + +import android.net.Uri + +/** + * Parse the requested Uri and store it in a easily readable format + * + * @param uri the target deeplink uri to link to + */ +internal class DeepLinkRequest(val uri: Uri) { + /** + * A list of path segments + */ + val pathSegments: List = uri.pathSegments + + /** + * A map of query name to query value + */ + val queries = buildMap { + uri.queryParameterNames.forEach { argName -> + this[argName] = uri.getQueryParameter(argName)!! + } + } + + // TODO add parsing for other Uri components, i.e. fragments, mimeType, action +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/deeplink/util/KeyDecoder.kt b/JetNews/app/src/main/java/com/example/jetnews/deeplink/util/KeyDecoder.kt new file mode 100644 index 0000000000..728be8ebe0 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/deeplink/util/KeyDecoder.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2026 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.example.jetnews.deeplink.util + +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.AbstractDecoder +import kotlinx.serialization.encoding.CompositeDecoder +import kotlinx.serialization.modules.EmptySerializersModule +import kotlinx.serialization.modules.SerializersModule + +/** + * Decodes the list of arguments into a back stack key + * + * **IMPORTANT** This decoder assumes that all argument types are Primitives. + */ +@OptIn(ExperimentalSerializationApi::class) +internal class KeyDecoder(private val arguments: Map) : AbstractDecoder() { + + override val serializersModule: SerializersModule = EmptySerializersModule() + private var elementIndex: Int = -1 + private var elementName: String = "" + + /** + * Decodes the index of the next element to be decoded. Index represents a position of the + * current element in the [descriptor] that can be found with [descriptor].getElementIndex. + * + * The returned index will trigger deserializer to call [decodeValue] on the argument at that + * index. + * + * The decoder continually calls this method to process the next available argument until this + * method returns [CompositeDecoder.DECODE_DONE], which indicates that there are no more + * arguments to decode. + * + * This method should sequentially return the element index for every element that has its value + * available within [arguments]. + */ + override fun decodeElementIndex(descriptor: SerialDescriptor): Int { + var currentIndex = elementIndex + while (true) { + // proceed to next element + currentIndex++ + // if we have reached the end, let decoder know there are not more arguments to decode + if (currentIndex >= descriptor.elementsCount) return CompositeDecoder.DECODE_DONE + val currentName = descriptor.getElementName(currentIndex) + // Check if bundle has argument value. If so, we tell decoder to process + // currentIndex. Otherwise, we skip this index and proceed to next index. + if (arguments.contains(currentName)) { + elementIndex = currentIndex + elementName = currentName + return elementIndex + } + } + } + + /** + * Returns argument value from the [arguments] for the argument at the index returned by + * [decodeElementIndex] + */ + override fun decodeValue(): Any { + val arg = arguments[elementName] + checkNotNull(arg) { "Unexpected null value for non-nullable argument $elementName" } + return arg + } + + override fun decodeNull(): Nothing? = null + + // we want to know if it is not null, so its !isNull + override fun decodeNotNullMark(): Boolean = arguments[elementName] != null +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/AppDrawer.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/AppDrawer.kt index 9acf45b8f6..72f1fafc8e 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/AppDrawer.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/AppDrawer.kt @@ -36,13 +36,16 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavKey import com.example.jetnews.R +import com.example.jetnews.ui.home.HomeKey +import com.example.jetnews.ui.interests.InterestsKey import com.example.jetnews.ui.theme.JetnewsTheme @Composable fun AppDrawer( drawerState: DrawerState, - currentRoute: String, + currentRoute: NavKey, navigateToHome: () -> Unit, navigateToInterests: () -> Unit, closeDrawer: () -> Unit, @@ -58,7 +61,7 @@ fun AppDrawer( NavigationDrawerItem( label = { Text(stringResource(id = R.string.home_title)) }, icon = { Icon(painterResource(R.drawable.ic_home), null) }, - selected = currentRoute == JetnewsDestinations.HOME_ROUTE, + selected = currentRoute is HomeKey, onClick = { navigateToHome() closeDrawer() @@ -68,7 +71,7 @@ fun AppDrawer( NavigationDrawerItem( label = { Text(stringResource(id = R.string.interests_title)) }, icon = { Icon(painterResource(R.drawable.ic_list_alt), null) }, - selected = currentRoute == JetnewsDestinations.INTERESTS_ROUTE, + selected = currentRoute is InterestsKey, onClick = { navigateToInterests() closeDrawer() @@ -102,7 +105,7 @@ fun PreviewAppDrawer() { JetnewsTheme { AppDrawer( drawerState = rememberDrawerState(initialValue = DrawerValue.Open), - currentRoute = JetnewsDestinations.HOME_ROUTE, + currentRoute = HomeKey, navigateToHome = {}, navigateToInterests = {}, closeDrawer = { }, diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsApp.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsApp.kt index e0acc812e1..1ee650d3fa 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsApp.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsApp.kt @@ -20,43 +20,50 @@ import androidx.compose.foundation.layout.Row import androidx.compose.material3.DrawerState import androidx.compose.material3.DrawerValue import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.material3.rememberDrawerState -import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.rememberNavController +import androidx.navigation3.runtime.NavKey +import androidx.window.core.layout.WindowSizeClass import com.example.jetnews.data.AppContainer import com.example.jetnews.ui.components.AppNavRail +import com.example.jetnews.ui.home.HomeKey +import com.example.jetnews.ui.interests.InterestsKey +import com.example.jetnews.ui.navigation.Navigator +import com.example.jetnews.ui.navigation.rememberNavigationState import com.example.jetnews.ui.theme.JetnewsTheme import kotlinx.coroutines.launch @Composable -fun JetnewsApp(appContainer: AppContainer, widthSizeClass: WindowWidthSizeClass) { - JetnewsTheme { - val navController = rememberNavController() - val navigationActions = remember(navController) { - JetnewsNavigationActions(navController) - } +fun JetnewsApp(appContainer: AppContainer, isOpenedByDeepLink: Boolean, initialBackStack: List) { + + val navigationState = rememberNavigationState( + mainTopLevelRoute = HomeKey, + topLevelRoutes = setOf(HomeKey, InterestsKey), + initialBackStack = initialBackStack, + ) - val coroutineScope = rememberCoroutineScope() + val navigator = remember(navigationState) { Navigator(navigationState) } - val navBackStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = - navBackStackEntry?.destination?.route ?: JetnewsDestinations.HOME_ROUTE + val coroutineScope = rememberCoroutineScope() - val isExpandedScreen = widthSizeClass == WindowWidthSizeClass.Expanded + val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass + val isExpandedScreen = remember(windowSizeClass) { + windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) + } + + JetnewsTheme { val sizeAwareDrawerState = rememberSizeAwareDrawerState(isExpandedScreen) ModalNavigationDrawer( drawerContent = { AppDrawer( drawerState = sizeAwareDrawerState, - currentRoute = currentRoute, - navigateToHome = navigationActions.navigateToHome, - navigateToInterests = navigationActions.navigateToInterests, + currentRoute = navigationState.topLevelRoute, + navigateToHome = navigator::toHome, + navigateToInterests = navigator::toInterests, closeDrawer = { coroutineScope.launch { sizeAwareDrawerState.close() } }, ) }, @@ -67,15 +74,18 @@ fun JetnewsApp(appContainer: AppContainer, widthSizeClass: WindowWidthSizeClass) Row { if (isExpandedScreen) { AppNavRail( - currentRoute = currentRoute, - navigateToHome = navigationActions.navigateToHome, - navigateToInterests = navigationActions.navigateToInterests, + currentRoute = navigationState.topLevelRoute, + navigateToHome = navigator::toHome, + navigateToInterests = navigator::toInterests, ) } - JetnewsNavGraph( + JetnewsNavDisplay( + navigationState = navigationState, + navigator = navigator, appContainer = appContainer, + onBack = navigator::goBack, isExpandedScreen = isExpandedScreen, - navController = navController, + isOpenedByDeepLink = isOpenedByDeepLink, openDrawer = { coroutineScope.launch { sizeAwareDrawerState.open() } }, ) } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavDisplay.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavDisplay.kt new file mode 100644 index 0000000000..7c75f6ef2f --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavDisplay.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2026 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.example.jetnews.ui + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith +import androidx.compose.animation.unveilIn +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.graphics.TransformOrigin +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.scene.SceneInfo +import androidx.navigation3.scene.rememberSceneState +import androidx.navigation3.ui.NavDisplay +import androidx.navigationevent.NavigationEvent +import androidx.navigationevent.compose.NavigationBackHandler +import androidx.navigationevent.compose.rememberNavigationEventState +import com.example.jetnews.data.AppContainer +import com.example.jetnews.ui.home.homeEntry +import com.example.jetnews.ui.interests.interestsEntry +import com.example.jetnews.ui.navigation.NavigationState +import com.example.jetnews.ui.navigation.Navigator +import com.example.jetnews.ui.navigation.rememberListDetailSceneStrategy +import com.example.jetnews.ui.post.postEntry + +const val PIVOT_FRACTION_OFFSET = .2f + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun JetnewsNavDisplay( + navigationState: NavigationState, + navigator: Navigator, + appContainer: AppContainer, + onBack: () -> Unit, + isExpandedScreen: Boolean, + isOpenedByDeepLink: Boolean, + openDrawer: () -> Unit, +) { + // Because the entryProvider is used within a `remember` block during NavEntry decoration, + // using rememberUpdatedState allows the current value to be accessed even as window + // configuration changes. + val currentIsExpandedScreen by rememberUpdatedState(isExpandedScreen) + + val entryProvider = entryProvider { + homeEntry( + postsRepository = appContainer.postsRepository, + isExpandedScreen = { currentIsExpandedScreen }, + openDrawer = openDrawer, + navigateToPost = { navigator.toPost(it) }, + ) + postEntry( + postsRepository = appContainer.postsRepository, + isExpandedScreen = { currentIsExpandedScreen }, + onBack = navigator::goBack, + ) + interestsEntry( + interestsRepository = appContainer.interestsRepository, + isExpandedScreen = { currentIsExpandedScreen }, + openDrawer = openDrawer, + ) + } + + val navEntries = navigationState.toDecoratedEntries(entryProvider = entryProvider) + + val listDetailSceneStrategy = rememberListDetailSceneStrategy(isExpandedScreen) + + SharedTransitionLayout { + Surface { + val sceneState = rememberSceneState( + entries = navEntries, + sceneStrategies = listOf(listDetailSceneStrategy), + sharedTransitionScope = this, + onBack = onBack, + ) + + val scene = sceneState.currentScene + + val currentInfo = SceneInfo(scene) + val previousSceneInfos = sceneState.previousScenes.map { SceneInfo(it) } + val navigationEventState = rememberNavigationEventState( + currentInfo = currentInfo, + backInfo = previousSceneInfos, + ) + + NavigationBackHandler( + state = navigationEventState, + isBackEnabled = !isOpenedByDeepLink && scene.previousEntries.isNotEmpty(), + onBackCompleted = { + repeat(navEntries.size - scene.previousEntries.size) { onBack() } + }, + ) + + NavDisplay( + sceneState = sceneState, + navigationEventState = navigationEventState, + predictivePopTransitionSpec = { + unveilIn() togetherWith scaleOut( + targetScale = .8f, + transformOrigin = when (it) { + NavigationEvent.EDGE_LEFT -> TransformOrigin(1 - PIVOT_FRACTION_OFFSET, .5f) + NavigationEvent.EDGE_RIGHT -> TransformOrigin(PIVOT_FRACTION_OFFSET, .5f) + else -> TransformOrigin.Center + }, + ) + }, + ) + } + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavGraph.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavGraph.kt deleted file mode 100644 index 8e8202e592..0000000000 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavGraph.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright 2021 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.example.jetnews.ui - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.lifecycle.viewmodel.compose.viewModel -import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController -import androidx.navigation.navDeepLink -import com.example.jetnews.JetnewsApplication.Companion.JETNEWS_APP_URI -import com.example.jetnews.data.AppContainer -import com.example.jetnews.ui.home.HomeRoute -import com.example.jetnews.ui.home.HomeViewModel -import com.example.jetnews.ui.interests.InterestsRoute -import com.example.jetnews.ui.interests.InterestsViewModel - -const val POST_ID = "postId" - -@Composable -fun JetnewsNavGraph( - appContainer: AppContainer, - isExpandedScreen: Boolean, - modifier: Modifier = Modifier, - navController: NavHostController = rememberNavController(), - openDrawer: () -> Unit = {}, - startDestination: String = JetnewsDestinations.HOME_ROUTE, -) { - NavHost( - navController = navController, - startDestination = startDestination, - modifier = modifier, - ) { - composable( - route = JetnewsDestinations.HOME_ROUTE, - deepLinks = listOf( - navDeepLink { - uriPattern = - "$JETNEWS_APP_URI/${JetnewsDestinations.HOME_ROUTE}?$POST_ID={$POST_ID}" - }, - ), - ) { navBackStackEntry -> - val homeViewModel: HomeViewModel = viewModel( - factory = HomeViewModel.provideFactory( - postsRepository = appContainer.postsRepository, - preSelectedPostId = navBackStackEntry.arguments?.getString(POST_ID), - ), - ) - HomeRoute( - homeViewModel = homeViewModel, - isExpandedScreen = isExpandedScreen, - openDrawer = openDrawer, - ) - } - composable(JetnewsDestinations.INTERESTS_ROUTE) { - val interestsViewModel: InterestsViewModel = viewModel( - factory = InterestsViewModel.provideFactory(appContainer.interestsRepository), - ) - InterestsRoute( - interestsViewModel = interestsViewModel, - isExpandedScreen = isExpandedScreen, - openDrawer = openDrawer, - ) - } - } -} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavigation.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavigation.kt deleted file mode 100644 index 8dd1ee01d6..0000000000 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/JetnewsNavigation.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2021 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.example.jetnews.ui - -import androidx.navigation.NavGraph.Companion.findStartDestination -import androidx.navigation.NavHostController - -/** - * Destinations used in the [JetnewsApp]. - */ -object JetnewsDestinations { - const val HOME_ROUTE = "home" - const val INTERESTS_ROUTE = "interests" -} - -/** - * Models the navigation actions in the app. - */ -class JetnewsNavigationActions(navController: NavHostController) { - val navigateToHome: () -> Unit = { - navController.navigate(JetnewsDestinations.HOME_ROUTE) { - // 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 - } - } - val navigateToInterests: () -> Unit = { - navController.navigate(JetnewsDestinations.INTERESTS_ROUTE) { - popUpTo(navController.graph.findStartDestination().id) { - saveState = true - } - launchSingleTop = true - restoreState = true - } - } -} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/MainActivity.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/MainActivity.kt index bd6d2ac0bd..667b2674a3 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/MainActivity.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/MainActivity.kt @@ -20,21 +20,41 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi -import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import com.example.jetnews.JetnewsApplication +import com.example.jetnews.deeplink.handleDeepLink +import com.example.jetnews.ui.home.HomeKey +import com.example.jetnews.ui.navigation.rememberInitialBackStack +import com.example.jetnews.ui.utils.NewIntentEffect class MainActivity : ComponentActivity() { - @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) val appContainer = (application as JetnewsApplication).container setContent { - val widthSizeClass = calculateWindowSizeClass(this).widthSizeClass - JetnewsApp(appContainer, widthSizeClass) + val initialDeepLinkBackStack = remember { intent.data?.handleDeepLink() } + var isOpenedByDeepLink by rememberSaveable { mutableStateOf(initialDeepLinkBackStack != null) } + var initialBackStack by rememberInitialBackStack(initialDeepLinkBackStack ?: listOf(HomeKey)) + + NewIntentEffect { newIntent -> + newIntent.data?.handleDeepLink()?.let { + isOpenedByDeepLink = true + initialBackStack = it + } + } + + JetnewsApp( + appContainer, + isOpenedByDeepLink, + initialBackStack, + ) } } } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt index 4dbd4c8bc8..b709ef4fbc 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt @@ -19,14 +19,21 @@ package com.example.jetnews.ui.article import android.content.Context import android.content.Intent import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.BottomAppBar import androidx.compose.material3.CenterAlignedTopAppBar @@ -35,6 +42,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults @@ -45,6 +53,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -53,6 +62,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.example.jetnews.R import com.example.jetnews.data.Result @@ -92,8 +102,8 @@ fun ArticleScreen( FunctionalityNotAvailablePopup { showUnimplementedActionDialog = false } } - Row(modifier.fillMaxSize()) { - val context = LocalContext.current + val context = LocalContext.current + Box { ArticleScreenContent( post = post, // Allow opening the Drawer if the screen is not expanded @@ -116,13 +126,31 @@ fun ArticleScreen( FavoriteButton(onClick = { showUnimplementedActionDialog = true }) BookmarkButton(isBookmarked = isFavorite, onClick = onToggleFavorite) ShareButton(onClick = { sharePost(post, context) }) - TextSettingsButton(onClick = { showUnimplementedActionDialog = true }) + TextSettingsButton( + onClick = { + showUnimplementedActionDialog = true + }, + ) }, ) } }, lazyListState = lazyListState, + showTopAppBar = !isExpandedScreen, ) + + if (isExpandedScreen) { + // Floating toolbar + PostTopBar( + isFavorite = isFavorite, + onToggleFavorite = onToggleFavorite, + onSharePost = { sharePost(post, context) }, + modifier = Modifier + .windowInsetsPadding(WindowInsets.safeDrawing) + .fillMaxWidth() + .wrapContentWidth(Alignment.End), + ) + } } } @@ -140,24 +168,29 @@ private fun ArticleScreenContent( navigationIconContent: @Composable () -> Unit = { }, bottomBarContent: @Composable () -> Unit = { }, lazyListState: LazyListState = rememberLazyListState(), + showTopAppBar: Boolean = true, ) { val topAppBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(topAppBarState) Scaffold( topBar = { - TopAppBar( - title = post.publication?.name.orEmpty(), - navigationIconContent = navigationIconContent, - scrollBehavior = scrollBehavior, - ) + if (showTopAppBar) { + TopAppBar( + title = post.publication?.name.orEmpty(), + navigationIconContent = navigationIconContent, + scrollBehavior = scrollBehavior, + ) + } }, bottomBar = bottomBarContent, ) { innerPadding -> PostContent( post = post, contentPadding = innerPadding, - modifier = Modifier - .nestedScroll(scrollBehavior.nestedScrollConnection), + modifier = if (showTopAppBar) { + Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + } else Modifier, state = lazyListState, ) } @@ -194,6 +227,25 @@ private fun TopAppBar( ) } +/** + * Top bar for a Post when displayed next to the Home feed + */ +@Composable +private fun PostTopBar(isFavorite: Boolean, onToggleFavorite: () -> Unit, onSharePost: () -> Unit, modifier: Modifier = Modifier) { + Surface( + shape = RoundedCornerShape(8.dp), + border = BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.onSurface.copy(alpha = .6f)), + modifier = modifier.padding(end = 16.dp), + ) { + Row(Modifier.padding(horizontal = 8.dp)) { + FavoriteButton(onClick = { /* Functionality not available */ }) + BookmarkButton(isBookmarked = isFavorite, onClick = onToggleFavorite) + ShareButton(onClick = onSharePost) + TextSettingsButton(onClick = { /* Functionality not available */ }) + } + } +} + /** * Display a popup explaining functionality not available. * diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/components/AppNavRail.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/components/AppNavRail.kt index b6d48e74f2..428845d6c7 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/components/AppNavRail.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/components/AppNavRail.kt @@ -30,12 +30,14 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavKey import com.example.jetnews.R -import com.example.jetnews.ui.JetnewsDestinations +import com.example.jetnews.ui.home.HomeKey +import com.example.jetnews.ui.interests.InterestsKey import com.example.jetnews.ui.theme.JetnewsTheme @Composable -fun AppNavRail(currentRoute: String, navigateToHome: () -> Unit, navigateToInterests: () -> Unit, modifier: Modifier = Modifier) { +fun AppNavRail(currentRoute: NavKey, navigateToHome: () -> Unit, navigateToInterests: () -> Unit, modifier: Modifier = Modifier) { NavigationRail( header = { Icon( @@ -49,14 +51,14 @@ fun AppNavRail(currentRoute: String, navigateToHome: () -> Unit, navigateToInter ) { Spacer(Modifier.weight(1f)) NavigationRailItem( - selected = currentRoute == JetnewsDestinations.HOME_ROUTE, + selected = currentRoute is HomeKey, onClick = navigateToHome, icon = { Icon(painterResource(id = R.drawable.ic_home), stringResource(R.string.home_title)) }, label = { Text(stringResource(R.string.home_title)) }, alwaysShowLabel = false, ) NavigationRailItem( - selected = currentRoute == JetnewsDestinations.INTERESTS_ROUTE, + selected = currentRoute is InterestsKey, onClick = navigateToInterests, icon = { Icon(painterResource(id = R.drawable.ic_list_alt), stringResource(R.string.interests_title)) }, label = { Text(stringResource(R.string.interests_title)) }, @@ -72,7 +74,7 @@ fun AppNavRail(currentRoute: String, navigateToHome: () -> Unit, navigateToInter fun PreviewAppNavRail() { JetnewsTheme { AppNavRail( - currentRoute = JetnewsDestinations.HOME_ROUTE, + currentRoute = HomeKey, navigateToHome = {}, navigateToInterests = {}, ) diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeRoute.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeRoute.kt index 47da7d4029..6512f39df8 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeRoute.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeRoute.kt @@ -16,19 +16,66 @@ package com.example.jetnews.ui.home -import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.example.jetnews.ui.article.ArticleScreen -import com.example.jetnews.ui.home.HomeScreenType.ArticleDetails -import com.example.jetnews.ui.home.HomeScreenType.Feed -import com.example.jetnews.ui.home.HomeScreenType.FeedWithArticleDetails +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.jetnews.data.posts.PostsRepository +import com.example.jetnews.ui.navigation.ListDetailScene +import kotlinx.serialization.Serializable + +@Serializable +data object HomeKey : NavKey + +fun EntryProviderScope.homeEntry( + postsRepository: PostsRepository, + isExpandedScreen: () -> Boolean, + openDrawer: () -> Unit, + navigateToPost: (String) -> Unit, +) { + entry( + metadata = ListDetailScene.list( + ListDetailScene.ListConfiguration( + modifier = Modifier.width(334.dp), + detailPlaceholder = { + Surface(modifier = Modifier.fillMaxSize()) { + Box(contentAlignment = Alignment.Center) { + Text( + "Select an article", + style = MaterialTheme.typography.labelLarge, + ) + } + } + }, + ), + ), + ) { + val homeViewModel: HomeViewModel = + viewModel(factory = HomeViewModel.provideFactory(postsRepository)) + + HomeRoute( + homeViewModel = homeViewModel, + isExpandedScreen = isExpandedScreen(), + openDrawer = openDrawer, + navigateToPost = navigateToPost, + ) + } +} /** * Displays the Home route. @@ -45,165 +92,23 @@ fun HomeRoute( homeViewModel: HomeViewModel, isExpandedScreen: Boolean, openDrawer: () -> Unit, + navigateToPost: (String) -> Unit, snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { // UiState of the HomeScreen val uiState by homeViewModel.uiState.collectAsStateWithLifecycle() - HomeRoute( + HomeFeedScreen( uiState = uiState, - isExpandedScreen = isExpandedScreen, + showTopAppBar = !isExpandedScreen, onToggleFavorite = { homeViewModel.toggleFavourite(it) }, - onSelectPost = { homeViewModel.selectArticle(it) }, + onSelectPost = { navigateToPost(it) }, onRefreshPosts = { homeViewModel.refreshPosts() }, onErrorDismiss = { homeViewModel.errorShown(it) }, - onInteractWithFeed = { homeViewModel.interactedWithFeed() }, - onInteractWithArticleDetails = { homeViewModel.interactedWithArticleDetails(it) }, - onSearchInputChanged = { homeViewModel.onSearchInputChanged(it) }, openDrawer = openDrawer, + homeListLazyListState = rememberLazyListState(), snackbarHostState = snackbarHostState, + onSearchInputChanged = { homeViewModel.onSearchInputChanged(it) }, + searchInput = uiState.searchInput, ) } - -/** - * Displays the Home route. - * - * This composable is not coupled to any specific state management. - * - * @param uiState (state) the data to show on the screen - * @param isExpandedScreen (state) whether the screen is expanded - * @param onToggleFavorite (event) toggles favorite for a post - * @param onSelectPost (event) indicate that a post was selected - * @param onRefreshPosts (event) request a refresh of posts - * @param onErrorDismiss (event) error message was shown - * @param onInteractWithFeed (event) indicate that the feed was interacted with - * @param onInteractWithArticleDetails (event) indicate that the article details were interacted - * with - * @param openDrawer (event) request opening the app drawer - * @param snackbarHostState (state) state for the [Scaffold] component on this screen - */ -@Composable -fun HomeRoute( - uiState: HomeUiState, - isExpandedScreen: Boolean, - onToggleFavorite: (String) -> Unit, - onSelectPost: (String) -> Unit, - onRefreshPosts: () -> Unit, - onErrorDismiss: (Long) -> Unit, - onInteractWithFeed: () -> Unit, - onInteractWithArticleDetails: (String) -> Unit, - onSearchInputChanged: (String) -> Unit, - openDrawer: () -> Unit, - snackbarHostState: SnackbarHostState, -) { - // Construct the lazy list states for the list and the details outside of deciding which one to - // show. This allows the associated state to survive beyond that decision, and therefore - // we get to preserve the scroll throughout any changes to the content. - val homeListLazyListState = rememberLazyListState() - val articleDetailLazyListStates = when (uiState) { - is HomeUiState.HasPosts -> uiState.postsFeed.allPosts - is HomeUiState.NoPosts -> emptyList() - }.associate { post -> - key(post.id) { - post.id to rememberLazyListState() - } - } - - val homeScreenType = getHomeScreenType(isExpandedScreen, uiState) - when (homeScreenType) { - HomeScreenType.FeedWithArticleDetails -> { - HomeFeedWithArticleDetailsScreen( - uiState = uiState, - showTopAppBar = !isExpandedScreen, - onToggleFavorite = onToggleFavorite, - onSelectPost = onSelectPost, - onRefreshPosts = onRefreshPosts, - onErrorDismiss = onErrorDismiss, - onInteractWithList = onInteractWithFeed, - onInteractWithDetail = onInteractWithArticleDetails, - openDrawer = openDrawer, - homeListLazyListState = homeListLazyListState, - articleDetailLazyListStates = articleDetailLazyListStates, - snackbarHostState = snackbarHostState, - onSearchInputChanged = onSearchInputChanged, - ) - } - - HomeScreenType.Feed -> { - HomeFeedScreen( - uiState = uiState, - showTopAppBar = !isExpandedScreen, - onToggleFavorite = onToggleFavorite, - onSelectPost = onSelectPost, - onRefreshPosts = onRefreshPosts, - onErrorDismiss = onErrorDismiss, - openDrawer = openDrawer, - homeListLazyListState = homeListLazyListState, - snackbarHostState = snackbarHostState, - onSearchInputChanged = onSearchInputChanged, - ) - } - - HomeScreenType.ArticleDetails -> { - // Guaranteed by above condition for home screen type - check(uiState is HomeUiState.HasPosts) - - ArticleScreen( - post = uiState.selectedPost, - isExpandedScreen = isExpandedScreen, - onBack = onInteractWithFeed, - isFavorite = uiState.favorites.contains(uiState.selectedPost.id), - onToggleFavorite = { - onToggleFavorite(uiState.selectedPost.id) - }, - lazyListState = articleDetailLazyListStates.getValue( - uiState.selectedPost.id, - ), - ) - - // If we are just showing the detail, have a back press switch to the list. - // This doesn't take anything more than notifying that we "interacted with the list" - // since that is what drives the display of the feed - BackHandler { - onInteractWithFeed() - } - } - } -} - -/** - * A precise enumeration of which type of screen to display at the home route. - * - * There are 3 options: - * - [FeedWithArticleDetails], which displays both a list of all articles and a specific article. - * - [Feed], which displays just the list of all articles - * - [ArticleDetails], which displays just a specific article. - */ -private enum class HomeScreenType { - FeedWithArticleDetails, - Feed, - ArticleDetails, -} - -/** - * Returns the current [HomeScreenType] to display, based on whether or not the screen is expanded - * and the [HomeUiState]. - */ -@Composable -private fun getHomeScreenType(isExpandedScreen: Boolean, uiState: HomeUiState): HomeScreenType = when (isExpandedScreen) { - false -> { - when (uiState) { - is HomeUiState.HasPosts -> { - if (uiState.isArticleOpen) { - HomeScreenType.ArticleDetails - } else { - HomeScreenType.Feed - } - } - - is HomeUiState.NoPosts -> HomeScreenType.Feed - } - } - - true -> HomeScreenType.FeedWithArticleDetails -} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreens.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreens.kt index a46da2d96f..bd2ca43367 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreens.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreens.kt @@ -19,8 +19,6 @@ package com.example.jetnews.ui.home import android.content.Context import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.widget.Toast -import androidx.compose.animation.Crossfade -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -31,21 +29,15 @@ import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.CenterAlignedTopAppBar @@ -59,7 +51,6 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults @@ -71,20 +62,15 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -95,131 +81,17 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.example.jetnews.R import com.example.jetnews.data.Result import com.example.jetnews.data.posts.impl.BlockingFakePostsRepository import com.example.jetnews.model.Post import com.example.jetnews.model.PostsFeed -import com.example.jetnews.ui.article.postContentItems -import com.example.jetnews.ui.article.sharePost import com.example.jetnews.ui.components.JetnewsSnackbarHost import com.example.jetnews.ui.modifiers.interceptKey import com.example.jetnews.ui.theme.JetnewsTheme -import com.example.jetnews.ui.utils.BookmarkButton -import com.example.jetnews.ui.utils.FavoriteButton -import com.example.jetnews.ui.utils.ShareButton -import com.example.jetnews.ui.utils.TextSettingsButton -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.isActive import kotlinx.coroutines.runBlocking -/** - * The home screen displaying the feed along with an article details. - */ -@Composable -fun HomeFeedWithArticleDetailsScreen( - uiState: HomeUiState, - showTopAppBar: Boolean, - onToggleFavorite: (String) -> Unit, - onSelectPost: (String) -> Unit, - onRefreshPosts: () -> Unit, - onErrorDismiss: (Long) -> Unit, - onInteractWithList: () -> Unit, - onInteractWithDetail: (String) -> Unit, - openDrawer: () -> Unit, - homeListLazyListState: LazyListState, - articleDetailLazyListStates: Map, - snackbarHostState: SnackbarHostState, - modifier: Modifier = Modifier, - onSearchInputChanged: (String) -> Unit, -) { - HomeScreenWithList( - uiState = uiState, - showTopAppBar = showTopAppBar, - onRefreshPosts = onRefreshPosts, - onErrorDismiss = onErrorDismiss, - openDrawer = openDrawer, - snackbarHostState = snackbarHostState, - modifier = modifier, - ) { hasPostsUiState, contentPadding, contentModifier -> - Row(contentModifier) { - PostList( - postsFeed = hasPostsUiState.postsFeed, - favorites = hasPostsUiState.favorites, - showExpandedSearch = !showTopAppBar, - onArticleTapped = onSelectPost, - onToggleFavorite = onToggleFavorite, - contentPadding = contentPadding, - modifier = Modifier - .width(334.dp) - .notifyInput(onInteractWithList), - state = homeListLazyListState, - searchInput = hasPostsUiState.searchInput, - onSearchInputChanged = onSearchInputChanged, - ) - // Crossfade between different detail posts - Crossfade( - targetState = hasPostsUiState.selectedPost, - label = "Detail Post Crossfade", - ) { detailPost -> - // Get the lazy list state for this detail view - val detailLazyListState by remember { - derivedStateOf { - articleDetailLazyListStates.getValue(detailPost.id) - } - } - - // Key against the post id to avoid sharing any state between different posts - key(detailPost.id) { - Box { - LazyColumn( - state = detailLazyListState, - contentPadding = contentPadding, - modifier = Modifier - .padding(horizontal = 16.dp) - .fillMaxSize() - .notifyInput { - onInteractWithDetail(detailPost.id) - }, - ) { - postContentItems(detailPost) - } - - // Floating toolbar - val context = LocalContext.current - PostTopBar( - isFavorite = hasPostsUiState.favorites.contains(detailPost.id), - onToggleFavorite = { onToggleFavorite(detailPost.id) }, - onSharePost = { sharePost(detailPost, context) }, - modifier = Modifier - .windowInsetsPadding(WindowInsets.safeDrawing) - .fillMaxWidth() - .wrapContentWidth(Alignment.End), - ) - } - } - } - } - } -} - -/** - * A [Modifier] that tracks all input, and calls [block] every time input is received. - */ -private fun Modifier.notifyInput(block: () -> Unit): Modifier = composed { - val blockState = rememberUpdatedState(block) - pointerInput(Unit) { - while (currentCoroutineContext().isActive) { - awaitPointerEventScope { - awaitPointerEvent(PointerEventPass.Initial) - blockState.value() - } - } - } -} - /** * The home screen displaying just the article feed. */ @@ -267,9 +139,6 @@ fun HomeFeedScreen( * * This sets up the scaffold with the top app bar, and surrounds the [hasPostsContent] with refresh, * loading and error handling. - * - * This helper functions exists because [HomeFeedWithArticleDetailsScreen] and [HomeFeedScreen] are - * extremely similar, except for the rendered content when there are posts to display. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -644,25 +513,6 @@ private fun submitSearch(onSearchInputChanged: (String) -> Unit, context: Contex ).show() } -/** - * Top bar for a Post when displayed next to the Home feed - */ -@Composable -private fun PostTopBar(isFavorite: Boolean, onToggleFavorite: () -> Unit, onSharePost: () -> Unit, modifier: Modifier = Modifier) { - Surface( - shape = RoundedCornerShape(8.dp), - border = BorderStroke(Dp.Hairline, MaterialTheme.colorScheme.onSurface.copy(alpha = .6f)), - modifier = modifier.padding(end = 16.dp), - ) { - Row(Modifier.padding(horizontal = 8.dp)) { - FavoriteButton(onClick = { /* Functionality not available */ }) - BookmarkButton(isBookmarked = isFavorite, onClick = onToggleFavorite) - ShareButton(onClick = onSharePost) - TextSettingsButton(onClick = { /* Functionality not available */ }) - } - } -} - /** * TopAppBar for the Home screen */ @@ -726,8 +576,6 @@ fun PreviewHomeListDrawerScreen() { HomeFeedScreen( uiState = HomeUiState.HasPosts( postsFeed = postsFeed, - selectedPost = postsFeed.highlightedPost, - isArticleOpen = false, favorites = emptySet(), isLoading = false, errorMessages = emptyList(), @@ -762,40 +610,6 @@ fun PreviewHomeListNavRailScreen() { HomeFeedScreen( uiState = HomeUiState.HasPosts( postsFeed = postsFeed, - selectedPost = postsFeed.highlightedPost, - isArticleOpen = false, - favorites = emptySet(), - isLoading = false, - errorMessages = emptyList(), - searchInput = "", - ), - showTopAppBar = true, - onToggleFavorite = {}, - onSelectPost = {}, - onRefreshPosts = {}, - onErrorDismiss = {}, - openDrawer = {}, - homeListLazyListState = rememberLazyListState(), - snackbarHostState = SnackbarHostState(), - onSearchInputChanged = {}, - ) - } -} - -@Preview("Home list detail screen", device = Devices.PIXEL_C) -@Preview("Home list detail screen (dark)", uiMode = UI_MODE_NIGHT_YES, device = Devices.PIXEL_C) -@Preview("Home list detail screen (big font)", fontScale = 1.5f, device = Devices.PIXEL_C) -@Composable -fun PreviewHomeListDetailScreen() { - val postsFeed = runBlocking { - (BlockingFakePostsRepository().getPostsFeed() as Result.Success).data - } - JetnewsTheme { - HomeFeedWithArticleDetailsScreen( - uiState = HomeUiState.HasPosts( - postsFeed = postsFeed, - selectedPost = postsFeed.highlightedPost, - isArticleOpen = false, favorites = emptySet(), isLoading = false, errorMessages = emptyList(), @@ -806,15 +620,8 @@ fun PreviewHomeListDetailScreen() { onSelectPost = {}, onRefreshPosts = {}, onErrorDismiss = {}, - onInteractWithList = {}, - onInteractWithDetail = {}, openDrawer = {}, homeListLazyListState = rememberLazyListState(), - articleDetailLazyListStates = postsFeed.allPosts.associate { post -> - key(post.id) { - post.id to rememberLazyListState() - } - }, snackbarHostState = SnackbarHostState(), onSearchInputChanged = {}, ) diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeViewModel.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeViewModel.kt index 04170bf8cc..39081e9198 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeViewModel.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeViewModel.kt @@ -22,7 +22,6 @@ import androidx.lifecycle.viewModelScope import com.example.jetnews.R import com.example.jetnews.data.Result import com.example.jetnews.data.posts.PostsRepository -import com.example.jetnews.model.Post import com.example.jetnews.model.PostsFeed import com.example.jetnews.utils.ErrorMessage import java.util.UUID @@ -56,13 +55,9 @@ sealed interface HomeUiState { /** * There are posts to render, as contained in [postsFeed]. - * - * There is guaranteed to be a [selectedPost], which is one of the posts from [postsFeed]. */ data class HasPosts( val postsFeed: PostsFeed, - val selectedPost: Post, - val isArticleOpen: Boolean, val favorites: Set, override val isLoading: Boolean, override val errorMessages: List, @@ -75,8 +70,6 @@ sealed interface HomeUiState { */ private data class HomeViewModelState( val postsFeed: PostsFeed? = null, - val selectedPostId: String? = null, // TODO back selectedPostId in a SavedStateHandle - val isArticleOpen: Boolean = false, val favorites: Set = emptySet(), val isLoading: Boolean = false, val errorMessages: List = emptyList(), @@ -96,13 +89,6 @@ private data class HomeViewModelState( } else { HomeUiState.HasPosts( postsFeed = postsFeed, - // Determine the selected post. This will be the post the user last selected. - // If there is none (or that post isn't in the current feed), default to the - // highlighted post - selectedPost = postsFeed.allPosts.find { - it.id == selectedPostId - } ?: postsFeed.highlightedPost, - isArticleOpen = isArticleOpen, favorites = favorites, isLoading = isLoading, errorMessages = errorMessages, @@ -114,13 +100,11 @@ private data class HomeViewModelState( /** * ViewModel that handles the business logic of the Home screen */ -class HomeViewModel(private val postsRepository: PostsRepository, preSelectedPostId: String?) : ViewModel() { +class HomeViewModel(private val postsRepository: PostsRepository) : ViewModel() { private val viewModelState = MutableStateFlow( HomeViewModelState( isLoading = true, - selectedPostId = preSelectedPostId, - isArticleOpen = preSelectedPostId != null, ), ) @@ -178,14 +162,6 @@ class HomeViewModel(private val postsRepository: PostsRepository, preSelectedPos } } - /** - * Selects the given article to view more information about it. - */ - fun selectArticle(postId: String) { - // Treat selecting a detail as simply interacting with it - interactedWithArticleDetails(postId) - } - /** * Notify that an error was displayed on the screen */ @@ -196,27 +172,6 @@ class HomeViewModel(private val postsRepository: PostsRepository, preSelectedPos } } - /** - * Notify that the user interacted with the feed - */ - fun interactedWithFeed() { - viewModelState.update { - it.copy(isArticleOpen = false) - } - } - - /** - * Notify that the user interacted with the article details - */ - fun interactedWithArticleDetails(postId: String) { - viewModelState.update { - it.copy( - selectedPostId = postId, - isArticleOpen = true, - ) - } - } - /** * Notify that the user updated the search query */ @@ -230,12 +185,11 @@ class HomeViewModel(private val postsRepository: PostsRepository, preSelectedPos * Factory for HomeViewModel that takes PostsRepository as a dependency */ companion object { - fun provideFactory(postsRepository: PostsRepository, preSelectedPostId: String? = null): ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return HomeViewModel(postsRepository, preSelectedPostId) as T - } + fun provideFactory(postsRepository: PostsRepository): ViewModelProvider.Factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return HomeViewModel(postsRepository) as T } + } } } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsRoute.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsRoute.kt index 9da71adeb9..2a26083800 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsRoute.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/interests/InterestsRoute.kt @@ -21,6 +21,31 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.jetnews.data.interests.InterestsRepository +import kotlinx.serialization.Serializable + +@Serializable +data object InterestsKey : NavKey + +fun EntryProviderScope.interestsEntry( + interestsRepository: InterestsRepository, + isExpandedScreen: () -> Boolean, + openDrawer: () -> Unit, +) { + entry { + val interestsViewModel: InterestsViewModel = + viewModel(factory = InterestsViewModel.provideFactory(interestsRepository)) + + InterestsRoute( + interestsViewModel, + isExpandedScreen = isExpandedScreen(), + openDrawer = openDrawer, + ) + } +} /** * Stateful composable that displays the Navigation route for the Interests screen. diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/navigation/DeepLinkKey.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/navigation/DeepLinkKey.kt new file mode 100644 index 0000000000..6e31d9f98b --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/navigation/DeepLinkKey.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 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.example.jetnews.ui.navigation + +import androidx.navigation3.runtime.NavKey + +interface DeepLinkKey : NavKey { + val parent: NavKey +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/navigation/ListDetailScene.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/navigation/ListDetailScene.kt new file mode 100644 index 0000000000..c97c2ff319 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/navigation/ListDetailScene.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2026 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.example.jetnews.ui.navigation + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavMetadataKey +import androidx.navigation3.runtime.contains +import androidx.navigation3.runtime.get +import androidx.navigation3.runtime.metadata +import androidx.navigation3.scene.Scene +import androidx.navigation3.scene.SceneStrategy +import androidx.navigation3.scene.SceneStrategyScope +import androidx.navigation3.ui.NavDisplay + +class ListDetailScene( + override val key: Any, + val listEntry: NavEntry, + val detailEntry: NavEntry?, + override val previousEntries: List>, +) : Scene { + + override val entries: List> = listOfNotNull(listEntry, detailEntry) + override val content: @Composable (() -> Unit) = { + val listConfiguration = listEntry.metadata[ListMetadataKey] ?: ListConfiguration() + + Row { + Box(modifier = listConfiguration.modifier) { + listEntry.Content() + } + Box( + modifier = detailEntry?.metadata?.get(DetailMetadataKey)?.modifier ?: Modifier, + ) { + AnimatedContent( + targetState = detailEntry, + contentKey = { entry -> entry?.contentKey ?: "detailPlaceholder" }, + transitionSpec = { + fadeIn() togetherWith fadeOut() + }, + ) { entry -> + if (entry == null) { + listConfiguration.detailPlaceholder() + } else { + entry.Content() + } + } + } + } + } + + override val metadata = metadata { + put(NavDisplay.TransitionKey) { fadeIn() togetherWith ExitTransition.KeepUntilTransitionsFinished } + put(NavDisplay.PopTransitionKey) { fadeIn() togetherWith ExitTransition.KeepUntilTransitionsFinished } + } + + sealed interface PaneConfiguration { + val modifier: Modifier + } + + data class ListConfiguration(override val modifier: Modifier = Modifier, val detailPlaceholder: @Composable () -> Unit = {}) : + PaneConfiguration + + data class DetailConfiguration(override val modifier: Modifier = Modifier) : PaneConfiguration + + object ListMetadataKey : NavMetadataKey + object DetailMetadataKey : NavMetadataKey + + companion object { + fun list(listConfiguration: ListConfiguration = ListConfiguration()) = metadata { + put(ListMetadataKey, listConfiguration) + } + + fun detail(detailConfiguration: DetailConfiguration = DetailConfiguration()) = metadata { + put(DetailMetadataKey, detailConfiguration) + } + } +} + +@Composable +fun rememberListDetailSceneStrategy(isExpandedScreen: Boolean) = remember(isExpandedScreen) { + ListDetailSceneStrategy(isExpandedScreen) +} + +class ListDetailSceneStrategy(val isExpandedScreen: Boolean) : SceneStrategy { + override fun SceneStrategyScope.calculateScene(entries: List>): Scene? { + if (!isExpandedScreen) return null + + val lastEntry = entries.lastOrNull() ?: return null + if (ListDetailScene.ListMetadataKey !in lastEntry.metadata && + ListDetailScene.DetailMetadataKey !in lastEntry.metadata + ) { + return null + } + + val listEntryIndex = entries.indexOfLast { ListDetailScene.ListMetadataKey in it.metadata } + if (listEntryIndex == -1) return null + + val listEntry = entries[listEntryIndex] + val detailEntry = entries.getOrNull(listEntryIndex + 1) + ?.takeIf { ListDetailScene.DetailMetadataKey in it.metadata } + + return ListDetailScene( + key = listEntry.contentKey, + listEntry = listEntry, + detailEntry = detailEntry, + previousEntries = entries.take(listEntryIndex), + ) + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/navigation/NavigationState.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/navigation/NavigationState.kt new file mode 100644 index 0000000000..cbd4e3e251 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/navigation/NavigationState.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2026 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.example.jetnews.ui.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSerializable +import androidx.compose.runtime.setValue +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavEntryDecorator +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.rememberDecoratedNavEntries +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator +import androidx.navigation3.runtime.serialization.NavKeySerializer +import androidx.savedstate.compose.serialization.serializers.MutableStateSerializer +import kotlinx.serialization.builtins.ListSerializer + +@Composable +fun rememberInitialBackStack(backStack: List): MutableState> { + return rememberSerializable( + serializer = MutableStateSerializer(ListSerializer(NavKeySerializer())), + ) { + mutableStateOf(backStack) + } +} + +@Composable +fun rememberNavigationState(mainTopLevelRoute: NavKey, topLevelRoutes: Set, initialBackStack: List): NavigationState { + + val initialTopLevelRoute = initialBackStack.first() + + val topLevelRoute = rememberSerializable( + serializer = MutableStateSerializer(NavKeySerializer()), + ) { + mutableStateOf(initialTopLevelRoute) + }.apply { + // If a new intent comes in while the activity is already running, the value for + // topLevelRoute needs to be updated to reflect it + value = initialTopLevelRoute + } + + val backStacks = remember(topLevelRoutes, initialBackStack) { + mutableMapOf>() + } + + topLevelRoutes.forEach { route -> + val backStack = if (route == initialTopLevelRoute) initialBackStack else listOf(route) + backStacks[route] = key(backStack) { + rememberNavBackStack(*backStack.toTypedArray()) + } + } + + return remember(mainTopLevelRoute, topLevelRoute, backStacks) { + NavigationState(mainTopLevelRoute, topLevelRoute, backStacks) + } +} + +class NavigationState( + val mainTopLevelRoute: NavKey, + topLevelRoute: MutableState, + val backStacks: Map>, +) { + var topLevelRoute: NavKey by topLevelRoute + + @Composable + fun toDecoratedEntries( + entryProvider: (NavKey) -> NavEntry, + entryDecorators: List> = listOf(rememberSaveableStateHolderNavEntryDecorator()), + ): List> { + val decoratedEntries = backStacks.mapValues { (_, stack) -> + rememberDecoratedNavEntries( + backStack = stack, + entryDecorators = entryDecorators, + entryProvider = entryProvider, + ) + } + + return getTopLevelRoutesInUse() + .flatMap { decoratedEntries[it] ?: emptyList() } + } + + private fun getTopLevelRoutesInUse(): List = if (topLevelRoute == mainTopLevelRoute) { + listOf(mainTopLevelRoute) + } else { + listOf(mainTopLevelRoute, topLevelRoute) + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/navigation/Navigator.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/navigation/Navigator.kt new file mode 100644 index 0000000000..7c554cfe60 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/navigation/Navigator.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2026 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.example.jetnews.ui.navigation + +import com.example.jetnews.ui.home.HomeKey +import com.example.jetnews.ui.interests.InterestsKey +import com.example.jetnews.ui.post.PostKey + +class Navigator(val state: NavigationState) { + fun toHome() { + if (state.topLevelRoute == HomeKey) return + state.topLevelRoute = HomeKey + } + + fun toPost(postId: String) { + val postKey = PostKey(postId) + if (state.topLevelRoute == HomeKey && state.backStacks[HomeKey]?.lastOrNull()?.equals(postKey) == true) return + state.topLevelRoute = HomeKey + state.backStacks[HomeKey]?.apply { + if (getOrNull(1) == null) add(postKey) else set(1, postKey) + } + } + + fun toInterests() { + if (state.topLevelRoute == InterestsKey) return + state.topLevelRoute = InterestsKey + } + + fun goBack() { + val currentStack = state.backStacks[state.topLevelRoute] ?: error("Stack for ${state.topLevelRoute} not found") + val currentRoute = currentStack.last() + + // If we're at the base of the current route, go back to the start route stack. + if (currentRoute == state.topLevelRoute) { + state.topLevelRoute = state.mainTopLevelRoute + } else { + currentStack.removeLastOrNull() + } + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostRoute.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostRoute.kt new file mode 100644 index 0000000000..eee5593ff2 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostRoute.kt @@ -0,0 +1,125 @@ +/* + * Copyright 2026 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.example.jetnews.ui.post + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.EntryProviderScope +import androidx.navigation3.runtime.NavKey +import com.example.jetnews.data.posts.PostsRepository +import com.example.jetnews.ui.article.ArticleScreen +import com.example.jetnews.ui.home.HomeKey +import com.example.jetnews.ui.navigation.DeepLinkKey +import com.example.jetnews.ui.navigation.ListDetailScene +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.debounce +import kotlinx.serialization.Serializable + +@Serializable +data class PostKey(val postId: String) : DeepLinkKey { + override val parent = HomeKey +} + +fun EntryProviderScope.postEntry(postsRepository: PostsRepository, isExpandedScreen: () -> Boolean, onBack: () -> Unit) { + entry( + metadata = ListDetailScene.detail(), + ) { + val postViewModel: PostViewModel = + viewModel(factory = PostViewModel.provideFactory(postsRepository, it.postId), key = it.postId) + + val uiState by postViewModel.uiState.collectAsStateWithLifecycle() + + PostRoute( + uiState = uiState, + isExpandedScreen = isExpandedScreen(), + onBack = onBack, + onToggleFavorite = postViewModel::toggleFavorite, + onScroll = postViewModel::onScroll, + ) + } +} + +@OptIn(FlowPreview::class) +@Composable +fun PostRoute( + uiState: PostUiState, + isExpandedScreen: Boolean, + onBack: () -> Unit, + onToggleFavorite: () -> Unit, + onScroll: (index: Int, offset: Int) -> Unit, +) { + if (uiState.loading) { + Surface(modifier = Modifier.fillMaxSize()) { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier.width(64.dp), + ) + } + } + } else { + val post = uiState.post + if (post != null) { + val lazyListState = rememberLazyListState( + initialFirstVisibleItemIndex = uiState.initialFirstVisibleItemIndex, + initialFirstVisibleItemScrollOffset = uiState.initialFirstVisibleItemScrollOffset, + ) + + LaunchedEffect(lazyListState) { + snapshotFlow { + Pair( + lazyListState.firstVisibleItemIndex, + lazyListState.firstVisibleItemScrollOffset, + ) + } + .debounce(50) + .collectLatest { (index, offset) -> + onScroll(index, offset) + } + } + + ArticleScreen( + post = post, + isExpandedScreen = isExpandedScreen, + onBack = onBack, + isFavorite = uiState.isFavorite, + onToggleFavorite = onToggleFavorite, + lazyListState = lazyListState, + ) + } else { + Surface(modifier = Modifier.fillMaxSize()) { + Box(contentAlignment = Alignment.Center) { + Text(text = "Post not found") + } + } + } + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostViewModel.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostViewModel.kt new file mode 100644 index 0000000000..359158b309 --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostViewModel.kt @@ -0,0 +1,89 @@ +/* + * Copyright 2026 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.example.jetnews.ui.post + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.jetnews.data.posts.PostsRepository +import com.example.jetnews.data.successOr +import com.example.jetnews.model.Post +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class PostUiState( + val post: Post? = null, + val initialFirstVisibleItemIndex: Int = 0, + val initialFirstVisibleItemScrollOffset: Int = 0, + val isFavorite: Boolean = false, + val loading: Boolean = false, +) + +class PostViewModel(private val postsRepository: PostsRepository, private val postId: String) : ViewModel() { + private val _uiState = MutableStateFlow(PostUiState(loading = true)) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + _uiState.update { it.copy(loading = true) } + + viewModelScope.launch { + val post = postsRepository.getPost(postId).successOr(null) + + _uiState.update { + it.copy( + post = post, + loading = false, + ) + } + } + + viewModelScope.launch { + postsRepository.observeFavorites().collect { favorites -> + _uiState.update { it.copy(isFavorite = favorites.contains(postId)) } + } + } + } + + fun toggleFavorite() { + viewModelScope.launch { postsRepository.toggleFavorite(postId) } + } + + fun onScroll(index: Int, offset: Int) { + _uiState.update { + it.copy( + initialFirstVisibleItemIndex = index, + initialFirstVisibleItemScrollOffset = offset, + ) + } + } + + /** + * Factory for PostViewModel that takes PostsRepository as a dependency + */ + companion object { + fun provideFactory(postsRepository: PostsRepository, postId: String): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return PostViewModel(postsRepository, postId) as T + } + } + } +} diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/utils/NewIntentEffect.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/utils/NewIntentEffect.kt new file mode 100644 index 0000000000..17a886bf7d --- /dev/null +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/utils/NewIntentEffect.kt @@ -0,0 +1,47 @@ +/* + * Copyright 2026 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.example.jetnews.ui.utils + +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.core.util.Consumer + +@Composable +fun NewIntentEffect(onNewIntent: (Intent) -> Unit) { + val activity = LocalActivity.current as? ComponentActivity + val currentOnNewIntent by rememberUpdatedState(onNewIntent) + + DisposableEffect(activity) { + if (activity == null) return@DisposableEffect onDispose {} + + val onNewIntentListener = Consumer { intent -> + activity.intent = intent + currentOnNewIntent(intent) + } + + activity.addOnNewIntentListener(onNewIntentListener) + + onDispose { + activity.removeOnNewIntentListener(onNewIntentListener) + } + } +} diff --git a/JetNews/gradle/libs.versions.toml b/JetNews/gradle/libs.versions.toml index 07831faa3c..7ba82e5c4c 100644 --- a/JetNews/gradle/libs.versions.toml +++ b/JetNews/gradle/libs.versions.toml @@ -16,7 +16,9 @@ androidx-glance = "1.1.1" androidx-lifecycle = "2.8.2" androidx-lifecycle-compose = "2.10.0" androidx-lifecycle-runtime-compose = "2.10.0" +androidx-lifecycle-viewmodel-navigation3 = "2.10.0" androidx-navigation = "2.9.7" +androidx-navigation3 = "1.1.0-beta01" androidx-palette = "1.0.0" androidx-test = "1.7.0" androidx-test-espresso = "3.7.0" @@ -40,7 +42,7 @@ horologist = "0.7.15" jdkDesugar = "2.1.5" junit = "4.13.2" kotlin = "2.3.10" -kotlinx-serialization-json = "1.10.0" +kotlinx-serialization = "1.10.0" kotlinx_immutable = "0.4.0" ksp = "2.3.6" maps-compose = "8.2.0" @@ -101,11 +103,14 @@ androidx-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-kt androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle-runtime-compose" } androidx-lifecycle-viewModelCompose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle-compose" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle-compose" } +androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "androidx-lifecycle-viewmodel-navigation3"} androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle-compose" } androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" } androidx-navigation-fragment = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "androidx-navigation" } androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "androidx-navigation" } +androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "androidx-navigation3" } +androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "androidx-navigation3" } androidx-palette = { module = "androidx.palette:palette", version.ref = "androidx-palette" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } @@ -148,7 +153,8 @@ kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.re kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinx_immutable" } kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } -kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } okhttp3 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } play-services-wearable = { module = "com.google.android.gms:play-services-wearable", version.ref = "play-services-wearable" } From e4b2d5f6c7725b85a186e4e0d4682de0f29ed302 Mon Sep 17 00:00:00 2001 From: Ben Sagmoe Date: Mon, 23 Mar 2026 12:33:31 -0400 Subject: [PATCH 2/4] Update test --- .../src/androidTest/java/com/example/jetnews/JetnewsTests.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt b/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt index 115ca86b91..e2f9960834 100644 --- a/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt +++ b/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt @@ -61,7 +61,7 @@ class JetnewsTests { println(composeTestRule.onRoot().printToString()) try { - composeTestRule.onAllNodes(hasText("It provides fully static", substring = true))[0].assertExists() + composeTestRule.onAllNodes(hasText("Use Dagger in Kotlin!", substring = true))[0].assertExists() } catch (e: AssertionError) { println(composeTestRule.onRoot().printToString()) throw e From bc33dd8f37d91e36664ec27050b20d04d3feda70 Mon Sep 17 00:00:00 2001 From: Ben Sagmoe Date: Mon, 23 Mar 2026 14:25:55 -0400 Subject: [PATCH 3/4] Fix opensArticle test to work on small screen used by CI --- .../java/com/example/jetnews/JetnewsTests.kt | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt b/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt index e2f9960834..c81139db51 100644 --- a/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt +++ b/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt @@ -22,9 +22,8 @@ import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.printToString +import androidx.compose.ui.test.performScrollTo import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.example.jetnews.data.posts.impl.manuel @@ -55,17 +54,10 @@ class JetnewsTests { @Test fun app_opensArticle() { - - println(composeTestRule.onRoot().printToString()) - composeTestRule.onAllNodes(hasText(manuel.name, substring = true))[0].performClick() - - println(composeTestRule.onRoot().printToString()) - try { - composeTestRule.onAllNodes(hasText("Use Dagger in Kotlin!", substring = true))[0].assertExists() - } catch (e: AssertionError) { - println(composeTestRule.onRoot().printToString()) - throw e - } + composeTestRule.onAllNodes(hasText(manuel.name, substring = true))[0] + .performScrollTo() + .performClick() + composeTestRule.waitUntilExactlyOneExists(hasText("Use Dagger in Kotlin!", substring = true), 5000L) } @Test From 635b3fd278503322f6097b617bcddaa8d0feb4d8 Mon Sep 17 00:00:00 2001 From: Ben Sagmoe Date: Mon, 23 Mar 2026 16:03:24 -0400 Subject: [PATCH 4/4] Rename 'article' functions and variables to use 'post' instead. Update KDocs and README --- JetNews/README.md | 37 +++++-------- .../java/com/example/jetnews/JetnewsTests.kt | 2 +- .../com/example/jetnews/ui/home/HomeRoute.kt | 5 +- .../example/jetnews/ui/home/HomeScreens.kt | 51 ++++++++++-------- .../jetnews/ui/home/PostCardYourNetwork.kt | 4 +- .../com/example/jetnews/ui/home/PostCards.kt | 8 +-- .../ui/{article => post}/PostContent.kt | 4 +- .../com/example/jetnews/ui/post/PostRoute.kt | 3 +- .../ArticleScreen.kt => post/PostScreen.kt} | 52 +++++++++++-------- ...ackground.xml => icon_post_background.xml} | 0 JetNews/app/src/main/res/values/strings.xml | 11 ++-- 11 files changed, 94 insertions(+), 83 deletions(-) rename JetNews/app/src/main/java/com/example/jetnews/ui/{article => post}/PostContent.kt (99%) rename JetNews/app/src/main/java/com/example/jetnews/ui/{article/ArticleScreen.kt => post/PostScreen.kt} (87%) rename JetNews/app/src/main/res/drawable/{icon_article_background.xml => icon_post_background.xml} (100%) diff --git a/JetNews/README.md b/JetNews/README.md index 1bb270768b..1afd17a8a6 100644 --- a/JetNews/README.md +++ b/JetNews/README.md @@ -15,23 +15,28 @@ project from Android Studio following the steps ## Features -This sample contains three screens: a list of articles, a detail page for articles, and a page to -subscribe to topics of interest. The navigation from the the list of articles to the interests +This sample contains three screens: a list of posts, a detail page for a post, and a page to +subscribe to topics of interest. The navigation from the list of posts to the interests screen uses a navigation drawer. ### App scaffolding Package [`com.example.jetnews.ui`][1] -[`JetnewsApp.kt`][2] arranges the different screens in the `NavDrawerLayout`. +[`JetnewsApp.kt`][2] sets up the app's navigation state and the modal drawer used for navigation +on smaller windows. -[`JetnewsNavGraph.kt`][3] configures the navigation routes and actions in the app. +[`JetnewsNavDisplay.kt`][3] displays the primary content of the app: the list of posts, the +posts themselves, and the interests page. It uses a list-detail scene strategy to adaptively +display more or less content depending on the window size. + +Screenshot [1]: app/src/main/java/com/example/jetnews/ui [2]: app/src/main/java/com/example/jetnews/ui/JetnewsApp.kt -[3]: app/src/main/java/com/example/jetnews/ui/JetnewsNavGraph.kt +[3]: app/src/main/java/com/example/jetnews/ui/JetnewsNavDisplay.kt -### Main article list +### Main post list Package [`com.example.jetnews.ui.home`][4] @@ -47,14 +52,14 @@ See how to: [4]: app/src/main/java/com/example/jetnews/ui/home -### Article detail +### Post detail -Package [`com.example.jetnews.ui.article`][5] +Package [`com.example.jetnews.ui.post`][5] This screen dives into the Text API, showing how to use different fonts than the ones defined in [`Typography`][6]. It also adds a bottom app bar, with custom actions. -[5]: app/src/main/java/com/example/jetnews/ui/article +[5]: app/src/main/java/com/example/jetnews/ui/post [6]: app/src/main/java/com/example/jetnews/ui/theme/Type.kt ### Interests screen @@ -99,20 +104,6 @@ UI tests can be run on device/emulators or on JVM with Robolectric. * To run Instrumented tests use the "Instrumented tests" run configuration or run the `./gradlew connectedCheck` command. * To run tests with Robolectric use the "Robolectric tests" run configuration or run the `./gradlew testDebug` command. -## Jetnews for every screen - -Screenshot - -We recently updated Jetnews to enhance its behavior across all mobile devices, both big and small. -Jetnews already had support for “traditional” mobile screens, so it was tempting to describe all of -our changes as “adding large screen support.” While that is true, it misses the point of having -adaptive UI. For example, if your app is running in split screen mode on a tablet, it shouldn't try -to display “tablet UI” unless it actually has enough space for it. With all of these changes, -Jetnews is working better than ever on large screens, but also on small screens too. - -Check out the blog post that explains all the changes in more details: -https://medium.com/androiddevelopers/jetnews-for-every-screen-4d8e7927752 - ## License ``` diff --git a/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt b/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt index c81139db51..dc5c86fc15 100644 --- a/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt +++ b/JetNews/app/src/androidTest/java/com/example/jetnews/JetnewsTests.kt @@ -53,7 +53,7 @@ class JetnewsTests { } @Test - fun app_opensArticle() { + fun app_opensPost() { composeTestRule.onAllNodes(hasText(manuel.name, substring = true))[0] .performScrollTo() .performClick() diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeRoute.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeRoute.kt index 6512f39df8..3d8ef127f4 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeRoute.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeRoute.kt @@ -30,11 +30,13 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey +import com.example.jetnews.R import com.example.jetnews.data.posts.PostsRepository import com.example.jetnews.ui.navigation.ListDetailScene import kotlinx.serialization.Serializable @@ -56,7 +58,7 @@ fun EntryProviderScope.homeEntry( Surface(modifier = Modifier.fillMaxSize()) { Box(contentAlignment = Alignment.Center) { Text( - "Select an article", + stringResource(R.string.home_detail_placeholder), style = MaterialTheme.typography.labelLarge, ) } @@ -85,6 +87,7 @@ fun EntryProviderScope.homeEntry( * @param homeViewModel ViewModel that handles the business logic of this screen * @param isExpandedScreen (state) whether the screen is expanded * @param openDrawer (event) request opening the app drawer + * @param navigateToPost (event) request navigation to Post screen * @param snackbarHostState (state) state for the [Scaffold] component on this screen */ @Composable diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreens.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreens.kt index bd2ca43367..e347e1c0c1 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreens.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/home/HomeScreens.kt @@ -93,7 +93,7 @@ import com.example.jetnews.ui.theme.JetnewsTheme import kotlinx.coroutines.runBlocking /** - * The home screen displaying just the article feed. + * The home screen displaying just the post feed. */ @Composable fun HomeFeedScreen( @@ -123,7 +123,7 @@ fun HomeFeedScreen( postsFeed = hasPostsUiState.postsFeed, favorites = hasPostsUiState.favorites, showExpandedSearch = !showTopAppBar, - onArticleTapped = onSelectPost, + onPostTapped = onSelectPost, onToggleFavorite = onToggleFavorite, contentPadding = contentPadding, modifier = contentModifier, @@ -289,18 +289,25 @@ private fun LoadingContent( /** * Display a feed of posts. * - * When a post is clicked on, [onArticleTapped] will be called. + * When a post is clicked on, [onPostTapped] will be called. * * @param postsFeed (state) the feed to display - * @param onArticleTapped (event) request navigation to Article screen + * @param favorites (state) a set of favorite posts + * @param showExpandedSearch (state) whether the expanded search is shown + * @param onPostTapped (event) request navigation to Post screen + * @param onToggleFavorite (event) request that this post toggle its favorite state * @param modifier modifier for the root element + * @param contentPadding the padding to apply to the content + * @param state the state object to be used to control or observe the list's state + * @param searchInput (state) the search input + * @param onSearchInputChanged (event) request that the search input changed */ @Composable private fun PostList( postsFeed: PostsFeed, favorites: Set, showExpandedSearch: Boolean, - onArticleTapped: (postId: String) -> Unit, + onPostTapped: (postId: String) -> Unit, onToggleFavorite: (String) -> Unit, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(0.dp), @@ -322,12 +329,12 @@ private fun PostList( ) } } - item { PostListTopSection(postsFeed.highlightedPost, onArticleTapped) } + item { PostListTopSection(postsFeed.highlightedPost, onPostTapped) } if (postsFeed.recommendedPosts.isNotEmpty()) { item { PostListSimpleSection( postsFeed.recommendedPosts, - onArticleTapped, + onPostTapped, favorites, onToggleFavorite, ) @@ -336,12 +343,12 @@ private fun PostList( if (postsFeed.popularPosts.isNotEmpty() && !showExpandedSearch) { item { PostListPopularSection( - postsFeed.popularPosts, onArticleTapped, + postsFeed.popularPosts, onPostTapped, ) } } if (postsFeed.recentPosts.isNotEmpty()) { - item { PostListHistorySection(postsFeed.recentPosts, onArticleTapped) } + item { PostListHistorySection(postsFeed.recentPosts, onPostTapped) } } } } @@ -364,10 +371,10 @@ private fun FullScreenLoading() { * Top section of [PostList] * * @param post (state) highlighted post to display - * @param navigateToArticle (event) request navigation to Article screen + * @param navigateToPost (event) request navigation to Post screen */ @Composable -private fun PostListTopSection(post: Post, navigateToArticle: (String) -> Unit) { +private fun PostListTopSection(post: Post, navigateToPost: (String) -> Unit) { Text( modifier = Modifier.padding(start = 16.dp, top = 16.dp, end = 16.dp), text = stringResource(id = R.string.home_top_section_title), @@ -375,7 +382,7 @@ private fun PostListTopSection(post: Post, navigateToArticle: (String) -> Unit) ) PostCardTop( post = post, - modifier = Modifier.clickable(onClick = { navigateToArticle(post.id) }), + modifier = Modifier.clickable(onClick = { navigateToPost(post.id) }), ) PostListDivider() } @@ -384,12 +391,14 @@ private fun PostListTopSection(post: Post, navigateToArticle: (String) -> Unit) * Full-width list items for [PostList] * * @param posts (state) to display - * @param navigateToArticle (event) request navigation to Article screen + * @param navigateToPost (event) request navigation to Post screen + * @param favorites (state) a set of favorite posts + * @param onToggleFavorite (event) request that this post toggle its favorite state */ @Composable private fun PostListSimpleSection( posts: List, - navigateToArticle: (String) -> Unit, + navigateToPost: (String) -> Unit, favorites: Set, onToggleFavorite: (String) -> Unit, ) { @@ -397,7 +406,7 @@ private fun PostListSimpleSection( posts.forEach { post -> PostCardSimple( post = post, - navigateToArticle = navigateToArticle, + navigateToPost = navigateToPost, isFavorite = favorites.contains(post.id), onToggleFavorite = { onToggleFavorite(post.id) }, ) @@ -410,10 +419,10 @@ private fun PostListSimpleSection( * Horizontal scrolling cards for [PostList] * * @param posts (state) to display - * @param navigateToArticle (event) request navigation to Article screen + * @param navigateToPost (event) request navigation to Post screen */ @Composable -private fun PostListPopularSection(posts: List, navigateToArticle: (String) -> Unit) { +private fun PostListPopularSection(posts: List, navigateToPost: (String) -> Unit) { Column { Text( modifier = Modifier.padding(16.dp), @@ -430,7 +439,7 @@ private fun PostListPopularSection(posts: List, navigateToArticle: (String for (post in posts) { PostCardPopular( post, - navigateToArticle, + navigateToPost, ) } } @@ -443,13 +452,13 @@ private fun PostListPopularSection(posts: List, navigateToArticle: (String * Full-width list items that display "based on your history" for [PostList] * * @param posts (state) to display - * @param navigateToArticle (event) request navigation to Article screen + * @param navigateToPost (event) request navigation to Post screen */ @Composable -private fun PostListHistorySection(posts: List, navigateToArticle: (String) -> Unit) { +private fun PostListHistorySection(posts: List, navigateToPost: (String) -> Unit) { Column { posts.forEach { post -> - PostCardHistory(post, navigateToArticle) + PostCardHistory(post, navigateToPost) PostListDivider() } } diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCardYourNetwork.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCardYourNetwork.kt index 5c8026f2a6..9679c948a6 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCardYourNetwork.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCardYourNetwork.kt @@ -51,9 +51,9 @@ import com.example.jetnews.ui.theme.JetnewsTheme @OptIn(ExperimentalMaterial3Api::class) @Composable -fun PostCardPopular(post: Post, navigateToArticle: (String) -> Unit, modifier: Modifier = Modifier) { +fun PostCardPopular(post: Post, navigateToPost: (String) -> Unit, modifier: Modifier = Modifier) { Card( - onClick = { navigateToArticle(post.id) }, + onClick = { navigateToPost(post.id) }, shape = MaterialTheme.shapes.medium, modifier = modifier .width(280.dp), diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCards.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCards.kt index 6afdbd5012..57cafe7f46 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCards.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/home/PostCards.kt @@ -87,11 +87,11 @@ fun PostTitle(post: Post) { } @Composable -fun PostCardSimple(post: Post, navigateToArticle: (String) -> Unit, isFavorite: Boolean, onToggleFavorite: () -> Unit) { +fun PostCardSimple(post: Post, navigateToPost: (String) -> Unit, isFavorite: Boolean, onToggleFavorite: () -> Unit) { val bookmarkAction = stringResource(if (isFavorite) R.string.unbookmark else R.string.bookmark) Row( modifier = Modifier - .clickable(onClick = { navigateToArticle(post.id) }) + .clickable(onClick = { navigateToPost(post.id) }) .semantics { // By defining a custom action, we tell accessibility services that this whole // composable has an action attached to it. The accessibility service can choose @@ -128,12 +128,12 @@ fun PostCardSimple(post: Post, navigateToArticle: (String) -> Unit, isFavorite: } @Composable -fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) { +fun PostCardHistory(post: Post, navigateToPost: (String) -> Unit) { var openDialog by remember { mutableStateOf(false) } Row( Modifier - .clickable(onClick = { navigateToArticle(post.id) }), + .clickable(onClick = { navigateToPost(post.id) }), ) { PostImage( post = post, diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/article/PostContent.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostContent.kt similarity index 99% rename from JetNews/app/src/main/java/com/example/jetnews/ui/article/PostContent.kt rename to JetNews/app/src/main/java/com/example/jetnews/ui/post/PostContent.kt index 8337ccbaef..7e69902e09 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/article/PostContent.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostContent.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetnews.ui.article +package com.example.jetnews.ui.post import android.content.res.Configuration.UI_MODE_NIGHT_YES import androidx.compose.foundation.Image @@ -146,7 +146,7 @@ private fun PostMetadata(metadata: Metadata, modifier: Modifier = Modifier) { Text( text = stringResource( - id = R.string.article_post_min_read, + id = R.string.post_min_read, metadata.date, metadata.readTimeMinutes, ), style = MaterialTheme.typography.bodySmall, diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostRoute.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostRoute.kt index eee5593ff2..9648b6a08a 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostRoute.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostRoute.kt @@ -35,7 +35,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.example.jetnews.data.posts.PostsRepository -import com.example.jetnews.ui.article.ArticleScreen import com.example.jetnews.ui.home.HomeKey import com.example.jetnews.ui.navigation.DeepLinkKey import com.example.jetnews.ui.navigation.ListDetailScene @@ -106,7 +105,7 @@ fun PostRoute( } } - ArticleScreen( + PostScreen( post = post, isExpandedScreen = isExpandedScreen, onBack = onBack, diff --git a/JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostScreen.kt similarity index 87% rename from JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt rename to JetNews/app/src/main/java/com/example/jetnews/ui/post/PostScreen.kt index b709ef4fbc..bf9f2e36bb 100644 --- a/JetNews/app/src/main/java/com/example/jetnews/ui/article/ArticleScreen.kt +++ b/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostScreen.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.example.jetnews.ui.article +package com.example.jetnews.ui.post import android.content.Context import android.content.Intent @@ -77,18 +77,19 @@ import com.example.jetnews.ui.utils.TextSettingsButton import kotlinx.coroutines.runBlocking /** - * Stateless Article Screen that displays a single post adapting the UI to different screen sizes. + * Stateless Post Screen that displays a single post adapting the UI to different screen sizes. * * @param post (state) item to display - * @param showNavigationIcon (state) if the navigation icon should be shown + * @param isExpandedScreen (state) whether the screen is expanded * @param onBack (event) request navigate back * @param isFavorite (state) is this item currently a favorite * @param onToggleFavorite (event) request that this post toggle its favorite state - * @param lazyListState (state) the [LazyListState] for the article content + * @param modifier modifier for the root element + * @param lazyListState (state) the [LazyListState] for the post content */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ArticleScreen( +fun PostScreen( post: Post, isExpandedScreen: Boolean, onBack: () -> Unit, @@ -103,8 +104,8 @@ fun ArticleScreen( } val context = LocalContext.current - Box { - ArticleScreenContent( + Box(modifier) { + PostScreenContent( post = post, // Allow opening the Drawer if the screen is not expanded navigationIconContent = { @@ -155,15 +156,17 @@ fun ArticleScreen( } /** - * Stateless Article Screen that displays a single post. + * Stateless Post Screen that displays a single post. * * @param post (state) item to display * @param navigationIconContent (UI) content to show for the navigation icon * @param bottomBarContent (UI) content to show for the bottom bar + * @param lazyListState (state) the [LazyListState] for the post content + * @param showTopAppBar (state) if the top app bar should be shown */ @ExperimentalMaterial3Api @Composable -private fun ArticleScreenContent( +private fun PostScreenContent( post: Post, navigationIconContent: @Composable () -> Unit = { }, bottomBarContent: @Composable () -> Unit = { }, @@ -208,7 +211,7 @@ private fun TopAppBar( title = { Row { Image( - painter = painterResource(id = R.drawable.icon_article_background), + painter = painterResource(id = R.drawable.icon_post_background), contentDescription = null, modifier = Modifier .clip(CircleShape) @@ -229,6 +232,11 @@ private fun TopAppBar( /** * Top bar for a Post when displayed next to the Home feed + * + * @param isFavorite (state) is this item currently a favorite + * @param onToggleFavorite (event) request that this post toggle its favorite state + * @param onSharePost (event) request sharing the post + * @param modifier modifier for the root element */ @Composable private fun PostTopBar(isFavorite: Boolean, onToggleFavorite: () -> Unit, onSharePost: () -> Unit, modifier: Modifier = Modifier) { @@ -257,7 +265,7 @@ private fun FunctionalityNotAvailablePopup(onDismiss: () -> Unit) { onDismissRequest = onDismiss, text = { Text( - text = stringResource(id = R.string.article_functionality_not_available), + text = stringResource(id = R.string.post_functionality_not_available), style = MaterialTheme.typography.bodyLarge, ) }, @@ -284,37 +292,37 @@ fun sharePost(post: Post, context: Context) { context.startActivity( Intent.createChooser( intent, - context.getString(R.string.article_share_post), + context.getString(R.string.post_share_post), ), ) } -@Preview("Article screen") -@Preview("Article screen (dark)", uiMode = UI_MODE_NIGHT_YES) -@Preview("Article screen (big font)", fontScale = 1.5f) +@Preview("Post screen") +@Preview("Post screen (dark)", uiMode = UI_MODE_NIGHT_YES) +@Preview("Post screen (big font)", fontScale = 1.5f) @Composable -fun PreviewArticleDrawer() { +fun PreviewPostDrawer() { JetnewsTheme { val post = runBlocking { (BlockingFakePostsRepository().getPost(post3.id) as Result.Success).data } - ArticleScreen(post, false, {}, false, {}) + PostScreen(post, false, {}, false, {}) } } -@Preview("Article screen navrail", device = Devices.PIXEL_C) +@Preview("Post screen navrail", device = Devices.PIXEL_C) @Preview( - "Article screen navrail (dark)", + "Post screen navrail (dark)", uiMode = UI_MODE_NIGHT_YES, device = Devices.PIXEL_C, ) -@Preview("Article screen navrail (big font)", fontScale = 1.5f, device = Devices.PIXEL_C) +@Preview("Post screen navrail (big font)", fontScale = 1.5f, device = Devices.PIXEL_C) @Composable -fun PreviewArticleNavRail() { +fun PreviewPostNavRail() { JetnewsTheme { val post = runBlocking { (BlockingFakePostsRepository().getPost(post3.id) as Result.Success).data } - ArticleScreen(post, true, {}, false, {}) + PostScreen(post, true, {}, false, {}) } } diff --git a/JetNews/app/src/main/res/drawable/icon_article_background.xml b/JetNews/app/src/main/res/drawable/icon_post_background.xml similarity index 100% rename from JetNews/app/src/main/res/drawable/icon_article_background.xml rename to JetNews/app/src/main/res/drawable/icon_post_background.xml diff --git a/JetNews/app/src/main/res/values/strings.xml b/JetNews/app/src/main/res/values/strings.xml index cd9aa9b7cb..f11aa6c18f 100644 --- a/JetNews/app/src/main/res/values/strings.xml +++ b/JetNews/app/src/main/res/values/strings.xml @@ -38,12 +38,13 @@ Popular on Jetnews %1$s - %2$d min read BASED ON YOUR HISTORY - Search articles + Search posts + Select a post - - Functionality not available \uD83D\uDE48 - Share post - %1$s • %2$d min read + + Functionality not available \uD83D\uDE48 + Share post + %1$s • %2$d min read Interests