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 package com.aurora.store.compose.ui.details
import android.content.ActivityNotFoundException 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.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -111,51 +116,65 @@ fun AppDetailsScreen(
LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) } LaunchedEffect(key1 = packageName) { viewModel.fetchAppDetails(packageName) }
when (state) { AnimatedContent(
is AppState.Loading -> ScreenContentLoading(onNavigateUp = onNavigateUp) 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 -> { currentState is AppState.Error ->
ScreenContentError( ScreenContentError(
onNavigateUp = onNavigateUp, onNavigateUp = onNavigateUp,
message = (state as AppState.Error).message message = currentState.message
) )
}
else -> { else -> {
ScreenContentApp( val loadedApp = app!!
app = app!!, ScreenContentApp(
featuredReviews = featuredReviews, app = loadedApp,
suggestions = suggestions, featuredReviews = featuredReviews,
isFavorite = favorite, suggestions = suggestions,
isAnonymous = viewModel.authProvider.isAnonymous, isFavorite = favorite,
state = state, isAnonymous = viewModel.authProvider.isAnonymous,
plexusScores = plexusScores, state = currentState,
dataSafetyReport = dataSafetyReport, plexusScores = plexusScores,
exodusReport = exodusReport, dataSafetyReport = dataSafetyReport,
onNavigateUp = onNavigateUp, exodusReport = exodusReport,
onNavigateToAppDetails = onNavigateToAppDetails, onNavigateUp = onNavigateUp,
onDownload = { requestedApp -> viewModel.enqueueDownload(requestedApp) }, onNavigateToAppDetails = onNavigateToAppDetails,
onFavorite = { viewModel.toggleFavourite(app!!) }, onDownload = { requestedApp -> viewModel.enqueueDownload(requestedApp) },
onCancelDownload = { viewModel.cancelDownload(app!!) }, onFavorite = { viewModel.toggleFavourite(loadedApp) },
onUninstall = { AppInstaller.uninstall(context, packageName) }, onCancelDownload = { viewModel.cancelDownload(loadedApp) },
onOpen = { onUninstall = { AppInstaller.uninstall(context, packageName) },
try { onOpen = {
context.startActivity( try {
PackageUtil.getLaunchIntent(context, packageName) context.startActivity(
) PackageUtil.getLaunchIntent(context, packageName)
} catch (_: ActivityNotFoundException) { )
context.toast(context.getString(R.string.unable_to_open)) } catch (_: ActivityNotFoundException) {
} context.toast(context.getString(R.string.unable_to_open))
}, }
onTestingSubscriptionChange = { subscribe -> },
viewModel.updateTestingProgramStatus(packageName, subscribe) onTestingSubscriptionChange = { subscribe ->
}, viewModel.updateTestingProgramStatus(packageName, subscribe)
forceSinglePane = forceSinglePane },
) 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 * Composable to show progress while fetching app details
*/ */
@@ -282,51 +301,70 @@ private fun ScreenContentApp(
@Composable @Composable
fun SetupActions() { fun SetupActions() {
when (state) { AnimatedContent(
is AppState.Queued, targetState = state,
is AppState.Purchasing, contentKey = { it::class },
is AppState.Downloading -> { transitionSpec = { fadeIn() togetherWith fadeOut() },
Actions( label = "Actions"
primaryActionDisplayName = stringResource(R.string.action_open), ) { currentState ->
secondaryActionDisplayName = stringResource(R.string.action_cancel), when (currentState) {
isPrimaryActionEnabled = false, is AppState.Queued,
onSecondaryAction = onCancelDownload is AppState.Purchasing,
) is AppState.Downloading -> {
} Actions(
primaryActionDisplayName = stringResource(R.string.action_open),
is AppState.Updatable -> { secondaryActionDisplayName = stringResource(R.string.action_cancel),
Actions( isPrimaryActionEnabled = false,
primaryActionDisplayName = stringResource(R.string.action_update), onSecondaryAction = onCancelDownload
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
} }
Actions( is AppState.Verifying,
primaryActionDisplayName = primaryActionName, is AppState.Installing -> {
secondaryActionDisplayName = stringResource(R.string.title_manual_download), Actions(
onPrimaryAction = ::onInstall, primaryActionDisplayName = stringResource(R.string.action_open),
onSecondaryAction = { showExtraPane(ExtraScreen.ManualDownload) } 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( Privacy(
report = exodusReport, report = exodusReport,
onNavigateToDetailsExodus = if (exodusReport?.id != -1) { onNavigateToDetailsExodus = if (exodusReport != null && exodusReport.id != -1) {
{ showExtraPane(ExtraScreen.Exodus) } { showExtraPane(ExtraScreen.Exodus) }
} else { } else {
null null
@@ -428,31 +466,33 @@ private fun ScreenContentApp(
TopAppBar(actions = { if (!shouldShowMenuOnMainPane) SetupMenu() }) TopAppBar(actions = { if (!shouldShowMenuOnMainPane) SetupMenu() })
} }
) { paddingValues -> ) { paddingValues ->
Column( if (suggestions.isNotEmpty()) {
modifier = Modifier Column(
.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(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(vertical = dimensionResource(R.dimen.padding_medium)) .padding(paddingValues)
) { ) {
items(items = suggestions, key = { item -> item.id }) { app -> Row(
LargeAppListItem( modifier = Modifier.padding(dimensionResource(R.dimen.margin_medium)),
app = app, verticalAlignment = Alignment.CenterVertically
onClick = { onNavigateToAppDetails(app.packageName) } ) {
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( Text(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
text = when (cState) { text = when (cState) {
AppState.Installing::class,
AppState.Downloading::class -> { AppState.Downloading::class -> {
"${Formatter.formatShortFileSize(context, speed)}/s" + "${Formatter.formatShortFileSize(context, speed)}/s" +
", " + CommonUtil.getETAString(context, timeRemaining) ", " + CommonUtil.getETAString(context, timeRemaining)
} }
AppState.Installing::class -> stringResource(R.string.action_installing)
AppState.Queued::class -> stringResource(R.string.status_queued) 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 -> { else -> {
stringResource(R.string.version, versionName, versionCode) 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 dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@@ -48,6 +49,12 @@ class DownloadHelper @Inject constructor(
val pagedDownloads get() = downloadDao.pagedDownloads() 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. * 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 Queued : AppState()
data object Purchasing : AppState() data object Purchasing : AppState()
data object Verifying : AppState()
data class Installing(val progress: Float) : AppState() data class Installing(val progress: Float) : AppState()
data class Error(val message: String?) : AppState() data class Error(val message: String?) : AppState()
data class Installed(val versionName: String, val versionCode: Long) : 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 * Whether there is some sort of ongoing process related to the app
*/ */
fun inProgress(): Boolean = fun inProgress(): Boolean = this is Downloading ||
this is Downloading || this is Installing || this is Purchasing || this is Queued 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) { fun progress(): Float = when (this) {
is Downloading -> progress is Downloading -> progress
is Installing -> progress
else -> 0F 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.Report
import com.aurora.store.data.model.Scores import com.aurora.store.data.model.Scores
import com.aurora.store.data.providers.AuthProvider 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.Favourite
import com.aurora.store.data.room.favourite.FavouriteDao import com.aurora.store.data.room.favourite.FavouriteDao
import com.aurora.store.util.CertUtil import com.aurora.store.util.CertUtil
@@ -154,7 +155,12 @@ class AppDetailsViewModel @Inject constructor(
_app.value = appDetailsHelper.getAppByPackageName(packageName).copy( _app.value = appDetailsHelper.getAppByPackageName(packageName).copy(
isInstalled = PackageUtil.isInstalled(context, packageName) 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) { } catch (exception: Exception) {
Log.e(TAG, "Failed to fetch app details", exception) Log.e(TAG, "Failed to fetch app details", exception)
_app.value = null _app.value = null
@@ -252,22 +258,32 @@ class AppDetailsViewModel @Inject constructor(
}.launchIn(viewModelScope) }.launchIn(viewModelScope)
download.filterNotNull().onEach { download.filterNotNull().onEach {
_state.value = when (it.status) { _state.value = stateFromDownload(it)
DownloadStatus.DOWNLOADING -> AppState.Downloading(
it.progress.toFloat(),
it.speed,
it.timeRemaining
)
DownloadStatus.QUEUED -> AppState.Queued
DownloadStatus.PURCHASING -> AppState.Purchasing
else -> defaultAppState
}
}.launchIn(viewModelScope) }.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) { private fun fetchFeaturedReviews(packageName: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {

View File

@@ -514,6 +514,8 @@
<string name="details_compatibility_status_unsupported">Unsupported: Not functional</string> <string name="details_compatibility_status_unsupported">Unsupported: Not functional</string>
<string name="details_compatibility_status_unknown">Unknown: Not checked yet</string> <string name="details_compatibility_status_unknown">Unknown: Not checked yet</string>
<string name="preparing_to_install">Preparing to install</string> <string name="preparing_to_install">Preparing to install</string>
<string name="preparing_to_download">Preparing to download</string>
<string name="verifying_downloads">Verifying downloads</string>
<plurals name="permissions_required"> <plurals name="permissions_required">
<item quantity="one">Permission required</item> <item quantity="one">Permission required</item>
<item quantity="other">Permissions required</item> <item quantity="other">Permissions required</item>