From 456056a3be0251caf365eb379fe8c99735b37b1c Mon Sep 17 00:00:00 2001 From: Rainxch Zed Date: Sat, 14 Mar 2026 20:44:09 +0500 Subject: [PATCH] feat: add app linking, export/import, and pre-release support - Implement "Link app to repo" feature to manually associate installed apps with GitHub repositories - Add export and import functionality for tracked apps via JSON files - Add a setting to include or exclude pre-releases when checking for updates - Improve version comparison logic to support semantic versioning and prevent false downgrade notifications - Add uninstall confirmation dialogs to prevent accidental app removals - Update `PackageMonitor` and `AppsRepository` to support fetching all installed device apps - Enhance `ShareManager` with file picking and sharing capabilities across Android and Desktop platforms - Update UI with new action buttons, bottom sheets, and toggle cards for the new features --- .../src/androidMain/AndroidManifest.xml | 6 + .../zed/rainxch/githubstore/MainActivity.kt | 7 + .../src/androidMain/res/xml/filepaths.xml | 4 + .../data/services/AndroidPackageMonitor.kt | 38 ++ .../core/data/utils/AndroidShareManager.kt | 93 +++++ .../zed/rainxch/core/data/di/SharedModule.kt | 1 + .../repository/InstalledAppsRepositoryImpl.kt | 100 ++++- .../data/repository/ThemesRepositoryImpl.kt | 12 + .../data/services/DesktopPackageMonitor.kt | 3 + .../core/data/utils/DesktopShareManager.kt | 47 +++ .../rainxch/core/domain/model/DeviceApp.kt | 8 + .../rainxch/core/domain/model/ExportedApp.kt | 18 + .../domain/repository/ThemesRepository.kt | 2 + .../core/domain/system/PackageMonitor.kt | 3 + .../rainxch/core/domain/utils/ShareManager.kt | 2 + .../composeResources/values/strings.xml | 19 + feature/apps/data/build.gradle.kts | 1 + .../zed/rainxch/apps/data/di/SharedModule.kt | 2 + .../data/repository/AppsRepositoryImpl.kt | 178 ++++++++- .../apps/domain/model/GithubRepoInfo.kt | 12 + .../rainxch/apps/domain/model/ImportResult.kt | 7 + .../apps/domain/repository/AppsRepository.kt | 15 + .../rainxch/apps/presentation/AppsAction.kt | 18 + .../rainxch/apps/presentation/AppsEvent.kt | 14 + .../zed/rainxch/apps/presentation/AppsRoot.kt | 121 +++++++ .../rainxch/apps/presentation/AppsState.kt | 35 +- .../apps/presentation/AppsViewModel.kt | 225 ++++++++++++ .../components/LinkAppBottomSheet.kt | 342 ++++++++++++++++++ .../details/presentation/DetailsAction.kt | 3 + .../details/presentation/DetailsRoot.kt | 41 +++ .../details/presentation/DetailsState.kt | 1 + .../details/presentation/DetailsViewModel.kt | 27 ++ .../components/SmartInstallButton.kt | 2 +- .../profile/presentation/ProfileAction.kt | 1 + .../profile/presentation/ProfileState.kt | 1 + .../profile/presentation/ProfileViewModel.kt | 17 + .../components/sections/Installation.kt | 47 +++ 37 files changed, 1468 insertions(+), 5 deletions(-) create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DeviceApp.kt create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt create mode 100644 feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/GithubRepoInfo.kt create mode 100644 feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/ImportResult.kt create mode 100644 feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index d6ecdaa1..52f5235c 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -125,6 +125,12 @@ + + + (null) + private val shareManager: ShareManager by inject() override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() enableEdgeToEdge() + // Register activity result launcher for file picker (must be before STARTED) + (shareManager as? AndroidShareManager)?.registerActivityResultLauncher(this) + super.onCreate(savedInstanceState) handleIncomingIntent(intent) diff --git a/composeApp/src/androidMain/res/xml/filepaths.xml b/composeApp/src/androidMain/res/xml/filepaths.xml index 626005cb..99987807 100644 --- a/composeApp/src/androidMain/res/xml/filepaths.xml +++ b/composeApp/src/androidMain/res/xml/filepaths.xml @@ -5,4 +5,8 @@ name="ghs_downloads" path="/" /> + + \ No newline at end of file diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt index c62822dc..7b215245 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt @@ -1,10 +1,12 @@ package zed.rainxch.core.data.services import android.content.Context +import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.os.Build import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.model.SystemPackageInfo import zed.rainxch.core.domain.system.PackageMonitor @@ -55,4 +57,40 @@ class AndroidPackageMonitor( packages.map { it.packageName }.toSet() } + + override suspend fun getAllInstalledApps(): List = + withContext(Dispatchers.IO) { + val packages = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(0L)) + } else { + @Suppress("DEPRECATION") + packageManager.getInstalledPackages(0) + } + + packages + .filter { pkg -> + // Exclude system apps (keep user-installed + updated system apps) + val isSystemApp = (pkg.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_SYSTEM != 0 + val isUpdatedSystem = (pkg.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0 + !isSystemApp || isUpdatedSystem + } + .map { pkg -> + val versionCode = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + pkg.longVersionCode + } else { + @Suppress("DEPRECATION") + pkg.versionCode.toLong() + } + + DeviceApp( + packageName = pkg.packageName, + appName = pkg.applicationInfo?.loadLabel(packageManager)?.toString() ?: pkg.packageName, + versionName = pkg.versionName, + versionCode = versionCode, + ) + } + .sortedBy { it.appName.lowercase() } + } } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidShareManager.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidShareManager.kt index 4024cc89..888537ae 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidShareManager.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidShareManager.kt @@ -1,12 +1,49 @@ package zed.rainxch.core.data.utils +import android.app.Activity import android.content.Context import android.content.Intent +import android.net.Uri +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.FileProvider import zed.rainxch.core.domain.utils.ShareManager +import java.io.File class AndroidShareManager( private val context: Context, ) : ShareManager { + private var filePickerCallback: ((String?) -> Unit)? = null + private var filePickerLauncher: ActivityResultLauncher? = null + + fun registerActivityResultLauncher(activity: ComponentActivity) { + filePickerLauncher = activity.registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + val callback = filePickerCallback + filePickerCallback = null + + if (result.resultCode == Activity.RESULT_OK) { + val uri = result.data?.data + if (uri != null) { + try { + val content = context.contentResolver.openInputStream(uri) + ?.bufferedReader() + ?.use { it.readText() } + callback?.invoke(content) + } catch (e: Exception) { + callback?.invoke(null) + } + } else { + callback?.invoke(null) + } + } else { + callback?.invoke(null) + } + } + } + override fun shareText(text: String) { val intent = Intent(Intent.ACTION_SEND).apply { @@ -21,4 +58,60 @@ class AndroidShareManager( context.startActivity(chooser) } + + override fun shareFile(fileName: String, content: String, mimeType: String) { + val cacheDir = File(context.cacheDir, "exports") + cacheDir.mkdirs() + + val file = File(cacheDir, fileName) + file.writeText(content) + + val uri: Uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + + val intent = Intent(Intent.ACTION_SEND).apply { + type = mimeType + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + val chooser = Intent.createChooser(intent, null).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + context.startActivity(chooser) + } + + override fun pickFile(mimeType: String, onResult: (String?) -> Unit) { + filePickerCallback = onResult + + val launcher = filePickerLauncher + if (launcher != null) { + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = mimeType + } + launcher.launch(intent) + } else { + // Fallback: try with ACTION_GET_CONTENT + val intent = Intent(Intent.ACTION_GET_CONTENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = mimeType + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(Intent.createChooser(intent, null).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }) + // Note: fallback won't deliver result without launcher + onResult(null) + } catch (e: Exception) { + onResult(null) + } + } + } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt index d2e290d9..ec7c5f45 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt @@ -75,6 +75,7 @@ val coreModule = historyDao = get(), installer = get(), httpClient = get(), + themesRepository = get(), ) } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index ea98821a..c5ef9c5f 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 @@ -23,6 +23,7 @@ import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.repository.InstalledAppsRepository +import zed.rainxch.core.domain.repository.ThemesRepository import zed.rainxch.core.domain.system.Installer class InstalledAppsRepositoryImpl( @@ -31,6 +32,7 @@ class InstalledAppsRepositoryImpl( private val historyDao: UpdateHistoryDao, private val installer: Installer, private val httpClient: HttpClient, + private val themesRepository: ThemesRepository, ) : InstalledAppsRepository { override suspend fun executeInTransaction(block: suspend () -> R): R = database.useWriterConnection { transactor -> @@ -76,6 +78,8 @@ class InstalledAppsRepositoryImpl( repo: String, ): GithubRelease? { return try { + val includePreReleases = themesRepository.getIncludePreReleases().first() + val releases = httpClient .executeRequest> { @@ -88,7 +92,8 @@ class InstalledAppsRepositoryImpl( val latest = releases .asSequence() - .filter { (it.draft != true) && (it.prerelease != true) } + .filter { it.draft != true } + .filter { includePreReleases || it.prerelease != true } .maxByOrNull { it.publishedAt ?: it.createdAt ?: "" } ?: return null @@ -119,7 +124,13 @@ class InstalledAppsRepositoryImpl( } val primaryAsset = installer.choosePrimaryAsset(installableAssets) - val isUpdateAvailable = normalizedInstalledTag != normalizedLatestTag + // Only flag as update if the latest version is actually newer + // (not just different — avoids false "downgrade" notifications) + val isUpdateAvailable = if (normalizedInstalledTag == normalizedLatestTag) { + false + } else { + isVersionNewer(normalizedLatestTag, normalizedInstalledTag) + } Logger.d { "Update check for ${app.appName}: " + @@ -226,4 +237,89 @@ class InstalledAppsRepositoryImpl( } private fun normalizeVersion(version: String): String = version.removePrefix("v").removePrefix("V").trim() + + /** + * Compare two version strings and return true if [candidate] is newer than [current]. + * Handles semantic versioning (1.2.3), pre-release suffixes (1.2.3-beta.1), + * and falls back to lexicographic comparison for non-standard formats. + * + * Pre-release versions are considered older than their stable counterparts: + * 1.2.3-beta < 1.2.3 (per semver spec) + * + * This prevents false "downgrade" notifications when a user has a pre-release + * installed and the latest stable version has a lower or equal base version. + */ + private fun isVersionNewer(candidate: String, current: String): Boolean { + val candidateParsed = parseSemanticVersion(candidate) + val currentParsed = parseSemanticVersion(current) + + if (candidateParsed != null && currentParsed != null) { + // Compare major.minor.patch + for (i in 0 until maxOf(candidateParsed.numbers.size, currentParsed.numbers.size)) { + val c = candidateParsed.numbers.getOrElse(i) { 0 } + val r = currentParsed.numbers.getOrElse(i) { 0 } + if (c > r) return true + if (c < r) return false + } + // Numbers are equal; compare pre-release suffixes + // No pre-release > has pre-release (e.g., 1.0.0 > 1.0.0-beta) + return when { + candidateParsed.preRelease == null && currentParsed.preRelease != null -> true + candidateParsed.preRelease != null && currentParsed.preRelease == null -> false + candidateParsed.preRelease != null && currentParsed.preRelease != null -> + comparePreRelease(candidateParsed.preRelease, currentParsed.preRelease) > 0 + else -> false // both null, versions are equal + } + } + + // Fallback: lexicographic comparison (better than just "not equal") + return candidate > current + } + + private data class SemanticVersion( + val numbers: List, + val preRelease: String?, + ) + + private fun parseSemanticVersion(version: String): SemanticVersion? { + // Split off pre-release suffix: "1.2.3-beta.1" -> "1.2.3" and "beta.1" + val hyphenIndex = version.indexOf('-') + val numberPart = if (hyphenIndex >= 0) version.substring(0, hyphenIndex) else version + val preRelease = if (hyphenIndex >= 0) version.substring(hyphenIndex + 1) else null + + val parts = numberPart.split(".") + val numbers = parts.mapNotNull { it.toIntOrNull() } + + // Only valid if we could parse at least one number and all parts were valid numbers + if (numbers.isEmpty() || numbers.size != parts.size) return null + + return SemanticVersion(numbers, preRelease) + } + + /** + * Compare pre-release identifiers per semver spec: + * Identifiers consisting of only digits are compared numerically. + * Identifiers with letters are compared lexically. + * Numeric identifiers always have lower precedence than alphanumeric. + * A larger set of pre-release fields has higher precedence if all preceding are equal. + */ + private fun comparePreRelease(a: String, b: String): Int { + val aParts = a.split(".") + val bParts = b.split(".") + + for (i in 0 until minOf(aParts.size, bParts.size)) { + val aNum = aParts[i].toIntOrNull() + val bNum = bParts[i].toIntOrNull() + + val cmp = when { + aNum != null && bNum != null -> aNum.compareTo(bNum) + aNum != null -> -1 // numeric < alphanumeric + bNum != null -> 1 + else -> aParts[i].compareTo(bParts[i]) + } + if (cmp != 0) return cmp + } + + return aParts.size.compareTo(bParts.size) + } } diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt index 68eb2d64..89ae3202 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt @@ -24,6 +24,7 @@ class ThemesRepositoryImpl( private val INSTALLER_TYPE_KEY = stringPreferencesKey("installer_type") private val AUTO_UPDATE_KEY = booleanPreferencesKey("auto_update_enabled") private val UPDATE_CHECK_INTERVAL_KEY = longPreferencesKey("update_check_interval_hours") + private val INCLUDE_PRE_RELEASES_KEY = booleanPreferencesKey("include_pre_releases") override fun getThemeColor(): Flow = preferences.data.map { prefs -> @@ -121,6 +122,17 @@ class ThemesRepositoryImpl( } } + override fun getIncludePreReleases(): Flow = + preferences.data.map { prefs -> + prefs[INCLUDE_PRE_RELEASES_KEY] ?: false + } + + override suspend fun setIncludePreReleases(enabled: Boolean) { + preferences.edit { prefs -> + prefs[INCLUDE_PRE_RELEASES_KEY] = enabled + } + } + companion object { const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 6L } diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopPackageMonitor.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopPackageMonitor.kt index b9f745dd..cd7e6e08 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopPackageMonitor.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopPackageMonitor.kt @@ -1,5 +1,6 @@ package zed.rainxch.core.data.services +import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.model.SystemPackageInfo import zed.rainxch.core.domain.system.PackageMonitor @@ -9,4 +10,6 @@ class DesktopPackageMonitor : PackageMonitor { override suspend fun getInstalledPackageInfo(packageName: String): SystemPackageInfo? = null override suspend fun getAllInstalledPackageNames(): Set = setOf() + + override suspend fun getAllInstalledApps(): List = emptyList() } diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopShareManager.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopShareManager.kt index e7336fdb..fd14e4db 100644 --- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopShareManager.kt +++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopShareManager.kt @@ -1,8 +1,15 @@ package zed.rainxch.core.data.utils import zed.rainxch.core.domain.utils.ShareManager +import java.awt.Desktop +import java.awt.FileDialog +import java.awt.Frame import java.awt.Toolkit import java.awt.datatransfer.StringSelection +import java.io.File +import javax.swing.JFileChooser +import javax.swing.SwingUtilities +import javax.swing.filechooser.FileNameExtensionFilter class DesktopShareManager : ShareManager { override fun shareText(text: String) { @@ -10,4 +17,44 @@ class DesktopShareManager : ShareManager { val selection = StringSelection(text) clipboard.setContents(selection, null) } + + override fun shareFile(fileName: String, content: String, mimeType: String) { + SwingUtilities.invokeLater { + val chooser = JFileChooser().apply { + dialogTitle = "Save exported apps" + selectedFile = File(fileName) + fileFilter = FileNameExtensionFilter("JSON files", "json") + } + + val result = chooser.showSaveDialog(null) + if (result == JFileChooser.APPROVE_OPTION) { + var file = chooser.selectedFile + if (!file.name.endsWith(".json")) { + file = File(file.absolutePath + ".json") + } + file.writeText(content) + } + } + } + + override fun pickFile(mimeType: String, onResult: (String?) -> Unit) { + SwingUtilities.invokeLater { + val chooser = JFileChooser().apply { + dialogTitle = "Select file to import" + fileFilter = FileNameExtensionFilter("JSON files", "json") + } + + val result = chooser.showOpenDialog(null) + if (result == JFileChooser.APPROVE_OPTION) { + try { + val content = chooser.selectedFile.readText() + onResult(content) + } catch (e: Exception) { + onResult(null) + } + } else { + onResult(null) + } + } + } } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DeviceApp.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DeviceApp.kt new file mode 100644 index 00000000..645964e3 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DeviceApp.kt @@ -0,0 +1,8 @@ +package zed.rainxch.core.domain.model + +data class DeviceApp( + val packageName: String, + val appName: String, + val versionName: String?, + val versionCode: Long, +) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt new file mode 100644 index 00000000..a92d1a2f --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt @@ -0,0 +1,18 @@ +package zed.rainxch.core.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +data class ExportedApp( + val packageName: String, + val repoOwner: String, + val repoName: String, + val repoUrl: String, +) + +@Serializable +data class ExportedAppList( + val version: Int = 1, + val exportedAt: Long = 0L, + val apps: List = emptyList(), +) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ThemesRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ThemesRepository.kt index aad26498..f307ca04 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ThemesRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ThemesRepository.kt @@ -22,4 +22,6 @@ interface ThemesRepository { suspend fun setAutoUpdateEnabled(enabled: Boolean) fun getUpdateCheckInterval(): Flow suspend fun setUpdateCheckInterval(hours: Long) + fun getIncludePreReleases(): Flow + suspend fun setIncludePreReleases(enabled: Boolean) } \ No newline at end of file diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/PackageMonitor.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/PackageMonitor.kt index e8d80e01..041a0a9f 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/PackageMonitor.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/PackageMonitor.kt @@ -1,5 +1,6 @@ package zed.rainxch.core.domain.system +import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.model.SystemPackageInfo interface PackageMonitor { @@ -8,4 +9,6 @@ interface PackageMonitor { suspend fun getInstalledPackageInfo(packageName: String): SystemPackageInfo? suspend fun getAllInstalledPackageNames(): Set + + suspend fun getAllInstalledApps(): List } diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/ShareManager.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/ShareManager.kt index 05b18b39..262c7e44 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/ShareManager.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/ShareManager.kt @@ -2,4 +2,6 @@ package zed.rainxch.core.domain.utils interface ShareManager { fun shareText(text: String) + fun shareFile(fileName: String, content: String, mimeType: String = "application/json") + fun pickFile(mimeType: String = "application/json", onResult: (String?) -> Unit) } diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 886071dd..b0ddca4d 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -533,4 +533,23 @@ 6h 12h 24h + + Add by link + Link app to repository + Pick an installed app to link with a GitHub repository + Search apps… + GitHub repository URL + github.com/owner/repo + Validating… + Link & Track + Export + Import + Import apps + Paste the exported JSON to restore your tracked apps + Paste exported JSON here… + + Include pre-releases + Track pre-release versions when checking for updates. When disabled, only stable releases are considered. + Uninstall app? + Are you sure you want to uninstall %1$s? This action cannot be undone and app data may be lost. \ No newline at end of file diff --git a/feature/apps/data/build.gradle.kts b/feature/apps/data/build.gradle.kts index 8deab868..f452adf3 100644 --- a/feature/apps/data/build.gradle.kts +++ b/feature/apps/data/build.gradle.kts @@ -14,6 +14,7 @@ kotlin { implementation(projects.feature.apps.domain) implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) implementation(libs.bundles.ktor.common) implementation(libs.bundles.koin.common) } diff --git a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt index cb1f7189..ffe42d76 100644 --- a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt +++ b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt @@ -12,6 +12,8 @@ val appsModule = appsRepository = get(), logger = get(), httpClient = get(), + packageMonitor = get(), + themesRepository = get(), ) } } diff --git a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt index e3be78bd..d6be1a6c 100644 --- a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt +++ b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt @@ -6,23 +6,40 @@ import io.ktor.client.request.header import io.ktor.client.request.parameter import io.ktor.http.HttpHeaders import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.serialization.json.Json +import zed.rainxch.apps.domain.model.GithubRepoInfo +import zed.rainxch.apps.domain.model.ImportResult import zed.rainxch.apps.domain.repository.AppsRepository +import zed.rainxch.core.data.dto.GithubRepoNetworkModel import zed.rainxch.core.data.dto.ReleaseNetwork import zed.rainxch.core.data.mappers.toDomain import zed.rainxch.core.data.network.executeRequest import zed.rainxch.core.domain.logging.GitHubStoreLogger +import zed.rainxch.core.domain.model.DeviceApp +import zed.rainxch.core.domain.model.ExportedApp +import zed.rainxch.core.domain.model.ExportedAppList import zed.rainxch.core.domain.model.GithubRelease +import zed.rainxch.core.domain.model.InstallSource import zed.rainxch.core.domain.model.InstalledApp import zed.rainxch.core.domain.model.RateLimitException import zed.rainxch.core.domain.repository.InstalledAppsRepository +import zed.rainxch.core.domain.repository.ThemesRepository +import zed.rainxch.core.domain.system.PackageMonitor import zed.rainxch.core.domain.utils.AppLauncher +import kotlin.time.Clock class AppsRepositoryImpl( private val appLauncher: AppLauncher, private val appsRepository: InstalledAppsRepository, private val logger: GitHubStoreLogger, private val httpClient: HttpClient, + private val packageMonitor: PackageMonitor, + private val themesRepository: ThemesRepository, ) : AppsRepository { + + private val json = Json { ignoreUnknownKeys = true } + override suspend fun getApps(): Flow> = appsRepository.getAllInstalledApps() override suspend fun openApp( @@ -48,6 +65,8 @@ class AppsRepositoryImpl( repo: String, ): GithubRelease? = try { + val includePreReleases = themesRepository.getIncludePreReleases().first() + val releases = httpClient .executeRequest> { @@ -59,7 +78,8 @@ class AppsRepositoryImpl( releases .asSequence() - .filter { (it.draft != true) && (it.prerelease != true) } + .filter { it.draft != true } + .filter { includePreReleases || it.prerelease != true } .maxByOrNull { it.publishedAt ?: it.createdAt ?: "" } ?.toDomain() } catch (e: RateLimitException) { @@ -68,4 +88,160 @@ class AppsRepositoryImpl( logger.error("Failed to fetch latest release for $owner/$repo: ${e.message}") null } + + override suspend fun getDeviceApps(): List = + packageMonitor.getAllInstalledApps() + + override suspend fun getTrackedPackageNames(): Set = + appsRepository.getAllInstalledApps().first().map { it.packageName }.toSet() + + override suspend fun fetchRepoInfo(owner: String, repo: String): GithubRepoInfo? = + try { + val repoModel = + httpClient + .executeRequest { + get("/repos/$owner/$repo") { + header(HttpHeaders.Accept, "application/vnd.github+json") + } + }.getOrThrow() + + // Also fetch latest release tag + val includePreReleases = themesRepository.getIncludePreReleases().first() + val latestTag = try { + val releases = + httpClient + .executeRequest> { + get("/repos/$owner/$repo/releases") { + header(HttpHeaders.Accept, "application/vnd.github+json") + parameter("per_page", 5) + } + }.getOrThrow() + + releases + .asSequence() + .filter { it.draft != true } + .filter { includePreReleases || it.prerelease != true } + .maxByOrNull { it.publishedAt ?: it.createdAt ?: "" } + ?.tagName + } catch (_: Exception) { + null + } + + GithubRepoInfo( + id = repoModel.id, + name = repoModel.name, + owner = repoModel.owner.login, + ownerAvatarUrl = repoModel.owner.avatarUrl, + description = repoModel.description, + language = repoModel.language, + htmlUrl = repoModel.htmlUrl, + latestReleaseTag = latestTag, + ) + } catch (e: RateLimitException) { + throw e + } catch (e: Exception) { + logger.error("Failed to fetch repo info for $owner/$repo: ${e.message}") + null + } + + override suspend fun linkAppToRepo(deviceApp: DeviceApp, repoInfo: GithubRepoInfo) { + val now = Clock.System.now().toEpochMilliseconds() + + val installedApp = InstalledApp( + packageName = deviceApp.packageName, + repoId = repoInfo.id, + repoName = repoInfo.name, + repoOwner = repoInfo.owner, + repoOwnerAvatarUrl = repoInfo.ownerAvatarUrl, + repoDescription = repoInfo.description, + primaryLanguage = repoInfo.language, + repoUrl = repoInfo.htmlUrl, + installedVersion = deviceApp.versionName ?: "unknown", + installedAssetName = null, + installedAssetUrl = null, + latestVersion = repoInfo.latestReleaseTag, + latestAssetName = null, + latestAssetUrl = null, + latestAssetSize = null, + appName = deviceApp.appName, + installSource = InstallSource.MANUAL, + installedAt = now, + lastCheckedAt = 0L, + lastUpdatedAt = now, + isUpdateAvailable = false, + updateCheckEnabled = true, + releaseNotes = null, + systemArchitecture = "", + fileExtension = "apk", + isPendingInstall = false, + installedVersionName = deviceApp.versionName, + installedVersionCode = deviceApp.versionCode, + ) + + appsRepository.saveInstalledApp(installedApp) + } + + override suspend fun exportApps(): String { + val apps = appsRepository.getAllInstalledApps().first() + val exported = ExportedAppList( + version = 1, + exportedAt = Clock.System.now().toEpochMilliseconds(), + apps = apps.map { app -> + ExportedApp( + packageName = app.packageName, + repoOwner = app.repoOwner, + repoName = app.repoName, + repoUrl = app.repoUrl, + ) + }, + ) + return json.encodeToString(ExportedAppList.serializer(), exported) + } + + override suspend fun importApps(jsonString: String): ImportResult { + val exportedList = try { + json.decodeFromString(ExportedAppList.serializer(), jsonString) + } catch (e: Exception) { + logger.error("Failed to parse import JSON: ${e.message}") + return ImportResult(imported = 0, skipped = 0, failed = 1) + } + + val trackedPackages = getTrackedPackageNames() + var imported = 0 + var skipped = 0 + var failed = 0 + + for (exportedApp in exportedList.apps) { + if (exportedApp.packageName in trackedPackages) { + skipped++ + continue + } + + try { + val repoInfo = fetchRepoInfo(exportedApp.repoOwner, exportedApp.repoName) + if (repoInfo == null) { + failed++ + continue + } + + // Try to get device app info if installed + val systemInfo = packageMonitor.getInstalledPackageInfo(exportedApp.packageName) + + val deviceApp = DeviceApp( + packageName = exportedApp.packageName, + appName = exportedApp.repoName, + versionName = systemInfo?.versionName, + versionCode = systemInfo?.versionCode ?: 0L, + ) + + linkAppToRepo(deviceApp, repoInfo) + imported++ + } catch (e: Exception) { + logger.error("Failed to import ${exportedApp.repoOwner}/${exportedApp.repoName}: ${e.message}") + failed++ + } + } + + return ImportResult(imported = imported, skipped = skipped, failed = failed) + } } diff --git a/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/GithubRepoInfo.kt b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/GithubRepoInfo.kt new file mode 100644 index 00000000..c3509ee9 --- /dev/null +++ b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/GithubRepoInfo.kt @@ -0,0 +1,12 @@ +package zed.rainxch.apps.domain.model + +data class GithubRepoInfo( + val id: Long, + val name: String, + val owner: String, + val ownerAvatarUrl: String, + val description: String?, + val language: String?, + val htmlUrl: String, + val latestReleaseTag: String?, +) diff --git a/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/ImportResult.kt b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/ImportResult.kt new file mode 100644 index 00000000..4fe815bb --- /dev/null +++ b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/ImportResult.kt @@ -0,0 +1,7 @@ +package zed.rainxch.apps.domain.model + +data class ImportResult( + val imported: Int, + val skipped: Int, + val failed: Int, +) diff --git a/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt index e281b77f..8397a60d 100644 --- a/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt +++ b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt @@ -1,6 +1,9 @@ package zed.rainxch.apps.domain.repository import kotlinx.coroutines.flow.Flow +import zed.rainxch.apps.domain.model.GithubRepoInfo +import zed.rainxch.apps.domain.model.ImportResult +import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.model.GithubRelease import zed.rainxch.core.domain.model.InstalledApp @@ -16,4 +19,16 @@ interface AppsRepository { owner: String, repo: String, ): GithubRelease? + + suspend fun getDeviceApps(): List + + suspend fun getTrackedPackageNames(): Set + + suspend fun fetchRepoInfo(owner: String, repo: String): GithubRepoInfo? + + suspend fun linkAppToRepo(deviceApp: DeviceApp, repoInfo: GithubRepoInfo) + + suspend fun exportApps(): String + + suspend fun importApps(json: String): ImportResult } 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 5a5d134b..8c10d2c2 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 @@ -1,5 +1,6 @@ package zed.rainxch.apps.presentation +import zed.rainxch.core.domain.model.DeviceApp import zed.rainxch.core.domain.model.InstalledApp sealed interface AppsAction { @@ -36,4 +37,21 @@ sealed interface AppsAction { data class OnUninstallApp( val app: InstalledApp, ) : AppsAction + + // Uninstall confirmation + data class OnUninstallConfirmed(val app: InstalledApp) : AppsAction + data object OnDismissUninstallDialog : AppsAction + + // Link app to repo + data object OnAddByLinkClick : AppsAction + data object OnDismissLinkSheet : AppsAction + data class OnDeviceAppSearchChange(val query: String) : AppsAction + data class OnDeviceAppSelected(val app: DeviceApp) : AppsAction + data class OnRepoUrlChanged(val url: String) : AppsAction + data object OnValidateAndLinkRepo : AppsAction + data object OnBackToAppPicker : AppsAction + + // Export/Import + data object OnExportApps : AppsAction + data object OnImportApps : AppsAction } diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt index 5eab3e72..f717bd78 100644 --- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt @@ -1,5 +1,7 @@ package zed.rainxch.apps.presentation +import zed.rainxch.apps.domain.model.ImportResult + sealed interface AppsEvent { data class ShowError( val message: String, @@ -12,4 +14,16 @@ sealed interface AppsEvent { data class NavigateToRepo( val repoId: Long, ) : AppsEvent + + data class AppLinkedSuccessfully( + val appName: String, + ) : AppsEvent + + data class ExportReady( + val json: String, + ) : AppsEvent + + data class ImportComplete( + val result: ImportResult, + ) : AppsEvent } 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 dd44378d..69bf413f 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 @@ -22,6 +22,7 @@ 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 +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Cancel import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close @@ -29,15 +30,22 @@ 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.material.icons.outlined.FileDownload +import androidx.compose.material.icons.outlined.FileUpload +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.AlertDialog 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.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearWavyProgressIndicator @@ -46,14 +54,17 @@ 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.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.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.clip @@ -68,6 +79,7 @@ import io.github.fletchmckee.liquid.liquefiable import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel +import zed.rainxch.apps.presentation.components.LinkAppBottomSheet import zed.rainxch.apps.presentation.model.AppItem import zed.rainxch.apps.presentation.model.UpdateAllProgress import zed.rainxch.apps.presentation.model.UpdateState @@ -105,6 +117,25 @@ fun AppsRoot( snackbarHostState.showSnackbar(event.message) } } + + is AppsEvent.AppLinkedSuccessfully -> { + coroutineScope.launch { + snackbarHostState.showSnackbar("${event.appName} linked successfully") + } + } + + is AppsEvent.ExportReady -> { + // Share already handled by ShareManager in ViewModel + } + + is AppsEvent.ImportComplete -> { + val r = event.result + coroutineScope.launch { + snackbarHostState.showSnackbar( + "Imported ${r.imported}, skipped ${r.skipped}, failed ${r.failed}", + ) + } + } } } @@ -133,6 +164,7 @@ fun AppsScreen( ) { val liquidState = LocalBottomNavigationLiquid.current val bottomNavHeight = LocalBottomNavigationHeight.current + var showOverflowMenu by remember { mutableStateOf(false) } Scaffold( topBar = { @@ -154,9 +186,54 @@ fun AppsScreen( contentDescription = stringResource(Res.string.check_for_updates), ) } + + Box { + IconButton(onClick = { showOverflowMenu = true }) { + Icon( + imageVector = Icons.Outlined.MoreVert, + contentDescription = null, + ) + } + DropdownMenu( + expanded = showOverflowMenu, + onDismissRequest = { showOverflowMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(Res.string.export_apps)) }, + onClick = { + showOverflowMenu = false + onAction(AppsAction.OnExportApps) + }, + leadingIcon = { + Icon(Icons.Outlined.FileUpload, contentDescription = null) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(Res.string.import_apps)) }, + onClick = { + showOverflowMenu = false + onAction(AppsAction.OnImportApps) + }, + leadingIcon = { + Icon(Icons.Outlined.FileDownload, contentDescription = null) + }, + ) + } + } }, ) }, + floatingActionButton = { + FloatingActionButton( + onClick = { onAction(AppsAction.OnAddByLinkClick) }, + modifier = Modifier.padding(bottom = bottomNavHeight), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(Res.string.add_by_link), + ) + } + }, snackbarHost = { SnackbarHost( hostState = snackbarHostState, @@ -165,6 +242,50 @@ fun AppsScreen( }, modifier = Modifier.liquefiable(liquidState), ) { innerPadding -> + + // Link app bottom sheet + if (state.showLinkSheet) { + LinkAppBottomSheet( + state = state, + onAction = onAction, + ) + } + + // Uninstall confirmation dialog + state.appPendingUninstall?.let { app -> + AlertDialog( + onDismissRequest = { onAction(AppsAction.OnDismissUninstallDialog) }, + title = { + Text( + text = stringResource(Res.string.confirm_uninstall_title), + fontWeight = FontWeight.Bold, + ) + }, + text = { + Text( + text = stringResource(Res.string.confirm_uninstall_message, app.appName), + ) + }, + confirmButton = { + TextButton( + onClick = { onAction(AppsAction.OnUninstallConfirmed(app)) }, + ) { + Text( + text = stringResource(Res.string.uninstall), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton( + onClick = { onAction(AppsAction.OnDismissUninstallDialog) }, + ) { + Text(text = stringResource(Res.string.cancel)) + } + }, + ) + } + PullToRefreshBox( isRefreshing = state.isRefreshing, onRefresh = { onAction(AppsAction.OnRefresh) }, 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 e6c8b1e3..adcc80e1 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 @@ -1,7 +1,10 @@ package zed.rainxch.apps.presentation +import zed.rainxch.apps.domain.model.GithubRepoInfo import zed.rainxch.apps.presentation.model.AppItem import zed.rainxch.apps.presentation.model.UpdateAllProgress +import zed.rainxch.core.domain.model.DeviceApp +import zed.rainxch.core.domain.model.InstalledApp data class AppsState( val apps: List = emptyList(), @@ -14,4 +17,34 @@ data class AppsState( val isCheckingForUpdates: Boolean = false, val lastCheckedTimestamp: Long? = null, val isRefreshing: Boolean = false, -) + // Link app to repo + val showLinkSheet: Boolean = false, + val linkStep: LinkStep = LinkStep.PickApp, + val deviceApps: List = emptyList(), + val deviceAppSearchQuery: String = "", + val selectedDeviceApp: DeviceApp? = null, + val repoUrl: String = "", + val isValidatingRepo: Boolean = false, + val repoValidationError: String? = null, + val fetchedRepoInfo: GithubRepoInfo? = null, + // Export/Import + val isExporting: Boolean = false, + val isImporting: Boolean = false, + // Uninstall confirmation + val appPendingUninstall: InstalledApp? = null, +) { + val filteredDeviceApps: List + get() = if (deviceAppSearchQuery.isBlank()) { + deviceApps + } else { + deviceApps.filter { + it.appName.contains(deviceAppSearchQuery, ignoreCase = true) || + it.packageName.contains(deviceAppSearchQuery, ignoreCase = true) + } + } +} + +enum class LinkStep { + PickApp, + EnterUrl, +} 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 41524c27..9d65fab7 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 @@ -26,6 +26,7 @@ import zed.rainxch.core.domain.network.Downloader import zed.rainxch.core.domain.repository.InstalledAppsRepository import zed.rainxch.core.domain.system.Installer import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase +import zed.rainxch.core.domain.utils.ShareManager import zed.rainxch.githubstore.core.presentation.res.* import java.io.File @@ -36,6 +37,7 @@ class AppsViewModel( private val installedAppsRepository: InstalledAppsRepository, private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase, private val logger: GitHubStoreLogger, + private val shareManager: ShareManager, ) : ViewModel() { companion object { private const val UPDATE_CHECK_COOLDOWN_MS = 30 * 60 * 1000L // 30 minutes @@ -204,7 +206,58 @@ class AppsViewModel( } is AppsAction.OnUninstallApp -> { + _state.update { it.copy(appPendingUninstall = action.app) } + } + + // Link app to repo + AppsAction.OnAddByLinkClick -> openLinkSheet() + AppsAction.OnDismissLinkSheet -> dismissLinkSheet() + is AppsAction.OnDeviceAppSearchChange -> { + _state.update { it.copy(deviceAppSearchQuery = action.query) } + } + is AppsAction.OnDeviceAppSelected -> { + _state.update { + it.copy( + selectedDeviceApp = action.app, + linkStep = LinkStep.EnterUrl, + repoUrl = "", + repoValidationError = null, + fetchedRepoInfo = null, + ) + } + } + is AppsAction.OnRepoUrlChanged -> { + _state.update { + it.copy( + repoUrl = action.url, + repoValidationError = null, + ) + } + } + AppsAction.OnValidateAndLinkRepo -> validateAndLinkRepo() + AppsAction.OnBackToAppPicker -> { + _state.update { + it.copy( + linkStep = LinkStep.PickApp, + selectedDeviceApp = null, + repoUrl = "", + repoValidationError = null, + fetchedRepoInfo = null, + ) + } + } + + // Export/Import + AppsAction.OnExportApps -> exportApps() + AppsAction.OnImportApps -> importAppsFromFile() + + // Uninstall confirmation + is AppsAction.OnUninstallConfirmed -> { uninstallApp(action.app) + _state.update { it.copy(appPendingUninstall = null) } + } + AppsAction.OnDismissUninstallDialog -> { + _state.update { it.copy(appPendingUninstall = null) } } } } @@ -634,6 +687,178 @@ class AppsViewModel( } } + // ── Link app to repo ────────────────────────────────────────── + + private fun openLinkSheet() { + viewModelScope.launch { + _state.update { + it.copy( + showLinkSheet = true, + linkStep = LinkStep.PickApp, + deviceApps = emptyList(), + deviceAppSearchQuery = "", + selectedDeviceApp = null, + repoUrl = "", + repoValidationError = null, + fetchedRepoInfo = null, + ) + } + + try { + val trackedPackages = appsRepository.getTrackedPackageNames() + val deviceApps = appsRepository.getDeviceApps() + .filter { it.packageName !in trackedPackages } + + _state.update { it.copy(deviceApps = deviceApps) } + } catch (e: Exception) { + logger.error("Failed to load device apps: ${e.message}") + _events.send(AppsEvent.ShowError("Failed to load installed apps")) + } + } + } + + private fun dismissLinkSheet() { + _state.update { + it.copy( + showLinkSheet = false, + linkStep = LinkStep.PickApp, + deviceApps = emptyList(), + deviceAppSearchQuery = "", + selectedDeviceApp = null, + repoUrl = "", + repoValidationError = null, + fetchedRepoInfo = null, + isValidatingRepo = false, + ) + } + } + + private fun validateAndLinkRepo() { + val selectedApp = _state.value.selectedDeviceApp ?: return + val url = _state.value.repoUrl.trim() + + // Parse owner/repo from URL + val (owner, repo) = parseGithubUrl(url) ?: run { + _state.update { it.copy(repoValidationError = "Invalid GitHub URL. Use format: github.com/owner/repo") } + return + } + + viewModelScope.launch { + _state.update { it.copy(isValidatingRepo = true, repoValidationError = null) } + + try { + val repoInfo = appsRepository.fetchRepoInfo(owner, repo) + if (repoInfo == null) { + _state.update { + it.copy( + isValidatingRepo = false, + repoValidationError = "Repository not found: $owner/$repo", + ) + } + return@launch + } + + appsRepository.linkAppToRepo(selectedApp, repoInfo) + + _state.update { + it.copy( + isValidatingRepo = false, + showLinkSheet = false, + ) + } + + _events.send(AppsEvent.AppLinkedSuccessfully(selectedApp.appName)) + _events.send(AppsEvent.ShowSuccess("${selectedApp.appName} linked to ${repoInfo.owner}/${repoInfo.name}")) + } catch (e: RateLimitException) { + _state.update { + it.copy( + isValidatingRepo = false, + repoValidationError = "GitHub API rate limit exceeded. Try again later.", + ) + } + } catch (e: Exception) { + logger.error("Failed to link app: ${e.message}") + _state.update { + it.copy( + isValidatingRepo = false, + repoValidationError = "Failed to link: ${e.message}", + ) + } + } + } + } + + private fun parseGithubUrl(input: String): Pair? { + val cleaned = input.trim() + .removePrefix("https://") + .removePrefix("http://") + .removePrefix("www.") + .removePrefix("github.com/") + .removeSuffix("/") + .split("?")[0] // remove query params + .split("#")[0] // remove fragment + + val parts = cleaned.split("/") + if (parts.size < 2) return null + + val owner = parts[0] + val repo = parts[1] + + if (owner.isBlank() || repo.isBlank()) return null + if (owner.length > 39 || repo.length > 100) return null + + return owner to repo + } + + // ── Export/Import ───────────────────────────────────────────── + + private fun exportApps() { + viewModelScope.launch { + _state.update { it.copy(isExporting = true) } + try { + val json = appsRepository.exportApps() + val fileName = "github-store-apps-${System.currentTimeMillis()}.json" + shareManager.shareFile(fileName, json) + _events.send(AppsEvent.ExportReady(json)) + } catch (e: Exception) { + logger.error("Export failed: ${e.message}") + _events.send(AppsEvent.ShowError("Export failed: ${e.message}")) + } finally { + _state.update { it.copy(isExporting = false) } + } + } + } + + private fun importAppsFromFile() { + shareManager.pickFile("application/json") { content -> + if (content != null) { + viewModelScope.launch { + importApps(content) + } + } + } + } + + private suspend fun importApps(json: String) { + _state.update { it.copy(isImporting = true) } + try { + val result = appsRepository.importApps(json) + _events.send(AppsEvent.ImportComplete(result)) + _events.send( + AppsEvent.ShowSuccess( + "Imported ${result.imported} apps" + + if (result.skipped > 0) ", ${result.skipped} skipped" else "" + + if (result.failed > 0) ", ${result.failed} failed" else "", + ), + ) + } catch (e: Exception) { + logger.error("Import failed: ${e.message}") + _events.send(AppsEvent.ShowError("Import failed: ${e.message}")) + } finally { + _state.update { it.copy(isImporting = false) } + } + } + override fun onCleared() { super.onCleared() diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt new file mode 100644 index 00000000..9b84ed75 --- /dev/null +++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt @@ -0,0 +1,342 @@ +package zed.rainxch.apps.presentation.components + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.jetbrains.compose.resources.stringResource +import zed.rainxch.apps.presentation.AppsAction +import zed.rainxch.apps.presentation.AppsState +import zed.rainxch.apps.presentation.LinkStep +import zed.rainxch.core.domain.model.DeviceApp +import zed.rainxch.githubstore.core.presentation.res.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LinkAppBottomSheet( + state: AppsState, + onAction: (AppsAction) -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = { onAction(AppsAction.OnDismissLinkSheet) }, + sheetState = sheetState, + ) { + AnimatedContent( + targetState = state.linkStep, + transitionSpec = { + if (targetState == LinkStep.EnterUrl) { + (slideInHorizontally { it } + fadeIn()) togetherWith + (slideOutHorizontally { -it } + fadeOut()) + } else { + (slideInHorizontally { -it } + fadeIn()) togetherWith + (slideOutHorizontally { it } + fadeOut()) + } + }, + label = "link_step", + ) { step -> + when (step) { + LinkStep.PickApp -> PickAppStep( + deviceApps = state.filteredDeviceApps, + searchQuery = state.deviceAppSearchQuery, + onSearchChange = { onAction(AppsAction.OnDeviceAppSearchChange(it)) }, + onAppSelected = { onAction(AppsAction.OnDeviceAppSelected(it)) }, + ) + + LinkStep.EnterUrl -> EnterUrlStep( + selectedApp = state.selectedDeviceApp, + repoUrl = state.repoUrl, + isValidating = state.isValidatingRepo, + validationError = state.repoValidationError, + onUrlChanged = { onAction(AppsAction.OnRepoUrlChanged(it)) }, + onConfirm = { onAction(AppsAction.OnValidateAndLinkRepo) }, + onBack = { onAction(AppsAction.OnBackToAppPicker) }, + ) + } + } + } +} + +@Composable +private fun PickAppStep( + deviceApps: List, + searchQuery: String, + onSearchChange: (String) -> Unit, + onAppSelected: (DeviceApp) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + Text( + text = stringResource(Res.string.link_app_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + + Spacer(Modifier.height(4.dp)) + + Text( + text = stringResource(Res.string.pick_installed_app), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) + + TextField( + value = searchQuery, + onValueChange = onSearchChange, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)), + placeholder = { + Text(stringResource(Res.string.search_apps_hint)) + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null, + ) + }, + singleLine = true, + colors = TextFieldDefaults.colors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + ), + ) + + Spacer(Modifier.height(8.dp)) + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(400.dp), + ) { + items( + items = deviceApps, + key = { it.packageName }, + ) { app -> + DeviceAppItem( + app = app, + onClick = { onAppSelected(app) }, + ) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), + ) + } + + if (deviceApps.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(Res.string.no_apps_found), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun DeviceAppItem( + app: DeviceApp, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 12.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + ) { + Text( + text = app.appName, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = app.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(Modifier.width(8.dp)) + + app.versionName?.let { version -> + Text( + text = version, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + } +} + +@Composable +private fun EnterUrlStep( + selectedApp: DeviceApp?, + repoUrl: String, + isValidating: Boolean, + validationError: String?, + onUrlChanged: (String) -> Unit, + onConfirm: () -> Unit, + onBack: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + + Text( + text = stringResource(Res.string.link_app_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + + Spacer(Modifier.height(16.dp)) + + // Selected app info + if (selectedApp != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = selectedApp.appName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = selectedApp.packageName, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + selectedApp.versionName?.let { + Text( + text = it, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = repoUrl, + onValueChange = onUrlChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(Res.string.enter_repo_url)) }, + placeholder = { Text(stringResource(Res.string.repo_url_hint)) }, + singleLine = true, + isError = validationError != null, + supportingText = validationError?.let { + { Text(it, color = MaterialTheme.colorScheme.error) } + }, + shape = RoundedCornerShape(12.dp), + ) + + Spacer(Modifier.height(20.dp)) + + FilledTonalButton( + onClick = onConfirm, + modifier = Modifier.fillMaxWidth(), + enabled = repoUrl.isNotBlank() && !isValidating, + shape = RoundedCornerShape(12.dp), + ) { + if (isValidating) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(8.dp)) + Text(stringResource(Res.string.validating_repo)) + } else { + Text( + text = stringResource(Res.string.link_and_track), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + } + } + } +} 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 e60447b5..9594dd12 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 @@ -14,6 +14,9 @@ sealed interface DetailsAction { data object OnDismissDowngradeWarning : DetailsAction data object UninstallApp : DetailsAction + data object OnRequestUninstall : DetailsAction + data object OnDismissUninstallConfirmation : DetailsAction + data object OnConfirmUninstall : DetailsAction data class DownloadAsset( val downloadUrl: String, 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 f61c236f..e573949d 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 @@ -189,6 +189,47 @@ fun DetailsRoot( ) } + // Uninstall confirmation dialog + if (state.showUninstallConfirmation) { + val appName = state.installedApp?.appName ?: "" + AlertDialog( + onDismissRequest = { + viewModel.onAction(DetailsAction.OnDismissUninstallConfirmation) + }, + title = { + Text( + text = stringResource(Res.string.confirm_uninstall_title), + ) + }, + text = { + Text( + text = stringResource(Res.string.confirm_uninstall_message, appName), + ) + }, + confirmButton = { + TextButton( + onClick = { + viewModel.onAction(DetailsAction.OnConfirmUninstall) + }, + ) { + Text( + text = stringResource(Res.string.uninstall), + color = MaterialTheme.colorScheme.error, + ) + } + }, + dismissButton = { + TextButton( + onClick = { + viewModel.onAction(DetailsAction.OnDismissUninstallConfirmation) + }, + ) { + Text(text = stringResource(Res.string.cancel)) + } + }, + ) + } + if (state.showExternalInstallerPrompt) { AlertDialog( onDismissRequest = { 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 bd337ee6..e137302b 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 @@ -61,6 +61,7 @@ data class DetailsState( val downgradeWarning: DowngradeWarning? = null, val showExternalInstallerPrompt: Boolean = false, val pendingInstallFilePath: String? = null, + val showUninstallConfirmation: Boolean = false, ) { val filteredReleases: List get() = 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 b32c05a9..3ef82419 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 @@ -399,7 +399,34 @@ class DetailsViewModel( } } + DetailsAction.OnRequestUninstall -> { + _state.update { it.copy(showUninstallConfirmation = true) } + } + + DetailsAction.OnDismissUninstallConfirmation -> { + _state.update { it.copy(showUninstallConfirmation = false) } + } + + DetailsAction.OnConfirmUninstall -> { + _state.update { it.copy(showUninstallConfirmation = false) } + val installedApp = _state.value.installedApp ?: return + logger.debug("Uninstalling app (confirmed): ${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), + ), + ) + } + } + } + DetailsAction.UninstallApp -> { + // Legacy direct uninstall (used from downgrade warning flow) val installedApp = _state.value.installedApp ?: return logger.debug("Uninstalling app: ${installedApp.packageName}") viewModelScope.launch { 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 17dbd1ad..766e7473 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 @@ -104,7 +104,7 @@ fun SmartInstallButton( ) { // Uninstall button ElevatedCard( - onClick = { onAction(DetailsAction.UninstallApp) }, + onClick = { onAction(DetailsAction.OnRequestUninstall) }, modifier = Modifier .weight(1f) 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 5306e43a..e160afdd 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 @@ -71,6 +71,7 @@ sealed interface ProfileAction { data object OnRequestShizukuPermission : ProfileAction data class OnAutoUpdateToggled(val enabled: Boolean) : ProfileAction data class OnUpdateCheckIntervalChanged(val hours: Long) : ProfileAction + data class OnIncludePreReleasesToggled(val enabled: Boolean) : ProfileAction data class OnAutoDetectClipboardToggled( val enabled: Boolean, diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt index 647a184a..0c0d8afa 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt @@ -28,4 +28,5 @@ data class ProfileState( val shizukuAvailability: ShizukuAvailability = ShizukuAvailability.UNAVAILABLE, val autoUpdateEnabled: Boolean = false, val updateCheckIntervalHours: Long = 6L, + val includePreReleases: Boolean = false, ) 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 2d2e9d2e..75f452b4 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 @@ -51,6 +51,7 @@ class ProfileViewModel( observeShizukuStatus() loadAutoUpdatePreference() loadUpdateCheckInterval() + loadIncludePreReleases() hasLoadedInitialData = true } @@ -242,6 +243,16 @@ class ProfileViewModel( } } + private fun loadIncludePreReleases() { + viewModelScope.launch { + themesRepository.getIncludePreReleases().collect { enabled -> + _state.update { + it.copy(includePreReleases = enabled) + } + } + } + } + fun onAction(action: ProfileAction) { when (action) { ProfileAction.OnHelpClick -> { @@ -422,6 +433,12 @@ class ProfileViewModel( } } + is ProfileAction.OnIncludePreReleasesToggled -> { + viewModelScope.launch { + themesRepository.setIncludePreReleases(action.enabled) + } + } + ProfileAction.OnProxySave -> { val currentState = _state.value val port = diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Installation.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Installation.kt index f11cea95..5af80b49 100644 --- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Installation.kt +++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Installation.kt @@ -123,6 +123,15 @@ fun LazyListScope.updatesSection( onAction(ProfileAction.OnUpdateCheckIntervalChanged(hours)) } ) + + Spacer(Modifier.height(12.dp)) + + PreReleaseToggleCard( + enabled = state.includePreReleases, + onToggle = { enabled -> + onAction(ProfileAction.OnIncludePreReleasesToggled(enabled)) + } + ) } } @@ -449,6 +458,44 @@ private fun UpdateCheckIntervalCard( } } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun PreReleaseToggleCard( + enabled: Boolean, + onToggle: (Boolean) -> Unit, +) { + ExpressiveCard { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = stringResource(Res.string.include_pre_releases_title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(Res.string.include_pre_releases_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = enabled, + onCheckedChange = onToggle + ) + } + } +} + @Composable private fun HintText(text: String) { Text(