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
Aç
- 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" }