From 6a215b210048550b8f20fa59bf74e8982c9676f7 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 17 Feb 2026 23:26:25 +0500 Subject: [PATCH 1/7] feat(details): Implement release picker and fetch all releases This commit introduces a version picker on the details screen, allowing users to select between stable, pre-release, and all available releases for an application. Previously, only the latest published release was fetched and displayed. Now, the app fetches a list of all releases, automatically selects the latest stable version by default, and provides UI for the user to switch to other versions. - **feat(details)**: Fetched all releases instead of just the latest. The view now defaults to the latest stable release, falling back to the most recent pre-release if no stable version is available. - **feat(details)**: Added a version picker component (`VersionPicker.kt`) to the details screen, which allows users to filter and select from stable releases, pre-releases, or all available versions. - **refactor(details)**: Updated `DetailsViewModel` and `DetailsState` to manage the list of all releases, the selected release, and the state of the version picker. - **refactor(data)**: Implemented `getAllReleases` in `DetailsRepository` to retrieve up to 30 recent releases from the GitHub API. - **chore(i18n)**: Added new string resources for the version picker UI (e.g., "Stable", "Pre-release", "Select version"). --- .../core/data/mappers/ReleaseNetwork.kt | 3 +- .../core/domain/model/GithubRelease.kt | 3 +- .../composeResources/values/strings.xml | 9 ++ .../data/repository/DetailsRepositoryImpl.kt | 57 +++++++--- .../domain/repository/DetailsRepository.kt | 6 ++ .../details/presentation/DetailsAction.kt | 7 ++ .../details/presentation/DetailsRoot.kt | 4 +- .../details/presentation/DetailsState.kt | 16 ++- .../details/presentation/DetailsViewModel.kt | 102 ++++++++++++++---- .../components/SmartInstallButton.kt | 2 +- .../components/sections/Header.kt | 15 ++- .../components/sections/WhatsNew.kt | 8 +- 12 files changed, 183 insertions(+), 49 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/ReleaseNetwork.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/ReleaseNetwork.kt index e56828a8..cb9ba30c 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/ReleaseNetwork.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/ReleaseNetwork.kt @@ -19,5 +19,6 @@ fun ReleaseNetwork.toDomain(): GithubRelease = GithubRelease( assets = assets.map { it.toDomain() }, tarballUrl = tarballUrl, zipballUrl = zipballUrl, - htmlUrl = htmlUrl + htmlUrl = htmlUrl, + isPrerelease = prerelease == true ) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubRelease.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubRelease.kt index bc307b94..56c0a295 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubRelease.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/GithubRelease.kt @@ -10,5 +10,6 @@ data class GithubRelease( val assets: List, val tarballUrl: String, val zipballUrl: String, - val htmlUrl: String + val htmlUrl: String, + val isPrerelease: Boolean = false ) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index dc6d2b37..9654f420 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -335,4 +335,13 @@ Search Apps Profile + + + Stable + Pre-release + All + Select version + Pre-release + No version + Versions \ No newline at end of file diff --git a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt index 1baeb22b..d29ba1e6 100644 --- a/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt +++ b/feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt @@ -91,21 +91,50 @@ class DetailsRepositoryImpl( .maxByOrNull { it.publishedAt ?: it.createdAt ?: "" } ?: return null - val processedLatestRelease = latest.copy( - body = latest.body?.replace("
", "") - ?.replace("
", "") - ?.replace("", "") - ?.replace("", "") - ?.replace("\r\n", "\n") - ?.let { rawMarkdown -> - preprocessMarkdown( - markdown = rawMarkdown, - baseUrl = "https://raw.githubusercontent.com/$owner/$repo/${defaultBranch}/" - ) - } - ) + return latest.copy( + body = processReleaseBody(latest.body, owner, repo, defaultBranch) + ).toDomain() + } - return processedLatestRelease.toDomain() + override suspend fun getAllReleases( + owner: String, + repo: String, + defaultBranch: String + ): List { + val releases = httpClient.executeRequest> { + get("/repos/$owner/$repo/releases") { + header(HttpHeaders.Accept, "application/vnd.github+json") + parameter("per_page", 30) + } + }.getOrNull() ?: return emptyList() + + return releases + .filter { it.draft != true } + .map { release -> + release.copy( + body = processReleaseBody(release.body, owner, repo, defaultBranch) + ).toDomain() + } + .sortedByDescending { it.publishedAt } + } + + private fun processReleaseBody( + body: String?, + owner: String, + repo: String, + defaultBranch: String + ): String? { + return body?.replace("
", "") + ?.replace("
", "") + ?.replace("", "") + ?.replace("", "") + ?.replace("\r\n", "\n") + ?.let { rawMarkdown -> + preprocessMarkdown( + markdown = rawMarkdown, + baseUrl = "https://raw.githubusercontent.com/$owner/$repo/${defaultBranch}/" + ) + } } diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt index 1399f77a..4863e79e 100644 --- a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt @@ -20,6 +20,12 @@ interface DetailsRepository { defaultBranch: String ): GithubRelease? + suspend fun getAllReleases( + owner: String, + repo: String, + defaultBranch: String + ): List + suspend fun getReadme( owner: String, repo: String, diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt index e2384989..89b935ef 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt @@ -1,6 +1,8 @@ package zed.rainxch.details.presentation import org.jetbrains.compose.resources.StringResource +import zed.rainxch.core.domain.model.GithubRelease +import zed.rainxch.details.domain.model.ReleaseCategory sealed interface DetailsAction { data object Retry : DetailsAction @@ -28,4 +30,9 @@ sealed interface DetailsAction { data object UpdateApp : DetailsAction data class OnMessage(val messageText: StringResource) : DetailsAction + + // Version picker + data class SelectReleaseCategory(val category: ReleaseCategory) : DetailsAction + data class SelectRelease(val release: GithubRelease) : DetailsAction + data object ToggleVersionPicker : DetailsAction } \ No newline at end of file diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt index cce5b943..396b922e 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt @@ -196,8 +196,8 @@ fun DetailsScreen( ) } - state.latestRelease?.let { latestRelease -> - whatsNew(latestRelease) + state.selectedRelease?.let { release -> + whatsNew(release) } state.userProfile?.let { userProfile -> diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt index 2f8414c5..e5c7f9de 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt @@ -6,6 +6,7 @@ import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.GithubAsset import zed.rainxch.core.domain.model.GithubUserProfile import zed.rainxch.core.domain.model.InstalledApp +import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.domain.model.RepoStats import zed.rainxch.details.presentation.model.DownloadStage import zed.rainxch.details.presentation.model.InstallLogItem @@ -15,11 +16,15 @@ data class DetailsState( val errorMessage: String? = null, val repository: GithubRepoSummary? = null, - val latestRelease: GithubRelease? = null, + val selectedRelease: GithubRelease? = null, val installableAssets: List = emptyList(), val primaryAsset: GithubAsset? = null, val userProfile: GithubUserProfile? = null, + val allReleases: List = emptyList(), + val selectedReleaseCategory: ReleaseCategory = ReleaseCategory.STABLE, + val isVersionPickerVisible: Boolean = false, + val stats: RepoStats? = null, val readmeMarkdown: String? = null, val readmeLanguage: String? = null, @@ -46,4 +51,11 @@ data class DetailsState( val installedApp: InstalledApp? = null, val isFavourite: Boolean = false, val isStarred: Boolean = false, -) \ No newline at end of file +) { + val filteredReleases: List + get() = when (selectedReleaseCategory) { + ReleaseCategory.STABLE -> allReleases.filter { !it.isPrerelease } + ReleaseCategory.PRE_RELEASE -> allReleases.filter { it.isPrerelease } + ReleaseCategory.ALL -> allReleases + } +} diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index 3359ff58..d087a8d3 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -22,6 +22,8 @@ import kotlinx.datetime.toLocalDateTime import org.jetbrains.compose.resources.getString import zed.rainxch.core.domain.logging.GitHubStoreLogger import zed.rainxch.core.domain.model.FavoriteRepo +import zed.rainxch.core.domain.model.GithubAsset +import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.Platform @@ -34,6 +36,7 @@ import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase import zed.rainxch.core.domain.utils.BrowserHelper +import zed.rainxch.details.domain.model.ReleaseCategory import zed.rainxch.details.domain.repository.DetailsRepository import zed.rainxch.details.presentation.model.DownloadStage import zed.rainxch.details.presentation.model.InstallLogItem @@ -82,6 +85,16 @@ class DetailsViewModel( private val rateLimited = AtomicBoolean(false) + private fun recomputeAssetsForRelease( + release: GithubRelease? + ): Pair, GithubAsset?> { + val installable = release?.assets?.filter { asset -> + installer.isAssetInstallable(asset.name) + }.orEmpty() + val primary = installer.choosePrimaryAsset(installable) + return installable to primary + } + @OptIn(ExperimentalTime::class) private fun loadInitial() { viewModelScope.launch { @@ -134,17 +147,17 @@ class DetailsViewModel( isStarred = isStarred == true, ) - val latestReleaseDeferred = async { + val allReleasesDeferred = async { try { - detailsRepository.getLatestPublishedRelease( + detailsRepository.getAllReleases( owner = owner, repo = name, defaultBranch = repo.defaultBranch ) } catch (_: RateLimitException) { rateLimited.set(true) - null + emptyList() } catch (t: Throwable) { - logger.warn("Failed to load latest release: ${t.message}") - null + logger.warn("Failed to load releases: ${t.message}") + emptyList() } } @@ -217,7 +230,7 @@ class DetailsViewModel( val isObtainiumEnabled = platform == Platform.ANDROID val isAppManagerEnabled = platform == Platform.ANDROID - val latestRelease = latestReleaseDeferred.await() + val allReleases = allReleasesDeferred.await() val stats = statsDeferred.await() val readme = readmeDeferred.await() val userProfile = userProfileDeferred.await() @@ -228,11 +241,11 @@ class DetailsViewModel( return@launch } - val installable = latestRelease?.assets?.filter { asset -> - installer.isAssetInstallable(asset.name) - }.orEmpty() + // Auto-select latest stable, fall back to first release if no stable exists + val selectedRelease = allReleases.firstOrNull { !it.isPrerelease } + ?: allReleases.firstOrNull() - val primary = installer.choosePrimaryAsset(installable) + val (installable, primary) = recomputeAssetsForRelease(selectedRelease) val isObtainiumAvailable = installer.isObtainiumInstalled() val isAppManagerAvailable = installer.isAppManagerInstalled() @@ -243,7 +256,9 @@ class DetailsViewModel( isLoading = false, errorMessage = null, repository = repo, - latestRelease = latestRelease, + allReleases = allReleases, + selectedRelease = selectedRelease, + selectedReleaseCategory = ReleaseCategory.STABLE, stats = stats, readmeMarkdown = readme?.first, readmeLanguage = readme?.second, @@ -283,7 +298,7 @@ class DetailsViewModel( DetailsAction.InstallPrimary -> { val primary = _state.value.primaryAsset - val release = _state.value.latestRelease + val release = _state.value.selectedRelease if (primary != null && release != null) { installAsset( downloadUrl = primary.downloadUrl, @@ -295,7 +310,7 @@ class DetailsViewModel( } is DetailsAction.DownloadAsset -> { - val release = _state.value.latestRelease + val release = _state.value.selectedRelease downloadAsset( downloadUrl = action.downloadUrl, assetName = action.assetName, @@ -318,7 +333,7 @@ class DetailsViewModel( appendLog( assetName = assetName, size = 0L, - tag = _state.value.latestRelease?.tagName ?: "", + tag = _state.value.selectedRelease?.tagName ?: "", result = LogResult.Cancelled ) } catch (t: Throwable) { @@ -339,7 +354,7 @@ class DetailsViewModel( viewModelScope.launch { try { val repo = _state.value.repository ?: return@launch - val latestRelease = _state.value.latestRelease + val selectedRelease = _state.value.selectedRelease val favoriteRepo = FavoriteRepo( repoId = repo.id, @@ -349,8 +364,8 @@ class DetailsViewModel( repoDescription = repo.description, primaryLanguage = repo.language, repoUrl = repo.htmlUrl, - latestVersion = latestRelease?.tagName, - latestReleaseUrl = latestRelease?.htmlUrl, + latestVersion = selectedRelease?.tagName, + latestReleaseUrl = selectedRelease?.htmlUrl, addedAt = System.now().toEpochMilliseconds(), lastSyncedAt = System.now().toEpochMilliseconds() ) @@ -400,9 +415,9 @@ class DetailsViewModel( DetailsAction.UpdateApp -> { val installedApp = _state.value.installedApp - val latestRelease = _state.value.latestRelease + val selectedRelease = _state.value.selectedRelease - if (installedApp != null && latestRelease != null && installedApp.isUpdateAvailable) { + if (installedApp != null && selectedRelease != null && installedApp.isUpdateAvailable) { val latestAsset = _state.value.installableAssets.firstOrNull { it.name == installedApp.latestAssetName } ?: _state.value.primaryAsset @@ -412,7 +427,7 @@ class DetailsViewModel( downloadUrl = latestAsset.downloadUrl, assetName = latestAsset.name, sizeBytes = latestAsset.size, - releaseTag = latestRelease.tagName, + releaseTag = selectedRelease.tagName, isUpdate = true ) } @@ -455,7 +470,7 @@ class DetailsViewModel( viewModelScope.launch { try { val primary = _state.value.primaryAsset - val release = _state.value.latestRelease + val release = _state.value.selectedRelease if (primary != null && release != null) { currentAssetName = primary.name @@ -523,7 +538,7 @@ class DetailsViewModel( currentAssetName = null _state.value.primaryAsset?.let { asset -> - _state.value.latestRelease?.let { release -> + _state.value.selectedRelease?.let { release -> appendLog( assetName = asset.name, size = asset.size, @@ -545,6 +560,47 @@ class DetailsViewModel( } } + // Version picker actions + is DetailsAction.SelectReleaseCategory -> { + val newCategory = action.category + val filtered = when (newCategory) { + ReleaseCategory.STABLE -> _state.value.allReleases.filter { !it.isPrerelease } + ReleaseCategory.PRE_RELEASE -> _state.value.allReleases.filter { it.isPrerelease } + ReleaseCategory.ALL -> _state.value.allReleases + } + val newSelected = filtered.firstOrNull() + val (installable, primary) = recomputeAssetsForRelease(newSelected) + + _state.update { + it.copy( + selectedReleaseCategory = newCategory, + selectedRelease = newSelected, + installableAssets = installable, + primaryAsset = primary + ) + } + } + + is DetailsAction.SelectRelease -> { + val release = action.release + val (installable, primary) = recomputeAssetsForRelease(release) + + _state.update { + it.copy( + selectedRelease = release, + installableAssets = installable, + primaryAsset = primary, + isVersionPickerVisible = false + ) + } + } + + DetailsAction.ToggleVersionPicker -> { + _state.update { + it.copy(isVersionPickerVisible = !it.isVersionPickerVisible) + } + } + DetailsAction.OnNavigateBackClick -> { // Handled in composable } @@ -860,4 +916,4 @@ class DetailsViewModel( const val OBTAINIUM_REPO_ID: Long = 523534328 const val APP_MANAGER_REPO_ID: Long = 268006778 } -} \ No newline at end of file +} diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt index 6c28f5cd..8b1f372a 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt @@ -78,7 +78,7 @@ fun SmartInstallButton( val buttonText = when { !enabled && primaryAsset == null -> stringResource(Res.string.not_available) - installedApp != null && installedApp.installedVersion != state.latestRelease?.tagName -> stringResource( + installedApp != null && installedApp.installedVersion != state.selectedRelease?.tagName -> stringResource( Res.string.update_app ) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt index 8bddb5b9..4fe3e2ef 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Header.kt @@ -25,6 +25,7 @@ import zed.rainxch.details.presentation.DetailsAction import zed.rainxch.details.presentation.DetailsState import zed.rainxch.details.presentation.components.AppHeader import zed.rainxch.details.presentation.components.SmartInstallButton +import zed.rainxch.details.presentation.components.VersionPicker import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState fun LazyListScope.header( @@ -37,7 +38,7 @@ fun LazyListScope.header( if (state.repository != null) { AppHeader( author = state.userProfile, - release = state.latestRelease, + release = state.selectedRelease, repository = state.repository, installedApp = state.installedApp, downloadStage = state.downloadStage, @@ -47,6 +48,18 @@ fun LazyListScope.header( } } + if (state.allReleases.isNotEmpty()) { + item { + VersionPicker( + selectedRelease = state.selectedRelease, + selectedCategory = state.selectedReleaseCategory, + filteredReleases = state.filteredReleases, + isPickerVisible = state.isVersionPickerVisible, + onAction = onAction + ) + } + } + item { val liquidState = LocalTopbarLiquidState.current diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt index e8471023..59b83af1 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/WhatsNew.kt @@ -29,7 +29,7 @@ import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState import zed.rainxch.details.presentation.utils.rememberMarkdownColors import zed.rainxch.details.presentation.utils.rememberMarkdownTypography -fun LazyListScope.whatsNew(latestRelease: GithubRelease) { +fun LazyListScope.whatsNew(release: GithubRelease) { item { val liquidState = LocalTopbarLiquidState.current @@ -66,14 +66,14 @@ fun LazyListScope.whatsNew(latestRelease: GithubRelease) { verticalAlignment = Alignment.CenterVertically ) { Text( - latestRelease.tagName, + release.tagName, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.liquefiable(liquidState) ) Text( - latestRelease.publishedAt.take(10), + release.publishedAt.take(10), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.liquefiable(liquidState) @@ -87,7 +87,7 @@ fun LazyListScope.whatsNew(latestRelease: GithubRelease) { val flavour = remember { GFMFlavourDescriptor() } Markdown( - content = latestRelease.description ?: stringResource(Res.string.no_release_notes), + content = release.description ?: stringResource(Res.string.no_release_notes), colors = colors, typography = typography, flavour = flavour, From 8b687143f1e73dfd3981ce6354be041d09c3a4b3 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 17 Feb 2026 23:32:50 +0500 Subject: [PATCH 2/7] i18n: Add translations for version picker and repository labels This commit introduces new string translations across multiple languages for UI elements related to the version picker and repository status. - **feat(i18n)**: Added translations for the version picker, including categories ("Stable", "Pre-release", "All"), titles ("Versions", "Select version"), and states ("No version selected"). - **feat(i18n)**: Added a "Pre-release" badge label. - **feat(i18n)**: Added a translation for the "Forked repository" label. --- .../composeResources/values-bn/strings-bn.xml | 10 ++++++++++ .../composeResources/values-es/strings-es.xml | 10 ++++++++++ .../composeResources/values-fr/strings-fr.xml | 10 ++++++++++ .../composeResources/values-hi/strings-hi.xml | 10 ++++++++++ .../composeResources/values-it/strings-it.xml | 10 ++++++++++ .../composeResources/values-ja/strings-ja.xml | 10 ++++++++++ .../composeResources/values-kr/strings-kr.xml | 10 ++++++++++ .../composeResources/values-pl/strings-pl.xml | 10 ++++++++++ .../composeResources/values-ru/strings-ru.xml | 10 ++++++++++ .../composeResources/values-tr/strings-tr.xml | 10 ++++++++++ .../composeResources/values-zh-rCN/strings-zh-rCN.xml | 10 ++++++++++ .../src/commonMain/composeResources/values/strings.xml | 4 ++-- 12 files changed, 112 insertions(+), 2 deletions(-) diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml index 8a9b1803..05965557 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -336,4 +336,14 @@ অ্যাপস প্রোফাইল + ফর্ক + + স্থিতিশীল + প্রি-রিলিজ + সব + ভার্সন নির্বাচন করুন + প্রি-রিলিজ + কোনো ভার্সন নির্বাচিত নয় + ভার্সনসমূহ + diff --git a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml index 38c95cd8..dec690f8 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -283,4 +283,14 @@ Aplicaciones Perfil + Bifurcar + + Estable + Prelanzamiento + Todos + Seleccionar versión + Prelanzamiento + Ninguna versión seleccionada + Versiones + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml index a3295c56..b36488e1 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -283,4 +283,14 @@ Applications Profil + Fork + + Stable + Préversion + Tous + Sélectionner une version + Préversion + Aucune version sélectionnée + Versions + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml index 80a27c0f..f9eb660b 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -333,4 +333,14 @@ खोज ऐप्स प्रोफ़ाइल + + फोर्क + + स्थिर + प्री-रिलीज़ + सभी + संस्करण चुनें + प्री-रिलीज़ + कोई संस्करण चयनित नहीं + संस्करण diff --git a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml index d7eefba2..61ecc718 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -332,4 +332,14 @@ App Profilo + Fork + + Stabile + Pre-release + Tutte + Seleziona versione + Pre-release + Nessuna versione selezionata + Versioni + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml index 4bba002b..1735bded 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -283,4 +283,14 @@ アプリ プロフィール + フォーク + + 安定版 + プレリリース + すべて + バージョンを選択 + プレリリース + バージョン未選択 + バージョン + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml index 0e4b2e07..8c724d84 100644 --- a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml +++ b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml @@ -334,4 +334,14 @@ 프로필 + 포크 + + 안정 버전 + 사전 출시 + 전체 + 버전 선택 + 사전 출시 + 선택된 버전 없음 + 버전 + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml index a4ba0270..292020c7 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -299,4 +299,14 @@ Aplikacje Profil + Fork + + Stabilna + Wersja przedpremierowa + Wszystkie + Wybierz wersję + Przedpremierowa + Nie wybrano wersji + Wersje + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml index 17375c6c..2a8c1a81 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -301,4 +301,14 @@ Приложения Профиль + Форк + + Стабильная + Предварительный релиз + Все + Выбрать версию + Предрелиз + Версия не выбрана + Версии + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml index 9fd9fe9f..a5853ef1 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -333,4 +333,14 @@ Uygulamalar Profil + Çatalla + + Kararlı + Ön sürüm + Tümü + Sürüm seç + Ön sürüm + Sürüm seçilmedi + Sürümler + diff --git a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml index 21c88168..dc9cf014 100644 --- a/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml +++ b/core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml @@ -284,4 +284,14 @@ 应用 个人资料 + 分叉 + + 稳定版 + 预发布 + 全部 + 选择版本 + 预发布 + 未选择版本 + 版本 + \ No newline at end of file diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 9654f420..a409e888 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -329,13 +329,13 @@ Released %1$d day(s) ago Released on %1$s - Fork - Home Search Apps Profile + Fork + Stable Pre-release From 5cf5390080b50691ee2fc8eacd5f0ec516658f8d Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Tue, 17 Feb 2026 23:33:59 +0500 Subject: [PATCH 3/7] feat(details): Implement version picker and release category filter This commit introduces a new `VersionPicker` composable in the details screen, allowing users to select a specific release from a list. It also adds a `ReleaseCategory` enum to filter releases by type (Stable, Pre-release, All). - **feat(details)!** Added `VersionPicker.kt`, a new composable that provides: - Filter chips for `Stable`, `Pre-release`, and `All` release categories. - A card that displays the currently selected version and opens a modal bottom sheet on click. - A bottom sheet that lists available versions, showing their tag, name, and publication date. It also highlights the selected version and indicates pre-releases. - **feat(details)!** Created `ReleaseCategory.kt` to define the filtering options for GitHub releases. --- .../details/domain/model/ReleaseCategory.kt | 7 + .../presentation/components/VersionPicker.kt | 242 ++++++++++++++++++ 2 files changed, 249 insertions(+) create mode 100644 feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/ReleaseCategory.kt create mode 100644 feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt diff --git a/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/ReleaseCategory.kt b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/ReleaseCategory.kt new file mode 100644 index 00000000..05a0352f --- /dev/null +++ b/feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/model/ReleaseCategory.kt @@ -0,0 +1,7 @@ +package zed.rainxch.details.domain.model + +enum class ReleaseCategory { + STABLE, + PRE_RELEASE, + ALL +} diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt new file mode 100644 index 00000000..2f5b2181 --- /dev/null +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt @@ -0,0 +1,242 @@ +package zed.rainxch.details.presentation.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.UnfoldMore +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.core.domain.model.GithubRelease +import zed.rainxch.details.domain.model.ReleaseCategory +import zed.rainxch.details.presentation.DetailsAction +import zed.rainxch.githubstore.core.presentation.res.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VersionPicker( + selectedRelease: GithubRelease?, + selectedCategory: ReleaseCategory, + filteredReleases: List, + isPickerVisible: Boolean, + onAction: (DetailsAction) -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier.fillMaxWidth()) { + // Category filter chips + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + ReleaseCategory.entries.forEach { category -> + FilterChip( + selected = category == selectedCategory, + onClick = { onAction(DetailsAction.SelectReleaseCategory(category)) }, + label = { + Text( + text = when (category) { + ReleaseCategory.STABLE -> stringResource(Res.string.category_stable) + ReleaseCategory.PRE_RELEASE -> stringResource(Res.string.category_pre_release) + ReleaseCategory.ALL -> stringResource(Res.string.category_all) + } + ) + } + ) + } + } + + Spacer(Modifier.height(8.dp)) + + // Version selector card + OutlinedCard( + onClick = { onAction(DetailsAction.ToggleVersionPicker) }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = selectedRelease?.tagName + ?: stringResource(Res.string.no_version_selected), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + selectedRelease?.name?.let { name -> + if (name != selectedRelease.tagName) { + Text( + text = name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + Icon( + imageVector = Icons.Default.UnfoldMore, + contentDescription = stringResource(Res.string.select_version), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + // Bottom sheet with version list + if (isPickerVisible) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) + + ModalBottomSheet( + onDismissRequest = { onAction(DetailsAction.ToggleVersionPicker) }, + sheetState = sheetState + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .navigationBarsPadding() + ) { + Text( + text = stringResource(Res.string.versions_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + ) + + HorizontalDivider() + + if (filteredReleases.isEmpty()) { + Text( + text = stringResource(Res.string.not_available), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(16.dp) + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items( + items = filteredReleases, + key = { it.id } + ) { release -> + VersionListItem( + release = release, + isSelected = release.id == selectedRelease?.id, + onClick = { onAction(DetailsAction.SelectRelease(release)) } + ) + } + } + } + } + } + } +} + +@Composable +private fun VersionListItem( + release: GithubRelease, + isSelected: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = release.tagName, + style = MaterialTheme.typography.titleSmall, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + } + ) + if (release.isPrerelease) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.tertiaryContainer + ) { + Text( + text = stringResource(Res.string.pre_release_badge), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + color = MaterialTheme.colorScheme.onTertiaryContainer + ) + } + } + } + + release.name?.let { name -> + if (name != release.tagName) { + Text( + text = name, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Text( + text = release.publishedAt.take(10), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline + ) + } + + if (isSelected) { + Spacer(Modifier.width(8.dp)) + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp) + ) + } + } +} From 658a85aa3ef984f0e1d26d051d00b08baa06d684 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Wed, 18 Feb 2026 06:09:41 +0500 Subject: [PATCH 4/7] feat(apps): Introduce pending install state and improve update logic This commit introduces a "pending install" state to more accurately reflect the app status between a successful download/install trigger and the system confirming the installation. This prevents users from interacting with an app that is about to be updated and improves the overall robustness of the app synchronization and update process. - **feat(apps)**: Added a `isPendingInstall` flag to `InstalledApp`. This state is now displayed in the app list, and the "Open" button is disabled for apps in this state. - **feat(apps)**: The `SyncInstalledAppsUseCase` now resolves pending installs. When a sync runs, it checks if a pending app is now present on the system and updates its version information accordingly, clearing the pending flag. - **refactor(update)**: The update process now marks an app as `pending` before initiating the installation. The database record is updated with the new version information only *after* the installation intent has been sent. - **fix(update)**: The update availability check is now more resilient. If downloading a temporary APK to check its version fails, it gracefully falls back to using the release tag name for comparison, preventing check failures due to network errors. - **feat(details)**: The version picker in the app details screen now displays a "Latest" badge next to the most recent release, improving clarity for users. --- .../repository/InstalledAppsRepositoryImpl.kt | 38 ++++++++++-------- .../use_cases/SyncInstalledAppsUseCase.kt | 37 +++++++++++++++++- .../composeResources/values/strings.xml | 2 + .../zed/rainxch/apps/presentation/AppsRoot.kt | 37 +++++++++++------- .../apps/presentation/AppsViewModel.kt | 39 +++++++------------ .../presentation/components/VersionPicker.kt | 17 ++++++++ 6 files changed, 113 insertions(+), 57 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index 5605ea03..3588fa11 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -170,28 +170,34 @@ class InstalledAppsRepositoryImpl( if (primaryAsset != null) { val tempAssetName = primaryAsset.name + ".tmp" - downloader.download(primaryAsset.downloadUrl, tempAssetName).collect { } - - val tempPath = downloader.getDownloadedFilePath(tempAssetName) - if (tempPath != null) { - val latestInfo = - installer.getApkInfoExtractor().extractPackageInfo(tempPath) - File(tempPath).delete() - - if (latestInfo != null) { - latestVersionName = latestInfo.versionName - latestVersionCode = latestInfo.versionCode - isUpdateAvailable = latestVersionCode > app.installedVersionCode + try { + downloader.download(primaryAsset.downloadUrl, tempAssetName).collect { } + + val tempPath = downloader.getDownloadedFilePath(tempAssetName) + if (tempPath != null) { + val latestInfo = + installer.getApkInfoExtractor().extractPackageInfo(tempPath) + File(tempPath).delete() + + if (latestInfo != null) { + latestVersionName = latestInfo.versionName + latestVersionCode = latestInfo.versionCode + isUpdateAvailable = latestVersionCode > app.installedVersionCode + } else { + // Couldn't extract APK info, fall back to tag comparison + latestVersionName = latestRelease.tagName + } } else { - isUpdateAvailable = false + // Download failed, fall back to tag comparison latestVersionName = latestRelease.tagName } - } else { - isUpdateAvailable = false + } catch (e: Exception) { + Logger.w { "Failed to download APK for version check of ${app.packageName}: ${e.message}" } + // Download/extraction failed, fall back to tag comparison latestVersionName = latestRelease.tagName } } else { - isUpdateAvailable = false + // No installable asset found, but tags differ so likely an update latestVersionName = latestRelease.tagName } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt index 1165a67c..b0059252 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt @@ -36,10 +36,19 @@ class SyncInstalledAppsUseCase( val toDelete = mutableListOf() val toMigrate = mutableListOf>() + val toResolvePending = mutableListOf() appsInDb.forEach { app -> + val isOnSystem = installedPackageNames.contains(app.packageName) when { - !installedPackageNames.contains(app.packageName) -> { + app.isPendingInstall -> { + if (isOnSystem) { + toResolvePending.add(app) + } + // Not on system yet but pending — keep in DB, don't delete + } + + !isOnSystem -> { toDelete.add(app.packageName) } @@ -60,6 +69,30 @@ class SyncInstalledAppsUseCase( } } + toResolvePending.forEach { app -> + try { + val systemInfo = packageMonitor.getInstalledPackageInfo(app.packageName) + if (systemInfo != null) { + installedAppsRepository.updateApp( + app.copy( + isPendingInstall = false, + isUpdateAvailable = false, + installedVersionName = systemInfo.versionName, + installedVersionCode = systemInfo.versionCode, + latestVersionName = systemInfo.versionName, + latestVersionCode = systemInfo.versionCode + ) + ) + logger.info("Resolved pending install: ${app.packageName} (v${systemInfo.versionName}, code=${systemInfo.versionCode})") + } else { + installedAppsRepository.updatePendingStatus(app.packageName, false) + logger.info("Resolved pending install (no system info): ${app.packageName}") + } + } catch (e: Exception) { + logger.error("Failed to resolve pending ${app.packageName}: ${e.message}") + } + } + toMigrate.forEach { (packageName, migrationResult) -> try { val app = appsInDb.find { it.packageName == packageName } ?: return@forEach @@ -84,7 +117,7 @@ class SyncInstalledAppsUseCase( } logger.info( - "Sync completed: ${toDelete.size} deleted, ${toMigrate.size} migrated" + "Sync completed: ${toDelete.size} deleted, ${toResolvePending.size} pending resolved, ${toMigrate.size} migrated" ) Result.success(Unit) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index a409e888..454251ae 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -169,6 +169,7 @@ Updating Verifying Installing + Pending install Open in Obtainium @@ -342,6 +343,7 @@ All Select version Pre-release + Latest No version Versions \ No newline at end of file diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt index c46eb7f0..c60feaf5 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt @@ -379,18 +379,28 @@ fun AppItemCard( color = MaterialTheme.colorScheme.onSurfaceVariant ) - if (app.isUpdateAvailable) { - Text( - text = "${app.installedVersion} → ${app.latestVersion}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary - ) - } else { - Text( - text = app.installedVersion, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + when { + app.isPendingInstall -> { + Text( + text = stringResource(Res.string.pending_install), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary + ) + } + app.isUpdateAvailable -> { + Text( + text = "${app.installedVersion} → ${app.latestVersion}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary + ) + } + else -> { + Text( + text = app.installedVersion, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } } } @@ -506,7 +516,8 @@ fun AppItemCard( Button( onClick = onOpenClick, modifier = Modifier.weight(1f), - enabled = appItem.updateState !is UpdateState.Downloading && + enabled = !app.isPendingInstall && + appItem.updateState !is UpdateState.Downloading && appItem.updateState !is UpdateState.Installing ) { Icon( diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt index c31cdac3..569a7402 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt @@ -267,18 +267,20 @@ class AppsViewModel( val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) ?: throw IllegalStateException("Failed to extract APK info") - updateAppInDatabase( - app = app, - newVersion = latestVersion, - assetName = latestAssetName, - assetUrl = latestAssetUrl, - newVersionName = apkInfo.versionName, - newVersionCode = apkInfo.versionCode - ) + markPendingUpdate(app) updateAppState(app.packageName, UpdateState.Installing) installer.install(filePath, ext) + installedAppsRepository.updateAppVersion( + packageName = app.packageName, + newTag = latestVersion, + newAssetName = latestAssetName, + newAssetUrl = latestAssetUrl, + newVersionName = apkInfo.versionName, + newVersionCode = apkInfo.versionCode + ) + updateAppState(app.packageName, UpdateState.Success) delay(2000) updateAppState(app.packageName, UpdateState.Idle) @@ -468,29 +470,14 @@ class AppsViewModel( } } - private suspend fun updateAppInDatabase( + private suspend fun markPendingUpdate( app: InstalledApp, - newVersion: String, - assetName: String, - assetUrl: String, - newVersionName: String, - newVersionCode: Long ) { try { - installedAppsRepository.updateAppVersion( - packageName = app.packageName, - newTag = newVersion, - newAssetName = assetName, - newAssetUrl = assetUrl, - newVersionName = newVersionName, - newVersionCode = newVersionCode - ) - installedAppsRepository.updatePendingStatus(app.packageName, true) - - logger.debug("Updated database for ${app.packageName} to tag $newVersion, versionName $newVersionName") + logger.debug("Marked ${app.packageName} as pending install") } catch (e: Exception) { - logger.error("Failed to update database: ${e.message}") + logger.error("Failed to mark pending update: ${e.message}") } } diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt index 2f5b2181..f17001aa 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt @@ -145,6 +145,8 @@ fun VersionPicker( modifier = Modifier.padding(16.dp) ) } else { + val latestReleaseId = filteredReleases.firstOrNull()?.id + LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(vertical = 8.dp) @@ -156,6 +158,7 @@ fun VersionPicker( VersionListItem( release = release, isSelected = release.id == selectedRelease?.id, + isLatest = release.id == latestReleaseId, onClick = { onAction(DetailsAction.SelectRelease(release)) } ) } @@ -170,6 +173,7 @@ fun VersionPicker( private fun VersionListItem( release: GithubRelease, isSelected: Boolean, + isLatest: Boolean, onClick: () -> Unit ) { Row( @@ -195,6 +199,19 @@ private fun VersionListItem( MaterialTheme.colorScheme.onSurface } ) + if (isLatest) { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.primaryContainer + ) { + Text( + text = stringResource(Res.string.latest_badge), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + } if (release.isPrerelease) { Surface( shape = RoundedCornerShape(4.dp), From abdc081e61c57f5efa0263103f46ccede7bca048 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Wed, 18 Feb 2026 08:12:41 +0500 Subject: [PATCH 5/7] refactor: Improve app update logic and code cleanup This commit introduces several refinements to the app update process, data handling, and general code quality. The app update mechanism now more robustly handles pending installs. When an update is initiated, it is marked as "pending." `SyncInstalledAppsUseCase` will then resolve this status once the new version is detected by the system's package manager, correctly updating the version info and `isUpdateAvailable` flag. The "Update" button is now disabled for apps with a pending install to prevent redundant actions. Error handling during the update process has also been improved to ensure the pending status is cleared on failures like cancellations or rate-limiting. Additionally, this commit includes code cleanup across multiple modules by removing redundant comments and KDoc. - **refactor(apps)**: Implemented a more robust pending install flow. `SyncInstalledAppsUseCase` now resolves pending installs by checking against the system's package manager. - **fix(apps)**: The "Update" button is now hidden for apps that have a pending install. - **fix(apps)**: Ensured the `isPendingInstall` flag is cleared on update cancellation, rate limit errors, or other failures. - **refactor(data)**: Encapsulated APK info extraction within a `try...finally` block to guarantee the temporary APK file is deleted, even if parsing fails. - **refactor(search)**: Removed automatic phrase quoting from search queries to allow for more flexible keyword matching. - **chore**: Removed redundant comments and KDoc from various files, including `DetailsViewModel`, `SearchRepositoryImpl`, `DesktopDeepLink`, and UI components. --- composeApp/build.gradle.kts | 1 - .../rainxch/githubstore/DesktopDeepLink.kt | 14 -------- .../repository/InstalledAppsRepositoryImpl.kt | 13 ++++--- .../use_cases/SyncInstalledAppsUseCase.kt | 20 +++++------ .../zed/rainxch/apps/presentation/AppsRoot.kt | 2 +- .../apps/presentation/AppsViewModel.kt | 35 +++++++++++++------ .../details/presentation/DetailsAction.kt | 1 - .../details/presentation/DetailsViewModel.kt | 2 -- .../components/SmartInstallButton.kt | 1 - .../presentation/components/VersionPicker.kt | 8 ++--- .../impl/CachedRepositoriesDataSourceImpl.kt | 2 -- .../data/repository/SearchRepositoryImpl.kt | 3 -- .../search/presentation/SearchViewModel.kt | 1 - 13 files changed, 46 insertions(+), 57 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 94442133..7b480930 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -122,7 +122,6 @@ compose.desktop { iconFile.set(project.file("logo/app_icon.icns")) bundleID = "zed.rainxch.githubstore" - // Register githubstore:// URI scheme so macOS opens the app for deep links infoPlist { extraKeysRawXml = """ CFBundleURLTypes diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt index 1e131e09..65f593d5 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/DesktopDeepLink.kt @@ -8,18 +8,6 @@ import java.net.InetAddress import java.net.ServerSocket import java.net.Socket -/** - * Handles desktop deep link registration and single-instance forwarding. - * - * - **Windows**: Registers `githubstore://` in HKCU registry on first launch. - * URI is received as a CLI argument (`args[0]`). - * - **macOS**: URI scheme is registered via Info.plist in the packaged .app. - * URI is received via `Desktop.setOpenURIHandler`. - * - **Linux**: Registers `githubstore://` via a `.desktop` file + `xdg-mime` on first launch. - * URI is received as a CLI argument (`args[0]`). - * - **Single-instance**: Uses a local TCP socket to forward URIs from - * a second instance to the already-running primary instance. - */ object DesktopDeepLink { private const val SINGLE_INSTANCE_PORT = 47632 @@ -69,7 +57,6 @@ object DesktopDeepLink { val appsDir = File(System.getProperty("user.home"), ".local/share/applications") val desktopFile = File(appsDir, "$DESKTOP_FILE_NAME.desktop") - // Already registered if (desktopFile.exists()) return val exePath = resolveExePath() ?: return @@ -88,7 +75,6 @@ object DesktopDeepLink { """.trimIndent() ) - // Register as the default handler for githubstore:// URIs runCommand("xdg-mime", "default", "$DESKTOP_FILE_NAME.desktop", "x-scheme-handler/$SCHEME") } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index 3588fa11..5427d2a6 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -175,29 +175,28 @@ class InstalledAppsRepositoryImpl( val tempPath = downloader.getDownloadedFilePath(tempAssetName) if (tempPath != null) { - val latestInfo = + val latestInfo = try { installer.getApkInfoExtractor().extractPackageInfo(tempPath) - File(tempPath).delete() - + } finally { + File(tempPath).delete() + } if (latestInfo != null) { latestVersionName = latestInfo.versionName latestVersionCode = latestInfo.versionCode isUpdateAvailable = latestVersionCode > app.installedVersionCode } else { - // Couldn't extract APK info, fall back to tag comparison latestVersionName = latestRelease.tagName } + } else { - // Download failed, fall back to tag comparison latestVersionName = latestRelease.tagName } } catch (e: Exception) { Logger.w { "Failed to download APK for version check of ${app.packageName}: ${e.message}" } - // Download/extraction failed, fall back to tag comparison + downloader.getDownloadedFilePath(tempAssetName)?.let { File(it).delete() } latestVersionName = latestRelease.tagName } } else { - // No installable asset found, but tags differ so likely an update latestVersionName = latestRelease.tagName } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt index b0059252..b6d26f19 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt @@ -15,6 +15,7 @@ import zed.rainxch.core.domain.system.PackageMonitor * Responsibilities: * 1. Remove apps from DB that are no longer installed on the system * 2. Migrate legacy apps missing versionName/versionCode fields + * 3. Resolve pending installs once they appear in the system package manager * * This should be called before loading or refreshing app data to ensure consistency. */ @@ -45,7 +46,6 @@ class SyncInstalledAppsUseCase( if (isOnSystem) { toResolvePending.add(app) } - // Not on system yet but pending — keep in DB, don't delete } !isOnSystem -> { @@ -73,16 +73,16 @@ class SyncInstalledAppsUseCase( try { val systemInfo = packageMonitor.getInstalledPackageInfo(app.packageName) if (systemInfo != null) { - installedAppsRepository.updateApp( - app.copy( - isPendingInstall = false, - isUpdateAvailable = false, - installedVersionName = systemInfo.versionName, - installedVersionCode = systemInfo.versionCode, - latestVersionName = systemInfo.versionName, - latestVersionCode = systemInfo.versionCode + app.latestVersionCode?.let { latestVersionCode -> + installedAppsRepository.updateApp( + app.copy( + isPendingInstall = false, + installedVersionName = systemInfo.versionName, + installedVersionCode = systemInfo.versionCode, + isUpdateAvailable = latestVersionCode > systemInfo.versionCode + ) ) - ) + } logger.info("Resolved pending install: ${app.packageName} (v${systemInfo.versionName}, code=${systemInfo.versionCode})") } else { installedAppsRepository.updatePendingStatus(app.packageName, false) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt index c60feaf5..5b7e99c0 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt @@ -556,7 +556,7 @@ fun AppItemCard( } else -> { - if (app.isUpdateAvailable) { + if (app.isUpdateAvailable && !app.isPendingInstall) { Button( onClick = onUpdateClick, modifier = Modifier.weight(1f) diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt index 569a7402..975820a0 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt @@ -270,7 +270,13 @@ class AppsViewModel( markPendingUpdate(app) updateAppState(app.packageName, UpdateState.Installing) - installer.install(filePath, ext) + + try { + installer.install(filePath, ext) + } catch (e: Exception) { + installedAppsRepository.updatePendingStatus(app.packageName, false) + throw e + } installedAppsRepository.updateAppVersion( packageName = app.packageName, @@ -290,10 +296,20 @@ class AppsViewModel( } catch (e: CancellationException) { logger.debug("Update cancelled for ${app.packageName}") cleanupUpdate(app.packageName, app.latestAssetName) + try { + installedAppsRepository.updatePendingStatus(app.packageName, false) + } catch (clearEx: Exception) { + logger.error("Failed to clear pending status on cancellation: ${clearEx.message}") + } updateAppState(app.packageName, UpdateState.Idle) throw e } catch (_: RateLimitException) { logger.debug("Rate limited during update for ${app.packageName}") + try { + installedAppsRepository.updatePendingStatus(app.packageName, false) + } catch (clearEx: Exception) { + logger.error("Failed to clear pending status on rate limit: ${clearEx.message}") + } updateAppState(app.packageName, UpdateState.Idle) _events.send( AppsEvent.ShowError(getString(Res.string.rate_limit_exceeded)) @@ -301,6 +317,11 @@ class AppsViewModel( } catch (e: Exception) { logger.error("Update failed for ${app.packageName}: ${e.message}") cleanupUpdate(app.packageName, app.latestAssetName) + try { + installedAppsRepository.updatePendingStatus(app.packageName, false) + } catch (clearEx: Exception) { + logger.error("Failed to clear pending status on error: ${clearEx.message}") + } updateAppState( app.packageName, UpdateState.Error(e.message ?: "Update failed") @@ -470,15 +491,9 @@ class AppsViewModel( } } - private suspend fun markPendingUpdate( - app: InstalledApp, - ) { - try { - installedAppsRepository.updatePendingStatus(app.packageName, true) - logger.debug("Marked ${app.packageName} as pending install") - } catch (e: Exception) { - logger.error("Failed to mark pending update: ${e.message}") - } + private suspend fun markPendingUpdate(app: InstalledApp) { + installedAppsRepository.updatePendingStatus(app.packageName, true) + logger.debug("Marked ${app.packageName} as pending install") } private suspend fun cleanupUpdate(packageName: String, assetName: String?) { diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt index 89b935ef..fa4677d6 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt @@ -31,7 +31,6 @@ sealed interface DetailsAction { data class OnMessage(val messageText: StringResource) : DetailsAction - // Version picker data class SelectReleaseCategory(val category: ReleaseCategory) : DetailsAction data class SelectRelease(val release: GithubRelease) : DetailsAction data object ToggleVersionPicker : DetailsAction diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt index d087a8d3..29628fdf 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt @@ -241,7 +241,6 @@ class DetailsViewModel( return@launch } - // Auto-select latest stable, fall back to first release if no stable exists val selectedRelease = allReleases.firstOrNull { !it.isPrerelease } ?: allReleases.firstOrNull() @@ -560,7 +559,6 @@ class DetailsViewModel( } } - // Version picker actions is DetailsAction.SelectReleaseCategory -> { val newCategory = action.category val filtered = when (newCategory) { diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt index 8b1f372a..befc58cf 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt @@ -68,7 +68,6 @@ fun SmartInstallButton( val isActiveDownload = state.isDownloading || state.downloadStage != DownloadStage.IDLE - // Determine button color and text based on install status val buttonColor = when { !enabled && !isActiveDownload -> MaterialTheme.colorScheme.surfaceContainer isUpdateAvailable -> MaterialTheme.colorScheme.tertiary diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt index f17001aa..2855fe12 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt @@ -51,7 +51,6 @@ fun VersionPicker( modifier: Modifier = Modifier ) { Column(modifier = modifier.fillMaxWidth()) { - // Category filter chips Row( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth() @@ -75,7 +74,6 @@ fun VersionPicker( Spacer(Modifier.height(8.dp)) - // Version selector card OutlinedCard( onClick = { onAction(DetailsAction.ToggleVersionPicker) }, modifier = Modifier.fillMaxWidth() @@ -115,7 +113,6 @@ fun VersionPicker( } } - // Bottom sheet with version list if (isPickerVisible) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) @@ -179,7 +176,10 @@ private fun VersionListItem( Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .clickable( + onClickLabel = stringResource(Res.string.select_version), + onClick = onClick + ) .padding(horizontal = 16.dp, vertical = 12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically diff --git a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/data_source/impl/CachedRepositoriesDataSourceImpl.kt b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/data_source/impl/CachedRepositoriesDataSourceImpl.kt index 18a6ebac..50a05a91 100644 --- a/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/data_source/impl/CachedRepositoriesDataSourceImpl.kt +++ b/feature/home/data/src/commonMain/kotlin/zed/rainxch/home/data/data_source/impl/CachedRepositoriesDataSourceImpl.kt @@ -65,7 +65,6 @@ class CachedRepositoriesDataSourceImpl( private suspend fun fetchCachedReposForCategory( category: HomeCategory ): CachedRepoResponse? { - // Check in-memory cache first val cached = cacheMutex.withLock { memoryCache[category] } if (cached != null) { val age = Clock.System.now() - cached.fetchedAt @@ -106,7 +105,6 @@ class CachedRepositoriesDataSourceImpl( val responseText = response.bodyAsText() val parsed = json.decodeFromString(responseText) - // Store in memory cache cacheMutex.withLock { memoryCache[category] = CacheEntry( data = parsed, diff --git a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt index 862e8bc3..677ed3c5 100644 --- a/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt +++ b/feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt @@ -103,7 +103,6 @@ class SearchRepositoryImpl( return@channelFlow } - // Entire page yielded 0 verified repos — auto-skip to next page if (!baseHasMore) { send( PaginatedDiscoveryRepositories( @@ -120,7 +119,6 @@ class SearchRepositoryImpl( pagesSkipped++ } - // Exhausted auto-skip budget, tell UI there's more so it can try again send( PaginatedDiscoveryRepositories( repos = emptyList(), @@ -182,7 +180,6 @@ class SearchRepositoryImpl( val q = if (clean.isBlank()) { "stars:>100" } else { - // Always quote the query to match it as a phrase for better relevance "\"$clean\"" } val scope = " in:name,description" diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt index 1c7a4047..0f890aa8 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt @@ -303,7 +303,6 @@ class SearchViewModel( ) } } else if (action.query.trim().length < MIN_QUERY_LENGTH) { - // Don't search yet — query too short, clear previous results currentSearchJob?.cancel() _state.update { it.copy( From f678c999907aa337cd8e4256c59c02ba2eb8ce63 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Wed, 18 Feb 2026 09:43:47 +0500 Subject: [PATCH 6/7] refactor: Improve app update logic and UI This commit introduces several refinements to the app update process and the release selection UI. The app update check in `SyncInstalledAppsUseCase` now gracefully handles cases where `latestVersionCode` is null by defaulting it to `0L`, preventing potential null pointer issues. In the repository implementation, a log message has been clarified to indicate that failures during version checks can occur during either download or extraction of the APK. Additionally, the release category filter chips in the `VersionPicker` component are now wrapped in a `LazyRow` to ensure they scroll horizontally on smaller screens, preventing UI overflow. - **refactor(domain)**: Handled null `latestVersionCode` in `SyncInstalledAppsUseCase` by coalescing to `0L` for safer update comparisons. - **fix(details)**: Replaced `Row` with `LazyRow` for the release category `FilterChip`s to improve horizontal scrolling on small displays. - **chore(data)**: Clarified a warning log message in `InstalledAppsRepositoryImpl` to include both download and extraction failures for APK version checks. --- .../repository/InstalledAppsRepositoryImpl.kt | 2 +- .../use_cases/SyncInstalledAppsUseCase.kt | 17 +++++----- .../presentation/components/VersionPicker.kt | 34 +++++++++++-------- 3 files changed, 29 insertions(+), 24 deletions(-) diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index 5427d2a6..2739110b 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -192,7 +192,7 @@ class InstalledAppsRepositoryImpl( latestVersionName = latestRelease.tagName } } catch (e: Exception) { - Logger.w { "Failed to download APK for version check of ${app.packageName}: ${e.message}" } + Logger.w { "Failed to download or extract APK for version check of ${app.packageName}: ${e.message}" } downloader.getDownloadedFilePath(tempAssetName)?.let { File(it).delete() } latestVersionName = latestRelease.tagName } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt index b6d26f19..346c43c2 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt @@ -73,16 +73,15 @@ class SyncInstalledAppsUseCase( try { val systemInfo = packageMonitor.getInstalledPackageInfo(app.packageName) if (systemInfo != null) { - app.latestVersionCode?.let { latestVersionCode -> - installedAppsRepository.updateApp( - app.copy( - isPendingInstall = false, - installedVersionName = systemInfo.versionName, - installedVersionCode = systemInfo.versionCode, - isUpdateAvailable = latestVersionCode > systemInfo.versionCode - ) + val latestVersionCode = app.latestVersionCode ?: 0L + installedAppsRepository.updateApp( + app.copy( + isPendingInstall = false, + installedVersionName = systemInfo.versionName, + installedVersionCode = systemInfo.versionCode, + isUpdateAvailable = latestVersionCode > systemInfo.versionCode ) - } + ) logger.info("Resolved pending install: ${app.packageName} (v${systemInfo.versionName}, code=${systemInfo.versionCode})") } else { installedAppsRepository.updatePendingStatus(app.packageName, false) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt index 2855fe12..8cff449a 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -55,20 +56,25 @@ fun VersionPicker( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth() ) { - ReleaseCategory.entries.forEach { category -> - FilterChip( - selected = category == selectedCategory, - onClick = { onAction(DetailsAction.SelectReleaseCategory(category)) }, - label = { - Text( - text = when (category) { - ReleaseCategory.STABLE -> stringResource(Res.string.category_stable) - ReleaseCategory.PRE_RELEASE -> stringResource(Res.string.category_pre_release) - ReleaseCategory.ALL -> stringResource(Res.string.category_all) - } - ) - } - ) + LazyRow ( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(ReleaseCategory.entries) { category -> + FilterChip( + selected = category == selectedCategory, + onClick = { onAction(DetailsAction.SelectReleaseCategory(category)) }, + label = { + Text( + text = when (category) { + ReleaseCategory.STABLE -> stringResource(Res.string.category_stable) + ReleaseCategory.PRE_RELEASE -> stringResource(Res.string.category_pre_release) + ReleaseCategory.ALL -> stringResource(Res.string.category_all) + } + ) + } + ) + } } } From 7667aaffe54ae6c9d5f568940e3e0ebbc6446c33 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Thu, 19 Feb 2026 10:08:00 +0500 Subject: [PATCH 7/7] refactor(updates): Simplify update check logic and improve UX This commit refactors the update-checking mechanism to be faster and more reliable by removing the pre-download and APK parsing steps. It now compares version tags directly. It also enhances the user experience on the "Apps" screen with pull-to-refresh, better status indicators, and improved handling of pending and stale app installations. - **refactor(data)**: Simplified `checkForUpdate` logic in `InstalledAppsRepositoryImpl`. The check for updates now directly compares the normalized installed version tag with the latest release tag, removing the need to download the APK for version code comparison. This makes the process significantly faster and less error-prone. - **feat(apps)**: Added pull-to-refresh functionality on the Apps screen to manually trigger a sync and check for updates. - **feat(apps)**: Implemented a "last checked" timestamp on the Apps screen to inform the user when updates were last fetched. An automatic check now runs on a 30-minute cooldown. - **feat(domain)**: Added logic to `SyncInstalledAppsUseCase` to automatically clean up pending installs that have not been completed within 24 hours. - **feat(ui)**: Introduced a "Pending Install" badge on the app details screen for apps that are awaiting installation. The install buttons are now correctly disabled for pending installs. - **chore(android)**: Registered a `PackageEventReceiver` in the main `Application` class to respond to app install/uninstall events, ensuring the database is kept in sync with the system's state. --- .../rainxch/githubstore/app/GithubStoreApp.kt | 25 ++ .../zed/rainxch/core/data/di/SharedModule.kt | 1 - .../repository/InstalledAppsRepositoryImpl.kt | 92 +------- .../use_cases/SyncInstalledAppsUseCase.kt | 24 +- .../composeResources/values/strings.xml | 8 + .../rainxch/apps/presentation/AppsAction.kt | 1 + .../zed/rainxch/apps/presentation/AppsRoot.kt | 217 +++++++++++------- .../rainxch/apps/presentation/AppsState.kt | 5 +- .../apps/presentation/AppsViewModel.kt | 43 +++- .../presentation/components/AppHeader.kt | 40 +++- .../components/SmartInstallButton.kt | 4 +- 11 files changed, 282 insertions(+), 178 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt index 26ad49b6..a4fbc5e3 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -1,16 +1,41 @@ package zed.rainxch.githubstore.app import android.app.Application +import android.os.Build +import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext +import zed.rainxch.core.data.services.PackageEventReceiver +import zed.rainxch.core.domain.repository.InstalledAppsRepository +import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.githubstore.app.di.initKoin class GithubStoreApp : Application() { + private var packageEventReceiver: PackageEventReceiver? = null + override fun onCreate() { super.onCreate() initKoin { androidContext(this@GithubStoreApp) } + + registerPackageEventReceiver() + } + + private fun registerPackageEventReceiver() { + val receiver = PackageEventReceiver( + installedAppsRepository = get(), + packageMonitor = get() + ) + val filter = PackageEventReceiver.createIntentFilter() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(receiver, filter) + } + + packageEventReceiver = receiver } } \ No newline at end of file diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index f8512d0a..75296657 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -63,7 +63,6 @@ val coreModule = module { installedAppsDao = get(), historyDao = get(), installer = get(), - downloader = get(), httpClient = get() ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index 2739110b..2f0e40dc 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -12,7 +12,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import zed.rainxch.core.data.dto.ReleaseNetwork -import zed.rainxch.core.data.dto.RepoByIdNetwork + import zed.rainxch.core.data.local.db.AppDatabase import zed.rainxch.core.data.local.db.dao.InstalledAppDao import zed.rainxch.core.data.local.db.dao.UpdateHistoryDao @@ -24,16 +24,13 @@ import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.domain.model.InstalledApp -import zed.rainxch.core.domain.network.Downloader import zed.rainxch.core.domain.repository.InstalledAppsRepository -import java.io.File class InstalledAppsRepositoryImpl( private val database: AppDatabase, private val installedAppsDao: InstalledAppDao, private val historyDao: UpdateHistoryDao, private val installer: Installer, - private val downloader: Downloader, private val httpClient: HttpClient ) : InstalledAppsRepository { @@ -79,21 +76,6 @@ class InstalledAppsRepositoryImpl( installedAppsDao.deleteByPackageName(packageName) } - private suspend fun fetchDefaultBranch(owner: String, repo: String): String? { - return try { - val repoInfo = httpClient.executeRequest { - get("/repos/$owner/$repo") { - header(HttpHeaders.Accept, "application/vnd.github+json") - } - }.getOrNull() - - repoInfo?.defaultBranch - } catch (e: Exception) { - Logger.e { "Failed to fetch default branch for $owner/$repo: ${e.message}" } - null - } - } - private suspend fun fetchLatestPublishedRelease( owner: String, repo: String @@ -125,14 +107,6 @@ class InstalledAppsRepositoryImpl( val app = installedAppsDao.getAppByPackage(packageName) ?: return false try { - val branch = fetchDefaultBranch(app.repoOwner, app.repoName) - - if (branch == null) { - Logger.w { "Could not determine default branch for ${app.repoOwner}/${app.repoName}" } - installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis()) - return false - } - val latestRelease = fetchLatestPublishedRelease( owner = app.repoOwner, repo = app.repoName @@ -142,68 +116,16 @@ class InstalledAppsRepositoryImpl( val normalizedInstalledTag = normalizeVersion(app.installedVersion) val normalizedLatestTag = normalizeVersion(latestRelease.tagName) - if (normalizedInstalledTag == normalizedLatestTag) { - installedAppsDao.updateVersionInfo( - packageName = packageName, - available = false, - version = latestRelease.tagName, - assetName = app.latestAssetName, - assetUrl = app.latestAssetUrl, - assetSize = app.latestAssetSize, - releaseNotes = latestRelease.description ?: "", - timestamp = System.currentTimeMillis(), - latestVersionName = app.latestVersionName, - latestVersionCode = app.latestVersionCode - ) - return false - } - val installableAssets = latestRelease.assets.filter { asset -> installer.isAssetInstallable(asset.name) } - val primaryAsset = installer.choosePrimaryAsset(installableAssets) - var isUpdateAvailable = true - var latestVersionName: String? = null - var latestVersionCode: Long? = null - - if (primaryAsset != null) { - val tempAssetName = primaryAsset.name + ".tmp" - try { - downloader.download(primaryAsset.downloadUrl, tempAssetName).collect { } - - val tempPath = downloader.getDownloadedFilePath(tempAssetName) - if (tempPath != null) { - val latestInfo = try { - installer.getApkInfoExtractor().extractPackageInfo(tempPath) - } finally { - File(tempPath).delete() - } - if (latestInfo != null) { - latestVersionName = latestInfo.versionName - latestVersionCode = latestInfo.versionCode - isUpdateAvailable = latestVersionCode > app.installedVersionCode - } else { - latestVersionName = latestRelease.tagName - } - - } else { - latestVersionName = latestRelease.tagName - } - } catch (e: Exception) { - Logger.w { "Failed to download or extract APK for version check of ${app.packageName}: ${e.message}" } - downloader.getDownloadedFilePath(tempAssetName)?.let { File(it).delete() } - latestVersionName = latestRelease.tagName - } - } else { - latestVersionName = latestRelease.tagName - } + val isUpdateAvailable = normalizedInstalledTag != normalizedLatestTag Logger.d { - "Update check for ${app.appName}: currentTag=${app.installedVersion}, latestTag=${latestRelease.tagName}, " + - "currentCode=${app.installedVersionCode}, latestCode=$latestVersionCode, isUpdate=$isUpdateAvailable, " + - "primaryAsset=${primaryAsset?.name}" + "Update check for ${app.appName}: installedTag=${app.installedVersion}, " + + "latestTag=${latestRelease.tagName}, isUpdate=$isUpdateAvailable" } installedAppsDao.updateVersionInfo( @@ -215,11 +137,13 @@ class InstalledAppsRepositoryImpl( assetSize = primaryAsset?.size, releaseNotes = latestRelease.description ?: "", timestamp = System.currentTimeMillis(), - latestVersionName = latestVersionName, - latestVersionCode = latestVersionCode + latestVersionName = latestRelease.tagName, + latestVersionCode = null ) return isUpdateAvailable + } else { + installedAppsDao.updateLastChecked(packageName, System.currentTimeMillis()) } } catch (e: Exception) { Logger.e { "Failed to check updates for $packageName: ${e.message}" } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt index 346c43c2..36072214 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/use_cases/SyncInstalledAppsUseCase.kt @@ -11,12 +11,13 @@ import zed.rainxch.core.domain.system.PackageMonitor /** * Use case for synchronizing installed apps state with the system package manager. - * + * * Responsibilities: * 1. Remove apps from DB that are no longer installed on the system * 2. Migrate legacy apps missing versionName/versionCode fields * 3. Resolve pending installs once they appear in the system package manager - * + * 4. Clean up stale pending installs (older than 24 hours) + * * This should be called before loading or refreshing app data to ensure consistency. */ class SyncInstalledAppsUseCase( @@ -25,6 +26,9 @@ class SyncInstalledAppsUseCase( private val platform: Platform, private val logger: GitHubStoreLogger ) { + companion object { + private const val PENDING_TIMEOUT_MS = 24 * 60 * 60 * 1000L // 24 hours + } /** * Executes the sync operation. * @@ -34,10 +38,12 @@ class SyncInstalledAppsUseCase( try { val installedPackageNames = packageMonitor.getAllInstalledPackageNames() val appsInDb = installedAppsRepository.getAllInstalledApps().first() + val now = System.currentTimeMillis() val toDelete = mutableListOf() val toMigrate = mutableListOf>() val toResolvePending = mutableListOf() + val toDeleteStalePending = mutableListOf() appsInDb.forEach { app -> val isOnSystem = installedPackageNames.contains(app.packageName) @@ -45,6 +51,8 @@ class SyncInstalledAppsUseCase( app.isPendingInstall -> { if (isOnSystem) { toResolvePending.add(app) + } else if (now - app.installedAt > PENDING_TIMEOUT_MS) { + toDeleteStalePending.add(app.packageName) } } @@ -69,6 +77,15 @@ class SyncInstalledAppsUseCase( } } + toDeleteStalePending.forEach { packageName -> + try { + installedAppsRepository.deleteInstalledApp(packageName) + logger.info("Removed stale pending install (>24h): $packageName") + } catch (e: Exception) { + logger.error("Failed to delete stale pending $packageName: ${e.message}") + } + } + toResolvePending.forEach { app -> try { val systemInfo = packageMonitor.getInstalledPackageInfo(app.packageName) @@ -116,7 +133,8 @@ class SyncInstalledAppsUseCase( } logger.info( - "Sync completed: ${toDelete.size} deleted, ${toResolvePending.size} pending resolved, ${toMigrate.size} migrated" + "Sync completed: ${toDelete.size} deleted, ${toDeleteStalePending.size} stale pending removed, " + + "${toResolvePending.size} pending resolved, ${toMigrate.size} migrated" ) Result.success(Unit) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 454251ae..45fd37f0 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -346,4 +346,12 @@ Latest No version Versions + + + Last checked: %1$s + Never checked + just now + %1$d min ago + %1$d h ago + Checking for updates… \ No newline at end of file diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt index 41b84061..1e25b4ce 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt @@ -11,5 +11,6 @@ sealed interface AppsAction { data object OnUpdateAll : AppsAction data object OnCancelUpdateAll : AppsAction data object OnCheckAllForUpdates : AppsAction + data object OnRefresh : AppsAction data class OnNavigateToRepo(val repoId: Long) : AppsAction } \ No newline at end of file diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt index 5b7e99c0..e19f7dd3 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt @@ -44,6 +44,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -162,111 +163,147 @@ fun AppsScreen( }, modifier = Modifier.liquefiable(liquidState) ) { innerPadding -> - Column( + PullToRefreshBox( + isRefreshing = state.isRefreshing, + onRefresh = { onAction(AppsAction.OnRefresh) }, modifier = Modifier .fillMaxSize() .padding(innerPadding) ) { - TextField( - value = state.searchQuery, - onValueChange = { onAction(AppsAction.OnSearchChange(it)) }, - leadingIcon = { - Icon(Icons.Default.Search, contentDescription = null) - }, - placeholder = { Text(stringResource(Res.string.search_your_apps)) }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - shape = CircleShape, - colors = TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ) - ) - - val hasUpdates = state.apps.any { it.installedApp.isUpdateAvailable } - if (hasUpdates && !state.isUpdatingAll) { - Button( - onClick = { onAction(AppsAction.OnUpdateAll) }, + Column( + modifier = Modifier.fillMaxSize() + ) { + TextField( + value = state.searchQuery, + onValueChange = { onAction(AppsAction.OnSearchChange(it)) }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = null) + }, + placeholder = { Text(stringResource(Res.string.search_your_apps)) }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), - enabled = state.updateAllButtonEnabled - ) { - Icon( - imageVector = Icons.Default.Update, - contentDescription = null + shape = CircleShape, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent ) + ) - Spacer(Modifier.width(8.dp)) - + if (state.isCheckingForUpdates) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp + ) + Text( + text = stringResource(Res.string.checking_for_updates), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else if (state.lastCheckedTimestamp != null) { Text( - text = stringResource(Res.string.update_all) + text = stringResource( + Res.string.last_checked, + formatLastChecked(state.lastCheckedTimestamp) + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) ) } - } - if (state.isUpdatingAll && state.updateAllProgress != null) { - UpdateAllProgressCard( - progress = state.updateAllProgress, - onCancel = { onAction(AppsAction.OnCancelUpdateAll) } - ) - } + val hasUpdates = state.apps.any { it.installedApp.isUpdateAvailable } + if (hasUpdates && !state.isUpdatingAll) { + Button( + onClick = { onAction(AppsAction.OnUpdateAll) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + enabled = state.updateAllButtonEnabled + ) { + Icon( + imageVector = Icons.Default.Update, + contentDescription = null + ) - val filteredApps = remember(state.apps, state.searchQuery) { - if (state.searchQuery.isBlank()) { - state.apps - } else { - state.apps.filter { appItem -> - appItem.installedApp.appName.contains( - state.searchQuery, - ignoreCase = true - ) || - appItem.installedApp.repoOwner.contains( - state.searchQuery, - ignoreCase = true - ) + Spacer(Modifier.width(8.dp)) + + Text( + text = stringResource(Res.string.update_all) + ) } } - } - when { - state.isLoading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator() - } + if (state.isUpdatingAll && state.updateAllProgress != null) { + UpdateAllProgressCard( + progress = state.updateAllProgress, + onCancel = { onAction(AppsAction.OnCancelUpdateAll) } + ) } - filteredApps.isEmpty() -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text(stringResource(Res.string.no_apps_found)) + val filteredApps = remember(state.apps, state.searchQuery) { + if (state.searchQuery.isBlank()) { + state.apps + } else { + state.apps.filter { appItem -> + appItem.installedApp.appName.contains( + state.searchQuery, + ignoreCase = true + ) || + appItem.installedApp.repoOwner.contains( + state.searchQuery, + ignoreCase = true + ) + } } } - else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items( - items = filteredApps, - key = { it.installedApp.packageName } - ) { appItem -> - AppItemCard( - appItem = appItem, - onOpenClick = { onAction(AppsAction.OnOpenApp(appItem.installedApp)) }, - onUpdateClick = { onAction(AppsAction.OnUpdateApp(appItem.installedApp)) }, - onCancelClick = { onAction(AppsAction.OnCancelUpdate(appItem.installedApp.packageName)) }, - onRepoClick = { onAction(AppsAction.OnNavigateToRepo(appItem.installedApp.repoId)) }, - modifier = Modifier.liquefiable(liquidState) - ) + when { + state.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } + + filteredApps.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(stringResource(Res.string.no_apps_found)) + } + } + + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items( + items = filteredApps, + key = { it.installedApp.packageName } + ) { appItem -> + AppItemCard( + appItem = appItem, + onOpenClick = { onAction(AppsAction.OnOpenApp(appItem.installedApp)) }, + onUpdateClick = { onAction(AppsAction.OnUpdateApp(appItem.installedApp)) }, + onCancelClick = { onAction(AppsAction.OnCancelUpdate(appItem.installedApp.packageName)) }, + onRepoClick = { onAction(AppsAction.OnNavigateToRepo(appItem.installedApp.repoId)) }, + modifier = Modifier.liquefiable(liquidState) + ) + } } } } @@ -579,6 +616,20 @@ fun AppItemCard( } } +@Composable +private fun formatLastChecked(timestamp: Long): String { + val now = System.currentTimeMillis() + val diff = now - timestamp + val minutes = diff / (60 * 1000) + val hours = diff / (60 * 60 * 1000) + + return when { + minutes < 1 -> stringResource(Res.string.last_checked_just_now) + minutes < 60 -> stringResource(Res.string.last_checked_minutes_ago, minutes.toInt()) + else -> stringResource(Res.string.last_checked_hours_ago, hours.toInt()) + } +} + @Preview @Composable private fun Preview() { diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt index 5b20aea3..cb74aaed 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt @@ -9,5 +9,8 @@ data class AppsState( val isLoading: Boolean = false, val isUpdatingAll: Boolean = false, val updateAllProgress: UpdateAllProgress? = null, - val updateAllButtonEnabled: Boolean = true + val updateAllButtonEnabled: Boolean = true, + val isCheckingForUpdates: Boolean = false, + val lastCheckedTimestamp: Long? = null, + val isRefreshing: Boolean = false ) \ No newline at end of file diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt index 975820a0..878bfb4c 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt @@ -38,9 +38,14 @@ class AppsViewModel( private val logger: GitHubStoreLogger ) : ViewModel() { + companion object { + private const val UPDATE_CHECK_COOLDOWN_MS = 30 * 60 * 1000L // 30 minutes + } + private var hasLoadedInitialData = false private val activeUpdates = mutableMapOf() private var updateAllJob: Job? = null + private var lastAutoCheckTimestamp: Long = 0L private val _state = MutableStateFlow(AppsState()) val state = _state @@ -98,18 +103,50 @@ class AppsViewModel( it.copy(isLoading = false) } } + + autoCheckForUpdatesIfNeeded() } } + private fun autoCheckForUpdatesIfNeeded() { + val now = System.currentTimeMillis() + if (now - lastAutoCheckTimestamp < UPDATE_CHECK_COOLDOWN_MS) { + logger.debug("Skipping auto-check: last check was ${(now - lastAutoCheckTimestamp) / 1000}s ago") + return + } + checkAllForUpdates() + } private fun checkAllForUpdates() { viewModelScope.launch { + _state.update { it.copy(isCheckingForUpdates = true) } try { syncInstalledAppsUseCase() - installedAppsRepository.checkAllForUpdates() + val now = System.currentTimeMillis() + lastAutoCheckTimestamp = now + _state.update { it.copy(lastCheckedTimestamp = now) } } catch (e: Exception) { logger.error("Check all for updates failed: ${e.message}") + } finally { + _state.update { it.copy(isCheckingForUpdates = false) } + } + } + } + + private fun refresh() { + viewModelScope.launch { + _state.update { it.copy(isRefreshing = true) } + try { + syncInstalledAppsUseCase() + installedAppsRepository.checkAllForUpdates() + val now = System.currentTimeMillis() + lastAutoCheckTimestamp = now + _state.update { it.copy(lastCheckedTimestamp = now) } + } catch (e: Exception) { + logger.error("Refresh failed: ${e.message}") + } finally { + _state.update { it.copy(isRefreshing = false) } } } } @@ -147,6 +184,10 @@ class AppsViewModel( checkAllForUpdates() } + AppsAction.OnRefresh -> { + refresh() + } + is AppsAction.OnNavigateToRepo -> { viewModelScope.launch { _events.send(AppsEvent.NavigateToRepo(action.repoId)) diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/AppHeader.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/AppHeader.kt index b8691d3f..d973728d 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/AppHeader.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/AppHeader.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Update import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator @@ -164,9 +165,12 @@ fun AppHeader( Spacer(Modifier.height(8.dp)) if (installedApp != null) { - InstallStatusBadge( - isUpdateAvailable = installedApp.isUpdateAvailable, - ) + when { + installedApp.isPendingInstall -> PendingInstallBadge() + else -> InstallStatusBadge( + isUpdateAvailable = installedApp.isUpdateAvailable, + ) + } } Spacer(Modifier.height(8.dp)) @@ -292,4 +296,34 @@ fun InstallStatusBadge( ) } } +} + +@Composable +fun PendingInstallBadge( + modifier: Modifier = Modifier +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.secondaryContainer + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + imageVector = Icons.Default.Schedule, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + Text( + text = stringResource(Res.string.pending_install), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + fontWeight = FontWeight.SemiBold + ) + } + } } \ No newline at end of file diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt index befc58cf..7c36fdd6 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt @@ -59,8 +59,8 @@ fun SmartInstallButton( val liquidState = LocalTopbarLiquidState.current val installedApp = state.installedApp - val isInstalled = installedApp != null - val isUpdateAvailable = installedApp?.isUpdateAvailable == true + val isInstalled = installedApp != null && !installedApp.isPendingInstall + val isUpdateAvailable = installedApp?.isUpdateAvailable == true && !installedApp.isPendingInstall val enabled = remember(primaryAsset, isDownloading, isInstalling) { primaryAsset != null && !isDownloading && !isInstalling