compose: details: Split AppDetails screen logic into individual files

Make it more readable and independent to adapt for different devices

Signed-off-by: Aayush Gupta <aayushgupta219@gmail.com>
This commit is contained in:
Aayush Gupta
2025-05-02 17:08:43 +08:00
parent 6f1ee9f082
commit fea81ce8b3
14 changed files with 1010 additions and 544 deletions

View File

@@ -10,6 +10,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import com.aurora.gplayapi.data.models.App import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.Artwork import com.aurora.gplayapi.data.models.Artwork
import com.aurora.gplayapi.data.models.Rating import com.aurora.gplayapi.data.models.Rating
import com.aurora.gplayapi.data.models.details.TestingProgram
import com.aurora.store.BuildConfig import com.aurora.store.BuildConfig
/** /**
@@ -62,12 +63,18 @@ class AppPreviewProvider : PreviewParameterProvider<App> {
developerWebsite = "https://auroraoss.com/", developerWebsite = "https://auroraoss.com/",
developerAddress = "330 N Midland Ave, Mumbai, India", developerAddress = "330 N Midland Ave, Mumbai, India",
screenshots = MutableList(5) { Artwork(url = "$it") }, screenshots = MutableList(5) { Artwork(url = "$it") },
testingProgram = TestingProgram(
isAvailable = true,
isSubscribed = false
),
rating = Rating( rating = Rating(
fiveStar = 201458104, fiveStar = 201458104,
fourStar = 313829104, fourStar = 313829104,
threeStar = 204581672, threeStar = 204581672,
twoStar = 183746829, twoStar = 183746829,
oneStar = 96384291 oneStar = 96384291,
average = 4.4F,
abbreviatedLabel = "10 M"
), ),
permissions = mutableListOf( permissions = mutableListOf(
Manifest.permission.INTERNET, Manifest.permission.INTERNET,

View File

@@ -6,35 +6,14 @@
package com.aurora.store.compose.ui.details package com.aurora.store.compose.ui.details
import android.content.ActivityNotFoundException 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.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize 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.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.safeContentPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.layout.AnimatedPane import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.SupportingPaneScaffoldRole import androidx.compose.material3.adaptive.layout.SupportingPaneScaffoldRole
import androidx.compose.material3.adaptive.navigation.NavigableSupportingPaneScaffold import androidx.compose.material3.adaptive.navigation.NavigableSupportingPaneScaffold
@@ -47,61 +26,48 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource 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.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewScreenSizes import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import androidx.compose.ui.util.fastForEach
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.window.core.layout.WindowWidthSizeClass
import coil3.annotation.ExperimentalCoilApi import coil3.annotation.ExperimentalCoilApi
import coil3.compose.LocalAsyncImagePreviewHandler import coil3.compose.LocalAsyncImagePreviewHandler
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.extensions.toast
import com.aurora.gplayapi.data.models.App 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.R
import com.aurora.store.compose.composables.HeaderComposable 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.TopAppBarComposable
import com.aurora.store.compose.composables.app.AnimatedAppIconComposable
import com.aurora.store.compose.composables.app.AppListComposable
import com.aurora.store.compose.composables.app.AppProgressComposable 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.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.AppPreviewProvider
import com.aurora.store.compose.composables.preview.coilPreviewProvider import com.aurora.store.compose.composables.preview.coilPreviewProvider
import com.aurora.store.compose.navigation.Screen import com.aurora.store.compose.navigation.Screen
import com.aurora.store.compose.ui.details.components.AppActions
import com.aurora.store.compose.ui.details.components.AppChangelog
import com.aurora.store.compose.ui.details.components.AppCompatibility
import com.aurora.store.compose.ui.details.components.AppDataSafety
import com.aurora.store.compose.ui.details.components.AppDetails
import com.aurora.store.compose.ui.details.components.AppDeveloperDetails
import com.aurora.store.compose.ui.details.components.AppPrivacy
import com.aurora.store.compose.ui.details.components.AppReviews
import com.aurora.store.compose.ui.details.components.AppScreenshots
import com.aurora.store.compose.ui.details.components.AppTags
import com.aurora.store.compose.ui.details.components.AppTesting
import com.aurora.store.compose.ui.dev.DevProfileScreen import com.aurora.store.compose.ui.dev.DevProfileScreen
import com.aurora.store.compose.ui.dialogs.ManualDownloadDialog import com.aurora.store.compose.ui.dialogs.ManualDownloadDialog
import com.aurora.store.data.installer.AppInstaller import com.aurora.store.data.installer.AppInstaller
import com.aurora.store.data.model.Report import com.aurora.store.data.model.Report
import com.aurora.store.data.model.Scores import com.aurora.store.data.model.Scores
import com.aurora.store.data.room.download.Download 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
import com.aurora.store.util.PackageUtil.PACKAGE_NAME_GMS import com.aurora.store.util.PackageUtil.PACKAGE_NAME_GMS
import com.aurora.store.viewmodel.details.AppDetailsViewModel import com.aurora.store.viewmodel.details.AppDetailsViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale
import kotlin.random.Random
import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport
@Composable @Composable
@@ -119,7 +85,6 @@ fun AppDetailsScreen(
val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle() val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle()
val download by viewModel.download.collectAsStateWithLifecycle() val download by viewModel.download.collectAsStateWithLifecycle()
val installProgress by viewModel.installProgress.collectAsStateWithLifecycle() val installProgress by viewModel.installProgress.collectAsStateWithLifecycle()
val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
var shouldShowManualDownloadDialog by rememberSaveable { mutableStateOf(false) } var shouldShowManualDownloadDialog by rememberSaveable { mutableStateOf(false) }
@@ -158,7 +123,6 @@ fun AppDetailsScreen(
ScreenContentApp( ScreenContentApp(
app = this, app = this,
isAnonymous = viewModel.authProvider.isAnonymous, isAnonymous = viewModel.authProvider.isAnonymous,
suggestions = suggestions,
download = download, download = download,
installProgress = installProgress, installProgress = installProgress,
plexusScores = plexusScores, plexusScores = plexusScores,
@@ -229,11 +193,10 @@ private fun ScreenContentError(onNavigateUp: () -> Unit = {}) {
private fun ScreenContentApp( private fun ScreenContentApp(
app: App, app: App,
isAnonymous: Boolean = true, isAnonymous: Boolean = true,
suggestions: List<App> = emptyList(),
download: Download? = null, download: Download? = null,
installProgress: Float? = null, installProgress: Float? = null,
plexusScores: Scores? = null, plexusScores: Scores? = null,
dataSafetyReport: DataSafetyReport? = null, dataSafetyReport: com.aurora.gplayapi.data.models.datasafety.Report? = null,
exodusReport: Report? = null, exodusReport: Report? = null,
hasValidUpdate: Boolean = false, hasValidUpdate: Boolean = false,
onNavigateUp: () -> Unit = {}, onNavigateUp: () -> Unit = {},
@@ -289,9 +252,9 @@ private fun ScreenContentApp(
} }
}, },
supportingPane = { supportingPane = {
AnimatedPane(modifier = Modifier.safeContentPadding()) { AnimatedPane {
ScreenContentAppSupportingPane( DetailsSuggestions(
suggestions = suggestions, onNavigateUp = null,
onNavigateToAppDetails = onNavigateToAppDetails onNavigateToAppDetails = onNavigateToAppDetails
) )
} }
@@ -428,7 +391,7 @@ private fun ScreenContentAppMainPane(
app = app, app = app,
inProgress = isDownloading || isInstalling, inProgress = isDownloading || isInstalling,
progress = progress, progress = progress,
onNavigateToDetailsDevApps = onNavigateToDetailsDevProfile, onNavigateToDetailsDevProfile = onNavigateToDetailsDevProfile,
hasValidUpdate = hasValidUpdate hasValidUpdate = hasValidUpdate
) )
@@ -443,7 +406,7 @@ private fun ScreenContentAppMainPane(
) )
AppScreenshots( AppScreenshots(
list = app.screenshots, screenshots = app.screenshots,
onNavigateToScreenshot = onNavigateToDetailsScreenshot onNavigateToScreenshot = onNavigateToDetailsScreenshot
) )
@@ -466,7 +429,7 @@ private fun ScreenContentAppMainPane(
subtitle = stringResource(R.string.permissions, app.permissions.size) subtitle = stringResource(R.string.permissions, app.permissions.size)
) )
if (dataSafetyReport != null) AppDataSafety(dataSafetyReport = dataSafetyReport) if (dataSafetyReport != null) AppDataSafety(report = dataSafetyReport)
AppPrivacy( AppPrivacy(
report = exodusReport, report = exodusReport,
@@ -486,493 +449,6 @@ private fun ScreenContentAppMainPane(
} }
} }
/**
* Composable to display similar and related app suggestions
*/
@Composable
private fun ScreenContentAppSupportingPane(
suggestions: List<App> = emptyList(),
onNavigateToAppDetails: (packageName: String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBarComposable() }
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
Row(
modifier = Modifier.padding(dimensionResource(R.dimen.margin_medium)),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_suggestions),
contentDescription = null
)
HeaderComposable(title = stringResource(R.string.pref_ui_similar_apps))
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(vertical = dimensionResource(R.dimen.padding_medium))
) {
items(items = suggestions, key = { item -> item.id }) { app ->
AppListComposable(
app = app,
onClick = { onNavigateToAppDetails(app.packageName) }
)
}
}
}
}
}
/**
* Composable to display basic app details
*/
@Composable
private fun AppDetails(
app: App,
progress: Float = 0F,
inProgress: Boolean = false,
onNavigateToDetailsDevApps: (developerName: String) -> Unit,
hasValidUpdate: Boolean = false,
) {
val context = LocalContext.current
Row(modifier = Modifier.fillMaxWidth()) {
AnimatedAppIconComposable(
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_large)),
iconUrl = app.iconArtwork.url,
inProgress = inProgress,
progress = progress
)
Column(modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small))) {
Text(
text = app.displayName,
style = MaterialTheme.typography.titleLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
modifier = Modifier
.clickable(onClick = { onNavigateToDetailsDevApps(app.developerName) }),
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 = {},
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo()
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium))
) {
val buttonWidthModifier = when (windowAdaptiveInfo.windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.COMPACT -> Modifier.weight(1F)
else -> Modifier.widthIn(min = dimensionResource(R.dimen.width_button))
}
FilledTonalButton(
modifier = buttonWidthModifier,
onClick = onSecondaryAction,
enabled = isSecondaryActionEnabled
) {
Text(
text = secondaryActionDisplayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Button(
modifier = buttonWidthModifier,
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),
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))
)
}
/**
* Composable to display app's beta testing status
*/
@Composable
private fun AppTesting(
isSubscribed: Boolean = false,
onTestingSubscriptionChange: (subscribe: Boolean) -> Unit
) {
HeaderComposable(title = stringResource(R.string.details_beta))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small))
) {
InfoComposable(
modifier = Modifier.weight(1F),
icon = R.drawable.ic_experiment,
title = AnnotatedString(
text = if (isSubscribed) {
stringResource(R.string.details_beta_subscribed)
} else {
stringResource(R.string.details_beta_available)
}
),
description = AnnotatedString(text = stringResource(R.string.details_beta_description))
)
FilledTonalButton(onClick = { onTestingSubscriptionChange(!isSubscribed) }) {
Text(
text = if (isSubscribed) {
stringResource(R.string.action_leave)
} else {
stringResource(R.string.action_join)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@PreviewScreenSizes @PreviewScreenSizes
@Composable @Composable
@OptIn(ExperimentalCoilApi::class) @OptIn(ExperimentalCoilApi::class)
@@ -981,7 +457,6 @@ private fun AppDetailsScreenPreview(@PreviewParameter(AppPreviewProvider::class)
ScreenContentApp( ScreenContentApp(
app = app, app = app,
isAnonymous = false, isAnonymous = false,
suggestions = List(5) { app.copy(id = Random.nextInt()) },
hasValidUpdate = false hasValidUpdate = false
) )
} }

View File

@@ -0,0 +1,109 @@
/*
* 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.Column
import androidx.compose.foundation.layout.Row
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.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
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.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.TopAppBarComposable
import com.aurora.store.compose.composables.app.AppListComposable
import com.aurora.store.compose.composables.preview.AppPreviewProvider
import com.aurora.store.compose.composables.preview.coilPreviewProvider
import com.aurora.store.viewmodel.details.AppDetailsViewModel
import kotlin.random.Random
/**
* Screen to display similar and related suggestions for an app, supposed to be used internally
* by the [AppDetailsScreen] and not navigable from other screens
*/
@Composable
fun DetailsSuggestions(
onNavigateUp: (() -> Unit)?,
viewModel: AppDetailsViewModel = hiltViewModel(),
onNavigateToAppDetails: (packageName: String) -> Unit
) {
val suggestions by viewModel.suggestions.collectAsStateWithLifecycle()
ScreenContent(
onNavigateUp = onNavigateUp,
suggestions = suggestions,
onNavigateToAppDetails = onNavigateToAppDetails
)
}
@Composable
private fun ScreenContent(
onNavigateUp: (() -> Unit)? = {},
suggestions: List<App> = emptyList(),
onNavigateToAppDetails: (packageName: String) -> Unit = {}
) {
Scaffold(
topBar = { TopAppBarComposable(onNavigateUp = onNavigateUp) }
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
Row(
modifier = Modifier.padding(dimensionResource(R.dimen.margin_medium)),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(R.drawable.ic_suggestions),
contentDescription = null
)
HeaderComposable(title = stringResource(R.string.pref_ui_similar_apps))
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(vertical = dimensionResource(R.dimen.padding_medium))
) {
items(items = suggestions, key = { item -> item.id }) { app ->
AppListComposable(
app = app,
onClick = { onNavigateToAppDetails(app.packageName) }
)
}
}
}
}
}
@Preview
@Composable
@OptIn(ExperimentalCoilApi::class)
private fun DetailsSuggestionsPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) {
ScreenContent(
onNavigateUp = null,
suggestions = List(10) { app.copy(id = Random.nextInt()) }
)
}
}

