diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index 59cd71d02..400cb3f3a 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -26,7 +26,7 @@ import androidx.fragment.app.FragmentTransaction; import com.jakewharton.processphoenix.ProcessPhoenix; import net.newpipe.app.extensions.ContextKt; -import net.newpipe.app.navigation.Screen; +import net.newpipe.app.navigation.Destination; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.NewPipeDatabase; @@ -684,7 +684,7 @@ public final class NavigationHelper { } public static void openAbout(final Context context) { - ContextKt.navigateTo(context, Screen.About.INSTANCE); + ContextKt.navigateTo(context, Destination.About.INSTANCE); } public static void openSettings(final Context context) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2d4480046..28898950d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -143,6 +143,7 @@ jetbrains-navigation3-ui = { module = "org.jetbrains.androidx.navigation3:naviga jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } junit = { module = "junit:junit", version.ref = "junit" } koin-annotations = { module = "io.insert-koin:koin-annotations", version.ref = "koin" } +koin-compose-navigation3 = { module = "io.insert-koin:koin-compose-navigation3", version.ref = "koin" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" } kotlin-test-core = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-rx3 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx3", version.ref = "kotlinx-coroutines-rx3" } diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 197f4a72f..8ee8b727e 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -106,6 +106,7 @@ kotlin { implementation(libs.jetbrains.lifecycle.navigation3) implementation(libs.kotlinx.serialization.json) + implementation(libs.koin.compose.navigation3) implementation(libs.koin.compose.viewmodel) implementation(libs.koin.annotations) diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/ComposeActivity.kt b/shared/src/androidMain/kotlin/net/newpipe/app/ComposeActivity.kt index bab2ebfe0..b5b871a35 100644 --- a/shared/src/androidMain/kotlin/net/newpipe/app/ComposeActivity.kt +++ b/shared/src/androidMain/kotlin/net/newpipe/app/ComposeActivity.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat import kotlinx.serialization.json.Json import net.newpipe.Constants -import net.newpipe.app.navigation.Screen +import net.newpipe.app.navigation.Destination import net.newpipe.app.theme.currentService /** @@ -28,7 +28,7 @@ class ComposeActivity : ComponentActivity() { setContent { App( // TODO: Change when everything is in compose and this is the primary activity - startDestination = Json.decodeFromString( + startDestination = Json.decodeFromString( intent.getStringExtra(Constants.INTENT_SCREEN_KEY)!! ), onCloseRequest = ::finish diff --git a/shared/src/androidMain/kotlin/net/newpipe/app/extensions/Context.kt b/shared/src/androidMain/kotlin/net/newpipe/app/extensions/Context.kt index 2c1601c30..8495e2cd8 100644 --- a/shared/src/androidMain/kotlin/net/newpipe/app/extensions/Context.kt +++ b/shared/src/androidMain/kotlin/net/newpipe/app/extensions/Context.kt @@ -10,12 +10,12 @@ import android.content.Intent import kotlinx.serialization.json.Json import net.newpipe.Constants import net.newpipe.app.ComposeActivity -import net.newpipe.app.navigation.Screen +import net.newpipe.app.navigation.Destination /** * Navigates to a given compose destination */ -fun Context.navigateTo(screen: Screen) = Intent(this, ComposeActivity::class.java).also { intent -> - intent.putExtra(Constants.INTENT_SCREEN_KEY, Json.encodeToString(screen)) +fun Context.navigateTo(destination: Destination) = Intent(this, ComposeActivity::class.java).also { intent -> + intent.putExtra(Constants.INTENT_SCREEN_KEY, Json.encodeToString(destination)) startActivity(intent) } diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/App.kt b/shared/src/commonMain/kotlin/net/newpipe/app/App.kt index 691cc7844..ae4d54da0 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/App.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/App.kt @@ -7,8 +7,9 @@ package net.newpipe.app import androidx.compose.runtime.Composable import net.newpipe.app.di.KoinApp +import net.newpipe.app.navigation.Destination import net.newpipe.app.navigation.NavDisplay -import net.newpipe.app.navigation.Screen +import net.newpipe.app.navigation.navModule import net.newpipe.app.theme.AppTheme import org.koin.compose.KoinApplication import org.koin.plugin.module.dsl.koinConfiguration @@ -21,11 +22,17 @@ import org.koin.plugin.module.dsl.koinConfiguration */ @Composable fun App( - startDestination: Screen = Screen.About, + startDestination: Destination = Destination.About, onCloseRequest: () -> Unit, withKoin: @Composable () -> Unit = {} ) { - KoinApplication(configuration = koinConfiguration()) { + KoinApplication( + configuration = koinConfiguration( + appDeclaration = { + modules(navModule()) + } + ) + ) { AppTheme { NavDisplay( startDestination = startDestination, diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/navigation/Destination.kt b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/Destination.kt new file mode 100644 index 000000000..bf0d11557 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/Destination.kt @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable +import net.newpipe.app.model.License + +/** + * Destinations for navigation in compose + */ +@Serializable +sealed interface Destination : NavKey { + + @Serializable + data object About : Destination +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavDisplay.kt b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavDisplay.kt index 8b426bfb8..6dfa5878c 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavDisplay.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavDisplay.kt @@ -6,30 +6,42 @@ package net.newpipe.app.navigation import androidx.compose.runtime.Composable -import androidx.navigation3.runtime.entryProvider -import androidx.navigation3.runtime.rememberNavBackStack +import androidx.compose.runtime.saveable.rememberSerializable +import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator +import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay -import net.newpipe.app.screen.about.AboutScreen +import androidx.savedstate.compose.serialization.serializers.SnapshotStateListSerializer +import org.koin.compose.koinInject +import org.koin.compose.navigation3.koinEntryProvider +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.parameter.parametersOf /** * Navigation display for compose screens * @param startDestination Starting destination for the app - * @param onCloseRequest Callback to close app when there are no more screens left in backstack + * @param navigator Navigator to help with navigation, injected by Koin + * @param onCloseRequest Callback to close the app */ +@OptIn(KoinExperimentalAPI::class) @Composable -fun NavDisplay(startDestination: Screen, onCloseRequest: () -> Unit) { - val backstack = rememberNavBackStack(screenConfig, startDestination) - - fun onNavigateUp() = if (backstack.size > 1) backstack.removeLastOrNull() else onCloseRequest() +fun NavDisplay( + startDestination: Destination, + onCloseRequest: () -> Unit, + navigator: Navigator = koinInject { + parametersOf(startDestination, onCloseRequest) + } +) { + val backstack = rememberSerializable(serializer = SnapshotStateListSerializer()) { + navigator.backstack + } NavDisplay( backStack = backstack, - entryProvider = entryProvider { - entry { - AboutScreen( - onNavigateUp = ::onNavigateUp - ) - } - } + onBack = { navigator.navigateUp() }, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator() + ), + entryProvider = koinEntryProvider() ) } diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavModule.kt b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavModule.kt new file mode 100644 index 000000000..4367d50a0 --- /dev/null +++ b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/NavModule.kt @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2026 NewPipe e.V. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package net.newpipe.app.navigation + +import androidx.compose.runtime.mutableStateListOf +import co.touchlab.kermit.Logger +import net.newpipe.app.screen.about.AboutScreen +import org.koin.core.annotation.KoinExperimentalAPI +import org.koin.core.annotation.Provided +import org.koin.core.annotation.Singleton +import org.koin.dsl.module +import org.koin.dsl.navigation3.navigation +import org.koin.plugin.module.dsl.single + +/** + * Navigation module to make navigation easier with nav3 + * + * There is currently no annotation to handle this so we are using DSL API of Koin + */ +@OptIn(KoinExperimentalAPI::class) +fun navModule() = module { + single() + + navigation { + AboutScreen() + } +} + +/** + * Helper to navigate up and to different destinations in compose + */ +@Singleton +class Navigator( + @Provided + private val startDestination: Destination, + + @Provided + private val onCloseRequest: () -> Unit +) { + + /** + * Navigation backstack in compose + */ + val backstack = mutableStateListOf(startDestination) + + /** + * Navigates to the given destination + */ + fun navigateTo(destination: Destination) = backstack.add(destination) + + /** + * Navigates to the previous entry in the backstack + */ + fun navigateUp() = when { + backstack.size > 1 -> backstack.removeLastOrNull() + + else -> { + Logger.i(messageString = "Cannot remove the only entry in backstack!") + onCloseRequest() + } + } +} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/navigation/Screen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/navigation/Screen.kt deleted file mode 100644 index f1be64927..000000000 --- a/shared/src/commonMain/kotlin/net/newpipe/app/navigation/Screen.kt +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 NewPipe e.V. - * SPDX-License-Identifier: GPL-3.0-or-later - */ - -package net.newpipe.app.navigation - -import androidx.navigation3.runtime.NavKey -import androidx.savedstate.serialization.SavedStateConfiguration -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.Serializable -import kotlinx.serialization.modules.SerializersModule -import kotlinx.serialization.modules.polymorphic - -/** - * Destinations for navigation in compose - */ -@Serializable -sealed interface Screen : NavKey { - - @Serializable - data object About : Screen -} - -/** - * Saved state configuration for screens - */ -@OptIn(ExperimentalSerializationApi::class) -internal val screenConfig = SavedStateConfiguration { - serializersModule = SerializersModule { - polymorphic(NavKey::class) { - subclassesOfSealed() - } - } -} diff --git a/shared/src/commonMain/kotlin/net/newpipe/app/screen/about/AboutScreen.kt b/shared/src/commonMain/kotlin/net/newpipe/app/screen/about/AboutScreen.kt index 4e75c0bb5..d8895cdd9 100644 --- a/shared/src/commonMain/kotlin/net/newpipe/app/screen/about/AboutScreen.kt +++ b/shared/src/commonMain/kotlin/net/newpipe/app/screen/about/AboutScreen.kt @@ -25,17 +25,19 @@ import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.util.fastForEachIndexed import kotlinx.coroutines.launch import net.newpipe.app.composable.TopAppBar +import net.newpipe.app.navigation.Navigator import net.newpipe.app.preview.ThemePreviewProvider import net.newpipe.app.screen.about.navigation.Page import net.newpipe.app.theme.currentServiceScheme import newpipe.shared.generated.resources.Res import newpipe.shared.generated.resources.title_activity_about import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject @Composable -fun AboutScreen(onNavigateUp: () -> Unit) { +fun AboutScreen(navigator: Navigator = koinInject()) { AboutScreenContent( - onNavigateUp = onNavigateUp + onNavigateUp = { navigator.navigateUp() } ) }