diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 032c125dc..10d10449e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -196,6 +196,7 @@ dependencies { implementation(libs.androidx.adaptive.core) implementation(libs.androidx.adaptive.navigation) implementation(libs.androidx.adaptive.layout) + implementation(libs.androidx.adaptive.suite) implementation(libs.androidx.paging.compose) implementation(libs.androidx.navigation3.runtime) implementation(libs.androidx.navigation3.ui) diff --git a/app/src/main/java/com/aurora/store/MainActivity.kt b/app/src/main/java/com/aurora/store/MainActivity.kt index 683f22517..7c44307fd 100644 --- a/app/src/main/java/com/aurora/store/MainActivity.kt +++ b/app/src/main/java/com/aurora/store/MainActivity.kt @@ -30,7 +30,6 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.displayCutout import androidx.core.view.WindowInsetsCompat.Type.ime import androidx.core.view.WindowInsetsCompat.Type.systemBars -import androidx.lifecycle.lifecycleScope import androidx.navigation.FloatingWindow import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.setupWithNavController @@ -42,10 +41,8 @@ import com.aurora.store.util.Preferences import com.aurora.store.util.Preferences.PREFERENCE_DEFAULT_SELECTED_TAB import com.aurora.store.view.ui.sheets.NetworkDialogSheet import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -57,8 +54,7 @@ class MainActivity : AppCompatActivity() { // TopLevelFragments private val topLevelFrags = listOf( R.id.appsContainerFragment, - R.id.gamesContainerFragment, - R.id.updatesFragment + R.id.gamesContainerFragment ) override fun onCreate(savedInstanceState: Bundle?) { @@ -114,7 +110,6 @@ class MainActivity : AppCompatActivity() { // Handle quick exit from back actions val defaultTab = when (Preferences.getInteger(this, PREFERENCE_DEFAULT_SELECTED_TAB)) { 1 -> R.id.gamesContainerFragment - 2 -> R.id.updatesFragment else -> R.id.appsContainerFragment } onBackPressedDispatcher.addCallback(this) { @@ -141,16 +136,6 @@ class MainActivity : AppCompatActivity() { } } } - - // Updates - lifecycleScope.launch { - viewModel.updateHelper.updates.collectLatest { list -> - binding.navView.getOrCreateBadge(R.id.updatesFragment).apply { - isVisible = !list.isNullOrEmpty() - number = list?.size ?: 0 - } - } - } } private fun isIntroDone(): Boolean = Preferences.getBoolean(this, Preferences.PREFERENCE_INTRO) diff --git a/app/src/main/java/com/aurora/store/compose/composable/OptionListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/OptionListItem.kt new file mode 100644 index 000000000..5c570b419 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/OptionListItem.kt @@ -0,0 +1,76 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composable + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.material3.Icon +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.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 +import com.aurora.store.R +import com.aurora.store.compose.navigation.Screen +import com.aurora.store.data.model.ComposeOption +import com.aurora.store.data.model.Option + +/** + * Composable to display UI navigation options + * @param modifier Modifier for the composable + * @param option Option to display + * @param onClick Callback when this composable is clicked + */ +@Composable +fun OptionListItem(modifier: Modifier = Modifier, option: Option, onClick: () -> Unit = {}) { + Row( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(dimensionResource(R.dimen.padding_normal)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.margin_small), + Alignment.Start + ) + ) { + Icon( + painter = painterResource(id = option.icon), + contentDescription = stringResource(id = option.title), + modifier = Modifier + .padding(horizontal = dimensionResource(R.dimen.padding_medium)) + .requiredSize(dimensionResource(R.dimen.icon_size_default)) + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = option.title), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun OptionListItemPreview() { + OptionListItem( + option = ComposeOption( + title = R.string.title_apps_games, + icon = R.drawable.ic_apps, + screen = Screen.Installed + ) + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/composable/UpdateListItem.kt b/app/src/main/java/com/aurora/store/compose/composable/UpdateListItem.kt new file mode 100644 index 000000000..eb62a0273 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composable/UpdateListItem.kt @@ -0,0 +1,170 @@ +/* + * SPDX-FileCopyrightText: 2026 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composable + +import android.text.format.Formatter +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.AnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import com.aurora.gplayapi.data.models.App +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.PreviewTemplate +import com.aurora.store.data.room.download.Download +import com.aurora.store.data.room.update.Update + +@Composable +fun UpdateListItem( + modifier: Modifier = Modifier, + update: Update, + download: Download? = null, + onClick: () -> Unit = {}, + onUpdate: () -> Unit = {}, + onCancel: () -> Unit = {}, + isExpanded: Boolean = false +) { + val isDownloading = download?.isRunning ?: false + val size = Formatter.formatShortFileSize(LocalContext.current, update.size) + + var isVisible by remember { mutableStateOf(true) } + var isExpanded by remember { mutableStateOf(isExpanded) } + + AnimatedVisibility(visible = isVisible, exit = shrinkVertically() + fadeOut()) { + Column( + modifier = modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding( + horizontal = dimensionResource(R.dimen.padding_medium), + vertical = dimensionResource(R.dimen.padding_small) + ) + ) { + Row(horizontalArrangement = Arrangement.SpaceBetween) { + Row(modifier = Modifier.weight(1F)) { + AnimatedAppIcon( + modifier = Modifier.requiredSize( + dimensionResource(R.dimen.icon_size_medium) + ), + iconUrl = update.iconURL, + inProgress = download?.isRunning == true, + progress = download?.progress?.toFloat() ?: 0F + ) + Column( + modifier = Modifier + .padding(horizontal = dimensionResource(R.dimen.margin_small)) + ) { + Text( + text = update.displayName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = "$size • ${update.updatedOn}", + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = stringResource( + R.string.version, + update.versionName, + update.versionCode + ), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + IconButton(onClick = { isExpanded = !isExpanded }) { + Icon( + painter = painterResource(R.drawable.ic_arrow_down), + contentDescription = stringResource(R.string.expand) + ) + } + + FilledTonalButton(onClick = if (isDownloading) onCancel else onUpdate) { + Text( + text = when { + isDownloading -> stringResource(R.string.action_cancel) + else -> stringResource(R.string.action_update) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + AnimatedVisibility(visible = isExpanded) { + Box( + modifier = Modifier + .padding(vertical = dimensionResource(R.dimen.margin_small)) + .fillMaxWidth() + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) + .background(color = MaterialTheme.colorScheme.secondaryContainer) + .padding(dimensionResource(R.dimen.padding_medium)) + ) { + Text( + text = if (update.changelog.isBlank()) { + AnnotatedString( + text = stringResource(R.string.details_changelog_unavailable) + ) + } else { + AnnotatedString.fromHtml(htmlString = update.changelog) + }, + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun UpdateListItemPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + val context = LocalContext.current + PreviewTemplate { + UpdateListItem( + update = Update.fromApp(context, app), + isExpanded = true + ) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt index a40419114..8a6b06937 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/NavDisplay.kt @@ -27,6 +27,7 @@ import com.aurora.store.compose.ui.dev.DevProfileScreen import com.aurora.store.compose.ui.dispenser.DispenserScreen import com.aurora.store.compose.ui.downloads.DownloadsScreen import com.aurora.store.compose.ui.favourite.FavouriteScreen +import com.aurora.store.compose.ui.home.HomeContainerScreen import com.aurora.store.compose.ui.installed.InstalledScreen import com.aurora.store.compose.ui.onboarding.OnboardingScreen import com.aurora.store.compose.ui.preferences.installation.InstallerScreen @@ -155,6 +156,14 @@ fun NavDisplay(startDestination: NavKey) { } ) } + + entry { + HomeContainerScreen( + onNavigateTo = { screen -> + backstack.add(screen) + } + ) + } } ) } diff --git a/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt b/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt index 26d463ee3..4b18bf7fa 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/Screen.kt @@ -63,4 +63,7 @@ sealed class Screen : NavKey, Parcelable { @Serializable data object Installed : Screen() + + @Serializable + data object Home : Screen() } diff --git a/app/src/main/java/com/aurora/store/compose/ui/home/AppsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/home/AppsScreen.kt new file mode 100644 index 000000000..1a48a3fc2 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/home/AppsScreen.kt @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.home + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.aurora.store.compose.preview.PreviewTemplate + +@Composable +fun AppsScreen() { + ScreenContent() +} + +@Composable +private fun ScreenContent() { +} + +@Preview(showBackground = true) +@Composable +private fun AppsScreenPreview() { + PreviewTemplate { + ScreenContent() + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/home/GamesScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/home/GamesScreen.kt new file mode 100644 index 000000000..3e5a69ffe --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/home/GamesScreen.kt @@ -0,0 +1,27 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.home + +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import com.aurora.store.compose.preview.PreviewTemplate + +@Composable +fun GamesScreen() { + ScreenContent() +} + +@Composable +private fun ScreenContent() { +} + +@Preview(showBackground = true) +@Composable +private fun GamesScreenPreview() { + PreviewTemplate { + ScreenContent() + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/home/HomeContainerScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/home/HomeContainerScreen.kt new file mode 100644 index 000000000..335928682 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/home/HomeContainerScreen.kt @@ -0,0 +1,142 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.home + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewScreenSizes +import androidx.navigation3.runtime.NavKey +import com.aurora.store.R +import com.aurora.store.compose.composable.TopAppBar +import com.aurora.store.compose.navigation.Screen +import com.aurora.store.compose.preview.PreviewTemplate +import com.aurora.store.compose.ui.home.menu.HomeContainerMenu +import com.aurora.store.compose.ui.home.menu.MenuItem +import com.aurora.store.compose.ui.home.navigation.HomeScreen + +@Composable +fun HomeContainerScreen(onNavigateTo: (screen: NavKey) -> Unit) { + val screens = listOf( + HomeScreen.APPS, + HomeScreen.GAMES, + HomeScreen.UPDATES + ) + + ScreenContent( + screens = screens, + onNavigateTo = onNavigateTo + ) +} + +@Composable +private fun ScreenContent( + default: HomeScreen = HomeScreen.APPS, + screens: List = emptyList(), + onNavigateTo: (screen: NavKey) -> Unit = {} +) { + var currentScreen by rememberSaveable { mutableStateOf(default) } + var shouldShowMoreDialog by rememberSaveable { mutableStateOf(false) } + + if (shouldShowMoreDialog) { + MoreDialog( + onDismiss = { shouldShowMoreDialog = false }, + onNavigateTo = onNavigateTo + ) + } + + @Composable + fun SetupMenu() { + HomeContainerMenu { menuItem -> + when (menuItem) { + MenuItem.DOWNLOADS -> onNavigateTo(Screen.Downloads) + MenuItem.MORE -> { + shouldShowMoreDialog = true + } + } + } + } + + NavigationSuiteScaffold( + navigationSuiteItems = { + screens.forEach { screen -> + item( + icon = { + Icon( + painter = painterResource(screen.icon), + contentDescription = null + ) + }, + label = { Text(text = stringResource(screen.localized)) }, + selected = currentScreen == screen, + onClick = { currentScreen = screen } + ) + } + } + ) { + Scaffold( + topBar = { + TopAppBar( + title = stringResource(currentScreen.localized), + actions = { SetupMenu() } + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = { onNavigateTo(Screen.Search) }, + icon = { + Icon( + painter = painterResource(R.drawable.ic_round_search), + contentDescription = null + ) + }, + text = { + Text(text = stringResource(R.string.action_search)) + } + ) + } + ) { paddingValues -> + Column(modifier = Modifier.padding(paddingValues)) { + when (currentScreen) { + HomeScreen.APPS -> AppsScreen() + HomeScreen.GAMES -> GamesScreen() + HomeScreen.UPDATES -> { + UpdatesScreen( + onNavigateToAppDetails = { packageName -> + onNavigateTo(Screen.AppDetails(packageName)) + } + ) + } + } + } + } + } +} + +@PreviewScreenSizes +@Composable +private fun HomeContainerScreenPreview() { + PreviewTemplate { + val screens = listOf( + HomeScreen.APPS, + HomeScreen.GAMES, + HomeScreen.UPDATES + ) + ScreenContent(screens = screens) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/home/MoreDialog.kt b/app/src/main/java/com/aurora/store/compose/ui/home/MoreDialog.kt new file mode 100644 index 000000000..d368fb1b3 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/home/MoreDialog.kt @@ -0,0 +1,350 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.home + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +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.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.window.Dialog +import androidx.navigation.NavDeepLinkBuilder +import androidx.navigation3.runtime.NavKey +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.aurora.Constants.URL_POLICY +import com.aurora.Constants.URL_TOS +import com.aurora.extensions.browse +import com.aurora.extensions.setAppTheme +import com.aurora.store.MainActivity +import com.aurora.store.R +import com.aurora.store.compose.composable.OptionListItem +import com.aurora.store.compose.navigation.Screen +import com.aurora.store.compose.preview.PreviewTemplate +import com.aurora.store.data.model.ComposeOption +import com.aurora.store.data.model.Option +import com.aurora.store.data.model.ThemeState +import com.aurora.store.data.model.ViewOption +import com.aurora.store.util.Preferences +import com.aurora.store.util.Preferences.PREFERENCE_THEME_STYLE + +@Composable +fun MoreDialog( + avatar: Any = R.mipmap.ic_launcher, + name: String = stringResource(R.string.account_anonymous), + email: String = stringResource(R.string.account_anonymous_email), + onNavigateTo: (screen: NavKey) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + val activity = LocalActivity.current + val context = LocalContext.current + + val options = listOf( + ComposeOption( + title = R.string.title_apps_games, + icon = R.drawable.ic_apps, + screen = Screen.Installed + ), + ComposeOption( + title = R.string.title_blacklist_manager, + icon = R.drawable.ic_blacklist, + screen = Screen.Blacklist + ), + ComposeOption( + title = R.string.title_favourites_manager, + icon = R.drawable.ic_favorite_unchecked, + screen = Screen.Favourite + ), + ComposeOption( + title = R.string.title_spoof_manager, + icon = R.drawable.ic_spoof, + screen = Screen.Spoof + ) + ) + val extraOptions = listOf( + ViewOption( + title = R.string.title_settings, + icon = R.drawable.ic_menu_settings, + destinationID = R.id.settingsFragment + ), + ComposeOption( + title = R.string.title_about, + icon = R.drawable.ic_menu_about, + screen = Screen.About + ) + ) + + fun onOptionClicked(option: Option) { + when (option) { + is ViewOption -> { + val intent = NavDeepLinkBuilder(context) + .setGraph(R.navigation.mobile_navigation) + .setDestination(option.destinationID) + .setComponentName(MainActivity::class.java) + .createTaskStackBuilder() + .intents + .first() + + activity?.startActivity(intent) + } + + is ComposeOption -> onNavigateTo(option.screen) + } + + onDismiss() + } + + @Composable + fun ThemeStateIconButton() { + var themeState by remember { + mutableStateOf( + ThemeState.entries[Preferences.getInteger(context, PREFERENCE_THEME_STYLE)] + ) + } + + IconButton( + onClick = { + themeState = when (themeState) { + ThemeState.AUTO -> ThemeState.LIGHT + ThemeState.LIGHT -> ThemeState.DARK + ThemeState.DARK -> ThemeState.AUTO + } + Preferences.putInteger(context, PREFERENCE_THEME_STYLE, themeState.ordinal) + setAppTheme(themeState.ordinal) + } + ) { + Icon( + painter = painterResource(id = themeState.icon), + contentDescription = null + ) + } + } + + @Composable + fun AppBar() { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .padding( + dimensionResource(R.dimen.padding_xsmall), + dimensionResource(R.dimen.padding_medium), + dimensionResource(R.dimen.padding_xsmall), + dimensionResource(R.dimen.padding_medium) + ) + ) { + ThemeStateIconButton() + + Text( + modifier = Modifier.wrapContentWidth(), + text = stringResource(id = R.string.app_name), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center + ) + + IconButton(onClick = onDismiss) { + Icon( + painter = painterResource(id = R.drawable.ic_cancel), + contentDescription = stringResource(id = R.string.action_cancel) + ) + } + } + } + + @Composable + fun AccountHeader() { + Column( + modifier = Modifier + .fillMaxWidth() + .clip( + RoundedCornerShape( + topStart = dimensionResource(R.dimen.radius_large), + topEnd = dimensionResource(R.dimen.radius_large), + bottomStart = dimensionResource(R.dimen.radius_xxsmall), + bottomEnd = dimensionResource(R.dimen.radius_xxsmall) + ) + ) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(dimensionResource(R.dimen.padding_large)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_large)), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.margin_large), + Alignment.Start + ) + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(avatar) + .crossfade(true) + .build(), + contentDescription = stringResource(id = R.string.title_account_manager), + placeholder = painterResource(R.drawable.ic_account), + contentScale = ContentScale.Crop, + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_category)) + .clip(CircleShape) + ) + Column( + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.Start + ) { + Text( + text = name, + style = MaterialTheme.typography.bodyMediumEmphasized, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = email, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + OutlinedButton( + onClick = { onNavigateTo(Screen.Accounts) }, + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_normal)), + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(id = R.string.manage_account), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + @Composable + fun Footer() { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.margin_xxsmall), + Alignment.CenterHorizontally + ) + ) { + TextButton(onClick = { context.browse(URL_POLICY) }) { + Text( + text = stringResource(id = R.string.privacy_policy_title), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Text(text = "•") + TextButton(onClick = { context.browse(URL_TOS) }) { + Text( + text = stringResource(id = R.string.menu_terms), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(dimensionResource(R.dimen.radius_large)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .verticalScroll(rememberScrollState()) + .padding(dimensionResource(R.dimen.padding_medium)), + verticalArrangement = Arrangement.spacedBy( + dimensionResource(R.dimen.margin_xsmall), + Alignment.CenterVertically + ) + ) { + AppBar() + AccountHeader() + + Column( + modifier = Modifier + .fillMaxWidth() + .clip( + RoundedCornerShape( + topStart = dimensionResource(R.dimen.radius_xxsmall), + topEnd = dimensionResource(R.dimen.radius_xxsmall), + bottomStart = dimensionResource(R.dimen.radius_large), + bottomEnd = dimensionResource(R.dimen.radius_large) + ) + ) + .background(MaterialTheme.colorScheme.secondaryContainer) + ) { + options.fastForEach { option -> + OptionListItem( + option = option, + onClick = { onOptionClicked(option) } + ) + } + } + + extraOptions.fastForEach { option -> + OptionListItem( + option = option, + onClick = { onOptionClicked(option) } + ) + } + + Footer() + } + } + } +} + +@Preview +@Composable +private fun MoreDialogPreview() { + PreviewTemplate { + MoreDialog() + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/home/UpdatesScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/home/UpdatesScreen.kt new file mode 100644 index 000000000..8887a5013 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/home/UpdatesScreen.kt @@ -0,0 +1,225 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.home + +import androidx.compose.foundation.layout.Arrangement +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.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.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 +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.PagingData +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.aurora.extensions.emptyPagingItems +import com.aurora.gplayapi.data.models.App +import com.aurora.store.R +import com.aurora.store.compose.composable.ContainedLoadingIndicator +import com.aurora.store.compose.composable.Error +import com.aurora.store.compose.composable.UpdateListItem +import com.aurora.store.compose.preview.AppPreviewProvider +import com.aurora.store.compose.preview.PreviewTemplate +import com.aurora.store.data.room.update.Update +import com.aurora.store.data.room.update.UpdateWithDownload +import com.aurora.store.viewmodel.all.UpdatesViewModel +import kotlin.random.Random +import kotlinx.coroutines.flow.MutableStateFlow + +@Composable +fun UpdatesScreen( + onNavigateToAppDetails: (packageName: String) -> Unit, + viewModel: UpdatesViewModel = hiltViewModel() +) { + val hasOngoingUpdates by viewModel.hasOngoingUpdates.collectAsStateWithLifecycle() + val isCheckingUpdates by viewModel.isCheckingUpdates.collectAsStateWithLifecycle() + val updatesCount by viewModel.updatesCount.collectAsStateWithLifecycle() + val updates = viewModel.updates.collectAsLazyPagingItems() + + ScreenContent( + updates = updates, + totalUpdatesCount = updatesCount, + hasOngoingUpdates = hasOngoingUpdates, + isCheckingUpdates = isCheckingUpdates, + onCheckUpdates = { viewModel.fetchUpdates() }, + onUpdate = { update -> viewModel.download(update) }, + onUpdateAll = { viewModel.downloadAll() }, + onCancel = { packageName -> viewModel.cancelDownload(packageName) }, + onCancelAll = { viewModel.cancelAll() }, + onNavigateToAppDetails = onNavigateToAppDetails + ) +} + +@Composable +private fun ScreenContent( + hasOngoingUpdates: Boolean = false, + totalUpdatesCount: Int = 0, + updates: LazyPagingItems = emptyPagingItems(), + isCheckingUpdates: Boolean = false, + onCheckUpdates: () -> Unit = {}, + onUpdate: (update: Update) -> Unit = {}, + onUpdateAll: () -> Unit = {}, + onCancel: (packageName: String) -> Unit = {}, + onCancelAll: () -> Unit = {}, + onNavigateToAppDetails: (packageName: String) -> Unit = {} +) { + /* + * For some reason paging3 frequently out-of-nowhere invalidates the list which causes + * the loading animation to play again even if the keys are same causing a glitching effect. + * + * Save the initial loading state to make sure we don't replay the loading animation again. + */ + var initialLoad by rememberSaveable { mutableStateOf(true) } + + val state = rememberPullToRefreshState() + + PullToRefreshBox( + modifier = Modifier.fillMaxSize(), + isRefreshing = isCheckingUpdates, + onRefresh = onCheckUpdates, + state = state, + indicator = { + PullToRefreshDefaults.LoadingIndicator( + state = state, + isRefreshing = isCheckingUpdates, + modifier = Modifier.align(Alignment.TopCenter) + ) + } + ) { + when { + updates.loadState.refresh is LoadState.Loading && initialLoad -> { + ContainedLoadingIndicator() + } + + else -> { + initialLoad = false + + if (updates.itemCount == 0) { + Error( + painter = painterResource(R.drawable.ic_updates), + message = stringResource(R.string.details_no_updates), + actionMessage = stringResource(R.string.check_updates), + onAction = onCheckUpdates + ) + } else { + LazyColumn { + stickyHeader { + Surface(modifier = Modifier.fillMaxWidth()) { + Header( + hasOngoingUpdates = hasOngoingUpdates, + totalUpdatesCount = totalUpdatesCount, + onUpdateAll = onUpdateAll, + onCancelAll = onCancelAll + ) + } + } + + items( + count = updates.itemCount, + key = updates.itemKey { it.update.packageName } + ) { index -> + updates[index]?.let { updateWithDownload -> + val update = updateWithDownload.update + UpdateListItem( + modifier = Modifier.animateItem(), + update = update, + download = updateWithDownload.download, + onUpdate = { onUpdate(update) }, + onCancel = { onCancel(update.packageName) }, + onClick = { onNavigateToAppDetails(update.packageName) } + ) + } + } + } + } + } + } + } +} + +@Composable +private fun Header( + modifier: Modifier = Modifier, + totalUpdatesCount: Int = 1, + onUpdateAll: () -> Unit = {}, + onCancelAll: () -> Unit = {}, + hasOngoingUpdates: Boolean = false +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.padding_medium), + vertical = dimensionResource(R.dimen.padding_xsmall) + ), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = when (totalUpdatesCount) { + 1 -> "$totalUpdatesCount ${stringResource(R.string.update_available)}" + else -> "$totalUpdatesCount ${stringResource(R.string.updates_available)}" + }, + style = MaterialTheme.typography.titleMediumEmphasized, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Button(onClick = if (hasOngoingUpdates) onCancelAll else onUpdateAll) { + Text( + text = when { + hasOngoingUpdates -> stringResource(R.string.action_cancel) + else -> stringResource(R.string.action_update_all) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun UpdatesScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + val context = LocalContext.current + PreviewTemplate { + val updates = List(10) { + UpdateWithDownload( + update = Update.fromApp(context, app) + .copy(packageName = Random.nextInt().toString()), + download = null + ) + } + val pagedUpdates = MutableStateFlow(PagingData.from(updates)).collectAsLazyPagingItems() + ScreenContent( + isCheckingUpdates = true, + updates = pagedUpdates + ) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/home/menu/HomeContainerMenu.kt b/app/src/main/java/com/aurora/store/compose/ui/home/menu/HomeContainerMenu.kt new file mode 100644 index 000000000..e6fa653e9 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/home/menu/HomeContainerMenu.kt @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.home.menu + +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.aurora.store.R +import com.aurora.store.compose.preview.PreviewTemplate + +/** + * Menu for home container screen + * @param modifier Modifier for the composable + * @param onMenuItemClicked Callback when a menu action is clicked + */ +@Composable +fun HomeContainerMenu( + modifier: Modifier = Modifier, + onMenuItemClicked: (menuItem: MenuItem) -> Unit = {} +) { + IconButton(onClick = { onMenuItemClicked(MenuItem.DOWNLOADS) }) { + Icon( + painter = painterResource(R.drawable.ic_download_manager), + contentDescription = stringResource(R.string.title_download_manager) + ) + } + + IconButton(onClick = { onMenuItemClicked(MenuItem.MORE) }) { + Icon( + painter = painterResource(R.drawable.ic_settings_account), + contentDescription = stringResource(R.string.title_more) + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun HomeContainerMenuPreview() { + PreviewTemplate { + HomeContainerMenu() + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/home/menu/MenuItem.kt b/app/src/main/java/com/aurora/store/compose/ui/home/menu/MenuItem.kt new file mode 100644 index 000000000..0911dc9db --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/home/menu/MenuItem.kt @@ -0,0 +1,14 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.home.menu + +/** + * Valid actions for home menu + */ +enum class MenuItem { + DOWNLOADS, + MORE +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/home/navigation/HomeScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/home/navigation/HomeScreen.kt new file mode 100644 index 000000000..881c36a88 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/home/navigation/HomeScreen.kt @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.home.navigation + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.aurora.store.R + +/** + * Screens that can be shown in home + */ +enum class HomeScreen(@StringRes val localized: Int, @DrawableRes val icon: Int) { + APPS(R.string.title_apps, R.drawable.ic_apps), + GAMES(R.string.title_games, R.drawable.ic_games), + UPDATES(R.string.title_updates, R.drawable.ic_updates) +} diff --git a/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt b/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt index 37a6c7fa0..9fe7cd840 100644 --- a/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt +++ b/app/src/main/java/com/aurora/store/data/helper/UpdateHelper.kt @@ -90,11 +90,17 @@ class UpdateHelper @Inject constructor( .map { list -> if (!isExtendedUpdateEnabled) list.filter { it.hasValidCert } else list } .stateIn(AuroraApp.scope, SharingStarted.WhileSubscribed(), null) + val pagedUpdates get() = updateDao.pagedUpdates() + val isCheckingUpdates = WorkManager.getInstance(context) .getWorkInfosForUniqueWorkFlow(EXPEDITED_UPDATE_WORKER) .map { list -> !list.all { it.state.isFinished } } .stateIn(AuroraApp.scope, SharingStarted.WhileSubscribed(), false) + val hasOngoingUpdates get() = updateDao.hasOngoingUpdates() + + val updatesCount get() = updateDao.updatesCount() + /** * Deletes invalid updates from database and starts observing events */ diff --git a/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt index da3dada10..860dff93e 100644 --- a/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt +++ b/app/src/main/java/com/aurora/store/data/model/DownloadStatus.kt @@ -15,6 +15,6 @@ enum class DownloadStatus(@StringRes val localized: Int) { companion object { val finished = listOf(FAILED, CANCELLED, COMPLETED) - val running = listOf(QUEUED, PURCHASING, DOWNLOADING) + val running = listOf(QUEUED, PURCHASING, DOWNLOADING, VERIFYING) } } diff --git a/app/src/main/java/com/aurora/store/data/model/Option.kt b/app/src/main/java/com/aurora/store/data/model/Option.kt new file mode 100644 index 000000000..ad2662496 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/model/Option.kt @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.data.model + +import androidx.annotation.DrawableRes +import androidx.annotation.IdRes +import androidx.annotation.StringRes +import com.aurora.store.compose.navigation.Screen + +abstract class Option( + @StringRes open val title: Int, + @DrawableRes open val icon: Int +) + +data class ViewOption( + override val title: Int, + override val icon: Int, + @IdRes val destinationID: Int +) : Option(title, icon) + +data class ComposeOption( + override val title: Int, + override val icon: Int, + val screen: Screen +) : Option(title, icon) diff --git a/app/src/main/java/com/aurora/store/data/model/ThemeState.kt b/app/src/main/java/com/aurora/store/data/model/ThemeState.kt new file mode 100644 index 000000000..77a48452f --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/model/ThemeState.kt @@ -0,0 +1,18 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.data.model + +import androidx.annotation.DrawableRes +import com.aurora.store.R + +/** + * Values for supported theme states + */ +enum class ThemeState(@DrawableRes val icon: Int) { + AUTO(R.drawable.ic_auto), + LIGHT(R.drawable.ic_light), + DARK(R.drawable.ic_dark) +} diff --git a/app/src/main/java/com/aurora/store/data/room/update/UpdateDao.kt b/app/src/main/java/com/aurora/store/data/room/update/UpdateDao.kt index 50ae13778..91e5f36a0 100644 --- a/app/src/main/java/com/aurora/store/data/room/update/UpdateDao.kt +++ b/app/src/main/java/com/aurora/store/data/room/update/UpdateDao.kt @@ -1,10 +1,12 @@ package com.aurora.store.data.room.update +import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import com.aurora.store.data.model.DownloadStatus import kotlinx.coroutines.flow.Flow @Dao @@ -27,4 +29,28 @@ interface UpdateDao { deleteAll() insertAll(updates) } + + @Transaction + @Query( + """ + SELECT * FROM `update` + LEFT JOIN download ON `update`.packageName = `download`.packageName + ORDER BY `update`.displayName ASC + """ + ) + fun pagedUpdates(): PagingSource + + @Query( + """ + SELECT EXISTS( + SELECT 1 FROM `update` + INNER JOIN download ON `update`.packageName = `download`.packageName + WHERE `download`.downloadStatus IN (:status) + ) + """ + ) + fun hasOngoingUpdates(status: List = DownloadStatus.running): Flow + + @Query("SELECT COUNT(*) FROM `update`") + fun updatesCount(): Flow } diff --git a/app/src/main/java/com/aurora/store/data/room/update/UpdateWithDownload.kt b/app/src/main/java/com/aurora/store/data/room/update/UpdateWithDownload.kt new file mode 100644 index 000000000..6877b4749 --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/room/update/UpdateWithDownload.kt @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2026 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.data.room.update + +import androidx.room.Embedded +import androidx.room.Relation +import com.aurora.store.data.room.download.Download + +data class UpdateWithDownload( + @Embedded val update: Update, + @Relation(parentColumn = "packageName", entityColumn = "packageName") val download: Download? +) diff --git a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt index 448bd86d1..e7916b3b7 100644 --- a/app/src/main/java/com/aurora/store/util/NotificationUtil.kt +++ b/app/src/main/java/com/aurora/store/util/NotificationUtil.kt @@ -217,7 +217,7 @@ object NotificationUtil { .setGraph(R.navigation.mobile_navigation) .setDestination(R.id.splashFragment) .setComponentName(MainActivity::class.java) - .setArguments(bundleOf("destinationId" to R.id.updatesFragment)) + .setArguments(bundleOf("destinationId" to 2)) .createPendingIntent() return NotificationCompat.Builder(context, Constants.NOTIFICATION_CHANNEL_UPDATES) diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/UpdateHeaderView.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/UpdateHeaderView.kt deleted file mode 100644 index 2f9607652..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/UpdateHeaderView.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.views - -import android.content.Context -import android.util.AttributeSet -import com.airbnb.epoxy.CallbackProp -import com.airbnb.epoxy.ModelProp -import com.airbnb.epoxy.ModelView -import com.airbnb.epoxy.OnViewRecycled -import com.aurora.store.databinding.ViewHeaderUpdateBinding - -@ModelView( - autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT, - baseModelClass = BaseModel::class -) -class UpdateHeaderView @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : BaseView(context, attrs, defStyleAttr) { - - @ModelProp - fun title(title: String) { - binding.txtTitle.text = title - } - - @ModelProp - fun action(action: String) { - binding.btnAction.text = action - } - - @CallbackProp - fun click(onClickListener: OnClickListener?) { - binding.btnAction.setOnClickListener(onClickListener) - } - - @OnViewRecycled - fun clear() { - binding.btnAction.isEnabled = true - } -} diff --git a/app/src/main/java/com/aurora/store/view/epoxy/views/app/AppUpdateView.kt b/app/src/main/java/com/aurora/store/view/epoxy/views/app/AppUpdateView.kt deleted file mode 100644 index 693f67ca4..000000000 --- a/app/src/main/java/com/aurora/store/view/epoxy/views/app/AppUpdateView.kt +++ /dev/null @@ -1,205 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.epoxy.views.app - -import android.animation.ObjectAnimator -import android.content.Context -import android.graphics.drawable.Drawable -import android.util.AttributeSet -import android.view.View -import android.view.animation.AccelerateDecelerateInterpolator -import androidx.core.content.ContextCompat -import androidx.core.text.HtmlCompat -import androidx.core.view.isVisible -import coil3.asDrawable -import coil3.load -import coil3.request.placeholder -import coil3.request.transformations -import coil3.transform.CircleCropTransformation -import coil3.transform.RoundedCornersTransformation -import com.airbnb.epoxy.CallbackProp -import com.airbnb.epoxy.ModelProp -import com.airbnb.epoxy.ModelView -import com.airbnb.epoxy.OnViewRecycled -import com.aurora.extensions.invisible -import com.aurora.extensions.px -import com.aurora.store.R -import com.aurora.store.data.model.DownloadStatus -import com.aurora.store.data.room.download.Download -import com.aurora.store.data.room.update.Update -import com.aurora.store.databinding.ViewAppUpdateBinding -import com.aurora.store.util.CommonUtil -import com.aurora.store.view.epoxy.views.BaseModel -import com.aurora.store.view.epoxy.views.BaseView - -@ModelView( - autoLayout = ModelView.Size.MATCH_WIDTH_WRAP_HEIGHT, - baseModelClass = BaseModel::class -) -class AppUpdateView @JvmOverloads constructor( - context: Context?, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : BaseView(context, attrs, defStyleAttr) { - private var iconDrawable: Drawable? = null - private val cornersTransformation = RoundedCornersTransformation(8.px.toFloat()) - - @ModelProp - fun update(update: Update) { - /*Inflate App details*/ - with(update) { - binding.txtLine1.text = displayName - binding.imgIcon.load(iconURL) { - placeholder(R.drawable.bg_placeholder) - transformations(cornersTransformation) - listener { _, result -> - result.image.asDrawable(resources).let { iconDrawable = it } - } - } - - binding.txtLine2.text = developerName - binding.txtLine3.text = ("${CommonUtil.addSiPrefix(size)} • $updatedOn") - binding.txtLine4.text = ("$versionName ($versionCode)") - binding.txtChangelog.text = if (changelog.isNotEmpty()) { - HtmlCompat.fromHtml( - changelog, - HtmlCompat.FROM_HTML_OPTION_USE_CSS_COLORS - ) - } else { - context.getString(R.string.details_changelog_unavailable) - } - - binding.headerIndicator.setOnClickListener { - if (binding.cardChangelog.isVisible) { - binding.headerIndicator.icon = - ContextCompat.getDrawable(context, R.drawable.ic_arrow_down) - binding.cardChangelog.visibility = View.GONE - } else { - binding.headerIndicator.icon = - ContextCompat.getDrawable(context, R.drawable.ic_arrow_up) - binding.cardChangelog.visibility = View.VISIBLE - } - } - } - } - - @ModelProp - fun download(download: Download?) { - if (download != null) { - binding.btnAction.updateState(download.status) - when (download.status) { - DownloadStatus.VERIFYING, - DownloadStatus.QUEUED -> { - binding.progressDownload.isIndeterminate = true - animateImageView(scaleFactor = 0.75f) - } - - DownloadStatus.DOWNLOADING -> { - binding.progressDownload.isIndeterminate = false - binding.progressDownload.progress = download.progress - animateImageView(scaleFactor = 0.75f) - } - - else -> { - binding.progressDownload.isIndeterminate = true - animateImageView(scaleFactor = 1f) - } - } - } - } - - @CallbackProp - fun click(onClickListener: OnClickListener?) { - binding.layoutContent.setOnClickListener(onClickListener) - } - - @CallbackProp - fun positiveAction(onClickListener: OnClickListener?) { - binding.btnAction.addPositiveOnClickListener(onClickListener) - } - - @CallbackProp - fun negativeAction(onClickListener: OnClickListener?) { - binding.btnAction.addNegativeOnClickListener(onClickListener) - } - - @CallbackProp - fun longClick(onClickListener: OnLongClickListener?) { - binding.layoutContent.setOnLongClickListener(onClickListener) - } - - @OnViewRecycled - fun clear() { - iconDrawable = null - - binding.apply { - headerIndicator.removeCallbacks {} - progressDownload.invisible() - btnAction.apply { - removeCallbacks { } - updateState(DownloadStatus.UNAVAILABLE) - } - } - } - - private fun animateImageView(scaleFactor: Float = 1f) { - val isDownloadVisible = binding.progressDownload.isShown - - // Avoids flickering when the download is in progress - if (isDownloadVisible && scaleFactor != 1f) { - return - } - - if (!isDownloadVisible && scaleFactor == 1f) { - return - } - - if (scaleFactor == 1f) { - binding.progressDownload.invisible() - } else { - binding.progressDownload.show() - } - - val scale = listOf( - ObjectAnimator.ofFloat(binding.imgIcon, "scaleX", scaleFactor), - ObjectAnimator.ofFloat(binding.imgIcon, "scaleY", scaleFactor) - ) - - scale.forEach { animation -> - animation.apply { - interpolator = AccelerateDecelerateInterpolator() - duration = 250 - start() - } - } - - iconDrawable?.let { - binding.imgIcon.load(it) { - transformations( - if (scaleFactor == 1f) { - cornersTransformation - } else { - CircleCropTransformation() - } - ) - } - } - } -} diff --git a/app/src/main/java/com/aurora/store/view/ui/splash/BaseFlavouredSplashFragment.kt b/app/src/main/java/com/aurora/store/view/ui/splash/BaseFlavouredSplashFragment.kt index ff0164d60..2cc154285 100644 --- a/app/src/main/java/com/aurora/store/view/ui/splash/BaseFlavouredSplashFragment.kt +++ b/app/src/main/java/com/aurora/store/view/ui/splash/BaseFlavouredSplashFragment.kt @@ -172,21 +172,9 @@ abstract class BaseFlavouredSplashFragment : BaseFragment private fun navigateToDefaultTab() { val defaultDestination = Preferences.getInteger(requireContext(), PREFERENCE_DEFAULT_SELECTED_TAB) - val directions = - when (requireArguments().getInt("destinationId", defaultDestination)) { - R.id.updatesFragment -> { - requireArguments().remove("destinationId") - SplashFragmentDirections.actionSplashFragmentToUpdatesFragment() - } - - 1 -> SplashFragmentDirections.actionSplashFragmentToGamesContainerFragment() - - 2 -> SplashFragmentDirections.actionSplashFragmentToUpdatesFragment() - - else -> SplashFragmentDirections.actionSplashFragmentToNavigationApps() - } + // TODO: Handle default destination requireActivity().viewModelStore.clear() // Clear ViewModelStore to avoid bugs with logout - findNavController().navigate(directions) + requireContext().navigate(Screen.Home) } private fun requestAuthTokenForGoogle(accountName: String, oldToken: String? = null) { diff --git a/app/src/main/java/com/aurora/store/view/ui/updates/UpdatesFragment.kt b/app/src/main/java/com/aurora/store/view/ui/updates/UpdatesFragment.kt deleted file mode 100644 index d2e9c6a81..000000000 --- a/app/src/main/java/com/aurora/store/view/ui/updates/UpdatesFragment.kt +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Aurora Store - * Copyright (C) 2021, Rahul Kumar Patel - * - * Aurora Store is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 2 of the License, or - * (at your option) any later version. - * - * Aurora Store is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with Aurora Store. If not, see . - * - */ - -package com.aurora.store.view.ui.updates - -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.updateLayoutParams -import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope -import androidx.navigation.fragment.findNavController -import com.aurora.Constants -import com.aurora.extensions.browse -import com.aurora.extensions.navigate -import com.aurora.extensions.requiresObbDir -import com.aurora.store.MobileNavigationDirections -import com.aurora.store.R -import com.aurora.store.compose.navigation.Screen -import com.aurora.store.data.model.MinimalApp -import com.aurora.store.data.model.PermissionType -import com.aurora.store.data.providers.PermissionProvider.Companion.isGranted -import com.aurora.store.data.room.download.Download -import com.aurora.store.data.room.update.Update -import com.aurora.store.databinding.FragmentUpdatesBinding -import com.aurora.store.view.epoxy.views.UpdateHeaderViewModel_ -import com.aurora.store.view.epoxy.views.app.AppUpdateViewModel_ -import com.aurora.store.view.epoxy.views.app.NoAppViewModel_ -import com.aurora.store.view.epoxy.views.shimmer.AppListViewShimmerModel_ -import com.aurora.store.view.ui.commons.BaseFragment -import com.aurora.store.viewmodel.all.UpdatesViewModel -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.launch - -@AndroidEntryPoint -class UpdatesFragment : BaseFragment() { - - private val viewModel: UpdatesViewModel by viewModels() - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - // Adjust FAB margins for edgeToEdge display - ViewCompat.setOnApplyWindowInsetsListener(binding.searchFab) { _, windowInsets -> - val insets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars()) - binding.searchFab.updateLayoutParams { - bottomMargin = insets.bottom + resources.getDimensionPixelSize(R.dimen.margin_large) - } - WindowInsetsCompat.CONSUMED - } - - // Toolbar - binding.toolbar.setOnMenuItemClickListener { - when (it.itemId) { - R.id.menu_download_manager -> { - requireContext().navigate(Screen.Downloads) - } - - R.id.menu_more -> { - findNavController().navigate( - MobileNavigationDirections.actionGlobalMoreDialogFragment() - ) - } - } - true - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.updates - .combine(viewModel.downloadsList) { uList, dList -> - uList?.associateWith { a -> - dList.find { - it.packageName == a.packageName && it.versionCode == a.versionCode - } - } - }.collectLatest { map -> - updateController(map) - viewModel.updateAllEnqueued = map?.values?.all { - it?.isRunning == true - } ?: false - } - } - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.fetchingUpdates.collect { - binding.swipeRefreshLayout.isRefreshing = it - if (it && viewModel.updates.value.isNullOrEmpty()) { - updateController(emptyMap()) - } - } - } - - binding.swipeRefreshLayout.setOnRefreshListener { - viewModel.fetchUpdates() - } - - binding.searchFab.setOnClickListener { - requireContext().navigate(Screen.Search) - } - } - - private fun updateController(appList: Map?) { - binding.recycler.withModels { - setFilterDuplicates(true) - if (appList == null) { - for (i in 1..10) { - add( - AppListViewShimmerModel_() - .id(i) - ) - } - } else { - if (appList.isEmpty()) { - add( - NoAppViewModel_() - .id("no_update") - .icon(R.drawable.ic_updates) - .message(R.string.details_no_updates) - .showAction(true) - .actionMessage(R.string.check_updates) - .actionCallback { _ -> viewModel.fetchUpdates() } - ) - } else { - add( - UpdateHeaderViewModel_() - .id("header_all") - .title( - "${appList.size} " + - if (appList.size == 1) { - getString(R.string.update_available) - } else { - getString(R.string.updates_available) - } - ) - .action( - if (viewModel.updateAllEnqueued) { - getString(R.string.action_cancel) - } else { - getString(R.string.action_update_all) - } - ) - .click { _ -> - if (viewModel.updateAllEnqueued) { - cancelAll() - } else { - updateAll() - } - requestModelBuild() - } - ) - - for ((update, download) in appList) { - add( - AppUpdateViewModel_() - .id(update.packageName) - .update(update) - .download(download) - .click { _ -> - if (update.packageName == requireContext().packageName) { - requireContext().browse(Constants.GITLAB_URL) - } else { - openDetailsFragment(update.packageName) - } - } - .longClick { _ -> - openAppMenuSheet(MinimalApp.fromUpdate(update)) - false - } - .positiveAction { _ -> updateSingle(update) } - .negativeAction { _ -> cancelSingle(update) } - ) - } - } - } - } - } - - private fun updateSingle(update: Update) { - if (update.fileList.requiresObbDir()) { - if (isGranted(requireContext(), PermissionType.STORAGE_MANAGER)) { - viewModel.download(update) - } else { - permissionProvider.request(PermissionType.STORAGE_MANAGER) { - if (it) viewModel.download(update) - } - } - } else { - viewModel.download(update) - } - } - - private fun updateAll() { - viewModel.updateAllEnqueued = true - if (viewModel.updates.value?.any { it.fileList.requiresObbDir() } == true) { - if (isGranted(requireContext(), PermissionType.STORAGE_MANAGER)) { - viewModel.downloadAll() - } else { - permissionProvider.request(PermissionType.STORAGE_MANAGER) { - if (it) viewModel.downloadAll() - } - } - } else { - viewModel.downloadAll() - } - } - - private fun cancelSingle(update: Update) { - viewModel.cancelDownload(update.packageName) - } - - private fun cancelAll() { - viewModel.cancelAll() - } -} diff --git a/app/src/main/java/com/aurora/store/viewmodel/all/UpdatesViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/all/UpdatesViewModel.kt index ec1027871..9864e463f 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/all/UpdatesViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/all/UpdatesViewModel.kt @@ -21,11 +21,22 @@ package com.aurora.store.viewmodel.all import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.helper.UpdateHelper +import com.aurora.store.data.paging.GenericPagingSource.Companion.pager import com.aurora.store.data.room.update.Update +import com.aurora.store.data.room.update.UpdateWithDownload import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @HiltViewModel @@ -34,12 +45,19 @@ class UpdatesViewModel @Inject constructor( private val downloadHelper: DownloadHelper ) : ViewModel() { - var updateAllEnqueued: Boolean = false + private val _updates = MutableStateFlow>(PagingData.empty()) + val updates = _updates.asStateFlow() - val downloadsList get() = downloadHelper.downloadsList - val updates get() = updateHelper.updates + val isCheckingUpdates = updateHelper.isCheckingUpdates + val hasOngoingUpdates = updateHelper.hasOngoingUpdates + .stateIn(viewModelScope, SharingStarted.Eagerly, false) - val fetchingUpdates = updateHelper.isCheckingUpdates + val updatesCount = updateHelper.updatesCount + .stateIn(viewModelScope, SharingStarted.Eagerly, 0) + + init { + getPagedUpdates() + } fun fetchUpdates() { updateHelper.checkUpdatesNow() @@ -51,7 +69,7 @@ class UpdatesViewModel @Inject constructor( fun downloadAll() { viewModelScope.launch { - updates.value?.forEach { downloadHelper.enqueueUpdate(it) } + updateHelper.updates.value?.forEach { downloadHelper.enqueueUpdate(it) } } } @@ -62,4 +80,12 @@ class UpdatesViewModel @Inject constructor( fun cancelAll() { viewModelScope.launch { downloadHelper.cancelAll(true) } } + + private fun getPagedUpdates() { + pager { updateHelper.pagedUpdates }.flow + .distinctUntilChanged() + .cachedIn(viewModelScope) + .onEach { _updates.value = it } + .launchIn(viewModelScope) + } } diff --git a/app/src/main/res/layout/view_app_update.xml b/app/src/main/res/layout/view_app_update.xml deleted file mode 100644 index e508bff03..000000000 --- a/app/src/main/res/layout/view_app_update.xml +++ /dev/null @@ -1,139 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/view_header_update.xml b/app/src/main/res/layout/view_header_update.xml deleted file mode 100644 index 6276cda27..000000000 --- a/app/src/main/res/layout/view_header_update.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/menu/menu_bottom_nav.xml b/app/src/main/res/menu/menu_bottom_nav.xml index 84ef7308a..c44b0ba8b 100644 --- a/app/src/main/res/menu/menu_bottom_nav.xml +++ b/app/src/main/res/menu/menu_bottom_nav.xml @@ -29,9 +29,4 @@ android:icon="@drawable/ic_games" android:title="@string/title_games" /> - - - \ No newline at end of file + diff --git a/app/src/main/res/navigation/mobile_navigation.xml b/app/src/main/res/navigation/mobile_navigation.xml index a3218ea8a..37f90b36d 100644 --- a/app/src/main/res/navigation/mobile_navigation.xml +++ b/app/src/main/res/navigation/mobile_navigation.xml @@ -51,11 +51,6 @@ android:name="com.aurora.store.view.ui.games.GamesContainerFragment" android:label="@string/title_games" tools:layout="@layout/fragment_apps_games" /> - - 200dp + 2dp 8dp 10dp 12dp diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a94f2f0dc..7510ef98b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,6 +58,7 @@ androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core" } androidx-adaptive-core = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" } androidx-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout", version.ref = "adaptive" } androidx-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation", version.ref = "adaptive" } +androidx-adaptive-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptive" } androidx-espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } androidx-hilt-viewmodel = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidx-hilt" } androidx-junit = { module = "androidx.test.ext:junit-ktx", version.ref = "androidx-junit" }