From 4800be787cec57289ed1392f3506147f9bfbe80e Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Sat, 25 Oct 2025 11:23:47 -0400 Subject: [PATCH] Add support for Pull to Refresh on the Home screen. --- app/build.gradle.kts | 1 + .../mozilla/tryfox/ui/screens/HomeScreen.kt | 69 ++++++----- .../tryfox/ui/screens/HomeViewModel.kt | 114 ++++++++++-------- gradle/libs.versions.toml | 2 + 4 files changed, 109 insertions(+), 77 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3ca89db..2b9c88a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -107,6 +107,7 @@ dependencies { implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material) implementation(libs.androidx.compose.material3.adaptive.navigation.suite) // Navigation Compose diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt index 7aaf9ca..7ed25fa 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt @@ -2,17 +2,19 @@ package org.mozilla.tryfox.ui.screens import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer 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.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Search +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -47,7 +49,7 @@ import org.mozilla.tryfox.ui.models.AppUiModel import org.mozilla.tryfox.util.parseDateToMillis import java.io.File -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable fun HomeScreen( modifier: Modifier = Modifier, @@ -56,6 +58,8 @@ fun HomeScreen( homeViewModel: HomeViewModel = viewModel(), ) { val screenState by homeViewModel.homeScreenState.collectAsState() + val isRefreshing by homeViewModel.isRefreshing.collectAsState() + val pullRefreshState = rememberPullRefreshState(isRefreshing, { homeViewModel.refreshData() }) LaunchedEffect(Unit) { homeViewModel.initialLoad() @@ -111,33 +115,36 @@ fun HomeScreen( ) }, ) { innerPadding -> - when (val currentScreenState = screenState) { - is HomeScreenState.InitialLoading -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentAlignment = Alignment.Center, - ) { - CircularProgressIndicator() - Text( - stringResource(id = R.string.home_loading_initial_data), - modifier = Modifier.padding(top = 70.dp), // Adjust as needed to place below indicator - ) + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .pullRefresh(pullRefreshState), + ) { + when (val currentScreenState = screenState) { + is HomeScreenState.InitialLoading -> { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + Text( + stringResource(id = R.string.home_loading_initial_data), + modifier = Modifier.padding(top = 70.dp), // Adjust as needed to place below indicator + ) + } } - } - is HomeScreenState.Loaded -> { - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, - ) { - Spacer(modifier = Modifier.height(16.dp)) - LazyColumn(modifier = Modifier.fillMaxWidth()) { + is HomeScreenState.Loaded -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + item { Spacer(modifier = Modifier.height(16.dp)) } items(currentScreenState.apps.values.toList()) { app -> AppComponent( app = app, @@ -157,6 +164,12 @@ fun HomeScreen( } } } + + PullRefreshIndicator( + refreshing = isRefreshing, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + ) } } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt index afee49c..9c1eb74 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt @@ -51,6 +51,9 @@ class HomeViewModel( private val _homeScreenState = MutableStateFlow(HomeScreenState.InitialLoading) val homeScreenState: StateFlow = _homeScreenState.asStateFlow() + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + private val deviceSupportedAbis: List by lazy { deviceSupportedAbisForTesting ?: Build.SUPPORTED_ABIS?.toList() ?: emptyList() } @@ -108,66 +111,79 @@ class HomeViewModel( fun initialLoad() { viewModelScope.launch { _homeScreenState.value = HomeScreenState.InitialLoading + _isRefreshing.value = true cacheManager.checkCacheStatus() // Initial check + fetchData() + _isRefreshing.value = false + } + } - val appInfoMap = mapOf( - FENIX to mozillaPackageManager.fenix, - FOCUS to mozillaPackageManager.focus, - REFERENCE_BROWSER to mozillaPackageManager.referenceBrowser, - ) - - _homeScreenState.update { - val currentCacheState = cacheManager.cacheState.value - val initialApps = appInfoMap.mapValues { (appName, appState) -> - AppUiModel( - name = appName, - packageName = appState.packageName, - installedVersion = appState.version, - installedDate = appState.formattedInstallDate, - apks = ApksResult.Loading, - ) - } - HomeScreenState.Loaded( - apps = initialApps, - cacheManagementState = currentCacheState, - isDownloadingAnyFile = false, - ) - } + fun refreshData() { + viewModelScope.launch { + _isRefreshing.value = true + fetchData() + _isRefreshing.value = false + } + } - val results = mapOf( - FENIX to mozillaArchiveRepository.getFenixNightlyBuilds(), - FOCUS to mozillaArchiveRepository.getFocusNightlyBuilds(), - REFERENCE_BROWSER to mozillaArchiveRepository.getReferenceBrowserNightlyBuilds(), - ) + private suspend fun fetchData() { + val appInfoMap = mapOf( + FENIX to mozillaPackageManager.fenix, + FOCUS to mozillaPackageManager.focus, + REFERENCE_BROWSER to mozillaPackageManager.referenceBrowser, + ) - val newApps = results.mapValues { (appName, result) -> - val appState = appInfoMap[appName] - val apksResult = when (result) { - is NetworkResult.Success -> { - val latestApks = getLatestApks(result.data) - ApksResult.Success(convertParsedApksToUiModels(latestApks)) - } - is NetworkResult.Error -> ApksResult.Error("Error fetching $appName nightly builds: ${result.message}") - } + _homeScreenState.update { + val currentCacheState = cacheManager.cacheState.value + val initialApps = appInfoMap.mapValues { (appName, appState) -> AppUiModel( name = appName, - packageName = appState?.packageName ?: "", - installedVersion = appState?.version, - installedDate = appState?.formattedInstallDate, - apks = apksResult, + packageName = appState.packageName, + installedVersion = appState.version, + installedDate = appState.formattedInstallDate, + apks = ApksResult.Loading, ) } + HomeScreenState.Loaded( + apps = initialApps, + cacheManagementState = currentCacheState, + isDownloadingAnyFile = false, + ) + } - val isDownloading = newApps.values.any { app -> - (app.apks as? ApksResult.Success)?.apks?.any { it.downloadState is DownloadState.InProgress } == true - } + val results = mapOf( + FENIX to mozillaArchiveRepository.getFenixNightlyBuilds(), + FOCUS to mozillaArchiveRepository.getFocusNightlyBuilds(), + REFERENCE_BROWSER to mozillaArchiveRepository.getReferenceBrowserNightlyBuilds(), + ) - _homeScreenState.update { - if (it is HomeScreenState.Loaded) { - it.copy(apps = newApps, isDownloadingAnyFile = isDownloading) - } else { - it + val newApps = results.mapValues { (appName, result) -> + val appState = appInfoMap[appName] + val apksResult = when (result) { + is NetworkResult.Success -> { + val latestApks = getLatestApks(result.data) + ApksResult.Success(convertParsedApksToUiModels(latestApks)) } + is NetworkResult.Error -> ApksResult.Error("Error fetching $appName nightly builds: ${result.message}") + } + AppUiModel( + name = appName, + packageName = appState?.packageName ?: "", + installedVersion = appState?.version, + installedDate = appState?.formattedInstallDate, + apks = apksResult, + ) + } + + val isDownloading = newApps.values.any { app -> + (app.apks as? ApksResult.Success)?.apks?.any { it.downloadState is DownloadState.InProgress } == true + } + + _homeScreenState.update { + if (it is HomeScreenState.Loaded) { + it.copy(apps = newApps, isDownloadingAnyFile = isDownloading) + } else { + it } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0682c30..37a0b86 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ mockitoKotlin = "6.0.0" logcat = "0.4" espressoIntents = "3.7.0" # Added javax.inject version koin = "4.1.1" +composeMaterial = "1.6.8" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -45,6 +46,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material = { group = "androidx.compose.material", name = "material", version.ref = "composeMaterial" } androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }