From f6f858e9f581a90c22710257681cc16a0a880940 Mon Sep 17 00:00:00 2001 From: Aayush Gupta Date: Tue, 25 Mar 2025 15:19:11 +0800 Subject: [PATCH] compose: Initial migration of AppDetails* logic to compose [1/*] Signed-off-by: Aayush Gupta --- app/build.gradle.kts | 2 + app/src/main/AndroidManifest.xml | 3 +- .../java/com/aurora/extensions/Context.kt | 13 + .../aurora/extensions/NavBackStackEntry.kt | 24 + .../java/com/aurora/extensions/Shimmer.kt | 65 ++ .../java/com/aurora/extensions/Typography.kt | 19 + ...omposable.kt => ActionHeaderComposable.kt} | 18 +- .../composables/BlackListComposable.kt | 8 +- .../compose/composables/CategoryComposable.kt | 11 +- .../compose/composables/DownloadComposable.kt | 20 +- .../composables/FavouriteComposable.kt | 23 +- .../compose/composables/HeaderComposable.kt | 52 +- .../compose/composables/InfoComposable.kt | 85 ++ .../composables/InstalledAppComposable.kt | 8 +- .../composables/TextDividerComposable.kt | 2 +- .../composables/TopAppBarComposable.kt | 9 +- .../compose/composables/app/AppComposable.kt | 36 +- .../composables/app/AppListComposable.kt | 30 +- .../composables/app/AppProgressComposable.kt | 5 +- .../composables/app/AppTagComposable.kt | 45 ++ .../composables/app/NoAppComposable.kt | 8 +- .../composables/details/ExodusComposable.kt | 59 ++ .../composables/details/RatingComposable.kt | 56 ++ .../composables/details/ReviewComposable.kt | 110 +++ .../details/ScreenshotComposable.kt | 70 ++ .../store/compose/composables/preview/App.kt | 80 ++ .../store/compose/composables/preview/Coil.kt | 20 + .../store/compose/navigation/NavGraph.kt | 54 ++ .../aurora/store/compose/navigation/Screen.kt | 20 +- .../compose/ui/details/AppDetailsScreen.kt | 741 ++++++++++++++++++ .../compose/ui/details/DetailsExodusScreen.kt | 94 +++ .../compose/ui/details/DetailsMoreScreen.kt | 124 +++ .../compose/ui/details/DetailsReviewScreen.kt | 156 ++++ .../ui/details/DetailsScreenshotScreen.kt | 79 ++ .../ui/dialogs/ManualDownloadDialog.kt | 102 +++ .../com/aurora/store/data/model/Exodus.kt | 2 +- .../com/aurora/store/data/model/Plexus.kt | 16 +- .../store/data/paging/GenericPagingSource.kt | 69 ++ .../java/com/aurora/store/util/CommonUtil.kt | 5 +- .../store/view/ui/commons/BaseFragment.kt | 6 +- .../view/ui/details/AppDetailsFragment.kt | 94 +-- .../viewmodel/details/AppDetailsViewModel.kt | 263 ++++--- app/src/main/res/drawable/ic_android.xml | 7 +- app/src/main/res/drawable/ic_apk_install.xml | 7 +- app/src/main/res/drawable/ic_apps_outage.xml | 14 + app/src/main/res/drawable/ic_campaign.xml | 14 + app/src/main/res/drawable/ic_cloud_upload.xml | 7 +- .../main/res/drawable/ic_download_manager.xml | 27 +- app/src/main/res/drawable/ic_mail.xml | 27 +- app/src/main/res/drawable/ic_map.xml | 29 - app/src/main/res/drawable/ic_menu_about.xml | 27 +- app/src/main/res/drawable/ic_network.xml | 27 +- app/src/main/res/drawable/ic_paid.xml | 14 + .../main/res/drawable/ic_person_location.xml | 14 + app/src/main/res/drawable/ic_share.xml | 27 +- app/src/main/res/drawable/ic_star.xml | 27 +- app/src/main/res/drawable/ic_updates.xml | 31 +- app/src/main/res/drawable/ic_visibility.xml | 14 + .../main/res/layout/layout_details_dev.xml | 4 +- app/src/main/res/values/strings.xml | 11 + gradle/libs.versions.toml | 3 + 61 files changed, 2528 insertions(+), 509 deletions(-) create mode 100644 app/src/main/java/com/aurora/extensions/NavBackStackEntry.kt create mode 100644 app/src/main/java/com/aurora/extensions/Shimmer.kt create mode 100644 app/src/main/java/com/aurora/extensions/Typography.kt rename app/src/main/java/com/aurora/store/compose/composables/{UpdateHeaderComposable.kt => ActionHeaderComposable.kt} (84%) create mode 100644 app/src/main/java/com/aurora/store/compose/composables/InfoComposable.kt create mode 100644 app/src/main/java/com/aurora/store/compose/composables/app/AppTagComposable.kt create mode 100644 app/src/main/java/com/aurora/store/compose/composables/details/ExodusComposable.kt create mode 100644 app/src/main/java/com/aurora/store/compose/composables/details/RatingComposable.kt create mode 100644 app/src/main/java/com/aurora/store/compose/composables/details/ReviewComposable.kt create mode 100644 app/src/main/java/com/aurora/store/compose/composables/details/ScreenshotComposable.kt create mode 100644 app/src/main/java/com/aurora/store/compose/composables/preview/App.kt create mode 100644 app/src/main/java/com/aurora/store/compose/composables/preview/Coil.kt create mode 100644 app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt create mode 100644 app/src/main/java/com/aurora/store/compose/ui/details/DetailsExodusScreen.kt create mode 100644 app/src/main/java/com/aurora/store/compose/ui/details/DetailsMoreScreen.kt create mode 100644 app/src/main/java/com/aurora/store/compose/ui/details/DetailsReviewScreen.kt create mode 100644 app/src/main/java/com/aurora/store/compose/ui/details/DetailsScreenshotScreen.kt create mode 100644 app/src/main/java/com/aurora/store/compose/ui/dialogs/ManualDownloadDialog.kt create mode 100644 app/src/main/java/com/aurora/store/data/paging/GenericPagingSource.kt create mode 100644 app/src/main/res/drawable/ic_apps_outage.xml create mode 100644 app/src/main/res/drawable/ic_campaign.xml delete mode 100644 app/src/main/res/drawable/ic_map.xml create mode 100644 app/src/main/res/drawable/ic_paid.xml create mode 100644 app/src/main/res/drawable/ic_person_location.xml create mode 100644 app/src/main/res/drawable/ic_visibility.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8d1731150..3e882266c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -192,7 +192,9 @@ dependencies { implementation(libs.androidx.swiperefreshlayout) implementation(libs.androidx.viewpager2) implementation(libs.androidx.work.runtime.ktx) + implementation(libs.androidx.paging.runtime) + implementation(libs.androidx.paging.compose) implementation(libs.androidx.navigation.compose) implementation(libs.kotlinx.serialization.json) implementation(libs.androidx.navigation.fragment.ktx) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4dd0e0db7..0c6b65f8e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -110,7 +110,8 @@ + android:exported="false" + android:windowSoftInputMode="adjustPan" /> NavBackStackEntry.parentViewModel(navController: NavController): T { + val navGraphRoute = navController.graph.findStartDestination().route ?: return hiltViewModel() + val parentEntry = remember(this) { navController.getBackStackEntry(navGraphRoute) } + return hiltViewModel(parentEntry) +} diff --git a/app/src/main/java/com/aurora/extensions/Shimmer.kt b/app/src/main/java/com/aurora/extensions/Shimmer.kt new file mode 100644 index 000000000..20d3bb418 --- /dev/null +++ b/app/src/main/java/com/aurora/extensions/Shimmer.kt @@ -0,0 +1,65 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.extensions + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +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.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.IntSize + +/** + * Modifier for composable to play shimmer animation + * @param toShow Whether to play shimmer animation + */ +fun Modifier.shimmer(toShow: Boolean): Modifier { + return composed { + if (toShow) { + val shimmerColors = listOf( + Color.LightGray.copy(alpha = 0.6f), + Color.LightGray.copy(alpha = 0.2f), + Color.LightGray.copy(alpha = 0.6f) + ) + + var size by remember { mutableStateOf(IntSize.Zero) } + + val transition = rememberInfiniteTransition() + val transitionAnimation = transition.animateFloat( + initialValue = 0f, + targetValue = 1000f, + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 1000, + easing = FastOutSlowInEasing + ), + repeatMode = RepeatMode.Reverse + ) + ) + background( + brush = Brush.linearGradient( + colors = shimmerColors, + start = Offset.Zero, + end = Offset(x = transitionAnimation.value, y = transitionAnimation.value) + ) + ).onGloballyPositioned { size = it.size } + } else { + Modifier + } + } +} diff --git a/app/src/main/java/com/aurora/extensions/Typography.kt b/app/src/main/java/com/aurora/extensions/Typography.kt new file mode 100644 index 000000000..47cc69047 --- /dev/null +++ b/app/src/main/java/com/aurora/extensions/Typography.kt @@ -0,0 +1,19 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.extensions + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.sp + +/** + * Text style for composable with very small text + */ +val Typography.bodyVerySmall: TextStyle + @Composable + get() = MaterialTheme.typography.bodySmall.copy(fontSize = 10.sp) diff --git a/app/src/main/java/com/aurora/store/compose/composables/UpdateHeaderComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/ActionHeaderComposable.kt similarity index 84% rename from app/src/main/java/com/aurora/store/compose/composables/UpdateHeaderComposable.kt rename to app/src/main/java/com/aurora/store/compose/composables/ActionHeaderComposable.kt index e5f5a0567..b1b043118 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/UpdateHeaderComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/ActionHeaderComposable.kt @@ -31,9 +31,9 @@ import com.aurora.store.R * @see HeaderComposable */ @Composable -fun UpdateHeaderComposable( - @StringRes title: Int, - @StringRes actionTitle: Int, +fun ActionHeaderComposable( + title: String, + actionTitle: String, onAction: () -> Unit = {} ) { Row( @@ -47,14 +47,14 @@ fun UpdateHeaderComposable( verticalAlignment = Alignment.CenterVertically ) { Text( - text = stringResource(title), + text = title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis ) Button(onClick = onAction) { Text( - text = stringResource(actionTitle), + text = actionTitle, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -64,9 +64,9 @@ fun UpdateHeaderComposable( @Preview(showBackground = true) @Composable -private fun UpdateHeaderComposablePreview() { - UpdateHeaderComposable( - title = R.string.updates_available, - actionTitle = R.string.action_update_all +private fun ActionHeaderComposablePreview() { + ActionHeaderComposable( + title = stringResource(R.string.updates_available), + actionTitle = stringResource(R.string.action_update_all) ) } diff --git a/app/src/main/java/com/aurora/store/compose/composables/BlackListComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/BlackListComposable.kt index 27b5a86f7..7ed15fc1f 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/BlackListComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/BlackListComposable.kt @@ -7,7 +7,6 @@ package com.aurora.store.compose.composables import android.graphics.Bitmap import android.graphics.Color -import android.graphics.drawable.ColorDrawable import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -28,8 +27,9 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.sp import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.drawable.toDrawable +import com.aurora.extensions.bodyVerySmall import com.aurora.store.BuildConfig import com.aurora.store.R @@ -89,7 +89,7 @@ fun BlackListComposable( ) Text( text = stringResource(R.string.version, versionName, versionCode), - style = MaterialTheme.typography.bodySmall.copy(fontSize = 10.sp), + style = MaterialTheme.typography.bodyVerySmall, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -103,7 +103,7 @@ fun BlackListComposable( @Composable private fun BlackListComposablePreview() { BlackListComposable( - icon = ColorDrawable(Color.TRANSPARENT).toBitmap(56, 56), + icon = Color.GRAY.toDrawable().toBitmap(56, 56), displayName = LocalContext.current.getString(R.string.app_name), packageName = BuildConfig.APPLICATION_ID, versionName = BuildConfig.VERSION_NAME, diff --git a/app/src/main/java/com/aurora/store/compose/composables/CategoryComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/CategoryComposable.kt index fd5680999..ac177b69d 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/CategoryComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/CategoryComposable.kt @@ -14,18 +14,21 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Modifier 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.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import coil3.annotation.ExperimentalCoilApi import coil3.compose.AsyncImage +import coil3.compose.LocalAsyncImagePreviewHandler import coil3.request.ImageRequest import coil3.request.crossfade import com.aurora.gplayapi.data.models.Category import com.aurora.store.R +import com.aurora.store.compose.composables.preview.coilPreviewProvider /** * Composable to show a category in a list @@ -47,7 +50,6 @@ fun CategoryComposable(category: Category, onClick: () -> Unit = {}) { .crossfade(true) .build(), contentDescription = null, - placeholder = painterResource(R.drawable.ic_android), contentScale = ContentScale.Crop, modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_default)) ) @@ -62,6 +64,9 @@ fun CategoryComposable(category: Category, onClick: () -> Unit = {}) { @Preview(showBackground = true) @Composable +@OptIn(ExperimentalCoilApi::class) private fun CategoryComposablePreview() { - CategoryComposable(category = Category(title = "Art & Design")) + CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) { + CategoryComposable(category = Category(title = "Art & Design")) + } } diff --git a/app/src/main/java/com/aurora/store/compose/composables/DownloadComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/DownloadComposable.kt index 6d015496b..084f4f253 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/DownloadComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/DownloadComposable.kt @@ -18,22 +18,26 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider 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.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import coil3.annotation.ExperimentalCoilApi import coil3.compose.AsyncImage +import coil3.compose.LocalAsyncImagePreviewHandler import coil3.request.ImageRequest import coil3.request.crossfade import com.aurora.gplayapi.data.models.App -import com.aurora.store.BuildConfig import com.aurora.store.R +import com.aurora.store.compose.composables.preview.AppPreviewProvider +import com.aurora.store.compose.composables.preview.coilPreviewProvider import com.aurora.store.data.room.download.Download import com.aurora.store.util.CommonUtil.getDownloadSpeedString import com.aurora.store.util.CommonUtil.getETAString @@ -66,7 +70,6 @@ fun DownloadComposable( .crossfade(true) .build(), contentDescription = null, - placeholder = painterResource(R.drawable.ic_android), contentScale = ContentScale.Crop, modifier = Modifier .requiredSize(dimensionResource(R.dimen.icon_size_medium)) @@ -116,10 +119,9 @@ fun DownloadComposable( @Preview(showBackground = true) @Composable -private fun DownloadComposablePreview() { - val app = App( - packageName = BuildConfig.APPLICATION_ID, - displayName = LocalContext.current.getString(R.string.app_name) - ) - DownloadComposable(download = Download.fromApp(app)) +@OptIn(ExperimentalCoilApi::class) +private fun DownloadComposablePreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) { + DownloadComposable(download = Download.fromApp(app)) + } } diff --git a/app/src/main/java/com/aurora/store/compose/composables/FavouriteComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/FavouriteComposable.kt index 298e11037..2c1e73c20 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/FavouriteComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/FavouriteComposable.kt @@ -19,6 +19,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -29,13 +30,17 @@ 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.unit.sp +import androidx.compose.ui.tooling.preview.PreviewParameter +import coil3.annotation.ExperimentalCoilApi import coil3.compose.AsyncImage +import coil3.compose.LocalAsyncImagePreviewHandler import coil3.request.ImageRequest import coil3.request.crossfade +import com.aurora.extensions.bodyVerySmall import com.aurora.gplayapi.data.models.App -import com.aurora.store.BuildConfig import com.aurora.store.R +import com.aurora.store.compose.composables.preview.AppPreviewProvider +import com.aurora.store.compose.composables.preview.coilPreviewProvider import com.aurora.store.data.room.favourite.Favourite /** @@ -68,7 +73,6 @@ fun FavouriteComposable( .crossfade(true) .build(), contentDescription = null, - placeholder = painterResource(R.drawable.ic_android), contentScale = ContentScale.Crop, modifier = Modifier .requiredSize(dimensionResource(R.dimen.icon_size_medium)) @@ -95,7 +99,7 @@ fun FavouriteComposable( favourite.added, DateUtils.FORMAT_SHOW_DATE ), - style = MaterialTheme.typography.bodySmall.copy(fontSize = 10.sp) + style = MaterialTheme.typography.bodyVerySmall ) } } @@ -110,10 +114,9 @@ fun FavouriteComposable( @Preview(showBackground = true) @Composable -private fun FavouriteComposablePreview() { - val app = App( - packageName = BuildConfig.APPLICATION_ID, - displayName = LocalContext.current.getString(R.string.app_name) - ) - FavouriteComposable(favourite = Favourite.fromApp(app, Favourite.Mode.MANUAL)) +@OptIn(ExperimentalCoilApi::class) +private fun FavouriteComposablePreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) { + FavouriteComposable(favourite = Favourite.fromApp(app, Favourite.Mode.MANUAL)) + } } diff --git a/app/src/main/java/com/aurora/store/compose/composables/HeaderComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/HeaderComposable.kt index 049be8247..dc7c4f17c 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/HeaderComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/HeaderComposable.kt @@ -5,18 +5,20 @@ package com.aurora.store.compose.composables -import androidx.annotation.StringRes import androidx.compose.foundation.clickable 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.shape.RoundedCornerShape 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.draw.clip import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -27,16 +29,18 @@ import com.aurora.store.R /** * Composable to display sticky header in list * @param title Title to display + * @param subtitle Optional subtitle to display * @param onClick Callback when this composable is clicked * @see TextDividerComposable - * @see UpdateHeaderComposable + * @see ActionHeaderComposable */ @Composable -fun HeaderComposable(@StringRes title: Int, onClick: () -> Unit = {}) { +fun HeaderComposable(title: String, subtitle: String? = null, onClick: (() -> Unit)? = null) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) + .clickable(onClick = { if (onClick != null) onClick() }, enabled = onClick != null) .padding( horizontal = dimensionResource(R.dimen.padding_small), vertical = dimensionResource(R.dimen.padding_xxsmall) @@ -44,22 +48,38 @@ fun HeaderComposable(@StringRes title: Int, onClick: () -> Unit = {}) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text( - text = stringResource(title), - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1F) - ) - Icon( - painter = painterResource(R.drawable.ic_arrow_right), - contentDescription = null - ) + Column( + modifier = Modifier.weight(1F), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_xsmall)) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium + ) + } + } + if (onClick != null) { + Icon( + painter = painterResource(R.drawable.ic_arrow_right), + contentDescription = null, + ) + } } } @Preview(showBackground = true) @Composable private fun HeaderComposablePreview() { - HeaderComposable(title = R.string.app_name) + HeaderComposable( + title = stringResource(R.string.details_privacy), + subtitle = stringResource(R.string.exodus_powered), + onClick = {} + ) } diff --git a/app/src/main/java/com/aurora/store/compose/composables/InfoComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/InfoComposable.kt new file mode 100644 index 000000000..fe33d13a0 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composables/InfoComposable.kt @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composables + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +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.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.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.composables.preview.AppPreviewProvider + +/** + * Composable to show some information + * @param title Title of the information + * @param description Information to show + * @param icon Optional icon representing the information + * @param onClick Callback when this composable is clicked + */ +@Composable +fun InfoComposable( + title: AnnotatedString, + description: AnnotatedString? = null, + @DrawableRes icon: Int? = null, + onClick: (() -> Unit)? = null +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = { if (onClick != null) onClick() }, enabled = onClick != null) + .padding( + horizontal = dimensionResource(R.dimen.padding_small), + vertical = dimensionResource(R.dimen.padding_xxsmall) + ), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_normal)), + verticalAlignment = Alignment.CenterVertically + ) { + if (icon != null) Icon(painter = painterResource(icon), contentDescription = null) + Column(modifier = Modifier.weight(1F)) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!description.isNullOrBlank()) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun InfoComposablePreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + InfoComposable( + title = AnnotatedString(text = stringResource(R.string.details_dev_website)), + description = AnnotatedString.fromHtml(htmlString = app.developerWebsite), + icon = R.drawable.ic_network + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/composables/InstalledAppComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/InstalledAppComposable.kt index ab6c3975b..54b75b5c3 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/InstalledAppComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/InstalledAppComposable.kt @@ -7,7 +7,6 @@ package com.aurora.store.compose.composables import android.graphics.Bitmap import android.graphics.Color -import android.graphics.drawable.ColorDrawable import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.combinedClickable @@ -26,8 +25,9 @@ import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.sp import androidx.core.graphics.drawable.toBitmap +import androidx.core.graphics.drawable.toDrawable +import com.aurora.extensions.bodyVerySmall import com.aurora.store.BuildConfig import com.aurora.store.R @@ -84,7 +84,7 @@ fun InstalledAppComposable( ) Text( text = stringResource(R.string.version, versionName, versionCode), - style = MaterialTheme.typography.bodySmall.copy(fontSize = 10.sp), + style = MaterialTheme.typography.bodyVerySmall, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -97,7 +97,7 @@ fun InstalledAppComposable( @Composable private fun InstalledAppComposablePreview() { InstalledAppComposable( - icon = ColorDrawable(Color.TRANSPARENT).toBitmap(56, 56), + icon = Color.GRAY.toDrawable().toBitmap(56, 56), displayName = LocalContext.current.getString(R.string.app_name), packageName = BuildConfig.APPLICATION_ID, versionName = BuildConfig.VERSION_NAME, diff --git a/app/src/main/java/com/aurora/store/compose/composables/TextDividerComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/TextDividerComposable.kt index 9296eddba..3e95ed366 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/TextDividerComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/TextDividerComposable.kt @@ -23,7 +23,7 @@ import com.aurora.store.R * Composable to display a sticky header in a list * @param title Title to display * @see HeaderComposable - * @see UpdateHeaderComposable + * @see ActionHeaderComposable */ @Composable fun TextDividerComposable(@StringRes title: Int) { diff --git a/app/src/main/java/com/aurora/store/compose/composables/TopAppBarComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/TopAppBarComposable.kt index 737a04c08..927dc3da8 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/TopAppBarComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/TopAppBarComposable.kt @@ -5,7 +5,6 @@ package com.aurora.store.compose.composables -import androidx.annotation.StringRes import androidx.compose.foundation.layout.RowScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -28,14 +27,12 @@ import com.aurora.store.R @Composable @OptIn(ExperimentalMaterial3Api::class) fun TopAppBarComposable( - @StringRes title: Int? = null, + title: String? = null, onNavigateUp: () -> Unit, actions: @Composable (RowScope.() -> Unit) = {} ) { TopAppBar( - title = { - if (title != null) Text(text = stringResource(title)) - }, + title = { if (title != null) Text(text = title) }, navigationIcon = { IconButton(onClick = onNavigateUp) { Icon( @@ -52,7 +49,7 @@ fun TopAppBarComposable( @Composable private fun TopAppBarComposablePreview() { TopAppBarComposable( - title = R.string.title_about, + title = stringResource(R.string.title_about), onNavigateUp = {} ) } diff --git a/app/src/main/java/com/aurora/store/compose/composables/app/AppComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/app/AppComposable.kt index 80604776c..984c216c8 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/app/AppComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/app/AppComposable.kt @@ -5,6 +5,7 @@ package com.aurora.store.compose.composables.app +import android.text.format.Formatter import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding @@ -14,22 +15,25 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider 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.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.sp +import androidx.compose.ui.tooling.preview.PreviewParameter +import coil3.annotation.ExperimentalCoilApi import coil3.compose.AsyncImage +import coil3.compose.LocalAsyncImagePreviewHandler import coil3.request.ImageRequest import coil3.request.crossfade +import com.aurora.extensions.bodyVerySmall import com.aurora.gplayapi.data.models.App -import com.aurora.store.BuildConfig import com.aurora.store.R -import com.aurora.store.util.CommonUtil.addSiPrefix +import com.aurora.store.compose.composables.preview.AppPreviewProvider +import com.aurora.store.compose.composables.preview.coilPreviewProvider /** * Composable for displaying minimal app details in a horizontal-scrollable list @@ -39,6 +43,8 @@ import com.aurora.store.util.CommonUtil.addSiPrefix */ @Composable fun AppComposable(app: App, onClick: () -> Unit = {}) { + val context = LocalContext.current + Column( modifier = Modifier .width(dimensionResource(R.dimen.icon_size_cluster)) @@ -51,7 +57,6 @@ fun AppComposable(app: App, onClick: () -> Unit = {}) { .crossfade(true) .build(), contentDescription = null, - placeholder = painterResource(R.drawable.ic_android), contentScale = ContentScale.Crop, modifier = Modifier .requiredSize(dimensionResource(R.dimen.icon_size_cluster)) @@ -64,20 +69,21 @@ fun AppComposable(app: App, onClick: () -> Unit = {}) { overflow = TextOverflow.Ellipsis ) Text( - text = if (app.size > 0) addSiPrefix(app.size) else app.downloadString, - style = MaterialTheme.typography.bodySmall.copy(fontSize = 10.sp) + text = if (app.size > 0) { + Formatter.formatShortFileSize(context, app.size) + } else { + app.downloadString + }, + style = MaterialTheme.typography.bodyVerySmall ) } } @Preview(showBackground = true) @Composable -private fun AppComposablePreview() { - AppComposable( - app = App( - packageName = BuildConfig.APPLICATION_ID, - displayName = LocalContext.current.getString(R.string.app_name), - size = 7431013 - ) - ) +@OptIn(ExperimentalCoilApi::class) +private fun AppComposablePreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) { + AppComposable(app = app) + } } diff --git a/app/src/main/java/com/aurora/store/compose/composables/app/AppListComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/app/AppListComposable.kt index 128088484..ec2371062 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/app/AppListComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/app/AppListComposable.kt @@ -15,25 +15,28 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider 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.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.sp +import androidx.compose.ui.tooling.preview.PreviewParameter +import coil3.annotation.ExperimentalCoilApi import coil3.compose.AsyncImage +import coil3.compose.LocalAsyncImagePreviewHandler import coil3.request.ImageRequest import coil3.request.crossfade import com.aurora.Constants.PACKAGE_NAME_GMS +import com.aurora.extensions.bodyVerySmall import com.aurora.gplayapi.data.models.App -import com.aurora.store.BuildConfig import com.aurora.store.R +import com.aurora.store.compose.composables.preview.AppPreviewProvider +import com.aurora.store.compose.composables.preview.coilPreviewProvider import com.aurora.store.util.CommonUtil -import com.aurora.store.util.PackageUtil /** * Composable for displaying minimal app details in a vertical-scrollable list @@ -58,7 +61,6 @@ fun AppListComposable(app: App, onClick: () -> Unit = {}) { .crossfade(true) .build(), contentDescription = null, - placeholder = painterResource(R.drawable.ic_android), contentScale = ContentScale.Crop, modifier = Modifier .requiredSize(dimensionResource(R.dimen.icon_size_medium)) @@ -76,7 +78,7 @@ fun AppListComposable(app: App, onClick: () -> Unit = {}) { Text(text = app.developerName, style = MaterialTheme.typography.bodySmall) Text( text = buildExtras(app).joinToString(separator = " • "), - style = MaterialTheme.typography.bodySmall.copy(fontSize = 10.sp) + style = MaterialTheme.typography.bodyVerySmall ) } } @@ -84,17 +86,11 @@ fun AppListComposable(app: App, onClick: () -> Unit = {}) { @Preview(showBackground = true) @Composable -fun AppListComposablePreview() { - AppListComposable( - app = App( - packageName = BuildConfig.APPLICATION_ID, - displayName = LocalContext.current.getString(R.string.app_name), - developerName = "Rahul Kumar Patel", - isFree = true, - containsAds = false, - size = 7431013 - ) - ) +@OptIn(ExperimentalCoilApi::class) +fun AppListComposablePreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) { + AppListComposable(app = app) + } } @Composable diff --git a/app/src/main/java/com/aurora/store/compose/composables/app/AppProgressComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/app/AppProgressComposable.kt index d1d9151b4..c9fb15bea 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/app/AppProgressComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/app/AppProgressComposable.kt @@ -19,11 +19,12 @@ import com.aurora.store.R /** * Composable to display an indeterminate circular progress indicator + * @param modifier Modifier for the composable */ @Composable -fun AppProgressComposable() { +fun AppProgressComposable(modifier: Modifier = Modifier) { Box( - modifier = Modifier + modifier = modifier .fillMaxSize() .padding(dimensionResource(R.dimen.padding_small)), contentAlignment = Alignment.Center diff --git a/app/src/main/java/com/aurora/store/compose/composables/app/AppTagComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/app/AppTagComposable.kt new file mode 100644 index 000000000..0cf3151da --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composables/app/AppTagComposable.kt @@ -0,0 +1,45 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composables.app + +import androidx.annotation.DrawableRes +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +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.composables.preview.AppPreviewProvider + +/** + * Composable to show a tag related to an app + * @param label Label of the tag + * @param icon Icon of the tag + * @param onClick Callback when this composable is clicked + */ +@Composable +fun AppTagComposable(label: String, @DrawableRes icon: Int, onClick: () -> Unit = {}) { + FilterChip( + onClick = onClick, + label = { Text(text = label, style = MaterialTheme.typography.bodySmall) }, + leadingIcon = { Icon(painter = painterResource(icon), contentDescription = label) }, + selected = true + ) +} + +@Preview(showBackground = true) +@Composable +private fun AppTagComposablePreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + AppTagComposable( + label = stringResource(R.string.details_free), + icon = R.drawable.ic_paid + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/composables/app/NoAppComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/app/NoAppComposable.kt index f86c3ac36..4be59d5f2 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/app/NoAppComposable.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/app/NoAppComposable.kt @@ -7,12 +7,12 @@ package com.aurora.store.compose.composables.app import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.requiredSize import androidx.compose.material3.Button +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -26,6 +26,7 @@ import com.aurora.store.R /** * Composable to show error message when no apps are available for a request + * @param modifier Modifier for the composable * @param icon Drawable for error * @param message Message for error * @param actionMessage Message to show on action button; defaults to null with button not visible @@ -34,20 +35,21 @@ import com.aurora.store.R */ @Composable fun NoAppComposable( + modifier: Modifier = Modifier, @DrawableRes icon: Int, @StringRes message: Int, @StringRes actionMessage: Int? = null, onAction: () -> Unit = {} ) { Column( - modifier = Modifier.fillMaxSize(), + modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy( dimensionResource(R.dimen.margin_small), Alignment.CenterVertically ), horizontalAlignment = Alignment.CenterHorizontally ) { - Image( + Icon( painter = painterResource(id = icon), contentDescription = null, modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size)) diff --git a/app/src/main/java/com/aurora/store/compose/composables/details/ExodusComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/details/ExodusComposable.kt new file mode 100644 index 000000000..a8be0e0ee --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composables/details/ExodusComposable.kt @@ -0,0 +1,59 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composables.details + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import com.aurora.extensions.bodyVerySmall +import com.aurora.store.R +import com.aurora.store.data.model.ExodusTracker + +/** + * Composable to display details about a tracker reported by Exodus Privacy + * @param tracker Tracker to display details about + */ +@Composable +fun ExodusComposable(tracker: ExodusTracker) { + Column(modifier = Modifier.padding(dimensionResource(R.dimen.padding_small))) { + Text( + text = tracker.name, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = tracker.signature, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + text = tracker.date, + style = MaterialTheme.typography.bodyVerySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun ExodusComposablePreview() { + ExodusComposable( + tracker = ExodusTracker( + name = "Google Analytics", + signature = "com.google.android.apps.analytics.|com.google.android.gms.analytics.|com.google.analytics.", + date = "2017-09-24" + ) + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/composables/details/RatingComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/details/RatingComposable.kt new file mode 100644 index 000000000..989ea26b6 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composables/details/RatingComposable.kt @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composables.details + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +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.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import com.aurora.store.R + +/** + * Composable to show a progress bar with rating for an app + * @param label Label of the rating, for e.g. 5 + * @param rating Current rating, for e.g. 0.3 + */ +@Composable +fun RatingComposable(label: String, rating: Float) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimensionResource(R.dimen.padding_small)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small)) + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold + ) + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(dimensionResource(R.dimen.radius_small)), + progress = { rating } + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun RatingComposablePreview() { + RatingComposable(label = "5", rating = 0.5F) +} diff --git a/app/src/main/java/com/aurora/store/compose/composables/details/ReviewComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/details/ReviewComposable.kt new file mode 100644 index 000000000..bbc774f95 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composables/details/ReviewComposable.kt @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composables.details + +import android.text.format.DateUtils +import android.widget.RatingBar +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.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.datasource.LoremIpsum +import androidx.compose.ui.viewinterop.AndroidView +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImage +import coil3.compose.LocalAsyncImagePreviewHandler +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.aurora.gplayapi.data.models.Review +import com.aurora.store.R +import com.aurora.store.compose.composables.preview.coilPreviewProvider + +/** + * Composable for viewing a review about an app + * @param review [Review] about an app + */ +@Composable +fun ReviewComposable(review: Review) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimensionResource(R.dimen.padding_medium), + vertical = dimensionResource(R.dimen.padding_small) + ) + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(review.userPhotoUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_small)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) + ) + Column( + modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small)), + ) { + Text( + text = review.userName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = DateUtils.formatDateTime( + LocalContext.current, + review.timeStamp, + DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_YEAR + ), + style = MaterialTheme.typography.bodySmall + ) + AndroidView( + factory = { context -> + RatingBar(context, null, android.R.attr.ratingBarStyleSmall) + }, + update = { view -> + view.rating = review.rating.toFloat() + } + ) + Text( + text = review.comment, + style = MaterialTheme.typography.bodySmall + ) + } + } +} + +@Preview(showBackground = true) +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun ReviewComposablePreview() { + CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) { + ReviewComposable( + review = Review( + userName = "Rahul Kumar Patel", + timeStamp = 1745750879, + comment = LoremIpsum(40).values.first(), + rating = 4 + ) + ) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/composables/details/ScreenshotComposable.kt b/app/src/main/java/com/aurora/store/compose/composables/details/ScreenshotComposable.kt new file mode 100644 index 000000000..46cc97c77 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composables/details/ScreenshotComposable.kt @@ -0,0 +1,70 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composables.details + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImagePainter +import coil3.compose.LocalAsyncImagePreviewHandler +import coil3.compose.rememberAsyncImagePainter +import coil3.compose.rememberConstraintsSizeResolver +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.aurora.extensions.shimmer +import com.aurora.gplayapi.data.models.App +import com.aurora.store.compose.composables.preview.AppPreviewProvider +import com.aurora.store.compose.composables.preview.coilPreviewProvider + +/** + * Composable to display a screenshot of an app + * @param url URL of the screenshot + * @param modifier Modifier for the composable + */ +@Composable +fun ScreenshotComposable(modifier: Modifier = Modifier, url: String) { + // See https://coil-kt.github.io/coil/compose/#rememberasyncimagepainter + val sizeResolver = rememberConstraintsSizeResolver() + val painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(url) + .size(sizeResolver) + .crossfade(true) + .build() + ) + val state by painter.state.collectAsStateWithLifecycle() + val aspectRatioModifier = state.painter?.intrinsicSize?.let { intrinsicSize -> + Modifier.aspectRatio(ratio = intrinsicSize.width / intrinsicSize.height) + } + + Image( + painter = painter, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = modifier + .shimmer(state is AsyncImagePainter.State.Loading) + .then(sizeResolver) + .then(aspectRatioModifier ?: Modifier) + ) +} + +@Preview +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun ScreenshotComposablePreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) { + ScreenshotComposable(url = app.screenshots.firstOrNull()?.url ?: "") + } +} diff --git a/app/src/main/java/com/aurora/store/compose/composables/preview/App.kt b/app/src/main/java/com/aurora/store/compose/composables/preview/App.kt new file mode 100644 index 000000000..68408609c --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composables/preview/App.kt @@ -0,0 +1,80 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composables.preview + +import android.Manifest +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.data.models.Artwork +import com.aurora.gplayapi.data.models.Rating +import com.aurora.store.BuildConfig + +/** + * Preview provider for composable working with [App] + */ +class AppPreviewProvider : PreviewParameterProvider { + companion object { + private const val CHANGELOG = """ + • New app compatibility ratings powered by Plexus
+ • Improvements to blacklist manager
+ • Ability to change auto-update restrictions
+ • Minor bug fixes and improvements
+ • Translation updates; additional strings localized + """ + + private const val DESCRIPTION = """ +

Aurora Store is an unofficial, FOSS client to Google Play with an elegant design. Aurora Store allows users to download, update, and search for apps like the Play Store. It works perfectly fine with or without Google Play Services or microG.

+ +

Features:

+ +

• FOSS: Has GPLv3 licence
+ • Beautiful design: Built upon latest Material 3 guidelines
+ • Account login: You can login with either personal or an anonymous account
+ • Device & Locale spoofing: Change your device and/or locale to access geo locked apps
+ • Exodus Privacy integration: Instantly see trackers in app
+ • Plexus integration: Instantly see app compatibility without Google Play Services or with microG
+ • Updates blacklisting: Ignore updates for specific apps

+ """ + } + + override val values: Sequence + get() = sequenceOf( + App( + packageName = BuildConfig.APPLICATION_ID, + displayName = "Aurora Store", + developerName = "Rahul Kumar Patel", + versionCode = BuildConfig.VERSION_CODE, + versionName = BuildConfig.VERSION_NAME, + shortDescription = "An unofficial FOSS client to Google Play with an elegant design and privacy", + changes = CHANGELOG, + description = DESCRIPTION, + isFree = true, + containsAds = false, + isInstalled = true, + size = 7431013, + updatedOn = "Mar 17, 2025", + labeledRating = "4.3", + installs = 1000000000, + developerEmail = "rahul@auroraoss.com", + developerWebsite = "https://auroraoss.com/", + developerAddress = "330 N Midland Ave, Mumbai, India", + screenshots = MutableList(5) { Artwork(url = "$it") }, + rating = Rating( + fiveStar = 201458104, + fourStar = 313829104, + threeStar = 204581672, + twoStar = 183746829, + oneStar = 96384291 + ), + permissions = mutableListOf( + Manifest.permission.INTERNET, + Manifest.permission.ACCESS_NETWORK_STATE, + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE + ) + ) + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/composables/preview/Coil.kt b/app/src/main/java/com/aurora/store/compose/composables/preview/Coil.kt new file mode 100644 index 000000000..dfa2fe477 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/composables/preview/Coil.kt @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.composables.preview + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import coil3.ColorImage +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImagePreviewHandler + +/** + * Preview provider for composable working with coil for loading remote images + */ +@OptIn(ExperimentalCoilApi::class) +val coilPreviewProvider = AsyncImagePreviewHandler { + ColorImage(Color.Gray.toArgb()) +} diff --git a/app/src/main/java/com/aurora/store/compose/navigation/NavGraph.kt b/app/src/main/java/com/aurora/store/compose/navigation/NavGraph.kt index 5ba69343e..ed376b063 100644 --- a/app/src/main/java/com/aurora/store/compose/navigation/NavGraph.kt +++ b/app/src/main/java/com/aurora/store/compose/navigation/NavGraph.kt @@ -10,7 +10,14 @@ import androidx.compose.runtime.Composable import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.toRoute +import com.aurora.extensions.parentViewModel import com.aurora.store.compose.ui.commons.BlacklistScreen +import com.aurora.store.compose.ui.details.AppDetailsScreen +import com.aurora.store.compose.ui.details.DetailsExodusScreen +import com.aurora.store.compose.ui.details.DetailsMoreScreen +import com.aurora.store.compose.ui.details.DetailsReviewScreen +import com.aurora.store.compose.ui.details.DetailsScreenshotScreen /** * Navigation graph for compose screens @@ -33,5 +40,52 @@ fun NavGraph(navHostController: NavHostController, startDestination: Screen) { composable { BlacklistScreen(onNavigateUp = { onNavigateUp() }) } + + composable { backstackEntry -> + val appDetails = backstackEntry.toRoute() + AppDetailsScreen( + packageName = appDetails.packageName, + viewModel = backstackEntry.parentViewModel(navHostController), + onNavigateUp = { onNavigateUp() }, + onNavigateToDetailsMore = { navHostController.navigate(Screen.DetailsMore) }, + onNavigateToDetailsReview = { navHostController.navigate(Screen.DetailsReview) }, + onNavigateToDetailsExodus = { navHostController.navigate(Screen.DetailsExodus) }, + onNavigateToDetailsScreenshot = { index -> + navHostController.navigate( + Screen.DetailsScreenshot(index) + ) + } + ) + } + + composable { backstackEntry -> + DetailsExodusScreen( + onNavigateUp = { onNavigateUp() }, + viewModel = backstackEntry.parentViewModel(navHostController) + ) + } + + composable { backstackEntry -> + DetailsMoreScreen( + onNavigateUp = { onNavigateUp() }, + viewModel = backstackEntry.parentViewModel(navHostController) + ) + } + + composable { backstackEntry -> + val screenshotDetails = backstackEntry.toRoute() + DetailsScreenshotScreen( + index = screenshotDetails.index, + onNavigateUp = { onNavigateUp() }, + viewModel = backstackEntry.parentViewModel(navHostController) + ) + } + + composable { backstackEntry -> + DetailsReviewScreen( + onNavigateUp = { onNavigateUp() }, + viewModel = backstackEntry.parentViewModel(navHostController) + ) + } } } 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 927502aed..e40c3f541 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 @@ -19,7 +19,10 @@ import kotlinx.serialization.Serializable */ @Parcelize @Serializable -sealed class Screen(@StringRes val label: Int, @DrawableRes val icon: Int? = null): Parcelable { +sealed class Screen( + @StringRes val label: Int? = null, + @DrawableRes val icon: Int? = null +) : Parcelable { companion object { const val PARCEL_KEY = "SCREEN" @@ -27,4 +30,19 @@ sealed class Screen(@StringRes val label: Int, @DrawableRes val icon: Int? = nul @Serializable data object Blacklist : Screen(label = R.string.title_blacklist_manager) + + @Serializable + data class AppDetails(val packageName: String) : Screen() + + @Serializable + data object DetailsMore : Screen() + + @Serializable + data class DetailsScreenshot(val index: Int) : Screen() + + @Serializable + data object DetailsExodus : Screen() + + @Serializable + data object DetailsReview : Screen() } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt new file mode 100644 index 000000000..93a5c0e1f --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt @@ -0,0 +1,741 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.details + +import android.content.ActivityNotFoundException +import android.text.format.Formatter +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +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.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.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 androidx.compose.ui.util.fastForEach +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.AsyncImage +import coil3.compose.LocalAsyncImagePreviewHandler +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.aurora.extensions.bodyVerySmall +import com.aurora.extensions.browse +import com.aurora.extensions.copyToClipBoard +import com.aurora.extensions.mailTo +import com.aurora.extensions.toast +import com.aurora.gplayapi.data.models.App +import com.aurora.gplayapi.data.models.Artwork +import com.aurora.gplayapi.data.models.Rating +import com.aurora.gplayapi.data.models.datasafety.EntryType +import com.aurora.store.R +import com.aurora.store.compose.composables.HeaderComposable +import com.aurora.store.compose.composables.InfoComposable +import com.aurora.store.compose.composables.TopAppBarComposable +import com.aurora.store.compose.composables.app.AppProgressComposable +import com.aurora.store.compose.composables.app.AppTagComposable +import com.aurora.store.compose.composables.app.NoAppComposable +import com.aurora.store.compose.composables.details.RatingComposable +import com.aurora.store.compose.composables.details.ScreenshotComposable +import com.aurora.store.compose.composables.preview.AppPreviewProvider +import com.aurora.store.compose.composables.preview.coilPreviewProvider +import com.aurora.store.compose.ui.dialogs.ManualDownloadDialog +import com.aurora.store.data.installer.AppInstaller +import com.aurora.store.data.model.Report +import com.aurora.store.data.model.Scores +import com.aurora.store.data.room.download.Download +import com.aurora.store.util.CommonUtil +import com.aurora.store.util.PackageUtil +import com.aurora.store.util.PackageUtil.PACKAGE_NAME_GMS +import com.aurora.store.viewmodel.details.AppDetailsViewModel +import java.util.Locale +import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport + +@Composable +fun AppDetailsScreen( + packageName: String, + onNavigateUp: () -> Unit, + onNavigateToDetailsMore: () -> Unit, + onNavigateToDetailsScreenshot: (index: Int) -> Unit, + onNavigateToDetailsReview: () -> Unit, + onNavigateToDetailsExodus: () -> Unit, + viewModel: AppDetailsViewModel = hiltViewModel() +) { + val context = LocalContext.current + + val app by viewModel.app.collectAsStateWithLifecycle() + val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle() + val dataSafetyReport by viewModel.dataSafetyReport.collectAsStateWithLifecycle() + val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle() + val download by viewModel.download.collectAsStateWithLifecycle() + + var shouldShowManualDownloadDialog by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(key1 = Unit) { viewModel.fetchAppDetails(packageName) } + LaunchedEffect(key1 = Unit) { + viewModel.purchaseStatus.collect { success -> + if (success) { + shouldShowManualDownloadDialog = false + context.toast(R.string.toast_manual_available) + } else { + context.toast(R.string.toast_manual_unavailable) + } + } + } + + if (shouldShowManualDownloadDialog) { + ManualDownloadDialog( + currentVersionCode = app!!.versionCode.toLong(), + onConfirm = { versionCode -> + viewModel.purchase(app!!.copy(versionCode = versionCode.toInt())) + }, + onDismiss = { shouldShowManualDownloadDialog = false } + ) + } + + with(app) { + when { + this != null -> { + if (this.packageName.isBlank()) { + ScreenContentLoading(onNavigateUp = onNavigateUp) + } else { + ScreenContent( + app = this, + download = download, + plexusScores = plexusScores, + dataSafetyReport = dataSafetyReport, + exodusReport = exodusReport, + hasValidUpdate = viewModel.hasValidUpdate, + showSimilarApps = viewModel.showSimilarApps, + onNavigateUp = onNavigateUp, + onNavigateToDetailsMore = onNavigateToDetailsMore, + onNavigateToDetailsScreenshot = onNavigateToDetailsScreenshot, + onNavigateToDetailsReview = onNavigateToDetailsReview, + onNavigateToDetailsExodus = onNavigateToDetailsExodus, + onDownload = { viewModel.download(this) }, + onManualDownload = { shouldShowManualDownloadDialog = true }, + onCancelDownload = { viewModel.cancelDownload(this) }, + onUninstall = { AppInstaller.uninstall(context, packageName) }, + onOpen = { + try { + context.startActivity( + PackageUtil.getLaunchIntent(context, packageName) + ) + } catch (exception: ActivityNotFoundException) { + context.toast(context.getString(R.string.unable_to_open)) + } + } + ) + } + } + + // TODO: Deal with different kind of errors + else -> ScreenContentError(onNavigateUp = onNavigateUp) + } + } +} + +@Composable +private fun ScreenContentLoading(onNavigateUp: () -> Unit = {}) { + Scaffold( + topBar = { TopAppBarComposable(onNavigateUp = onNavigateUp) } + ) { paddingValues -> + AppProgressComposable(modifier = Modifier.padding(paddingValues)) + } +} + +@Composable +private fun ScreenContentError(onNavigateUp: () -> Unit = {}) { + Scaffold( + topBar = { TopAppBarComposable(onNavigateUp = onNavigateUp) } + ) { paddingValues -> + NoAppComposable( + modifier = Modifier.padding(paddingValues), + icon = R.drawable.ic_apps_outage, + message = R.string.toast_app_unavailable + ) + } +} + +@Composable +private fun ScreenContent( + app: App, + download: Download? = null, + plexusScores: Scores? = null, + dataSafetyReport: DataSafetyReport? = null, + exodusReport: Report? = null, + hasValidUpdate: Boolean = false, + showSimilarApps: Boolean = false, + onNavigateUp: () -> Unit = {}, + onNavigateToDetailsMore: () -> Unit = {}, + onNavigateToDetailsScreenshot: (index: Int) -> Unit = {}, + onNavigateToDetailsReview: () -> Unit = {}, + onNavigateToDetailsExodus: () -> Unit = {}, + onDownload: () -> Unit = {}, + onManualDownload: () -> Unit = {}, + onCancelDownload: () -> Unit = {}, + onUninstall: () -> Unit = {}, + onOpen: () -> Unit = {} +) { + val context = LocalContext.current + + Scaffold( + topBar = { + TopAppBarComposable( + onNavigateUp = onNavigateUp + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(dimensionResource(R.dimen.padding_medium)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + ) { + // TODO: Deal with download status + AppDetails(app = app, hasValidUpdate = hasValidUpdate) + + when { + download?.isRunning == true -> { + AppActions( + primaryActionDisplayName = stringResource(R.string.action_open), + secondaryActionDisplayName = stringResource(R.string.action_cancel), + isPrimaryActionEnabled = false, + onSecondaryAction = onCancelDownload + ) + } + + app.isInstalled && hasValidUpdate -> { + AppActions( + primaryActionDisplayName = stringResource(R.string.action_update), + secondaryActionDisplayName = stringResource(R.string.action_uninstall), + onPrimaryAction = onDownload, + onSecondaryAction = onUninstall + ) + } + + app.isInstalled -> { + AppActions( + primaryActionDisplayName = stringResource(R.string.action_open), + secondaryActionDisplayName = stringResource(R.string.action_uninstall), + onPrimaryAction = onOpen, + onSecondaryAction = onUninstall + ) + } + + else -> { + val primaryActionName = if (PackageUtil.isArchived(context, app.packageName)) { + stringResource(R.string.action_unarchive) + } else { + if (app.isFree) stringResource(R.string.action_install) else app.price + } + + AppActions( + primaryActionDisplayName = primaryActionName, + secondaryActionDisplayName = stringResource(R.string.title_manual_download), + onPrimaryAction = onDownload, + onSecondaryAction = onManualDownload + ) + } + } + + AppTags(app = app) + AppChangelog(changelog = app.changes) + HeaderComposable( + title = stringResource(R.string.details_more_about_app), + subtitle = app.shortDescription, + onClick = onNavigateToDetailsMore + ) + + AppScreenshots( + list = app.screenshots, + onNavigateToScreenshot = onNavigateToDetailsScreenshot + ) + + AppReviews(rating = app.rating, onNavigateToDetailsReview = onNavigateToDetailsReview) + + AppCompatibility( + needsGms = app.dependencies.dependentPackages.contains(PACKAGE_NAME_GMS), + plexusScores = plexusScores + ) + + HeaderComposable( + title = stringResource(R.string.details_permission), + subtitle = stringResource(R.string.permissions, app.permissions.size) + ) + + if (dataSafetyReport != null) AppDataSafety(dataSafetyReport = dataSafetyReport) + + AppPrivacy( + report = exodusReport, + onNavigateToDetailsExodus = if (!exodusReport?.trackers.isNullOrEmpty()) { + onNavigateToDetailsExodus + } else { + null + } + ) + + AppDeveloperDetails( + address = app.developerAddress, + website = app.developerWebsite, + email = app.developerEmail + ) + } + } +} + +/** + * Composable to display basic app details + */ +@Composable +private fun AppDetails(app: App, hasValidUpdate: Boolean = false) { + val context = LocalContext.current + + Row(modifier = Modifier.fillMaxWidth()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(app.iconArtwork.url) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .requiredSize(dimensionResource(R.dimen.icon_size_large)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium))) + ) + Column(modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small))) { + Text( + text = app.displayName, + style = MaterialTheme.typography.titleLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + Text( + text = app.developerName, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.primary + ) + Text( + text = if (!hasValidUpdate) { + stringResource(R.string.version, app.versionName, app.versionCode) + } else { + stringResource( + R.string.version_update, + PackageUtil.getInstalledVersionName(context, app.packageName), + PackageUtil.getInstalledVersionCode(context, app.packageName), + app.versionName, + app.versionCode + ) + }, + style = MaterialTheme.typography.bodyVerySmall + ) + } + } +} + +/** + * Composable to display primary and secondary actions available for the app + */ +@Composable +private fun AppActions( + primaryActionDisplayName: String, + secondaryActionDisplayName: String, + isPrimaryActionEnabled: Boolean = true, + isSecondaryActionEnabled: Boolean = true, + onPrimaryAction: () -> Unit = {}, + onSecondaryAction: () -> Unit = {} +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)) + ) { + FilledTonalButton( + modifier = Modifier.weight(1F), + onClick = onSecondaryAction, + enabled = isSecondaryActionEnabled + ) { + Text( + text = secondaryActionDisplayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + Button( + modifier = Modifier.weight(1F), + onClick = onPrimaryAction, + enabled = isPrimaryActionEnabled + ) { + Text( + text = primaryActionDisplayName, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +/** + * Composable to display app changelog + */ +@Composable +private fun AppChangelog(changelog: String) { + HeaderComposable(title = stringResource(R.string.details_changelog)) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) + .background(color = MaterialTheme.colorScheme.secondaryContainer) + .padding(dimensionResource(R.dimen.padding_medium)) + ) { + Text( + text = if (changelog.isBlank()) { + AnnotatedString(text = stringResource(R.string.details_changelog_unavailable)) + } else { + AnnotatedString.fromHtml(htmlString = changelog) + }, + style = MaterialTheme.typography.bodyMedium + ) + } +} + +/** + * Composable to display tags related to the app + */ +@Composable +private fun AppTags(app: App) { + val context = LocalContext.current + + val installsLabel = CommonUtil.addDiPrefix(app.installs) + val averageRating = if (app.labeledRating == "0.0" || app.labeledRating.isBlank()) { + null + } else { + app.labeledRating + } + val paidLabel = if (app.isFree) { + stringResource(R.string.details_free) + } else { + stringResource(R.string.details_paid) + } + val adsLabel = if (app.containsAds) { + stringResource(R.string.details_contains_ads) + } else { + stringResource(R.string.details_no_ads) + } + + val tags = mapOf( + averageRating to R.drawable.ic_star, + installsLabel to R.drawable.ic_download_manager, + Formatter.formatShortFileSize(context, app.size) to R.drawable.ic_apk_install, + app.updatedOn to R.drawable.ic_updates, + paidLabel to R.drawable.ic_paid, + adsLabel to R.drawable.ic_campaign, + ).filterKeys { it != null } + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium)) + ) { + items(items = tags.keys.toList()) { label -> + AppTagComposable(label = label!!, icon = tags.getValue(label)) + } + } +} + +/** + * Composable to display details of the app developer + */ +@Composable +private fun AppDeveloperDetails(address: String, website: String, email: String) { + val context = LocalContext.current + + HeaderComposable(title = stringResource(R.string.details_dev_details)) + Column { + if (website.isNotBlank()) { + InfoComposable( + title = AnnotatedString(text = stringResource(R.string.details_dev_website)), + description = AnnotatedString(text = website), + icon = R.drawable.ic_network, + onClick = { context.browse(website) } + ) + } + + if (email.isNotBlank()) { + InfoComposable( + title = AnnotatedString(text = stringResource(R.string.details_dev_email)), + description = AnnotatedString(text = email), + icon = R.drawable.ic_mail, + onClick = { context.mailTo(email) } + ) + } + + if (address.isNotBlank()) { + InfoComposable( + title = AnnotatedString(text = stringResource(R.string.details_dev_address)), + description = AnnotatedString.fromHtml(htmlString = address), + icon = R.drawable.ic_person_location, + onClick = { context.copyToClipBoard(address) } + ) + } + } +} + +/** + * Composable to display screenshots of the app + */ +@Composable +private fun AppScreenshots(list: List, onNavigateToScreenshot: (index: Int) -> Unit) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small))) { + items(items = list, key = { artwork -> artwork.url }) { artwork -> + ScreenshotComposable( + modifier = Modifier + .height(dimensionResource(R.dimen.screenshot_height)) + .clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small))) + .clickable { onNavigateToScreenshot(list.indexOf(artwork)) }, + url = "${artwork.url}=rw-w480-v1-e15" + ) + } + } +} + +/** + * Composable to display reviews of the app + */ +@Composable +private fun AppReviews(rating: Rating, onNavigateToDetailsReview: () -> Unit) { + val stars = listOf( + rating.oneStar, rating.twoStar, rating.threeStar, rating.fourStar, rating.fiveStar + ).map { it.toFloat() }.also { + // No ratings available, nothing to show + if (it.sum() == 0F) return + } + + HeaderComposable( + title = stringResource(R.string.details_ratings), + subtitle = stringResource(R.string.details_ratings_subtitle), + onClick = onNavigateToDetailsReview + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small)) + ) { + Column( + modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium)), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = String.format(Locale.getDefault(), "%.1f", rating.average), + maxLines = 1, + style = MaterialTheme.typography.displayMedium, + overflow = TextOverflow.Ellipsis + ) + Text( + text = rating.abbreviatedLabel, + maxLines = 1, + style = MaterialTheme.typography.bodySmall, + overflow = TextOverflow.Ellipsis + ) + } + + Column( + modifier = Modifier.padding(dimensionResource(R.dimen.padding_medium)), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small)) + ) { + stars.reversed().fastForEach { star -> + RatingComposable( + label = (stars.indexOf(star) + 1).toString(), + rating = star / stars.sum() + ) + } + } + } +} + +/** + * Composable to display app compatibility rating from Plexus + */ +@Composable +private fun AppCompatibility(needsGms: Boolean, plexusScores: Scores?) { + HeaderComposable( + title = stringResource(R.string.details_compatibility_title), + subtitle = stringResource(R.string.plexus_powered), + ) + + if (!needsGms) { + InfoComposable( + icon = R.drawable.ic_menu_about, + title = AnnotatedString( + text = stringResource(R.string.details_compatibility_gms_not_required_title) + ), + description = AnnotatedString( + text = stringResource(R.string.details_compatibility_gms_not_required_subtitle) + ) + ) + + // Nothing more to show + return + } + + InfoComposable( + icon = R.drawable.ic_menu_about, + title = AnnotatedString( + text = stringResource(R.string.details_compatibility_gms_required_title) + ), + description = AnnotatedString( + text = stringResource(R.string.details_compatibility_gms_required_subtitle) + ) + ) + + val scoresStatus = mapOf( + R.string.details_compatibility_no_gms to plexusScores?.aosp?.status, + R.string.details_compatibility_microg to plexusScores?.microG?.status, + ) + scoresStatus.forEach { (title, description) -> + InfoComposable( + icon = R.drawable.ic_android, + title = AnnotatedString(text = stringResource(title)), + description = AnnotatedString( + text = stringResource(description ?: R.string.details_compatibility_status_unknown) + ) + ) + } +} + +/** + * Composable to display app's data safety report + */ +@Composable +private fun AppDataSafety(dataSafetyReport: DataSafetyReport) { + HeaderComposable( + title = stringResource(R.string.details_data_safety_title), + subtitle = stringResource(R.string.details_data_safety_subtitle) + ) + + dataSafetyReport.entries.groupBy { it.type }.forEach { (type, entries) -> + when (type) { + EntryType.DATA_COLLECTED -> { + InfoComposable( + icon = R.drawable.ic_cloud_upload, + title = AnnotatedString( + text = stringResource(R.string.details_data_safety_collect) + ), + description = AnnotatedString( + text = entries.first().subEntries.joinToString(", ") { it.name } + .ifBlank { + stringResource(R.string.details_data_safety_collect_none) + } + ) + ) + } + + EntryType.DATA_SHARED -> { + InfoComposable( + icon = R.drawable.ic_share, + title = AnnotatedString( + text = stringResource(R.string.details_data_safety_shared) + ), + description = AnnotatedString( + text = entries.first().subEntries.joinToString(", ") { it.name } + .ifBlank { + stringResource(R.string.details_data_safety_share_none) + } + ), + ) + } + + // We don't care about any other sections + else -> {} + } + } +} + +/** + * Composable to display app's privacy report from ExodusPrivacy + */ +@Composable +private fun AppPrivacy(report: Report?, onNavigateToDetailsExodus: (() -> Unit)?) { + HeaderComposable( + title = stringResource(R.string.details_privacy), + subtitle = stringResource(R.string.exodus_powered), + onClick = onNavigateToDetailsExodus + ) + + val reportStatus = when { + report == null -> stringResource(R.string.failed_to_fetch_report) + report.id == -1 -> stringResource(R.string.exodus_progress) + else -> if (report.trackers.isEmpty()) { + stringResource(R.string.exodus_no_tracker) + } else { + stringResource(R.string.exodus_report_trackers, report.trackers.size, report.version) + } + } + + InfoComposable( + icon = R.drawable.ic_visibility, + title = AnnotatedString(text = reportStatus), + description = AnnotatedString(text = stringResource(R.string.exodus_tracker_desc)) + ) +} + +@Preview +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun AppDetailsScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) { + ScreenContent(app = app, hasValidUpdate = false) + } +} + +@Preview +@Composable +private fun AppDetailsScreenPreviewLoading() { + ScreenContentLoading() +} + +@Preview +@Composable +private fun AppDetailsScreenPreviewError() { + ScreenContentError() +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/DetailsExodusScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/DetailsExodusScreen.kt new file mode 100644 index 000000000..7e7e6152b --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/DetailsExodusScreen.kt @@ -0,0 +1,94 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.details + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.aurora.Constants.EXODUS_REPORT_URL +import com.aurora.extensions.browse +import com.aurora.gplayapi.data.models.App +import com.aurora.store.R +import com.aurora.store.compose.composables.HeaderComposable +import com.aurora.store.compose.composables.TopAppBarComposable +import com.aurora.store.compose.composables.details.ExodusComposable +import com.aurora.store.compose.composables.preview.AppPreviewProvider +import com.aurora.store.data.model.ExodusTracker +import com.aurora.store.viewmodel.details.AppDetailsViewModel + +@Composable +fun DetailsExodusScreen( + onNavigateUp: () -> Unit, + viewModel: AppDetailsViewModel = hiltViewModel() +) { + val app by viewModel.app.collectAsStateWithLifecycle() + val exodusReport by viewModel.exodusReport.collectAsStateWithLifecycle() + + ScreenContent( + topAppBarTitle = app!!.displayName, + id = exodusReport!!.id, + version = exodusReport!!.version, + trackers = viewModel.getExodusTrackersFromReport(), + onNavigateUp = onNavigateUp, + ) +} + +@Composable +private fun ScreenContent( + topAppBarTitle: String? = null, + id: Int = -1, + version: String = String(), + trackers: List = emptyList(), + onNavigateUp: () -> Unit = {}, +) { + val context = LocalContext.current + + Scaffold( + topBar = { + TopAppBarComposable(title = topAppBarTitle, onNavigateUp = onNavigateUp) + } + ) { paddingValues -> + LazyColumn( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .padding(horizontal = dimensionResource(R.dimen.padding_medium)) + ) { + stickyHeader { + HeaderComposable( + title = stringResource(R.string.exodus_report_trackers, trackers.size, version), + subtitle = stringResource(R.string.exodus_view_report), + onClick = { context.browse(EXODUS_REPORT_URL + id) } + ) + } + + items(items = trackers, key = { item -> item.id }) { tracker -> + ExodusComposable(tracker = tracker) + } + } + } +} + +@Preview +@Composable +private fun DetailsExodusScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + ScreenContent( + topAppBarTitle = app.displayName, + version = app.versionName + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/DetailsMoreScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/DetailsMoreScreen.kt new file mode 100644 index 000000000..1216bf317 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/DetailsMoreScreen.kt @@ -0,0 +1,124 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.details + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.annotation.ExperimentalCoilApi +import coil3.compose.LocalAsyncImagePreviewHandler +import com.aurora.gplayapi.data.models.App +import com.aurora.store.R +import com.aurora.store.compose.composables.HeaderComposable +import com.aurora.store.compose.composables.InfoComposable +import com.aurora.store.compose.composables.TopAppBarComposable +import com.aurora.store.compose.composables.preview.AppPreviewProvider +import com.aurora.store.compose.composables.preview.coilPreviewProvider +import com.aurora.store.viewmodel.details.AppDetailsViewModel +import java.util.Locale + +@Composable +fun DetailsMoreScreen( + onNavigateUp: () -> Unit, + viewModel: AppDetailsViewModel = hiltViewModel() +) { + val app by viewModel.app.collectAsStateWithLifecycle() + + ScreenContent(app = app!!, onNavigateUp = onNavigateUp) +} + +@Composable +private fun ScreenContent(app: App, onNavigateUp: () -> Unit = {}) { + Scaffold( + topBar = { TopAppBarComposable(title = app.displayName, onNavigateUp = onNavigateUp) } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium)) + ) { + HeaderComposable(title = stringResource(R.string.details_description)) + Text( + modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.padding_medium)), + text = AnnotatedString.fromHtml( + htmlString = app.description + ), + style = MaterialTheme.typography.bodyMedium + ) + + if (app.appInfo.appInfoMap.isNotEmpty()) { + HeaderComposable(title = stringResource(R.string.details_more_info)) + + InfoComposable( + title = AnnotatedString( + text = stringResource(R.string.details_more_package_name) + ), + description = AnnotatedString(text = app.packageName) + ) + + InfoComposable( + title = AnnotatedString( + text = stringResource(R.string.details_more_content_rating) + ), + description = AnnotatedString(text = app.contentRating.title) + ) + + app.appInfo.appInfoMap.forEach { (title, subtitle) -> + InfoComposable( + title = AnnotatedString( + text = title.replace("_", " ") + .lowercase(Locale.getDefault()) + .replaceFirstChar { + if (it.isLowerCase()) { + it.titlecase(Locale.getDefault()) + } else { + it.toString() + } + } + ), + description = AnnotatedString(text = subtitle) + ) + } + + InfoComposable( + title = AnnotatedString( + text = stringResource(R.string.details_more_target_api) + ), + description = AnnotatedString(text = "API ${app.targetSdk}") + ) + } + } + } +} + +@Preview +@Composable +@OptIn(ExperimentalCoilApi::class) +private fun DetailsMoreScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) { + ScreenContent(app = app) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/DetailsReviewScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/DetailsReviewScreen.kt new file mode 100644 index 000000000..e18c396dd --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/DetailsReviewScreen.kt @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.details + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +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.dimensionResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.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.gplayapi.data.models.Review +import com.aurora.store.R +import com.aurora.store.compose.composables.TopAppBarComposable +import com.aurora.store.compose.composables.details.ReviewComposable +import com.aurora.store.viewmodel.details.AppDetailsViewModel +import kotlinx.coroutines.flow.flowOf + +@Composable +fun DetailsReviewScreen( + onNavigateUp: () -> Unit, + viewModel: AppDetailsViewModel = hiltViewModel() +) { + val app by viewModel.app.collectAsStateWithLifecycle() + val reviews = viewModel.reviews.collectAsLazyPagingItems() + + ScreenContent( + topAppBarTitle = app!!.displayName, + reviews = reviews, + onNavigateUp = onNavigateUp, + onFilter = { filter -> viewModel.fetchReviews(filter) } + ) +} + +@Composable +private fun ScreenContent( + topAppBarTitle: String? = null, + onNavigateUp: () -> Unit = {}, + reviews: LazyPagingItems, + onFilter: (filter: Review.Filter) -> Unit = {} +) { + + Scaffold( + topBar = { + TopAppBarComposable(title = topAppBarTitle, onNavigateUp = onNavigateUp) + } + ) { paddingValues -> + Column( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize() + .padding(horizontal = dimensionResource(R.dimen.padding_medium)) + ) { + FilterHeader { filter -> onFilter(filter) } + + // TODO: Implement loading and error screen + when (reviews.loadState.refresh) { + is LoadState.Loading -> {} + + is LoadState.Error -> {} + + else -> { + LazyColumn(modifier = Modifier.fillMaxSize()) { + items( + count = reviews.itemCount, + key = reviews.itemKey { it.commentId } + ) { index -> + ReviewComposable(review = reviews[index]!!) + } + } + } + } + } + + + } +} + +/** + * Composable to hold sticky header for filtering through the reviews + */ +@Composable +private fun FilterHeader(onClick: (filter: Review.Filter) -> Unit) { + var activeFilter by rememberSaveable { mutableStateOf(Review.Filter.ALL) } + + val filters = mapOf( + Review.Filter.ALL to R.string.filter_review_all, + Review.Filter.NEWEST to R.string.filter_latest, + Review.Filter.CRITICAL to R.string.filter_review_critical, + Review.Filter.POSITIVE to R.string.filter_review_positive, + Review.Filter.FIVE to R.string.filter_review_five, + Review.Filter.FOUR to R.string.filter_review_four, + Review.Filter.THREE to R.string.filter_review_three, + Review.Filter.TWO to R.string.filter_review_two, + Review.Filter.ONE to R.string.filter_review_one + ) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_normal)) + ) { + items(items = filters.keys.toList(), key = { item -> item }) { filter -> + val selected = activeFilter == filter + FilterChip( + onClick = { + activeFilter = filter + onClick(filter) + }, + label = { Text(text = stringResource(filters.getValue(filter))) }, + selected = selected, + leadingIcon = { + if (selected) { + Icon( + imageVector = Icons.Filled.Done, + contentDescription = stringResource(filters.getValue(filter)) + ) + } + }, + ) + } + } +} + +@Preview +@Composable +private fun DetailsReviewScreenPreview() { + val reviews = flowOf(PagingData.empty()).collectAsLazyPagingItems() + + ScreenContent( + reviews = reviews + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/DetailsScreenshotScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/DetailsScreenshotScreen.kt new file mode 100644 index 000000000..a6f1d757d --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/DetailsScreenshotScreen.kt @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.details + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.aurora.gplayapi.data.models.Artwork +import com.aurora.store.R +import com.aurora.store.compose.composables.TopAppBarComposable +import com.aurora.store.compose.composables.details.ScreenshotComposable +import com.aurora.store.viewmodel.details.AppDetailsViewModel + +@Composable +fun DetailsScreenshotScreen( + index: Int, + onNavigateUp: () -> Unit, + viewModel: AppDetailsViewModel = hiltViewModel() +) { + val app by viewModel.app.collectAsStateWithLifecycle() + + ScreenContent( + topAppBarTitle = app!!.displayName, + onNavigateUp = onNavigateUp, + screenshots = app!!.screenshots, + index = index + ) +} + +@Composable +private fun ScreenContent( + topAppBarTitle: String? = null, + onNavigateUp: () -> Unit = {}, + screenshots: List = emptyList(), + index: Int = 0 +) { + val displayMetrics = LocalContext.current.resources.displayMetrics + val pagerState = rememberPagerState(initialPage = index) { screenshots.size } + + Scaffold( + topBar = { + TopAppBarComposable(title = topAppBarTitle, onNavigateUp = onNavigateUp) + } + ) { paddingValues -> + HorizontalPager( + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + state = pagerState + ) { page -> + val artwork = screenshots[page] + ScreenshotComposable( + modifier = Modifier.fillMaxSize(), + url = "${artwork.url}=rw-w${displayMetrics.widthPixels}-v1-e15" + ) + } + } +} + +@Preview +@Composable +private fun DetailsScreenshotScreenPreview() { + ScreenContent( + topAppBarTitle = stringResource(R.string.app_name) + ) +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/dialogs/ManualDownloadDialog.kt b/app/src/main/java/com/aurora/store/compose/ui/dialogs/ManualDownloadDialog.kt new file mode 100644 index 000000000..1e6fa634c --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/dialogs/ManualDownloadDialog.kt @@ -0,0 +1,102 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.dialogs + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.core.text.isDigitsOnly +import com.aurora.gplayapi.data.models.App +import com.aurora.store.R +import com.aurora.store.compose.composables.preview.AppPreviewProvider +import kotlinx.coroutines.android.awaitFrame + +/** + * Dialog for entering the versionCode manually for an App to download + * @param currentVersionCode Current versionCode of the app + * @param onConfirm Callback when the versionCode has been entered + * @param onDismiss Callback when the dialog has been dismissed + */ +@Composable +fun ManualDownloadDialog( + currentVersionCode: Long, + onConfirm: (versionCode: Long) -> Unit = {}, + onDismiss: () -> Unit = {} +) { + val focusRequester = remember { FocusRequester() } + var versionCode by remember { + val initText = currentVersionCode.toString() + mutableStateOf(TextFieldValue(text = initText, selection = TextRange(initText.length))) + } + + LaunchedEffect(focusRequester) { + awaitFrame() + focusRequester.requestFocus() + } + + AlertDialog( + title = { Text(text = stringResource(R.string.title_manual_download)) }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text(text = stringResource(R.string.manual_download_hint)) + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + value = versionCode, + onValueChange = { if (it.text.isDigitsOnly()) versionCode = it }, + shape = RoundedCornerShape(10.dp), + singleLine = true, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number + ) + ) + } + }, + onDismissRequest = onDismiss, + confirmButton = { + TextButton (onClick = { onConfirm(versionCode.text.toLong()) }) { + Text(text = stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(text = stringResource(android.R.string.cancel)) + } + } + ) +} + +@Preview +@Composable +private fun ManualDownloadDialogPreview(@PreviewParameter(AppPreviewProvider::class) app: App) { + ManualDownloadDialog(currentVersionCode = app.versionCode.toLong()) +} diff --git a/app/src/main/java/com/aurora/store/data/model/Exodus.kt b/app/src/main/java/com/aurora/store/data/model/Exodus.kt index b5067e6a4..4f9ca97a3 100644 --- a/app/src/main/java/com/aurora/store/data/model/Exodus.kt +++ b/app/src/main/java/com/aurora/store/data/model/Exodus.kt @@ -33,7 +33,7 @@ data class ExodusReport( @Serializable @Parcelize data class Report( - val id: Int = 0, + val id: Int = -1, val downloads: String = String(), val version: String = String(), val creationDate: String = String(), diff --git a/app/src/main/java/com/aurora/store/data/model/Plexus.kt b/app/src/main/java/com/aurora/store/data/model/Plexus.kt index d18788162..cd5f1a263 100644 --- a/app/src/main/java/com/aurora/store/data/model/Plexus.kt +++ b/app/src/main/java/com/aurora/store/data/model/Plexus.kt @@ -22,23 +22,25 @@ data class Data( @Serializable data class Scores( @SerialName("micro_g") - val microG: Rating, + val microG: Rating = Rating(), @SerialName("native") - val aosp: Rating + val aosp: Rating = Rating() ) @Serializable data class Rating( - val denominator: Float, - val numerator: Float, - val rating_type: String, - val total_count: Long + val denominator: Float = -1F, + val numerator: Float = -1F, + val rating_type: String = String(), + val total_count: Long = -1 ) { - private val fraction get() = numerator / denominator + private val fraction + get() = if (numerator == -1F && denominator == -1F) -1F else numerator / denominator @get:StringRes val status: Int get() = when { + fraction == -1F -> R.string.plexus_progress fraction == 0F -> R.string.details_compatibility_status_unknown fraction >= 0.90 -> R.string.details_compatibility_status_compatible fraction >= 0.50 -> R.string.details_compatibility_status_limited diff --git a/app/src/main/java/com/aurora/store/data/paging/GenericPagingSource.kt b/app/src/main/java/com/aurora/store/data/paging/GenericPagingSource.kt new file mode 100644 index 000000000..5ac823d4a --- /dev/null +++ b/app/src/main/java/com/aurora/store/data/paging/GenericPagingSource.kt @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.data.paging + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * A generic paging source that is supposed to be able to load any type of data + * + * Consider calling [createPager] method to create an instance of [Pager] instead of interacting + * with the class directly. + * @param block Data to load into the pager + */ +class GenericPagingSource( + private val block: suspend (Int) -> List +) : PagingSource() { + + companion object { + private const val DEFAULT_PAGE_SIZE = 20 + + /** + * Method to dynamically create and manage pager objects + * @param pageSize Size of the page + * @param enablePlaceholders Whether placeholders should be shown + * @param data Data to load into the pager + */ + fun createPager( + pageSize: Int = DEFAULT_PAGE_SIZE, + enablePlaceholders: Boolean = true, + data: suspend (Int) -> List + ): Pager = Pager( + config = PagingConfig(enablePlaceholders = enablePlaceholders, pageSize = pageSize), + pagingSourceFactory = { GenericPagingSource(data) } + ) + } + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: 1 + return try { + withContext(Dispatchers.IO) { + val data = block(page) + val totalPages = if (data.isNotEmpty()) page + 1 else page + LoadResult.Page( + data = data, + prevKey = if (page == 1) null else page - 1, + nextKey = if (page == totalPages) null else totalPages, + itemsAfter = if (page == totalPages) 0 else DEFAULT_PAGE_SIZE + ) + } + } catch (exception: Exception) { + LoadResult.Error(exception) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } +} diff --git a/app/src/main/java/com/aurora/store/util/CommonUtil.kt b/app/src/main/java/com/aurora/store/util/CommonUtil.kt index 275a38b4e..2845df5b8 100644 --- a/app/src/main/java/com/aurora/store/util/CommonUtil.kt +++ b/app/src/main/java/com/aurora/store/util/CommonUtil.kt @@ -62,9 +62,8 @@ object CommonUtil { return tempValue.toString() + siPrefixes[order] } - fun addDiPrefix(value: Long): String { - if (value <= 1L) - return "NA" + fun addDiPrefix(value: Long): String? { + if (value <= 1L) return null var tempValue = value var order = 0 while (tempValue >= 1000.0) { diff --git a/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt b/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt index 86b52250f..52c8e2a83 100644 --- a/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt +++ b/app/src/main/java/com/aurora/store/view/ui/commons/BaseFragment.kt @@ -28,10 +28,12 @@ import androidx.fragment.app.Fragment import androidx.navigation.fragment.findNavController import androidx.viewbinding.ViewBinding import com.airbnb.epoxy.EpoxyRecyclerView +import com.aurora.extensions.navigate import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.Category import com.aurora.gplayapi.data.models.StreamCluster import com.aurora.store.MobileNavigationDirections +import com.aurora.store.compose.navigation.Screen import com.aurora.store.data.model.MinimalApp import com.aurora.store.data.providers.PermissionProvider import com.aurora.store.view.ui.details.AppDetailsFragmentDirections @@ -81,8 +83,8 @@ abstract class BaseFragment : Fragment() { } fun openDetailsFragment(packageName: String, app: App? = null) { - findNavController().navigate( - MobileNavigationDirections.actionGlobalAppDetailsFragment(packageName, app) + requireContext().navigate( + Screen.AppDetails(packageName) ) } diff --git a/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsFragment.kt b/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsFragment.kt index 72a91fbd6..bf30e94ad 100644 --- a/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsFragment.kt +++ b/app/src/main/java/com/aurora/store/view/ui/details/AppDetailsFragment.kt @@ -30,7 +30,6 @@ import android.provider.Settings import android.view.View import android.view.animation.AccelerateDecelerateInterpolator import android.widget.RelativeLayout -import android.widget.Toast import androidx.compose.ui.util.fastAny import androidx.core.content.ContextCompat import androidx.core.text.HtmlCompat @@ -52,7 +51,6 @@ import com.aurora.extensions.browse import com.aurora.extensions.hide import com.aurora.extensions.px import com.aurora.extensions.requiresObbDir -import com.aurora.extensions.runOnUiThread import com.aurora.extensions.share import com.aurora.extensions.show import com.aurora.extensions.toast @@ -85,7 +83,6 @@ import com.aurora.store.util.ShortcutManagerUtil import com.aurora.store.view.custom.RatingView import com.aurora.store.view.epoxy.controller.DetailsCarouselController import com.aurora.store.view.epoxy.controller.GenericCarouselController -import com.aurora.store.view.epoxy.views.details.ReviewViewModel_ import com.aurora.store.view.epoxy.views.details.ScreenshotView import com.aurora.store.view.epoxy.views.details.ScreenshotViewModel_ import com.aurora.store.view.ui.commons.BaseFragment @@ -186,45 +183,6 @@ class AppDetailsFragment : BaseFragment() { // Toolbar updateToolbar(app) - - // App Details - viewModel.fetchAppDetails(app.packageName) - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.app.collect { - if (it.packageName.isNotBlank()) { - app = it - - // App User Review - // We can not fetch it outside of this block, as we need the testing program status - if (!viewModel.authProvider.isAnonymous && app.isInstalled) { - viewModel.fetchUserAppReview(app) - } - - updateToolbar(app) - updateAppHeader(app) // Re-inflate the app details, as web data may vary. - updateExtraDetails(app) - updateCompatibilityInfo() - - if (app.versionCode == 0L) { - warnAppUnavailable(app) - } - - // Fetch App Reviews - viewModel.fetchAppReviews(app.packageName) - - // Fetch Data Safety Report - viewModel.fetchAppDataSafetyReport(app.packageName) - - // Fetch Exodus Privacy Report - viewModel.fetchAppReport(app.packageName) - } else { - toast(getString(R.string.status_unavailable)) - // TODO: Redirect to App Unavailable Fragment - } - } - } - viewModel.download.filterNotNull().onEach { when (it.downloadStatus) { DownloadStatus.QUEUED, @@ -260,35 +218,6 @@ class AppDetailsFragment : BaseFragment() { } }.launchIn(viewLifecycleOwner.lifecycleScope) - // Reviews - viewLifecycleOwner.lifecycleScope.launch { - viewModel.reviews.collect { - binding.layoutDetailsReview.epoxyRecycler.withModels { - it.take(4).forEach { add(ReviewViewModel_().id(it.timeStamp).review(it)) } - } - } - } - - // User Rating - viewLifecycleOwner.lifecycleScope.launch { - viewModel.userReview.collect { - if (it.commentId.isNotEmpty()) { - runOnUiThread { updateUserReview(it) } - } else { - Toast.makeText( - requireContext(), - getString(R.string.toast_rated_failed), - Toast.LENGTH_SHORT - ).show() - } - } - } - - // Data Safety Report - viewLifecycleOwner.lifecycleScope.launch { - viewModel.dataSafetyReport.collect { updateDataSafetyViews(it) } - } - // Exodus Privacy Report viewLifecycleOwner.lifecycleScope.launch { viewModel.exodusReport.collect { report -> @@ -331,27 +260,6 @@ class AppDetailsFragment : BaseFragment() { } } - // Plexus Report - viewModel.plexusReport.onEach { plexusReport -> - if (plexusReport?.report?.scores == null) { - binding.layoutDetailsCompatibility.txtStatusMicroG.subTitle = - getString(R.string.details_compatibility_status_unknown) - - binding.layoutDetailsCompatibility.txtStatusAosp.subTitle = - getString(R.string.details_compatibility_status_unknown) - } else { - binding.layoutDetailsCompatibility.headerCompatibility.addClickListener { - requireContext().browse("${Constants.PLEXUS_SEARCH_URL}${app.packageName}") - } - - binding.layoutDetailsCompatibility.txtStatusMicroG.subTitle = - getString(plexusReport.report.scores.microG.status) - - binding.layoutDetailsCompatibility.txtStatusAosp.subTitle = - getString(plexusReport.report.scores.microG.status) - } - }.launchIn(viewLifecycleOwner.lifecycleScope) - // Beta program viewLifecycleOwner.lifecycleScope.launch { viewModel.testingProgramStatus.collect { @@ -774,7 +682,7 @@ class AppDetailsFragment : BaseFragment() { binding.layoutDetailDescription.apply { val installs = CommonUtil.addDiPrefix(app.installs) - if (installs != "NA") { + if (installs != null) { txtInstalls.text = CommonUtil.addDiPrefix(app.installs) } else { txtInstalls.hide() diff --git a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt index b559142bc..9a5029b7b 100644 --- a/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt +++ b/app/src/main/java/com/aurora/store/viewmodel/details/AppDetailsViewModel.kt @@ -1,28 +1,43 @@ +/* + * SPDX-FileCopyrightText: 2023-2025 The Calyx Institute + * SPDX-FileCopyrightText: 2023-2024 Aurora OSS + * SPDX-License-Identifier: GPL-3.0-or-later + */ + package com.aurora.store.viewmodel.details import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn import com.aurora.Constants -import com.aurora.extensions.toast import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.Review import com.aurora.gplayapi.data.models.details.TestingProgramStatus import com.aurora.gplayapi.helpers.AppDetailsHelper +import com.aurora.gplayapi.helpers.PurchaseHelper import com.aurora.gplayapi.helpers.ReviewsHelper import com.aurora.gplayapi.helpers.web.WebDataSafetyHelper import com.aurora.gplayapi.network.IHttpClient import com.aurora.store.BuildConfig -import com.aurora.store.R import com.aurora.store.data.helper.DownloadHelper import com.aurora.store.data.model.ExodusReport +import com.aurora.store.data.model.ExodusTracker import com.aurora.store.data.model.PlexusReport import com.aurora.store.data.model.Report +import com.aurora.store.data.model.Scores +import com.aurora.store.data.paging.GenericPagingSource.Companion.createPager import com.aurora.store.data.providers.AuthProvider import com.aurora.store.data.room.favourite.Favourite import com.aurora.store.data.room.favourite.FavouriteDao +import com.aurora.store.util.CertUtil import com.aurora.store.util.PackageUtil +import com.aurora.store.util.PackageUtil.PACKAGE_NAME_GMS +import com.aurora.store.util.Preferences +import com.aurora.store.util.Preferences.PREFERENCE_SIMILAR +import com.aurora.store.util.Preferences.PREFERENCE_UPDATES_EXTENDED import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers @@ -32,6 +47,9 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.serialization.json.Json @@ -43,112 +61,124 @@ import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport class AppDetailsViewModel @Inject constructor( val authProvider: AuthProvider, @ApplicationContext private val context: Context, + private val purchaseHelper: PurchaseHelper, private val appDetailsHelper: AppDetailsHelper, private val reviewsHelper: ReviewsHelper, private val webDataSafetyHelper: WebDataSafetyHelper, private val downloadHelper: DownloadHelper, private val favouriteDao: FavouriteDao, private val httpClient: IHttpClient, - private val json: Json + private val json: Json, + private val exodusTrackers: JSONObject ) : ViewModel() { private val TAG = AppDetailsViewModel::class.java.simpleName - private val appStash: MutableMap = mutableMapOf() - private val _app = MutableSharedFlow() - val app = _app.asSharedFlow() + private val _app = MutableStateFlow(App("")) + val app = _app.asStateFlow() - private val reviewsStash = mutableMapOf>() - private val _reviews = MutableSharedFlow>() - val reviews = _reviews.asSharedFlow() + private var reviewsNextPageUrl: String? = null + private val _reviews = MutableStateFlow>(PagingData.empty()) + val reviews = _reviews.asStateFlow() - private val userReviewStash = mutableMapOf() - private val _userReview = MutableSharedFlow() - val userReview = _userReview.asSharedFlow() + private val _userReview = MutableStateFlow(null) + val userReview = _userReview.asStateFlow() - private val dataSafetyReportStash = mutableMapOf() - private val _dataSafetyReport = MutableSharedFlow() - val dataSafetyReport = _dataSafetyReport.asSharedFlow() + private val _dataSafetyReport = MutableStateFlow(null) + val dataSafetyReport = _dataSafetyReport.asStateFlow() - private val exodusReportStash = mutableMapOf() - private val _exodusReport = MutableSharedFlow() - val exodusReport = _exodusReport.asSharedFlow() + private val _exodusReport = MutableStateFlow(Report()) + val exodusReport = _exodusReport.asStateFlow() - private val plexusReportStash = mutableMapOf() - private val _plexusReport = MutableSharedFlow() - val plexusReport = _plexusReport.asSharedFlow() + private val _plexusScores = MutableStateFlow(Scores()) + val plexusScores = _plexusScores.asStateFlow() - private val testProgramStatusStash = mutableMapOf() - private val _testingProgramStatus = MutableSharedFlow() - val testingProgramStatus = _testingProgramStatus.asSharedFlow() + private val _testingProgramStatus = MutableStateFlow(null) + val testingProgramStatus = _testingProgramStatus.asStateFlow() - private val _favourite = MutableStateFlow(false) + private val _favourite = MutableStateFlow(false) val favourite = _favourite.asStateFlow() + private val _purchaseStatus = MutableSharedFlow() + val purchaseStatus = _purchaseStatus.asSharedFlow() + val download = combine(app, downloadHelper.downloadsList) { a, list -> - if (a.packageName.isBlank()) return@combine null - list.find { d -> d.packageName == a.packageName } + if (a?.packageName.isNullOrBlank()) return@combine null + list.find { d -> d.packageName == a?.packageName } }.stateIn(viewModelScope, SharingStarted.Eagerly, null) + private val isExtendedUpdateEnabled: Boolean + get() = Preferences.getBoolean(context, PREFERENCE_UPDATES_EXTENDED) + + private val isUpdatable: Boolean + get() = PackageUtil.isUpdatable( + context, + app.value!!.packageName, + app.value!!.versionCode.toLong() + ) + + private val hasValidCerts: Boolean + get() = app.value!!.certificateSetList.any { + it.certificateSet in CertUtil.getEncodedCertificateHashes( + context, + app.value!!.packageName + ) + } + + val hasValidUpdate: Boolean + get() = (isUpdatable && hasValidCerts) || (isUpdatable && isExtendedUpdateEnabled) + + val showSimilarApps: Boolean + get() = Preferences.getBoolean(context, PREFERENCE_SIMILAR) + fun fetchAppDetails(packageName: String) { viewModelScope.launch(Dispatchers.IO) { try { - checkFavourite(packageName) + _favourite.value = favouriteDao.isFavourite(packageName) - val app: App = appStash.getOrPut(packageName) { - appDetailsHelper.getAppByPackageName(packageName).copy( - isInstalled = PackageUtil.isInstalled(context, packageName) - ) - } - - _app.emit(app) + _dataSafetyReport.value = webDataSafetyHelper.fetch(packageName) + _app.value = appDetailsHelper.getAppByPackageName(packageName).copy( + isInstalled = PackageUtil.isInstalled(context, packageName) + ) } catch (exception: Exception) { Log.e(TAG, "Failed to fetch app details", exception) - _app.emit(App("")) + _app.value = null + } + }.invokeOnCompletion { throwable -> + // Only proceed if there was no error while fetching the app details + if (throwable != null) return@invokeOnCompletion + + fetchReviews() + fetchExodusPrivacyReport(packageName) + if (app.value!!.dependencies.dependentPackages.contains(PACKAGE_NAME_GMS)) { + fetchPlexusReport(packageName) } } } - fun fetchAppReviews(packageName: String) { - viewModelScope.launch(Dispatchers.IO) { - try { - val reviews = reviewsStash.getOrPut(packageName) { - reviewsHelper.getReviewSummary(packageName) - } + fun fetchReviews(filter: Review.Filter = Review.Filter.ALL) { + reviewsNextPageUrl = null - _reviews.emit(reviews) - } catch (exception: Exception) { - Log.e(TAG, "Failed to fetch app reviews", exception) - _reviews.emit(emptyList()) - } - } + createPager { + val reviewsCluster = reviewsNextPageUrl?.let { nextPageUrl -> + reviewsHelper.next(nextPageUrl) + } ?: reviewsHelper.getReviews(app.value!!.packageName, filter) + + reviewsNextPageUrl = reviewsCluster.nextPageUrl + reviewsCluster.reviewList + }.flow.distinctUntilChanged() + .cachedIn(viewModelScope) + .onEach { _reviews.value = it } + .launchIn(viewModelScope) } - fun fetchAppDataSafetyReport(packageName: String) { + private fun fetchExodusPrivacyReport(packageName: String) { viewModelScope.launch(Dispatchers.IO) { try { - val report = dataSafetyReportStash.getOrPut(packageName) { - webDataSafetyHelper.fetch(packageName) - } - _dataSafetyReport.emit(report) - } catch (exception: Exception) { - Log.e(TAG, "Failed to fetch data safety report", exception) - } - } - } - - fun fetchAppReport(packageName: String) { - viewModelScope.launch(Dispatchers.IO) { - try { - val exodusReport = exodusReportStash.getOrPut(packageName) { - getLatestExodusReport(packageName) - } - - _exodusReport.emit(exodusReport) + _exodusReport.value = getLatestExodusReport(packageName) } catch (exception: Exception) { Log.e(TAG, "Failed to fetch privacy report", exception) - exodusReportStash[packageName] = null - _exodusReport.emit(null) + _exodusReport.value = null } } } @@ -156,17 +186,10 @@ class AppDetailsViewModel @Inject constructor( fun fetchPlexusReport(packageName: String) { viewModelScope.launch(Dispatchers.IO) { try { - val plexusReport = plexusReportStash.getOrPut(packageName) { - val url = "${Constants.PLEXUS_API_URL}/${packageName}/?scores=true" - val playResponse = httpClient.get(url, emptyMap()) - json.decodeFromString(String(playResponse.responseBytes)) - } - - _plexusReport.emit(plexusReport) + _plexusScores.value = getPlexusReport(packageName)?.report?.scores } catch (exception: Exception) { Log.e(TAG, "Failed to fetch compatibility report", exception) - plexusReportStash[packageName] = null - _plexusReport.emit(null) + _plexusScores.value = null } } } @@ -174,15 +197,9 @@ class AppDetailsViewModel @Inject constructor( fun fetchTestingProgramStatus(packageName: String, subscribe: Boolean) { viewModelScope.launch(Dispatchers.IO) { try { - val testingProgramStatus = testProgramStatusStash.getOrPut(packageName) { - appDetailsHelper.testingProgram(packageName, subscribe) - } - - _testingProgramStatus.emit(testingProgramStatus) + _testingProgramStatus.value = appDetailsHelper.testingProgram(packageName, subscribe) } catch (exception: Exception) { Log.e(TAG, "Failed to fetch testing program status", exception) - testProgramStatusStash[packageName] = null - _testingProgramStatus.emit(null) } } } @@ -190,19 +207,8 @@ class AppDetailsViewModel @Inject constructor( fun fetchUserAppReview(app: App) { viewModelScope.launch(Dispatchers.IO) { try { - val stashedUserReview = userReviewStash[app.packageName] - if (stashedUserReview != null) { - _userReview.emit(stashedUserReview) - return@launch - } - val isTesting = app.testingProgram?.isSubscribed ?: false - val userReview = reviewsHelper.getUserReview(app.packageName, isTesting) - - if (userReview != null) { - userReviewStash[app.packageName] = userReview - _userReview.emit(userReview) - } + _userReview.value = reviewsHelper.getUserReview(app.packageName, isTesting) } catch (exception: Exception) { Log.e(TAG, "Failed to fetch user review", exception) } @@ -212,19 +218,13 @@ class AppDetailsViewModel @Inject constructor( fun postAppReview(packageName: String, review: Review, isBeta: Boolean) { viewModelScope.launch(Dispatchers.IO) { try { - val userReview = reviewsHelper.addOrEditReview( + _userReview.value = reviewsHelper.addOrEditReview( packageName, review.title, review.comment, review.rating, isBeta ) - - if (userReview != null) { - context.toast(R.string.toast_rated_success) - userReviewStash[packageName] = userReview - _userReview.emit(userReview) - } } catch (exception: Exception) { Log.e(TAG, "Failed to post review", exception) } @@ -235,6 +235,19 @@ class AppDetailsViewModel @Inject constructor( viewModelScope.launch { downloadHelper.enqueueApp(app) } } + fun purchase(app: App) { + viewModelScope.launch(Dispatchers.IO) { + try { + val files = purchaseHelper.purchase(app.packageName, app.versionCode, app.offerType) + _purchaseStatus.emit(files.isNotEmpty()) + if (files.isNotEmpty()) download(app.copy(fileList = files.toMutableList())) + } catch (exception: Exception) { + _purchaseStatus.emit(false) + Log.e(TAG, "Failed to purchase the app ", exception) + } + } + } + fun cancelDownload(app: App) { viewModelScope.launch { downloadHelper.cancelDownload(app.packageName) } } @@ -259,25 +272,43 @@ class AppDetailsViewModel @Inject constructor( } } - private fun checkFavourite(packageName: String) { - viewModelScope.launch(Dispatchers.IO) { - _favourite.value = favouriteDao.isFavourite(packageName) - } + fun getExodusTrackersFromReport(): List { + val trackerObjects = exodusReport.value!!.trackers.map { + exodusTrackers.getJSONObject(it.toString()) + }.toList() + + return trackerObjects.map { + ExodusTracker( + id = it.getInt("id"), + name = it.getString("name"), + url = it.getString("website"), + signature = it.getString("code_signature"), + date = it.getString("creation_date"), + description = it.getString("description"), + networkSignature = it.getString("network_signature"), + documentation = listOf(it.getString("documentation")), + categories = listOf(it.getString("categories")) + ) + }.toList() } private fun getLatestExodusReport(packageName: String): Report? { - val headers: MutableMap = mutableMapOf() - headers["Content-Type"] = Constants.JSON_MIME_TYPE - headers["Accept"] = Constants.JSON_MIME_TYPE - headers["Authorization"] = "Token ${BuildConfig.EXODUS_API_KEY}" + val url = "${Constants.EXODUS_SEARCH_URL}$packageName" + val headers = mutableMapOf( + "Content-Type" to Constants.JSON_MIME_TYPE, + "Accept" to Constants.JSON_MIME_TYPE, + "Authorization" to "Token ${BuildConfig.EXODUS_API_KEY}" + ) - val url = Constants.EXODUS_SEARCH_URL + packageName val playResponse = httpClient.get(url, headers) - - val report = parseExodusResponse(String(playResponse.responseBytes), packageName) + return parseExodusResponse(String(playResponse.responseBytes), packageName) .firstOrNull() + } - return report + private fun getPlexusReport(packageName: String): PlexusReport? { + val url = "${Constants.PLEXUS_API_URL}/${packageName}/?scores=true" + val playResponse = httpClient.get(url, emptyMap()) + return json.decodeFromString(String(playResponse.responseBytes)) } private fun parseExodusResponse(response: String, packageName: String): List { diff --git a/app/src/main/res/drawable/ic_android.xml b/app/src/main/res/drawable/ic_android.xml index dddde987c..465a00825 100644 --- a/app/src/main/res/drawable/ic_android.xml +++ b/app/src/main/res/drawable/ic_android.xml @@ -1,10 +1,13 @@ + + android:viewportHeight="960"> diff --git a/app/src/main/res/drawable/ic_apk_install.xml b/app/src/main/res/drawable/ic_apk_install.xml index 437ecda63..c7df7eaf4 100644 --- a/app/src/main/res/drawable/ic_apk_install.xml +++ b/app/src/main/res/drawable/ic_apk_install.xml @@ -1,10 +1,13 @@ + + android:viewportHeight="960"> diff --git a/app/src/main/res/drawable/ic_apps_outage.xml b/app/src/main/res/drawable/ic_apps_outage.xml new file mode 100644 index 000000000..fdf6faed1 --- /dev/null +++ b/app/src/main/res/drawable/ic_apps_outage.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_campaign.xml b/app/src/main/res/drawable/ic_campaign.xml new file mode 100644 index 000000000..a6dc691f9 --- /dev/null +++ b/app/src/main/res/drawable/ic_campaign.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_cloud_upload.xml b/app/src/main/res/drawable/ic_cloud_upload.xml index ecf35e1d8..c764a30d6 100644 --- a/app/src/main/res/drawable/ic_cloud_upload.xml +++ b/app/src/main/res/drawable/ic_cloud_upload.xml @@ -1,10 +1,13 @@ + + android:viewportHeight="960"> diff --git a/app/src/main/res/drawable/ic_download_manager.xml b/app/src/main/res/drawable/ic_download_manager.xml index dcc1a6d4a..324183c16 100644 --- a/app/src/main/res/drawable/ic_download_manager.xml +++ b/app/src/main/res/drawable/ic_download_manager.xml @@ -1,29 +1,14 @@ + - - + diff --git a/app/src/main/res/drawable/ic_mail.xml b/app/src/main/res/drawable/ic_mail.xml index 230c765c2..8a068e2f0 100644 --- a/app/src/main/res/drawable/ic_mail.xml +++ b/app/src/main/res/drawable/ic_mail.xml @@ -1,29 +1,14 @@ + - - + diff --git a/app/src/main/res/drawable/ic_map.xml b/app/src/main/res/drawable/ic_map.xml deleted file mode 100644 index ae5035a3f..000000000 --- a/app/src/main/res/drawable/ic_map.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/ic_menu_about.xml b/app/src/main/res/drawable/ic_menu_about.xml index 62d38ac25..b07dc7438 100644 --- a/app/src/main/res/drawable/ic_menu_about.xml +++ b/app/src/main/res/drawable/ic_menu_about.xml @@ -1,29 +1,14 @@ + - - + diff --git a/app/src/main/res/drawable/ic_network.xml b/app/src/main/res/drawable/ic_network.xml index 42a8b85c0..234a2c98f 100644 --- a/app/src/main/res/drawable/ic_network.xml +++ b/app/src/main/res/drawable/ic_network.xml @@ -1,29 +1,14 @@ + - - + diff --git a/app/src/main/res/drawable/ic_paid.xml b/app/src/main/res/drawable/ic_paid.xml new file mode 100644 index 000000000..5ac894297 --- /dev/null +++ b/app/src/main/res/drawable/ic_paid.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_person_location.xml b/app/src/main/res/drawable/ic_person_location.xml new file mode 100644 index 000000000..4e8422997 --- /dev/null +++ b/app/src/main/res/drawable/ic_person_location.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_share.xml b/app/src/main/res/drawable/ic_share.xml index 052068538..3f697951e 100644 --- a/app/src/main/res/drawable/ic_share.xml +++ b/app/src/main/res/drawable/ic_share.xml @@ -1,29 +1,14 @@ + - - + diff --git a/app/src/main/res/drawable/ic_star.xml b/app/src/main/res/drawable/ic_star.xml index ff7dfd968..0d36017ae 100644 --- a/app/src/main/res/drawable/ic_star.xml +++ b/app/src/main/res/drawable/ic_star.xml @@ -1,29 +1,14 @@ + - - + diff --git a/app/src/main/res/drawable/ic_updates.xml b/app/src/main/res/drawable/ic_updates.xml index 5296d5e78..ddace5151 100644 --- a/app/src/main/res/drawable/ic_updates.xml +++ b/app/src/main/res/drawable/ic_updates.xml @@ -1,30 +1,15 @@ + - - + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + - diff --git a/app/src/main/res/drawable/ic_visibility.xml b/app/src/main/res/drawable/ic_visibility.xml new file mode 100644 index 000000000..7fbed391c --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/layout/layout_details_dev.xml b/app/src/main/res/layout/layout_details_dev.xml index 0c3b9b7a8..10c031fca 100644 --- a/app/src/main/res/layout/layout_details_dev.xml +++ b/app/src/main/res/layout/layout_details_dev.xml @@ -56,7 +56,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:visibility="gone" - app:imgIcon="@drawable/ic_map" + app:imgIcon="@drawable/ic_person_location" app:txtTitle="@string/details_dev_address" tools:visibility="visible" /> - \ No newline at end of file + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e9764cc69..c59abd6b9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -128,10 +128,15 @@ "No updates available" "Paid" "Permission" + Data that this app may collect + The developer says this app doesn\'t collect user data. + Data that this app may share + The developer says this app doesn\'t share user data with other companies or organizations. Data safety Data privacy and security practices declared by developer "Privacy" "Rating and reviews" + Feedback given by other users "Review title" "What do you think about this app?" "Cancel all" @@ -153,9 +158,11 @@ "Resume all" "Estimating" "Contains no known trackers" + This version of the app may still contain more trackers not present yet in the Exodus Privacy\'s database. "Powered by Exodus Privacy" "Checking for trackers…" known trackers(s) found in + %1$d known trackers(s) found in %2$s "View report" "All" "Latest" @@ -481,6 +488,10 @@ %1$d permissions + More info + Targets + Package name + Content rating Unarchive Unable to open app %1$s (%2$d) ➔ %3$s (%4$d) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f49e96491..6032e4bda 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ lifecycle = "2.9.2" material = "1.12.0" navigation = "2.9.3" okhttp = "5.1.0" +paging = "3.3.6" preference = "1.2.1" processPhoenix = "3.0.0" protobufJavalite = "4.31.1" @@ -55,6 +56,8 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" } androidx-navigation-fragment-ktx = { module = "androidx.navigation:navigation-fragment-ktx", version.ref = "navigation" } androidx-navigation-ui-ktx = { module = "androidx.navigation:navigation-ui-ktx", version.ref = "navigation" } +androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" } +androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" } androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preference" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }