diff --git a/app/src/main/java/com/aurora/store/compose/composables/preview/App.kt b/app/src/main/java/com/aurora/store/compose/composables/preview/App.kt index 68408609c..08960cce0 100644 --- a/app/src/main/java/com/aurora/store/compose/composables/preview/App.kt +++ b/app/src/main/java/com/aurora/store/compose/composables/preview/App.kt @@ -10,6 +10,7 @@ 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.gplayapi.data.models.details.TestingProgram import com.aurora.store.BuildConfig /** @@ -62,12 +63,18 @@ class AppPreviewProvider : PreviewParameterProvider { developerWebsite = "https://auroraoss.com/", developerAddress = "330 N Midland Ave, Mumbai, India", screenshots = MutableList(5) { Artwork(url = "$it") }, + testingProgram = TestingProgram( + isAvailable = true, + isSubscribed = false + ), rating = Rating( fiveStar = 201458104, fourStar = 313829104, threeStar = 204581672, twoStar = 183746829, - oneStar = 96384291 + oneStar = 96384291, + average = 4.4F, + abbreviatedLabel = "10 M" ), permissions = mutableListOf( Manifest.permission.INTERNET, diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt index 6c8866c06..74a276623 100644 --- a/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt +++ b/app/src/main/java/com/aurora/store/compose/ui/details/AppDetailsScreen.kt @@ -6,35 +6,14 @@ 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.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.shape.RoundedCornerShape 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.Text 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.SupportingPaneScaffoldRole 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.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.platform.LocalContext import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.fromHtml -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewScreenSizes -import androidx.compose.ui.util.fastForEach import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.window.core.layout.WindowWidthSizeClass import coil3.annotation.ExperimentalCoilApi 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.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.AnimatedAppIconComposable -import com.aurora.store.compose.composables.app.AppListComposable 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.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.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 kotlinx.coroutines.launch -import java.util.Locale -import kotlin.random.Random import com.aurora.gplayapi.data.models.datasafety.Report as DataSafetyReport @Composable @@ -119,7 +85,6 @@ fun AppDetailsScreen( val plexusScores by viewModel.plexusScores.collectAsStateWithLifecycle() val download by viewModel.download.collectAsStateWithLifecycle() val installProgress by viewModel.installProgress.collectAsStateWithLifecycle() - val suggestions by viewModel.suggestions.collectAsStateWithLifecycle() var shouldShowManualDownloadDialog by rememberSaveable { mutableStateOf(false) } @@ -158,7 +123,6 @@ fun AppDetailsScreen( ScreenContentApp( app = this, isAnonymous = viewModel.authProvider.isAnonymous, - suggestions = suggestions, download = download, installProgress = installProgress, plexusScores = plexusScores, @@ -229,11 +193,10 @@ private fun ScreenContentError(onNavigateUp: () -> Unit = {}) { private fun ScreenContentApp( app: App, isAnonymous: Boolean = true, - suggestions: List = emptyList(), download: Download? = null, installProgress: Float? = null, plexusScores: Scores? = null, - dataSafetyReport: DataSafetyReport? = null, + dataSafetyReport: com.aurora.gplayapi.data.models.datasafety.Report? = null, exodusReport: Report? = null, hasValidUpdate: Boolean = false, onNavigateUp: () -> Unit = {}, @@ -289,9 +252,9 @@ private fun ScreenContentApp( } }, supportingPane = { - AnimatedPane(modifier = Modifier.safeContentPadding()) { - ScreenContentAppSupportingPane( - suggestions = suggestions, + AnimatedPane { + DetailsSuggestions( + onNavigateUp = null, onNavigateToAppDetails = onNavigateToAppDetails ) } @@ -428,7 +391,7 @@ private fun ScreenContentAppMainPane( app = app, inProgress = isDownloading || isInstalling, progress = progress, - onNavigateToDetailsDevApps = onNavigateToDetailsDevProfile, + onNavigateToDetailsDevProfile = onNavigateToDetailsDevProfile, hasValidUpdate = hasValidUpdate ) @@ -443,7 +406,7 @@ private fun ScreenContentAppMainPane( ) AppScreenshots( - list = app.screenshots, + screenshots = app.screenshots, onNavigateToScreenshot = onNavigateToDetailsScreenshot ) @@ -466,7 +429,7 @@ private fun ScreenContentAppMainPane( subtitle = stringResource(R.string.permissions, app.permissions.size) ) - if (dataSafetyReport != null) AppDataSafety(dataSafetyReport = dataSafetyReport) + if (dataSafetyReport != null) AppDataSafety(report = dataSafetyReport) AppPrivacy( report = exodusReport, @@ -486,493 +449,6 @@ private fun ScreenContentAppMainPane( } } -/** - * Composable to display similar and related app suggestions - */ -@Composable -private fun ScreenContentAppSupportingPane( - suggestions: List = 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, 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 @Composable @OptIn(ExperimentalCoilApi::class) @@ -981,7 +457,6 @@ private fun AppDetailsScreenPreview(@PreviewParameter(AppPreviewProvider::class) ScreenContentApp( app = app, isAnonymous = false, - suggestions = List(5) { app.copy(id = Random.nextInt()) }, hasValidUpdate = false ) } diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/DetailsSuggestions.kt b/app/src/main/java/com/aurora/store/compose/ui/details/DetailsSuggestions.kt new file mode 100644 index 000000000..a8b5370be --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/DetailsSuggestions.kt @@ -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 = 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()) } + ) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/components/AppActions.kt b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppActions.kt new file mode 100644 index 000000000..e9405e7c8 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppActions.kt @@ -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) + ) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/components/AppChangelog.kt b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppChangelog.kt new file mode 100644 index 000000000..ab79b1546 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppChangelog.kt @@ -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) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/components/AppCompatibility.kt b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppCompatibility.kt new file mode 100644 index 000000000..a84ef25d3 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppCompatibility.kt @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.details.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) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/components/AppDataSafety.kt b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppDataSafety.kt new file mode 100644 index 000000000..9977b2894 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppDataSafety.kt @@ -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()) + ) + ) + ) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/components/AppDetails.kt b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppDetails.kt new file mode 100644 index 000000000..439f30b43 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppDetails.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/components/AppDeveloperDetails.kt b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppDeveloperDetails.kt new file mode 100644 index 000000000..9190f711b --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppDeveloperDetails.kt @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.details.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 + ) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/components/AppPrivacy.kt b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppPrivacy.kt new file mode 100644 index 000000000..c75c6a3b5 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppPrivacy.kt @@ -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 = {}) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/components/AppReviews.kt b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppReviews.kt new file mode 100644 index 000000000..1f488ce2a --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppReviews.kt @@ -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) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/components/AppScreenshots.kt b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppScreenshots.kt new file mode 100644 index 000000000..b87bebb5c --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppScreenshots.kt @@ -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, 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) + } + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/components/AppTags.kt b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppTags.kt new file mode 100644 index 000000000..71397ee13 --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppTags.kt @@ -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) + } +} diff --git a/app/src/main/java/com/aurora/store/compose/ui/details/components/AppTesting.kt b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppTesting.kt new file mode 100644 index 000000000..b02fa195d --- /dev/null +++ b/app/src/main/java/com/aurora/store/compose/ui/details/components/AppTesting.kt @@ -0,0 +1,79 @@ +/* + * SPDX-FileCopyrightText: 2025 The Calyx Institute + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.aurora.store.compose.ui.details.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) + } +}