View File

@@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details.components
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.widthIn
import androidx.compose.material3.Button
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
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.window.core.layout.WindowWidthSizeClass
import com.aurora.store.R
/**
* Composable to display primary and secondary actions available for the app, supposed to be used
* as a part of the Column with proper vertical arrangement spacing in the AppDetailsScreen.
* @param primaryActionDisplayName Name of the primary action
* @param secondaryActionDisplayName Name of the secondary action
* @param isPrimaryActionEnabled Whether the primary action is enabled
* @param isSecondaryActionEnabled Whether the secondary action is enabled
* @param onPrimaryAction Callback when the primary action is clicked
* @param onSecondaryAction Callback when the secondary action is clicked
* @param windowAdaptiveInfo Adaptive window information
*/
@Composable
fun AppActions(
primaryActionDisplayName: String,
secondaryActionDisplayName: String,
isPrimaryActionEnabled: Boolean = true,
isSecondaryActionEnabled: Boolean = true,
onPrimaryAction: () -> Unit = {},
onSecondaryAction: () -> Unit = {},
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfo()
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.padding_medium))
) {
val buttonWidthModifier = when (windowAdaptiveInfo.windowSizeClass.windowWidthSizeClass) {
WindowWidthSizeClass.COMPACT -> Modifier.weight(1F)
else -> Modifier.widthIn(min = dimensionResource(R.dimen.width_button))
}
FilledTonalButton(
modifier = buttonWidthModifier,
onClick = onSecondaryAction,
enabled = isSecondaryActionEnabled
) {
Text(
text = secondaryActionDisplayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Button(
modifier = buttonWidthModifier,
onClick = onPrimaryAction,
enabled = isPrimaryActionEnabled
) {
Text(
text = primaryActionDisplayName,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun AppActionsPreview() {
Column(verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))) {
AppActions(
primaryActionDisplayName = stringResource(R.string.action_install),
secondaryActionDisplayName = stringResource(R.string.title_manual_download)
)
}
}

View File

@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
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 com.aurora.gplayapi.data.models.App
import com.aurora.store.R
import com.aurora.store.compose.composables.HeaderComposable
import com.aurora.store.compose.composables.preview.AppPreviewProvider
/**
* Composable to display app changelog, supposed to be used as a part
* of the Column with proper vertical arrangement spacing in the AppDetailsScreen.
* @param changelog Changelog to show
*/
@Composable
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
)
}
}
@Preview(showBackground = true)
@Composable
private fun AppChangelogPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
Column(verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))) {
AppChangelog(changelog = app.changes)
}
}

