Add an onboarding card for updates

This commit is contained in:
Torsten Grote committed 2026-04-28 08:58:41 -03:00
1 parent 9741fe98a8
commit 28668ea80c
15 files changed
+142 -64

No files matched your search

@@ -30,6 +30,7 @@ class MyAppsScreenshotTest(localeName: String) : LocalizedScreenshotTest(localeN
appUpdates = getUpdates(localeList),
installingApps = emptyList(),
installedApps = getInstalledApps(localeList),
showUpdatesHint = false,
showAppIssueHint = false,
networkState = NetworkState(isOnline = true, isMetered = false),
)
@@ -18,6 +18,7 @@ class OnboardingManager
constructor(@param:ApplicationContext private val context: Context) {
private companion object {
const val KEY_MY_APPS_UPDATES = "myAppsUpdates"
const val KEY_FILTER = "appFilter"
const val KEY_REPO_LIST = "repoList"
const val KEY_REPO_DETAILS = "repoDetails"
@@ -26,6 +27,9 @@ constructor(@param:ApplicationContext private val context: Context) {
private val prefs = context.getSharedPreferences("onboarding", MODE_PRIVATE)
private val _showMyAppsUpdatesHint = Onboarding(KEY_MY_APPS_UPDATES, prefs)
val showMyAppsUpdatesHint = _showMyAppsUpdatesHint.flow
private val _showFilterOnboarding = Onboarding(KEY_FILTER, prefs)
val showFilterOnboarding = _showFilterOnboarding.flow
@@ -38,6 +42,10 @@ constructor(@param:ApplicationContext private val context: Context) {
private val _showAppIssueHint = Onboarding(KEY_APP_ISSUE_HINT, prefs)
val showAppIssueHint = _showAppIssueHint.flow
fun onMyAppsUpdateHintSeen() {
_showMyAppsUpdatesHint.onSeen(prefs)
}
fun onFilterOnboardingSeen() {
_showFilterOnboarding.onSeen(prefs)
}
@@ -188,6 +188,7 @@ fun MyAppsLoadingPreview() {
installingApps = emptyList(),
appUpdates = null,
installedApps = null,
showUpdatesHint = false,
showAppIssueHint = false,
sortOrder = AppListSortOrder.NAME,
networkState = NetworkState(isOnline = false, isMetered = false),
@@ -226,6 +227,7 @@ fun MyAppsEmptyPreview() {
installingApps = emptyList(),
appUpdates = emptyList(),
installedApps = emptyList(),
showUpdatesHint = false,
showAppIssueHint = false,
sortOrder = AppListSortOrder.NAME,
networkState = NetworkState(isOnline = false, isMetered = false),
@@ -15,6 +15,7 @@ data class MyAppsModel(
val installingApps: List<InstallingAppItem>,
val appsWithIssue: List<AppWithIssueItem>? = null,
val installedApps: List<InstalledAppItem>? = null,
val showUpdatesHint: Boolean,
val showAppIssueHint: Boolean,
val sortOrder: AppListSortOrder = AppListSortOrder.NAME,
val networkState: NetworkState,
@@ -32,6 +33,8 @@ interface MyAppsActions {
fun ignoreAppIssue(item: AppWithIssueItem)
fun onUpdatesHintSeen()
fun onAppIssueHintSeen()
fun exportInstalledApps()
@@ -37,6 +37,7 @@ import org.fdroid.database.NotAvailable
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.MeteredConnectionDialog
import org.fdroid.ui.utils.OfflineBar
import org.fdroid.ui.utils.OnboardingCard
import org.fdroid.ui.utils.getMyAppsInfo
import org.fdroid.ui.utils.myAppsModel
@@ -74,7 +75,17 @@ fun MyAppsList(
modifier.then(if (currentPackageName == null) Modifier else Modifier.selectableGroup()),
) {
// Updates header with Update all button
if (!updatableApps.isNullOrEmpty()) {
if (updatableApps != null && updatableApps.isEmpty() && myAppsInfo.model.showUpdatesHint) {
item(key = "A-hint", contentType = "hint") {
OnboardingCard(
title = stringResource(R.string.apps_updates_hint_title),
message = stringResource(R.string.apps_updates_hint_text),
buttonText = stringResource(R.string.hide),
onGotIt = { myAppsInfo.actions.onUpdatesHintSeen() },
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
} else if (!updatableApps.isNullOrEmpty()) {
if (!myAppsInfo.model.networkState.isOnline) {
item(key = "OfflineBar", contentType = "offlineBar") { OfflineBar() }
}
@@ -211,12 +222,8 @@ fun MyAppsList(
NotAvailableDialog(app.packageName) { showNotAvailableDialog = false }
}
}
// Installed apps header (only show when we have non-empty lists above)
val aboveNonEmpty =
installingApps.isNotEmpty() ||
!updatableApps.isNullOrEmpty() ||
!appsWithIssue.isNullOrEmpty()
if (aboveNonEmpty && !installedApps.isNullOrEmpty()) {
// List of installed apps
if (!installedApps.isNullOrEmpty()) {
item(key = "D", contentType = "header") {
Text(
text = stringResource(R.string.installed_apps__activity_title),
@@ -224,9 +231,6 @@ fun MyAppsList(
modifier = Modifier.padding(16.dp),
)
}
}
// List of installed apps
if (installedApps != null)
items(items = installedApps, key = { it.packageName }, contentType = { "D" }) { app ->
val isSelected = app.packageName == currentPackageName
val interactionModifier =
@@ -241,6 +245,7 @@ fun MyAppsList(
val modifier = Modifier.animateItem().then(interactionModifier)
InstalledAppRow(app, isSelected, modifier)
}
}
}
val meteredLambda = showMeteredDialog
if (meteredLambda != null)
@@ -264,3 +269,24 @@ private fun MyAppsListPreview() {
)
}
}
@Preview
@Composable
@RestrictTo(RestrictTo.Scope.TESTS)
private fun Preview() {
FDroidContent {
MyApps(
myAppsInfo =
getMyAppsInfo(
myAppsModel.copy(
appUpdates = emptyList(),
installingApps = emptyList(),
appsWithIssue = emptyList(),
)
),
currentPackageName = null,
onAppItemClick = {},
onNav = {},
)
}
}
@@ -21,6 +21,7 @@ fun MyAppsPresenter(
appInstallStatesFlow: StateFlow<Map<String, InstallState>>,
appsWithIssuesFlow: StateFlow<List<AppWithIssueItem>?>,
installedAppsFlow: Flow<List<InstalledAppItem>>,
showUpdatesHintFlow: StateFlow<Boolean>,
showAppIssueHintFlow: StateFlow<Boolean>,
searchQueryFlow: StateFlow<String>,
sortOrderFlow: StateFlow<AppListSortOrder>,
@@ -36,52 +37,48 @@ fun MyAppsPresenter(
// we want to show apps currently installing/updating even if they have updates available,
// so we need to handle those first
val installingApps =
appInstallStates.mapNotNull { (packageName, state) ->
if (state is InstallStateWithInfo) {
val keep =
searchQuery.isBlank() || state.name.normalize().contains(searchQuery, ignoreCase = true)
if (keep) {
processedPackageNames.add(packageName)
InstallingAppItem(packageName, state)
} else null
} else {
null
}
}
val updates =
appUpdates?.filter {
val installingApps = appInstallStates.mapNotNull { (packageName, state) ->
if (state is InstallStateWithInfo) {
val keep =
if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
} else {
it.packageName !in processedPackageNames &&
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
if (keep) processedPackageNames.add(it.packageName)
keep
searchQuery.isBlank() || state.name.normalize().contains(searchQuery, ignoreCase = true)
if (keep) {
processedPackageNames.add(packageName)
InstallingAppItem(packageName, state)
} else null
} else {
null
}
val withIssues =
appsWithIssues?.filter {
val keep =
if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
} else {
it.packageName !in processedPackageNames &&
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
if (keep) processedPackageNames.add(it.packageName)
keep
}
val installed =
installedApps?.filter {
}
val updates = appUpdates?.filter {
val keep =
if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
} else {
it.packageName !in processedPackageNames &&
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
if (keep) processedPackageNames.add(it.packageName)
keep
}
val withIssues = appsWithIssues?.filter {
val keep =
if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
} else {
it.packageName !in processedPackageNames &&
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
if (keep) processedPackageNames.add(it.packageName)
keep
}
val installed = installedApps?.filter {
if (searchQuery.isBlank()) {
it.packageName !in processedPackageNames
} else {
it.packageName !in processedPackageNames &&
it.name.normalize().contains(searchQuery, ignoreCase = true)
}
}
var updateBytes: Long? = 0L
updates?.forEach {
val size = it.update.size
@@ -102,6 +99,9 @@ fun MyAppsPresenter(
appUpdates = updates?.sort(sortOrder),
appsWithIssue = withIssues?.sort(sortOrder),
installedApps = installed?.sort(sortOrder),
showUpdatesHint =
showUpdatesHintFlow.collectAsState().value &&
installingApps.none { it.installState.showProgress },
showAppIssueHint = showAppIssueHintFlow.collectAsState().value,
sortOrder = sortOrder,
networkState = networkStateFlow.collectAsState().value,
@@ -99,6 +99,7 @@ constructor(
appInstallStatesFlow = appInstallManager.appInstallStates,
appsWithIssuesFlow = updatesManager.appsWithIssues,
installedAppsFlow = installedAppItems,
showUpdatesHintFlow = onboardingManager.showMyAppsUpdatesHint,
showAppIssueHintFlow = onboardingManager.showAppIssueHint,
searchQueryFlow = searchQuery,
sortOrderFlow = sortOrder,
@@ -139,6 +140,8 @@ constructor(
updatesManager.loadUpdates()
}
override fun onUpdatesHintSeen() = onboardingManager.onMyAppsUpdateHintSeen()
override fun onAppIssueHintSeen() = onboardingManager.onAppIssueHintSeen()
override fun exportInstalledApps() {
@@ -61,7 +61,7 @@ import org.fdroid.ui.FDroidContent
import org.fdroid.ui.search.TopSearchBar
import org.fdroid.ui.utils.BackButton
import org.fdroid.ui.utils.BigLoadingIndicator
import org.fdroid.ui.utils.OnboardingCard
import org.fdroid.ui.utils.OnboardingPopupCard
import org.fdroid.ui.utils.TopAppBarButton
import org.fdroid.ui.utils.appListItems
import org.fdroid.ui.utils.getAppListInfo
@@ -81,7 +81,7 @@ fun AppList(
val hintController = rememberHintController(overlay = getHintOverlayColor())
val hint = rememberHint {
OnboardingCard(
OnboardingPopupCard(
title = stringResource(R.string.onboarding_app_list_filter_title),
message = stringResource(R.string.onboarding_app_list_filter_message),
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
@@ -43,7 +43,7 @@ import org.fdroid.R
import org.fdroid.download.NetworkState
import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.BigLoadingIndicator
import org.fdroid.ui.utils.OnboardingCard
import org.fdroid.ui.utils.OnboardingPopupCard
import org.fdroid.ui.utils.getHintOverlayColor
import org.fdroid.ui.utils.getRepositoriesInfo
import org.fdroid.ui.utils.repoItems
@@ -54,7 +54,7 @@ fun Repositories(info: RepositoryInfo, isBigScreen: Boolean, onBackClicked: () -
val hintController = rememberHintController(overlay = getHintOverlayColor())
val hint = rememberHint {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
OnboardingCard(
OnboardingPopupCard(
title = stringResource(R.string.repo_list_info_title),
message = stringResource(R.string.repo_list_info_text),
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
@@ -39,7 +39,7 @@ import org.fdroid.ui.FDroidContent
import org.fdroid.ui.utils.BackButton
import org.fdroid.ui.utils.BigLoadingIndicator
import org.fdroid.ui.utils.MeteredConnectionDialog
import org.fdroid.ui.utils.OnboardingCard
import org.fdroid.ui.utils.OnboardingPopupCard
import org.fdroid.ui.utils.TopAppBarButton
import org.fdroid.ui.utils.TopAppBarOverflowButton
import org.fdroid.ui.utils.getHintOverlayColor
@@ -58,7 +58,7 @@ fun RepoDetails(
val hintController = rememberHintController(overlay = getHintOverlayColor())
val hint = rememberHint {
Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) {
OnboardingCard(
OnboardingPopupCard(
title = stringResource(R.string.repo_details),
message = stringResource(R.string.repo_details_info_text),
modifier = Modifier.padding(horizontal = 32.dp, vertical = 8.dp),
@@ -1,5 +1,8 @@
package org.fdroid.ui.utils
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.ElevatedCard
@@ -20,16 +23,38 @@ import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.ui.FDroidContent
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun OnboardingPopupCard(
title: String,
message: String,
modifier: Modifier = Modifier,
buttonText: String = stringResource(R.string.got_it),
onGotIt: () -> Unit = {},
) {
val focusRequester = remember { FocusRequester() }
OnboardingCard(
title = title,
message = message,
modifier = modifier.widthIn(max = TooltipDefaults.richTooltipMaxWidth),
buttonText = buttonText,
buttonModifier = Modifier.focusRequester(focusRequester),
onGotIt = onGotIt,
)
LaunchedEffect(Unit) { focusRequester.requestFocus() }
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
fun OnboardingCard(
title: String,
message: String,
modifier: Modifier = Modifier,
buttonText: String = stringResource(R.string.got_it),
buttonModifier: Modifier = Modifier,
onGotIt: () -> Unit = {},
) {
val focusRequester = remember { FocusRequester() }
ElevatedCard(modifier = modifier.widthIn(max = TooltipDefaults.richTooltipMaxWidth)) {
ElevatedCard(modifier = modifier) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
@@ -40,15 +65,15 @@ fun OnboardingCard(
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 16.dp),
)
TextButton(
onClick = onGotIt,
modifier =
Modifier.focusRequester(focusRequester).padding(vertical = 8.dp, horizontal = 16.dp),
) {
Text(text = stringResource(R.string.got_it))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(
onClick = onGotIt,
modifier = buttonModifier.padding(vertical = 8.dp, horizontal = 16.dp),
) {
Text(text = buttonText)
}
}
}
LaunchedEffect(Unit) { focusRequester.requestFocus() }
}
@Preview
@@ -407,6 +407,8 @@ fun getMyAppsInfo(model: MyAppsModel): MyAppsInfo =
override fun ignoreAppIssue(item: AppWithIssueItem) {}
override fun onUpdatesHintSeen() {}
override fun onAppIssueHintSeen() {}
override fun exportInstalledApps() {}
@@ -495,6 +497,7 @@ internal val myAppsModel =
lastUpdated = System.currentTimeMillis() - DAYS.toMillis(3),
),
),
showUpdatesHint = true,
showAppIssueHint = true,
sortOrder = AppListSortOrder.NAME,
networkState = NetworkState(isOnline = false, isMetered = false),
+3
View File
@@ -56,6 +56,7 @@
<string name="onboarding_app_list_filter_message">Use filters to only show apps from specific categories or repositories. You can also change the sort order.</string>
<string name="got_it">Got it</string>
<string name="clear">Clear</string>
<string name="hide">Hide</string>
<string name="category_group_productivity">Productivity</string>
<string name="category_group_tools">Tools</string>
@@ -295,6 +296,8 @@ This often occurs with apps installed via Google Play or other sources, if they
<string name="app_list__dismiss_downloading_app">Download canceled</string>
<string name="installed_apps__activity_title">Installed apps</string>
<string name="apps_updates_hint_title">Congratulations 🥳</string>
<string name="apps_updates_hint_text">All your apps are up to date</string>
<!-- Should be exactly the same as the standard word for "Share" throughout Android and apps -->
<string name="send_installed_apps">Share installed apps</string>
<string name="send_installed_apps_csv">Apps installed by F-Droid as CSV file</string>
@@ -22,6 +22,7 @@ fun MyAppsOnlyInstalledAppsAndNoInternet() {
installingApps = emptyList(),
appsWithIssue = null,
installedApps = getInstalledApps(),
showUpdatesHint = true,
showAppIssueHint = false,
sortOrder = AppListSortOrder.NAME,
networkState = NetworkState(isOnline = true, isMetered = false),
@@ -46,6 +47,7 @@ fun MyAppsInstalledAndIssues() {
installingApps = emptyList(),
appsWithIssue = getAppIssues(),
installedApps = getInstalledApps(),
showUpdatesHint = false,
showAppIssueHint = true,
sortOrder = AppListSortOrder.NAME,
networkState = NetworkState(isOnline = false, isMetered = false),
@@ -70,6 +72,7 @@ fun MyAppsUpdatesOnlyAndNoInternet() {
installingApps = emptyList(),
appsWithIssue = null,
installedApps = emptyList(),
showUpdatesHint = false,
showAppIssueHint = false,
sortOrder = AppListSortOrder.LAST_UPDATED,
networkState = NetworkState(isOnline = false, isMetered = false),
@@ -119,6 +122,7 @@ fun MyAppsFullList() {
installingApps = installingApps,
appsWithIssue = getAppIssues(),
installedApps = getInstalledApps(),
showUpdatesHint = false,
showAppIssueHint = false,
sortOrder = AppListSortOrder.NAME,
networkState = NetworkState(isOnline = true, isMetered = false),
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:037f0d24d270c8fb52ac88bff5f40c999ea11035d03e270b6fd7968ad24506a5
size 67807
oid sha256:d915f7d6abf21e98310c1806371c071eb04e62d9104a5f6c06d933f1fffbb237
size 88468