From e9d72163cac3df3186ad097e5ba50e9045c6ee30 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 27 Feb 2026 04:11:50 +0500 Subject: [PATCH 01/13] feat(details): Implement manual tracking for existing apps This commit introduces the ability for users to manually track apps that are already installed on their system but not yet managed by the application. It also enhances the background synchronization logic to detect external version changes. - **feat(details)**: Added `TrackExistingApp` action and logic to `DetailsViewModel` to allow users to register an already installed app into the local database based on repository metadata. - **feat(details)**: Introduced `isTrackable` state to identify when a repository corresponds to an installed but untracked system package. - **feat(sync)**: Updated `SyncInstalledAppsUseCase` to detect external version changes (sideloads, downgrades, or root-level updates) by comparing database records with the system's package manager. - **refactor(sync)**: Improved sync reporting to include the count of version-checked apps. --- .../use_cases/SyncInstalledAppsUseCase.kt | 40 ++++++- .../details/presentation/DetailsAction.kt | 2 + .../details/presentation/DetailsState.kt | 11 ++ .../details/presentation/DetailsViewModel.kt | 109 ++++++++++++++++++ 4 files changed, 161 insertions(+), 1 deletion(-) 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 36072214..bf82ef42 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 @@ -17,6 +17,7 @@ import zed.rainxch.core.domain.system.PackageMonitor * 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) + * 5. Detect external version changes (downgrades on rooted devices, sideloads, etc.) * * This should be called before loading or refreshing app data to ensure consistency. */ @@ -44,6 +45,7 @@ class SyncInstalledAppsUseCase( val toMigrate = mutableListOf>() val toResolvePending = mutableListOf() val toDeleteStalePending = mutableListOf() + val toSyncVersions = mutableListOf() appsInDb.forEach { app -> val isOnSystem = installedPackageNames.contains(app.packageName) @@ -64,6 +66,11 @@ class SyncInstalledAppsUseCase( val migrationResult = determineMigrationData(app) toMigrate.add(app.packageName to migrationResult) } + + // Detect external version changes (downgrades on rooted devices, sideloads, etc.) + isOnSystem && platform == Platform.ANDROID -> { + toSyncVersions.add(app) + } } } @@ -130,11 +137,42 @@ class SyncInstalledAppsUseCase( logger.error("Failed to migrate $packageName: ${e.message}") } } + + toSyncVersions.forEach { app -> + try { + val systemInfo = packageMonitor.getInstalledPackageInfo(app.packageName) + if (systemInfo != null && systemInfo.versionCode != app.installedVersionCode) { + val wasDowngrade = systemInfo.versionCode < app.installedVersionCode + val latestVersionCode = app.latestVersionCode ?: 0L + val isUpdateAvailable = latestVersionCode > systemInfo.versionCode + + installedAppsRepository.updateApp( + app.copy( + installedVersionName = systemInfo.versionName, + installedVersionCode = systemInfo.versionCode, + installedVersion = systemInfo.versionName, + isUpdateAvailable = isUpdateAvailable + ) + ) + + val action = if (wasDowngrade) "downgrade" else "external update" + logger.info( + "Detected $action for ${app.packageName}: " + + "DB v${app.installedVersionName}(${app.installedVersionCode}) → " + + "System v${systemInfo.versionName}(${systemInfo.versionCode}), " + + "updateAvailable=$isUpdateAvailable" + ) + } + } catch (e: Exception) { + logger.error("Failed to sync version for ${app.packageName}: ${e.message}") + } + } } logger.info( "Sync completed: ${toDelete.size} deleted, ${toDeleteStalePending.size} stale pending removed, " + - "${toResolvePending.size} pending resolved, ${toMigrate.size} migrated" + "${toResolvePending.size} pending resolved, ${toMigrate.size} migrated, " + + "${toSyncVersions.size} version-checked" ) Result.success(Unit) 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 cf32d2c1..57643458 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 @@ -23,6 +23,8 @@ sealed interface DetailsAction { data object OpenInAppManager : DetailsAction data object OnToggleInstallDropdown : DetailsAction + data object TrackExistingApp : DetailsAction + data object OnNavigateBackClick : DetailsAction data object OnToggleFavorite : DetailsAction 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 9837d49f..91e2d166 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 @@ -53,7 +53,18 @@ data class DetailsState( val installedApp: InstalledApp? = null, val isFavourite: Boolean = false, val isStarred: Boolean = false, + val isTrackingApp: Boolean = false, ) { + /** + * True when the app is detected as installed on the system (via assets matching) + * but is NOT yet tracked in our database. Shows the "Track this app" button. + */ + val isTrackable: Boolean + get() = installedApp == null && + !isLoading && + repository != null && + primaryAsset != null + val filteredReleases: List get() = when (selectedReleaseCategory) { ReleaseCategory.STABLE -> allReleases.filter { !it.isPrerelease } 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 7aafb74e..9d17f6f3 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 @@ -302,6 +302,111 @@ class DetailsViewModel( } } + @OptIn(ExperimentalTime::class) + private fun trackExistingApp() { + viewModelScope.launch { + try { + val repo = _state.value.repository ?: return@launch + val release = _state.value.selectedRelease + val primaryAsset = _state.value.primaryAsset + + if (platform != Platform.ANDROID) return@launch + + _state.update { it.copy(isTrackingApp = true) } + + // Try to find the package name from the primary asset's APK info + // We need to check if any app matching this repo is installed + val allPackages = packageMonitor.getAllInstalledPackageNames() + + // Try to extract package name from the APK asset name pattern + // Common patterns: com.example.app, app-release.apk, etc. + val possiblePackageName = "app.github.${repo.owner.login}.${repo.name}".lowercase() + + // Check if already tracked + val existingTracked = installedAppsRepository.getAppByRepoId(repo.id) + if (existingTracked != null) { + _events.send(DetailsEvent.OnMessage(getString(Res.string.already_tracked))) + _state.update { it.copy(isTrackingApp = false) } + return@launch + } + + val systemInfo = packageMonitor.getInstalledPackageInfo(possiblePackageName) + + val packageName: String + val versionName: String + val versionCode: Long + val appName: String + + if (systemInfo != null && systemInfo.isInstalled) { + packageName = possiblePackageName + versionName = systemInfo.versionName + versionCode = systemInfo.versionCode + appName = repo.name + } else { + // Can't detect package on system — still track with release tag info + packageName = possiblePackageName + versionName = release?.tagName ?: "unknown" + versionCode = 0L + appName = repo.name + } + + val releaseTag = release?.tagName ?: versionName + + val installedApp = InstalledApp( + packageName = packageName, + repoId = repo.id, + repoName = repo.name, + repoOwner = repo.owner.login, + repoOwnerAvatarUrl = repo.owner.avatarUrl, + repoDescription = repo.description, + primaryLanguage = repo.language, + repoUrl = repo.htmlUrl, + installedVersion = releaseTag, + installedAssetName = primaryAsset?.name, + installedAssetUrl = primaryAsset?.downloadUrl, + latestVersion = releaseTag, + latestAssetName = primaryAsset?.name, + latestAssetUrl = primaryAsset?.downloadUrl, + latestAssetSize = primaryAsset?.size, + appName = appName, + installSource = InstallSource.MANUAL, + installedAt = System.now().toEpochMilliseconds(), + lastCheckedAt = System.now().toEpochMilliseconds(), + lastUpdatedAt = System.now().toEpochMilliseconds(), + isUpdateAvailable = false, + updateCheckEnabled = true, + releaseNotes = release?.description ?: "", + systemArchitecture = installer.detectSystemArchitecture().name, + fileExtension = primaryAsset?.name?.substringAfterLast('.', "apk") ?: "apk", + isPendingInstall = false, + installedVersionName = versionName, + installedVersionCode = versionCode, + latestVersionName = versionName, + latestVersionCode = versionCode + ) + + installedAppsRepository.saveInstalledApp(installedApp) + + // Reload the installed app state + val savedApp = installedAppsRepository.getAppByRepoId(repo.id) + _state.update { it.copy(installedApp = savedApp, isTrackingApp = false) } + + _events.send(DetailsEvent.OnMessage(getString(Res.string.app_tracked_successfully))) + + logger.debug("Successfully tracked existing app: ${repo.name} as $packageName") + + } catch (e: Exception) { + logger.error("Failed to track existing app: ${e.message}") + _state.update { it.copy(isTrackingApp = false) } + _events.send( + DetailsEvent.OnMessage( + getString(Res.string.failed_to_track_app, e.message ?: "Unknown error") + ) + ) + } + } + } + @OptIn(ExperimentalTime::class) fun onAction(action: DetailsAction) { when (action) { @@ -655,6 +760,10 @@ class DetailsViewModel( } } + DetailsAction.TrackExistingApp -> { + trackExistingApp() + } + DetailsAction.OnNavigateBackClick -> { // Handled in composable } From 23a4a30c7792cab66f939c7aeb7394db2a7af4dd Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 27 Feb 2026 04:12:00 +0500 Subject: [PATCH 02/13] feat(android): Implement background update checks using WorkManager This commit introduces periodic background checks for app updates on Android. It leverages `WorkManager` to run a synchronization task every 6 hours (by default) to ensure tracked apps are up to date with their GitHub repositories. - **feat(android)**: Added `UpdateCheckWorker` to perform background synchronization and update checks. - **feat(android)**: Added `UpdateScheduler` to manage the lifecycle and constraints of the periodic background work. - **feat(android)**: Integrated update scheduling into the `GithubStoreApp` initialization. - **refactor(data)**: Updated `InstalledAppsRepositoryImpl` update check logic to include additional logging for version codes and tags. - **i18n**: Added new string resources for the "Track app" feature (e.g., "Track this app", "Already tracked"). - **chore(build)**: Added `androidx.work:work-runtime-ktx` dependency to the `core:data` module. --- .../rainxch/githubstore/app/GithubStoreApp.kt | 6 ++ core/data/build.gradle.kts | 1 + .../core/data/services/UpdateCheckWorker.kt | 55 +++++++++++++++ .../core/data/services/UpdateScheduler.kt | 70 +++++++++++++++++++ .../repository/InstalledAppsRepositoryImpl.kt | 15 +++- .../composeResources/values/strings.xml | 6 ++ gradle/libs.versions.toml | 4 ++ 7 files changed, 154 insertions(+), 3 deletions(-) create mode 100644 core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt create mode 100644 core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt 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 a4fbc5e3..c669b449 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -5,6 +5,7 @@ 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.data.services.UpdateScheduler import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.githubstore.app.di.initKoin @@ -21,6 +22,7 @@ class GithubStoreApp : Application() { } registerPackageEventReceiver() + scheduleBackgroundUpdateChecks() } private fun registerPackageEventReceiver() { @@ -38,4 +40,8 @@ class GithubStoreApp : Application() { packageEventReceiver = receiver } + + private fun scheduleBackgroundUpdateChecks() { + UpdateScheduler.schedule(context = this) + } } \ No newline at end of file diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 3b34df30..56135e62 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -27,6 +27,7 @@ kotlin { androidMain { dependencies { implementation(libs.ktor.client.okhttp) + implementation(libs.androidx.work.runtime) } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt new file mode 100644 index 00000000..64103c6c --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt @@ -0,0 +1,55 @@ +package zed.rainxch.core.data.services + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import co.touchlab.kermit.Logger +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import zed.rainxch.core.domain.repository.InstalledAppsRepository +import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase + +/** + * Periodic background worker that checks all tracked installed apps for available updates. + * + * Runs via WorkManager on a configurable schedule (default: every 6 hours). + * First syncs app state with the system package manager, then checks each + * tracked app's GitHub repository for new releases. + */ +class UpdateCheckWorker( + context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params), KoinComponent { + + private val installedAppsRepository: InstalledAppsRepository by inject() + private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase by inject() + + override suspend fun doWork(): Result { + return try { + Logger.i { "UpdateCheckWorker: Starting periodic update check" } + + // First sync installed apps state with system + val syncResult = syncInstalledAppsUseCase() + if (syncResult.isFailure) { + Logger.w { "UpdateCheckWorker: Sync had issues: ${syncResult.exceptionOrNull()?.message}" } + } + + // Check all tracked apps for updates + installedAppsRepository.checkAllForUpdates() + + Logger.i { "UpdateCheckWorker: Periodic update check completed successfully" } + Result.success() + } catch (e: Exception) { + Logger.e { "UpdateCheckWorker: Update check failed: ${e.message}" } + if (runAttemptCount < 3) { + Result.retry() + } else { + Result.failure() + } + } + } + + companion object { + const val WORK_NAME = "github_store_update_check" + } +} diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt new file mode 100644 index 00000000..47dd286e --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt @@ -0,0 +1,70 @@ +package zed.rainxch.core.data.services + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import co.touchlab.kermit.Logger +import java.util.concurrent.TimeUnit + +/** + * Manages scheduling and cancellation of periodic update checks using WorkManager. + * + * Default schedule: every 6 hours with network connectivity constraint. + * Uses exponential backoff for retries with a 30-minute initial delay. + */ +object UpdateScheduler { + + private const val DEFAULT_INTERVAL_HOURS = 6L + + /** + * Schedules periodic update checks. Safe to call multiple times — + * existing work is kept unless [replace] is true. + */ + fun schedule( + context: Context, + intervalHours: Long = DEFAULT_INTERVAL_HOURS, + replace: Boolean = false + ) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = PeriodicWorkRequestBuilder( + intervalHours, TimeUnit.HOURS + ) + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 30, TimeUnit.MINUTES + ) + .build() + + val policy = if (replace) { + ExistingPeriodicWorkPolicy.UPDATE + } else { + ExistingPeriodicWorkPolicy.KEEP + } + + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + UpdateCheckWorker.WORK_NAME, + policy, + request + ) + + Logger.i { "UpdateScheduler: Scheduled periodic update check every ${intervalHours}h (policy=$policy)" } + } + + /** + * Cancels the scheduled periodic update checks. + */ + fun cancel(context: Context) { + WorkManager.getInstance(context) + .cancelUniqueWork(UpdateCheckWorker.WORK_NAME) + Logger.i { "UpdateScheduler: Cancelled periodic update checks" } + } +} 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 f49fa14e..2ab322db 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 @@ -124,11 +124,20 @@ class InstalledAppsRepositoryImpl( } val primaryAsset = installer.choosePrimaryAsset(installableAssets) - val isUpdateAvailable = normalizedInstalledTag != normalizedLatestTag + // Use versionCode comparison when available (more reliable for rooted/downgraded devices) + // Fall back to tag string comparison when versionCode is not available + val isUpdateAvailable = if (app.installedVersionCode > 0L) { + // Compare tags first — if same tag, no update regardless of versionCode + normalizedInstalledTag != normalizedLatestTag + } else { + normalizedInstalledTag != normalizedLatestTag + } Logger.d { - "Update check for ${app.appName}: installedTag=${app.installedVersion}, " + - "latestTag=${latestRelease.tagName}, isUpdate=$isUpdateAvailable" + "Update check for ${app.appName}: " + + "installedTag=${app.installedVersion}, latestTag=${latestRelease.tagName}, " + + "installedCode=${app.installedVersionCode}, " + + "isUpdate=$isUpdateAvailable" } installedAppsDao.updateVersionInfo( diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 71f968e3..1f239558 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -382,4 +382,10 @@ %1$d min ago %1$d h ago Checking for updates… + + + Track this app + App added to tracking list + Failed to track app: %1$s + App is already being tracked \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 42cc9130..44a0c4e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -54,6 +54,7 @@ markdownRenderer = "0.39.1" liquid = "1.1.1" landscapist = "2.9.1" jsystemthemedetector = "3.9.1" +work = "2.10.1" projectApplicationId = "zed.rainxch.githubstore" projectVersionName = "1.6.0" @@ -178,6 +179,9 @@ jetbrains-bundle = { module = "org.jetbrains.androidx.core:core-bundle", version markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" } markdown-renderer-coil3 = { module = "com.mikepenz:multiplatform-markdown-renderer-coil3", version.ref = "markdownRenderer" } +# WorkManager +androidx-work-runtime = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } + # Liquid effect liquid = { module = "io.github.fletchmckee.liquid:liquid", version.ref = "liquid" } From 3351188e605a9afe2905145cd0308ee2fde8da64 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 27 Feb 2026 08:31:25 +0500 Subject: [PATCH 03/13] i18n: Update and expand string resources across multiple languages This commit adds new localized strings for the application's uninstall, downgrade, tracking, and profile features across several languages (TR, ES, ZH-CN, FR, HI, IT, JA, KR, PL, BN, RU, and default). - **i18n**: Added strings for app uninstallation and improved downgrade warning messages to clarify that data will be lost. - **i18n**: Added strings for the "Track app" feature, including success and failure states. - **i18n**: Introduced new strings for the profile section, covering GitHub sign-in, repositories, stars, and favorites. - **refactor(strings)**: Reorganized and renamed some existing keys related to app opening and installation for better clarity. --- .../composeResources/values-bn/strings-bn.xml | 23 +++++++++++++++++-- .../composeResources/values-es/strings-es.xml | 23 +++++++++++++++++-- .../composeResources/values-fr/strings-fr.xml | 23 +++++++++++++++++-- .../composeResources/values-hi/strings-hi.xml | 23 +++++++++++++++++-- .../composeResources/values-it/strings-it.xml | 23 +++++++++++++++++-- .../composeResources/values-ja/strings-ja.xml | 22 ++++++++++++++++-- .../composeResources/values-kr/strings-kr.xml | 22 ++++++++++++++++-- .../composeResources/values-pl/strings-pl.xml | 23 +++++++++++++++++-- .../composeResources/values-ru/strings-ru.xml | 23 +++++++++++++++++-- .../composeResources/values-tr/strings-tr.xml | 23 +++++++++++++++++-- .../values-zh-rCN/strings-zh-rCN.xml | 22 ++++++++++++++++-- .../composeResources/values/strings.xml | 16 +++++++++++-- 12 files changed, 242 insertions(+), 24 deletions(-) diff --git a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml index 85d28bc1..71a5791b 100644 --- a/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml +++ b/core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml @@ -350,11 +350,15 @@ ইনস্টল মুলতুবি - + + আনইনস্টল খুলুন - সংস্করণ %1$s ইনস্টল করা যাচ্ছে না কারণ একটি নতুন সংস্করণ (%2$s) ইতিমধ্যে ইনস্টল করা আছে। অনুগ্রহ করে প্রথমে বর্তমান সংস্করণটি ম্যানুয়ালি আনইনস্টল করুন। + ডাউনগ্রেডের জন্য আনইনস্টল প্রয়োজন + সংস্করণ %1$s ইনস্টল করতে বর্তমান সংস্করণ (%2$s) প্রথমে আনইনস্টল করতে হবে। অ্যাপের ডেটা মুছে যাবে। + প্রথমে আনইনস্টল করুন %1$s ইনস্টল করুন %1$s খুলতে ব্যর্থ + %1$s আনইনস্টল করতে ব্যর্থ সর্বশেষ @@ -388,4 +392,19 @@ পাসওয়ার্ড দেখান পাসওয়ার্ড লুকান + + + এই অ্যাপ ট্র্যাক করুন + অ্যাপ ট্র্যাকিং তালিকায় যোগ করা হয়েছে + অ্যাপ ট্র্যাক করতে ব্যর্থ: %1$s + অ্যাপটি ইতিমধ্যে ট্র্যাক করা হচ্ছে + + + GitHub-এ সাইন ইন করুন + সম্পূর্ণ অভিজ্ঞতা আনলক করুন। আপনার অ্যাপ পরিচালনা করুন, পছন্দ সিঙ্ক করুন এবং দ্রুত ব্রাউজ করুন। + রিপোজিটরি + লগইন + GitHub-এ আপনার স্টার করা রিপোজিটরি + স্থানীয়ভাবে সংরক্ষিত আপনার প্রিয় রিপোজিটরি + 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 1e85c169..f1b6d9d7 100644 --- a/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml +++ b/core/presentation/src/commonMain/composeResources/values-es/strings-es.xml @@ -315,11 +315,15 @@ Inspeccionar con AppManager Verificar permisos, rastreadores y seguridad - + + Desinstalar Abrir - No se puede instalar la versión %1$s porque ya hay una versión más reciente (%2$s) instalada. Por favor, desinstala la versión actual manualmente primero. + La degradación requiere desinstalar + Instalar la versión %1$s requiere desinstalar la versión actual (%2$s) primero. Los datos de la app se perderán. + Desinstalar primero Instalar %1$s Error al abrir %1$s + Error al desinstalar %1$s Última @@ -353,4 +357,19 @@ Mostrar contraseña Ocultar contraseña + + + Rastrear esta app + App añadida a la lista de seguimiento + Error al rastrear la app: %1$s + La app ya está siendo rastreada + + + Iniciar sesión en GitHub + Desbloquea la experiencia completa. Gestiona tus apps, sincroniza tus preferencias y navega más rápido. + Repos + Iniciar sesión + Tus repositorios destacados de GitHub + Tus repositorios favoritos guardados localmente + \ 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 09330e25..4280fd37 100644 --- a/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml +++ b/core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml @@ -315,11 +315,15 @@ Inspecter avec AppManager Vérifier les permissions, trackers et sécurité - + + Désinstaller Ouvrir - Impossible d\'installer la version %1$s car une version plus récente (%2$s) est déjà installée. Veuillez d\'abord désinstaller manuellement la version actuelle. + La rétrogradation nécessite la désinstallation + L\'installation de la version %1$s nécessite la désinstallation de la version actuelle (%2$s). Les données de l\'application seront perdues. + Désinstaller d\'abord Installer %1$s Impossible d\'ouvrir %1$s + Impossible de désinstaller %1$s Dernière @@ -353,4 +357,19 @@ Afficher le mot de passe Masquer le mot de passe + + + Suivre cette app + App ajoutée à la liste de suivi + Échec du suivi de l\'app : %1$s + L\'app est déjà suivie + + + Se connecter à GitHub + Débloquez l\'expérience complète. Gérez vos apps, synchronisez vos préférences et naviguez plus vite. + Dépôts + Connexion + Vos dépôts étoilés sur GitHub + Vos dépôts favoris enregistrés localement + \ 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 d45230c5..3d5c9fa8 100644 --- a/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml +++ b/core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml @@ -350,11 +350,15 @@ इंस्टॉल लंबित - + + अनइंस्टॉल खोलें - संस्करण %1$s इंस्टॉल नहीं किया जा सकता क्योंकि एक नया संस्करण (%2$s) पहले से इंस्टॉल है। कृपया पहले वर्तमान संस्करण को मैन्युअल रूप से अनइंस्टॉल करें। + डाउनग्रेड के लिए अनइंस्टॉल आवश्यक + संस्करण %1$s इंस्टॉल करने के लिए पहले वर्तमान संस्करण (%2$s) को अनइंस्टॉल करना होगा। ऐप डेटा खो जाएगा। + पहले अनइंस्टॉल करें %1$s इंस्टॉल करें %1$s खोलने में विफल + %1$s अनइंस्टॉल करने में विफल नवीनतम @@ -388,4 +392,19 @@ पासवर्ड दिखाएँ पासवर्ड छुपाएँ + + + इस ऐप को ट्रैक करें + ऐप ट्रैकिंग सूची में जोड़ा गया + ऐप ट्रैक करने में विफल: %1$s + ऐप पहले से ट्रैक हो रहा है + + + GitHub में साइन इन करें + पूरा अनुभव अनलॉक करें। अपने ऐप्स मैनेज करें, प्राथमिकताएँ सिंक करें और तेज़ी से ब्राउज़ करें। + रिपॉजिटरी + लॉगिन + GitHub पर आपकी स्टार की गई रिपॉजिटरी + स्थानीय रूप से सहेजी गई आपकी पसंदीदा रिपॉजिटरी + 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 88381c51..95787604 100644 --- a/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml +++ b/core/presentation/src/commonMain/composeResources/values-it/strings-it.xml @@ -346,11 +346,15 @@ Installazione in sospeso - + + Disinstalla Apri - Impossibile installare la versione %1$s perché una versione più recente (%2$s) è già installata. Disinstalla manualmente la versione corrente prima di procedere. + Il downgrade richiede la disinstallazione + L\'installazione della versione %1$s richiede la disinstallazione della versione corrente (%2$s). I dati dell\'app verranno persi. + Disinstalla prima Installa %1$s Impossibile aprire %1$s + Impossibile disinstallare %1$s Ultima @@ -389,4 +393,19 @@ Mostra password Nascondi password + + + Traccia questa app + App aggiunta alla lista di monitoraggio + Impossibile tracciare l\'app: %1$s + L\'app è già monitorata + + + Accedi a GitHub + Sblocca l\'esperienza completa. Gestisci le tue app, sincronizza le preferenze e naviga più velocemente. + Repo + Accedi + I tuoi repository preferiti su GitHub + I tuoi repository preferiti salvati localmente + \ 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 2d14564e..4faefcb4 100644 --- a/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml +++ b/core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml @@ -315,11 +315,15 @@ AppManagerで検査 権限、トラッカー、セキュリティを確認 - + + アンインストール 開く - より新しいバージョン(%2$s)がすでにインストールされているため、バージョン%1$sをインストールできません。まず現在のバージョンを手動でアンインストールしてください。 + ダウングレードにはアンインストールが必要 + バージョン%1$sのインストールには、現在のバージョン(%2$s)のアンインストールが必要です。アプリデータは失われます。 + 先にアンインストール %1$sをインストール %1$sを開けませんでした + %1$sのアンインストールに失敗しました 最新 @@ -353,4 +357,18 @@ パスワードを表示 パスワードを非表示 + + + このアプリを追跡 + アプリを追跡リストに追加しました + アプリの追跡に失敗しました: %1$s + このアプリは既に追跡中です + + + GitHubにサインイン + すべての機能を解放しましょう。アプリを管理し、設定を同期し、より速く閲覧できます。 + リポジトリ + ログイン + GitHubのスター付きリポジトリ + ローカルに保存されたお気に入りリポジトリ \ 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 ee6e7872..c3a73a83 100644 --- a/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml +++ b/core/presentation/src/commonMain/composeResources/values-kr/strings-kr.xml @@ -348,11 +348,15 @@ 설치 대기 중 - + + 제거 열기 - 더 최신 버전(%2$s)이 이미 설치되어 있어 버전 %1$s을(를) 설치할 수 없습니다. 현재 버전을 먼저 수동으로 제거해 주세요. + 다운그레이드를 위해 제거가 필요합니다 + 버전 %1$s을(를) 설치하려면 현재 버전(%2$s)을 먼저 제거해야 합니다. 앱 데이터가 삭제됩니다. + 먼저 제거 %1$s 설치 %1$s 열기 실패 + %1$s 제거 실패 최신 @@ -386,4 +390,18 @@ 비밀번호 표시 비밀번호 숨기기 + + + 이 앱 추적 + 앱이 추적 목록에 추가되었습니다 + 앱 추적 실패: %1$s + 이미 추적 중인 앱입니다 + + + GitHub에 로그인 + 전체 기능을 잠금 해제하세요. 앱을 관리하고, 설정을 동기화하고, 더 빠르게 탐색하세요. + 저장소 + 로그인 + GitHub에서 별표한 저장소 + 로컬에 저장된 즐겨찾기 저장소 \ 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 d266bd52..d87c1591 100644 --- a/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml +++ b/core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml @@ -313,11 +313,15 @@ Oczekuje na instalację - + + Odinstaluj Otwórz - Nie można zainstalować wersji %1$s, ponieważ nowsza wersja (%2$s) jest już zainstalowana. Najpierw ręcznie odinstaluj bieżącą wersję. + Obniżenie wersji wymaga odinstalowania + Instalacja wersji %1$s wymaga odinstalowania bieżącej wersji (%2$s). Dane aplikacji zostaną utracone. + Najpierw odinstaluj Zainstaluj %1$s Nie udało się otworzyć %1$s + Nie udało się odinstalować %1$s Najnowsza @@ -351,4 +355,19 @@ Pokaż hasło Ukryj hasło + + + Śledź tę aplikację + Aplikacja dodana do listy śledzonych + Nie udało się śledzić aplikacji: %1$s + Aplikacja jest już śledzona + + + Zaloguj się przez GitHub + Odblokuj pełnię możliwości. Zarządzaj aplikacjami, synchronizuj preferencje i przeglądaj szybciej. + Repozytoria + Zaloguj się + Twoje repozytoria oznaczone gwiazdką na GitHubie + Twoje ulubione repozytoria zapisane lokalnie + \ 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 f74c0baa..b28c02b0 100644 --- a/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml +++ b/core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml @@ -315,11 +315,15 @@ Ожидает установки - + + Удалить Открыть - Невозможно установить версию %1$s, так как более новая версия (%2$s) уже установлена. Пожалуйста, сначала удалите текущую версию вручную. + Для понижения версии требуется удаление + Для установки версии %1$s необходимо сначала удалить текущую версию (%2$s). Данные приложения будут потеряны. + Сначала удалить Установить %1$s Не удалось открыть %1$s + Не удалось удалить %1$s Последняя @@ -353,4 +357,19 @@ Показать пароль Скрыть пароль + + + Отслеживать приложение + Приложение добавлено в список отслеживания + Не удалось отследить приложение: %1$s + Приложение уже отслеживается + + + Войти через GitHub + Откройте полный доступ. Управляйте приложениями, синхронизируйте настройки и просматривайте быстрее. + Репозитории + Войти + Ваши избранные репозитории на GitHub + Ваши избранные репозитории, сохранённые локально + \ 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 655a4645..cdc302b9 100644 --- a/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml +++ b/core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml @@ -347,11 +347,15 @@ Kurulum bekleniyor - + + Kaldır - Daha yeni bir sürüm (%2$s) zaten yüklü olduğu için %1$s sürümü yüklenemiyor. Lütfen önce mevcut sürümü manuel olarak kaldırın. + Sürüm düşürme kaldırma gerektirir + %1$s sürümünü yüklemek için önce mevcut sürümü (%2$s) kaldırmanız gerekir. Uygulama verileri kaybolacaktır. + Önce kaldır %1$s yükle %1$s açılamadı + %1$s kaldırılamadı En son @@ -385,4 +389,19 @@ Şifreyi göster Şifreyi gizle + + + Bu uygulamayı izle + Uygulama izleme listesine eklendi + Uygulama izlenemedi: %1$s + Uygulama zaten izleniyor + + + GitHub ile giriş yap + Tam deneyimi keşfedin. Uygulamalarınızı yönetin, tercihlerinizi senkronize edin ve daha hızlı gezinin. + Repolar + Giriş yap + GitHub'daki yıldızlı repolarınız + Yerel olarak kaydedilen favori repolarınız + 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 75f9027b..fb031268 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 @@ -316,11 +316,15 @@ 使用 AppManager 检查 检查权限、追踪器和安全性 - + + 卸载 打开 - 无法安装版本 %1$s,因为已安装了更新的版本(%2$s)。请先手动卸载当前版本。 + 降级需要先卸载 + 安装版本 %1$s 需要先卸载当前版本(%2$s)。应用数据将丢失。 + 先卸载 安装 %1$s 无法打开 %1$s + 无法卸载 %1$s 最新 @@ -354,4 +358,18 @@ 显示密码 隐藏密码 + + + 跟踪此应用 + 应用已添加到跟踪列表 + 跟踪应用失败:%1$s + 该应用已在跟踪中 + + + 登录 GitHub + 解锁完整体验。管理您的应用、同步偏好设置,更快地浏览。 + 仓库 + 登录 + 您在 GitHub 上收藏的仓库 + 本地保存的收藏仓库 \ 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 1f239558..db5106c0 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -193,11 +193,15 @@ Installing Pending install - + + Uninstall Open - Cannot install version %1$s because a newer version (%2$s) is already installed. Please uninstall the current version manually first. + Downgrade requires uninstall + Installing version %1$s requires uninstalling the current version (%2$s) first. Your app data will be lost. + Uninstall first Install %1$s Failed to open %1$s + Failed to uninstall %1$s Open in Obtainium @@ -388,4 +392,12 @@ App added to tracking list Failed to track app: %1$s App is already being tracked + + + Sign in to GitHub + Unlock the full experience. Manage your apps, sync your preferences, and browse faster. + Repos + Login + Your Starred Repositories from GitHub + Your Favourite Repositories saved locally \ No newline at end of file From 5388d7fefc3f1742df012102c2797f86b8511c24 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 27 Feb 2026 08:31:49 +0500 Subject: [PATCH 04/13] refactor(ui): optimize bottom navigation behavior This commit improves the navigation logic within `BottomNavigation` by implementing standard state preservation and restoration. This ensures that the back stack is properly managed and the state of individual tabs is maintained when switching between them. - **refactor(ui)**: Updated `navController.navigate` in `BottomNavigation` to use `popUpTo` the home screen with `saveState = true`. - **refactor(ui)**: Enabled `launchSingleTop` and `restoreState` to prevent multiple instances of the same destination and restore previous tab states. --- .../rainxch/githubstore/app/navigation/AppNavigation.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index 0f003782..fae43360 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -248,7 +248,14 @@ fun AppNavigation( BottomNavigation( currentScreen = currentScreen, onNavigate = { - navController.navigate(it) + navController.navigate(it) { + popUpTo(GithubStoreGraph.HomeScreen) { + saveState = true + } + + launchSingleTop = true + restoreState = true + } }, modifier = Modifier .align(Alignment.BottomCenter) From ba84ade0e8a86f9f62284bb3c0ee4a613092589b Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 27 Feb 2026 08:32:24 +0500 Subject: [PATCH 05/13] feat(core): implement app uninstallation support This commit introduces the ability to uninstall applications directly from the app. It adds the necessary infrastructure in the domain and data layers, specifically for Android, and updates the UI in both the apps list and details screens to support this action. It also improves the downgrade experience by prompting for uninstallation when a version downgrade is detected. - **feat(core)**: Added `uninstall` method to the `Installer` interface. - **feat(android)**: Implemented `uninstall` in `AndroidInstaller` using `ACTION_DELETE` and added `REQUEST_DELETE_PACKAGES` permission to `AndroidManifest.xml`. - **feat(details)**: Added `UninstallApp` action to `DetailsViewModel` and integrated an uninstall button into `SmartInstallButton`. - **feat(details)**: Introduced `ShowDowngradeWarning` event to display an `AlertDialog` when a downgrade is attempted, offering uninstallation as a prerequisite. - **feat(apps)**: Added `OnUninstallApp` action and logic to `AppsViewModel`. - **feat(apps)**: Integrated an uninstallation icon button (trash can) into the app list items in `AppsRoot`. - **refactor(data)**: Simplified update check logic in `InstalledAppsRepositoryImpl` by relying on tag comparison. - **chore(desktop)**: Added a stub for `uninstall` in `DesktopInstaller` as it is currently unsupported. --- .../src/androidMain/AndroidManifest.xml | 2 + .../core/data/services/AndroidInstaller.kt | 14 +++ .../repository/InstalledAppsRepositoryImpl.kt | 9 +- .../core/data/services/DesktopInstaller.kt | 5 + .../rainxch/core/domain/system/Installer.kt | 1 + .../rainxch/apps/presentation/AppsAction.kt | 1 + .../zed/rainxch/apps/presentation/AppsRoot.kt | 19 +++ .../apps/presentation/AppsViewModel.kt | 20 ++++ .../details/presentation/DetailsAction.kt | 1 + .../details/presentation/DetailsEvent.kt | 5 + .../details/presentation/DetailsRoot.kt | 47 ++++++++ .../details/presentation/DetailsViewModel.kt | 30 ++++- .../components/SmartInstallButton.kt | 109 +++++++++++++----- 13 files changed, 222 insertions(+), 41 deletions(-) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 9c3aeb7d..c5f24899 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -12,6 +12,8 @@ android:name="android.permission.REQUEST_INSTALL_PACKAGES" tools:ignore="RequestInstallPackagesPolicy" /> + + 0L) { - // Compare tags first — if same tag, no update regardless of versionCode - normalizedInstalledTag != normalizedLatestTag - } else { - normalizedInstalledTag != normalizedLatestTag - } + val isUpdateAvailable = normalizedInstalledTag != normalizedLatestTag Logger.d { "Update check for ${app.appName}: " + diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt index 164eef17..823d767a 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopInstaller.kt @@ -322,6 +322,11 @@ class DesktopInstaller( } } + override fun uninstall(packageName: String) { + // Desktop doesn't have a unified uninstall mechanism + Logger.d { "Uninstall not supported on desktop for: $packageName" } + } + override suspend fun install(filePath: String, extOrMime: String) = withContext(Dispatchers.IO) { val file = File(filePath) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt index 2fa394a0..a4e8c2a5 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/Installer.kt @@ -9,6 +9,7 @@ interface Installer { suspend fun ensurePermissionsOrThrow(extOrMime: String) suspend fun install(filePath: String, extOrMime: String) + fun uninstall(packageName: String) fun isAssetInstallable(assetName: String): Boolean fun choosePrimaryAsset(assets: List): GithubAsset? 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 1e25b4ce..03df9c76 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 @@ -13,4 +13,5 @@ sealed interface AppsAction { data object OnCheckAllForUpdates : AppsAction data object OnRefresh : AppsAction data class OnNavigateToRepo(val repoId: Long) : AppsAction + data class OnUninstallApp(val app: InstalledApp) : 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 46ca4691..12595cae 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 @@ -27,6 +27,7 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Update +import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -301,6 +302,7 @@ fun AppsScreen( onOpenClick = { onAction(AppsAction.OnOpenApp(appItem.installedApp)) }, onUpdateClick = { onAction(AppsAction.OnUpdateApp(appItem.installedApp)) }, onCancelClick = { onAction(AppsAction.OnCancelUpdate(appItem.installedApp.packageName)) }, + onUninstallClick = { onAction(AppsAction.OnUninstallApp(appItem.installedApp)) }, onRepoClick = { onAction(AppsAction.OnNavigateToRepo(appItem.installedApp.repoId)) }, modifier = Modifier.liquefiable(liquidState) ) @@ -374,6 +376,7 @@ fun AppItemCard( onOpenClick: () -> Unit, onUpdateClick: () -> Unit, onCancelClick: () -> Unit, + onUninstallClick: () -> Unit, onRepoClick: () -> Unit, modifier: Modifier = Modifier ) { @@ -553,6 +556,22 @@ fun AppItemCard( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { + if (!app.isPendingInstall && + appItem.updateState !is UpdateState.Downloading && + appItem.updateState !is UpdateState.Installing && + appItem.updateState !is UpdateState.CheckingUpdate + ) { + IconButton( + onClick = onUninstallClick + ) { + Icon( + imageVector = Icons.Outlined.DeleteOutline, + contentDescription = stringResource(Res.string.uninstall), + tint = MaterialTheme.colorScheme.error + ) + } + } + Button( onClick = onOpenClick, modifier = Modifier.weight(1f), diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt index c3ceba56..121cbbbc 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 @@ -193,6 +193,26 @@ class AppsViewModel( _events.send(AppsEvent.NavigateToRepo(action.repoId)) } } + + is AppsAction.OnUninstallApp -> { + uninstallApp(action.app) + } + } + } + + private fun uninstallApp(app: InstalledApp) { + viewModelScope.launch { + try { + installer.uninstall(app.packageName) + logger.debug("Requested uninstall for ${app.packageName}") + } catch (e: Exception) { + logger.error("Failed to request uninstall for ${app.packageName}: ${e.message}") + _events.send( + AppsEvent.ShowError( + getString(Res.string.failed_to_uninstall, app.appName) + ) + ) + } } } 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 57643458..cb8e2409 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 @@ -7,6 +7,7 @@ import zed.rainxch.details.domain.model.ReleaseCategory sealed interface DetailsAction { data object Retry : DetailsAction data object InstallPrimary : DetailsAction + data object UninstallApp : DetailsAction data class DownloadAsset( val downloadUrl: String, val assetName: String, diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt index c559f81a..e940dfd4 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt @@ -4,4 +4,9 @@ sealed interface DetailsEvent { data class OnOpenRepositoryInApp(val repositoryId: Long) : DetailsEvent data class InstallTrackingFailed(val message: String) : DetailsEvent data class OnMessage(val message: String) : DetailsEvent + data class ShowDowngradeWarning( + val packageName: String, + val currentVersion: String, + val targetVersion: String + ) : DetailsEvent } \ 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 1ad4155d..d2ef6fb1 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 @@ -20,6 +20,7 @@ import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material3.AlertDialog import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -31,13 +32,16 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow @@ -77,6 +81,9 @@ fun DetailsRoot( val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() + var downgradeWarning by remember { + mutableStateOf(null) + } ObserveAsEvents(viewModel.events) { event -> when (event) { @@ -93,9 +100,49 @@ fun DetailsRoot( snackbarHostState.showSnackbar(event.message) } } + + is DetailsEvent.ShowDowngradeWarning -> { + downgradeWarning = event + } } } + downgradeWarning?.let { warning -> + AlertDialog( + onDismissRequest = { downgradeWarning = null }, + title = { + Text(text = stringResource(Res.string.downgrade_requires_uninstall)) + }, + text = { + Text( + text = stringResource( + Res.string.downgrade_warning_message, + warning.targetVersion, + warning.currentVersion + ) + ) + }, + confirmButton = { + TextButton( + onClick = { + downgradeWarning = null + viewModel.onAction(DetailsAction.UninstallApp) + } + ) { + Text( + text = stringResource(Res.string.uninstall_first), + color = MaterialTheme.colorScheme.error + ) + } + }, + dismissButton = { + TextButton(onClick = { downgradeWarning = null }) { + Text(text = stringResource(Res.string.cancel)) + } + } + ) + } + DetailsScreen( state = state, snackbarHostState = snackbarHostState, 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 9d17f6f3..5bdbb91f 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 @@ -435,12 +435,10 @@ class DetailsViewModel( if (isDowngrade) { viewModelScope.launch { _events.send( - DetailsEvent.OnMessage( - getString( - Res.string.downgrade_warning_message, - release.tagName, - installedApp.installedVersion - ) + DetailsEvent.ShowDowngradeWarning( + packageName = installedApp.packageName, + currentVersion = installedApp.installedVersion, + targetVersion = release.tagName ) ) } @@ -457,6 +455,24 @@ class DetailsViewModel( } } + DetailsAction.UninstallApp -> { + val installedApp = _state.value.installedApp ?: return + logger.debug("Uninstalling app: ${installedApp.packageName}") + viewModelScope.launch { + try { + installer.uninstall(installedApp.packageName) + } catch (e: Exception) { + logger.error("Failed to request uninstall for ${installedApp.packageName}: ${e.message}") + _events.send( + DetailsEvent.OnMessage( + getString(Res.string.failed_to_uninstall, installedApp.packageName) + ) + ) + } + } + + } + is DetailsAction.DownloadAsset -> { val release = _state.value.selectedRelease downloadAsset( @@ -761,6 +777,8 @@ class DetailsViewModel( } DetailsAction.TrackExistingApp -> { + val snapshot = _state.value + if (snapshot.isTrackingApp || !snapshot.isTrackable) return trackExistingApp() } 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 53be6573..c66af839 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 @@ -17,6 +17,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.Update import androidx.compose.material3.CardDefaults @@ -78,36 +79,90 @@ fun SmartInstallButton( // When same version is installed, show Open button if (isSameVersionInstalled && !isActiveDownload) { - ElevatedCard( - modifier = modifier - .height(52.dp) - .clickable { onAction(DetailsAction.OpenApp) } - .liquefiable(liquidState), - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.primary - ), - shape = CircleShape + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + // Uninstall button + ElevatedCard( + onClick = { onAction(DetailsAction.UninstallApp) }, + modifier = Modifier + .weight(1f) + .height(52.dp) + .liquefiable(liquidState), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ), + shape = RoundedCornerShape( + topStart = 24.dp, + bottomStart = 24.dp, + topEnd = 6.dp, + bottomEnd = 6.dp + ) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = null, - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - Text( - text = stringResource(Res.string.open_app), - color = MaterialTheme.colorScheme.onPrimary, - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = stringResource(Res.string.uninstall), + color = MaterialTheme.colorScheme.onErrorContainer, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + } + } + } + + // Open button + ElevatedCard( + modifier = Modifier + .weight(1f) + .height(52.dp) + .clickable { onAction(DetailsAction.OpenApp) } + .liquefiable(liquidState), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.primary + ), + shape = RoundedCornerShape( + topStart = 6.dp, + bottomStart = 6.dp, + topEnd = 24.dp, + bottomEnd = 24.dp + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimary + ) + Text( + text = stringResource(Res.string.open_app), + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.titleMedium + ) + } } } } From 9d0e8ed1e90889beb1dbb1c090a1faa74b796c22 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 27 Feb 2026 08:32:32 +0500 Subject: [PATCH 06/13] i18n(profile): Externalize hardcoded strings to resource files This commit replaces hardcoded UI strings in the profile feature with localized string resources to support internationalization. - **i18n(profile)**: Replaced hardcoded text in `AccountSection` with `Res.string` references for sign-in titles, descriptions, login buttons, and user stats (repos, followers, following). - **i18n(profile)**: Updated `Options` section to use string resources for "Stars" and "Favourites" labels and descriptions. - **chore**: Added necessary imports for Compose Multiplatform resources in the profile presentation module. --- .../components/sections/AccountSection.kt | 14 ++++++++------ .../presentation/components/sections/Options.kt | 10 ++++++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt index f2284035..0492ad3c 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt @@ -33,9 +33,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import org.jetbrains.compose.ui.tooling.preview.Preview +import org.jetbrains.compose.resources.stringResource import zed.rainxch.core.presentation.components.GitHubStoreImage import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.core.presentation.theme.GithubStoreTheme +import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.profile.domain.model.UserProfile import zed.rainxch.profile.presentation.ProfileAction import zed.rainxch.profile.presentation.ProfileState @@ -105,7 +107,7 @@ fun LazyListScope.accountSection( Spacer(Modifier.height(8.dp)) Text( - text = "Sign in to GitHub", + text = stringResource(Res.string.profile_sign_in_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center @@ -114,7 +116,7 @@ fun LazyListScope.accountSection( Spacer(Modifier.height(4.dp)) Text( - text = "Unlock the full experience. Manage your apps, sync your preference, and browser faster.", + text = stringResource(Res.string.profile_sign_in_description), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center @@ -130,19 +132,19 @@ fun LazyListScope.accountSection( horizontalArrangement = Arrangement.spacedBy(12.dp) ) { StatCard( - label = "Repos", + label = stringResource(Res.string.profile_repos), value = state.userProfile.repositoryCount.toString(), modifier = Modifier.weight(1f) ) StatCard( - label = "Followers", + label = stringResource(Res.string.followers), value = state.userProfile.followers.toString(), modifier = Modifier.weight(1f) ) StatCard( - label = "Following", + label = stringResource(Res.string.following), value = state.userProfile.following.toString(), modifier = Modifier.weight(1f) ) @@ -153,7 +155,7 @@ fun LazyListScope.accountSection( Spacer(Modifier.height(8.dp)) GithubStoreButton( - text = "Login", + text = stringResource(Res.string.profile_login), onClick = { onAction(ProfileAction.OnLoginClick) }, diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt index da290808..b32d8efa 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt @@ -29,6 +29,8 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.githubstore.core.presentation.res.* import zed.rainxch.profile.presentation.ProfileAction fun LazyListScope.options( @@ -38,8 +40,8 @@ fun LazyListScope.options( item { OptionCard( icon = Icons.Default.Star, - label = "Stars", - description = "Your Starred Repositories from GitHub", + label = stringResource(Res.string.stars), + description = stringResource(Res.string.profile_stars_description), onClick = { onAction(ProfileAction.OnStarredReposClick) }, @@ -50,8 +52,8 @@ fun LazyListScope.options( OptionCard( icon = Icons.Default.Favorite, - label = "Favourites", - description = "Your Favourite Repositories saved locally", + label = stringResource(Res.string.favourites), + description = stringResource(Res.string.profile_favourites_description), onClick = { onAction(ProfileAction.OnFavouriteReposClick) } From 1a5d9ed8b1b9ef85c4c346856598e3aa3b35a846 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 27 Feb 2026 10:59:43 +0500 Subject: [PATCH 07/13] =?UTF-8?q?feat(ui):=20implement=20update=20badges?= =?UTF-8?q?=20and=20enhance=20profile=20=CB=9Cnavigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces visual indicators for available updates in the bottom navigation and adds support for navigating to developer profiles from the account section. It also includes several UI refinements and fixes for background rendering. - **feat(navigation)**: Added an update notification badge to the "Apps" tab in the `BottomNavigation` when updates are available. - **feat(profile)**: Added `OnRepositoriesClick` action to `ProfileViewModel` and implemented navigation to developer profiles when clicking on the repository count stat. - **feat(profile)**: Updated proxy type selection in the network settings to use a scrollable `LazyRow`. - **refactor(apps)**: Moved app filtering logic from the UI layer to `AppsViewModel` and added automatic sorting to prioritize apps with available updates. - **refactor(core)**: Relocated `isLiquidFrostAvailable` utility to the core presentation module for broader accessibility across features. - **fix(ui)**: Enhanced conditional rendering for "liquid" glass effects; components now fall back to a standard background when the effect is unavailable on the current platform or API level. - **fix(details)**: Updated `SmartInstallButton` to use the `onClick` parameter of the `Card` component for better touch handling. - **chore**: Cleaned up unused imports and comments across multiple presentation modules. --- .../app/navigation/AppNavigation.kt | 14 ++- .../app/navigation/BottomNavigation.kt | 103 ++++++++++-------- .../utils/isLiquidFrostAvailable.android.kt | 2 +- .../utils/isLiquidFrostAvailable.kt | 3 + .../utils/isLiquidFrostAvailable.jvm.kt | 2 +- .../zed/rainxch/apps/presentation/AppsRoot.kt | 25 +---- .../rainxch/apps/presentation/AppsState.kt | 1 + .../apps/presentation/AppsViewModel.kt | 31 +++++- .../details/presentation/DetailsRoot.kt | 19 ++-- .../components/SmartInstallButton.kt | 6 +- .../utils/isLiquidFrostAvailable.kt | 3 - .../components/HomeFilterChips.kt | 22 ++-- .../profile/presentation/ProfileAction.kt | 1 + .../profile/presentation/ProfileRoot.kt | 5 + .../profile/presentation/ProfileViewModel.kt | 5 + .../components/sections/AccountSection.kt | 11 +- .../components/sections/Network.kt | 10 +- 17 files changed, 162 insertions(+), 101 deletions(-) rename {feature/details/presentation/src/androidMain/kotlin/zed/rainxch/details => core/presentation/src/androidMain/kotlin/zed/rainxch/core}/presentation/utils/isLiquidFrostAvailable.android.kt (83%) create mode 100644 core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/isLiquidFrostAvailable.kt rename {feature/details/presentation/src/jvmMain/kotlin/zed/rainxch/details => core/presentation/src/jvmMain/kotlin/zed/rainxch/core}/presentation/utils/isLiquidFrostAvailable.jvm.kt (58%) delete mode 100644 feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/isLiquidFrostAvailable.kt diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt index fae43360..a809f77d 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt @@ -17,6 +17,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -26,6 +28,7 @@ import io.github.fletchmckee.liquid.rememberLiquidState import org.koin.compose.viewmodel.koinViewModel import org.koin.core.parameter.parametersOf import zed.rainxch.apps.presentation.AppsRoot +import zed.rainxch.apps.presentation.AppsViewModel import zed.rainxch.auth.presentation.AuthenticationRoot import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid @@ -45,6 +48,9 @@ fun AppNavigation( var bottomNavigationHeight by remember { mutableStateOf(0.dp) } val density = LocalDensity.current + val appsViewModel = koinViewModel() + val appsState by appsViewModel.state.collectAsStateWithLifecycle() + CompositionLocalProvider( LocalBottomNavigationLiquid provides liquidState, LocalBottomNavigationHeight provides bottomNavigationHeight @@ -222,6 +228,9 @@ fun AppNavigation( }, onNavigateToFavouriteRepos = { navController.navigate(GithubStoreGraph.FavouritesScreen) + }, + onNavigateToDevProfile = { username -> + navController.navigate(GithubStoreGraph.DeveloperProfileScreen(username)) } ) } @@ -237,7 +246,9 @@ fun AppNavigation( repositoryId = repoId ) ) - } + }, + viewModel = appsViewModel, + state = appsState ) } } @@ -257,6 +268,7 @@ fun AppNavigation( restoreState = true } }, + isUpdateAvailable = appsState.apps.any { it.installedApp.isUpdateAvailable }, modifier = Modifier .align(Alignment.BottomCenter) .navigationBarsPadding() diff --git a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigation.kt b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigation.kt index 04531e4f..756d3162 100644 --- a/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigation.kt +++ b/composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/BottomNavigation.kt @@ -58,12 +58,13 @@ import zed.rainxch.core.domain.getPlatform import zed.rainxch.core.domain.model.Platform import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.core.presentation.theme.GithubStoreTheme -import zed.rainxch.details.presentation.utils.isLiquidFrostAvailable +import zed.rainxch.core.presentation.utils.isLiquidFrostAvailable @Composable fun BottomNavigation( currentScreen: GithubStoreGraph, onNavigate: (GithubStoreGraph) -> Unit, + isUpdateAvailable: Boolean, modifier: Modifier = Modifier ) { val liquidState = LocalBottomNavigationLiquid.current @@ -138,9 +139,7 @@ fun BottomNavigation( if (isLiquidFrostAvailable()) { Modifier.liquid(liquidState) { this.shape = CircleShape - if (isLiquidFrostAvailable()) { - this.frost = if (isDarkTheme) 12.dp else 10.dp - } + this.frost = if (isDarkTheme) 12.dp else 10.dp this.curve = if (isDarkTheme) .35f else .45f this.refraction = if (isDarkTheme) .08f else .12f this.dispersion = if (isDarkTheme) .18f else .25f @@ -237,6 +236,7 @@ fun BottomNavigation( visibleItems.forEachIndexed { index, item -> LiquidGlassTabItem( item = item, + hasBadge = item.screen == GithubStoreGraph.AppsScreen && isUpdateAvailable, isSelected = item.screen == currentScreen, onSelect = { onNavigate(item.screen) }, onPositioned = { x, width -> @@ -264,6 +264,7 @@ private fun LiquidGlassTabItem( item: BottomNavigationItem, isSelected: Boolean, onSelect: () -> Unit, + hasBadge: Boolean = false, onPositioned: suspend (x: Float, width: Float) -> Unit ) { val scope = rememberCoroutineScope() @@ -331,7 +332,7 @@ private fun LiquidGlassTabItem( label = "hPadding" ) - Column( + Box( modifier = Modifier .clip(CircleShape) .clickable( @@ -347,46 +348,59 @@ private fun LiquidGlassTabItem( scaleX = pressScale scaleY = pressScale } - .padding(horizontal = horizontalPadding, vertical = 6.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(1.dp) + .padding(horizontal = horizontalPadding, vertical = 6.dp) ) { - Icon( - imageVector = if (isSelected) item.iconFilled else item.iconOutlined, - contentDescription = stringResource(item.titleRes), - modifier = Modifier - .size(22.dp) - .graphicsLayer { - scaleX = iconScale - scaleY = iconScale - translationY = with(density) { iconOffsetY.toPx() } - }, - tint = iconTint - ) - - Box( - modifier = Modifier - .height(if (isSelected) 16.dp else 0.dp) - .graphicsLayer { - alpha = labelAlpha - scaleX = labelScale - scaleY = labelScale - }, - contentAlignment = Alignment.Center + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(1.dp) ) { - Text( - text = stringResource(item.titleRes), - style = MaterialTheme.typography.labelSmall.copy( - fontSize = 10.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - lineHeight = 12.sp - ), - color = if (isSelected) { - MaterialTheme.colorScheme.onPrimaryContainer - } else { - MaterialTheme.colorScheme.onSurface - }, - maxLines = 1 + Icon( + imageVector = if (isSelected) item.iconFilled else item.iconOutlined, + contentDescription = stringResource(item.titleRes), + modifier = Modifier + .size(22.dp) + .graphicsLayer { + scaleX = iconScale + scaleY = iconScale + translationY = with(density) { iconOffsetY.toPx() } + }, + tint = iconTint + ) + + Box( + modifier = Modifier + .height(if (isSelected) 16.dp else 0.dp) + .graphicsLayer { + alpha = labelAlpha + scaleX = labelScale + scaleY = labelScale + }, + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(item.titleRes), + style = MaterialTheme.typography.labelSmall.copy( + fontSize = 10.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + lineHeight = 12.sp + ), + color = if (isSelected) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1 + ) + } + } + + if (hasBadge) { + Box( + Modifier + .size(12.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.error) + .align(Alignment.TopEnd) ) } } @@ -403,7 +417,8 @@ fun BottomNavigationPreview() { currentScreen = GithubStoreGraph.HomeScreen, onNavigate = { - } + }, + isUpdateAvailable = true ) } } diff --git a/feature/details/presentation/src/androidMain/kotlin/zed/rainxch/details/presentation/utils/isLiquidFrostAvailable.android.kt b/core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/utils/isLiquidFrostAvailable.android.kt similarity index 83% rename from feature/details/presentation/src/androidMain/kotlin/zed/rainxch/details/presentation/utils/isLiquidFrostAvailable.android.kt rename to core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/utils/isLiquidFrostAvailable.android.kt index ef546d7f..f137b03b 100644 --- a/feature/details/presentation/src/androidMain/kotlin/zed/rainxch/details/presentation/utils/isLiquidFrostAvailable.android.kt +++ b/core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/utils/isLiquidFrostAvailable.android.kt @@ -1,4 +1,4 @@ -package zed.rainxch.details.presentation.utils +package zed.rainxch.core.presentation.utils import android.os.Build import androidx.annotation.ChecksSdkIntAtLeast diff --git a/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/isLiquidFrostAvailable.kt b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/isLiquidFrostAvailable.kt new file mode 100644 index 00000000..5c8afd94 --- /dev/null +++ b/core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/isLiquidFrostAvailable.kt @@ -0,0 +1,3 @@ +package zed.rainxch.core.presentation.utils + +expect fun isLiquidFrostAvailable() : Boolean diff --git a/feature/details/presentation/src/jvmMain/kotlin/zed/rainxch/details/presentation/utils/isLiquidFrostAvailable.jvm.kt b/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/utils/isLiquidFrostAvailable.jvm.kt similarity index 58% rename from feature/details/presentation/src/jvmMain/kotlin/zed/rainxch/details/presentation/utils/isLiquidFrostAvailable.jvm.kt rename to core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/utils/isLiquidFrostAvailable.jvm.kt index 53627d2c..bdc016cf 100644 --- a/feature/details/presentation/src/jvmMain/kotlin/zed/rainxch/details/presentation/utils/isLiquidFrostAvailable.jvm.kt +++ b/core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/utils/isLiquidFrostAvailable.jvm.kt @@ -1,4 +1,4 @@ -package zed.rainxch.details.presentation.utils +package zed.rainxch.core.presentation.utils actual fun isLiquidFrostAvailable(): Boolean { return true 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 12595cae..6dc9192b 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 @@ -77,9 +77,9 @@ import zed.rainxch.core.presentation.utils.ObserveAsEvents fun AppsRoot( onNavigateBack: () -> Unit, onNavigateToRepo: (repoId: Long) -> Unit, - viewModel: AppsViewModel = koinViewModel() + viewModel: AppsViewModel = koinViewModel(), + state: AppsState, ) { - val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() @@ -251,23 +251,6 @@ fun AppsScreen( ) } - 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 - ) - } - } - } - when { state.isLoading -> { Box( @@ -278,7 +261,7 @@ fun AppsScreen( } } - filteredApps.isEmpty() -> { + state.filteredApps.isEmpty() -> { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center @@ -294,7 +277,7 @@ fun AppsScreen( verticalArrangement = Arrangement.spacedBy(12.dp) ) { items( - items = filteredApps, + items = state.filteredApps, key = { it.installedApp.packageName } ) { appItem -> AppItemCard( 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 cb74aaed..cd6416ab 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 @@ -5,6 +5,7 @@ import zed.rainxch.apps.presentation.model.UpdateAllProgress data class AppsState( val apps: List = emptyList(), + val filteredApps: List = emptyList(), val searchQuery: String = "", val isLoading: Boolean = false, val isUpdatingAll: Boolean = false, 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 121cbbbc..7af0be4e 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 @@ -1,5 +1,6 @@ package zed.rainxch.apps.presentation +import androidx.compose.runtime.remember import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import zed.rainxch.githubstore.core.presentation.res.* @@ -96,6 +97,8 @@ class AppsViewModel( } ) } + + filterApps() } } catch (e: Exception) { logger.error("Failed to load apps: ${e.message}") @@ -157,7 +160,11 @@ class AppsViewModel( } is AppsAction.OnSearchChange -> { - _state.update { it.copy(searchQuery = action.query) } + _state.update { + it.copy(searchQuery = action.query) + } + + filterApps() } is AppsAction.OnOpenApp -> { @@ -200,6 +207,28 @@ class AppsViewModel( } } + private fun filterApps() { + _state.update { + it.copy( + filteredApps = if (_state.value.searchQuery.isBlank()) { + _state.value.apps.sortedBy { it.installedApp.isUpdateAvailable } + } else { + _state.value.apps.filter { appItem -> + appItem.installedApp.appName.contains( + _state.value.searchQuery, + ignoreCase = true + ) || + appItem.installedApp.repoOwner.contains( + _state.value.searchQuery, + ignoreCase = true + ) + }.sortedBy { it.installedApp.isUpdateAvailable } + } + ) + } + + } + private fun uninstallApp(app: InstalledApp) { viewModelScope.launch { try { 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 d2ef6fb1..14b66097 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 @@ -61,6 +61,7 @@ import org.jetbrains.compose.ui.tooling.preview.Preview import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents +import zed.rainxch.core.presentation.utils.isLiquidFrostAvailable import zed.rainxch.details.presentation.components.sections.about import zed.rainxch.details.presentation.components.sections.author import zed.rainxch.details.presentation.components.sections.header @@ -69,7 +70,6 @@ import zed.rainxch.details.presentation.components.sections.stats import zed.rainxch.details.presentation.components.sections.whatsNew import zed.rainxch.details.presentation.components.states.ErrorState import zed.rainxch.details.presentation.utils.LocalTopbarLiquidState -import zed.rainxch.details.presentation.utils.isLiquidFrostAvailable @Composable fun DetailsRoot( @@ -385,15 +385,18 @@ private fun DetailsTopbar( 1f to MaterialTheme.colorScheme.surface.copy(alpha = 0.85f) ) ) - .liquid(liquidTopbarState) { - this.shape = CutCornerShape(0.dp) + .then( if (isLiquidFrostAvailable()) { - this.frost = 5.dp + Modifier.liquid(liquidTopbarState) { + this.shape = CutCornerShape(0.dp) + if (isLiquidFrostAvailable()) { + this.frost = 5.dp + } + this.curve = .25f + this.refraction = .05f + this.dispersion = .1f } - this.curve = .25f - this.refraction = .05f - this.dispersion = .1f - } + } else Modifier.background(MaterialTheme.colorScheme.surfaceContainerHighest)) ) } 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 c66af839..18370d2f 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 @@ -130,7 +130,6 @@ fun SmartInstallButton( modifier = Modifier .weight(1f) .height(52.dp) - .clickable { onAction(DetailsAction.OpenApp) } .liquefiable(liquidState), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.primary @@ -140,7 +139,10 @@ fun SmartInstallButton( bottomStart = 6.dp, topEnd = 24.dp, bottomEnd = 24.dp - ) + ), + onClick = { + onAction(DetailsAction.OpenApp) + } ) { Box( modifier = Modifier.fillMaxSize(), diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/isLiquidFrostAvailable.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/isLiquidFrostAvailable.kt deleted file mode 100644 index 80e0bae2..00000000 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/utils/isLiquidFrostAvailable.kt +++ /dev/null @@ -1,3 +0,0 @@ -package zed.rainxch.details.presentation.utils - -expect fun isLiquidFrostAvailable() : Boolean \ No newline at end of file diff --git a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HomeFilterChips.kt b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HomeFilterChips.kt index 5c2074ff..66c194cb 100644 --- a/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HomeFilterChips.kt +++ b/feature/home/presentation/src/commonMain/kotlin/zed/rainxch/home/presentation/components/HomeFilterChips.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.github.fletchmckee.liquid.liquid import kotlinx.coroutines.launch +import zed.rainxch.core.presentation.utils.isLiquidFrostAvailable import zed.rainxch.home.domain.model.HomeCategory import zed.rainxch.home.presentation.locals.LocalHomeTopBarLiquid import zed.rainxch.home.presentation.utils.displayText @@ -61,7 +62,6 @@ fun LiquidGlassCategoryChips( ) { val liquidState = LocalHomeTopBarLiquid.current val density = LocalDensity.current - val scope = rememberCoroutineScope() val isDarkTheme = !MaterialTheme.colorScheme.background.luminance().let { it > 0.5f } @@ -125,15 +125,17 @@ fun LiquidGlassCategoryChips( MaterialTheme.colorScheme.primaryContainer.copy(alpha = .45f) } ) - .liquid(liquidState) { - this.shape = containerShape - this.frost = if (isDarkTheme) 14.dp else 12.dp - this.curve = if (isDarkTheme) .30f else .40f - this.refraction = if (isDarkTheme) .06f else .10f - this.dispersion = if (isDarkTheme) .15f else .22f - this.saturation = if (isDarkTheme) .35f else .50f - this.contrast = if (isDarkTheme) 1.7f else 1.5f - } + .then(if(isLiquidFrostAvailable()) { + Modifier.liquid(liquidState) { + this.shape = containerShape + this.frost = if (isDarkTheme) 14.dp else 12.dp + this.curve = if (isDarkTheme) .30f else .40f + this.refraction = if (isDarkTheme) .06f else .10f + this.dispersion = if (isDarkTheme) .15f else .22f + this.saturation = if (isDarkTheme) .35f else .50f + this.contrast = if (isDarkTheme) 1.7f else 1.5f + } + } else Modifier.background(MaterialTheme.colorScheme.surfaceContainerHighest)) ) { Box( modifier = Modifier diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt index a71c7b43..0a810047 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt @@ -19,6 +19,7 @@ sealed interface ProfileAction { data class OnProxyTypeSelected(val type: ProxyType) : ProfileAction data class OnProxyHostChanged(val host: String) : ProfileAction data class OnProxyPortChanged(val port: String) : ProfileAction + data class OnRepositoriesClick(val username: String) : ProfileAction data class OnProxyUsernameChanged(val username: String) : ProfileAction data class OnProxyPasswordChanged(val password: String) : ProfileAction data object OnProxyPasswordVisibilityToggle : ProfileAction diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt index dd8ee30c..2e4bf372 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt @@ -49,6 +49,7 @@ import zed.rainxch.profile.presentation.components.sections.settings @Composable fun ProfileRoot( onNavigateBack: () -> Unit, + onNavigateToDevProfile: (username: String) -> Unit, onNavigateToAuthentication: () -> Unit, onNavigateToStarredRepos: () -> Unit, onNavigateToFavouriteRepos: () -> Unit, @@ -107,6 +108,10 @@ fun ProfileRoot( ProfileAction.OnStarredReposClick -> { onNavigateToStarredRepos() } + + is ProfileAction.OnRepositoriesClick -> { + onNavigateToDevProfile(action.username) + } else -> { viewModel.onAction(action) diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt index b1919d14..0d81ec8b 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt @@ -224,6 +224,11 @@ class ProfileViewModel( /* Handed in composable */ } + + is ProfileAction.OnRepositoriesClick -> { + /* Handed in composable */ + } + is ProfileAction.OnFontThemeSelected -> { viewModelScope.launch { themesRepository.setFontTheme(action.fontTheme) diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt index 0492ad3c..cc9fd2b4 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt @@ -134,7 +134,10 @@ fun LazyListScope.accountSection( StatCard( label = stringResource(Res.string.profile_repos), value = state.userProfile.repositoryCount.toString(), - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), + onClick = { + onAction(ProfileAction.OnRepositoriesClick(state.userProfile.username)) + } ) StatCard( @@ -174,7 +177,8 @@ fun LazyListScope.accountSection( private fun StatCard( label: String, value: String, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null ) { Card( modifier = modifier, @@ -186,7 +190,8 @@ private fun StatCard( border = BorderStroke( width = 1.dp, color = MaterialTheme.colorScheme.secondary - ) + ), + onClick = { onClick?.invoke() } ) { Column( modifier = Modifier diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt index f5a06b39..ec798ada 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons @@ -65,7 +67,6 @@ fun LazyListScope.networkSection( } ) - // Description text for None / System proxy types AnimatedVisibility( visible = state.proxyType == ProxyType.NONE || state.proxyType == ProxyType.SYSTEM, enter = expandVertically() + fadeIn(), @@ -82,7 +83,6 @@ fun LazyListScope.networkSection( ) } - // Details card for HTTP / SOCKS proxy types AnimatedVisibility( visible = state.proxyType == ProxyType.HTTP || state.proxyType == ProxyType.SOCKS, enter = expandVertically() + fadeIn(), @@ -125,11 +125,11 @@ private fun ProxyTypeCard( Spacer(Modifier.height(12.dp)) - Row( + LazyRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - ProxyType.entries.forEach { type -> + items(ProxyType.entries) { type -> FilterChip( selected = selectedType == type, onClick = { onTypeSelected(type) }, @@ -148,7 +148,6 @@ private fun ProxyTypeCard( overflow = TextOverflow.Ellipsis ) }, - modifier = Modifier.weight(1f) ) } } @@ -180,7 +179,6 @@ private fun ProxyDetailsCard( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // Host + Port row Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) From 754831bc59c867eb46ea278c09741bdbc8dc76a0 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 27 Feb 2026 11:52:51 +0500 Subject: [PATCH 08/13] style(ui): Refine UI components and layout in profile screen This commit updates various UI components in the profile feature to improve visual consistency and spacing. It standardizes corner radii, adjusts paddings for better layout flow, and refines the logout dialog's appearance and behavior. - **style(ui)**: Standardized `RoundedCornerShape` to 32.dp across `Account`, `About`, `Network`, and `Options` sections. - **style(ui)**: Increased `SnackbarHost` bottom padding to prevent overlap with navigation. - **style(ui)**: Adjusted `ProfileRoot` list spacing and bottom padding to account for the bottom navigation height. - **style(ui)**: Refined `LogoutDialog` with bold headers, increased spacing, and disabled click-outside dismissal. - **style(ui)**: Improved layout of `AboutItem` and `OptionsItem` with adjusted horizontal padding and spacing. - **fix(ui)**: Removed redundant `shapes` parameter from `IconButton` in the `Account` section. --- .../rainxch/profile/presentation/ProfileRoot.kt | 14 +++++++------- .../presentation/components/LogoutDialog.kt | 14 +++++++++++--- .../presentation/components/sections/About.kt | 7 +++++-- .../presentation/components/sections/Account.kt | 3 +-- .../presentation/components/sections/Network.kt | 6 +++--- .../presentation/components/sections/Options.kt | 6 +++--- 6 files changed, 30 insertions(+), 20 deletions(-) diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt index 2e4bf372..eac285ee 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileRoot.kt @@ -108,7 +108,7 @@ fun ProfileRoot( ProfileAction.OnStarredReposClick -> { onNavigateToStarredRepos() } - + is ProfileAction.OnRepositoriesClick -> { onNavigateToDevProfile(action.username) } @@ -146,7 +146,7 @@ fun ProfileScreen( snackbarHost = { SnackbarHost( hostState = snackbarState, - modifier = Modifier.padding(bottom = bottomNavHeight) + modifier = Modifier.padding(bottom = bottomNavHeight + 16.dp) ) }, topBar = { @@ -193,18 +193,18 @@ fun ProfileScreen( onAction = onAction ) - if (state.isUserLoggedIn) { - item { - Spacer(Modifier.height(16.dp)) - } + item { + Spacer(Modifier.height(16.dp)) + } + if (state.isUserLoggedIn) { logout( onAction = onAction ) } item { - Spacer(Modifier.height(64.dp)) + Spacer(Modifier.height(bottomNavHeight + 32.dp)) } } } diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt index d203f13d..57446e0d 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/LogoutDialog.kt @@ -18,7 +18,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import zed.rainxch.githubstore.core.presentation.res.* import org.jetbrains.compose.resources.stringResource @@ -31,18 +33,24 @@ fun LogoutDialog( ) { BasicAlertDialog( onDismissRequest = onDismissRequest, + properties = DialogProperties( + dismissOnClickOutside = false, + usePlatformDefaultWidth = false + ), modifier = modifier + .padding(16.dp) .clip(RoundedCornerShape(24.dp)) .background(MaterialTheme.colorScheme.surfaceContainerHigh) .padding(16.dp) ) { Column ( - verticalArrangement = Arrangement.spacedBy(6.dp) + verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( text = stringResource(Res.string.warning), style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold ) Text( @@ -54,7 +62,7 @@ fun LogoutDialog( Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End) ) { TextButton( onClick = { diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/About.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/About.kt index 79a6cca7..cfef4968 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/About.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/About.kt @@ -49,11 +49,13 @@ fun LazyListScope.about( Spacer(Modifier.height(8.dp)) ElevatedCard( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh ), - shape = RoundedCornerShape(16.dp) + shape = RoundedCornerShape(32.dp) ) { AboutItem( icon = Icons.Filled.Info, @@ -63,6 +65,7 @@ fun LazyListScope.about( text = versionName, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(horizontal = 8.dp) ) } ) diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Account.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Account.kt index 5c6dc026..0a5716b2 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Account.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Account.kt @@ -42,7 +42,7 @@ fun LazyListScope.logout( colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.errorContainer ), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(32.dp), onClick = { onAction(ProfileAction.OnLogoutClick) } @@ -52,7 +52,6 @@ fun LazyListScope.logout( title = stringResource(Res.string.logout), actions = { IconButton( - shapes = IconButtonDefaults.shapes(), onClick = { onAction(ProfileAction.OnLogoutClick) }, diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt index ec798ada..cec3b1d6 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Network.kt @@ -111,7 +111,7 @@ private fun ProxyTypeCard( colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer ), - shape = RoundedCornerShape(20.dp) + shape = RoundedCornerShape(32.dp) ) { Column( modifier = Modifier.padding(16.dp) @@ -123,7 +123,7 @@ private fun ProxyTypeCard( fontWeight = FontWeight.SemiBold ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(8.dp)) LazyRow( modifier = Modifier.fillMaxWidth(), @@ -173,7 +173,7 @@ private fun ProxyDetailsCard( colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer ), - shape = RoundedCornerShape(20.dp) + shape = RoundedCornerShape(32.dp) ) { Column( modifier = Modifier.padding(16.dp), diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt index b32d8efa..29ae2734 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Options.kt @@ -80,7 +80,7 @@ private fun OptionCard( disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = .7f), ), onClick = onClick, - shape = RoundedCornerShape(36.dp), + shape = RoundedCornerShape(32.dp), border = BorderStroke( width = .5.dp, color = MaterialTheme.colorScheme.surface @@ -88,9 +88,9 @@ private fun OptionCard( enabled = enabled ) { Row( - modifier = Modifier.padding(horizontal = 12.dp), + modifier = Modifier.padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( imageVector = icon, From 64be34732ac0c3283aa36deb8c13122ee2270875 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 27 Feb 2026 11:53:06 +0500 Subject: [PATCH 09/13] ui(profile, apps): Refine appearance and improve layout for installed apps This commit updates the UI components across the profile and apps features to enhance visual consistency and layout. It introduces `ExpressiveCard` for a more modern look, adjusts spacing and corner radii, and improves the `AppsRoot` screen with better bottom navigation awareness. - **ui(profile)**: Reduced vertical spacing in the appearance section from 16dp to 12dp. - **ui(profile)**: Increased `RoundedCornerShape` values (16dp to 24dp for theme previews, 20dp to 32dp for cards) to match the new expressive design. - **ui(apps)**: Refactored `AppItem` to use `ExpressiveCard` and updated its layout with larger avatars (64dp) and bold typography. - **ui(apps)**: Added `LocalBottomNavigationHeight` integration to provide proper padding for the `SnackbarHost` and list content, preventing overlap with navigation. - **ui(apps)**: Removed the back navigation icon from the `TopAppBar` in the apps root. - **ui(apps)**: Enhanced empty state styling with specific typography and colors. - **refactor(apps)**: Standardized card shapes and colors using `ElevatedCard` with a `surfaceContainer` background and 32dp rounded corners. --- .../zed/rainxch/apps/presentation/AppsRoot.kt | 76 +++++++++++++------ .../components/sections/Appearance.kt | 8 +- 2 files changed, 55 insertions(+), 29 deletions(-) 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 6dc9192b..0fbea3ab 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 @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.OpenInNew @@ -31,8 +32,10 @@ import androidx.compose.material.icons.outlined.DeleteOutline import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon @@ -69,6 +72,7 @@ import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.apps.presentation.model.AppItem import zed.rainxch.apps.presentation.model.UpdateAllProgress import zed.rainxch.apps.presentation.model.UpdateState +import zed.rainxch.core.presentation.locals.LocalBottomNavigationHeight import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.core.presentation.utils.ObserveAsEvents @@ -127,19 +131,11 @@ fun AppsScreen( snackbarHostState: SnackbarHostState ) { val liquidState = LocalBottomNavigationLiquid.current + val bottomNavHeight = LocalBottomNavigationHeight.current + Scaffold( topBar = { TopAppBar( - navigationIcon = { - IconButton( - onClick = { onAction(AppsAction.OnNavigateBackClick) } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.navigate_back) - ) - } - }, title = { Text( text = stringResource(Res.string.installed_apps), @@ -161,7 +157,10 @@ fun AppsScreen( ) }, snackbarHost = { - SnackbarHost(snackbarHostState) + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottomNavHeight + 16.dp) + ) }, modifier = Modifier.liquefiable(liquidState) ) { innerPadding -> @@ -247,7 +246,9 @@ fun AppsScreen( if (state.isUpdatingAll && state.updateAllProgress != null) { UpdateAllProgressCard( progress = state.updateAllProgress, - onCancel = { onAction(AppsAction.OnCancelUpdateAll) } + onCancel = { + onAction(AppsAction.OnCancelUpdateAll) + } ) } @@ -266,7 +267,11 @@ fun AppsScreen( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Text(stringResource(Res.string.no_apps_found)) + Text( + text = stringResource(Res.string.no_apps_found), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground + ) } } @@ -290,6 +295,10 @@ fun AppsScreen( modifier = Modifier.liquefiable(liquidState) ) } + + item { + Spacer(Modifier.height(bottomNavHeight + 32.dp)) + } } } } @@ -365,22 +374,20 @@ fun AppItemCard( ) { val app = appItem.installedApp - Card( - modifier = modifier.fillMaxWidth() - ) { + ExpressiveCard { Column( - modifier = Modifier.padding(16.dp) + modifier = modifier + .padding(16.dp) + .clickable { onRepoClick() } ) { Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onRepoClick() }, + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp) ) { CoilImage( imageModel = { app.repoOwnerAvatarUrl }, modifier = Modifier - .size(48.dp) + .size(64.dp) .clip(CircleShape), loading = { Box( @@ -395,7 +402,9 @@ fun AppItemCard( Column(modifier = Modifier.weight(1f)) { Text( text = app.appName, - style = MaterialTheme.typography.titleMedium + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold ) Text( @@ -412,6 +421,7 @@ fun AppItemCard( color = MaterialTheme.colorScheme.tertiary ) } + app.isUpdateAvailable -> { Text( text = "${app.installedVersion} → ${app.latestVersion}", @@ -419,6 +429,7 @@ fun AppItemCard( color = MaterialTheme.colorScheme.primary ) } + else -> { Text( text = app.installedVersion, @@ -435,7 +446,7 @@ fun AppItemCard( Text( text = app.repoDescription!!, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodyMediumEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis ) @@ -528,8 +539,7 @@ fun AppItemCard( ) } - UpdateState.Idle -> { - } + UpdateState.Idle -> {} } Spacer(Modifier.height(12.dp)) @@ -556,6 +566,7 @@ fun AppItemCard( } Button( + shapes = ButtonDefaults.shapes(), onClick = onOpenClick, modifier = Modifier.weight(1f), enabled = !app.isPendingInstall && @@ -635,6 +646,21 @@ private fun formatLastChecked(timestamp: Long): String { } } +@Composable +private fun ExpressiveCard( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + ElevatedCard( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + shape = RoundedCornerShape(32.dp), + content = { content() } + ) +} + @Preview @Composable private fun Preview() { diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Appearance.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Appearance.kt index 32ad70d9..bc883b3b 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Appearance.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Appearance.kt @@ -84,7 +84,7 @@ fun LazyListScope.appearanceSection( } ) - VerticalSpacer(16.dp) + VerticalSpacer(12.dp) ThemeColorCard( selectedThemeColor = state.selectedThemeColor, @@ -137,7 +137,7 @@ private fun ThemeSelectionCard( isDarkTheme: Boolean?, onDarkThemeChange: (Boolean?) -> Unit ) { - ExpressiveCard { + ExpressiveCard{ Row( modifier = Modifier .fillMaxWidth() @@ -192,7 +192,7 @@ private fun ThemeModeOption( Column( modifier = modifier .scale(scale) - .clip(RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(24.dp)) .background( if (isSelected) { MaterialTheme.colorScheme.primaryContainer @@ -410,7 +410,7 @@ private fun ExpressiveCard( colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer ), - shape = RoundedCornerShape(20.dp), + shape = RoundedCornerShape(32.dp), content = { content() } ) } \ No newline at end of file From 34018e03b0971209cb328137b35cb54845aef66c Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 27 Feb 2026 11:53:32 +0500 Subject: [PATCH 10/13] feat(search): refine search logic and improve UI/UX This commit optimizes the GitHub repository search query construction and enhances the search interface with better navigation and clear actions. - **feat(search)**: Added `OnClearClick` action to allow users to quickly clear the search query. - **refactor(search)**: Simplified `buildSearchQuery` in `SearchRepositoryImpl` by removing `platformHints`, relying on general metadata instead of specific repository topics for platform filtering. - **fix(search)**: Updated `SearchViewModel` to accurately reflect the local repository count in the `totalCount` state. - **ui(search)**: Replaced the back navigation button with a clear icon inside the search `TextField`. - **ui(search)**: Replaced the standard `Button` with `GithubStoreButton` for the search retry action. - **ui(search)**: Adjusted spacing, padding, and layout constraints in `SearchRoot` for a more consistent visual experience. - **chore**: Updated `LazyRow` items to use `SearchPlatform.entries` directly and added necessary experimental annotations. --- .../data/repository/SearchRepositoryImpl.kt | 13 +---- .../search/presentation/SearchAction.kt | 1 + .../rainxch/search/presentation/SearchRoot.kt | 56 ++++++++++--------- .../search/presentation/SearchViewModel.kt | 10 +++- 4 files changed, 43 insertions(+), 37 deletions(-) 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 04766002..be069d6f 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 @@ -74,7 +74,7 @@ class SearchRepositoryImpl( return@channelFlow } - val searchQuery = buildSearchQuery(query, searchPlatform, language) + val searchQuery = buildSearchQuery(query, language) val (sort, order) = sortBy.toGithubParams() try { @@ -195,7 +195,6 @@ class SearchRepositoryImpl( private fun buildSearchQuery( userQuery: String, - searchPlatform: SearchPlatform, language: ProgrammingLanguage ): String { val clean = userQuery.trim() @@ -207,21 +206,13 @@ class SearchRepositoryImpl( val scope = " in:name,description" val common = " archived:false fork:true" - val platformHints = when (searchPlatform) { - SearchPlatform.All -> "" - SearchPlatform.Android -> " topic:android" - SearchPlatform.Windows -> " topic:windows" - SearchPlatform.Macos -> " topic:macos" - SearchPlatform.Linux -> " topic:linux" - } - val languageFilter = if (language != ProgrammingLanguage.All && language.queryValue != null) { " language:${language.queryValue}" } else { "" } - return ("$q$scope$common" + platformHints + languageFilter).trim() + return ("$q$scope$common" + languageFilter).trim() } private fun assetMatchesPlatform(nameRaw: String, platform: SearchPlatform): Boolean { diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt index c5071c59..9094fb07 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt @@ -15,6 +15,7 @@ sealed interface SearchAction { data object OnSearchImeClick : SearchAction data object OnNavigateBackClick : SearchAction data object LoadMore : SearchAction + data object OnClearClick : SearchAction data object Retry : SearchAction data object OnToggleLanguageSheetVisibility : SearchAction } diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt index 40bcedab..57beaa0a 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt @@ -1,5 +1,6 @@ package zed.rainxch.search.presentation +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -23,11 +24,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.outlined.KeyboardArrowDown -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularWavyProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api @@ -48,6 +48,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color @@ -64,6 +65,7 @@ import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview import org.koin.compose.viewmodel.koinViewModel import zed.rainxch.core.domain.model.GithubRepoSummary +import zed.rainxch.core.presentation.components.GithubStoreButton import zed.rainxch.core.presentation.components.RepositoryCard import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.core.presentation.theme.GithubStoreTheme @@ -204,14 +206,14 @@ fun SearchScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(horizontal = 8.dp) + .padding(horizontal = 16.dp) ) { LazyRow( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - items(SearchPlatform.entries.toList()) { sortBy -> + items(SearchPlatform.entries) { sortBy -> FilterChip( selected = state.selectedSearchPlatform == sortBy, label = { @@ -229,11 +231,9 @@ fun SearchScreen( } } - Spacer(Modifier.height(4.dp)) - Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically ) { Text( @@ -284,7 +284,7 @@ fun SearchScreen( } } - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(6.dp)) if (state.totalCount != null) { Text( @@ -296,7 +296,7 @@ fun SearchScreen( color = MaterialTheme.colorScheme.outline, modifier = Modifier .fillMaxWidth() - .padding(bottom = 8.dp) + .padding(bottom = 6.dp) ) } @@ -322,11 +322,12 @@ fun SearchScreen( Spacer(Modifier.height(8.dp)) - Button(onClick = { onAction(SearchAction.Retry) }) { - Text( - text = stringResource(Res.string.retry) - ) - } + GithubStoreButton( + text = stringResource(Res.string.retry), + onClick = { + onAction(SearchAction.Retry) + } + ) } } } @@ -381,7 +382,7 @@ fun SearchScreen( } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class) @Composable private fun SearchTopbar( onAction: (SearchAction) -> Unit, @@ -396,16 +397,6 @@ private fun SearchTopbar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - IconButton( - onClick = { onAction(SearchAction.OnNavigateBackClick) } - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(Res.string.navigate_back), - modifier = Modifier.size(24.dp) - ) - } - TextField( value = state.query, onValueChange = { value -> @@ -418,6 +409,21 @@ private fun SearchTopbar( modifier = Modifier.size(20.dp) ) }, + trailingIcon = { + IconButton( + onClick = { + onAction(SearchAction.OnClearClick) + }, + modifier = Modifier + .size(20.dp) + .clip(CircleShape) + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = null + ) + } + }, placeholder = { Text( text = stringResource(Res.string.search_repositories_hint), 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 0f890aa8..ee9f77c5 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 @@ -226,7 +226,7 @@ class SearchViewModel( currentState.copy( repositories = allRepos, hasMorePages = paginatedRepos.hasMore, - totalCount = paginatedRepos.totalCount ?: currentState.totalCount, + totalCount = _state.value.repositories.size, errorMessage = if (allRepos.isEmpty() && !paginatedRepos.hasMore) { getString(Res.string.no_repositories_found) } else null @@ -359,6 +359,14 @@ class SearchViewModel( performSearch(isInitial = true) } + SearchAction.OnClearClick -> { + _state.update { + it.copy( + query = "" + ) + } + } + is SearchAction.OnRepositoryClick -> { /* Handled in composable */ } From 97c71553fd930a3f5323ff7849fec0d15d5659bf Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 27 Feb 2026 12:05:54 +0500 Subject: [PATCH 11/13] style(ui): improve UI expressiveness and consistency across components This commit enhances the visual consistency and interactivity of several UI components by adopting a more "expressive" design language. It introduces a reusable `ExpressiveCard` component, updates shapes to use larger corner radii, and implements specialized Material3 shapes for icon buttons. - **feat(dev-profile)**: Added `ExpressiveCard` component using `ElevatedCard` with a 32.dp corner radius and consistent surface coloring. - **refactor(dev-profile)**: Migrated `StatsRow`, `DeveloperRepoItem`, and `ProfileInfoCard` to use `ExpressiveCard` for a unified look. - **style(dev-profile)**: Applied unique `MaterialShapes` (Cookie9Sided and Cookie6Sided) to sort and favorite buttons. - **style(apps)**: Updated repository cards in `AppsRoot` with 32.dp rounded corners and improved click handling with proper clipping. - **style(details)**: Updated the `Owner` section card to use a `RoundedCornerShape(32.dp)` to match the new design language. - **style(dev-profile)**: Added `DropdownMenu` rounding and additional padding to sort menu items. --- .../zed/rainxch/apps/presentation/AppsRoot.kt | 3 ++- .../presentation/components/sections/Owner.kt | 2 ++ .../components/DeveloperRepoItem.kt | 10 +++++--- .../presentation/components/ExpressiveCard.kt | 25 +++++++++++++++++++ .../components/FilterSortControls.kt | 11 ++++++-- .../components/ProfileInfoCard.kt | 4 +-- .../presentation/components/StatsRow.kt | 2 +- 7 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ExpressiveCard.kt 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 0fbea3ab..8e2217cc 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 @@ -377,8 +377,9 @@ fun AppItemCard( ExpressiveCard { Column( modifier = modifier - .padding(16.dp) + .clip(RoundedCornerShape(32.dp)) .clickable { onRepoClick() } + .padding(16.dp) ) { Row( modifier = Modifier.fillMaxWidth(), diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Owner.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Owner.kt index 48dc4280..fde01076 100644 --- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Owner.kt +++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/sections/Owner.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material3.CardDefaults @@ -74,6 +75,7 @@ fun LazyListScope.author( colors = CardDefaults.outlinedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainerLowest ), + shape = RoundedCornerShape(32.dp) ) { Row( modifier = Modifier.padding(12.dp), diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/DeveloperRepoItem.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/DeveloperRepoItem.kt index e76aee1a..10cc4a89 100644 --- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/DeveloperRepoItem.kt +++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/DeveloperRepoItem.kt @@ -2,6 +2,7 @@ package zed.rainxch.devprofile.presentation.components +import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -25,9 +26,11 @@ import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconToggleButton import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Text +import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -53,13 +56,13 @@ fun DeveloperRepoItem( onToggleFavorite: () -> Unit, modifier: Modifier = Modifier ) { - Card( + ExpressiveCard( modifier = modifier.fillMaxWidth(), - onClick = onItemClick ) { Column( modifier = Modifier .fillMaxWidth() + .clickable(onClick = onItemClick) .padding(16.dp) ) { Row( @@ -93,7 +96,8 @@ fun DeveloperRepoItem( FilledIconToggleButton( checked = repository.isFavorite, onCheckedChange = { onToggleFavorite() }, - modifier = Modifier.size(40.dp) + modifier = Modifier.size(40.dp), + shape = MaterialShapes.Cookie6Sided.toShape(), ) { Icon( imageVector = if (repository.isFavorite) { diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ExpressiveCard.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ExpressiveCard.kt new file mode 100644 index 00000000..998534b6 --- /dev/null +++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ExpressiveCard.kt @@ -0,0 +1,25 @@ +package zed.rainxch.devprofile.presentation.components + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ExpressiveCard( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + ElevatedCard( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ), + shape = RoundedCornerShape(32.dp), + content = { content() } + ) +} \ No newline at end of file diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/FilterSortControls.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/FilterSortControls.kt index 81effad5..c59de5f1 100644 --- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/FilterSortControls.kt +++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/FilterSortControls.kt @@ -7,6 +7,7 @@ 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.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -20,11 +21,13 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SecondaryScrollableTabRow import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -165,6 +168,7 @@ private fun FilterChipTab( } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun SortMenu( currentSort: RepoSortType, @@ -175,7 +179,8 @@ private fun SortMenu( Box { FilledIconButton( onClick = { expanded = true }, - modifier = Modifier.size(40.dp) + modifier = Modifier.size(40.dp), + shape = MaterialShapes.Cookie9Sided.toShape() ) { Icon( imageVector = Icons.AutoMirrored.Filled.Sort, @@ -186,12 +191,14 @@ private fun SortMenu( DropdownMenu( expanded = expanded, - onDismissRequest = { expanded = false } + onDismissRequest = { expanded = false }, + shape = RoundedCornerShape(32.dp) ) { RepoSortType.entries.forEach { sort -> DropdownMenuItem( text = { Row( + modifier = Modifier.padding(4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ProfileInfoCard.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ProfileInfoCard.kt index b24e811c..242be32c 100644 --- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ProfileInfoCard.kt +++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/ProfileInfoCard.kt @@ -41,9 +41,7 @@ fun ProfileInfoCard( profile: DeveloperProfile, onAction: (DeveloperProfileAction) -> Unit ) { - Card( - modifier = Modifier.fillMaxWidth() - ) { + ExpressiveCard { Column( modifier = Modifier .fillMaxWidth() diff --git a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/StatsRow.kt b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/StatsRow.kt index af0d627b..b490d6d3 100644 --- a/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/StatsRow.kt +++ b/feature/dev-profile/presentation/src/commonMain/kotlin/zed/rainxch/devprofile/presentation/components/StatsRow.kt @@ -52,7 +52,7 @@ private fun StatCard( value: String, modifier: Modifier = Modifier ) { - Card(modifier = modifier) { + ExpressiveCard(modifier = modifier) { Column( modifier = Modifier .fillMaxWidth() From 892c347ed1e663cd8622368275e12378565f64eb Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 27 Feb 2026 16:13:59 +0500 Subject: [PATCH 12/13] refactor(search, apps): improve state management and UI consistency This commit refines the search logic, enhances the filtering mechanism in the apps feature, and cleans up UI-related imports and annotations. - **feat(search)**: Updated `OnClearClick` action to reset repositories, loading states, and error messages in addition to clearing the query. - **fix(search)**: Corrected `totalCount` calculation in `SearchViewModel` to ensure it reflects the actual size of the updated repository list. - **feat(apps)**: Optimized `filterApps` by extracting the filtering logic into `computeFilteredApps` and ensured the filtered list is updated when app status or progress changes. - **refactor(apps)**: Fixed `ExpressiveCard` modifier application in `AppCard` to ensure correct layout behavior. - **refactor(search)**: Cleaned up unused imports and `OptIn` annotations for `ExperimentalMaterial3Api` and `ExperimentalFoundationApi`. - **chore**: Replaced wildcard imports with explicit imports in `SearchRoot.kt` for better clarity. --- .../zed/rainxch/apps/presentation/AppsRoot.kt | 4 +-- .../apps/presentation/AppsViewModel.kt | 34 ++++++++++--------- .../rainxch/search/presentation/SearchRoot.kt | 10 +++--- .../search/presentation/SearchViewModel.kt | 10 ++++-- 4 files changed, 34 insertions(+), 24 deletions(-) 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 8e2217cc..fd287b87 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 @@ -374,9 +374,9 @@ fun AppItemCard( ) { val app = appItem.installedApp - ExpressiveCard { + ExpressiveCard (modifier = modifier) { Column( - modifier = modifier + modifier = Modifier .clip(RoundedCornerShape(32.dp)) .clickable { onRepoClick() } .padding(16.dp) 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 7af0be4e..ff907530 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 @@ -208,27 +208,25 @@ class AppsViewModel( } private fun filterApps() { - _state.update { - it.copy( - filteredApps = if (_state.value.searchQuery.isBlank()) { - _state.value.apps.sortedBy { it.installedApp.isUpdateAvailable } - } else { - _state.value.apps.filter { appItem -> - appItem.installedApp.appName.contains( - _state.value.searchQuery, - ignoreCase = true - ) || - appItem.installedApp.repoOwner.contains( - _state.value.searchQuery, - ignoreCase = true - ) - }.sortedBy { it.installedApp.isUpdateAvailable } - } + _state.update { current -> + current.copy( + filteredApps = computeFilteredApps(current.apps, current.searchQuery) ) } + } + private fun computeFilteredApps(apps: List, query: String): List { + return if (query.isBlank()) { + apps.sortedBy { it.installedApp.isUpdateAvailable } + } else { + apps.filter { appItem -> + appItem.installedApp.appName.contains(query, ignoreCase = true) || + appItem.installedApp.repoOwner.contains(query, ignoreCase = true) + }.sortedBy { it.installedApp.isUpdateAvailable } + } } + private fun uninstallApp(app: InstalledApp) { viewModelScope.launch { try { @@ -566,6 +564,8 @@ class AppsViewModel( } ) } + + filterApps() } private fun updateAppProgress(packageName: String, progress: Int?) { @@ -580,6 +580,8 @@ class AppsViewModel( } ) } + + filterApps() } private suspend fun markPendingUpdate(app: InstalledApp) { diff --git a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt index 57beaa0a..c99f2376 100644 --- a/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt +++ b/feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt @@ -1,6 +1,5 @@ package zed.rainxch.search.presentation -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -59,7 +58,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.github.fletchmckee.liquid.liquefiable -import zed.rainxch.githubstore.core.presentation.res.* import kotlinx.coroutines.delay import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview @@ -71,6 +69,11 @@ import zed.rainxch.core.presentation.locals.LocalBottomNavigationLiquid import zed.rainxch.core.presentation.theme.GithubStoreTheme import zed.rainxch.domain.model.ProgrammingLanguage import zed.rainxch.domain.model.SearchPlatform +import zed.rainxch.githubstore.core.presentation.res.Res +import zed.rainxch.githubstore.core.presentation.res.language_label +import zed.rainxch.githubstore.core.presentation.res.results_found +import zed.rainxch.githubstore.core.presentation.res.retry +import zed.rainxch.githubstore.core.presentation.res.search_repositories_hint import zed.rainxch.search.presentation.components.LanguageFilterBottomSheet import zed.rainxch.search.presentation.utils.label @@ -120,7 +123,7 @@ fun SearchRoot( ) } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SearchScreen( state: SearchState, @@ -382,7 +385,6 @@ fun SearchScreen( } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalFoundationApi::class) @Composable private fun SearchTopbar( onAction: (SearchAction) -> Unit, 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 ee9f77c5..e494d43b 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 @@ -226,7 +226,7 @@ class SearchViewModel( currentState.copy( repositories = allRepos, hasMorePages = paginatedRepos.hasMore, - totalCount = _state.value.repositories.size, + totalCount = allRepos.size, errorMessage = if (allRepos.isEmpty() && !paginatedRepos.hasMore) { getString(Res.string.no_repositories_found) } else null @@ -362,7 +362,13 @@ class SearchViewModel( SearchAction.OnClearClick -> { _state.update { it.copy( - query = "" + query = "", + repositories = emptyList(), + isLoading = false, + isLoadingMore = false, + errorMessage = null, + totalCount = null + ) } } From 79bffe74e4e9b8d3a076fdac30d6095d0fa84b88 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Fri, 27 Feb 2026 17:45:29 +0500 Subject: [PATCH 13/13] feat(android): Add background update notifications This commit introduces notifications for available app updates found during the periodic background check. When the `UpdateCheckWorker` finds new versions, it will now display a system notification. It also improves the reliability of tracking installations initiated by the app. - **feat(android)**: Added `showUpdateNotificationIfNeeded` to `UpdateCheckWorker` to display a system notification when one or more app updates are available. - **feat(android)**: Implemented runtime permission checks for `POST_NOTIFICATIONS` on Android 13 (API 33) and higher. - **feat(android)**: Created a "App Updates" notification channel during application startup. - **refactor(android)**: In `PackageEventReceiver`, improved logic to more reliably confirm an update by comparing the installed version code against the expected version code from the latest release. - **refactor(apps)**: The app now waits for the `PackageEventReceiver` to confirm an installation via a system broadcast, rather than immediately marking an update as successful after the installer is launched. - **chore(android)**: Added the `POST_NOTIFICATIONS` permission to `AndroidManifest.xml`. - **chore(android)**: Removed descriptive KDoc comments from `UpdateScheduler` and `PackageEventReceiver` to rely on code clarity. --- .../src/androidMain/AndroidManifest.xml | 6 +- .../rainxch/githubstore/app/GithubStoreApp.kt | 19 +++++ .../data/services/PackageEventReceiver.kt | 47 +++++++---- .../core/data/services/UpdateCheckWorker.kt | 77 +++++++++++++++++++ .../core/data/services/UpdateScheduler.kt | 22 ++---- .../apps/presentation/AppsViewModel.kt | 35 +++++---- 6 files changed, 155 insertions(+), 51 deletions(-) diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index c5f24899..d89f08bf 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -4,6 +4,8 @@ + + @@ -11,7 +13,6 @@ - - - + 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 c669b449..132f1d19 100644 --- a/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt +++ b/composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt @@ -1,6 +1,8 @@ package zed.rainxch.githubstore.app import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager import android.os.Build import org.koin.android.ext.android.get import org.koin.android.ext.koin.androidContext @@ -21,10 +23,23 @@ class GithubStoreApp : Application() { androidContext(this@GithubStoreApp) } + createNotificationChannels() registerPackageEventReceiver() scheduleBackgroundUpdateChecks() } + private fun createNotificationChannels() { + val channel = NotificationChannel( + UPDATES_CHANNEL_ID, + "App Updates", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Notifications when app updates are available" + } + val notificationManager = getSystemService(NotificationManager::class.java) + notificationManager.createNotificationChannel(channel) + } + private fun registerPackageEventReceiver() { val receiver = PackageEventReceiver( installedAppsRepository = get(), @@ -44,4 +59,8 @@ class GithubStoreApp : Application() { private fun scheduleBackgroundUpdateChecks() { UpdateScheduler.schedule(context = this) } + + companion object { + const val UPDATES_CHANNEL_ID = "app_updates" + } } \ No newline at end of file diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt index ff864acc..94d269f7 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt @@ -12,12 +12,6 @@ import kotlinx.coroutines.launch import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.system.PackageMonitor -/** - * Listens to system package install/uninstall/replace broadcasts. - * When a tracked package is installed or updated, it resolves the pending - * install flag and updates version info from the system PackageManager. - * When a tracked package is removed, it deletes the record from the database. - */ class PackageEventReceiver( private val installedAppsRepository: InstalledAppsRepository, private val packageMonitor: PackageMonitor @@ -49,17 +43,37 @@ class PackageEventReceiver( if (app.isPendingInstall) { val systemInfo = packageMonitor.getInstalledPackageInfo(packageName) if (systemInfo != null) { - installedAppsRepository.updateApp( - app.copy( - isPendingInstall = false, - isUpdateAvailable = false, - installedVersionName = systemInfo.versionName, - installedVersionCode = systemInfo.versionCode, - latestVersionName = systemInfo.versionName, - latestVersionCode = systemInfo.versionCode + val expectedVersionCode = app.latestVersionCode ?: 0L + val wasActuallyUpdated = expectedVersionCode > 0L && + systemInfo.versionCode >= expectedVersionCode + + if (wasActuallyUpdated) { + installedAppsRepository.updateAppVersion( + packageName = packageName, + newTag = app.latestVersion ?: systemInfo.versionName, + newAssetName = app.latestAssetName ?: "", + newAssetUrl = app.latestAssetUrl ?: "", + newVersionName = systemInfo.versionName, + newVersionCode = systemInfo.versionCode ) - ) - Logger.i { "Resolved pending install via broadcast: $packageName (v${systemInfo.versionName})" } + installedAppsRepository.updatePendingStatus(packageName, false) + Logger.i { "Update confirmed via broadcast: $packageName (v${systemInfo.versionName})" } + } else { + installedAppsRepository.updateApp( + app.copy( + isPendingInstall = false, + installedVersionName = systemInfo.versionName, + installedVersionCode = systemInfo.versionCode, + isUpdateAvailable = (app.latestVersionCode + ?: 0L) > systemInfo.versionCode + ) + ) + Logger.i { + "Package replaced but not updated to target: $packageName " + + "(system: v${systemInfo.versionName}/${systemInfo.versionCode}, " + + "target: v${app.latestVersionName}/${app.latestVersionCode})" + } + } } else { installedAppsRepository.updatePendingStatus(packageName, false) Logger.i { "Resolved pending install via broadcast (no system info): $packageName" } @@ -83,7 +97,6 @@ class PackageEventReceiver( private suspend fun onPackageRemoved(packageName: String) { try { - val app = installedAppsRepository.getAppByPackage(packageName) ?: return installedAppsRepository.deleteInstalledApp(packageName) Logger.i { "Removed uninstalled app via broadcast: $packageName" } } catch (e: Exception) { diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt index 64103c6c..131ccb70 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt @@ -1,9 +1,19 @@ package zed.rainxch.core.data.services +import android.Manifest +import android.annotation.SuppressLint +import android.app.PendingIntent import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import co.touchlab.kermit.Logger +import kotlinx.coroutines.flow.first import org.koin.core.component.KoinComponent import org.koin.core.component.inject import zed.rainxch.core.domain.repository.InstalledAppsRepository @@ -15,6 +25,7 @@ import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase * Runs via WorkManager on a configurable schedule (default: every 6 hours). * First syncs app state with the system package manager, then checks each * tracked app's GitHub repository for new releases. + * Shows a notification when updates are found. */ class UpdateCheckWorker( context: Context, @@ -37,6 +48,9 @@ class UpdateCheckWorker( // Check all tracked apps for updates installedAppsRepository.checkAllForUpdates() + // Show notification if any updates are available + showUpdateNotificationIfNeeded() + Logger.i { "UpdateCheckWorker: Periodic update check completed successfully" } Result.success() } catch (e: Exception) { @@ -49,7 +63,70 @@ class UpdateCheckWorker( } } + @SuppressLint("MissingPermission") // Permission checked at runtime before notify() + private suspend fun showUpdateNotificationIfNeeded() { + val appsWithUpdates = installedAppsRepository.getAppsWithUpdates().first() + if (appsWithUpdates.isEmpty()) { + Logger.d { "UpdateCheckWorker: No updates available, skipping notification" } + return + } + + // Check notification permission for API 33+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val granted = ContextCompat.checkSelfPermission( + applicationContext, + Manifest.permission.POST_NOTIFICATIONS + ) == PackageManager.PERMISSION_GRANTED + if (!granted) { + Logger.w { "UpdateCheckWorker: POST_NOTIFICATIONS permission not granted, skipping notification" } + return + } + } + + val title = if (appsWithUpdates.size == 1) { + "${appsWithUpdates.first().appName} update available" + } else { + "${appsWithUpdates.size} app updates available" + } + + val text = if (appsWithUpdates.size == 1) { + val app = appsWithUpdates.first() + "${app.installedVersion} → ${app.latestVersion}" + } else { + appsWithUpdates.joinToString(", ") { it.appName } + } + + val launchIntent = applicationContext.packageManager + .getLaunchIntentForPackage(applicationContext.packageName) + ?.apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + + val pendingIntent = launchIntent?.let { + PendingIntent.getActivity( + applicationContext, + 0, + it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + } + + val notification = NotificationCompat.Builder(applicationContext, UPDATES_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentTitle(title) + .setContentText(text) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + NotificationManagerCompat.from(applicationContext).notify(NOTIFICATION_ID, notification) + Logger.i { "UpdateCheckWorker: Showed notification for ${appsWithUpdates.size} updates" } + } + companion object { const val WORK_NAME = "github_store_update_check" + private const val UPDATES_CHANNEL_ID = "app_updates" + private const val NOTIFICATION_ID = 1001 } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt index 47dd286e..0184dfcb 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt @@ -10,20 +10,10 @@ import androidx.work.WorkManager import co.touchlab.kermit.Logger import java.util.concurrent.TimeUnit -/** - * Manages scheduling and cancellation of periodic update checks using WorkManager. - * - * Default schedule: every 6 hours with network connectivity constraint. - * Uses exponential backoff for retries with a 30-minute initial delay. - */ object UpdateScheduler { private const val DEFAULT_INTERVAL_HOURS = 6L - /** - * Schedules periodic update checks. Safe to call multiple times — - * existing work is kept unless [replace] is true. - */ fun schedule( context: Context, intervalHours: Long = DEFAULT_INTERVAL_HOURS, @@ -34,7 +24,8 @@ object UpdateScheduler { .build() val request = PeriodicWorkRequestBuilder( - intervalHours, TimeUnit.HOURS + repeatInterval = intervalHours, + repeatIntervalTimeUnit = TimeUnit.HOURS ) .setConstraints(constraints) .setBackoffCriteria( @@ -51,17 +42,14 @@ object UpdateScheduler { WorkManager.getInstance(context) .enqueueUniquePeriodicWork( - UpdateCheckWorker.WORK_NAME, - policy, - request + uniqueWorkName = UpdateCheckWorker.WORK_NAME, + existingPeriodicWorkPolicy = policy, + request = request ) Logger.i { "UpdateScheduler: Scheduled periodic update check every ${intervalHours}h (policy=$policy)" } } - /** - * Cancels the scheduled periodic update checks. - */ fun cancel(context: Context) { WorkManager.getInstance(context) .cancelUniqueWork(UpdateCheckWorker.WORK_NAME) 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 ff907530..6b7ed5bc 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 @@ -1,6 +1,5 @@ package zed.rainxch.apps.presentation -import androidx.compose.runtime.remember import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import zed.rainxch.githubstore.core.presentation.res.* @@ -356,7 +355,23 @@ class AppsViewModel( val apkInfo = installer.getApkInfoExtractor().extractPackageInfo(filePath) ?: throw IllegalStateException("Failed to extract APK info") - markPendingUpdate(app) + // Save latest release metadata and mark as pending install + // so PackageEventReceiver can verify the actual installation + val currentApp = installedAppsRepository.getAppByPackage(app.packageName) + if (currentApp != null) { + installedAppsRepository.updateApp( + currentApp.copy( + isPendingInstall = true, + latestVersion = latestVersion, + latestAssetName = latestAssetName, + latestAssetUrl = latestAssetUrl, + latestVersionName = apkInfo.versionName, + latestVersionCode = apkInfo.versionCode + ) + ) + } else { + markPendingUpdate(app) + } updateAppState(app.packageName, UpdateState.Installing) @@ -367,20 +382,12 @@ class AppsViewModel( throw e } - installedAppsRepository.updateAppVersion( - packageName = app.packageName, - newTag = latestVersion, - newAssetName = latestAssetName, - newAssetUrl = latestAssetUrl, - newVersionName = apkInfo.versionName, - newVersionCode = apkInfo.versionCode - ) - - updateAppState(app.packageName, UpdateState.Success) - delay(2000) + // Don't mark as updated here — installer.install() just launches the + // system install dialog and returns immediately. PackageEventReceiver + // will handle confirming the actual installation via broadcast. updateAppState(app.packageName, UpdateState.Idle) - logger.debug("Successfully updated ${app.appName} to ${latestVersion}") + logger.debug("Launched installer for ${app.appName} ${latestVersion}, waiting for system confirmation") } catch (e: CancellationException) { logger.debug("Update cancelled for ${app.packageName}")