View File

@@ -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.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import com.aurora.store.R
import com.aurora.store.compose.composables.HeaderComposable
import com.aurora.store.compose.composables.InfoComposable
import com.aurora.store.data.model.Scores
/**
* Composable to display app compatibility rating from Plexus, supposed to be used as a part
* of the Column with proper vertical arrangement spacing in the AppDetailsScreen.
* @param needsGms Whether this app needs Google Play Services to work properly
* @param plexusScores Scores from the Plexus
*/
@Composable
fun AppCompatibility(needsGms: Boolean, plexusScores: Scores? = null) {
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)
)
)
}
}
@Preview(showBackground = true)
@Composable
private fun AppCompatibilityPreview() {
Column(verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))) {
AppCompatibility(needsGms = true)
}
}

View File

@@ -0,0 +1,89 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.datasafety.Entry
import com.aurora.gplayapi.data.models.datasafety.EntryType
import com.aurora.gplayapi.data.models.datasafety.Report
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.preview.AppPreviewProvider
/**
* Composable to display app's data safety report, supposed to be used as a part
* of the Column with proper vertical arrangement spacing in the AppDetailsScreen.
* @param report App's data safety report
*/
@Composable
fun AppDataSafety(report: Report) {
HeaderComposable(
title = stringResource(R.string.details_data_safety_title),
subtitle = stringResource(R.string.details_data_safety_subtitle)
)
report.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 -> {}
}
}
}
@Preview(showBackground = true)
@Composable
private fun AppDataSafetyPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
Column(verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))) {
AppDataSafety(
report = Report(
packageName = app.packageName,
entries = listOf(
Entry(type = EntryType.DATA_COLLECTED, name = String(), description = String()),
Entry(type = EntryType.DATA_SHARED, name = String(), description = String())
)
)
)
}
}

