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.
+
+
[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
-
-
-
-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/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..dc5c86fc15 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
@@ -54,18 +53,11 @@ 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("3 min read", substring = true))[0].assertExists()
- } catch (e: AssertionError) {
- println(composeTestRule.onRoot().printToString())
- throw e
- }
+ fun app_opensPost() {
+ composeTestRule.onAllNodes(hasText(manuel.name, substring = true))[0]
+ .performScrollTo()
+ .performClick()
+ composeTestRule.waitUntilExactlyOneExists(hasText("Use Dagger in Kotlin!", substring = true), 5000L)
}
@Test
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/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..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
@@ -16,19 +16,68 @@
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.res.stringResource
+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.R
+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(
+ stringResource(R.string.home_detail_placeholder),
+ 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.
@@ -38,6 +87,7 @@ import com.example.jetnews.ui.home.HomeScreenType.FeedWithArticleDetails
* @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
@@ -45,165 +95,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..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
@@ -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,133 +81,19 @@ 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.
+ * The home screen displaying just the post feed.
*/
@Composable
fun HomeFeedScreen(
@@ -251,7 +123,7 @@ fun HomeFeedScreen(
postsFeed = hasPostsUiState.postsFeed,
favorites = hasPostsUiState.favorites,
showExpandedSearch = !showTopAppBar,
- onArticleTapped = onSelectPost,
+ onPostTapped = onSelectPost,
onToggleFavorite = onToggleFavorite,
contentPadding = contentPadding,
modifier = contentModifier,
@@ -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
@@ -420,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),
@@ -453,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,
)
@@ -467,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) }
}
}
}
@@ -495,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),
@@ -506,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()
}
@@ -515,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,
) {
@@ -528,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) },
)
@@ -541,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),
@@ -561,7 +439,7 @@ private fun PostListPopularSection(posts: List, navigateToArticle: (String
for (post in posts) {
PostCardPopular(
post,
- navigateToArticle,
+ navigateToPost,
)
}
}
@@ -574,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()
}
}
@@ -644,25 +522,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 +585,6 @@ fun PreviewHomeListDrawerScreen() {
HomeFeedScreen(
uiState = HomeUiState.HasPosts(
postsFeed = postsFeed,
- selectedPost = postsFeed.highlightedPost,
- isArticleOpen = false,
favorites = emptySet(),
isLoading = false,
errorMessages = emptyList(),
@@ -762,40 +619,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 +629,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/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/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/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
new file mode 100644
index 0000000000..9648b6a08a
--- /dev/null
+++ b/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostRoute.kt
@@ -0,0 +1,124 @@
+/*
+ * 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.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)
+ }
+ }
+
+ PostScreen(
+ 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/article/ArticleScreen.kt b/JetNews/app/src/main/java/com/example/jetnews/ui/post/PostScreen.kt
similarity index 66%
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 4dbd4c8bc8..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,19 +14,26 @@
* limitations under the License.
*/
-package com.example.jetnews.ui.article
+package com.example.jetnews.ui.post
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
@@ -67,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,
@@ -92,9 +103,9 @@ fun ArticleScreen(
FunctionalityNotAvailablePopup { showUnimplementedActionDialog = false }
}
- Row(modifier.fillMaxSize()) {
- val context = LocalContext.current
- ArticleScreenContent(
+ val context = LocalContext.current
+ Box(modifier) {
+ PostScreenContent(
post = post,
// Allow opening the Drawer if the screen is not expanded
navigationIconContent = {
@@ -116,48 +127,73 @@ 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),
+ )
+ }
}
}
/**
- * 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 = { },
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,
)
}
@@ -175,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)
@@ -194,6 +230,30 @@ 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) {
+ 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.
*
@@ -205,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,
)
},
@@ -232,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/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/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
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" }