compose: Initial migration of AppDetails* logic to compose [1/*]
Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
@@ -74,6 +74,19 @@ fun Context.share(app: App) {
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.mailTo(email: String) {
|
||||
try {
|
||||
val sendIntent = Intent().apply {
|
||||
action = Intent.ACTION_SENDTO
|
||||
data = "mailto:".toUri()
|
||||
putExtra(Intent.EXTRA_EMAIL, email)
|
||||
}
|
||||
startActivity(Intent.createChooser(sendIntent, getString(R.string.details_dev_email)))
|
||||
} catch (exception: Exception) {
|
||||
Log.e(TAG, "Failed to email", exception)
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.openInfo(packageName: String) {
|
||||
try {
|
||||
val intent = Intent(
|
||||
|
||||
24
app/src/main/java/com/aurora/extensions/NavBackStackEntry.kt
Normal file
24
app/src/main/java/com/aurora/extensions/NavBackStackEntry.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2025 The Calyx Institute
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
package com.aurora.extensions
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraph.Companion.findStartDestination
|
||||
|
||||
/**
|
||||
* Gets viewModel from the parent composable if exists
|
||||
*/
|
||||
@Composable
|
||||
inline fun <reified T : ViewModel> NavBackStackEntry.parentViewModel(navController: NavController): T {
|
||||
val navGraphRoute = navController.graph.findStartDestination().route ?: return hiltViewModel()
|
||||
val parentEntry = remember(this) { navController.getBackStackEntry(navGraphRoute) }
|
||||
return hiltViewModel<T>(parentEntry)
|
||||
}
|
||||
65
app/src/main/java/com/aurora/extensions/Shimmer.kt
Normal file
65
app/src/main/java/com/aurora/extensions/Shimmer.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/com/aurora/extensions/Typography.kt
Normal file
19
app/src/main/java/com/aurora/extensions/Typography.kt
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 ?: "")
|
||||
}
|
||||
}
|
||||
@@ -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<App> {
|
||||
companion object {
|
||||
private const val CHANGELOG = """
|
||||
• New app compatibility ratings powered by Plexus<br>
|
||||
• Improvements to blacklist manager<br>
|
||||
• Ability to change auto-update restrictions<br>
|
||||
• Minor bug fixes and improvements<br>
|
||||
• Translation updates; additional strings localized
|
||||
"""
|
||||
|
||||
private const val DESCRIPTION = """
|
||||
<p>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.</p>
|
||||
|
||||
<p><strong>Features:</strong></p>
|
||||
|
||||
<p>• FOSS: Has GPLv3 licence<br>
|
||||
• Beautiful design: Built upon latest Material 3 guidelines<br>
|
||||
• Account login: You can login with either personal or an anonymous account<br>
|
||||
• Device & Locale spoofing: Change your device and/or locale to access geo locked apps<br>
|
||||
• Exodus Privacy integration: Instantly see trackers in app<br>
|
||||
• Plexus integration: Instantly see app compatibility without Google Play Services or with microG<br>
|
||||
• Updates blacklisting: Ignore updates for specific apps</p>
|
||||
"""
|
||||
}
|
||||
|
||||
override val values: Sequence<App>
|
||||
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
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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<Screen.Blacklist> {
|
||||
BlacklistScreen(onNavigateUp = { onNavigateUp() })
|
||||
}
|
||||
|
||||
composable<Screen.AppDetails> { backstackEntry ->
|
||||
val appDetails = backstackEntry.toRoute<Screen.AppDetails>()
|
||||
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<Screen.DetailsExodus> { backstackEntry ->
|
||||
DetailsExodusScreen(
|
||||
onNavigateUp = { onNavigateUp() },
|
||||
viewModel = backstackEntry.parentViewModel(navHostController)
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.DetailsMore> { backstackEntry ->
|
||||
DetailsMoreScreen(
|
||||
onNavigateUp = { onNavigateUp() },
|
||||
viewModel = backstackEntry.parentViewModel(navHostController)
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.DetailsScreenshot> { backstackEntry ->
|
||||
val screenshotDetails = backstackEntry.toRoute<Screen.DetailsScreenshot>()
|
||||
DetailsScreenshotScreen(
|
||||
index = screenshotDetails.index,
|
||||
onNavigateUp = { onNavigateUp() },
|
||||
viewModel = backstackEntry.parentViewModel(navHostController)
|
||||
)
|
||||
}
|
||||
|
||||
composable<Screen.DetailsReview> { backstackEntry ->
|
||||
DetailsReviewScreen(
|
||||
onNavigateUp = { onNavigateUp() },
|
||||
viewModel = backstackEntry.parentViewModel(navHostController)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<Artwork>, 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()
|
||||
}
|
||||
@@ -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<ExodusTracker> = 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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<Review>,
|
||||
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<Review>()).collectAsLazyPagingItems()
|
||||
|
||||
ScreenContent(
|
||||
reviews = reviews
|
||||
)
|
||||
}
|
||||
@@ -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<Artwork> = 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)
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<T : Any>(
|
||||
private val block: suspend (Int) -> List<T>
|
||||
) : PagingSource<Int, T>() {
|
||||
|
||||
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 <T : Any> createPager(
|
||||
pageSize: Int = DEFAULT_PAGE_SIZE,
|
||||
enablePlaceholders: Boolean = true,
|
||||
data: suspend (Int) -> List<T>
|
||||
): Pager<Int, T> = Pager(
|
||||
config = PagingConfig(enablePlaceholders = enablePlaceholders, pageSize = pageSize),
|
||||
pagingSourceFactory = { GenericPagingSource(data) }
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, T> {
|
||||
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, T>): Int? {
|
||||
return state.anchorPosition?.let { anchorPosition ->
|
||||
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
|
||||
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<ViewBindingType : ViewBinding> : Fragment() {
|
||||
}
|
||||
|
||||
fun openDetailsFragment(packageName: String, app: App? = null) {
|
||||
findNavController().navigate(
|
||||
MobileNavigationDirections.actionGlobalAppDetailsFragment(packageName, app)
|
||||
requireContext().navigate(
|
||||
Screen.AppDetails(packageName)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<FragmentDetailsBinding>() {
|
||||
|
||||
// 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<FragmentDetailsBinding>() {
|
||||
}
|
||||
}.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<FragmentDetailsBinding>() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<FragmentDetailsBinding>() {
|
||||
binding.layoutDetailDescription.apply {
|
||||
val installs = CommonUtil.addDiPrefix(app.installs)
|
||||
|
||||
if (installs != "NA") {
|
||||
if (installs != null) {
|
||||
txtInstalls.text = CommonUtil.addDiPrefix(app.installs)
|
||||
} else {
|
||||
txtInstalls.hide()
|
||||
|
||||
@@ -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<String, App> = mutableMapOf()
|
||||
private val _app = MutableSharedFlow<App>()
|
||||
val app = _app.asSharedFlow()
|
||||
private val _app = MutableStateFlow<App?>(App(""))
|
||||
val app = _app.asStateFlow()
|
||||
|
||||
private val reviewsStash = mutableMapOf<String, List<Review>>()
|
||||
private val _reviews = MutableSharedFlow<List<Review>>()
|
||||
val reviews = _reviews.asSharedFlow()
|
||||
private var reviewsNextPageUrl: String? = null
|
||||
private val _reviews = MutableStateFlow<PagingData<Review>>(PagingData.empty())
|
||||
val reviews = _reviews.asStateFlow()
|
||||
|
||||
private val userReviewStash = mutableMapOf<String, Review?>()
|
||||
private val _userReview = MutableSharedFlow<Review>()
|
||||
val userReview = _userReview.asSharedFlow()
|
||||
private val _userReview = MutableStateFlow<Review?>(null)
|
||||
val userReview = _userReview.asStateFlow()
|
||||
|
||||
private val dataSafetyReportStash = mutableMapOf<String, DataSafetyReport>()
|
||||
private val _dataSafetyReport = MutableSharedFlow<DataSafetyReport>()
|
||||
val dataSafetyReport = _dataSafetyReport.asSharedFlow()
|
||||
private val _dataSafetyReport = MutableStateFlow<DataSafetyReport?>(null)
|
||||
val dataSafetyReport = _dataSafetyReport.asStateFlow()
|
||||
|
||||
private val exodusReportStash = mutableMapOf<String, Report?>()
|
||||
private val _exodusReport = MutableSharedFlow<Report?>()
|
||||
val exodusReport = _exodusReport.asSharedFlow()
|
||||
private val _exodusReport = MutableStateFlow<Report?>(Report())
|
||||
val exodusReport = _exodusReport.asStateFlow()
|
||||
|
||||
private val plexusReportStash = mutableMapOf<String, PlexusReport?>()
|
||||
private val _plexusReport = MutableSharedFlow<PlexusReport?>()
|
||||
val plexusReport = _plexusReport.asSharedFlow()
|
||||
private val _plexusScores = MutableStateFlow<Scores?>(Scores())
|
||||
val plexusScores = _plexusScores.asStateFlow()
|
||||
|
||||
private val testProgramStatusStash = mutableMapOf<String, TestingProgramStatus?>()
|
||||
private val _testingProgramStatus = MutableSharedFlow<TestingProgramStatus?>()
|
||||
val testingProgramStatus = _testingProgramStatus.asSharedFlow()
|
||||
private val _testingProgramStatus = MutableStateFlow<TestingProgramStatus?>(null)
|
||||
val testingProgramStatus = _testingProgramStatus.asStateFlow()
|
||||
|
||||
private val _favourite = MutableStateFlow<Boolean>(false)
|
||||
private val _favourite = MutableStateFlow(false)
|
||||
val favourite = _favourite.asStateFlow()
|
||||
|
||||
private val _purchaseStatus = MutableSharedFlow<Boolean>()
|
||||
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<PlexusReport>(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<ExodusTracker> {
|
||||
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<String, String> = 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<PlexusReport>(String(playResponse.responseBytes))
|
||||
}
|
||||
|
||||
private fun parseExodusResponse(response: String, packageName: String): List<Report> {
|
||||
|
||||
Reference in New Issue
Block a user