appdetails: improve ui states
This commit is contained in:
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user