View File

@@ -0,0 +1,105 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details.components
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.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.platform.LocalContext
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.tooling.preview.PreviewParameter
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.LocalAsyncImagePreviewHandler
import com.aurora.extensions.bodyVerySmall
import com.aurora.gplayapi.data.models.App
import com.aurora.store.R
import com.aurora.store.compose.composables.app.AnimatedAppIconComposable
import com.aurora.store.compose.composables.preview.AppPreviewProvider
import com.aurora.store.compose.composables.preview.coilPreviewProvider
import com.aurora.store.util.PackageUtil
/**
* Composable to display basic app details, supposed to be used as a part
* of the Column with proper vertical arrangement spacing in the AppDetailsScreen.
* @param app App to show details about
* @param progress Ongoing progress percentage out of 100, for e.g. 50.0
* @param inProgress Whether there is some ongoing progress related to the app
* @param onNavigateToDetailsDevProfile Callback when the developer name is tapped
* @param hasValidUpdate Whether the app has a valid update available
*/
@Composable
fun AppDetails(
app: App,
progress: Float = 0F,
inProgress: Boolean = false,
onNavigateToDetailsDevProfile: (developerName: String) -> Unit = {},
hasValidUpdate: Boolean = false,
) {
val context = LocalContext.current
Row(modifier = Modifier.fillMaxWidth()) {
AnimatedAppIconComposable(
modifier = Modifier.requiredSize(dimensionResource(R.dimen.icon_size_large)),
iconUrl = app.iconArtwork.url,
inProgress = inProgress,
progress = progress
)
Column(modifier = Modifier.padding(horizontal = dimensionResource(R.dimen.margin_small))) {
Text(
text = app.displayName,
style = MaterialTheme.typography.titleLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Text(
modifier = Modifier
.clickable(onClick = { onNavigateToDetailsDevProfile(app.developerName) }),
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
)
}
}
}
@Preview(showBackground = true)
@Composable
@OptIn(ExperimentalCoilApi::class)
private fun AppDetailsPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
Column(verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))) {
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) {
AppDetails(app = app)
}
}
}

