diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 9c3aeb7d..d89f08bf 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -4,6 +4,8 @@ + + @@ -11,6 +13,7 @@ + - - + 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..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,10 +1,13 @@ 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 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 @@ -20,7 +23,21 @@ 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() { @@ -38,4 +55,12 @@ class GithubStoreApp : Application() { packageEventReceiver = receiver } + + 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/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..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 ) } } @@ -248,8 +259,16 @@ fun AppNavigation( BottomNavigation( currentScreen = currentScreen, onNavigate = { - navController.navigate(it) + navController.navigate(it) { + popUpTo(GithubStoreGraph.HomeScreen) { + saveState = true + } + + launchSingleTop = true + 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/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/AndroidInstaller.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt index 55e686ed..a97e1ff5 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidInstaller.kt @@ -140,6 +140,20 @@ class AndroidInstaller( } } + override fun uninstall(packageName: String) { + Logger.d { "Requesting uninstall for: $packageName" } + val intent = Intent(Intent.ACTION_DELETE).apply { + data = "package:$packageName".toUri() + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(intent) + } catch (e: Exception) { + Logger.w { "Failed to start uninstall for $packageName: ${e.message}" } + } + + } + override fun isObtainiumInstalled(): Boolean { return try { context.packageManager.getPackageInfo("dev.imranr.obtainium.fdroid", 0) 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 new file mode 100644 index 00000000..131ccb70 --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt @@ -0,0 +1,132 @@ +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 +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. + * Shows a notification when updates are found. + */ +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() + + // Show notification if any updates are available + showUpdateNotificationIfNeeded() + + 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() + } + } + } + + @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 new file mode 100644 index 00000000..0184dfcb --- /dev/null +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateScheduler.kt @@ -0,0 +1,58 @@ +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 + +object UpdateScheduler { + + private const val DEFAULT_INTERVAL_HOURS = 6L + + fun schedule( + context: Context, + intervalHours: Long = DEFAULT_INTERVAL_HOURS, + replace: Boolean = false + ) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val request = PeriodicWorkRequestBuilder( + repeatInterval = intervalHours, + repeatIntervalTimeUnit = TimeUnit.HOURS + ) + .setConstraints(constraints) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + 30, TimeUnit.MINUTES + ) + .build() + + val policy = if (replace) { + ExistingPeriodicWorkPolicy.UPDATE + } else { + ExistingPeriodicWorkPolicy.KEEP + } + + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + uniqueWorkName = UpdateCheckWorker.WORK_NAME, + existingPeriodicWorkPolicy = policy, + request = request + ) + + Logger.i { "UpdateScheduler: Scheduled periodic update check every ${intervalHours}h (policy=$policy)" } + } + + 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..ceba4d1f 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 @@ -127,8 +127,10 @@ class InstalledAppsRepositoryImpl( val isUpdateAvailable = 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/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/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/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/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 71f968e3..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 @@ -382,4 +386,18 @@ %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 + + + 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 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/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..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 @@ -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 @@ -27,11 +28,14 @@ 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 +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 @@ -68,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 @@ -76,9 +81,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() @@ -126,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), @@ -160,7 +157,10 @@ fun AppsScreen( ) }, snackbarHost = { - SnackbarHost(snackbarHostState) + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.padding(bottomNavHeight + 16.dp) + ) }, modifier = Modifier.liquefiable(liquidState) ) { innerPadding -> @@ -246,25 +246,10 @@ fun AppsScreen( if (state.isUpdatingAll && state.updateAllProgress != null) { UpdateAllProgressCard( progress = state.updateAllProgress, - onCancel = { onAction(AppsAction.OnCancelUpdateAll) } - ) - } - - 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 - ) + onCancel = { + onAction(AppsAction.OnCancelUpdateAll) } - } + ) } when { @@ -277,12 +262,16 @@ fun AppsScreen( } } - filteredApps.isEmpty() -> { + state.filteredApps.isEmpty() -> { Box( 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 + ) } } @@ -293,7 +282,7 @@ fun AppsScreen( verticalArrangement = Arrangement.spacedBy(12.dp) ) { items( - items = filteredApps, + items = state.filteredApps, key = { it.installedApp.packageName } ) { appItem -> AppItemCard( @@ -301,10 +290,15 @@ 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) ) } + + item { + Spacer(Modifier.height(bottomNavHeight + 32.dp)) + } } } } @@ -374,27 +368,27 @@ fun AppItemCard( onOpenClick: () -> Unit, onUpdateClick: () -> Unit, onCancelClick: () -> Unit, + onUninstallClick: () -> Unit, onRepoClick: () -> Unit, modifier: Modifier = Modifier ) { val app = appItem.installedApp - Card( - modifier = modifier.fillMaxWidth() - ) { + ExpressiveCard (modifier = modifier) { Column( - modifier = Modifier.padding(16.dp) + modifier = Modifier + .clip(RoundedCornerShape(32.dp)) + .clickable { onRepoClick() } + .padding(16.dp) ) { 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( @@ -409,7 +403,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( @@ -426,6 +422,7 @@ fun AppItemCard( color = MaterialTheme.colorScheme.tertiary ) } + app.isUpdateAvailable -> { Text( text = "${app.installedVersion} → ${app.latestVersion}", @@ -433,6 +430,7 @@ fun AppItemCard( color = MaterialTheme.colorScheme.primary ) } + else -> { Text( text = app.installedVersion, @@ -449,7 +447,7 @@ fun AppItemCard( Text( text = app.repoDescription!!, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodyMediumEmphasized, maxLines = 2, overflow = TextOverflow.Ellipsis ) @@ -542,8 +540,7 @@ fun AppItemCard( ) } - UpdateState.Idle -> { - } + UpdateState.Idle -> {} } Spacer(Modifier.height(12.dp)) @@ -553,7 +550,24 @@ 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( + shapes = ButtonDefaults.shapes(), onClick = onOpenClick, modifier = Modifier.weight(1f), enabled = !app.isPendingInstall && @@ -633,6 +647,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/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 c3ceba56..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 @@ -96,6 +96,8 @@ class AppsViewModel( } ) } + + filterApps() } } catch (e: Exception) { logger.error("Failed to load apps: ${e.message}") @@ -157,7 +159,11 @@ class AppsViewModel( } is AppsAction.OnSearchChange -> { - _state.update { it.copy(searchQuery = action.query) } + _state.update { + it.copy(searchQuery = action.query) + } + + filterApps() } is AppsAction.OnOpenApp -> { @@ -193,6 +199,46 @@ class AppsViewModel( _events.send(AppsEvent.NavigateToRepo(action.repoId)) } } + + is AppsAction.OnUninstallApp -> { + uninstallApp(action.app) + } + } + } + + private fun filterApps() { + _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 { + 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) + ) + ) + } } } @@ -309,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) @@ -320,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}") @@ -517,6 +571,8 @@ class AppsViewModel( } ) } + + filterApps() } private fun updateAppProgress(packageName: String, progress: Int?) { @@ -531,6 +587,8 @@ class AppsViewModel( } ) } + + filterApps() } private suspend fun markPendingUpdate(app: InstalledApp) { 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..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, @@ -23,6 +24,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/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..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 @@ -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 @@ -57,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 @@ -65,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( @@ -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, @@ -338,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/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..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 @@ -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) { @@ -330,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 ) ) } @@ -352,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( @@ -655,6 +776,12 @@ class DetailsViewModel( } } + DetailsAction.TrackExistingApp -> { + val snapshot = _state.value + if (snapshot.isTrackingApp || !snapshot.isTrackable) return + trackExistingApp() + } + DetailsAction.OnNavigateBackClick -> { // Handled in composable } 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..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 @@ -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,92 @@ 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 + ) + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + 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) + .liquefiable(liquidState), + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.primary + ), + shape = RoundedCornerShape( + topStart = 6.dp, + bottomStart = 6.dp, + topEnd = 24.dp, + bottomEnd = 24.dp + ), + onClick = { + onAction(DetailsAction.OpenApp) + } ) { - 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.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 + ) + } } } } 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/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/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() 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..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 @@ -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, @@ -108,6 +109,10 @@ fun ProfileRoot( onNavigateToStarredRepos() } + is ProfileAction.OnRepositoriesClick -> { + onNavigateToDevProfile(action.username) + } + else -> { viewModel.onAction(action) } @@ -141,7 +146,7 @@ fun ProfileScreen( snackbarHost = { SnackbarHost( hostState = snackbarState, - modifier = Modifier.padding(bottom = bottomNavHeight) + modifier = Modifier.padding(bottom = bottomNavHeight + 16.dp) ) }, topBar = { @@ -188,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/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/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/AccountSection.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/AccountSection.kt index f2284035..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 @@ -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,22 @@ 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) + modifier = Modifier.weight(1f), + onClick = { + onAction(ProfileAction.OnRepositoriesClick(state.userProfile.username)) + } ) 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 +158,7 @@ fun LazyListScope.accountSection( Spacer(Modifier.height(8.dp)) GithubStoreButton( - text = "Login", + text = stringResource(Res.string.profile_login), onClick = { onAction(ProfileAction.OnLoginClick) }, @@ -172,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, @@ -184,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/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 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..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 @@ -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(), @@ -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,13 +123,13 @@ private fun ProxyTypeCard( fontWeight = FontWeight.SemiBold ) - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(8.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) ) } } @@ -174,13 +173,12 @@ private fun ProxyDetailsCard( colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer ), - shape = RoundedCornerShape(20.dp) + shape = RoundedCornerShape(32.dp) ) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { - // Host + Port row Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.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 da290808..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 @@ -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) } @@ -78,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 @@ -86,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, 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..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 @@ -23,11 +23,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 +47,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 @@ -58,17 +58,22 @@ 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 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 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 @@ -118,7 +123,7 @@ fun SearchRoot( ) } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun SearchScreen( state: SearchState, @@ -204,14 +209,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 +234,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 +287,7 @@ fun SearchScreen( } } - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(6.dp)) if (state.totalCount != null) { Text( @@ -296,7 +299,7 @@ fun SearchScreen( color = MaterialTheme.colorScheme.outline, modifier = Modifier .fillMaxWidth() - .padding(bottom = 8.dp) + .padding(bottom = 6.dp) ) } @@ -322,11 +325,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 +385,6 @@ fun SearchScreen( } } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun SearchTopbar( onAction: (SearchAction) -> Unit, @@ -396,16 +399,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 +411,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..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 = paginatedRepos.totalCount ?: currentState.totalCount, + totalCount = allRepos.size, errorMessage = if (allRepos.isEmpty() && !paginatedRepos.hasMore) { getString(Res.string.no_repositories_found) } else null @@ -359,6 +359,20 @@ class SearchViewModel( performSearch(isInitial = true) } + SearchAction.OnClearClick -> { + _state.update { + it.copy( + query = "", + repositories = emptyList(), + isLoading = false, + isLoadingMore = false, + errorMessage = null, + totalCount = null + + ) + } + } + is SearchAction.OnRepositoryClick -> { /* Handled in composable */ } 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" }