appdetails: improve ui states

This commit is contained in:
Rahul Patel
2026-04-27 21:06:39 +05:30
parent 60d732e6fe
commit 8fdc560907
6 changed files with 199 additions and 124 deletions

View File

@@ -6,6 +6,10 @@
package com.aurora.store.compose.ui.details
import android.content.ActivityNotFoundException
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -30,6 +34,7 @@ import androidx.compose.material3.adaptive.navigation.rememberSupportingPaneScaf
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -111,51 +116,65 @@ fun AppDetailsScreen(
LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
when (state) {
is AppState.Loading -> ScreenContentLoading(onNavigateUp = onNavigateUp)
AnimatedContent(
targetState = state,
contentKey = { stateKey(it, app) },
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "AppDetailsState"
) { currentState ->
when {
currentState is AppState.Loading || app == null ->
ScreenContentLoading(onNavigateUp = onNavigateUp)
is AppState.Error -> {
ScreenContentError(
onNavigateUp = onNavigateUp,
message = (state as AppState.Error).message
)
}
currentState is AppState.Error ->
ScreenContentError(
onNavigateUp = onNavigateUp,
message = currentState.message
)
else -> {
ScreenContentApp(
app = app!!,
featuredReviews = featuredReviews,
suggestions = suggestions,
isFavorite = favorite,
isAnonymous = viewModel.authProvider.isAnonymous,
state = state,
plexusScores = plexusScores,
dataSafetyReport = dataSafetyReport,
exodusReport = exodusReport,
onNavigateUp = onNavigateUp,
onNavigateToAppDetails = onNavigateToAppDetails,
onDownload = { requestedApp -> viewModel.enqueueDownload(requestedApp) },
onFavorite = { viewModel.toggleFavourite(app!!) },
onCancelDownload = { viewModel.cancelDownload(app!!) },
onUninstall = { AppInstaller.uninstall(context, packageName) },
onOpen = {
try {
context.startActivity(
PackageUtil.getLaunchIntent(context, packageName)
)
} catch (_: ActivityNotFoundException) {
context.toast(context.getString(R.string.unable_to_open))
}
},
onTestingSubscriptionChange = { subscribe ->
viewModel.updateTestingProgramStatus(packageName, subscribe)
},
forceSinglePane = forceSinglePane
)
else -> {
val loadedApp = app!!
ScreenContentApp(
app = loadedApp,
featuredReviews = featuredReviews,
suggestions = suggestions,
isFavorite = favorite,
isAnonymous = viewModel.authProvider.isAnonymous,
state = currentState,
plexusScores = plexusScores,
dataSafetyReport = dataSafetyReport,
exodusReport = exodusReport,
onNavigateUp = onNavigateUp,
onNavigateToAppDetails = onNavigateToAppDetails,
onDownload = { requestedApp -> viewModel.enqueueDownload(requestedApp) },
onFavorite = { viewModel.toggleFavourite(loadedApp) },
onCancelDownload = { viewModel.cancelDownload(loadedApp) },
onUninstall = { AppInstaller.uninstall(context, packageName) },
onOpen = {
try {
context.startActivity(
PackageUtil.getLaunchIntent(context, packageName)
)
} catch (_: ActivityNotFoundException) {
context.toast(context.getString(R.string.unable_to_open))
}
},
onTestingSubscriptionChange = { subscribe ->
viewModel.updateTestingProgramStatus(packageName, subscribe)
},
forceSinglePane = forceSinglePane
)
}
}
}
}
private fun stateKey(state: AppState, app: App?): String = when {
state is AppState.Loading || app == null -> "loading"
state is AppState.Error -> "error"
else -> "content"
}
/**
* Composable to show progress while fetching app details
*/
@@ -282,51 +301,70 @@ private fun ScreenContentApp(
@Composable
fun SetupActions() {
when (state) {
is AppState.Queued,
is AppState.Purchasing,
is AppState.Downloading -> {
Actions(
primaryActionDisplayName = stringResource(R.string.action_open),
secondaryActionDisplayName = stringResource(R.string.action_cancel),
isPrimaryActionEnabled = false,
onSecondaryAction = onCancelDownload
)
}
is AppState.Updatable -> {
Actions(
primaryActionDisplayName = stringResource(R.string.action_update),
secondaryActionDisplayName = stringResource(R.string.action_uninstall),
onPrimaryAction = ::onInstall,
onSecondaryAction = onUninstall
)
}
is AppState.Installed -> {
Actions(
primaryActionDisplayName = stringResource(R.string.action_open),
secondaryActionDisplayName = stringResource(R.string.action_uninstall),
onPrimaryAction = onOpen,
onSecondaryAction = onUninstall,
isPrimaryActionEnabled = PackageUtil
.getLaunchIntent(context, app.packageName) != null
)
}
else -> {
val primaryActionName = if (state is AppState.Archived) {
stringResource(R.string.action_unarchive)
} else {
if (app.isFree) stringResource(R.string.action_install) else app.price
AnimatedContent(
targetState = state,
contentKey = { it::class },
transitionSpec = { fadeIn() togetherWith fadeOut() },
label = "Actions"
) { currentState ->
when (currentState) {
is AppState.Queued,
is AppState.Purchasing,
is AppState.Downloading -> {
Actions(
primaryActionDisplayName = stringResource(R.string.action_open),
secondaryActionDisplayName = stringResource(R.string.action_cancel),
isPrimaryActionEnabled = false,
onSecondaryAction = onCancelDownload
)
}
Actions(
primaryActionDisplayName = primaryActionName,
secondaryActionDisplayName = stringResource(R.string.title_manual_download),
onPrimaryAction = ::onInstall,
onSecondaryAction = { showExtraPane(ExtraScreen.ManualDownload) }
)
is AppState.Verifying,
is AppState.Installing -> {
Actions(
primaryActionDisplayName = stringResource(R.string.action_open),
secondaryActionDisplayName = stringResource(R.string.action_cancel),
isPrimaryActionEnabled = false,
isSecondaryActionEnabled = false
)
}
is AppState.Updatable -> {
Actions(
primaryActionDisplayName = stringResource(R.string.action_update),
secondaryActionDisplayName = stringResource(R.string.action_uninstall),
onPrimaryAction = ::onInstall,
onSecondaryAction = onUninstall
)
}
is AppState.Installed -> {
val canOpen = remember(app.packageName) {
PackageUtil.getLaunchIntent(context, app.packageName) != null
}
Actions(
primaryActionDisplayName = stringResource(R.string.action_open),
secondaryActionDisplayName = stringResource(R.string.action_uninstall),
onPrimaryAction = onOpen,
onSecondaryAction = onUninstall,
isPrimaryActionEnabled = canOpen
)
}
else -> {
val primaryActionName = if (currentState is AppState.Archived) {
stringResource(R.string.action_unarchive)
} else {
if (app.isFree) stringResource(R.string.action_install) else app.price
}
Actions(
primaryActionDisplayName = primaryActionName,
secondaryActionDisplayName = stringResource(R.string.title_manual_download),
onPrimaryAction = ::onInstall,
onSecondaryAction = { showExtraPane(ExtraScreen.ManualDownload) }
)
}
}
}
}
@@ -405,7 +443,7 @@ private fun ScreenContentApp(
Privacy(
report = exodusReport,
onNavigateToDetailsExodus = if (exodusReport?.id != -1) {
onNavigateToDetailsExodus = if (exodusReport != null && exodusReport.id != -1) {
{ showExtraPane(ExtraScreen.Exodus) }
} else {
null
@@ -428,31 +466,33 @@ private fun ScreenContentApp(
TopAppBar(actions = { if (!shouldShowMenuOnMainPane) SetupMenu() })
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
Row(
modifier = Modifier.padding(dimensionResource(R.dimen.margin_medium)),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_suggestions),
contentDescription = null
)
Header(title = stringResource(R.string.pref_ui_similar_apps))
}
LazyColumn(
if (suggestions.isNotEmpty()) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(vertical = dimensionResource(R.dimen.padding_medium))
.padding(paddingValues)
) {
items(items = suggestions, key = { item -> item.id }) { app ->
LargeAppListItem(
app = app,
onClick = { onNavigateToAppDetails(app.packageName) }
Row(
modifier = Modifier.padding(dimensionResource(R.dimen.margin_medium)),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_suggestions),
contentDescription = null
)
Header(title = stringResource(R.string.pref_ui_similar_apps))
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(vertical = dimensionResource(R.dimen.padding_medium))
) {
items(items = suggestions, key = { item -> item.id }) { app ->
LargeAppListItem(
app = app,
onClick = { onNavigateToAppDetails(app.packageName) }
)
}
}
}
}

View File

@@ -137,15 +137,20 @@ fun Details(
Text(
style = MaterialTheme.typography.bodySmall,
text = when (cState) {
AppState.Installing::class,
AppState.Downloading::class -> {
"${Formatter.formatShortFileSize(context, speed)}/s" +
", " + CommonUtil.getETAString(context, timeRemaining)
}
AppState.Installing::class -> stringResource(R.string.action_installing)
AppState.Queued::class -> stringResource(R.string.status_queued)
AppState.Purchasing::class -> stringResource(R.string.preparing_to_install)
AppState.Purchasing::class ->
stringResource(R.string.preparing_to_download)
AppState.Verifying::class ->
stringResource(R.string.verifying_downloads)
else -> {
stringResource(R.string.version, versionName, versionCode)

View File

@@ -20,6 +20,7 @@ import com.aurora.store.util.PathUtil
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@@ -48,6 +49,12 @@ class DownloadHelper @Inject constructor(
val pagedDownloads get() = downloadDao.pagedDownloads()
/**
* One-shot read of the current download record for [packageName], if any.
*/
suspend fun getDownload(packageName: String): Download? =
downloadDao.downloads().first().find { it.packageName == packageName }
/**
* Removes failed download from the queue and starts observing for newly enqueued apps.
*/

View File

@@ -54,6 +54,7 @@ sealed class AppState {
data object Queued : AppState()
data object Purchasing : AppState()
data object Verifying : AppState()
data class Installing(val progress: Float) : AppState()
data class Error(val message: String?) : AppState()
data class Installed(val versionName: String, val versionCode: Long) : AppState()
@@ -65,15 +66,19 @@ sealed class AppState {
/**
* Whether there is some sort of ongoing process related to the app
*/
fun inProgress(): Boolean =
this is Downloading || this is Installing || this is Purchasing || this is Queued
fun inProgress(): Boolean = this is Downloading ||
this is Installing ||
this is Purchasing ||
this is Queued ||
this is Verifying
/**
* Progress of the process related to the app; 0 otherwise
* Determinate progress (0..100) of the process related to the app; 0 when no
* determinate value is available (e.g. installing, where the indicator should
* be indeterminate).
*/
fun progress(): Float = when (this) {
is Downloading -> progress
is Installing -> progress
else -> 0F
}
}

View File

@@ -33,6 +33,7 @@ import com.aurora.store.data.model.PlexusReport
import com.aurora.store.data.model.Report
import com.aurora.store.data.model.Scores
import com.aurora.store.data.providers.AuthProvider
import com.aurora.store.data.room.download.Download
import com.aurora.store.data.room.favourite.Favourite
import com.aurora.store.data.room.favourite.FavouriteDao
import com.aurora.store.util.CertUtil
@@ -154,7 +155,12 @@ class AppDetailsViewModel @Inject constructor(
_app.value = appDetailsHelper.getAppByPackageName(packageName).copy(
isInstalled = PackageUtil.isInstalled(context, packageName)
)
_state.value = defaultAppState
// Seed state from any in-flight download for this package so reopening
// the screen doesn't briefly flash the default install action while the
// download flow catches up.
_state.value = downloadHelper.getDownload(packageName)
?.let { stateFromDownload(it) }
?: defaultAppState
} catch (exception: Exception) {
Log.e(TAG, "Failed to fetch app details", exception)
_app.value = null
@@ -252,22 +258,32 @@ class AppDetailsViewModel @Inject constructor(
}.launchIn(viewModelScope)
download.filterNotNull().onEach {
_state.value = when (it.status) {
DownloadStatus.DOWNLOADING -> AppState.Downloading(
it.progress.toFloat(),
it.speed,
it.timeRemaining
)
DownloadStatus.QUEUED -> AppState.Queued
DownloadStatus.PURCHASING -> AppState.Purchasing
else -> defaultAppState
}
_state.value = stateFromDownload(it)
}.launchIn(viewModelScope)
}
// COMPLETED is bridged to Installing so the UI doesn't briefly fall back to the
// install action between download finishing and the installer's first event.
// A stale COMPLETED row after install actually finished is handled by the
// isInstalled check.
private fun stateFromDownload(download: Download): AppState = when (download.status) {
DownloadStatus.DOWNLOADING -> AppState.Downloading(
download.progress.toFloat(),
download.speed,
download.timeRemaining
)
DownloadStatus.QUEUED -> AppState.Queued
DownloadStatus.PURCHASING -> AppState.Purchasing
DownloadStatus.VERIFYING -> AppState.Verifying
DownloadStatus.COMPLETED -> if (isInstalled) defaultAppState else AppState.Installing(0F)
else -> defaultAppState
}
private fun fetchFeaturedReviews(packageName: String) {
viewModelScope.launch(Dispatchers.IO) {
try {