View File

@@ -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.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
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.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import com.aurora.extensions.browse
import com.aurora.extensions.copyToClipBoard
import com.aurora.extensions.mailTo
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.preview.AppPreviewProvider
/**
* Composable to display details of the app developer, supposed to be used as a part
* of the Column with proper vertical arrangement spacing in the AppDetailsScreen.
* @param address Address of the app developer
* @param website Website of the app developer
* @param email Email address of the app developer
*/
@Composable
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) }
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun AppDeveloperDetailsPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
Column(verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))) {
AppDeveloperDetails(
address = app.developerAddress,
website = app.developerWebsite,
email = app.developerEmail
)
}
}

View File

@@ -0,0 +1,57 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.tooling.preview.Preview
import com.aurora.store.R
import com.aurora.store.compose.composables.HeaderComposable
import com.aurora.store.compose.composables.InfoComposable
import com.aurora.store.data.model.Report
/**
* Composable to display app's privacy report from ExodusPrivacy, supposed to be used as a part
* of the Column with proper vertical arrangement spacing in the AppDetailsScreen.
* @param report Report from Exodus Privacy for the app
* @param onNavigateToDetailsExodus Callback when the user navigates
*/
@Composable
fun AppPrivacy(report: Report?, onNavigateToDetailsExodus: (() -> Unit)? = null) {
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(showBackground = true)
@Composable
private fun AppPrivacyPreview() {
Column(verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))) {
AppPrivacy(report = Report(), onNavigateToDetailsExodus = {})
}
}

