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(