compose: details: Encapsulate app-dependent navigation logic

Make it harder to accidentally navigate to an extra screen dependent upon
an app object which is unavailable.

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta
2025-08-27 14:31:01 +08:00
parent c3a4e4a4f0
commit 8f2a673c6f
9 changed files with 86 additions and 82 deletions

View File

@@ -6,31 +6,23 @@
package com.aurora.store.compose.navigation package com.aurora.store.compose.navigation
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import com.aurora.store.R
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* Destination (Screen) for navigation in compose * Destinations for navigation in compose
* @param label Label of the screen
* @param icon Optional icon for the screen; Must not be null if screen is a top-level destination
*/ */
@Parcelize @Parcelize
@Serializable @Serializable
sealed class Screen( sealed class Screen : NavKey, Parcelable {
@StringRes val label: Int? = null,
@DrawableRes val icon: Int? = null
) : NavKey, Parcelable {
companion object { companion object {
const val PARCEL_KEY = "SCREEN" const val PARCEL_KEY = "SCREEN"
} }
@Serializable @Serializable
data object Blacklist : Screen(label = R.string.title_blacklist_manager) data object Blacklist : Screen()
@Serializable @Serializable
data class DevProfile(val developerId: String): Screen() data class DevProfile(val developerId: String): Screen()
@@ -40,40 +32,4 @@ sealed class Screen(
@Serializable @Serializable
data object Search : Screen() data object Search : Screen()
/**
* Child screen of [AppDetails]; Avoid navigating to this screen directly.
*/
@Serializable
data object DetailsMore : Screen()
/**
* Child screen of [AppDetails]; Avoid navigating to this screen directly.
*/
@Serializable
data class DetailsScreenshot(val index: Int) : Screen()
/**
* Child screen of [AppDetails]; Avoid navigating to this screen directly.
*/
@Serializable
data object DetailsExodus : Screen()
/**
* Child screen of [AppDetails]; Avoid navigating to this screen directly.
*/
@Serializable
data object DetailsReview : Screen()
/**
* Child screen of [AppDetails]; Avoid navigating to this screen directly.
*/
@Serializable
data object DetailsPermission : Screen()
/**
* Child screen of [AppDetails]; Avoid navigating to this screen directly.
*/
@Serializable
data object DetailsManualDownload : Screen()
} }

View File

@@ -43,6 +43,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.runtime.NavKey
import coil3.compose.LocalAsyncImagePreviewHandler import coil3.compose.LocalAsyncImagePreviewHandler
import com.aurora.Constants.SHARE_URL import com.aurora.Constants.SHARE_URL
import com.aurora.extensions.appInfo import com.aurora.extensions.appInfo
@@ -74,6 +75,7 @@ import com.aurora.store.compose.ui.details.components.AppRatingAndReviews
import com.aurora.store.compose.ui.details.components.AppScreenshots import com.aurora.store.compose.ui.details.components.AppScreenshots
import com.aurora.store.compose.ui.details.components.AppTags import com.aurora.store.compose.ui.details.components.AppTags
import com.aurora.store.compose.ui.details.components.AppTesting import com.aurora.store.compose.ui.details.components.AppTesting
import com.aurora.store.compose.ui.details.navigation.ExtraScreen
import com.aurora.store.compose.ui.dev.DevProfileScreen import com.aurora.store.compose.ui.dev.DevProfileScreen
import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.AppState import com.aurora.store.data.model.AppState
@@ -212,7 +214,7 @@ private fun ScreenContentApp(
scaffoldDirective = scaffoldDirective.copy(maxHorizontalPartitions = 1) scaffoldDirective = scaffoldDirective.copy(maxHorizontalPartitions = 1)
} }
val scaffoldNavigator = rememberSupportingPaneScaffoldNavigator<Screen>( val scaffoldNavigator = rememberSupportingPaneScaffoldNavigator<NavKey>(
scaffoldDirective = scaffoldDirective, scaffoldDirective = scaffoldDirective,
adaptStrategies = SupportingPaneScaffoldDefaults.adaptStrategies( adaptStrategies = SupportingPaneScaffoldDefaults.adaptStrategies(
supportingPaneAdaptStrategy = AdaptStrategy supportingPaneAdaptStrategy = AdaptStrategy
@@ -224,13 +226,13 @@ private fun ScreenContentApp(
val shouldShowMenuOnMainPane = scaffoldNavigator val shouldShowMenuOnMainPane = scaffoldNavigator
.scaffoldValue[SupportingPaneScaffoldRole.Supporting] == PaneAdaptedValue.Hidden .scaffoldValue[SupportingPaneScaffoldRole.Supporting] == PaneAdaptedValue.Hidden
fun showMainPane() { fun onNavigateBack() {
coroutineScope.launch { coroutineScope.launch {
scaffoldNavigator.navigateBack() scaffoldNavigator.navigateBack()
} }
} }
fun showExtraPane(screen: Screen) { fun showExtraPane(screen: NavKey) {
coroutineScope.launch { coroutineScope.launch {
scaffoldNavigator.navigateTo(SupportingPaneScaffoldRole.Extra, screen) scaffoldNavigator.navigateTo(SupportingPaneScaffoldRole.Extra, screen)
} }
@@ -242,7 +244,7 @@ private fun ScreenContentApp(
when (menuItem) { when (menuItem) {
AppDetailsMenuItem.FAVORITE -> onFavorite() AppDetailsMenuItem.FAVORITE -> onFavorite()
AppDetailsMenuItem.MANUAL_DOWNLOAD -> { AppDetailsMenuItem.MANUAL_DOWNLOAD -> {
showExtraPane(Screen.DetailsManualDownload) showExtraPane(ExtraScreen.ManualDownload)
} }
AppDetailsMenuItem.SHARE -> context.share(app) AppDetailsMenuItem.SHARE -> context.share(app)
AppDetailsMenuItem.APP_INFO -> context.appInfo(app.packageName) AppDetailsMenuItem.APP_INFO -> context.appInfo(app.packageName)
@@ -295,7 +297,7 @@ private fun ScreenContentApp(
primaryActionDisplayName = primaryActionName, primaryActionDisplayName = primaryActionName,
secondaryActionDisplayName = stringResource(R.string.title_manual_download), secondaryActionDisplayName = stringResource(R.string.title_manual_download),
onPrimaryAction = onDownload, onPrimaryAction = onDownload,
onSecondaryAction = { showExtraPane(Screen.DetailsManualDownload) } onSecondaryAction = { showExtraPane(ExtraScreen.ManualDownload) }
) )
} }
} }
@@ -336,18 +338,18 @@ private fun ScreenContentApp(
HeaderComposable( HeaderComposable(
title = stringResource(R.string.details_more_about_app), title = stringResource(R.string.details_more_about_app),
subtitle = app.shortDescription, subtitle = app.shortDescription,
onClick = { showExtraPane(Screen.DetailsMore) } onClick = { showExtraPane(ExtraScreen.More) }
) )
AppScreenshots( AppScreenshots(
screenshots = app.screenshots, screenshots = app.screenshots,
onNavigateToScreenshot = { showExtraPane(Screen.DetailsScreenshot(it)) } onNavigateToScreenshot = { showExtraPane(ExtraScreen.Screenshot(it)) }
) )
AppRatingAndReviews( AppRatingAndReviews(
rating = app.rating, rating = app.rating,
featuredReviews = featuredReviews, featuredReviews = featuredReviews,
onNavigateToDetailsReview = { showExtraPane(Screen.DetailsReview) } onNavigateToDetailsReview = { showExtraPane(ExtraScreen.Review) }
) )
if (!isAnonymous && app.testingProgram?.isAvailable == true) { if (!isAnonymous && app.testingProgram?.isAvailable == true) {
@@ -370,7 +372,7 @@ private fun ScreenContentApp(
stringResource(R.string.details_no_permission) stringResource(R.string.details_no_permission)
}, },
onClick = if (app.permissions.isNotEmpty()) { onClick = if (app.permissions.isNotEmpty()) {
{ showExtraPane(Screen.DetailsPermission) } { showExtraPane(ExtraScreen.Permission) }
} else { } else {
null null
} }
@@ -386,7 +388,7 @@ private fun ScreenContentApp(
AppPrivacy( AppPrivacy(
report = exodusReport, report = exodusReport,
onNavigateToDetailsExodus = if (!exodusReport?.trackers.isNullOrEmpty()) { onNavigateToDetailsExodus = if (!exodusReport?.trackers.isNullOrEmpty()) {
{ showExtraPane(Screen.DetailsExodus) } { showExtraPane(ExtraScreen.Exodus) }
} else { } else {
null null
} }
@@ -447,35 +449,41 @@ private fun ScreenContentApp(
scaffoldNavigator.currentDestination?.contentKey?.let { screen -> scaffoldNavigator.currentDestination?.contentKey?.let { screen ->
AnimatedPane { AnimatedPane {
when (screen) { when (screen) {
is Screen.DetailsReview -> DetailsReviewScreen( is ExtraScreen.Review -> ReviewScreen(
packageName = app.packageName, packageName = app.packageName,
onNavigateUp = ::showMainPane onNavigateUp = ::onNavigateBack
) )
is Screen.DetailsExodus -> DetailsExodusScreen(
is ExtraScreen.Exodus -> ExodusScreen(
packageName = app.packageName, packageName = app.packageName,
onNavigateUp = ::showMainPane onNavigateUp = ::onNavigateBack
) )
is Screen.DetailsMore -> DetailsMoreScreen(
is ExtraScreen.More -> MoreScreen(
packageName = app.packageName, packageName = app.packageName,
onNavigateUp = ::showMainPane, onNavigateUp = ::onNavigateBack,
onNavigateToAppDetails = onNavigateToAppDetails onNavigateToAppDetails = onNavigateToAppDetails
) )
is Screen.DetailsPermission -> DetailsPermissionScreen(
is ExtraScreen.Permission -> PermissionScreen(
packageName = app.packageName, packageName = app.packageName,
onNavigateUp = ::showMainPane onNavigateUp = ::onNavigateBack
) )
is Screen.DetailsScreenshot -> DetailsScreenshotScreen(
is ExtraScreen.Screenshot -> ScreenshotScreen(
packageName = app.packageName, packageName = app.packageName,
index = screen.index, index = screen.index,
onNavigateUp = ::showMainPane onNavigateUp = ::onNavigateBack
) )
is Screen.DetailsManualDownload -> DetailsManualDownloadScreen(
is ExtraScreen.ManualDownload -> ManualDownloadScreen(
packageName = app.packageName, packageName = app.packageName,
onNavigateUp = ::showMainPane onNavigateUp = ::onNavigateBack
) )
is Screen.DevProfile -> DevProfileScreen( is Screen.DevProfile -> DevProfileScreen(
publisherId = app.developerName, publisherId = app.developerName,
onNavigateUp = ::showMainPane, onNavigateUp = ::onNavigateBack,
onNavigateToAppDetails = { onNavigateToAppDetails(it) } onNavigateToAppDetails = { onNavigateToAppDetails(it) }
) )

View File

@@ -37,7 +37,7 @@ import com.aurora.store.viewmodel.details.AppDetailsViewModel
import com.aurora.store.viewmodel.details.DetailsExodusViewModel import com.aurora.store.viewmodel.details.DetailsExodusViewModel
@Composable @Composable
fun DetailsExodusScreen( fun ExodusScreen(
packageName: String, packageName: String,
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
appDetailsViewModel: AppDetailsViewModel = hiltViewModel(key = packageName), appDetailsViewModel: AppDetailsViewModel = hiltViewModel(key = packageName),
@@ -110,7 +110,7 @@ private fun ScreenContent(
@Preview @Preview
@Composable @Composable
private fun DetailsExodusScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { private fun ExodusScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
ScreenContent( ScreenContent(
topAppBarTitle = app.displayName, topAppBarTitle = app.displayName,
version = app.versionName version = app.versionName

View File

@@ -55,7 +55,7 @@ import com.aurora.store.viewmodel.details.AppDetailsViewModel
import kotlinx.coroutines.android.awaitFrame import kotlinx.coroutines.android.awaitFrame
@Composable @Composable
fun DetailsManualDownloadScreen( fun ManualDownloadScreen(
packageName: String, packageName: String,
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
viewModel: AppDetailsViewModel = hiltViewModel(key = packageName), viewModel: AppDetailsViewModel = hiltViewModel(key = packageName),
@@ -182,7 +182,7 @@ private fun ScreenContent(
@Preview @Preview
@Composable @Composable
private fun DetailsManualDownloadScreenPreview( private fun ManualDownloadScreenPreview(
@PreviewParameter(AppPreviewProvider::class) app: App @PreviewParameter(AppPreviewProvider::class) app: App
) { ) {
ScreenContent( ScreenContent(

View File

@@ -47,7 +47,7 @@ import com.aurora.store.viewmodel.details.DetailsMoreViewModel
import java.util.Locale import java.util.Locale
@Composable @Composable
fun DetailsMoreScreen( fun MoreScreen(
packageName: String, packageName: String,
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
onNavigateToAppDetails: (packageName: String) -> Unit, onNavigateToAppDetails: (packageName: String) -> Unit,
@@ -192,7 +192,7 @@ private fun AppInfoMore(app: App) {
@Preview @Preview
@Composable @Composable
private fun DetailsMoreScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { private fun MoreScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) { CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) {
ScreenContent(app = app) ScreenContent(app = app)
} }

View File

@@ -37,7 +37,7 @@ import com.aurora.store.viewmodel.details.DetailsPermissionViewModel
import java.util.Locale import java.util.Locale
@Composable @Composable
fun DetailsPermissionScreen( fun PermissionScreen(
packageName: String, packageName: String,
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
appDetailsViewModel: AppDetailsViewModel = hiltViewModel(key = packageName), appDetailsViewModel: AppDetailsViewModel = hiltViewModel(key = packageName),
@@ -117,7 +117,7 @@ private fun ScreenContent(
@Preview @Preview
@Composable @Composable
private fun DetailsPermissionScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { private fun PermissionScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
ScreenContent( ScreenContent(
topAppBarTitle = app.displayName topAppBarTitle = app.displayName
) )

View File

@@ -54,7 +54,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlin.random.Random import kotlin.random.Random
@Composable @Composable
fun DetailsReviewScreen( fun ReviewScreen(
packageName: String, packageName: String,
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
appDetailsViewModel: AppDetailsViewModel = hiltViewModel(key = packageName), appDetailsViewModel: AppDetailsViewModel = hiltViewModel(key = packageName),
@@ -182,7 +182,7 @@ private fun FilterHeader(onClick: (filter: Review.Filter) -> Unit) {
@Preview @Preview
@Composable @Composable
private fun DetailsReviewScreenPreview(@PreviewParameter(ReviewPreviewProvider::class) review: Review) { private fun ReviewScreenPreview(@PreviewParameter(ReviewPreviewProvider::class) review: Review) {
val reviews = List(10) { review.copy(commentId = Random.nextInt().toString()) } val reviews = List(10) { review.copy(commentId = Random.nextInt().toString()) }
val reviewsFlow = MutableStateFlow(PagingData.from(reviews)).collectAsLazyPagingItems() val reviewsFlow = MutableStateFlow(PagingData.from(reviews)).collectAsLazyPagingItems()

View File

@@ -30,7 +30,7 @@ import com.aurora.store.compose.composables.details.ScreenshotComposable
import com.aurora.store.viewmodel.details.AppDetailsViewModel import com.aurora.store.viewmodel.details.AppDetailsViewModel
@Composable @Composable
fun DetailsScreenshotScreen( fun ScreenshotScreen(
packageName: String, packageName: String,
index: Int, index: Int,
onNavigateUp: () -> Unit, onNavigateUp: () -> Unit,
@@ -96,7 +96,7 @@ private fun ScreenContent(
@Preview @Preview
@Composable @Composable
private fun DetailsScreenshotScreenPreview() { private fun ScreenshotScreenPreview() {
ScreenContent( ScreenContent(
topAppBarTitle = stringResource(R.string.app_name) topAppBarTitle = stringResource(R.string.app_name)
) )

View File

@@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details.navigation
import android.os.Parcelable
import androidx.navigation3.runtime.NavKey
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
/**
* Extra destinations for app detail's screen
*
* All of these destinations require and show information related to an app and thus aren't part of
* the main navigation display class.
*/
@Parcelize
@Serializable
sealed class ExtraScreen : NavKey, Parcelable {
@Serializable
data object More : ExtraScreen()
@Serializable
data class Screenshot(val index: Int) : ExtraScreen()
@Serializable
data object Exodus : ExtraScreen()
@Serializable
data object Review : ExtraScreen()
@Serializable
data object Permission : ExtraScreen()
@Serializable
data object ManualDownload : ExtraScreen()
}