View File

@@ -0,0 +1,96 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details.components
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.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.stringResource
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 com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.Rating
import com.aurora.store.R
import com.aurora.store.compose.composables.HeaderComposable
import com.aurora.store.compose.composables.details.RatingComposable
import com.aurora.store.compose.composables.preview.AppPreviewProvider
import java.util.Locale
/**
* Composable to display reviews of the app, supposed to be used as a part
* of the Column with proper vertical arrangement spacing in the AppDetailsScreen.
* @param rating Rating of the app
* @param onNavigateToDetailsReview Callback when the user navigates
*/
@Composable
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),
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()
)
}
}
}
}
@Preview(showBackground = true)
@Composable
private fun AppReviewsPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
Column(verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))) {
AppReviews(rating = app.rating)
}
}

View File

@@ -0,0 +1,61 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
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.res.dimensionResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import coil3.annotation.ExperimentalCoilApi
import coil3.compose.LocalAsyncImagePreviewHandler
import com.aurora.gplayapi.data.models.App
import com.aurora.gplayapi.data.models.Artwork
import com.aurora.store.R
import com.aurora.store.compose.composables.details.ScreenshotComposable
import com.aurora.store.compose.composables.preview.AppPreviewProvider
import com.aurora.store.compose.composables.preview.coilPreviewProvider
/**
* Composable to display screenshots of the app, supposed to be used as a part
* of the Column with proper vertical arrangement spacing in the AppDetailsScreen.
* @param screenshots Screenshots to display
* @param onNavigateToScreenshot Callback when a screenshot is clicked
*/
@Composable
fun AppScreenshots(screenshots: List<Artwork>, onNavigateToScreenshot: (index: Int) -> Unit = {}) {
LazyRow(horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small))) {
items(items = screenshots, key = { artwork -> artwork.url }) { artwork ->
ScreenshotComposable(
modifier = Modifier
.height(dimensionResource(R.dimen.screenshot_height))
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_small)))
.clickable { onNavigateToScreenshot(screenshots.indexOf(artwork)) },
url = "${artwork.url}=rw-w480-v1-e15"
)
}
}
}
@Preview(showBackground = true)
@Composable
@OptIn(ExperimentalCoilApi::class)
private fun AppScreenshotsPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
Column(verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))) {
CompositionLocalProvider(LocalAsyncImagePreviewHandler provides coilPreviewProvider) {
AppScreenshots(screenshots = app.screenshots)
}
}
}

