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/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/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/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/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/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..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,63 +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"
- 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 {
- isUpdateAvailable = false
- latestVersionName = latestRelease.tagName
- }
- } else {
- isUpdateAvailable = false
- latestVersionName = latestRelease.tagName
- }
- } else {
- isUpdateAvailable = false
- 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(
@@ -210,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/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/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..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,11 +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(
@@ -24,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.
*
@@ -33,13 +38,25 @@ 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)
when {
- !installedPackageNames.contains(app.packageName) -> {
+ app.isPendingInstall -> {
+ if (isOnSystem) {
+ toResolvePending.add(app)
+ } else if (now - app.installedAt > PENDING_TIMEOUT_MS) {
+ toDeleteStalePending.add(app.packageName)
+ }
+ }
+
+ !isOnSystem -> {
toDelete.add(app.packageName)
}
@@ -60,6 +77,38 @@ 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)
+ if (systemInfo != null) {
+ 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)
+ 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 +133,8 @@ class SyncInstalledAppsUseCase(
}
logger.info(
- "Sync completed: ${toDelete.size} deleted, ${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-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 dc6d2b37..45fd37f0 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
@@ -329,10 +330,28 @@
Released %1$d day(s) ago
Released on %1$s
- Fork
-
Home
Search
Apps
Profile
+
+ Fork
+
+
+ Stable
+ Pre-release
+ All
+ Select version
+ Pre-release
+ 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 c46eb7f0..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)
+ )
+ }
}
}
}
@@ -379,18 +416,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 +553,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(
@@ -545,7 +593,7 @@ fun AppItemCard(
}
else -> {
- if (app.isUpdateAvailable) {
+ if (app.isUpdateAvailable && !app.isPendingInstall) {
Button(
onClick = onUpdateClick,
modifier = Modifier.weight(1f)
@@ -568,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 c31cdac3..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))
@@ -267,18 +308,26 @@ class AppsViewModel(
val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath)
?: throw IllegalStateException("Failed to extract APK info")
- updateAppInDatabase(
- app = app,
- newVersion = latestVersion,
- assetName = latestAssetName,
- assetUrl = latestAssetUrl,
+ markPendingUpdate(app)
+
+ updateAppState(app.packageName, UpdateState.Installing)
+
+ try {
+ installer.install(filePath, ext)
+ } catch (e: Exception) {
+ installedAppsRepository.updatePendingStatus(app.packageName, false)
+ throw e
+ }
+
+ installedAppsRepository.updateAppVersion(
+ packageName = app.packageName,
+ newTag = latestVersion,
+ newAssetName = latestAssetName,
+ newAssetUrl = latestAssetUrl,
newVersionName = apkInfo.versionName,
newVersionCode = apkInfo.versionCode
)
- updateAppState(app.packageName, UpdateState.Installing)
- installer.install(filePath, ext)
-
updateAppState(app.packageName, UpdateState.Success)
delay(2000)
updateAppState(app.packageName, UpdateState.Idle)
@@ -288,10 +337,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))
@@ -299,6 +358,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")
@@ -468,30 +532,9 @@ class AppsViewModel(
}
}
- private suspend fun updateAppInDatabase(
- 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")
- } catch (e: Exception) {
- logger.error("Failed to update database: ${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/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/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/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..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
@@ -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,8 @@ sealed interface DetailsAction {
data object UpdateApp : DetailsAction
data class OnMessage(val messageText: StringResource) : DetailsAction
+
+ 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..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
@@ -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,10 @@ class DetailsViewModel(
return@launch
}
- val installable = latestRelease?.assets?.filter { asset ->
- installer.isAssetInstallable(asset.name)
- }.orEmpty()
+ 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 +255,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 +297,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 +309,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 +332,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 +353,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 +363,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 +414,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 +426,7 @@ class DetailsViewModel(
downloadUrl = latestAsset.downloadUrl,
assetName = latestAsset.name,
sizeBytes = latestAsset.size,
- releaseTag = latestRelease.tagName,
+ releaseTag = selectedRelease.tagName,
isUpdate = true
)
}
@@ -455,7 +469,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 +537,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 +559,46 @@ class DetailsViewModel(
}
}
+ 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 +914,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/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 6c28f5cd..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
@@ -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
@@ -78,7 +77,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/VersionPicker.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt
new file mode 100644
index 00000000..8cff449a
--- /dev/null
+++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/VersionPicker.kt
@@ -0,0 +1,265 @@
+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.LazyRow
+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()) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ 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)
+ }
+ )
+ }
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.height(8.dp))
+
+ 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
+ )
+ }
+ }
+ }
+
+ 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 {
+ val latestReleaseId = filteredReleases.firstOrNull()?.id
+
+ LazyColumn(
+ modifier = Modifier.fillMaxWidth(),
+ contentPadding = PaddingValues(vertical = 8.dp)
+ ) {
+ items(
+ items = filteredReleases,
+ key = { it.id }
+ ) { release ->
+ VersionListItem(
+ release = release,
+ isSelected = release.id == selectedRelease?.id,
+ isLatest = release.id == latestReleaseId,
+ onClick = { onAction(DetailsAction.SelectRelease(release)) }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun VersionListItem(
+ release: GithubRelease,
+ isSelected: Boolean,
+ isLatest: Boolean,
+ onClick: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(
+ onClickLabel = stringResource(Res.string.select_version),
+ 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 (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),
+ 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)
+ )
+ }
+ }
+}
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,
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(