Add one-click install for favourite apps
Addresses #1508 Add an 'Install all' action to the Favourites screen that bulk-fetches details and enqueues every installable favourite (skipping already installed apps and paid apps anonymous accounts can't acquire), gated behind a confirmation dialog that warns about per-app install prompts. Show live state on each favourite row: an installed tick that updates on install/uninstall events, and download/install progress mirroring the Updates screen. Hide 'Install all' once every favourite is installed. Treat only non-finished download statuses as in progress on both the Favourites and Updates rows so a dismissed system install prompt no longer leaves a row stuck showing 'Installing'.
This commit is contained in:
@@ -14,7 +14,11 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -30,49 +34,94 @@ import coil3.compose.AsyncImage
|
|||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import com.aurora.gplayapi.data.models.App
|
import com.aurora.gplayapi.data.models.App
|
||||||
|
import com.aurora.store.AuroraApp
|
||||||
import com.aurora.store.R
|
import com.aurora.store.R
|
||||||
|
import com.aurora.store.compose.composable.app.AnimatedAppIcon
|
||||||
import com.aurora.store.compose.preview.AppPreviewProvider
|
import com.aurora.store.compose.preview.AppPreviewProvider
|
||||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||||
import com.aurora.store.compose.theme.colorGreen
|
import com.aurora.store.compose.theme.colorGreen
|
||||||
import com.aurora.store.compose.theme.colorRed
|
import com.aurora.store.compose.theme.colorRed
|
||||||
|
import com.aurora.store.data.event.InstallerEvent
|
||||||
|
import com.aurora.store.data.model.DownloadStatus
|
||||||
|
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.util.PackageUtil
|
import com.aurora.store.util.PackageUtil
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun FavouriteListItem(
|
fun FavouriteListItem(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
favourite: Favourite,
|
favourite: Favourite,
|
||||||
|
download: Download? = null,
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
onClear: () -> Unit = {}
|
onClear: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val isInstalled = remember(favourite.packageName) {
|
|
||||||
PackageUtil.isInstalled(context, favourite.packageName)
|
// Seed from a one-shot check, then keep it live so the installed tick reflects the app
|
||||||
|
// being installed or removed without leaving the screen.
|
||||||
|
var isInstalled by remember(favourite.packageName) {
|
||||||
|
mutableStateOf(PackageUtil.isInstalled(context, favourite.packageName))
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(favourite.packageName) {
|
||||||
|
AuroraApp.events.installerEvent
|
||||||
|
.filter { it.packageName == favourite.packageName }
|
||||||
|
.collect { event ->
|
||||||
|
when (event) {
|
||||||
|
is InstallerEvent.Installed -> isInstalled = true
|
||||||
|
is InstallerEvent.Uninstalled -> isInstalled = false
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show download/install progress on the row; only non-finished statuses count as in
|
||||||
|
// progress, so a downloaded-but-not-installed (COMPLETED) app stays in its idle state.
|
||||||
|
val inProgress = download != null && !download.isFinished
|
||||||
|
val progress = if (download?.status == DownloadStatus.DOWNLOADING) {
|
||||||
|
download.progress.toFloat()
|
||||||
|
} else {
|
||||||
|
0f
|
||||||
|
}
|
||||||
|
|
||||||
|
val statusText = when {
|
||||||
|
download?.status == DownloadStatus.DOWNLOADING ->
|
||||||
|
"${stringResource(R.string.status_downloading)} • ${download.progress}%"
|
||||||
|
|
||||||
|
inProgress && download != null -> stringResource(download.status.localized)
|
||||||
|
else -> DateUtils.formatDateTime(context, favourite.added, DateUtils.FORMAT_SHOW_DATE)
|
||||||
|
}
|
||||||
|
|
||||||
RemovableListItem(onRemove = onClear) { triggerRemove ->
|
RemovableListItem(onRemove = onClear) { triggerRemove ->
|
||||||
AuroraListItem(
|
AuroraListItem(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
headline = favourite.displayName,
|
headline = favourite.displayName,
|
||||||
supporting = favourite.packageName,
|
supporting = favourite.packageName,
|
||||||
tertiary = DateUtils.formatDateTime(
|
tertiary = statusText,
|
||||||
context,
|
|
||||||
favourite.added,
|
|
||||||
DateUtils.FORMAT_SHOW_DATE
|
|
||||||
),
|
|
||||||
headlineStyle = MaterialTheme.typography.bodyMedium,
|
headlineStyle = MaterialTheme.typography.bodyMedium,
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
leading = {
|
leading = {
|
||||||
AsyncImage(
|
if (inProgress) {
|
||||||
model = ImageRequest.Builder(context)
|
AnimatedAppIcon(
|
||||||
.data(favourite.iconURL)
|
modifier = Modifier
|
||||||
.crossfade(true)
|
.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
|
||||||
.build(),
|
iconUrl = favourite.iconURL,
|
||||||
contentDescription = null,
|
inProgress = true,
|
||||||
contentScale = ContentScale.Crop,
|
progress = progress
|
||||||
modifier = Modifier
|
)
|
||||||
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
|
} else {
|
||||||
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
|
AsyncImage(
|
||||||
)
|
model = ImageRequest.Builder(context)
|
||||||
|
.data(favourite.iconURL)
|
||||||
|
.crossfade(true)
|
||||||
|
.build(),
|
||||||
|
contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier
|
||||||
|
.requiredSize(dimensionResource(R.dimen.icon_size_medium))
|
||||||
|
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
trailing = {
|
trailing = {
|
||||||
Row(
|
Row(
|
||||||
@@ -81,7 +130,7 @@ fun FavouriteListItem(
|
|||||||
dimensionResource(R.dimen.spacing_xsmall)
|
dimensionResource(R.dimen.spacing_xsmall)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
if (isInstalled) {
|
if (isInstalled && !inProgress) {
|
||||||
Icon(
|
Icon(
|
||||||
painter = painterResource(R.drawable.ic_check),
|
painter = painterResource(R.drawable.ic_check),
|
||||||
contentDescription = stringResource(R.string.title_installed),
|
contentDescription = stringResource(R.string.title_installed),
|
||||||
@@ -107,3 +156,18 @@ fun FavouriteListItem(
|
|||||||
private fun FavouriteListItemPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
|
private fun FavouriteListItemPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
|
||||||
FavouriteListItem(favourite = Favourite.fromApp(app, Favourite.Mode.MANUAL))
|
FavouriteListItem(favourite = Favourite.fromApp(app, Favourite.Mode.MANUAL))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PreviewWrapper(ThemePreviewProvider::class)
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun FavouriteListItemDownloadingPreview(
|
||||||
|
@PreviewParameter(AppPreviewProvider::class) app: App
|
||||||
|
) {
|
||||||
|
FavouriteListItem(
|
||||||
|
favourite = Favourite.fromApp(app, Favourite.Mode.MANUAL),
|
||||||
|
download = Download.fromApp(app).copy(
|
||||||
|
status = DownloadStatus.DOWNLOADING,
|
||||||
|
progress = 45
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ fun AppUpdateItem(
|
|||||||
onCancel: () -> Unit = {},
|
onCancel: () -> Unit = {},
|
||||||
onUnignore: (() -> Unit)? = null
|
onUnignore: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
|
// Only the INSTALLING status shows as "Installing"; a downloaded-but-not-installed
|
||||||
|
// (COMPLETED) app falls back to the "Update" action.
|
||||||
val inProgress = download != null && !download.isFinished
|
val inProgress = download != null && !download.isFinished
|
||||||
val pendingInstall = download?.status == DownloadStatus.COMPLETED
|
val installing = download?.status == DownloadStatus.INSTALLING
|
||||||
val progress = if (download?.status == DownloadStatus.DOWNLOADING) {
|
val progress = if (download?.status == DownloadStatus.DOWNLOADING) {
|
||||||
download.progress.toFloat()
|
download.progress.toFloat()
|
||||||
} else {
|
} else {
|
||||||
@@ -65,7 +67,7 @@ fun AppUpdateItem(
|
|||||||
AnimatedAppIcon(
|
AnimatedAppIcon(
|
||||||
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
|
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)),
|
||||||
iconUrl = update.iconURL,
|
iconUrl = update.iconURL,
|
||||||
inProgress = inProgress || pendingInstall,
|
inProgress = inProgress,
|
||||||
progress = progress
|
progress = progress
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -100,7 +102,7 @@ fun AppUpdateItem(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingInstall -> {
|
installing -> {
|
||||||
OutlinedButton(onClick = {}, enabled = false) {
|
OutlinedButton(onClick = {}, enabled = false) {
|
||||||
Text(stringResource(R.string.action_installing))
|
Text(stringResource(R.string.action_installing))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.aurora.store.compose.ui.commons
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.dimensionResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||||
|
import com.aurora.store.R
|
||||||
|
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation dialog for bulk-installing all favourite apps.
|
||||||
|
* @param count Number of favourites that will be processed
|
||||||
|
* @param onConfirm Callback on confirmation
|
||||||
|
* @param onDismiss Callback on dismissal
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun InstallFavouritesDialog(
|
||||||
|
count: Int = 0,
|
||||||
|
onConfirm: () -> Unit = {},
|
||||||
|
onDismiss: () -> Unit = {}
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
title = { Text(text = stringResource(R.string.title_install_favourites)) },
|
||||||
|
text = {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(
|
||||||
|
dimensionResource(R.dimen.spacing_small)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.install_favourites_summary, count))
|
||||||
|
Text(text = stringResource(R.string.install_favourites_warning))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onConfirm) {
|
||||||
|
Text(text = stringResource(R.string.action_install))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(text = stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewWrapper(ThemePreviewProvider::class)
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun InstallFavouritesDialogPreview() {
|
||||||
|
InstallFavouritesDialog(count = 5)
|
||||||
|
}
|
||||||
@@ -15,9 +15,13 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -25,11 +29,13 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.dimensionResource
|
import androidx.compose.ui.res.dimensionResource
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
import androidx.compose.ui.tooling.preview.PreviewWrapper
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.paging.LoadState
|
import androidx.paging.LoadState
|
||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.compose.LazyPagingItems
|
import androidx.paging.compose.LazyPagingItems
|
||||||
@@ -43,12 +49,15 @@ import com.aurora.store.compose.composable.ContainedLoadingIndicator
|
|||||||
import com.aurora.store.compose.composable.FavouriteListItem
|
import com.aurora.store.compose.composable.FavouriteListItem
|
||||||
import com.aurora.store.compose.composable.Placeholder
|
import com.aurora.store.compose.composable.Placeholder
|
||||||
import com.aurora.store.compose.composable.ScrollHint
|
import com.aurora.store.compose.composable.ScrollHint
|
||||||
|
import com.aurora.store.compose.composable.SectionHeader
|
||||||
import com.aurora.store.compose.composable.TopAppBar
|
import com.aurora.store.compose.composable.TopAppBar
|
||||||
import com.aurora.store.compose.navigation.Destination
|
import com.aurora.store.compose.navigation.Destination
|
||||||
import com.aurora.store.compose.preview.FavouritePreviewProvider
|
import com.aurora.store.compose.preview.FavouritePreviewProvider
|
||||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||||
|
import com.aurora.store.compose.ui.commons.InstallFavouritesDialog
|
||||||
import com.aurora.store.compose.ui.favourite.menu.FavouriteMenu
|
import com.aurora.store.compose.ui.favourite.menu.FavouriteMenu
|
||||||
import com.aurora.store.compose.ui.favourite.menu.MenuItem
|
import com.aurora.store.compose.ui.favourite.menu.MenuItem
|
||||||
|
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.viewmodel.all.FavouriteViewModel
|
import com.aurora.store.viewmodel.all.FavouriteViewModel
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
@@ -62,6 +71,24 @@ fun FavouriteScreen(
|
|||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val favourites = viewModel.favourites.collectAsLazyPagingItems()
|
val favourites = viewModel.favourites.collectAsLazyPagingItems()
|
||||||
|
val isEnqueuing by viewModel.isEnqueuing.collectAsStateWithLifecycle()
|
||||||
|
val hasInstallableFavourites by viewModel.hasInstallableFavourites
|
||||||
|
.collectAsStateWithLifecycle()
|
||||||
|
val downloads by viewModel.downloadsList.collectAsStateWithLifecycle()
|
||||||
|
val downloadMap = remember(downloads) { downloads.associateBy { it.packageName } }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.enqueueResult.collect { count ->
|
||||||
|
when {
|
||||||
|
count > 0 -> context.toast(
|
||||||
|
context.getString(R.string.toast_fav_install_enqueued, count)
|
||||||
|
)
|
||||||
|
|
||||||
|
count == 0 -> context.toast(R.string.toast_fav_install_none)
|
||||||
|
else -> context.toast(R.string.toast_fav_install_failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val documentOpenLauncher = rememberLauncherForActivityResult(
|
val documentOpenLauncher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.OpenDocument(),
|
contract = ActivityResultContracts.OpenDocument(),
|
||||||
@@ -89,7 +116,11 @@ fun FavouriteScreen(
|
|||||||
|
|
||||||
ScreenContent(
|
ScreenContent(
|
||||||
favourites = favourites,
|
favourites = favourites,
|
||||||
|
downloads = downloadMap,
|
||||||
|
isEnqueuing = isEnqueuing,
|
||||||
|
showInstallAll = hasInstallableFavourites,
|
||||||
onNavigateTo = onNavigateTo,
|
onNavigateTo = onNavigateTo,
|
||||||
|
onInstallAll = { viewModel.installAll() },
|
||||||
onRemoveFavourite = { packageName -> viewModel.removeFavourite(packageName) },
|
onRemoveFavourite = { packageName -> viewModel.removeFavourite(packageName) },
|
||||||
onImportFavourites = {
|
onImportFavourites = {
|
||||||
documentOpenLauncher.launch(arrayOf(JSON_MIME_TYPE))
|
documentOpenLauncher.launch(arrayOf(JSON_MIME_TYPE))
|
||||||
@@ -105,7 +136,11 @@ fun FavouriteScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ScreenContent(
|
private fun ScreenContent(
|
||||||
favourites: LazyPagingItems<Favourite> = emptyPagingItems(),
|
favourites: LazyPagingItems<Favourite> = emptyPagingItems(),
|
||||||
|
downloads: Map<String, Download> = emptyMap(),
|
||||||
|
isEnqueuing: Boolean = false,
|
||||||
|
showInstallAll: Boolean = true,
|
||||||
onNavigateTo: (Destination) -> Unit = {},
|
onNavigateTo: (Destination) -> Unit = {},
|
||||||
|
onInstallAll: () -> Unit = {},
|
||||||
onRemoveFavourite: (packageName: String) -> Unit = {},
|
onRemoveFavourite: (packageName: String) -> Unit = {},
|
||||||
onImportFavourites: () -> Unit = {},
|
onImportFavourites: () -> Unit = {},
|
||||||
onExportFavourites: () -> Unit = {}
|
onExportFavourites: () -> Unit = {}
|
||||||
@@ -117,6 +152,18 @@ private fun ScreenContent(
|
|||||||
* Save the initial loading state to make sure we don't replay the loading animation again.
|
* Save the initial loading state to make sure we don't replay the loading animation again.
|
||||||
*/
|
*/
|
||||||
var initialLoad by rememberSaveable { mutableStateOf(true) }
|
var initialLoad by rememberSaveable { mutableStateOf(true) }
|
||||||
|
var showInstallDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
if (showInstallDialog) {
|
||||||
|
InstallFavouritesDialog(
|
||||||
|
count = favourites.itemCount,
|
||||||
|
onConfirm = {
|
||||||
|
showInstallDialog = false
|
||||||
|
onInstallAll()
|
||||||
|
},
|
||||||
|
onDismiss = { showInstallDialog = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SetupMenu() {
|
fun SetupMenu() {
|
||||||
@@ -162,6 +209,29 @@ private fun ScreenContent(
|
|||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState
|
state = listState
|
||||||
) {
|
) {
|
||||||
|
item(key = "header") {
|
||||||
|
SectionHeader(
|
||||||
|
title = pluralStringResource(
|
||||||
|
R.plurals.favourites_count,
|
||||||
|
favourites.itemCount,
|
||||||
|
favourites.itemCount
|
||||||
|
),
|
||||||
|
trailing = if (showInstallAll) {
|
||||||
|
{
|
||||||
|
TextButton(
|
||||||
|
onClick = { showInstallDialog = true },
|
||||||
|
enabled = !isEnqueuing
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.action_install_all)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
items(
|
items(
|
||||||
count = favourites.itemCount,
|
count = favourites.itemCount,
|
||||||
key = favourites.itemKey { it.packageName }
|
key = favourites.itemKey { it.packageName }
|
||||||
@@ -170,6 +240,7 @@ private fun ScreenContent(
|
|||||||
FavouriteListItem(
|
FavouriteListItem(
|
||||||
modifier = Modifier.animateItem(),
|
modifier = Modifier.animateItem(),
|
||||||
favourite = favourite,
|
favourite = favourite,
|
||||||
|
download = downloads[favourite.packageName],
|
||||||
onClick = {
|
onClick = {
|
||||||
onNavigateTo(
|
onNavigateTo(
|
||||||
Destination.AppDetails(favourite.packageName)
|
Destination.AppDetails(favourite.packageName)
|
||||||
|
|||||||
@@ -14,20 +14,35 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.paging.PagingData
|
import androidx.paging.PagingData
|
||||||
import androidx.paging.cachedIn
|
import androidx.paging.cachedIn
|
||||||
import com.aurora.extensions.TAG
|
import com.aurora.extensions.TAG
|
||||||
|
import com.aurora.gplayapi.helpers.AppDetailsHelper
|
||||||
|
import com.aurora.store.AuroraApp
|
||||||
|
import com.aurora.store.data.event.InstallerEvent
|
||||||
|
import com.aurora.store.data.helper.DownloadHelper
|
||||||
import com.aurora.store.data.paging.GenericPagingSource.Companion.pager
|
import com.aurora.store.data.paging.GenericPagingSource.Companion.pager
|
||||||
|
import com.aurora.store.data.providers.AuthProvider
|
||||||
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.data.room.favourite.ImportExport
|
import com.aurora.store.data.room.favourite.ImportExport
|
||||||
|
import com.aurora.store.util.PackageUtil
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flowOn
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.onStart
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
@@ -35,12 +50,40 @@ import kotlinx.serialization.json.Json
|
|||||||
class FavouriteViewModel @Inject constructor(
|
class FavouriteViewModel @Inject constructor(
|
||||||
private val favouriteDao: FavouriteDao,
|
private val favouriteDao: FavouriteDao,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
|
private val appDetailsHelper: AppDetailsHelper,
|
||||||
|
private val downloadHelper: DownloadHelper,
|
||||||
|
private val authProvider: AuthProvider,
|
||||||
@ApplicationContext private val context: Context
|
@ApplicationContext private val context: Context
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _favourites = MutableStateFlow<PagingData<Favourite>>(PagingData.empty())
|
private val _favourites = MutableStateFlow<PagingData<Favourite>>(PagingData.empty())
|
||||||
val favourites = _favourites.asStateFlow()
|
val favourites = _favourites.asStateFlow()
|
||||||
|
|
||||||
|
val downloadsList get() = downloadHelper.downloadsList
|
||||||
|
|
||||||
|
private val _isEnqueuing = MutableStateFlow(false)
|
||||||
|
val isEnqueuing = _isEnqueuing.asStateFlow()
|
||||||
|
|
||||||
|
// Emits the number of favourites actually enqueued for install (0 when nothing was
|
||||||
|
// applicable, -1 when fetching details failed) so the screen can show accurate feedback.
|
||||||
|
private val _enqueueResult = MutableSharedFlow<Int>()
|
||||||
|
val enqueueResult = _enqueueResult.asSharedFlow()
|
||||||
|
|
||||||
|
// Whether at least one favourite is still not installed, recomputed whenever the favourites
|
||||||
|
// list changes or any app is installed/removed. Drives visibility of the "Install all"
|
||||||
|
// action so it hides once every favourite is already installed. Defaults to true to avoid
|
||||||
|
// briefly hiding the action before the first check completes.
|
||||||
|
val hasInstallableFavourites = combine(
|
||||||
|
favouriteDao.favourites(),
|
||||||
|
AuroraApp.events.installerEvent
|
||||||
|
.filter { it is InstallerEvent.Installed || it is InstallerEvent.Uninstalled }
|
||||||
|
.map { }
|
||||||
|
.onStart { emit(Unit) }
|
||||||
|
) { favourites, _ ->
|
||||||
|
favourites.any { !PackageUtil.isInstalled(context, it.packageName) }
|
||||||
|
}.flowOn(Dispatchers.IO)
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
getPagedFavourites()
|
getPagedFavourites()
|
||||||
}
|
}
|
||||||
@@ -86,6 +129,34 @@ class FavouriteViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches details for all favourites and enqueues the installable ones for download &
|
||||||
|
* install. Already installed (and up-to-date) apps are skipped, as are paid apps that an
|
||||||
|
* anonymous account can't acquire. The actual count enqueued is emitted via [enqueueResult].
|
||||||
|
*/
|
||||||
|
fun installAll() {
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
_isEnqueuing.value = true
|
||||||
|
try {
|
||||||
|
val packageNames = favouriteDao.favourites().first().map { it.packageName }
|
||||||
|
val installable = appDetailsHelper.getAppByPackageName(packageNames).filter { app ->
|
||||||
|
val needsInstall = !PackageUtil.isInstalled(context, app.packageName) ||
|
||||||
|
PackageUtil.isUpdatable(context, app.packageName, app.versionCode)
|
||||||
|
val acquirable = app.isFree || !authProvider.isAnonymous
|
||||||
|
needsInstall && acquirable
|
||||||
|
}
|
||||||
|
|
||||||
|
installable.forEach { downloadHelper.enqueueApp(it) }
|
||||||
|
_enqueueResult.emit(installable.size)
|
||||||
|
} catch (exception: Exception) {
|
||||||
|
Log.e(TAG, "Failed to enqueue favourites for install", exception)
|
||||||
|
_enqueueResult.emit(-1)
|
||||||
|
} finally {
|
||||||
|
_isEnqueuing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getPagedFavourites() {
|
private fun getPagedFavourites() {
|
||||||
pager { favouriteDao.pagedFavourites() }.flow
|
pager { favouriteDao.pagedFavourites() }.flow
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
<string name="action_granted">"Granted"</string>
|
<string name="action_granted">"Granted"</string>
|
||||||
<string name="action_ignore">"Ignore"</string>
|
<string name="action_ignore">"Ignore"</string>
|
||||||
<string name="action_install">"Install"</string>
|
<string name="action_install">"Install"</string>
|
||||||
|
<string name="action_install_all">"Install all"</string>
|
||||||
<string name="action_install_microG">"Install microG Bundle"</string>
|
<string name="action_install_microG">"Install microG Bundle"</string>
|
||||||
<string name="action_installations">"Installations"</string>
|
<string name="action_installations">"Installations"</string>
|
||||||
<string name="action_installing">"Installing"</string>
|
<string name="action_installing">"Installing"</string>
|
||||||
@@ -296,6 +297,9 @@
|
|||||||
<string name="title_installation">"Installation"</string>
|
<string name="title_installation">"Installation"</string>
|
||||||
<string name="title_installed">"Installed"</string>
|
<string name="title_installed">"Installed"</string>
|
||||||
<string name="title_installer">"App installer"</string>
|
<string name="title_installer">"App installer"</string>
|
||||||
|
<string name="title_install_favourites">"Install favourites?"</string>
|
||||||
|
<string name="install_favourites_summary">"This will download and install %1$d apps."</string>
|
||||||
|
<string name="install_favourites_warning">"You may see a separate installation prompt for each app."</string>
|
||||||
<string name="title_language">"Language"</string>
|
<string name="title_language">"Language"</string>
|
||||||
<string name="title_library">"Library"</string>
|
<string name="title_library">"Library"</string>
|
||||||
<string name="title_no_network">"No network"</string>
|
<string name="title_no_network">"No network"</string>
|
||||||
@@ -508,6 +512,9 @@
|
|||||||
<string name="toast_fav_export_failed">Failed to export favourites!</string>
|
<string name="toast_fav_export_failed">Failed to export favourites!</string>
|
||||||
<string name="toast_fav_import_success">Favourites imported!</string>
|
<string name="toast_fav_import_success">Favourites imported!</string>
|
||||||
<string name="toast_fav_export_success">Favourites exported!</string>
|
<string name="toast_fav_export_success">Favourites exported!</string>
|
||||||
|
<string name="toast_fav_install_enqueued">"Installing %1$d apps"</string>
|
||||||
|
<string name="toast_fav_install_none">"Nothing to install"</string>
|
||||||
|
<string name="toast_fav_install_failed">"Failed to fetch app details"</string>
|
||||||
|
|
||||||
<!-- BlacklistFragment-->
|
<!-- BlacklistFragment-->
|
||||||
<string name="toast_black_import_failed">Failed to import blacklist!</string>
|
<string name="toast_black_import_failed">Failed to import blacklist!</string>
|
||||||
@@ -586,6 +593,10 @@
|
|||||||
<item quantity="one">Permission required</item>
|
<item quantity="one">Permission required</item>
|
||||||
<item quantity="other">Permissions required</item>
|
<item quantity="other">Permissions required</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<plurals name="favourites_count">
|
||||||
|
<item quantity="one">%1$d favourite</item>
|
||||||
|
<item quantity="other">%1$d favourites</item>
|
||||||
|
</plurals>
|
||||||
<plurals name="notification_installed_summary">
|
<plurals name="notification_installed_summary">
|
||||||
<item quantity="one">%d app installed</item>
|
<item quantity="one">%d app installed</item>
|
||||||
<item quantity="other">%d apps installed</item>
|
<item quantity="other">%d apps installed</item>
|
||||||
|
|||||||
Reference in New Issue
Block a user