View File

@@ -0,0 +1,75 @@
/*
* SPDX-FileCopyrightText: 2025 The Calyx Institute
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.aurora.store.compose.ui.details.components
import android.text.format.Formatter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
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 com.aurora.gplayapi.data.models.App
import com.aurora.store.R
import com.aurora.store.compose.composables.app.AppTagComposable
import com.aurora.store.compose.composables.preview.AppPreviewProvider
import com.aurora.store.util.CommonUtil
/**
* Composable to display tags related to the app, supposed to be used as a part
* of the Column with proper vertical arrangement spacing in the AppDetailsScreen.
* @param app App to show tags
*/
@Composable
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))
}
}
}
@Preview(showBackground = true)
@Composable
private fun AppTagsPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
Column(verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))) {
AppTags(app = app)
}
}

View File

@@ -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.components
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.material3.FilledTonalButton
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.stringResource
import androidx.compose.ui.text.AnnotatedString
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.HeaderComposable
import com.aurora.store.compose.composables.InfoComposable
import com.aurora.store.compose.composables.preview.AppPreviewProvider
/**
* Composable to display app's beta testing status, supposed to be used as a part of the
* Column with proper vertical arrangement spacing in the AppDetailsScreen.
*
* @param isSubscribed Whether the user is subscribed to the beta testing program
* @param onTestingSubscriptionChange Callback when the the subscription button is clicked
*/
@Composable
fun AppTesting(
isSubscribed: Boolean,
onTestingSubscriptionChange: (subscribe: Boolean) -> Unit = {}
) {
HeaderComposable(title = stringResource(R.string.details_beta))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_small))
) {
InfoComposable(
modifier = Modifier.weight(1F),
icon = R.drawable.ic_experiment,
title = AnnotatedString(
text = if (isSubscribed) {
stringResource(R.string.details_beta_subscribed)
} else {
stringResource(R.string.details_beta_available)
}
),
description = AnnotatedString(text = stringResource(R.string.details_beta_description))
)
FilledTonalButton(onClick = { onTestingSubscriptionChange(!isSubscribed) }) {
Text(
text = if (isSubscribed) {
stringResource(R.string.action_leave)
} else {
stringResource(R.string.action_join)
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Preview(showBackground = true)
@Composable
private fun AppTestingPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
Column(verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.margin_medium))) {
AppTesting(isSubscribed = app.testingProgram!!.isSubscribed)
}
}