From 0c1786e77b153001457bcc775b923cbb89517231 Mon Sep 17 00:00:00 2001 From: Rahul Patel Date: Wed, 6 May 2026 01:17:05 +0530 Subject: [PATCH] microg: Improve bundle installation state & UX --- .../store/compose/composable/LinkListItem.kt | 2 +- .../aurora/store/compose/composable/MicroG.kt | 207 ++++++++++++++---- .../store/compose/composable/ScrollHint.kt | 116 ++++++++++ .../store/compose/ui/details/MicroGScreen.kt | 135 +++++++----- .../store/compose/ui/onboarding/MicroGPage.kt | 91 +++++--- .../compose/ui/onboarding/OnboardingScreen.kt | 158 ++++++------- .../aurora/store/data/model/ExternalItem.kt | 17 ++ .../aurora/store/data/model/InstallStatus.kt | 17 ++ .../viewmodel/onboarding/MicroGViewModel.kt | 119 +++++++--- app/src/main/res/values/strings.xml | 1 + 10 files changed, 628 insertions(+), 235 deletions(-) create mode 100644 app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt create mode 100644 app/src/main/java/com/aurora/store/data/model/ExternalItem.kt create mode 100644 app/src/main/java/com/aurora/store/data/model/InstallStatus.kt diff --git a/app/src/main/java/com/aurora/store/compose/composable/LinkListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/LinkListItem.kt index e1fddbba0..1b0455ed2 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/LinkListItem.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/LinkListItem.kt @@ -54,7 +54,7 @@ fun LinkListItem( modifier = modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(dimensionResource(R.dimen.padding_medium)), + .padding(dimensionResource(R.dimen.padding_small)), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) ) { diff --git a/app/src/main/java/com/aurora/store/compose/composable/MicroG.kt b/app/src/main/java/com/aurora/store/compose/composable/MicroG.kt index 25d9e859f..887848043 100644 --- a/app/src/main/java/com/aurora/store/compose/composable/MicroG.kt +++ b/app/src/main/java/com/aurora/store/compose/composable/MicroG.kt @@ -5,15 +5,18 @@ package com.aurora.store.compose.composable +import android.text.format.Formatter import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Checkbox -import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -23,8 +26,10 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview @@ -32,20 +37,27 @@ import androidx.compose.ui.tooling.preview.PreviewWrapper import androidx.compose.ui.util.fastForEach import com.aurora.extensions.browse import com.aurora.store.R +import com.aurora.store.compose.composable.app.AnimatedAppIcon import com.aurora.store.compose.preview.ThemePreviewProvider +import com.aurora.store.data.model.ExternalItem +import com.aurora.store.data.model.InstallStatus import com.aurora.store.data.model.Link +import com.aurora.store.util.CommonUtil.getETAString import com.aurora.store.viewmodel.onboarding.MicroGUIState /** * Composable to display suggestion to install microG * @param modifier Modifier for the composable * @param onInstall Callback when user requests installing microG bundle + * @param onRetry Callback when user retries after a failed download + * @param onTOSChecked Callback when user toggles the TOS checkbox */ @Composable fun MicroG( modifier: Modifier = Modifier, uiState: MicroGUIState, onInstall: () -> Unit = {}, + onRetry: () -> Unit = {}, onTOSChecked: (Boolean) -> Unit = {} ) { val context = LocalContext.current @@ -103,55 +115,176 @@ fun MicroG( Column( modifier = Modifier .fillMaxWidth() - .padding(vertical = dimensionResource(R.dimen.padding_xlarge)), + .padding(vertical = dimensionResource(R.dimen.padding_large)), verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small)) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy( - dimensionResource(R.dimen.padding_small) - ) - ) { - Checkbox( - checked = isChecked, - onCheckedChange = { - isChecked = it - onTOSChecked(it) - }, - enabled = !uiState.isInstalled && !uiState.isDownloading - ) + uiState.items.fastForEach { item -> + MicroGItemRow(item = item) + } + + if (!uiState.isInProgress && !uiState.isInstalled && !uiState.hasFailed) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.padding_small) + ) + ) { + Checkbox( + checked = isChecked, + onCheckedChange = { + isChecked = it + onTOSChecked(it) + } + ) + Text( + text = stringResource(R.string.onboarding_gms_agreement), + style = MaterialTheme.typography.bodyMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + if (!uiState.isInProgress && !uiState.isInstalled && !uiState.isOnline) { Text( - text = stringResource(R.string.onboarding_gms_agreement), - style = MaterialTheme.typography.bodyMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis + text = stringResource(R.string.check_connectivity), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error ) } - Button( - onClick = onInstall, - enabled = isChecked && !uiState.isDownloading && !uiState.isInstalled, - modifier = Modifier.fillMaxWidth() - ) { - Text( - text = if (uiState.isDownloading) { - stringResource(R.string.action_installing) + + if (!uiState.isInProgress && !uiState.isInstalled) { + val isRetry = uiState.hasFailed + Button( + onClick = if (isRetry) onRetry else onInstall, + enabled = uiState.isOnline && (isRetry || isChecked), + colors = if (isRetry) { + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError + ) } else { - stringResource(R.string.action_install_microG) - } - ) - } - if (uiState.isDownloading) { - LinearProgressIndicator( + ButtonDefaults.buttonColors() + }, modifier = Modifier.fillMaxWidth() - ) + ) { + Text( + text = if (isRetry) { + stringResource(R.string.action_retry) + } else { + stringResource(R.string.action_install_microG) + } + ) + } } } } } +@Composable +private fun MicroGItemRow(item: ExternalItem) { + val context = LocalContext.current + val inProgress = item.status == InstallStatus.DOWNLOADING || + item.status == InstallStatus.INSTALLING + + val statusText = stringResource(item.status.localized) + val statusColor = when (item.status) { + InstallStatus.FAILED -> MaterialTheme.colorScheme.error + InstallStatus.INSTALLED -> MaterialTheme.colorScheme.primary + else -> Color.Unspecified + } + val detailText = when (item.status) { + InstallStatus.DOWNLOADING -> { + val downloaded = Formatter.formatShortFileSize( + context, + (item.size * item.progress / 100L).coerceAtLeast(0L) + ) + val total = Formatter.formatShortFileSize(context, item.size) + val speed = Formatter.formatShortFileSize(context, item.speed) + val eta = getETAString(context, item.timeRemaining) + "$downloaded / $total • $speed/s • $eta" + } + + else -> Formatter.formatShortFileSize(context, item.size) + } + val trailingIcon = when (item.status) { + InstallStatus.INSTALLED -> R.drawable.ic_check + InstallStatus.FAILED -> R.drawable.ic_menu_about + else -> null + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.padding_small), + vertical = dimensionResource(R.dimen.padding_xsmall) + ), + verticalAlignment = Alignment.CenterVertically + ) { + AnimatedAppIcon( + modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_medium)), + iconUrl = item.iconURL, + inProgress = inProgress, + progress = item.progress.toFloat() + ) + Column( + modifier = Modifier + .weight(1F) + .padding(horizontal = dimensionResource(R.dimen.margin_small)) + ) { + Text( + text = item.displayName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = statusText, + style = MaterialTheme.typography.bodySmall, + color = statusColor + ) + Text( + text = detailText, + style = MaterialTheme.typography.bodySmall + ) + } + if (trailingIcon != null) { + Icon( + painter = painterResource(trailingIcon), + contentDescription = statusText, + tint = statusColor, + modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_default)) + ) + } + } +} + +private fun previewItems( + companionState: InstallStatus = InstallStatus.PENDING, + companionProgress: Int = 0 +): List = listOf( + ExternalItem( + packageName = "", + displayName = "microG Services", + iconURL = "", + size = 98_765_432L, + status = companionState, + progress = companionProgress + ), + ExternalItem( + packageName = "", + displayName = "microG Companion", + iconURL = "", + size = 1_234_567L, + status = companionState, + progress = companionProgress + ) +) + @PreviewWrapper(ThemePreviewProvider::class) @Preview(showBackground = true) @Composable -private fun MicroGPreview() { - MicroG(uiState = MicroGUIState()) +private fun MicroGReadyPreview() { + MicroG(uiState = MicroGUIState(items = previewItems())) } diff --git a/app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt b/app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt new file mode 100644 index 000000000..3597f508d --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/ScrollHint.kt @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: 2025 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composable + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.EaseInOut +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.animateScrollBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment.Companion.Center +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.aurora.store.R +import kotlin.math.roundToInt +import kotlinx.coroutines.launch + +@Composable +fun ScrollHint( + listState: LazyListState, + modifier: Modifier = Modifier, + bottomPadding: Dp = 72.dp, // keep above bottom bar + autoHideOnScroll: Boolean = true, + enableBounce: Boolean = true, + onClickScrollOffset: Float = 300f +) { + val coroutineScope = rememberCoroutineScope() + + val canScrollForward by remember { + derivedStateOf { listState.canScrollForward } + } + + var userScrolled by remember { mutableStateOf(false) } + + LaunchedEffect(listState.isScrollInProgress) { + if (listState.isScrollInProgress) { + userScrolled = true + } + } + + val visible = canScrollForward && (!autoHideOnScroll || !userScrolled) + + val offsetY by if (enableBounce && visible) { + val transition = rememberInfiniteTransition(label = "scrollHintBounce") + transition.animateFloat( + initialValue = 0f, + targetValue = 10f, + animationSpec = infiniteRepeatable( + animation = tween(800, easing = EaseInOut), + repeatMode = RepeatMode.Reverse + ), + label = "offsetY" + ) + } else { + remember { mutableFloatStateOf(0f) } + } + + AnimatedVisibility( + visible = visible, + modifier = modifier + .fillMaxWidth() + .padding(bottom = bottomPadding), + enter = fadeIn() + slideInVertically { it / 2 }, + exit = fadeOut() + ) { + Box( + contentAlignment = Center, + modifier = Modifier.fillMaxWidth() + ) { + Icon( + painter = painterResource(id = R.drawable.ic_arrow_down), + contentDescription = "Scroll down", + modifier = Modifier + .size(32.dp) + .offset { + IntOffset(0, offsetY.roundToInt()) + } + .alpha(0.7f) + .clickable { + coroutineScope.launch { + listState.animateScrollBy(onClickScrollOffset) + } + } + ) + } + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/MicroGScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/MicroGScreen.kt index 9299b7f01..a15abfdd9 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/MicroGScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/MicroGScreen.kt @@ -6,21 +6,24 @@ package com.aurora.store.compose.ui.details import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Button import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.adaptive.WindowAdaptiveInfo import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource @@ -29,6 +32,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.aurora.extensions.adaptiveNavigationIcon @@ -37,6 +41,7 @@ import com.aurora.extensions.toast import com.aurora.gplayapi.data.models.App import com.aurora.store.R import com.aurora.store.compose.composable.MicroG +import com.aurora.store.compose.composable.ScrollHint import com.aurora.store.compose.composable.TopAppBar import com.aurora.store.compose.preview.AppPreviewProvider import com.aurora.store.compose.preview.ThemePreviewProvider @@ -66,6 +71,7 @@ fun MicroGScreen( uiState = viewModel.uiState, onNavigateUp = onNavigateUp, onInstall = { viewModel.downloadMicroG() }, + onRetry = { viewModel.retryDownload() }, onIgnore = onIgnore ) } @@ -76,10 +82,12 @@ private fun ScreenContent( uiState: MicroGUIState = MicroGUIState(), onNavigateUp: () -> Unit = {}, onInstall: () -> Unit = {}, + onRetry: () -> Unit = {}, onIgnore: (Boolean) -> Unit = {}, windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo() ) { val context = LocalContext.current + val listState = rememberLazyListState() Scaffold( topBar = { @@ -88,66 +96,83 @@ private fun ScreenContent( navigationIcon = windowAdaptiveInfo.adaptiveNavigationIcon, onNavigateUp = onNavigateUp ) + }, + bottomBar = { + Surface(shadowElevation = 4.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = dimensionResource(R.dimen.padding_medium)) + .padding(vertical = dimensionResource(R.dimen.padding_small)), + horizontalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.padding_medium) + ) + ) { + FilledTonalButton( + modifier = Modifier.weight(1F), + onClick = onNavigateUp + ) { + Text( + text = stringResource(R.string.action_cancel), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Button( + modifier = Modifier.weight(1F), + onClick = { onIgnore(uiState.isInstalled) }, + enabled = !uiState.isInProgress + ) { + Text( + text = if (uiState.isInstalled) { + stringResource(R.string.action_install) + } else { + stringResource(R.string.action_ignore) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } } ) { paddingValues -> - Column( - modifier = Modifier - .padding(paddingValues) - .fillMaxSize() - .padding(dimensionResource(R.dimen.padding_medium)) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.SpaceBetween - ) { - MicroG( - modifier = Modifier.weight(1F), - onInstall = { - when { - PermissionProvider.isGranted( - context, - PermissionType.INSTALL_UNKNOWN_APPS - ) -> { - onInstall() - } - - else -> context.toast(R.string.permissions_denied) - } - }, - uiState = uiState - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy( - dimensionResource(R.dimen.padding_medium) - ) + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .padding(horizontal = dimensionResource(R.dimen.padding_medium)), + state = listState ) { - FilledTonalButton( - modifier = Modifier.weight(1F), - onClick = onNavigateUp - ) { - Text( - text = stringResource(R.string.action_cancel), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + item { + MicroG( + modifier = Modifier.fillMaxWidth(), + onInstall = { + when { + PermissionProvider.isGranted( + context, + PermissionType.INSTALL_UNKNOWN_APPS + ) -> { + onInstall() + } - Button( - modifier = Modifier.weight(1F), - onClick = { onIgnore(uiState.isInstalled) }, - enabled = !uiState.isDownloading - ) { - Text( - text = if (uiState.isInstalled) { - stringResource(R.string.action_install) - } else { - stringResource(R.string.action_ignore) + else -> context.toast(R.string.permissions_denied) + } }, - maxLines = 1, - overflow = TextOverflow.Ellipsis + onRetry = onRetry, + uiState = uiState ) } } + + ScrollHint( + listState = listState, + bottomPadding = 5.dp, + modifier = Modifier.align(Alignment.BottomCenter) + ) } } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/onboarding/MicroGPage.kt b/app/src/main/java/com/aurora/store/compose/ui/onboarding/MicroGPage.kt index 2b0c6619c..671597fb7 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/onboarding/MicroGPage.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/onboarding/MicroGPage.kt @@ -5,15 +5,16 @@ package com.aurora.store.compose.ui.onboarding -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource @@ -21,10 +22,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.aurora.extensions.toast import com.aurora.store.R import com.aurora.store.compose.composable.MicroG +import com.aurora.store.compose.composable.ScrollHint import com.aurora.store.compose.preview.ThemePreviewProvider import com.aurora.store.data.model.PermissionType import com.aurora.store.data.providers.PermissionProvider @@ -39,6 +42,7 @@ fun MicroGPage( ScreenContent( uiState = viewModel.uiState, onInstall = { viewModel.downloadMicroG() }, + onRetry = { viewModel.retryDownload() }, onMicrogTOSChecked = onMicrogTOSChecked ) } @@ -47,46 +51,61 @@ fun MicroGPage( private fun ScreenContent( uiState: MicroGUIState, onInstall: () -> Unit = {}, + onRetry: () -> Unit = {}, onMicrogTOSChecked: (Boolean) -> Unit = {} ) { val context = LocalContext.current + val listState = rememberLazyListState() - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = dimensionResource(R.dimen.padding_medium)) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) - ) { - Column( - modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium)) + Box(modifier = Modifier.fillMaxSize()) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = dimensionResource(R.dimen.padding_medium)), + state = listState ) { - Text( - text = stringResource(R.string.onboarding_title_gsf), - style = MaterialTheme.typography.headlineLargeEmphasized, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = stringResource(R.string.onboarding_title_gsf_desc), - style = MaterialTheme.typography.titleMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + item { + Column( + modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium)) + ) { + Text( + text = stringResource(R.string.onboarding_title_gsf), + style = MaterialTheme.typography.headlineLargeEmphasized, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = stringResource(R.string.onboarding_title_gsf_desc), + style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + + MicroG( + onInstall = { + when { + PermissionProvider.isGranted( + context, + PermissionType.INSTALL_UNKNOWN_APPS + ) -> { + onInstall() + } + + else -> context.toast(R.string.permissions_denied) + } + }, + onRetry = onRetry, + uiState = uiState, + onTOSChecked = onMicrogTOSChecked + ) + } } - MicroG( - onInstall = { - when { - PermissionProvider.isGranted(context, PermissionType.INSTALL_UNKNOWN_APPS) -> { - onInstall() - } - - else -> context.toast(R.string.permissions_denied) - } - }, - uiState = uiState, - onTOSChecked = onMicrogTOSChecked + ScrollHint( + listState = listState, + bottomPadding = 5.dp, + modifier = Modifier.align(Alignment.BottomCenter) ) } } diff --git a/app/src/main/java/com/aurora/store/compose/ui/onboarding/OnboardingScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/onboarding/OnboardingScreen.kt index a68c009dc..89c10872d 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/onboarding/OnboardingScreen.kt @@ -11,12 +11,14 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.Button import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -74,83 +76,6 @@ private fun ScreenContent( fun isFinalPage(): Boolean = pagerState.currentPage == (pagerState.pageCount - 1) - @Composable - fun SetupActions(uiState: OnboardingUiState) { - val horizontalButtonPadding = when { - windowAdaptiveInfo.isWindowCompact -> dimensionResource(R.dimen.padding_medium) - else -> dimensionResource(R.dimen.padding_xlarge) - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding( - vertical = dimensionResource(R.dimen.padding_medium), - horizontal = horizontalButtonPadding - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy( - dimensionResource(R.dimen.padding_medium), - Alignment.End - ) - ) { - val buttonWidthModifier = when { - windowAdaptiveInfo.isWindowCompact -> Modifier.weight(1F) - else -> Modifier.widthIn(min = dimensionResource(R.dimen.width_button)) - } - - TextButton( - modifier = buttonWidthModifier, - onClick = { - when (pagerState.currentPage) { - 0 -> onFinishOnboarding() - - else -> { - coroutineScope.launch { - pagerState.animateScrollToPage(pagerState.currentPage - 1) - } - } - } - } - ) { - Text( - text = when (pagerState.currentPage) { - 0 -> stringResource(R.string.action_skip) - else -> stringResource(R.string.action_back) - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - - Button( - modifier = buttonWidthModifier, - enabled = !isFinalPage() || - (!uiState.isMicroBundleChecked || uiState.isMicroGBundleInstalled), - onClick = { - when { - isFinalPage() -> onFinishOnboarding() - - else -> { - coroutineScope.launch { - pagerState.animateScrollToPage(pagerState.currentPage + 1) - } - } - } - } - ) { - Text( - text = when { - isFinalPage() -> stringResource(R.string.action_finish) - else -> stringResource(R.string.action_next) - }, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - Scaffold( topBar = { TopAppBar( @@ -159,6 +84,83 @@ private fun ScreenContent( PageIndicator(totalPages = pages.size, currentPage = pagerState.currentPage) } ) + }, + bottomBar = { + Surface(shadowElevation = 4.dp) { + Row( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding( + vertical = dimensionResource(R.dimen.padding_medium), + horizontal = if (windowAdaptiveInfo.isWindowCompact) { + dimensionResource(R.dimen.padding_medium) + } else { + dimensionResource(R.dimen.padding_xlarge) + } + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.padding_medium), + Alignment.End + ) + ) { + val buttonWidthModifier = when { + windowAdaptiveInfo.isWindowCompact -> Modifier.weight(1F) + else -> Modifier.widthIn(min = dimensionResource(R.dimen.width_button)) + } + + TextButton( + modifier = buttonWidthModifier, + onClick = { + when (pagerState.currentPage) { + 0 -> onFinishOnboarding() + + else -> { + coroutineScope.launch { + pagerState.animateScrollToPage(pagerState.currentPage - 1) + } + } + } + } + ) { + Text( + text = when (pagerState.currentPage) { + 0 -> stringResource(R.string.action_skip) + else -> stringResource(R.string.action_back) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Button( + modifier = buttonWidthModifier, + enabled = !isFinalPage() || + (!uiState.isMicroBundleChecked || uiState.isMicroGBundleInstalled), + onClick = { + when { + isFinalPage() -> onFinishOnboarding() + + else -> { + coroutineScope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } + } + } + ) { + Text( + text = when { + isFinalPage() -> stringResource(R.string.action_finish) + else -> stringResource(R.string.action_next) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } } ) { paddingValues -> Column( @@ -193,8 +195,6 @@ private fun ScreenContent( } } } - - SetupActions(uiState) } } } diff --git a/app/src/main/java/com/aurora/store/data/model/ExternalItem.kt b/app/src/main/java/com/aurora/store/data/model/ExternalItem.kt new file mode 100644 index 000000000..07259e950 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/model/ExternalItem.kt @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2025 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.data.model + +data class ExternalItem( + val packageName: String, + val displayName: String, + val iconURL: String, + val size: Long, + val status: InstallStatus, + val progress: Int = 0, + val speed: Long = 0L, + val timeRemaining: Long = -1L +) diff --git a/app/src/main/java/com/aurora/store/data/model/InstallStatus.kt b/app/src/main/java/com/aurora/store/data/model/InstallStatus.kt new file mode 100644 index 000000000..2b4bddfe6 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/model/InstallStatus.kt @@ -0,0 +1,17 @@ +/* + * SPDX-FileCopyrightText: 2025 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.data.model + +import androidx.annotation.StringRes +import com.aurora.store.R + +enum class InstallStatus(@StringRes val localized: Int) { + PENDING(R.string.action_pending), + DOWNLOADING(R.string.status_downloading), + INSTALLING(R.string.action_installing), + INSTALLED(R.string.title_installed), + FAILED(R.string.status_failed) +} diff --git a/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt index 1e063b6a4..c2c697960 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/onboarding/MicroGViewModel.kt @@ -17,26 +17,44 @@ import com.aurora.gplayapi.data.models.PlayFile import com.aurora.store.AuroraApp import com.aurora.store.data.event.InstallerEvent import com.aurora.store.data.helper.DownloadHelper +import com.aurora.store.data.model.DownloadStatus +import com.aurora.store.data.model.ExternalItem +import com.aurora.store.data.model.InstallStatus +import com.aurora.store.data.model.NetworkStatus +import com.aurora.store.data.providers.NetworkProvider +import com.aurora.store.data.room.download.Download import com.aurora.store.data.room.suite.ExternalApk import com.aurora.store.util.PackageUtil import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch data class MicroGUIState( - var isDownloading: Boolean = false, - var isInstalled: Boolean = false -) + val items: List = emptyList(), + val isOnline: Boolean = true +) { + val isInstalled: Boolean + get() = items.isNotEmpty() && items.all { it.status == InstallStatus.INSTALLED } + val isInProgress: Boolean + get() = items.any { + it.status == InstallStatus.DOWNLOADING || it.status == InstallStatus.INSTALLING + } + val hasFailed: Boolean + get() = !isInstalled && items.any { it.status == InstallStatus.FAILED } +} @HiltViewModel class MicroGViewModel @Inject constructor( @ApplicationContext private val context: Context, - private val downloadHelper: DownloadHelper + private val downloadHelper: DownloadHelper, + networkProvider: NetworkProvider ) : ViewModel() { companion object { @@ -55,15 +73,6 @@ class MicroGViewModel @Inject constructor( private const val ICON_FILE_PATH = "src/main/res/mipmap-xxxhdpi/ic_app.png" } - init { - AuroraApp.events.installerEvent.onEach { - when (it) { - is InstallerEvent.Installed -> confirmBundleInstall() - else -> {} - } - }.launchIn(AuroraApp.scope) - } - var uiState: MicroGUIState by mutableStateOf(MicroGUIState()) private set @@ -101,27 +110,83 @@ class MicroGViewModel @Inject constructor( ) ) + private val bundle = listOf(microGServiceApk, microGCompanionApk) + + init { + AuroraApp.events.installerEvent.onEach { + if (it is InstallerEvent.Installed) refresh() + }.launchIn(AuroraApp.scope) + + combine( + downloadHelper.downloadsList, + networkProvider.status.onStart { emit(NetworkStatus.AVAILABLE) } + ) { downloads, network -> + val byPackage = downloads.associateBy { it.packageName } + MicroGUIState( + items = bundle.map { apk -> buildItem(apk, byPackage[apk.packageName]) }, + isOnline = network == NetworkStatus.AVAILABLE + ) + }.onEach { uiState = it }.launchIn(viewModelScope) + } + fun downloadMicroG() { viewModelScope.launch(Dispatchers.IO) { - if (microGCompanionApk.isInstalled(context)) { - AuroraApp.events.send(InstallerEvent.Installed(PACKAGE_NAME_PLAY_STORE)) - } else { - downloadHelper.enqueueStandalone(microGCompanionApk) - uiState = uiState.copy(isDownloading = true) - } + bundle.forEach { enqueueIfNeeded(it) } + } + } - if (microGServiceApk.isInstalled(context)) { - AuroraApp.events.send(InstallerEvent.Installed(PACKAGE_NAME_GMS)) - } else { - downloadHelper.enqueueStandalone(microGServiceApk) - uiState = uiState.copy(isDownloading = true) + fun retryDownload() { + viewModelScope.launch(Dispatchers.IO) { + bundle.forEach { apk -> + downloadHelper.getDownload(apk.packageName)?.takeIf { it.isFinished }?.let { + downloadHelper.clearDownload(apk.packageName, apk.versionCode) + } + enqueueIfNeeded(apk) } } } - private fun confirmBundleInstall() { - if (PackageUtil.isMicroGBundleInstalled(context)) { - uiState = uiState.copy(isInstalled = true, isDownloading = false) + private suspend fun enqueueIfNeeded(apk: ExternalApk) { + if (apk.isInstalled(context)) { + AuroraApp.events.send(InstallerEvent.Installed(apk.packageName)) + } else { + downloadHelper.enqueueStandalone(apk) } } + + private fun refresh() { + uiState = uiState.copy( + items = uiState.items.map { item -> + if (item.status != InstallStatus.INSTALLED && + PackageUtil.isInstalled(context, item.packageName) + ) { + item.copy(status = InstallStatus.INSTALLED) + } else { + item + } + } + ) + } + + private fun buildItem(apk: ExternalApk, download: Download?): ExternalItem { + val state = when { + apk.isInstalled(context) -> InstallStatus.INSTALLED + download == null -> InstallStatus.PENDING + download.status == DownloadStatus.FAILED -> InstallStatus.FAILED + download.status == DownloadStatus.CANCELLED -> InstallStatus.PENDING + download.status == DownloadStatus.COMPLETED -> InstallStatus.INSTALLING + else -> InstallStatus.DOWNLOADING + } + + return ExternalItem( + packageName = apk.packageName, + displayName = apk.displayName, + iconURL = apk.iconURL, + size = apk.fileList.sumOf { it.size }, + status = state, + progress = download?.progress ?: 0, + speed = download?.speed ?: 0L, + timeRemaining = download?.timeRemaining ?: -1L + ) + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a9fbe77bb..417f4208b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -92,6 +92,7 @@ Request new analysis "Restart" "Resume" + "Retry" "Search" "Select all" "Share"