mirror of
https://github.com/whyorean/AuroraStore.git
synced 2026-08-02 10:21:10 -04:00
details: add version lookup to the manual download screen
Add a "Look up versions" button that opens a bottom sheet listing the app versions known to Exodus (version name, code and release date).
This commit is contained in:
@@ -65,11 +65,9 @@ import com.aurora.store.compose.preview.AppPreviewProvider
|
||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||
import com.aurora.store.data.model.ExodusTracker
|
||||
import com.aurora.store.data.model.Report
|
||||
import com.aurora.store.data.model.formatExodusDate
|
||||
import com.aurora.store.viewmodel.details.AppDetailsViewModel
|
||||
import com.aurora.store.viewmodel.details.ExodusViewModel
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun ExodusScreen(
|
||||
@@ -271,16 +269,6 @@ private fun ExodusReportDialog(
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatExodusDate(raw: String): String {
|
||||
val datePart = raw.take(10)
|
||||
return try {
|
||||
val parsed = SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(datePart)
|
||||
if (parsed != null) DateFormat.getDateInstance(DateFormat.MEDIUM).format(parsed) else raw
|
||||
} catch (_: Exception) {
|
||||
raw
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable to display errors related to fetching app details
|
||||
*/
|
||||
|
||||
@@ -20,8 +20,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ContainedLoadingIndicator
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
@@ -30,15 +32,12 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.WindowAdaptiveInfo
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
@@ -63,9 +62,10 @@ import com.aurora.store.compose.composable.Info
|
||||
import com.aurora.store.compose.composable.TopAppBar
|
||||
import com.aurora.store.compose.preview.AppPreviewProvider
|
||||
import com.aurora.store.compose.preview.ThemePreviewProvider
|
||||
import com.aurora.store.compose.ui.sheets.VersionPickerSheet
|
||||
import com.aurora.store.data.model.AppState
|
||||
import com.aurora.store.data.model.Report
|
||||
import com.aurora.store.viewmodel.details.AppDetailsViewModel
|
||||
import kotlinx.coroutines.android.awaitFrame
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
@@ -77,6 +77,8 @@ fun ManualDownloadScreen(
|
||||
) {
|
||||
val app by viewModel.app.collectAsStateWithLifecycle()
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val reports by viewModel.exodusReports.collectAsStateWithLifecycle()
|
||||
val lookupInProgress by viewModel.versionLookupInProgress.collectAsStateWithLifecycle()
|
||||
val topAppBarTitle = when {
|
||||
windowAdaptiveInfo.isWindowCompact -> app!!.displayName
|
||||
else -> stringResource(R.string.title_manual_download)
|
||||
@@ -86,6 +88,9 @@ fun ManualDownloadScreen(
|
||||
state = state,
|
||||
topAppBarTitle = topAppBarTitle,
|
||||
currentVersionCode = app!!.versionCode,
|
||||
reports = reports,
|
||||
lookupInProgress = lookupInProgress,
|
||||
onLookup = viewModel::lookupVersions,
|
||||
onRequestInstall = { versionCode ->
|
||||
val requestedApp = app!!.copy(
|
||||
versionCode = versionCode,
|
||||
@@ -105,6 +110,9 @@ private fun ScreenContent(
|
||||
state: AppState = AppState.Unavailable,
|
||||
topAppBarTitle: String? = null,
|
||||
currentVersionCode: Long = 0L,
|
||||
reports: List<Report> = emptyList(),
|
||||
lookupInProgress: Boolean = false,
|
||||
onLookup: () -> Unit = {},
|
||||
onRequestInstall: (versionCode: Long) -> Unit = {},
|
||||
windowAdaptiveInfo: WindowAdaptiveInfo = currentWindowAdaptiveInfoV2()
|
||||
) {
|
||||
@@ -114,15 +122,24 @@ private fun ScreenContent(
|
||||
val errorMessage = stringResource(R.string.manual_download_version_error)
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
var versionCode by remember {
|
||||
val initText = currentVersionCode.toString()
|
||||
mutableStateOf(TextFieldValue(text = initText, selection = TextRange(initText.length)))
|
||||
}
|
||||
|
||||
LaunchedEffect(focusRequester) {
|
||||
awaitFrame()
|
||||
focusRequester.requestFocus()
|
||||
var showVersionPicker by remember { mutableStateOf(false) }
|
||||
|
||||
if (showVersionPicker) {
|
||||
VersionPickerSheet(
|
||||
reports = reports,
|
||||
loading = lookupInProgress,
|
||||
onSelect = { report ->
|
||||
val code = report.versionCode
|
||||
versionCode = TextFieldValue(text = code, selection = TextRange(code.length))
|
||||
showVersionPicker = false
|
||||
},
|
||||
onDismiss = { showVersionPicker = false }
|
||||
)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -153,9 +170,7 @@ private fun ScreenContent(
|
||||
title = AnnotatedString(text = stringResource(R.string.manual_download_hint))
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.inProgress(),
|
||||
value = versionCode,
|
||||
onValueChange = {
|
||||
@@ -178,6 +193,28 @@ private fun ScreenContent(
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.inProgress(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
),
|
||||
onClick = {
|
||||
// Dismiss the keyboard so the sheet opens over a settled layout, then
|
||||
// load versions (a no-op fetch when they are already cached).
|
||||
focusManager.clearFocus()
|
||||
showVersionPicker = true
|
||||
onLookup()
|
||||
}
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.manual_download_lookup),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
@@ -199,7 +236,7 @@ private fun ScreenContent(
|
||||
|
||||
Button(
|
||||
modifier = Modifier.weight(1F),
|
||||
enabled = !state.inProgress(),
|
||||
enabled = !state.inProgress() && versionCode.text.isNotBlank(),
|
||||
onClick = {
|
||||
onRequestInstall(versionCode.text.toLong())
|
||||
focusManager.clearFocus()
|
||||
@@ -222,6 +259,11 @@ private fun ScreenContent(
|
||||
private fun ManualDownloadScreenPreview(@PreviewParameter(AppPreviewProvider::class) app: App) {
|
||||
ScreenContent(
|
||||
topAppBarTitle = app.displayName,
|
||||
currentVersionCode = app.versionCode
|
||||
currentVersionCode = app.versionCode,
|
||||
reports = listOf(
|
||||
Report(version = "8.5.1", versionCode = "85100"),
|
||||
Report(version = "8.4.0", versionCode = "84000"),
|
||||
Report(version = "8.3.2", versionCode = "83200")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Aurora OSS
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.aurora.store.compose.ui.sheets
|
||||
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ContainedLoadingIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aurora.store.R
|
||||
import com.aurora.store.data.model.Report
|
||||
import com.aurora.store.data.model.formatExodusDate
|
||||
|
||||
/**
|
||||
* Bottom sheet that lists the versions known to Exodus for an app. Selecting one hands its version
|
||||
* code back to the manual download screen; the list scrolls internally so long histories stay
|
||||
* manageable.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun VersionPickerSheet(
|
||||
reports: List<Report>,
|
||||
loading: Boolean,
|
||||
onSelect: (Report) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = stringResource(R.string.manual_download_select_version),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(R.dimen.spacing_medium),
|
||||
vertical = dimensionResource(R.dimen.spacing_xsmall)
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(dimensionResource(R.dimen.spacing_xsmall)))
|
||||
|
||||
AvailabilityWarning(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(R.dimen.spacing_small)
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(dimensionResource(R.dimen.spacing_xsmall)))
|
||||
|
||||
when {
|
||||
loading -> Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(dimensionResource(R.dimen.spacing_large)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
ContainedLoadingIndicator()
|
||||
}
|
||||
|
||||
reports.isEmpty() -> Text(
|
||||
text = stringResource(R.string.manual_download_no_versions),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = dimensionResource(R.dimen.spacing_medium),
|
||||
vertical = dimensionResource(R.dimen.spacing_medium)
|
||||
)
|
||||
)
|
||||
|
||||
else -> LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 480.dp)
|
||||
.padding(horizontal = dimensionResource(R.dimen.spacing_small))
|
||||
.clip(RoundedCornerShape(dimensionResource(R.dimen.radius_medium)))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = reports,
|
||||
key = { _, report -> "report-${report.id}" }
|
||||
) { index, report ->
|
||||
if (index > 0) HorizontalDivider()
|
||||
VersionRow(report = report, onClick = { onSelect(report) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.navigationBarsPadding())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AvailabilityWarning(modifier: Modifier = Modifier) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(dimensionResource(R.dimen.radius_medium)),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(dimensionResource(R.dimen.spacing_medium)),
|
||||
horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.spacing_small)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.height(dimensionResource(R.dimen.icon_size_default)),
|
||||
painter = painterResource(R.drawable.ic_help),
|
||||
contentDescription = null
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.manual_download_availability_warning),
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VersionRow(report: Report, onClick: () -> Unit) {
|
||||
val title = if (report.version.isBlank()) {
|
||||
report.versionCode
|
||||
} else {
|
||||
"${report.version} (${report.versionCode})"
|
||||
}
|
||||
val date = report.updatedAt.ifBlank { report.creationDate }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(
|
||||
horizontal = dimensionResource(R.dimen.spacing_large),
|
||||
vertical = dimensionResource(R.dimen.spacing_small)
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (date.isNotBlank()) {
|
||||
Text(
|
||||
text = formatExodusDate(date),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,27 @@
|
||||
package com.aurora.store.data.model
|
||||
|
||||
import android.os.Parcelable
|
||||
import java.text.DateFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Formats an Exodus date string (e.g. `2023-01-01T12:34:56Z`) into a localized medium date.
|
||||
* Falls back to the raw value if it cannot be parsed.
|
||||
*/
|
||||
fun formatExodusDate(date: String): String {
|
||||
val datePart = date.take(10)
|
||||
return try {
|
||||
val parsed = SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(datePart)
|
||||
if (parsed != null) DateFormat.getDateInstance(DateFormat.MEDIUM).format(parsed) else date
|
||||
} catch (_: Exception) {
|
||||
date
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ExodusReport(
|
||||
val creator: String = String(),
|
||||
|
||||
@@ -122,6 +122,9 @@ class AppDetailsViewModel @Inject constructor(
|
||||
private val _exodusReports = MutableStateFlow<List<Report>>(emptyList())
|
||||
val exodusReports = _exodusReports.asStateFlow()
|
||||
|
||||
private val _versionLookupInProgress = MutableStateFlow(false)
|
||||
val versionLookupInProgress = _versionLookupInProgress.asStateFlow()
|
||||
|
||||
private val _plexusScores = MutableStateFlow<Scores?>(Scores())
|
||||
val plexusScores = _plexusScores.asStateFlow()
|
||||
|
||||
@@ -492,6 +495,26 @@ class AppDetailsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the list of known versions from Exodus for the version picker on the manual download
|
||||
* screen. Reuses the reports already fetched for the privacy report when available, and only
|
||||
* hits the network when nothing has been loaded yet.
|
||||
*/
|
||||
fun lookupVersions() {
|
||||
val packageName = app.value?.packageName ?: return
|
||||
if (_exodusReports.value.isNotEmpty() || _versionLookupInProgress.value) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_versionLookupInProgress.value = true
|
||||
try {
|
||||
_exodusReports.value = exodusRepository.fetchReports(packageName)
|
||||
.sortedByDescending { it.versionCode.toLongOrNull() ?: -1L }
|
||||
} finally {
|
||||
_versionLookupInProgress.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getNewTrackers(packageName: String, installedVersionCode: Long) =
|
||||
exodusRepository.getNewTrackers(packageName, installedVersionCode)
|
||||
|
||||
|
||||
@@ -510,6 +510,10 @@
|
||||
<!-- ManualDownloadSheet -->
|
||||
<string name="manual_download_hint">Enter version code you wish to download</string>
|
||||
<string name="manual_download_version_error">Version code can only contain digits</string>
|
||||
<string name="manual_download_lookup">Look up versions</string>
|
||||
<string name="manual_download_select_version">Select a version</string>
|
||||
<string name="manual_download_no_versions">No known versions found</string>
|
||||
<string name="manual_download_availability_warning">Some versions may be unavailable to download or incompatible with your device.</string>
|
||||
|
||||
<!-- WelcomeFragment -->
|
||||
<string name="faqs_title" translatable="false">FAQs</string>
|
||||
|
||||
Reference in New Issue
Block a user