Initial migration of home logic to compose

Migrates UpdatesFragment and more dialog to compose. Rest still needs to be adapted.

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta
2025-12-22 13:01:58 +08:00
parent 743ea0a25d
commit 491f96be54
32 changed files with 1244 additions and 741 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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
)
)
}

View File

@@ -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
)
}
}

View File

@@ -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<Screen.Home> {
HomeContainerScreen(
onNavigateTo = { screen ->
backstack.add(screen)
}
)
}
}
)
}

View File

@@ -63,4 +63,7 @@ sealed class Screen : NavKey, Parcelable {
@Serializable
data object Installed : Screen()
@Serializable
data object Home : Screen()
}

View File

@@ -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()
}
}

View File

@@ -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()
}
}

View File

@@ -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<HomeScreen> = 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)
}
}

View File

@@ -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()
}
}

View File

@@ -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<UpdateWithDownload> = 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
)
}
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
*/

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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<Int, UpdateWithDownload>
@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> = DownloadStatus.running): Flow<Boolean>
@Query("SELECT COUNT(*) FROM `update`")
fun updatesCount(): Flow<Int>
}

View File

@@ -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?
)

View File

@@ -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)

View File

@@ -1,59 +0,0 @@
/*
* Aurora Store
* Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
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<ViewHeaderUpdateBinding>(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
}
}

View File

@@ -1,205 +0,0 @@
/*
* Aurora Store
* Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
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<ViewAppUpdateBinding>(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()
}
)
}
}
}
}

View File

@@ -172,21 +172,9 @@ abstract class BaseFlavouredSplashFragment : BaseFragment<FragmentSplashBinding>
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) {

View File

@@ -1,234 +0,0 @@
/*
* Aurora Store
* Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
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<FragmentUpdatesBinding>() {
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<ViewGroup.MarginLayoutParams> {
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<Update, Download?>?) {
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()
}
}

View File

@@ -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<UpdateWithDownload>>(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)
}
}

View File

@@ -1,139 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
~ Aurora Store
~ Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
~
~ 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 <http://www.gnu.org/licenses/>.
~
-->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layout_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:orientation="vertical"
android:paddingStart="@dimen/padding_medium"
android:paddingTop="@dimen/padding_small"
android:paddingEnd="@dimen/padding_small"
android:paddingBottom="@dimen/padding_small">
<RelativeLayout
android:id="@+id/head_flipper"
android:layout_width="@dimen/icon_size_medium"
android:layout_height="@dimen/icon_size_medium">
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/img_icon"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerInParent="true" />
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/progress_download"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_centerInParent="true"
android:visibility="gone"
app:indicatorSize="@dimen/icon_size_medium"
app:trackThickness="3dp"
tools:progress="40" />
</RelativeLayout>
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/txt_line1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_normal"
android:layout_toStartOf="@+id/headerIndicator"
android:layout_toEndOf="@id/head_flipper"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Aurora.Line1" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/txt_line2"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/txt_line1"
android:layout_alignStart="@+id/txt_line1"
android:layout_alignEnd="@id/txt_line1"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Aurora.Line3" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/txt_line3"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/txt_line2"
android:layout_alignStart="@+id/txt_line1"
android:layout_alignEnd="@id/txt_line1"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Aurora.Line3" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/txt_line4"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/txt_line3"
android:layout_alignStart="@+id/txt_line1"
android:layout_alignEnd="@id/txt_line1"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="@style/TextAppearance.Aurora.Line3" />
<com.google.android.material.button.MaterialButton
android:id="@+id/headerIndicator"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignTop="@id/txt_line1"
android:layout_toStartOf="@id/btn_action"
android:contentDescription="@string/details_changelog"
app:iconTint="?colorControlNormal"
app:icon="@drawable/ic_arrow_down" />
<com.aurora.store.view.custom.layouts.button.UpdateButton
android:id="@+id/btn_action"
android:layout_width="wrap_content"
android:layout_height="@dimen/height_button"
android:layout_alignTop="@id/txt_line1"
android:layout_alignParentEnd="true"
android:gravity="center" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_changelog"
style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/txt_line4"
android:layout_marginTop="@dimen/margin_small"
android:visibility="gone"
app:contentPadding="@dimen/padding_small">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/txt_changelog"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:singleLine="false"
android:textAppearance="@style/TextAppearance.Aurora.Line2"
android:textIsSelectable="true" />
</com.google.android.material.card.MaterialCardView>
</RelativeLayout>

View File

@@ -1,50 +0,0 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Aurora Store
~ Copyright (C) 2021, Rahul Kumar Patel <whyorean@gmail.com>
~
~ 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 <http://www.gnu.org/licenses/>.
~
-->
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_medium"
android:paddingTop="@dimen/padding_xsmall"
android:paddingEnd="@dimen/padding_small"
android:paddingBottom="@dimen/padding_xsmall">
<TextView
android:id="@+id/txt_title"
style="@style/AuroraTextStyle.Subtitle.Alt"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_toStartOf="@id/btn_action"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="1"
tools:text="Header" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_action"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:ellipsize="end"
android:text="@string/action_update_all" />
</RelativeLayout>

View File

@@ -29,9 +29,4 @@
android:icon="@drawable/ic_games"
android:title="@string/title_games" />
<item
android:id="@id/updatesFragment"
android:icon="@drawable/ic_updates"
android:title="@string/title_updates" />
</menu>
</menu>

View File

@@ -51,11 +51,6 @@
android:name="com.aurora.store.view.ui.games.GamesContainerFragment"
android:label="@string/title_games"
tools:layout="@layout/fragment_apps_games" />
<fragment
android:id="@+id/updatesFragment"
android:name="com.aurora.store.view.ui.updates.UpdatesFragment"
android:label="@string/title_updates"
tools:layout="@layout/fragment_updates" />
<fragment
android:id="@+id/settingsFragment"
android:name="com.aurora.store.view.ui.preferences.SettingsFragment"
@@ -162,12 +157,6 @@
app:launchSingleTop="true"
app:popUpTo="@id/mobile_navigation"
app:popUpToInclusive="true" />
<action
android:id="@+id/action_splashFragment_to_updatesFragment"
app:destination="@id/updatesFragment"
app:launchSingleTop="true"
app:popUpTo="@id/mobile_navigation"
app:popUpToInclusive="true" />
</fragment>
<fragment
android:id="@+id/googleFragment"

View File

@@ -67,6 +67,7 @@
<dimen name="review_height">200dp</dimen>
<dimen name="radius_xxsmall">2dp</dimen>
<dimen name="radius_small">8dp</dimen>
<dimen name="radius_medium">10dp</dimen>
<dimen name="radius_normal">12dp</dimen>

View File

@@ -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" }