diff --git a/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt b/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt new file mode 100644 index 00000000..a780b10d --- /dev/null +++ b/src/main/kotlin/app/morphe/engine/MultiSourceLoader.kt @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.engine + +import app.morphe.patcher.patch.Patch +import app.morphe.patcher.patch.loadPatchesFromJar +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import java.io.File +import java.util.logging.Logger + +/** + * Loads patches from one or more `.mpp` files in parallel and tags each loaded + * Patch instance with the source it came from. The union [Result.allPatches] + * is what [PatchEngine.patch] expects; the per-source breakdown is for + * GUI/CLI consumers that want to badge or filter by origin. + * + * Lives in the engine package so the CLI can consume the same multi-source + * loading path when it adopts multi-source. GUI-specific aggregation (display + * name resolution, icon color picking) lives in the GUI layer. + */ +object MultiSourceLoader { + + private val logger = Logger.getLogger(this::class.java.name) + + data class SourceInput( + val sourceId: String, + val sourceName: String, + val patchFile: File, + ) + + data class LoadedSource( + val sourceId: String, + val sourceName: String, + val patches: Set>, + val error: Throwable? = null, + ) { + val isSuccess: Boolean get() = error == null + } + + data class Result( + val perSource: List, + val allPatches: Set>, + val patchToSourceId: Map, String>, + ) { + val hasErrors: Boolean get() = perSource.any { !it.isSuccess } + } + + /** + * Load patches from each input in parallel. Each .mpp is copied to a temp file + * before loading to work around Windows URLClassLoader file-locking (mirrors + * the same workaround in PatchService.kt). + */ + suspend fun load(inputs: List): Result = coroutineScope { + val loaded = inputs.map { input -> + async(Dispatchers.IO) { loadOne(input) } + }.awaitAll() + + val allPatches = loaded.flatMap { it.patches }.toSet() + val patchToSourceId = loaded.flatMap { src -> + src.patches.map { it to src.sourceId } + }.toMap() + + Result(perSource = loaded, allPatches = allPatches, patchToSourceId = patchToSourceId) + } + + private suspend fun loadOne(input: SourceInput): LoadedSource = withContext(Dispatchers.IO) { + val tempCopy = File.createTempFile("morphe-mp-${input.sourceId}-", ".mpp") + try { + input.patchFile.copyTo(tempCopy, overwrite = true) + val patches = loadPatchesFromJar(setOf(tempCopy)) + logger.info("MultiSourceLoader: loaded ${patches.size} patches from '${input.sourceName}'") + LoadedSource( + sourceId = input.sourceId, + sourceName = input.sourceName, + patches = patches, + ) + } catch (e: Exception) { + logger.warning("MultiSourceLoader: failed to load '${input.sourceName}': ${e.message}") + LoadedSource( + sourceId = input.sourceId, + sourceName = input.sourceName, + patches = emptySet(), + error = e, + ) + } finally { + tempCopy.deleteOnExit() + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt index 001ccf85..52d7d429 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt @@ -27,7 +27,20 @@ val DEFAULT_PATCH_SOURCE = PatchSource( data class AppConfig( val themePreference: String = ThemePreference.SYSTEM.name, val lastCliVersion: String? = null, + /** + * LEGACY single-source version pin. Kept only for one-version migration into + * [lastPatchesVersionBySource] — read it on first load if the map is empty, + * then phase out. Do not read this directly anywhere new — go through + * [ConfigRepository.getLastPatchesVersionsBySource]. + */ val lastPatchesVersion: String? = null, + /** + * Per-source version pin: sourceId → release tag. Absence of a key means + * "no pin — use that source's latest stable". Replaces the legacy single + * [lastPatchesVersion] which silently contaminated other sources whose tag + * names happened to overlap. + */ + val lastPatchesVersionBySource: Map = emptyMap(), val preferredPatchChannel: String = PatchChannel.STABLE.name, val defaultOutputDirectory: String? = null, val autoCleanupTempFiles: Boolean = true, // Default ON @@ -57,6 +70,10 @@ data class AppConfig( // user who swaps from a stable build to a dev build sees the right default. // Once they pick one in Settings, this flips to true and we respect their choice. val userDidChooseUpdateChannel: Boolean = false, + // One-shot dismissal flag for the "multiple sources are now active" hint shown + // after upgrading to multi-source builds. Flips to true once the user dismisses + // the banner, never resets. + val multiSourceHintDismissed: Boolean = false, ) { fun getUpdateChannelPreference(): UpdateChannelPreference? { @@ -91,7 +108,10 @@ data class PatchSource ( val type: PatchSourceType, val url: String? = null, // For DEFAULT (morphe) and GITHUB (other source) type val filePath: String? = null, // For local files - val deletable: Boolean = true + val deletable: Boolean = true, + // Multi-source enablement. Default true so old configs migrate to "all enabled" + // on first load (per user choice — see project memory). + val enabled: Boolean = true, ) @Serializable diff --git a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt index 87e83811..e2769a68 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt @@ -84,7 +84,8 @@ enum class PatchOptionType { data class PatchConfig( val inputApkPath: String, val outputApkPath: String, - val patchesFilePath: String, + /** One or more .mpp file paths. Multiple = union of patches across sources. */ + val patchesFilePaths: List, val enabledPatches: List = emptyList(), val disabledPatches: List = emptyList(), val patchOptions: Map = emptyMap(), diff --git a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt index 5133a440..4ed372a6 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt @@ -97,13 +97,43 @@ class ConfigRepository { } /** - * Update last used patches version. + * LEGACY — kept so single-source callers don't break during the multi-source + * transition. New code should use [setLastPatchesVersionForSource]. */ + @Deprecated("Use setLastPatchesVersionForSource", ReplaceWith("setLastPatchesVersionForSource(sourceId, version)")) suspend fun setLastPatchesVersion(version: String) { val current = loadConfig() saveConfig(current.copy(lastPatchesVersion = version)) } + /** + * Pin a specific release tag for [sourceId]. Used by PatchesScreen when the + * user picks a version. Per-source = no cross-contamination across sources + * with overlapping tag names. + */ + suspend fun setLastPatchesVersionForSource(sourceId: String, version: String) { + val current = loadConfig() + val updated = current.lastPatchesVersionBySource + (sourceId to version) + saveConfig(current.copy(lastPatchesVersionBySource = updated)) + } + + /** + * Returns the per-source version pin map, with one-time migration from the + * legacy [AppConfig.lastPatchesVersion] field: if the map is empty and the + * legacy field is set, it's mapped to the default source. + */ + suspend fun getLastPatchesVersionsBySource(): Map { + val current = loadConfig() + if (current.lastPatchesVersionBySource.isNotEmpty()) { + return current.lastPatchesVersionBySource + } + val legacy = current.lastPatchesVersion ?: return emptyMap() + // Migrate: write the legacy pin onto the default source, return the new map. + val migrated = mapOf(DEFAULT_PATCH_SOURCE.id to legacy) + saveConfig(current.copy(lastPatchesVersionBySource = migrated)) + return migrated + } + /** * Mark the given CLI version as dismissed for the update banner. Pass null to * clear (so the banner reappears for whatever the next-available version is). @@ -261,6 +291,44 @@ class ConfigRepository { saveConfig(current.copy(patchSource = updatedSources)) } + /** + * Mark the multi-source upgrade hint as dismissed. One-shot — never resets. + */ + suspend fun setMultiSourceHintDismissed() { + val current = loadConfig() + if (current.multiSourceHintDismissed) return + saveConfig(current.copy(multiSourceHintDismissed = true)) + } + + /** + * Toggle enablement of a patch source. Safety net: if disabling would leave zero + * enabled sources, the default source is force-enabled (mirrors morphe-manager + * SourceManagementSheet.kt:142-149 LaunchedEffect). + */ + suspend fun setPatchSourceEnabled(id: String, enabled: Boolean) { + val current = loadConfig() + val updatedSources = current.patchSource.map { + if (it.id == id) it.copy(enabled = enabled) else it + } + val anyEnabled = updatedSources.any { it.enabled } + val finalSources = if (!anyEnabled) { + // Safety net: force-enable the default + updatedSources.map { + if (it.id == DEFAULT_PATCH_SOURCE.id) it.copy(enabled = true) else it + } + } else { + updatedSources + } + saveConfig(current.copy(patchSource = finalSources)) + } + + /** + * Get the list of currently enabled patch sources (in config order). + */ + suspend fun getEnabledPatchSources(): List { + return loadConfig().patchSource.filter { it.enabled } + } + /** * Remove a patch source by ID. Cannot remove non-deletable sources. */ diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt index 0a540b03..c933f0e2 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt @@ -28,10 +28,23 @@ class PatchSourceManager( private var cachedActiveRepo: PatchRepository? = null private var cachedActiveSource: PatchSource? = null - // Incremented on every source switch so Compose can key on it + // Snapshot of currently-enabled sources for sync access. Updated on initialize() + // and whenever setSourceEnabled / addSource / removeSource fires. + private var cachedEnabledSources: List = emptyList() + + // Incremented on every source switch / enable change so Compose can key on it private val _sourceVersion = MutableStateFlow(0) val sourceVersion: StateFlow = _sourceVersion.asStateFlow() + // Observable list of enabled sources for UI + private val _enabledSources = MutableStateFlow>(emptyList()) + val enabledSources: StateFlow> = _enabledSources.asStateFlow() + + // Observable list of ALL sources (enabled + disabled) — drives the + // SourceManagementSheet which needs to render every source with a toggle. + private val _allSources = MutableStateFlow>(emptyList()) + val allSources: StateFlow> = _allSources.asStateFlow() + /** * Load the active source from config and cache its PatchRepository. * Call once at app startup (from a LaunchedEffect). @@ -40,7 +53,9 @@ class PatchSourceManager( val source = configRepository.getActivePatchSource() cachedActiveSource = source cachedActiveRepo = getRepositoryForSource(source) + refreshEnabledSources() Logger.info("PatchSourceManager initialized with source '${source.name}' (type=${source.type})") + Logger.info("Enabled sources: ${cachedEnabledSources.joinToString { it.name }}") } /** @@ -153,4 +168,69 @@ class PatchSourceManager( cachedActiveRepo?.clearCache() _sourceVersion.value++ } + + // ── Multi-source API ────────────────────────────────────────────────────── + + /** + * Snapshot of currently-enabled sources, in config order. Synchronous. + */ + fun getEnabledSourcesSync(): List = cachedEnabledSources + + /** + * Pair each enabled source with its [PatchRepository]. The repo is null for LOCAL + * sources — callers should use [PatchSource.filePath] directly in that case. + */ + fun getEnabledRepositories(): List> = + cachedEnabledSources.map { it to getRepositoryForSource(it) } + + /** + * Toggle enablement of a source. Persists, refreshes the cached snapshot, and + * bumps [sourceVersion] so consumers reload. Default-source safety net is + * applied at the [ConfigRepository] layer. + */ + suspend fun setSourceEnabled(id: String, enabled: Boolean) { + configRepository.setPatchSourceEnabled(id, enabled) + refreshEnabledSources() + _sourceVersion.value++ + Logger.info("Source '$id' enabled=$enabled. Enabled now: ${cachedEnabledSources.joinToString { it.name }}") + } + + /** + * Add a new source. Persists and refreshes the cached snapshot. + */ + suspend fun addSource(source: PatchSource) { + configRepository.addPatchSource(source) + refreshEnabledSources() + _sourceVersion.value++ + } + + /** + * Remove a source by id. Refuses non-deletable (default) sources. Drops the + * cached repo for that id so a re-add doesn't reuse stale state. + */ + suspend fun removeSource(id: String) { + configRepository.removePatchSource(id) + repositories.remove(id) + refreshEnabledSources() + _sourceVersion.value++ + } + + /** + * Update an existing source (e.g. rename). Refuses non-deletable sources. + */ + suspend fun updateSource(updated: PatchSource) { + configRepository.updatePatchSource(updated) + // Drop the cached repo so the new url/name is picked up on next access. + repositories.remove(updated.id) + refreshEnabledSources() + _sourceVersion.value++ + } + + private suspend fun refreshEnabledSources() { + val all = configRepository.loadConfig().patchSource + val enabled = all.filter { it.enabled } + cachedEnabledSources = enabled + _enabledSources.value = enabled + _allSources.value = all + } } diff --git a/src/main/kotlin/app/morphe/gui/di/AppModule.kt b/src/main/kotlin/app/morphe/gui/di/AppModule.kt index ce47921d..9c2a68f5 100644 --- a/src/main/kotlin/app/morphe/gui/di/AppModule.kt +++ b/src/main/kotlin/app/morphe/gui/di/AppModule.kt @@ -93,7 +93,9 @@ val appModule = module { get(), get(), psm.getActiveSourceName(), - psm.getLocalFilePath() + psm.getLocalFilePath(), + params.get(), + params.get(), ) } factory { params -> diff --git a/src/main/kotlin/app/morphe/gui/ui/components/PatchSourceDialogs.kt b/src/main/kotlin/app/morphe/gui/ui/components/PatchSourceDialogs.kt new file mode 100644 index 00000000..42e3db6d --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/PatchSourceDialogs.kt @@ -0,0 +1,472 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.model.PatchSource +import app.morphe.gui.data.model.PatchSourceType +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheDimens +import app.morphe.gui.ui.theme.LocalMorpheFont +import java.awt.FileDialog +import java.awt.Frame +import java.io.File +import java.util.UUID + +@Composable +internal fun AddPatchSourceDialog( + onDismiss: () -> Unit, + onAdd: (PatchSource) -> Unit +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + var name by remember { mutableStateOf("") } + var sourceType by remember { mutableStateOf(PatchSourceType.GITHUB) } + var url by remember { mutableStateOf("") } + var filePath by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + "ADD SOURCE", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 1.sp + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.widthIn(min = 300.dp) + ) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + listOf(PatchSourceType.GITHUB, PatchSourceType.LOCAL).forEach { type -> + val isSelected = sourceType == type + Box( + modifier = Modifier + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + if (isSelected) accents.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), + RoundedCornerShape(corners.small) + ) + .background( + if (isSelected) accents.primary.copy(alpha = 0.08f) + else Color.Transparent + ) + .clickable { sourceType = type } + .padding(horizontal = 14.dp, vertical = 7.dp) + ) { + Text( + text = when (type) { + PatchSourceType.GITHUB -> "GITHUB" + PatchSourceType.LOCAL -> "LOCAL FILE" + else -> "" + }, + fontSize = 10.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = if (isSelected) accents.primary + else MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + LabeledField(label = "NAME", mono = mono) { + SlimTextField( + value = name, + onValueChange = { name = it; error = null }, + placeholder = "My Custom Patches", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.fillMaxWidth(), + ) + } + + when (sourceType) { + PatchSourceType.GITHUB -> { + LabeledField(label = "REPOSITORY URL", mono = mono) { + SlimTextField( + value = url, + onValueChange = { url = it; error = null }, + placeholder = "github.com/owner/repo", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.fillMaxWidth(), + ) + Text( + "Accepts GitHub URL or morphe.software/add-source link", + fontFamily = mono, + fontSize = 9.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + letterSpacing = 0.3.sp, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + PatchSourceType.LOCAL -> { + LabeledField(label = ".MPP FILE", mono = mono) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SlimTextField( + value = filePath, + onValueChange = { filePath = it; error = null }, + placeholder = "Path to .mpp", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.weight(1f), + readOnly = true, + ) + DialogActionButton( + label = "BROWSE", + mono = mono, + corners = corners, + onClick = { + val dialog = FileDialog(null as Frame?, "Select .mpp file", FileDialog.LOAD).apply { + setFilenameFilter { _, n -> n.endsWith(".mpp", ignoreCase = true) } + isVisible = true + } + if (dialog.directory != null && dialog.file != null) { + filePath = File(dialog.directory, dialog.file).absolutePath + if (name.isBlank()) name = dialog.file.removeSuffix(".mpp") + error = null + } + }, + ) + } + } + } + else -> {} + } + + error?.let { + Text( + text = it, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.error + ) + } + } + }, + confirmButton = { + val dimens = LocalMorpheDimens.current + Button( + onClick = { + if (name.isBlank()) { error = "Name is required"; return@Button } + when (sourceType) { + PatchSourceType.GITHUB -> { + val resolvedUrl = resolveGitHubUrl(url.trim()) + if (resolvedUrl == null) { + error = "Enter a valid GitHub URL or Morphe source link"; return@Button + } + onAdd(PatchSource( + id = UUID.randomUUID().toString(), + name = name.trim(), + type = sourceType, + url = resolvedUrl, + deletable = true + )) + return@Button + } + PatchSourceType.LOCAL -> { + if (filePath.isBlank() || !File(filePath).exists()) { + error = "Select a valid .mpp file"; return@Button + } + } + else -> {} + } + onAdd(PatchSource( + id = UUID.randomUUID().toString(), + name = name.trim(), + type = sourceType, + url = null, + filePath = if (sourceType == PatchSourceType.LOCAL) filePath.trim() else null, + deletable = true + )) + }, + colors = ButtonDefaults.buttonColors(containerColor = accents.primary), + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + "ADD", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + }, + dismissButton = { + val dimens = LocalMorpheDimens.current + TextButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + "CANCEL", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + } + ) +} + +@Composable +internal fun EditPatchSourceDialog( + source: PatchSource, + onDismiss: () -> Unit, + onSave: (PatchSource) -> Unit +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + var name by remember { mutableStateOf(source.name) } + var url by remember { mutableStateOf(source.url ?: "") } + var filePath by remember { mutableStateOf(source.filePath ?: "") } + var error by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + "EDIT SOURCE", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 1.sp + ) + }, + text = { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.widthIn(min = 300.dp) + ) { + Text( + text = when (source.type) { + PatchSourceType.GITHUB -> "GITHUB REPOSITORY" + PatchSourceType.LOCAL -> "LOCAL FILE" + else -> "" + }, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 1.sp + ) + + LabeledField(label = "NAME", mono = mono) { + SlimTextField( + value = name, + onValueChange = { name = it; error = null }, + placeholder = "", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.fillMaxWidth(), + ) + } + + when (source.type) { + PatchSourceType.GITHUB -> { + LabeledField(label = "REPOSITORY URL", mono = mono) { + SlimTextField( + value = url, + onValueChange = { url = it; error = null }, + placeholder = "github.com/owner/repo", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.fillMaxWidth(), + ) + } + } + PatchSourceType.LOCAL -> { + LabeledField(label = ".MPP FILE", mono = mono) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + SlimTextField( + value = filePath, + onValueChange = { filePath = it; error = null }, + placeholder = "Path to .mpp", + mono = mono, + accents = accents, + corners = corners, + modifier = Modifier.weight(1f), + readOnly = true, + ) + DialogActionButton( + label = "BROWSE", + mono = mono, + corners = corners, + onClick = { + val dialog = FileDialog(null as Frame?, "Select .mpp file", FileDialog.LOAD).apply { + setFilenameFilter { _, n -> n.endsWith(".mpp", ignoreCase = true) } + isVisible = true + } + if (dialog.directory != null && dialog.file != null) { + filePath = File(dialog.directory, dialog.file).absolutePath + error = null + } + }, + ) + } + } + } + else -> {} + } + + error?.let { + Text(text = it, fontSize = 11.sp, fontFamily = mono, color = MaterialTheme.colorScheme.error) + } + } + }, + confirmButton = { + val dimens = LocalMorpheDimens.current + Button( + onClick = { + if (name.isBlank()) { error = "Name is required"; return@Button } + when (source.type) { + PatchSourceType.GITHUB -> { + val resolvedUrl = resolveGitHubUrl(url.trim()) + if (resolvedUrl == null) { + error = "Enter a valid GitHub URL or Morphe source link"; return@Button + } + onSave(source.copy( + name = name.trim(), + url = resolvedUrl + )) + return@Button + } + PatchSourceType.LOCAL -> { + if (filePath.isBlank() || !File(filePath).exists()) { + error = "Select a valid .mpp file"; return@Button + } + } + else -> {} + } + onSave(source.copy( + name = name.trim(), + filePath = if (source.type == PatchSourceType.LOCAL) filePath.trim() else source.filePath + )) + }, + colors = ButtonDefaults.buttonColors(containerColor = accents.primary), + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + "SAVE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + }, + dismissButton = { + val dimens = LocalMorpheDimens.current + TextButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + "CANCEL", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + } + ) +} + +/** + * Resolves a URL to a GitHub repository URL. + * Accepts: full https://github.com/owner/repo URL, morphe.software/add-source?github=owner/repo + * link, or short form owner/repo. Returns the normalized URL or null. + */ +internal fun resolveGitHubUrl(input: String): String? { + val trimmed = input.trim() + if (trimmed.isBlank()) return null + + if (trimmed.contains("morphe.software/add-source")) { + val match = Regex("[?&]github=([^&]+)").find(trimmed) + val repoPath = match?.groupValues?.get(1) ?: return null + val clean = repoPath.trimEnd('/') + return if (clean.contains('/') && clean.split('/').size == 2) { + "https://github.com/$clean" + } else null + } + + if (trimmed.contains("github.com/")) { + val match = Regex("github\\.com/([^/]+/[^/]+)").find(trimmed) + return if (match != null) { + "https://github.com/${match.groupValues[1].trimEnd('/')}" + } else null + } + + if (trimmed.matches(Regex("[\\w.-]+/[\\w.-]+"))) { + return "https://github.com/$trimmed" + } + + return null +} + +// LabeledField, SlimTextField, DialogActionButton moved to SlimInputs.kt for +// reuse across the codebase (SettingsDialog uses them too). diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt index 042515b9..cb5bc389 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -68,8 +68,6 @@ fun SettingsButton( var showSettingsDialog by remember { mutableStateOf(false) } var autoCleanupTempFiles by remember { mutableStateOf(true) } var defaultOutputDirectory by remember { mutableStateOf(null) } - var patchSources by remember { mutableStateOf>(emptyList()) } - var activePatchSourceId by remember { mutableStateOf("") } var keystorePath by remember { mutableStateOf(null) } var keystorePassword by remember { mutableStateOf(null) } var keystoreAlias by remember { mutableStateOf(DEFAULT_KEYSTORE_ALIAS) } @@ -83,8 +81,6 @@ fun SettingsButton( val config = configRepository.loadConfig() autoCleanupTempFiles = config.autoCleanupTempFiles defaultOutputDirectory = config.defaultOutputDirectory - patchSources = config.patchSource - activePatchSourceId = config.activePatchSourceId keystorePath = config.keystorePath keystorePassword = config.keystorePassword keystoreAlias = config.keystoreAlias @@ -151,43 +147,6 @@ fun SettingsButton( }, allowCacheClear = allowCacheClear, isPatching = isPatching, - patchSources = patchSources, - activePatchSourceId = activePatchSourceId, - onActivePatchSourceChange = { id -> - if (id != activePatchSourceId) { - activePatchSourceId = id - scope.launch { - withContext(NonCancellable) { - patchSourceManager.switchSource(id) - } - } - } - }, - onAddPatchSource = { source -> - patchSources = patchSources + source - scope.launch { - configRepository.addPatchSource(source) - } - }, - onEditPatchSource = { updated -> - patchSources = patchSources.map { if (it.id == updated.id) updated else it } - scope.launch { - configRepository.updatePatchSource(updated) - if (updated.id == activePatchSourceId) { - patchSourceManager.clearAll() - patchSourceManager.switchSource(updated.id) - } - } - }, - onRemovePatchSource = { id -> - patchSources = patchSources.filter { it.id != id } - if (activePatchSourceId == id) { - activePatchSourceId = "morphe-default" - } - scope.launch { - configRepository.removePatchSource(id) - } - }, onCacheCleared = { patchSourceManager.notifyCacheCleared() }, diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt index de463055..bff11db4 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -36,6 +36,7 @@ import app.morphe.gui.data.constants.AppConstants import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.model.PatchSourceType import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheDimens import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.MorpheColors @@ -94,12 +95,6 @@ fun SettingsDialog( onDismiss: () -> Unit, allowCacheClear: Boolean = true, isPatching: Boolean = false, - patchSources: List = emptyList(), - activePatchSourceId: String = "", - onActivePatchSourceChange: (String) -> Unit = {}, - onAddPatchSource: (PatchSource) -> Unit = {}, - onEditPatchSource: (PatchSource) -> Unit = {}, - onRemovePatchSource: (String) -> Unit = {}, onCacheCleared: () -> Unit = {}, keystorePath: String? = null, keystorePassword: String? = null, @@ -123,8 +118,6 @@ fun SettingsDialog( var showLicensesDialog by remember { mutableStateOf(false) } var cacheCleared by remember { mutableStateOf(false) } var cacheClearFailed by remember { mutableStateOf(false) } - var showAddSourceDialog by remember { mutableStateOf(false) } - var editingSource by remember { mutableStateOf(null) } AlertDialog( onDismissRequest = onDismiss, @@ -295,27 +288,6 @@ fun SettingsDialog( SettingsDivider(borderColor) - // ── Patch Sources ── - PatchSourcesSection( - sources = patchSources, - activeSourceId = activePatchSourceId, - onActiveChange = { id -> - onActivePatchSourceChange(id) - onDismiss() - }, - onRemove = onRemovePatchSource, - onEdit = { source -> editingSource = source }, - onAddClick = { showAddSourceDialog = true }, - mono = mono, - accentColor = accents.primary, - borderColor = borderColor, - enabled = !isPatching, - expanded = collapsibleSectionStates["PATCH SOURCES"] == true, - onExpandedChange = { onCollapsibleSectionToggle("PATCH SOURCES", it) } - ) - - SettingsDivider(borderColor) - // ── Actions ── SectionLabel("ACTIONS", mono) Spacer(Modifier.height(8.dp)) @@ -490,30 +462,9 @@ fun SettingsDialog( ) } - if (showAddSourceDialog) { - AddPatchSourceDialog( - onDismiss = { showAddSourceDialog = false }, - onAdd = { source -> - onAddPatchSource(source) - showAddSourceDialog = false - } - ) - } - if (showLicensesDialog) { LicensesDialog(onDismiss = { showLicensesDialog = false }) } - - editingSource?.let { source -> - EditPatchSourceDialog( - source = source, - onDismiss = { editingSource = null }, - onSave = { updated -> - onEditPatchSource(updated) - editingSource = null - } - ) - } } @Composable @@ -1507,6 +1458,7 @@ private fun OutputFolderSection( enabled: Boolean = true ) { val corners = LocalMorpheCorners.current + val dimens = LocalMorpheDimens.current val alpha = if (enabled) 1f else 0.4f val outputDir = defaultOutputDirectory?.let { File(it) } val outputDirExists = outputDir?.isDirectory == true @@ -1526,7 +1478,7 @@ private fun OutputFolderSection( Spacer(Modifier.height(8.dp)) Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + modifier = Modifier.fillMaxWidth().height(dimens.controlHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { @@ -1665,545 +1617,6 @@ private fun ActionButton( } } -// ── Patch Sources Section ── - -@Composable -private fun PatchSourcesSection( - sources: List, - activeSourceId: String, - onActiveChange: (String) -> Unit, - onRemove: (String) -> Unit, - onEdit: (PatchSource) -> Unit, - onAddClick: () -> Unit, - mono: androidx.compose.ui.text.font.FontFamily, - accentColor: Color, - borderColor: Color, - enabled: Boolean = true, - expanded: Boolean = false, - onExpandedChange: (Boolean) -> Unit = {} -) { - val corners = LocalMorpheCorners.current - val alpha = if (enabled) 1f else 0.4f - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - CollapsibleSection( - title = "PATCH SOURCES", - mono = mono, - expanded = expanded, - onExpandedChange = onExpandedChange - ) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text( - text = if (!enabled) "Disabled while patching" else "Select where patches are loaded from", - fontSize = 11.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - - Spacer(modifier = Modifier.height(8.dp)) - - sources.forEach { source -> - val isActive = source.id == activeSourceId - val hoverInteraction = remember(source.id) { MutableInteractionSource() } - val isHovered by hoverInteraction.collectIsHoveredAsState() - - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(corners.medium)) - .border( - 1.dp, - when { - isActive -> accentColor.copy(alpha = 0.4f) - isHovered -> MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - else -> borderColor - }, - RoundedCornerShape(corners.medium) - ) - .background( - if (isActive) accentColor.copy(alpha = 0.08f) - else Color.Transparent - ) - .hoverable(hoverInteraction) - .then(if (enabled) Modifier.clickable { onActiveChange(source.id) } else Modifier) - .padding(horizontal = 12.dp, vertical = 10.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - // Active indicator dot - Box( - modifier = Modifier - .size(6.dp) - .background( - if (isActive) accentColor - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f), - RoundedCornerShape(1.dp) - ) - ) - Spacer(Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = source.name, - fontSize = 12.sp, - fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = when (source.type) { - PatchSourceType.DEFAULT -> "Default" - PatchSourceType.GITHUB -> source.url?.removePrefix("https://github.com/") ?: "GitHub" - PatchSourceType.LOCAL -> source.filePath?.let { File(it).name } ?: "Local file" - }, - fontSize = 10.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - if (source.deletable && enabled) { - IconButton( - onClick = { onEdit(source) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Edit, - contentDescription = "Edit", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - modifier = Modifier.size(14.dp) - ) - } - Spacer(Modifier.width(2.dp)) - IconButton( - onClick = { onRemove(source.id) }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Remove", - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), - modifier = Modifier.size(14.dp) - ) - } - } - } - } - Spacer(modifier = Modifier.height(4.dp)) - } - - // Add source - OutlinedButton( - onClick = onAddClick, - enabled = enabled, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small), - border = BorderStroke(1.dp, borderColor), - contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - modifier = Modifier.size(14.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - "ADD SOURCE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 10.sp, - letterSpacing = 0.5.sp - ) - } - } // inner Column - } // CollapsibleSection - } -} - -// ── Add / Edit Source Dialogs ── - -@Composable -private fun AddPatchSourceDialog( - onDismiss: () -> Unit, - onAdd: (PatchSource) -> Unit -) { - val corners = LocalMorpheCorners.current - val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - var name by remember { mutableStateOf("") } - var sourceType by remember { mutableStateOf(PatchSourceType.GITHUB) } - var url by remember { mutableStateOf("") } - var filePath by remember { mutableStateOf("") } - var error by remember { mutableStateOf(null) } - - AlertDialog( - onDismissRequest = onDismiss, - shape = RoundedCornerShape(corners.medium), - containerColor = MaterialTheme.colorScheme.surface, - title = { - Text( - "ADD SOURCE", - fontFamily = mono, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - letterSpacing = 1.sp - ) - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.widthIn(min = 300.dp) - ) { - // Type toggle - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - listOf(PatchSourceType.GITHUB, PatchSourceType.LOCAL).forEach { type -> - val isSelected = sourceType == type - Box( - modifier = Modifier - .clip(RoundedCornerShape(corners.small)) - .border( - 1.dp, - if (isSelected) accents.primary.copy(alpha = 0.5f) - else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), - RoundedCornerShape(corners.small) - ) - .background( - if (isSelected) accents.primary.copy(alpha = 0.08f) - else Color.Transparent - ) - .clickable { sourceType = type } - .padding(horizontal = 14.dp, vertical = 7.dp) - ) { - Text( - text = when (type) { - PatchSourceType.GITHUB -> "GITHUB" - PatchSourceType.LOCAL -> "LOCAL FILE" - else -> "" - }, - fontSize = 10.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, - fontFamily = mono, - letterSpacing = 0.5.sp, - color = if (isSelected) accents.primary - else MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - - OutlinedTextField( - value = name, - onValueChange = { name = it; error = null }, - label = { Text("Name", fontFamily = mono, fontSize = 11.sp) }, - placeholder = { Text("My Custom Patches", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - - when (sourceType) { - PatchSourceType.GITHUB -> { - OutlinedTextField( - value = url, - onValueChange = { url = it; error = null }, - label = { Text("Repository URL", fontFamily = mono, fontSize = 11.sp) }, - placeholder = { Text("github.com/owner/repo", fontFamily = mono, fontSize = 10.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - Text( - "Accepts GitHub URL or morphe.software/add-source link", - fontFamily = mono, - fontSize = 9.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - letterSpacing = 0.3.sp - ) - } - PatchSourceType.LOCAL -> { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedTextField( - value = filePath, - onValueChange = { filePath = it; error = null }, - label = { Text(".mpp file", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(corners.small), - readOnly = true - ) - OutlinedButton( - onClick = { - val dialog = FileDialog(null as Frame?, "Select .mpp file", FileDialog.LOAD).apply { - setFilenameFilter { _, n -> n.endsWith(".mpp", ignoreCase = true) } - isVisible = true - } - if (dialog.directory != null && dialog.file != null) { - filePath = File(dialog.directory, dialog.file).absolutePath - if (name.isBlank()) name = dialog.file.removeSuffix(".mpp") - error = null - } - }, - shape = RoundedCornerShape(corners.small) - ) { - Text( - "BROWSE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 10.sp, - letterSpacing = 0.5.sp - ) - } - } - } - else -> {} - } - - error?.let { - Text( - text = it, - fontSize = 11.sp, - fontFamily = mono, - color = MaterialTheme.colorScheme.error - ) - } - } - }, - confirmButton = { - Button( - onClick = { - if (name.isBlank()) { error = "Name is required"; return@Button } - when (sourceType) { - PatchSourceType.GITHUB -> { - val trimmedUrl = url.trim() - val resolvedUrl = resolveGitHubUrl(trimmedUrl) - if (resolvedUrl == null) { - error = "Enter a valid GitHub URL or Morphe source link"; return@Button - } - onAdd(PatchSource( - id = UUID.randomUUID().toString(), - name = name.trim(), - type = sourceType, - url = resolvedUrl, - deletable = true - )) - return@Button - } - PatchSourceType.LOCAL -> { - if (filePath.isBlank() || !File(filePath).exists()) { - error = "Select a valid .mpp file"; return@Button - } - } - else -> {} - } - onAdd(PatchSource( - id = UUID.randomUUID().toString(), - name = name.trim(), - type = sourceType, - url = null, - filePath = if (sourceType == PatchSourceType.LOCAL) filePath.trim() else null, - deletable = true - )) - }, - colors = ButtonDefaults.buttonColors(containerColor = accents.primary), - shape = RoundedCornerShape(corners.small) - ) { - Text( - "ADD", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text( - "CANCEL", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - } - ) -} - -@Composable -private fun EditPatchSourceDialog( - source: PatchSource, - onDismiss: () -> Unit, - onSave: (PatchSource) -> Unit -) { - val corners = LocalMorpheCorners.current - val mono = LocalMorpheFont.current - val accents = LocalMorpheAccents.current - var name by remember { mutableStateOf(source.name) } - var url by remember { mutableStateOf(source.url ?: "") } - var filePath by remember { mutableStateOf(source.filePath ?: "") } - var error by remember { mutableStateOf(null) } - - AlertDialog( - onDismissRequest = onDismiss, - shape = RoundedCornerShape(corners.medium), - containerColor = MaterialTheme.colorScheme.surface, - title = { - Text( - "EDIT SOURCE", - fontFamily = mono, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - letterSpacing = 1.sp - ) - }, - text = { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.widthIn(min = 300.dp) - ) { - // Type indicator - Text( - text = when (source.type) { - PatchSourceType.GITHUB -> "GITHUB REPOSITORY" - PatchSourceType.LOCAL -> "LOCAL FILE" - else -> "" - }, - fontSize = 9.sp, - fontWeight = FontWeight.Bold, - fontFamily = mono, - color = accents.primary, - letterSpacing = 1.sp - ) - - OutlinedTextField( - value = name, - onValueChange = { name = it; error = null }, - label = { Text("Name", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - - when (source.type) { - PatchSourceType.GITHUB -> { - OutlinedTextField( - value = url, - onValueChange = { url = it; error = null }, - label = { Text("Repository URL", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - } - PatchSourceType.LOCAL -> { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - OutlinedTextField( - value = filePath, - onValueChange = { filePath = it; error = null }, - label = { Text(".mpp file", fontFamily = mono, fontSize = 11.sp) }, - singleLine = true, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(corners.small), - readOnly = true - ) - OutlinedButton( - onClick = { - val dialog = FileDialog(null as Frame?, "Select .mpp file", FileDialog.LOAD).apply { - setFilenameFilter { _, n -> n.endsWith(".mpp", ignoreCase = true) } - isVisible = true - } - if (dialog.directory != null && dialog.file != null) { - filePath = File(dialog.directory, dialog.file).absolutePath - error = null - } - }, - shape = RoundedCornerShape(corners.small) - ) { - Text( - "BROWSE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 10.sp, - letterSpacing = 0.5.sp - ) - } - } - } - else -> {} - } - - error?.let { - Text(text = it, fontSize = 11.sp, fontFamily = mono, color = MaterialTheme.colorScheme.error) - } - } - }, - confirmButton = { - Button( - onClick = { - if (name.isBlank()) { error = "Name is required"; return@Button } - when (source.type) { - PatchSourceType.GITHUB -> { - val resolvedUrl = resolveGitHubUrl(url.trim()) - if (resolvedUrl == null) { - error = "Enter a valid GitHub URL or Morphe source link"; return@Button - } - onSave(source.copy( - name = name.trim(), - url = resolvedUrl - )) - return@Button - } - PatchSourceType.LOCAL -> { - if (filePath.isBlank() || !File(filePath).exists()) { - error = "Select a valid .mpp file"; return@Button - } - } - else -> {} - } - onSave(source.copy( - name = name.trim(), - filePath = if (source.type == PatchSourceType.LOCAL) filePath.trim() else source.filePath - )) - }, - colors = ButtonDefaults.buttonColors(containerColor = accents.primary), - shape = RoundedCornerShape(corners.small) - ) { - Text( - "SAVE", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text( - "CANCEL", - fontFamily = mono, - fontWeight = FontWeight.SemiBold, - fontSize = 11.sp, - letterSpacing = 0.5.sp - ) - } - } - ) -} // ── Strip Libs Section ── @@ -2282,6 +1695,8 @@ private fun SigningSection( onExpandedChange: (Boolean) -> Unit = {} ) { val corners = LocalMorpheCorners.current + val dimens = LocalMorpheDimens.current + val accents = LocalMorpheAccents.current val alpha = if (enabled) 1f else 0.4f var localPassword by remember(keystorePassword) { mutableStateOf(keystorePassword ?: "") } @@ -2315,7 +1730,7 @@ private fun SigningSection( // Keystore path row Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + modifier = Modifier.fillMaxWidth().height(dimens.controlHeight), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { @@ -2429,86 +1844,87 @@ private fun SigningSection( ) } - Spacer(Modifier.height(4.dp)) - - // Keystore password - OutlinedTextField( - value = localPassword, - onValueChange = { - localPassword = it - onCredentialsChange(it.ifEmpty { null }, localAlias, localEntryPassword) - }, - label = { Text("Keystore password", fontFamily = mono, fontSize = 10.sp) }, - singleLine = true, - enabled = enabled, - visualTransformation = if (showPassword) androidx.compose.ui.text.input.VisualTransformation.None - else androidx.compose.ui.text.input.PasswordVisualTransformation(), - trailingIcon = { - IconButton( - onClick = { showPassword = !showPassword }, - modifier = Modifier.size(20.dp) - ) { - Icon( - imageVector = if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showPassword) "Hide" else "Show", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - } - }, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) - - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(8.dp)) - // Key alias - OutlinedTextField( - value = localAlias, - onValueChange = { - localAlias = it - onCredentialsChange(localPassword.ifEmpty { null }, it, localEntryPassword) - }, - label = { Text("Key alias", fontFamily = mono, fontSize = 10.sp) }, - singleLine = true, - enabled = enabled, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + LabeledField(label = "KEYSTORE PASSWORD", mono = mono) { + SlimTextField( + value = localPassword, + onValueChange = { + localPassword = it + onCredentialsChange(it.ifEmpty { null }, localAlias, localEntryPassword) + }, + placeholder = "", + mono = mono, + accents = accents, + corners = corners, + enabled = enabled, + visualTransformation = if (showPassword) androidx.compose.ui.text.input.VisualTransformation.None + else androidx.compose.ui.text.input.PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + trailing = { + IconButton( + onClick = { showPassword = !showPassword }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showPassword) "Hide" else "Show", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + }, + ) + } - Spacer(Modifier.height(4.dp)) + LabeledField(label = "KEY ALIAS", mono = mono) { + SlimTextField( + value = localAlias, + onValueChange = { + localAlias = it + onCredentialsChange(localPassword.ifEmpty { null }, it, localEntryPassword) + }, + placeholder = "", + mono = mono, + accents = accents, + corners = corners, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) + } - // Key entry password - OutlinedTextField( - value = localEntryPassword, - onValueChange = { - localEntryPassword = it - onCredentialsChange(localPassword.ifEmpty { null }, localAlias, it) - }, - label = { Text("Key password", fontFamily = mono, fontSize = 10.sp) }, - singleLine = true, - enabled = enabled, - visualTransformation = if (showEntryPassword) androidx.compose.ui.text.input.VisualTransformation.None - else androidx.compose.ui.text.input.PasswordVisualTransformation(), - trailingIcon = { - IconButton( - onClick = { showEntryPassword = !showEntryPassword }, - modifier = Modifier.size(20.dp) - ) { - Icon( - imageVector = if (showEntryPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = if (showEntryPassword) "Hide" else "Show", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - } - }, - textStyle = LocalTextStyle.current.copy(fontFamily = mono, fontSize = 12.sp), - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(corners.small) - ) + LabeledField(label = "KEY PASSWORD", mono = mono) { + SlimTextField( + value = localEntryPassword, + onValueChange = { + localEntryPassword = it + onCredentialsChange(localPassword.ifEmpty { null }, localAlias, it) + }, + placeholder = "", + mono = mono, + accents = accents, + corners = corners, + enabled = enabled, + visualTransformation = if (showEntryPassword) androidx.compose.ui.text.input.VisualTransformation.None + else androidx.compose.ui.text.input.PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + trailing = { + IconButton( + onClick = { showEntryPassword = !showEntryPassword }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = if (showEntryPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showEntryPassword) "Hide" else "Show", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + }, + ) + } + } // Verify credentials button var verifyResult by remember { mutableStateOf(null) } @@ -2539,7 +1955,7 @@ private fun SigningSection( } }, enabled = enabled, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().height(dimens.controlHeight), shape = RoundedCornerShape(corners.small), border = BorderStroke( 1.dp, @@ -2549,7 +1965,7 @@ private fun SigningSection( else -> borderColor } ), - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp) + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), ) { Icon( imageVector = Icons.Default.Check, @@ -2635,14 +2051,14 @@ private fun SigningSection( } }, enabled = enabled, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().height(dimens.controlHeight), shape = RoundedCornerShape(corners.small), border = BorderStroke( 1.dp, if (generateSuccess) MorpheColors.Teal.copy(alpha = 0.4f) else accentColor.copy(alpha = 0.3f) ), - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp) + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), ) { Icon( imageVector = Icons.Default.Add, @@ -3027,6 +2443,7 @@ private fun ThemePreference.toDisplayName(): String { ThemePreference.CATPPUCCIN -> "Catppuccin" ThemePreference.SAKURA -> "Sakura" ThemePreference.MATCHA -> "Matcha" + ThemePreference.DEEPSPACE -> "Deepspace" ThemePreference.SYSTEM -> "System" } } @@ -3040,6 +2457,7 @@ private fun ThemePreference.iconSymbol(): String { ThemePreference.CATPPUCCIN -> "🐱" ThemePreference.SAKURA -> "🌸" ThemePreference.MATCHA -> "🍵" + ThemePreference.DEEPSPACE -> "✦" ThemePreference.SYSTEM -> "⚙" } } @@ -3053,6 +2471,7 @@ private fun ThemePreference.accentColor(): Color { ThemePreference.CATPPUCCIN -> Color(0xFFCBA6F7) ThemePreference.SAKURA -> Color(0xFFB43A67) ThemePreference.MATCHA -> Color(0xFF4C7A35) + ThemePreference.DEEPSPACE -> Color(0xFF00D9FF) ThemePreference.SYSTEM -> MorpheColors.Blue } } @@ -3095,44 +2514,6 @@ private fun clearAllCache(): Boolean { } } -/** - * Resolves a URL to a GitHub repository URL. - * Supports: - * - Direct GitHub URLs: https://github.com/owner/repo - * - Morphe source links: https://morphe.software/add-source?github=owner/repo - * - Short form: owner/repo (assumed GitHub) - * Returns a normalized https://github.com/owner/repo URL, or null if invalid. - */ -private fun resolveGitHubUrl(input: String): String? { - val trimmed = input.trim() - if (trimmed.isBlank()) return null - - // Morphe source link: morphe.software/add-source?github=owner/repo - if (trimmed.contains("morphe.software/add-source")) { - val match = Regex("[?&]github=([^&]+)").find(trimmed) - val repoPath = match?.groupValues?.get(1) ?: return null - val clean = repoPath.trimEnd('/') - return if (clean.contains('/') && clean.split('/').size == 2) { - "https://github.com/$clean" - } else null - } - - // Direct GitHub URL: https://github.com/owner/repo - if (trimmed.contains("github.com/")) { - // Extract owner/repo from full URL - val match = Regex("github\\.com/([^/]+/[^/]+)").find(trimmed) - return if (match != null) { - "https://github.com/${match.groupValues[1].trimEnd('/')}" - } else null - } - - // Short form: owner/repo - if (trimmed.matches(Regex("[\\w.-]+/[\\w.-]+"))) { - return "https://github.com/$trimmed" - } - - return null -} // ── Patched App Runtime Logs Section ── diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SlimInputs.kt b/src/main/kotlin/app/morphe/gui/ui/components/SlimInputs.kt new file mode 100644 index 00000000..bab56a1a --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/SlimInputs.kt @@ -0,0 +1,161 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.ui.theme.LocalMorpheDimens +import app.morphe.gui.ui.theme.MorpheAccentColors +import app.morphe.gui.ui.theme.MorpheCornerStyle + +/** + * Label-and-input group rendered as a tight Column. Use inside a parent Column + * that has its own `verticalArrangement = spacedBy(...)` for between-group + * spacing — this composable's internal label↔field gap stays a fixed 4dp. + */ +@Composable +internal fun LabeledField( + label: String, + mono: FontFamily, + content: @Composable ColumnScope.() -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = label, + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 9.sp, + letterSpacing = 1.2.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + content() + } +} + +/** + * Slim text input matching the cyberdeck aesthetic across the app — pinned to + * [LocalMorpheDimens.controlHeight] so it lines up with the project's standard + * button height. Uses [BasicTextField] with a custom decoration so we get full + * control of the height (Material 3's [androidx.compose.material3.OutlinedTextField] + * has a 56dp minimum that's too chunky for this app). + * + * Optional [trailing] slot for things like password-visibility toggles. + */ +@Composable +internal fun SlimTextField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + mono: FontFamily, + accents: MorpheAccentColors, + corners: MorpheCornerStyle, + modifier: Modifier = Modifier, + readOnly: Boolean = false, + enabled: Boolean = true, + visualTransformation: VisualTransformation = VisualTransformation.None, + trailing: (@Composable () -> Unit)? = null, +) { + val dimens = LocalMorpheDimens.current + val muted = MaterialTheme.colorScheme.onSurfaceVariant + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val borderColor by animateColorAsState( + if (isFocused) accents.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.18f), + animationSpec = tween(150), + label = "slimFieldBorder", + ) + + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + readOnly = readOnly, + enabled = enabled, + visualTransformation = visualTransformation, + interactionSource = interactionSource, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = mono, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = SolidColor(accents.primary), + modifier = modifier + .height(dimens.controlHeight) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)), + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .fillMaxSize() + .padding(start = 10.dp, end = if (trailing != null) 4.dp else 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.weight(1f)) { + if (value.isEmpty() && placeholder.isNotEmpty()) { + Text( + text = placeholder, + fontSize = 11.sp, + fontFamily = mono, + color = muted.copy(alpha = 0.4f), + ) + } + innerTextField() + } + if (trailing != null) trailing() + } + }, + ) +} + +/** + * Compact OutlinedButton pinned to [LocalMorpheDimens.controlHeight]. Used for + * BROWSE / RESET / similar inline action buttons next to a [SlimTextField]. + */ +@Composable +internal fun DialogActionButton( + label: String, + mono: FontFamily, + corners: MorpheCornerStyle, + onClick: () -> Unit, +) { + val dimens = LocalMorpheDimens.current + OutlinedButton( + onClick = onClick, + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), + modifier = Modifier.height(dimens.controlHeight), + ) { + Text( + label, + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + letterSpacing = 0.5.sp, + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt new file mode 100644 index 00000000..f8d77833 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/SourceManagementSheet.kt @@ -0,0 +1,440 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.model.PatchSource +import app.morphe.gui.data.model.PatchSourceType +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont +import java.io.File + +/** + * Multi-source management sheet, summoned from the home header `+` button. + * Lists every configured patch source with an enable toggle. Default source + * cannot be deleted or renamed (mirrors morphe-manager rules); other sources + * can be edited or removed. + * + * Caller wires actions to [PatchSourceManager] / [ConfigRepository] equivalents. + */ +/** + * How rows in the management sheet behave: + * - [MULTI_TOGGLE]: each source has an enable Switch. Used by Expert mode where + * patches from all enabled sources are unioned. + * - [SINGLE_SELECT]: each row is a radio. Used by Quick Patch mode where exactly + * one source is "active" at a time. + */ +enum class SourceSheetMode { MULTI_TOGGLE, SINGLE_SELECT } + +@Composable +fun SourceManagementSheet( + sources: List, + onToggleEnabled: (id: String, enabled: Boolean) -> Unit, + onAdd: (PatchSource) -> Unit, + onEdit: (PatchSource) -> Unit, + onRemove: (id: String) -> Unit, + onOpenPatches: (sourceId: String) -> Unit, + onDismiss: () -> Unit, + enabled: Boolean = true, + /** sourceId → resolved version label (e.g. "v1.27.0-dev.2"). Empty when not loaded. */ + sourceVersions: Map = emptyMap(), + /** sourceId → channel classification of the resolved release. Drives the badge. */ + sourceChannels: Map = emptyMap(), + /** Selection semantics. Defaults to multi-toggle (Expert mode). */ + mode: SourceSheetMode = SourceSheetMode.MULTI_TOGGLE, + /** sourceId of the currently picked source — only used when [mode] is SINGLE_SELECT. */ + activeSourceId: String? = null, + /** Called when the user picks a source — only used when [mode] is SINGLE_SELECT. */ + onSelectSingle: (sourceId: String) -> Unit = {}, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + + var showAddDialog by remember { mutableStateOf(false) } + var editingSource by remember { mutableStateOf(null) } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + "PATCH SOURCES", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 2.sp, + ) + }, + text = { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .widthIn(min = 360.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = when { + !enabled -> "Disabled while patching" + mode == SourceSheetMode.SINGLE_SELECT -> + "Pick which source Quick Patch uses. Multi-source is available in Expert mode." + else -> "Enable/Disable any combination. Patches from all enabled sources are unioned." + }, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f) + ) + + Spacer(Modifier.height(4.dp)) + + sources.forEach { source -> + SourceRow( + source = source, + version = sourceVersions[source.id], + channel = sourceChannels[source.id], + accentColor = accents.primary, + borderColor = borderColor, + mono = mono, + enabled = enabled, + mode = mode, + isActiveSelection = source.id == activeSourceId, + onSelectSingle = { onSelectSingle(source.id) }, + onToggleEnabled = { newVal -> onToggleEnabled(source.id, newVal) }, + onEdit = { editingSource = source }, + onRemove = { onRemove(source.id) }, + onOpenPatches = { onOpenPatches(source.id) }, + ) + } + + Spacer(Modifier.height(2.dp)) + + OutlinedButton( + onClick = { showAddDialog = true }, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(Modifier.width(8.dp)) + Text( + "ADD SOURCE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + letterSpacing = 0.5.sp + ) + } + } + }, + confirmButton = { + TextButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + ) { + Text( + "DONE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp, + ) + } + } + ) + + if (showAddDialog) { + AddPatchSourceDialog( + onDismiss = { showAddDialog = false }, + onAdd = { + onAdd(it) + showAddDialog = false + } + ) + } + + editingSource?.let { src -> + EditPatchSourceDialog( + source = src, + onDismiss = { editingSource = null }, + onSave = { + onEdit(it) + editingSource = null + } + ) + } +} + +@Composable +private fun SourceRow( + source: PatchSource, + version: String?, + channel: app.morphe.gui.util.EnabledSourcesLoader.Channel?, + accentColor: Color, + borderColor: Color, + mono: androidx.compose.ui.text.font.FontFamily, + enabled: Boolean, + onToggleEnabled: (Boolean) -> Unit, + onEdit: () -> Unit, + onRemove: () -> Unit, + onOpenPatches: () -> Unit, + mode: SourceSheetMode, + isActiveSelection: Boolean, + onSelectSingle: () -> Unit, +) { + val corners = LocalMorpheCorners.current + val hoverInteraction = remember(source.id) { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val isEnabled = source.enabled + val isDefault = !source.deletable + // Card click works regardless of enable state. In MULTI_TOGGLE mode it opens + // patches for the source (PatchesScreen). In SINGLE_SELECT mode it picks the + // source as the active one for Quick Patch. Disabled only while patching. + val canInteract = enabled + // For visual highlight: in MULTI mode highlight when source is enabled; in + // SINGLE_SELECT highlight when this row is the picked one. + val isHighlighted = if (mode == SourceSheetMode.SINGLE_SELECT) isActiveSelection else isEnabled + + val animatedBorder by animateColorAsState( + targetValue = when { + isHovered && canInteract -> accentColor.copy(alpha = if (isHighlighted) 0.7f else 0.45f) + isHighlighted -> accentColor.copy(alpha = 0.35f) + else -> borderColor + }, + animationSpec = tween(150) + ) + val animatedBg by animateColorAsState( + targetValue = when { + isHovered && canInteract -> accentColor.copy(alpha = if (isHighlighted) 0.12f else 0.05f) + isHighlighted -> accentColor.copy(alpha = 0.06f) + else -> Color.Transparent + }, + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, animatedBorder, RoundedCornerShape(corners.medium)) + .background(animatedBg) + .hoverable(hoverInteraction) + .then( + if (canInteract) Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = if (mode == SourceSheetMode.SINGLE_SELECT) onSelectSingle else onOpenPatches) + else Modifier + ) + .padding(horizontal = 12.dp, vertical = 10.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + // LED indicator — glows when enabled (MULTI) or selected (SINGLE). + LedIndicator(isOn = isHighlighted, isHot = isHovered && canInteract, accentColor = accentColor) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = source.name, + fontSize = 12.sp, + fontWeight = if (isEnabled) FontWeight.SemiBold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (isDefault) { + Text( + "DEFAULT", + fontSize = 8.sp, + fontFamily = mono, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = when (source.type) { + PatchSourceType.DEFAULT -> source.url?.removePrefix("https://github.com/") ?: "Built-in" + PatchSourceType.GITHUB -> source.url?.removePrefix("https://github.com/") ?: "GitHub" + PatchSourceType.LOCAL -> source.filePath?.let { File(it).name } ?: "Local file" + }, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + if (isEnabled && version != null) { + Text( + text = "·", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + ) + Text( + text = version, + fontSize = 10.sp, + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + color = accentColor.copy(alpha = 0.9f) + ) + ChannelBadge(channel = channel, mono = mono) + } + } + } + + // Edit + delete are hidden for default; toggle is always shown + if (!isDefault && enabled) { + IconButton(onClick = onEdit, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + modifier = Modifier.size(14.dp) + ) + } + IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + modifier = Modifier.size(14.dp) + ) + } + Spacer(Modifier.width(4.dp)) + } + when (mode) { + SourceSheetMode.MULTI_TOGGLE -> Switch( + checked = isEnabled, + onCheckedChange = onToggleEnabled, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedTrackColor = accentColor.copy(alpha = 0.5f), + checkedThumbColor = accentColor, + ), + modifier = Modifier.scale(0.8f) + ) + SourceSheetMode.SINGLE_SELECT -> RadioButton( + selected = isActiveSelection, + onClick = onSelectSingle, + enabled = enabled, + colors = RadioButtonDefaults.colors( + selectedColor = accentColor, + unselectedColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ), + ) + } + } + } +} + + +@Composable +private fun ChannelBadge( + channel: app.morphe.gui.util.EnabledSourcesLoader.Channel?, + mono: androidx.compose.ui.text.font.FontFamily, +) { + val corners = LocalMorpheCorners.current + val accents = LocalMorpheAccents.current + val (label, color) = when (channel) { + app.morphe.gui.util.EnabledSourcesLoader.Channel.STABLE_LATEST -> "STABLE LATEST" to accents.secondary + app.morphe.gui.util.EnabledSourcesLoader.Channel.STABLE_OLDER -> "STABLE OLDER" to accents.warning + app.morphe.gui.util.EnabledSourcesLoader.Channel.DEV_LATEST -> "DEV LATEST" to androidx.compose.ui.graphics.Color(0xFFFFD43B) + app.morphe.gui.util.EnabledSourcesLoader.Channel.DEV_OLDER -> "DEV OLDER" to accents.warning + else -> "STABLE LATEST" to accents.secondary + } + Box( + modifier = Modifier + .border(1.dp, color.copy(alpha = 0.3f), RoundedCornerShape(corners.small)) + .background(color.copy(alpha = 0.08f), RoundedCornerShape(corners.small)) + .padding(horizontal = 5.dp, vertical = 1.dp) + ) { + Text( + text = label, + fontSize = 8.sp, + fontFamily = mono, + fontWeight = FontWeight.Bold, + letterSpacing = 0.8.sp, + color = color, + ) + } +} + +/** + * Tiny status LED on the left of each source row. Solid glow when the source is + * enabled; dim ring when off. Brightens on hover for the click-to-open affordance. + */ +@Composable +private fun LedIndicator(isOn: Boolean, isHot: Boolean, accentColor: Color) { + val color by animateColorAsState( + targetValue = when { + isOn && isHot -> accentColor + isOn -> accentColor.copy(alpha = 0.85f) + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f) + }, + animationSpec = tween(200) + ) + val haloAlpha by animateColorAsState( + targetValue = if (isOn) accentColor.copy(alpha = if (isHot) 0.35f else 0.18f) else Color.Transparent, + animationSpec = tween(200) + ) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(14.dp)) { + // Soft halo ring + Box( + modifier = Modifier + .size(12.dp) + .background(haloAlpha, shape = androidx.compose.foundation.shape.CircleShape) + ) + // Core dot + Box( + modifier = Modifier + .size(7.dp) + .background(color, shape = androidx.compose.foundation.shape.CircleShape) + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt b/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt new file mode 100644 index 00000000..7eb200e7 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/SourcesPill.kt @@ -0,0 +1,144 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.model.PatchSource +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheDimens +import app.morphe.gui.ui.theme.LocalMorpheFont +import app.morphe.gui.ui.theme.MorpheAccentColors +import app.morphe.gui.util.EnabledSourcesLoader + +/** Per-source LED state surfaced in [SourcesCountPill]. */ +enum class SourceLedState { DISABLED, STABLE_LATEST, OLDER, DEV } + +/** + * Header pill showing source count + per-source channel LEDs + trailing "+". + * Used in expert mode (clickable, opens [SourceManagementSheet]) and in Quick + * Patch mode (purely informational — pass `onClick = null`). + */ +@Composable +fun SourcesCountPill( + sourceStates: List, + onClick: (() -> Unit)? = null, +) { + val corners = LocalMorpheCorners.current + val dimens = LocalMorpheDimens.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val interactive = onClick != null + val borderColor by animateColorAsState( + if (isHovered && interactive) accents.primary.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.10f), + animationSpec = tween(200) + ) + val tint = if (isHovered && interactive) accents.primary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f) + val count = sourceStates.size.coerceAtLeast(1) + val label = if (count == 1) "1 SOURCE" else "$count SOURCES" + Row( + modifier = Modifier + .height(dimens.controlHeight) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)) + .background(MaterialTheme.colorScheme.surface) + .then( + if (interactive) Modifier + .hoverable(hoverInteraction) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + else Modifier + ) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = label, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 1.5.sp, + color = tint, + ) + if (sourceStates.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(3.dp), + ) { + sourceStates.forEach { state -> SourceLed(state = state, accents = accents) } + } + } + if (interactive) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "Manage patch sources", + tint = tint, + modifier = Modifier.size(12.dp), + ) + } + } +} + +@Composable +private fun SourceLed(state: SourceLedState, accents: MorpheAccentColors) { + val color = when (state) { + SourceLedState.DISABLED -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + SourceLedState.STABLE_LATEST -> accents.primary + SourceLedState.OLDER -> accents.warning + SourceLedState.DEV -> Color(0xFFFFD43B) + } + Box( + modifier = Modifier + .size(6.dp) + .background(color, shape = CircleShape) + ) +} + +/** Map a [PatchSource] + its resolved channel to a UI LED state. */ +fun sourceLedState( + source: PatchSource, + channel: EnabledSourcesLoader.Channel?, +): SourceLedState { + if (!source.enabled) return SourceLedState.DISABLED + return when (channel) { + EnabledSourcesLoader.Channel.STABLE_LATEST -> SourceLedState.STABLE_LATEST + EnabledSourcesLoader.Channel.STABLE_OLDER -> SourceLedState.OLDER + EnabledSourcesLoader.Channel.DEV_LATEST, + EnabledSourcesLoader.Channel.DEV_OLDER -> SourceLedState.DEV + // No load yet — assume latest until we know otherwise. + null, EnabledSourcesLoader.Channel.UNKNOWN -> SourceLedState.STABLE_LATEST + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt index 5f06b25b..aa7f5255 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeScreen.kt @@ -7,7 +7,11 @@ package app.morphe.gui.ui.screens.home import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image @@ -22,6 +26,8 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.layout.* import androidx.compose.foundation.HorizontalScrollbar import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollbarAdapter import androidx.compose.ui.text.style.TextOverflow @@ -30,12 +36,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Warning import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -43,6 +51,8 @@ import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -55,6 +65,7 @@ import app.morphe.morphe_cli.generated.resources.Res import app.morphe.morphe_cli.generated.resources.morphe_dark import app.morphe.morphe_cli.generated.resources.morphe_light import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheDimens import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalThemeState @@ -65,10 +76,18 @@ import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import app.morphe.gui.data.model.SupportedApp +import app.morphe.gui.data.repository.PatchSourceManager +import app.morphe.gui.ui.components.SourceLedState +import app.morphe.gui.ui.components.SourceManagementSheet +import app.morphe.gui.ui.components.SourcesCountPill +import app.morphe.gui.ui.components.sourceLedState import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.components.morpheScrollbarStyle +import kotlinx.coroutines.launch +import org.koin.compose.koinInject import app.morphe.gui.ui.screens.home.components.ApkInfoCard import app.morphe.gui.ui.screens.home.components.FullScreenDropZone +import app.morphe.gui.ui.screens.home.components.SupportedAppListRow import app.morphe.gui.ui.components.OfflineBanner import app.morphe.gui.ui.components.UpdateBanner import app.morphe.gui.ui.screens.patches.PatchesScreen @@ -98,11 +117,76 @@ fun HomeScreenContent( val navigator = LocalNavigator.currentOrThrow val uiState by viewModel.uiState.collectAsState() + val patchSourceManager: PatchSourceManager = koinInject() + val allSources by patchSourceManager.allSources.collectAsState() + val coroutineScope = rememberCoroutineScope() + // Two-flag pattern for smooth navigation in/out of the sheet: + // - showSourceManagementSheet: actually visible right now + // - pendingReopenSheet: user navigated away from the sheet via a row click; + // we should reopen it once they pop back AND the screen transition settles. + // rememberSaveable on both so they survive Voyager's push/pop teardown. + var showSourceManagementSheet by rememberSaveable { mutableStateOf(false) } + var pendingReopenSheet by rememberSaveable { mutableStateOf(false) } + + // Re-show the sheet after the pop animation finishes, NOT immediately on + // re-entry. Without the delay the sheet flashes in mid-transition. + LaunchedEffect(Unit) { + if (pendingReopenSheet) { + kotlinx.coroutines.delay(220) + showSourceManagementSheet = true + pendingReopenSheet = false + } + } + val navStackSize = navigator.items.size LaunchedEffect(navStackSize) { viewModel.refreshPatchesIfNeeded() } + if (showSourceManagementSheet) { + val snapshot = viewModel.getResolvedSourcesSnapshot() + val versions: Map = snapshot + ?.resolved + ?.associate { it.source.id to it.resolvedVersion } + ?: emptyMap() + val channels: Map = snapshot + ?.resolved + ?.associate { it.source.id to it.channel } + ?: emptyMap() + SourceManagementSheet( + sources = allSources, + sourceVersions = versions, + sourceChannels = channels, + onToggleEnabled = { id, enabled -> + coroutineScope.launch { patchSourceManager.setSourceEnabled(id, enabled) } + }, + onAdd = { source -> + coroutineScope.launch { patchSourceManager.addSource(source) } + }, + onEdit = { updated -> + coroutineScope.launch { patchSourceManager.updateSource(updated) } + }, + onRemove = { id -> + coroutineScope.launch { patchSourceManager.removeSource(id) } + }, + onOpenPatches = { sourceId -> + // Hide sheet immediately so it doesn't ride the push animation. + // Mark it as pending-reopen so it returns smoothly after pop. + showSourceManagementSheet = false + pendingReopenSheet = true + coroutineScope.launch { + patchSourceManager.switchSource(sourceId) + navigator.push(PatchesScreen( + apkPath = uiState.apkInfo?.filePath ?: "", + apkName = uiState.apkInfo?.appName ?: "Select APK first" + )) + } + }, + onDismiss = { showSourceManagementSheet = false }, + enabled = !uiState.isAnalyzing, + ) + } + val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(uiState.error) { uiState.error?.let { error -> @@ -125,7 +209,17 @@ fun HomeScreenContent( modifier = Modifier .fillMaxSize() ) { - val useSplitLayout = maxWidth >= 720.dp + // Side-by-side layout: drop zone / APK info on the left, vertical + // supported-apps list on the right. Falls back to top/bottom on + // narrower windows. Hysteresis (switch up at 920dp, down at 880dp) + // prevents flicker when the user resizes near the threshold. + var splitLayoutState by remember { mutableStateOf(maxWidth >= 900.dp) } + splitLayoutState = when { + maxWidth >= 920.dp -> true + maxWidth < 880.dp -> false + else -> splitLayoutState + } + val useSplitLayout = splitLayoutState val isCompact = maxWidth < 500.dp val isSmall = maxHeight < 600.dp val padding = if (isCompact) 16.dp else 24.dp @@ -148,7 +242,9 @@ fun HomeScreenContent( apkName = uiState.apkInfo!!.appName, patchesFilePath = patchesFile.absolutePath, packageName = uiState.apkInfo!!.packageName, - apkArchitectures = uiState.apkInfo!!.architectures + apkArchitectures = uiState.apkInfo!!.architectures, + patchesFilePaths = viewModel.getAllResolvedPatchFiles().map { it.absolutePath }, + patchSourceNames = viewModel.getAllResolvedPatchSourceNames(), )) } }, @@ -178,6 +274,50 @@ fun HomeScreenContent( } } + val resolvedSnapshot = viewModel.getResolvedSourcesSnapshot() + val versionsBySource: Map = resolvedSnapshot + ?.resolved + ?.associate { it.source.id to it.resolvedVersion } + ?: emptyMap() + val channelsBySource: Map = + resolvedSnapshot + ?.resolved + ?.associate { it.source.id to it.channel } + ?: emptyMap() + // Source names whose patches target the currently-selected APK's package. + // Used by ApkInfoCard's "FROM" row to surface multi-source provenance. + val patchSourcesForSelectedApk: List = uiState.apkInfo?.let { info -> + val snapshot = resolvedSnapshot ?: return@let null + snapshot.guiPatchesBySource.entries + .filter { (_, patches) -> + patches.any { p -> p.compatiblePackages.any { it.name == info.packageName } } + } + .mapNotNull { (sourceId, _) -> + allSources.firstOrNull { it.id == sourceId }?.name + } + } ?: emptyList() + + // Per-package source attribution map used by the supported-apps cards. + // Built once per recomposition so each card just looks up its own list. + val sourceNamesByPackage: Map> = if (resolvedSnapshot == null) { + emptyMap() + } else { + val sourceIdToName = allSources.associate { it.id to it.name } + val accum = mutableMapOf>() + resolvedSnapshot.guiPatchesBySource.forEach { (sourceId, patches) -> + val name = sourceIdToName[sourceId] ?: return@forEach + val packages = patches.flatMap { it.compatiblePackages.map { p -> p.name } } + .filter { it.isNotBlank() } + .toSet() + packages.forEach { pkg -> + accum.getOrPut(pkg) { mutableListOf() }.add(name) + } + } + accum + } + val sourceStates: List = allSources.map { src -> + sourceLedState(src, channelsBySource[src.id]) + } val headerContent: @Composable ColumnScope.() -> Unit = { if (useHorizontalHeader) { HeaderBar( @@ -186,6 +326,8 @@ fun HomeScreenContent( onChangePatchesClick = onChangePatchesClick, onRetry = onRetry, onUpdateChannelChanged = { viewModel.refreshUpdateCheck() }, + onManageSourcesClick = { showSourceManagementSheet = true }, + sourceStates = sourceStates, ) } else { Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) @@ -237,7 +379,8 @@ fun HomeScreenContent( patchesLoaded = patchesLoaded, onClearClick = onClearClick, onChangeClick = onChangeClick, - onContinueClick = onContinueClick + onContinueClick = onContinueClick, + patchSourceNames = patchSourcesForSelectedApk, ) } } @@ -258,7 +401,8 @@ fun HomeScreenContent( isDefaultSource = uiState.isDefaultSource, supportedApps = uiState.supportedApps, loadError = uiState.patchLoadError, - onRetry = onRetry + onRetry = onRetry, + sourceNamesByPackage = sourceNamesByPackage, ) } } @@ -273,6 +417,8 @@ fun HomeScreenContent( onChangePatchesClick = onChangePatchesClick, onRetry = onRetry, onUpdateChannelChanged = { viewModel.refreshUpdateCheck() }, + onManageSourcesClick = { showSourceManagementSheet = true }, + sourceStates = sourceStates, ) } else { Column( @@ -316,22 +462,12 @@ fun HomeScreenContent( } } - // ── Scrollable body ── - BoxWithConstraints( - modifier = Modifier - .weight(1f) - .fillMaxWidth() - ) { - val bodyMaxHeight = this.maxHeight - val scrollState = rememberScrollState() - Column( - modifier = Modifier - .fillMaxWidth() - .verticalScroll(scrollState) - .heightIn(min = bodyMaxHeight), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = if (pinSupportedAppsToBottom) Arrangement.SpaceBetween else Arrangement.Top - ) { + // ── Body ── + if (useSplitLayout) { + // Side-by-side: drop zone / APK info on the left, + // vertical supported-apps list on the right. The list pane + // owns its own scroll; the rest stays static. + Column(modifier = Modifier.weight(1f).fillMaxWidth()) { if (uiState.showUpdateBanner) { UpdateBanner( info = uiState.updateInfo!!, @@ -339,57 +475,156 @@ fun HomeScreenContent( onDismissForVersion = { viewModel.dismissUpdateForVersion() }, modifier = Modifier .fillMaxWidth() - .padding(start = padding, end = padding, top = 8.dp) + .padding(start = padding, end = padding, top = 8.dp), ) } - - // ── Main workspace area ── - Box( + if (uiState.showMultiSourceHint) { + MultiSourceHintBanner( + onDismiss = { viewModel.dismissMultiSourceHint() }, + modifier = Modifier + .fillMaxWidth() + .padding(start = padding, end = padding, top = 8.dp), + ) + } + Row( modifier = Modifier + .weight(1f) .fillMaxWidth() - .padding(padding), - contentAlignment = Alignment.Center + // Small cute padding for small cute space + // between the HeaderBar's bottom + // divider and the actual body section. + .padding( + start = if (isCompact) 12.dp else 10.dp, + end = padding, + top = 4.dp, + bottom = padding, + ), + horizontalArrangement = Arrangement.spacedBy(padding), ) { - MiddleContent( - uiState = uiState, + // Left: browse/discover supported apps (wizard step 1). + SupportedAppsListPane( + supportedApps = uiState.supportedApps, + sourceNamesByPackage = sourceNamesByPackage, + isLoading = uiState.isLoadingPatches, + loadError = uiState.patchLoadError, + onRetry = onRetry, isCompact = isCompact, - patchesLoaded = patchesLoaded, - onClearClick = onClearClick, - onChangeClick = onChangeClick, - onContinueClick = onContinueClick + modifier = Modifier + .weight(1.2f) + .fillMaxHeight(), ) + // Right: APK info / drop zone (wizard step 2 — pick the + // APK you want patched). Content centers vertically when + // it fits, scrolls when it doesn't, so the CONTINUE + // button is never clipped off the bottom. + BoxWithConstraints( + modifier = Modifier.weight(1f).fillMaxHeight(), + ) { + val viewport = this.maxHeight + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = viewport), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + MiddleContent( + uiState = uiState, + isCompact = isCompact, + patchesLoaded = patchesLoaded, + onClearClick = onClearClick, + onChangeClick = onChangeClick, + onContinueClick = onContinueClick, + patchSourceNames = patchSourcesForSelectedApk, + ) + } + } } - - // ── Supported apps ── + } + } else { + // ── Scrollable top/bottom body (narrow windows) ── + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + val bodyMaxHeight = this.maxHeight + val scrollState = rememberScrollState() Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .heightIn(min = bodyMaxHeight), horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding( - start = padding, - end = padding, - bottom = if (isSmall) 8.dp else 16.dp - ) + verticalArrangement = if (pinSupportedAppsToBottom) Arrangement.SpaceBetween else Arrangement.Top, ) { - SupportedAppsSection( - isCompact = isCompact, - maxWidth = outerMaxWidth, - isLoading = uiState.isLoadingPatches, - isDefaultSource = uiState.isDefaultSource, - supportedApps = uiState.supportedApps, - loadError = uiState.patchLoadError, - onRetry = onRetry - ) + if (uiState.showUpdateBanner) { + UpdateBanner( + info = uiState.updateInfo!!, + onDismissForSession = { viewModel.dismissUpdateForSession() }, + onDismissForVersion = { viewModel.dismissUpdateForVersion() }, + modifier = Modifier + .fillMaxWidth() + .padding(start = padding, end = padding, top = 8.dp), + ) + } + if (uiState.showMultiSourceHint) { + MultiSourceHintBanner( + onDismiss = { viewModel.dismissMultiSourceHint() }, + modifier = Modifier + .fillMaxWidth() + .padding(start = padding, end = padding, top = 8.dp), + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(padding), + contentAlignment = Alignment.Center, + ) { + MiddleContent( + uiState = uiState, + isCompact = isCompact, + patchesLoaded = patchesLoaded, + onClearClick = onClearClick, + onChangeClick = onChangeClick, + onContinueClick = onContinueClick, + patchSourceNames = patchSourcesForSelectedApk, + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding( + start = padding, + end = padding, + bottom = if (isSmall) 8.dp else 16.dp, + ), + ) { + SupportedAppsSection( + isCompact = isCompact, + maxWidth = outerMaxWidth, + isLoading = uiState.isLoadingPatches, + isDefaultSource = uiState.isDefaultSource, + supportedApps = uiState.supportedApps, + loadError = uiState.patchLoadError, + onRetry = onRetry, + sourceNamesByPackage = sourceNamesByPackage, + ) + } } - } - // Show scrollbar only when content overflows - if (scrollState.maxValue > 0) { - VerticalScrollbar( - modifier = Modifier - .align(Alignment.CenterEnd) - .fillMaxHeight(), - adapter = rememberScrollbarAdapter(scrollState), - style = morpheScrollbarStyle() - ) + if (scrollState.maxValue > 0) { + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight(), + adapter = rememberScrollbarAdapter(scrollState), + style = morpheScrollbarStyle(), + ) + } } } } @@ -437,7 +672,9 @@ private fun handleContinue( apkName = info.appName, patchesFilePath = patchesFile.absolutePath, packageName = info.packageName, - apkArchitectures = info.architectures + apkArchitectures = info.architectures, + patchesFilePaths = viewModel.getAllResolvedPatchFiles().map { it.absolutePath }, + patchSourceNames = viewModel.getAllResolvedPatchSourceNames(), )) } } @@ -454,6 +691,8 @@ private fun HeaderBar( onChangePatchesClick: () -> Unit, onRetry: () -> Unit, onUpdateChannelChanged: () -> Unit = {}, + onManageSourcesClick: () -> Unit = {}, + sourceStates: List = emptyList(), ) { val mono = LocalMorpheFont.current val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.10f) @@ -495,20 +734,12 @@ private fun HeaderBar( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { - if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { - PatchesVersionInline( - patchesVersion = uiState.patchesVersion!!, - latestLabel = uiState.latestPatchesLabel, - onChangePatchesClick = onChangePatchesClick, - patchSourceName = uiState.patchSourceName - ) - } else if (uiState.isLoadingPatches) { + if (uiState.isLoadingPatches) { PatchesLoadingIndicator() - } else if (uiState.patchLoadError != null) { - PatchesVersionInline( - patchesVersion = "NOT LOADED", - latestLabel = null, - onChangePatchesClick = onChangePatchesClick + } else { + SourcesCountPill( + sourceStates = sourceStates, + onClick = onManageSourcesClick, ) } @@ -612,6 +843,48 @@ private fun PatchesVersionInline( } } +/** One-time intro banner shown when the user first sees multi-source mode. + * Persists dismissal in ConfigRepository so it never reappears once dismissed. */ +@Composable +private fun MultiSourceHintBanner( + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + Row( + modifier = modifier + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, accents.primary.copy(alpha = 0.3f), RoundedCornerShape(corners.small)) + .background(accents.primary.copy(alpha = 0.06f)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "MULTIPLE SOURCES ACTIVE — patches from every enabled source are unioned. Manage from the SOURCES button above.", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + letterSpacing = 0.2.sp, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onDismiss, modifier = Modifier.size(24.dp)) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Dismiss", + tint = accents.primary, + modifier = Modifier.size(14.dp), + ) + } + } +} + +// SourcesCountPill, SourceLed, SourceLedState, sourceLedState moved to +// gui/ui/components/SourcesPill.kt for reuse across modes (Quick Patch uses +// a non-clickable variant). + @Composable private fun PatchesLoadingIndicator() { val mono = LocalMorpheFont.current @@ -680,7 +953,8 @@ private fun MiddleContent( patchesLoaded: Boolean, onClearClick: () -> Unit, onChangeClick: () -> Unit, - onContinueClick: () -> Unit + onContinueClick: () -> Unit, + patchSourceNames: List = emptyList(), ) { when { uiState.isAnalyzing -> { @@ -693,7 +967,8 @@ private fun MiddleContent( isCompact = isCompact, onClearClick = onClearClick, onChangeClick = onChangeClick, - onContinueClick = onContinueClick + onContinueClick = onContinueClick, + patchSourceNames = patchSourceNames, ) } else -> { @@ -817,7 +1092,8 @@ private fun ApkSelectedSection( isCompact: Boolean, onClearClick: () -> Unit, onChangeClick: () -> Unit, - onContinueClick: () -> Unit + onContinueClick: () -> Unit, + patchSourceNames: List = emptyList(), ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -834,7 +1110,8 @@ private fun ApkSelectedSection( ApkInfoCard( apkInfo = apkInfo, onClearClick = onClearClick, - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + patchSourceNames = patchSourceNames, ) Spacer(modifier = Modifier.height(if (isCompact) 16.dp else 20.dp)) @@ -989,8 +1266,219 @@ private fun AnalyzingSection(isCompact: Boolean = false) { // ════════════════════════════════════════════════════════════════════ /** - * Bottom section — horizontal scrolling cards. + * Vertical-list variant of the supported-apps display used in the side-by-side + * layout. Search field at top, scrollable LazyColumn of [SupportedAppListRow] + * below. Single-expand semantics — clicking a row expands it and collapses any + * previously-expanded one. */ +@Composable +private fun SupportedAppsListPane( + supportedApps: List, + sourceNamesByPackage: Map>, + isLoading: Boolean, + loadError: String?, + onRetry: () -> Unit, + isCompact: Boolean, + modifier: Modifier = Modifier, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + + var searchQuery by remember { mutableStateOf("") } + var expandedPackage by remember { mutableStateOf(null) } + + val filtered = if (searchQuery.isBlank()) supportedApps + else supportedApps.filter { + it.displayName.contains(searchQuery, ignoreCase = true) || + it.packageName.contains(searchQuery, ignoreCase = true) + } + + // Collapse if the currently expanded app filters out. + LaunchedEffect(searchQuery, filtered) { + if (expandedPackage != null && filtered.none { it.packageName == expandedPackage }) { + expandedPackage = null + } + } + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val paneMaxHeight = maxHeight + Column( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .align(Alignment.Center), + ) { + // ── Header row: SUPPORTED APPS · count ── + // end = 12.dp matches the LazyColumn's right padding so "X apps" + // visually aligns with the right edge of the cards. + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(end = 12.dp, bottom = 4.dp), + ) { + Text( + text = "SUPPORTED APPS", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 1.5.sp, + color = homeMutedTextColor(0.4f), + ) + Spacer(Modifier.weight(1f)) + if (!isLoading && supportedApps.isNotEmpty()) { + Text( + text = "${supportedApps.size} apps", + fontSize = 9.sp, + fontFamily = mono, + color = homeMutedTextColor(0.4f), + ) + } + } + + // ── Search field ── + if (supportedApps.size > 4) { + // Match the LazyColumn's right padding so the field aligns with cards. + // Dp.Unspecified disables the default 340dp cap so the field fills + // the pane width like the cards below it. + Box(modifier = Modifier.fillMaxWidth().padding(end = 12.dp)) { + SlimSearchField( + value = searchQuery, + onValueChange = { searchQuery = it }, + mono = mono, + corners = corners, + accents = accents, + maxWidth = Dp.Unspecified, + ) + } + Spacer(modifier = Modifier.height(10.dp)) + } + + when { + isLoading -> { + Column( + modifier = Modifier.fillMaxWidth().padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + repeat(4) { idx -> + SkeletonAppRow( + corners = corners, + // Slight stagger: each row pulses 120ms after the previous + // so the skeleton list feels alive instead of lock-step. + staggerOffsetMs = idx * 120, + ) + } + } + } + loadError != null -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + ) { + Text( + text = "LOAD FAILED", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.error, + letterSpacing = 1.sp, + ) + Spacer(Modifier.height(6.dp)) + Text( + text = loadError, + fontSize = 11.sp, + fontFamily = mono, + color = homeMutedTextColor(0.6f), + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(10.dp)) + OutlinedButton( + onClick = onRetry, + shape = RoundedCornerShape(corners.small), + ) { + Text( + "RETRY", + fontFamily = mono, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.5.sp, + ) + } + } + } + filtered.isEmpty() -> { + Box( + modifier = Modifier.fillMaxWidth().padding(top = 32.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = if (searchQuery.isBlank()) "No supported apps" + else "No apps match \"$searchQuery\"", + fontSize = 11.sp, + fontFamily = mono, + color = homeMutedTextColor(0.5f), + ) + } + } + else -> { + val listState = rememberLazyListState() + // Cap the list at the pane's available height (minus a header + // + optional search allowance) so it scrolls when there are + // many apps but wraps tight + lets the Column center when few. + // Tight estimate: header ~22dp; search field (only shown when + // >4 apps) ~46dp. Anything over-budgeted leaves dead space + // above the list when content fills, so be precise. + val headerSearchAllowance = + if (supportedApps.size > 4) 68.dp else 22.dp + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn( + max = (paneMaxHeight - headerSearchAllowance) + .coerceAtLeast(120.dp) + ), + ) { + androidx.compose.foundation.lazy.LazyColumn( + state = listState, + // Scrollbar is 6dp wide and sits at the Box's right edge. + // 6 (scrollbar width) + 6 (visible gap) = 12dp keeps content + // fully clear of the scrollbar with breathing room. + modifier = Modifier.fillMaxWidth().padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items(items = filtered, key = { it.packageName }) { app -> + SupportedAppListRow( + app = app, + isExpanded = expandedPackage == app.packageName, + onClick = { + expandedPackage = if (expandedPackage == app.packageName) null + else app.packageName + }, + patchSourceNames = sourceNamesByPackage[app.packageName] ?: emptyList(), + ) + } + } + // Wrap the scrollbar in a matchParentSize Box so it + // tracks the LazyColumn's wrapped height WITHOUT forcing + // the outer Box to fill its heightIn(max=…) cap. Then + // align CenterEnd + wrap width to keep it pinned at the + // right edge at its natural 6dp thickness. + Box( + modifier = Modifier.matchParentSize(), + contentAlignment = Alignment.CenterEnd, + ) { + VerticalScrollbar( + modifier = Modifier.fillMaxHeight(), + adapter = rememberScrollbarAdapter(listState), + style = morpheScrollbarStyle(), + ) + } + } + } + } + } + } +} + @Composable private fun SupportedAppsSection( isCompact: Boolean = false, @@ -999,7 +1487,10 @@ private fun SupportedAppsSection( isDefaultSource: Boolean = true, supportedApps: List = emptyList(), loadError: String? = null, - onRetry: () -> Unit = {} + onRetry: () -> Unit = {}, + /** packageName → source display names contributing patches. Used to badge + * cards with their source attribution in multi-source mode. */ + sourceNamesByPackage: Map> = emptyMap(), ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -1114,65 +1605,13 @@ private fun SupportedAppsSection( } if (supportedApps.size > 4) { - if (isDefaultSource) { - // Default search field for Morphe-source patches. - OutlinedTextField( - value = searchQuery, - onValueChange = { searchQuery = it }, - placeholder = { - Text( - "Filter apps…", - fontSize = 11.sp, - fontFamily = mono, - color = homeMutedTextColor(0.4f) - ) - }, - leadingIcon = { - Icon( - Icons.Default.Search, - contentDescription = null, - tint = homeMutedTextColor(0.6f), - modifier = Modifier.size(16.dp) - ) - }, - trailingIcon = { - if (searchQuery.isNotEmpty()) { - IconButton(onClick = { searchQuery = "" }) { - Icon( - Icons.Default.Clear, - contentDescription = "Clear", - tint = homeMutedTextColor(0.5f), - modifier = Modifier.size(14.dp) - ) - } - } - }, - singleLine = true, - textStyle = MaterialTheme.typography.bodySmall.copy( - fontFamily = mono, - fontSize = 11.sp - ), - shape = RoundedCornerShape(corners.small), - modifier = Modifier - .widthIn(max = 260.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.35f), - unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), - cursorColor = accents.primary - ) - ) - } else { - // Slim, elongated search field for third-party patches. - // Uses BasicTextField + a custom decoration so we can break - // out of OutlinedTextField's 56dp minimum height. - SlimSearchField( - value = searchQuery, - onValueChange = { searchQuery = it }, - mono = mono, - corners = corners, - accents = accents - ) - } + SlimSearchField( + value = searchQuery, + onValueChange = { searchQuery = it }, + mono = mono, + corners = corners, + accents = accents + ) Spacer(modifier = Modifier.height(12.dp)) } @@ -1208,6 +1647,7 @@ private fun SupportedAppsSection( onClose = { selectedApp = null }, isDefaultSource = isDefaultSource, useVerticalLayout = useVerticalLayout, + sourceNamesByPackage = sourceNamesByPackage, modifier = Modifier .fillMaxWidth() .padding(horizontal = if (isCompact) 8.dp else 16.dp) @@ -1424,7 +1864,8 @@ private fun SupportedAppsMasterDetail( onClose: () -> Unit, isDefaultSource: Boolean, useVerticalLayout: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + sourceNamesByPackage: Map> = emptyMap(), ) { val cardSpacing = 10.dp @@ -1446,7 +1887,8 @@ private fun SupportedAppsMasterDetail( app = app, isSelected = app.packageName == selectedApp?.packageName, onClick = { onSelect(app) }, - isDefaultSource = isDefaultSource + isDefaultSource = isDefaultSource, + patchSourceNames = sourceNamesByPackage[app.packageName] ?: emptyList(), ) } } @@ -1481,7 +1923,8 @@ private fun SupportedAppVerticalCard( isSelected: Boolean, onClick: () -> Unit, isDefaultSource: Boolean, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + patchSourceNames: List = emptyList(), ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -1490,7 +1933,9 @@ private fun SupportedAppVerticalCard( // ── Dimensions ── val collapsedWidth = 188.dp val expandedExtraWidth = 320.dp - val cardHeight = if (isDefaultSource) 250.dp else 190.dp + // Uniform height across all cards — every card shows the EXPERIMENTAL row + // (with "—" when none) so they line up visually in the row. + val cardHeight = 250.dp // ── Animations ── val animatedExtraWidth by animateDpAsState( @@ -1600,19 +2045,17 @@ private fun SupportedAppVerticalCard( nullLabel = "Any version" ) - // Experimental row only for default (Morphe) patch sources. - // Third-party patches don't get experimental support here. - if (isDefaultSource) { - Spacer(modifier = Modifier.height(12.dp)) - VersionWithDownload( - channelLabel = "EXPERIMENTAL LATEST", - channelColor = accents.warning, - version = latestExperimental, - downloadUrl = if (hasExperimental) app.experimentalDownloadUrl else null, - mono = mono, - corners = corners - ) - } + // Always show the EXPERIMENTAL row — when the app has no experimental + // version, VersionWithDownload renders "—" via its nullLabel default. + Spacer(modifier = Modifier.height(12.dp)) + VersionWithDownload( + channelLabel = "EXPERIMENTAL LATEST", + channelColor = accents.warning, + version = if (hasExperimental) latestExperimental else null, + downloadUrl = if (hasExperimental) app.experimentalDownloadUrl else null, + mono = mono, + corners = corners + ) } // ════════════════════════════════════════════════ @@ -1627,11 +2070,20 @@ private fun SupportedAppVerticalCard( .background(borderColor) ) - Column( + // Right-panel content can overflow the fixed cardHeight when an app + // has lots of versions or sources. Wrap in a Box with a scrollable + // Column + vertical scrollbar so users can reach everything. + val rightPanelScroll = rememberScrollState() + Box( modifier = Modifier .width((animatedExtraWidth - 1.dp).coerceAtLeast(0.dp)) .fillMaxHeight() - .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rightPanelScroll) + .padding(start = 16.dp, end = 22.dp, top = 16.dp, bottom = 16.dp) ) { // ── Package name + close ── Row( @@ -1686,6 +2138,61 @@ private fun SupportedAppVerticalCard( Spacer(modifier = Modifier.height(12.dp)) + // ── PATCHES FROM (sources contributing patches for this app) ── + // Always shown for visual consistency. Renders "—" if no source + // attribution data is available for this app. + Text( + text = "PATCHES FROM", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary.copy(alpha = 0.85f), + letterSpacing = 1.2.sp + ) + Spacer(modifier = Modifier.height(6.dp)) + if (patchSourceNames.isNotEmpty()) { + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + patchSourceNames.forEach { name -> + Box( + modifier = Modifier + .border( + 1.dp, + accents.primary.copy(alpha = 0.3f), + RoundedCornerShape(corners.small), + ) + .background( + accents.primary.copy(alpha = 0.06f), + RoundedCornerShape(corners.small), + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = name, + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + letterSpacing = 0.3.sp, + color = accents.primary, + maxLines = 1, + ) + } + } + } + } else { + Text( + text = "—", + fontSize = 10.sp, + fontFamily = mono, + color = homeMutedTextColor(0.35f) + ) + } + Spacer(modifier = Modifier.height(14.dp)) + // ── ALSO STABLE tags ── Text( text = "ALSO STABLE", @@ -1730,54 +2237,65 @@ private fun SupportedAppVerticalCard( ) } - if (isDefaultSource) { - Spacer(modifier = Modifier.height(14.dp)) - - // ── EXPERIMENTAL tags (Morphe-source patches only) ── + // ── EXPERIMENTAL tags ── + // Always shown for visual consistency across cards. Renders "—" + // when this app has no experimental versions in the loaded patches. + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = "EXPERIMENTAL", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.warning.copy(alpha = 0.85f), + letterSpacing = 1.2.sp + ) + Spacer(modifier = Modifier.height(6.dp)) + if (app.experimentalVersions.isNotEmpty()) { + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + app.experimentalVersions.take(8).forEach { version -> + VersionPill( + version = version, + color = accents.warning, + mono = mono, + corners = corners + ) + } + if (app.experimentalVersions.size > 8) { + Text( + text = "+${app.experimentalVersions.size - 8}", + fontSize = 10.sp, + fontFamily = mono, + color = homeMutedTextColor(0.5f), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) + ) + } + } + } else { Text( - text = "EXPERIMENTAL", - fontSize = 9.sp, - fontWeight = FontWeight.Bold, + text = "—", + fontSize = 10.sp, fontFamily = mono, - color = accents.warning.copy(alpha = 0.85f), - letterSpacing = 1.2.sp + color = homeMutedTextColor(0.35f) ) - Spacer(modifier = Modifier.height(6.dp)) - if (app.experimentalVersions.isNotEmpty()) { - @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth() - ) { - app.experimentalVersions.take(8).forEach { version -> - VersionPill( - version = version, - color = accents.warning, - mono = mono, - corners = corners - ) - } - if (app.experimentalVersions.size > 8) { - Text( - text = "+${app.experimentalVersions.size - 8}", - fontSize = 10.sp, - fontFamily = mono, - color = homeMutedTextColor(0.5f), - modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp) - ) - } - } - } else { - Text( - text = "none", - fontSize = 10.sp, - fontFamily = mono, - color = homeMutedTextColor(0.35f) - ) - } } } + // Vertical scrollbar — only shows when content overflows. + if (rightPanelScroll.maxValue > 0) { + VerticalScrollbar( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .padding(vertical = 6.dp), + adapter = rememberScrollbarAdapter(rightPanelScroll), + style = morpheScrollbarStyle() + ) + } + } } } } @@ -1886,8 +2404,10 @@ private fun SlimSearchField( onValueChange: (String) -> Unit, mono: androidx.compose.ui.text.font.FontFamily, corners: app.morphe.gui.ui.theme.MorpheCornerStyle, - accents: app.morphe.gui.ui.theme.MorpheAccentColors + accents: app.morphe.gui.ui.theme.MorpheAccentColors, + maxWidth: Dp = 340.dp, ) { + val dimens = LocalMorpheDimens.current val muted = MaterialTheme.colorScheme.onSurfaceVariant val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() @@ -1910,9 +2430,9 @@ private fun SlimSearchField( ), cursorBrush = SolidColor(accents.primary), modifier = Modifier - .widthIn(max = 360.dp) + .widthIn(max = maxWidth) .fillMaxWidth() - .height(34.dp) + .height(dimens.controlHeight) .clip(RoundedCornerShape(corners.small)) .border(1.dp, borderColor, RoundedCornerShape(corners.small)), decorationBox = { innerTextField -> @@ -2059,3 +2579,81 @@ private fun openFilePicker(): File? { null } } + +// ════════════════════════════════════════════════════════════════════ +// LOADING SKELETON — ghost row that mimics SupportedAppListRow's shape +// ════════════════════════════════════════════════════════════════════ + +@Composable +private fun SkeletonAppRow( + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + staggerOffsetMs: Int, +) { + val infinite = rememberInfiniteTransition(label = "skeletonPulse") + val alpha by infinite.animateFloat( + initialValue = 0.06f, + targetValue = 0.16f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 900, delayMillis = staggerOffsetMs), + repeatMode = RepeatMode.Reverse, + ), + label = "skeletonAlpha", + ) + val baseColor = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha) + val cardBg = MaterialTheme.colorScheme.surface.copy(alpha = 0.4f) + val outline = MaterialTheme.colorScheme.outline.copy(alpha = 0.10f) + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .background(cardBg) + .border(1.dp, outline, RoundedCornerShape(corners.medium)) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + // Row 1: avatar + name/package bars + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor), + ) + Spacer(Modifier.width(10.dp)) + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + Box( + modifier = Modifier + .height(10.dp) + .width(140.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor), + ) + Box( + modifier = Modifier + .height(8.dp) + .width(180.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor.copy(alpha = alpha * 0.6f)), + ) + } + } + // Row 2: chip placeholders + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + modifier = Modifier + .height(20.dp) + .width(110.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor), + ) + Box( + modifier = Modifier + .height(20.dp) + .width(130.dp) + .clip(RoundedCornerShape(corners.small)) + .background(baseColor.copy(alpha = alpha * 0.7f)), + ) + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt index f06766f3..1f6101eb 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.dongliu.apk.parser.ApkFile +import app.morphe.gui.util.EnabledSourcesLoader import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService @@ -47,19 +48,40 @@ class HomeViewModel( // Cached patches and supported apps private var cachedPatches: List = emptyList() private var cachedPatchesFile: File? = null + /** All resolved patch files across enabled sources. Single-element in + * single-source mode. Exposed via [getAllResolvedPatchFiles] for screens + * that navigate downstream and need to pass the full set. */ + private var cachedAllPatchFiles: List = emptyList() private var loadJob: Job? = null + fun getAllResolvedPatchFiles(): List = + cachedAllPatchFiles.takeIf { it.isNotEmpty() } + ?: listOfNotNull(cachedPatchesFile) + + /** Display names for each entry in [getAllResolvedPatchFiles], in the same + * order. Used by PatchSelectionScreen to badge patches with their source. */ + fun getAllResolvedPatchSourceNames(): List = + cachedSourcesResult + ?.resolved + ?.filter { it.patchFile != null } + ?.map { it.source.name } + ?: emptyList() + init { // Auto-fetch patches on startup loadPatchesAndSupportedApps() // Background CLI update check — non-blocking, banner only. screenModelScope.launch { + val config = configRepository.loadConfig() val info = updateCheckRepository.getUpdateInfo() - val dismissed = configRepository.loadConfig().dismissedUpdateVersion + val dismissed = config.dismissedUpdateVersion + val multiSourceShouldShow = !config.multiSourceHintDismissed && + patchSourceManager.getEnabledSourcesSync().size > 1 _uiState.value = _uiState.value.copy( updateInfo = info, dismissedUpdateVersion = dismissed, + showMultiSourceHint = multiSourceShouldShow, ) } @@ -114,6 +136,16 @@ class HomeViewModel( _uiState.value = _uiState.value.copy(updateBannerSessionDismissed = true) } + /** + * Dismiss the multi-source intro hint persistently. One-shot. + */ + fun dismissMultiSourceHint() { + _uiState.value = _uiState.value.copy(showMultiSourceHint = false) + screenModelScope.launch { + configRepository.setMultiSourceHintDismissed() + } + } + /** * Hide the update banner persistently for the current available version. * The banner will reappear automatically when an even newer version becomes @@ -129,108 +161,45 @@ class HomeViewModel( // Track the last loaded version to avoid reloading unnecessarily private var lastLoadedVersion: String? = null + // Snapshot of per-source pinned versions used in the last load — drives + // refreshPatchesIfNeeded so we reload when ANY source's pin changes. + private var lastLoadedVersionsBySource: Map = emptyMap() /** - * Load patches from GitHub and extract supported apps. - * If a saved version exists in config, load that version instead of latest. + * Load patches from all enabled sources via [EnabledSourcesLoader] and build + * the union supported-apps list. Single-enabled-source case produces output + * equivalent to the pre-multi-source flow. */ private fun loadPatchesAndSupportedApps(forceRefresh: Boolean = false) { loadJob?.cancel() loadJob = screenModelScope.launch { _uiState.value = _uiState.value.copy(isLoadingPatches = true, patchLoadError = null) - // LOCAL source: skip GitHub entirely, load directly from the .mpp file - if (localPatchFilePath != null) { - val localFile = File(localPatchFilePath) - if (localFile.exists()) { - loadPatchesFromFile(localFile, localFile.nameWithoutExtension, latestVersion = null, isOffline = false) - } else { - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = "Local patch file not found: ${localFile.name}" - ) - } - return@launch - } - try { - // Check if there's a saved patches version in config - val config = configRepository.loadConfig() - val savedVersion = config.lastPatchesVersion - - // 1. Fetch all releases to find the right one - val releasesResult = patchRepository.fetchReleases() - val releases = releasesResult.getOrNull() - - if (releases.isNullOrEmpty()) { - // Try to fall back to cached .mpp file when offline - val offlinePatchFile = findCachedPatchFile(savedVersion) - if (offlinePatchFile != null) { - loadPatchesFromFile(offlinePatchFile, versionFromFilename(offlinePatchFile), latestVersion = null) - return@launch - } - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = "Could not fetch patches: ${releasesResult.exceptionOrNull()?.message}" - ) - return@launch - } - - // Find the latest stable release for reference - val latestStable = releases.firstOrNull { !it.isDevRelease() } - val latestVersion = latestStable?.tagName - val latestDevVersion = releases.firstOrNull { it.isDevRelease() }?.tagName - - // 2. Find the release to use - prefer saved version, fallback to latest stable - val release = if (savedVersion != null) { - releases.find { it.tagName == savedVersion } - ?: latestStable // Fallback to latest stable - } else { - latestStable // Latest stable - } - - if (release == null) { - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = "No suitable release found" - ) - return@launch - } - - // Skip reload if we've already loaded this version (unless forced) - if (!forceRefresh && lastLoadedVersion == release.tagName && cachedPatchesFile?.exists() == true) { - Logger.info("Skipping reload - already loaded version ${release.tagName}") - _uiState.value = _uiState.value.copy(isLoadingPatches = false) - return@launch - } - - Logger.info("Loading patches version: ${release.tagName} (saved=$savedVersion)") - - // 3. Download patches - val patchFileResult = patchRepository.downloadPatches(release) - val patchFile = patchFileResult.getOrNull() - - if (patchFile == null) { + val enabled = patchSourceManager.getEnabledRepositories() + if (enabled.isEmpty()) { _uiState.value = _uiState.value.copy( isLoadingPatches = false, - patchLoadError = "Could not download patches: ${patchFileResult.exceptionOrNull()?.message}" + patchLoadError = "No patch sources enabled. Add or enable a source from the home screen." ) return@launch } - cachedPatchesFile = patchFile - lastLoadedVersion = release.tagName - - // 3. Load patches using PatchService (direct library call) - val patchesResult = patchService.listPatches(patchFile.absolutePath) - val patches = patchesResult.getOrNull() - - if (patches == null || patches.isEmpty()) { - val rawError = patchesResult.exceptionOrNull()?.message ?: "Unknown error" - val friendlyError = if (rawError.contains("zip", ignoreCase = true) || rawError.contains("END header", ignoreCase = true)) { + // Per-source pinned versions (with one-time migration from legacy + // single-source field). Each source's resolver looks up its own pin; + // no cross-source contamination. + val preferredVersions = configRepository.getLastPatchesVersionsBySource() + lastLoadedVersionsBySource = preferredVersions + val result = EnabledSourcesLoader.loadAll(enabled, patchService, preferredVersions) + + if (!result.anyLoaded) { + val firstError = result.resolved.firstNotNullOfOrNull { it.error } + ?: result.loaded.perSource.firstNotNullOfOrNull { it.error?.message } + ?: "Could not load any patches" + val friendlyError = if (firstError.contains("zip", ignoreCase = true) || firstError.contains("END header", ignoreCase = true)) { "Patch file is missing or corrupted. Clear cache and re-download." } else { - "Could not load patches: $rawError" + firstError } _uiState.value = _uiState.value.copy( isLoadingPatches = false, @@ -239,36 +208,50 @@ class HomeViewModel( return@launch } - cachedPatches = patches + cachedPatches = result.unionGuiPatches + // Preserve existing single-file API for downstream navigation. In + // multi-source mode this points at the first resolved source; the + // full list is exposed via [getAllResolvedPatchFiles] and the + // per-source data via [getResolvedSourcesSnapshot]. + val firstResolved = result.resolved.firstOrNull { it.patchFile != null } + cachedPatchesFile = firstResolved?.patchFile + cachedAllPatchFiles = result.resolved.mapNotNull { it.patchFile } + lastLoadedVersion = firstResolved?.resolvedVersion + cachedSourcesResult = result + + val supportedApps = SupportedAppExtractor.extractSupportedApps(result.unionGuiPatches) + Logger.info( + "Loaded ${supportedApps.size} supported apps from " + + "${result.resolved.count { it.patchFile != null }} source(s): " + + supportedApps.map { it.displayName } + ) - // 5. Extract supported apps - val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) - Logger.info("Loaded ${supportedApps.size} supported apps from patches: ${supportedApps.map { "${it.displayName} (${it.recommendedVersion})" }}") + // Only flag the whole UI as offline when EVERY successfully-resolved + // source had to fall back to its cache. One source being offline + // while others are online shouldn't make the whole screen scream + // "offline" — that's a per-source state, surfaced in the sheet. + val resolvedSources = result.resolved.filter { it.patchFile != null } + val isOffline = resolvedSources.isNotEmpty() && resolvedSources.all { it.isOffline } + val displayVersion = firstResolved?.resolvedVersion + val sourceName = if (result.resolved.size == 1) { + firstResolved?.source?.name ?: patchSourceManager.getActiveSourceName() + } else { + "${result.resolved.count { it.patchFile != null }} sources" + } _uiState.value = _uiState.value.copy( isLoadingPatches = false, - isOffline = false, + isOffline = isOffline, supportedApps = supportedApps, - patchesVersion = release.tagName, - latestPatchesVersion = latestVersion, - latestDevPatchesVersion = latestDevVersion, - patchSourceName = patchSourceManager.getActiveSourceName(), + patchesVersion = displayVersion, + latestPatchesVersion = displayVersion, + latestDevPatchesVersion = null, + patchSourceName = sourceName, patchLoadError = null ) reanalyzeSelectedApk() } catch (e: Exception) { Logger.error("Failed to load patches and supported apps", e) - // Try to fall back to cached .mpp file - val config = configRepository.loadConfig() - val offlinePatchFile = findCachedPatchFile(config.lastPatchesVersion) - if (offlinePatchFile != null) { - try { - loadPatchesFromFile(offlinePatchFile, versionFromFilename(offlinePatchFile), latestVersion = null) - return@launch - } catch (inner: Exception) { - Logger.error("Failed to load cached patches fallback", inner) - } - } _uiState.value = _uiState.value.copy( isLoadingPatches = false, patchLoadError = e.message ?: "Unknown error" @@ -278,79 +261,11 @@ class HomeViewModel( } /** - * Find any cached .mpp file when offline. - * Prefers the file matching savedVersion from config. - * Searches the per-source cache directory. + * Snapshot of the most recent multi-source load. Used by 9d's + * PatchSelectionViewModel migration to render badged per-source patches. */ - private fun findCachedPatchFile(savedVersion: String?): File? { - val patchesDir = patchRepository.getCacheDir() - val patchFiles = patchesDir.listFiles { file -> - val ext = file.extension.lowercase() - ext == "mpp" || ext == "jar" - }?.filter { it.length() > 0 } ?: return null - - if (patchFiles.isEmpty()) return null - - return if (savedVersion != null) { - // Strip "v" prefix — savedVersion is "v1.13.0" but filenames are "patches-1.13.0.mpp" - val versionNumber = savedVersion.removePrefix("v") - patchFiles.firstOrNull { it.name.contains(versionNumber, ignoreCase = true) } - ?: patchFiles.maxByOrNull { it.lastModified() } - } else { - patchFiles.maxByOrNull { it.lastModified() } - } - } - - /** - * Extract a version string from an .mpp filename (e.g. "morphe-patches-1.3.0.mpp" -> "v1.3.0"). - */ - private fun versionFromFilename(file: File): String { - val name = file.nameWithoutExtension - // Try to find a version pattern like 1.2.3 or v1.2.3 - val match = Regex("""v?(\d+\.\d+\.\d+[^\s]*)""").find(name) - return match?.value ?: name - } - - /** - * Load patches from a local .mpp file and update UI state. - * Used as fallback when offline with cached patches. - */ - private suspend fun loadPatchesFromFile(patchFile: File, version: String, latestVersion: String?, isOffline: Boolean = true) { - cachedPatchesFile = patchFile - lastLoadedVersion = version - - val patchesResult = patchService.listPatches(patchFile.absolutePath) - val patches = patchesResult.getOrNull() - - if (patches == null || patches.isEmpty()) { - val rawError = patchesResult.exceptionOrNull()?.message ?: "Unknown error" - val friendlyError = if (rawError.contains("zip", ignoreCase = true) || rawError.contains("END header", ignoreCase = true)) { - "Patch file is missing or corrupted. Clear cache and re-download." - } else { - "Could not load patches: $rawError" - } - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = friendlyError - ) - return - } - - cachedPatches = patches - val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) - Logger.info("Loaded ${supportedApps.size} supported apps from ${if (isOffline) "cached" else "local"} patches: ${patchFile.name}") - - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - isOffline = isOffline, - supportedApps = supportedApps, - patchesVersion = version, - latestPatchesVersion = latestVersion, - patchSourceName = patchSourceManager.getActiveSourceName(), - patchLoadError = null - ) - reanalyzeSelectedApk() - } + fun getResolvedSourcesSnapshot(): EnabledSourcesLoader.Result? = cachedSourcesResult + private var cachedSourcesResult: EnabledSourcesLoader.Result? = null /** * Re-runs APK analysis against the freshly-loaded `supportedApps` so the info @@ -371,17 +286,14 @@ class HomeViewModel( } /** - * Refresh patches if a different version was selected. - * Called when returning to HomeScreen from PatchesScreen. + * Refresh patches if any source's pinned version was changed (e.g. via + * PatchesScreen). Called when returning to HomeScreen from another screen. */ fun refreshPatchesIfNeeded() { screenModelScope.launch { - val config = configRepository.loadConfig() - val savedVersion = config.lastPatchesVersion - - // If saved version differs from currently loaded version, reload - if (savedVersion != null && savedVersion != lastLoadedVersion) { - Logger.info("Patches version changed: $lastLoadedVersion -> $savedVersion, reloading...") + val saved = configRepository.getLastPatchesVersionsBySource() + if (saved != lastLoadedVersionsBySource) { + Logger.info("Patches versions changed across sources: $lastLoadedVersionsBySource -> $saved, reloading...") loadPatchesAndSupportedApps(forceRefresh = true) } } @@ -613,6 +525,9 @@ data class HomeUiState( val dismissedUpdateVersion: String? = null, /** Session-only dismiss; cleared on next app start. Not persisted. */ val updateBannerSessionDismissed: Boolean = false, + /** True when more than one source is enabled and the user hasn't dismissed + * the one-time multi-source intro hint yet. */ + val showMultiSourceHint: Boolean = false, ) { /** * Show the update banner only when an update was found AND the user hasn't diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt index 3702001c..359f6e0e 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/ApkInfoCard.kt @@ -45,7 +45,10 @@ import app.morphe.gui.util.toColor fun ApkInfoCard( apkInfo: ApkInfo, onClearClick: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + /** Names of enabled sources whose patches target [apkInfo.packageName]. When + * more than one, surfaces the multi-source provenance directly on the card. */ + patchSourceNames: List = emptyList(), ) { val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current @@ -286,6 +289,66 @@ fun ApkInfoCard( } } + // ── Patch sources providing patches for this app ── + if (patchSourceNames.isNotEmpty()) { + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(20.dp.toPx(), 0f), + end = Offset(size.width - 20.dp.toPx(), 0f), + strokeWidth = 1f + ) + } + .padding(start = 23.dp, end = 20.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "FROM", + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(Modifier.width(4.dp)) + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + patchSourceNames.forEach { name -> + Box( + modifier = Modifier + .border( + 1.dp, + accents.primary.copy(alpha = 0.3f), + RoundedCornerShape(corners.small), + ) + .background( + accents.primary.copy(alpha = 0.06f), + RoundedCornerShape(corners.small), + ) + .padding(horizontal = 8.dp, vertical = 3.dp) + ) { + Text( + text = name, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + letterSpacing = 0.3.sp, + color = accents.primary, + maxLines = 1, + ) + } + } + } + } + } + // ── Status bar ── val statusDisplay = resolveVersionStatusDisplay( apkInfo.versionStatus, apkInfo.checksumStatus, apkInfo.suggestedVersion diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt new file mode 100644 index 00000000..e1e9dab6 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/screens/home/components/SupportedAppListRow.kt @@ -0,0 +1,413 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.screens.home.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.OpenInNew +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import app.morphe.gui.data.model.SupportedApp +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont +import app.morphe.gui.util.DownloadUrlResolver.openUrlAndFollowRedirects + +/** + * Vertical-list-friendly supported-app row. Two-row collapsed layout: + * row 1: initial badge + app name + package name (muted) + * row 2: STABLE LATEST chip + EXPERIMENTAL LATEST chip (or "—") + * + * Whole row is clickable (Phase 3 hooks expansion to it). Version chips are + * also tappable as quick-download shortcuts — their clicks are consumed so + * they don't bubble up and trigger the row click. + */ +@Composable +fun SupportedAppListRow( + app: SupportedApp, + onClick: () -> Unit = {}, + isExpanded: Boolean = false, + /** Source display names whose patches target [app.packageName]. Rendered as + * the FROM chips inside the expanded body. Empty hides the FROM section. */ + patchSourceNames: List = emptyList(), + modifier: Modifier = Modifier, +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val hoverInteraction = remember(app.packageName) { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + + val borderColor by animateColorAsState( + targetValue = when { + isExpanded -> accents.primary.copy(alpha = 0.45f) + isHovered -> MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + }, + animationSpec = tween(150), + label = "rowBorder", + ) + val backgroundColor by animateColorAsState( + targetValue = when { + isExpanded -> accents.primary.copy(alpha = 0.05f) + isHovered -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + else -> MaterialTheme.colorScheme.surface + }, + animationSpec = tween(150), + label = "rowBg", + ) + + val initial = app.displayName.firstOrNull()?.uppercase() ?: "?" + val hasStable = app.recommendedVersion != null + val hasExperimental = app.experimentalVersions.isNotEmpty() + val latestExperimental = app.experimentalVersions.firstOrNull() + + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(backgroundColor) + .hoverable(hoverInteraction) + .pointerHoverIcon(PointerIcon.Hand) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // ── Row 1: initial + name + package ── + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, accents.primary.copy(alpha = 0.35f), RoundedCornerShape(corners.small)) + .background(accents.primary.copy(alpha = 0.06f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = initial, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + ) + } + Spacer(Modifier.width(10.dp)) + Text( + text = app.displayName, + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = app.packageName, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + + // ── Row 2: STABLE LATEST + EXPERIMENTAL LATEST chips ── + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + VersionChip( + channelLabel = "STABLE LATEST", + version = app.recommendedVersion, + color = accents.secondary, + // Pass the URL through unconditionally — when recommendedVersion + // is null (patches work on Any version), the URL still points to + // the app's general APKMirror page and stays clickable. + downloadUrl = app.apkDownloadUrl, + nullLabel = "Any", + mono = mono, + cornerSmall = corners.small, + ) + VersionChip( + channelLabel = "EXPERIMENTAL LATEST", + version = latestExperimental, + color = accents.warning, + downloadUrl = app.experimentalDownloadUrl, + nullLabel = "—", + mono = mono, + cornerSmall = corners.small, + ) + } + + // ── Expanded body: PATCHES FROM + ALSO STABLE + EXPERIMENTAL pills ── + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(animationSpec = tween(220), expandFrom = Alignment.Top) + + fadeIn(animationSpec = tween(180)), + exit = shrinkVertically(animationSpec = tween(180), shrinkTowards = Alignment.Top) + + fadeOut(animationSpec = tween(120)), + ) { + ExpandedBody( + app = app, + patchSourceNames = patchSourceNames, + accents = accents, + mono = mono, + cornerSmall = corners.small, + ) + } + } +} + +/** + * Channel label + version pair. When [downloadUrl] is non-null and [version] is + * present, the chip becomes a clickable quick-download (with hand cursor + open- + * in-new icon). When [version] is null, renders "—" in a muted style with no + * click affordance. + * + * The chip's clickable consumes the press — clicking it does NOT bubble up to + * the row's clickable, so quick-downloading doesn't accidentally expand the row. + */ +@Composable +private fun VersionChip( + channelLabel: String, + version: String?, + color: Color, + downloadUrl: String?, + nullLabel: String, + mono: androidx.compose.ui.text.font.FontFamily, + cornerSmall: androidx.compose.ui.unit.Dp, +) { + // A chip is a clickable download link whenever the URL is present, even if + // the version is null ("Any" label still routes to the app's general page). + val isLink = downloadUrl != null + val uriHandler = LocalUriHandler.current + val hoverInteraction = remember(channelLabel) { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val borderColor by animateColorAsState( + targetValue = when { + isLink && isHovered -> color.copy(alpha = 0.55f) + isLink -> color.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) + }, + animationSpec = tween(150), + label = "chipBorder", + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier + .clip(RoundedCornerShape(cornerSmall)) + .border(1.dp, borderColor, RoundedCornerShape(cornerSmall)) + .background( + if (isLink) color.copy(alpha = 0.06f) + else Color.Transparent + ) + .hoverable(hoverInteraction) + .then( + if (isLink) Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable { + openUrlAndFollowRedirects(downloadUrl!!) { uriHandler.openUri(it) } + } + else Modifier + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) { + Text( + text = channelLabel, + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.6.sp, + color = if (isLink) color + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + ) + Text( + text = "·", + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + ) + Text( + text = version?.let { if (it.startsWith("v")) it else "v$it" } ?: nullLabel, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = if (isLink) color + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f), + ) + if (isLink) { + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = "Download $channelLabel", + tint = color, + modifier = Modifier.size(10.dp), + ) + } + } +} + +/** Body that drops down below the collapsed row when [SupportedAppListRow.isExpanded] + * is true. Sections: PATCHES FROM, ALSO STABLE, EXPERIMENTAL. */ +@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) +@Composable +private fun ExpandedBody( + app: SupportedApp, + patchSourceNames: List, + accents: app.morphe.gui.ui.theme.MorpheAccentColors, + mono: androidx.compose.ui.text.font.FontFamily, + cornerSmall: androidx.compose.ui.unit.Dp, +) { + // "Other stable" = supported versions other than the recommended latest. + val otherStable = app.supportedVersions.filter { it != app.recommendedVersion } + val maxPills = 16 + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 6.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (patchSourceNames.isNotEmpty()) { + SectionLabel(text = "PATCHES FROM", color = accents.primary, mono = mono) + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + patchSourceNames.forEach { name -> + // Source pills use a bright near-white label (vs. the colored + // text used by version pills below) so the source name reads + // crisply without feeling dimmed. The accent still shows in + // the border / subtle background tint. + Pill( + text = name, + color = accents.primary, + mono = mono, + cornerSmall = cornerSmall, + textColor = MaterialTheme.colorScheme.onSurface, + borderAlpha = 0.45f, + backgroundAlpha = 0.10f, + ) + } + } + } + + if (otherStable.isNotEmpty()) { + SectionLabel(text = "ALSO STABLE", color = accents.secondary, mono = mono) + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + otherStable.take(maxPills).forEach { v -> + Pill(text = v, color = accents.secondary, mono = mono, cornerSmall = cornerSmall) + } + if (otherStable.size > maxPills) { + Text( + text = "+${otherStable.size - maxPills}", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + ) + } + } + } + + if (app.experimentalVersions.isNotEmpty()) { + SectionLabel(text = "EXPERIMENTAL", color = accents.warning, mono = mono) + androidx.compose.foundation.layout.FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + app.experimentalVersions.take(maxPills).forEach { v -> + Pill(text = v, color = accents.warning, mono = mono, cornerSmall = cornerSmall) + } + if (app.experimentalVersions.size > maxPills) { + Text( + text = "+${app.experimentalVersions.size - maxPills}", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + ) + } + } + } + } +} + +@Composable +private fun SectionLabel( + text: String, + color: Color, + mono: androidx.compose.ui.text.font.FontFamily, +) { + Text( + text = text, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 1.2.sp, + color = color.copy(alpha = 0.85f), + ) +} + +@Composable +private fun Pill( + text: String, + color: Color, + mono: androidx.compose.ui.text.font.FontFamily, + cornerSmall: androidx.compose.ui.unit.Dp, + textColor: Color = color, + borderAlpha: Float = 0.3f, + backgroundAlpha: Float = 0.06f, +) { + Box( + modifier = Modifier + .border(1.dp, color.copy(alpha = borderAlpha), RoundedCornerShape(cornerSmall)) + .background(color.copy(alpha = backgroundAlpha), RoundedCornerShape(cornerSmall)) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = text, + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = textColor, + maxLines = 1, + ) + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt index 91345b5f..4a2b905d 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionScreen.kt @@ -79,15 +79,25 @@ import java.io.File data class PatchSelectionScreen( val apkPath: String, val apkName: String, + /** Primary .mpp file path. Always non-null. In multi-source mode, the first + * enabled source's file. Used for legacy/single-source code paths and as + * the default when [patchesFilePaths] is empty. */ val patchesFilePath: String, val packageName: String, - val apkArchitectures: List = emptyList() + val apkArchitectures: List = emptyList(), + /** All enabled-source .mpp file paths. Single-element in single-source mode. + * Used by the patching pipeline to feed the engine the union of patches. */ + val patchesFilePaths: List = emptyList(), + /** Parallel to [patchesFilePaths] — display name per source. Drives badging + * in the patch list. Empty disables badging (legacy single-source). */ + val patchSourceNames: List = emptyList(), ) : Screen { @Composable override fun Content() { + val effectiveList = patchesFilePaths.takeIf { it.isNotEmpty() } ?: listOf(patchesFilePath) val viewModel = koinScreenModel { - parametersOf(apkPath, apkName, patchesFilePath, packageName, apkArchitectures) + parametersOf(apkPath, apkName, patchesFilePath, packageName, apkArchitectures, effectiveList, patchSourceNames) } PatchSelectionScreenContent(viewModel = viewModel) } @@ -438,6 +448,7 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { patch = patch, isSelected = uiState.selectedPatches.contains(patch.uniqueId), onToggle = { viewModel.togglePatch(patch.uniqueId) }, + sourceName = viewModel.getSourceNameFor(patch.uniqueId), getOptionValue = { optionKey, default -> viewModel.getOptionValue(patch.name, optionKey, default) }, @@ -660,6 +671,7 @@ private fun PatchListItem( patch: Patch, isSelected: Boolean, onToggle: () -> Unit, + sourceName: String? = null, getOptionValue: (optionKey: String, default: String?) -> String = { _, d -> d ?: "" }, onOptionValueChange: (optionKey: String, value: String) -> Unit = { _, _ -> } ) { @@ -747,6 +759,32 @@ private fun PatchListItem( modifier = Modifier.weight(1f, fill = false) ) + if (sourceName != null) { + Box( + modifier = Modifier + .border( + 1.dp, + accents.primary.copy(alpha = 0.3f), + RoundedCornerShape(corners.small) + ) + .background( + accents.primary.copy(alpha = 0.06f), + RoundedCornerShape(corners.small) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = sourceName.uppercase(), + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = accents.primary, + maxLines = 1, + ) + } + } + if (patch.compatiblePackages.isNotEmpty()) { val genericSegments = setOf("com", "org", "net", "android", "google", "apps", "app", "www") patch.compatiblePackages.take(2).forEach { pkg -> diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt index ce2e1e3e..087c17d6 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchSelectionViewModel.kt @@ -22,6 +22,9 @@ import app.morphe.gui.util.PatchService import app.morphe.gui.data.repository.PatchRepository import app.morphe.gui.util.FileUtils.ANDROID_ARCHITECTURES import app.morphe.patcher.resource.CpuArchitecture +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import java.io.File class PatchSelectionViewModel( @@ -35,11 +38,18 @@ class PatchSelectionViewModel( private val configRepository: ConfigRepository, private val preferencesRepository: PatchPreferencesRepository, private val patchSourceName: String, - private val localPatchFilePath: String? = null + private val localPatchFilePath: String? = null, + /** All enabled-source .mpp file paths. Single-element in single-source mode. */ + private val patchesFilePaths: List = listOf(patchesFilePath), + /** Parallel to [patchesFilePaths] — display name of each source. Used to badge + * patches in the list. Empty / mismatched length disables badging. */ + private val patchSourceNames: List = emptyList(), ) : ScreenModel { - // Actual path to use - may differ from patchesFilePath if we had to re-download + // Actual path to use for the primary file - may differ from patchesFilePath if we had to re-download private var actualPatchesFilePath: String = patchesFilePath + // All resolved file paths — drives multi-source patching when invoking the engine. + private var actualPatchesFilePaths: List = patchesFilePaths // User-configured output folder; null means save next to the input APK. private var defaultOutputDirectory: String? = null @@ -88,10 +98,16 @@ class PatchSelectionViewModel( return@launch } actualPatchesFilePath = downloadResult.getOrNull()!!.absolutePath + // Mirror the swap into the multi-paths list so the union load uses + // the freshly-downloaded file rather than the missing one. + actualPatchesFilePaths = actualPatchesFilePaths.map { + if (it == patchesFilePath) actualPatchesFilePath else it + } } - // Load patches using PatchService (direct library call) - val patchesResult = patchService.listPatches(actualPatchesFilePath, packageName.ifEmpty { null }) + // Load patches from all enabled-source files in parallel and union. + // Single-source case = identical to today (one file, one call). + val patchesResult = loadFromAllPaths() patchesResult.fold( onSuccess = { patches -> @@ -160,6 +176,46 @@ class PatchSelectionViewModel( } } + /** + * Load patches from every resolved enabled-source file in parallel and union + * the results. Single-element [actualPatchesFilePaths] reduces to one + * [PatchService.listPatches] call — identical behavior to the pre-multi-source + * version. Errors from individual files are surfaced only if EVERY file fails. + * + * Side effect: populates [_patchToSourceName] so the UI can badge each patch + * with its source. + */ + private suspend fun loadFromAllPaths(): Result> = coroutineScope { + val pkgFilter = packageName.ifEmpty { null } + val perFile = actualPatchesFilePaths.mapIndexed { idx, path -> + async { idx to patchService.listPatches(path, pkgFilter) } + }.awaitAll() + + // Tag each patch with its source name (falls back to "" if names not provided). + val tagging = mutableMapOf() + for ((idx, result) in perFile) { + val sourceName = patchSourceNames.getOrNull(idx) ?: continue + result.getOrNull()?.forEach { p -> tagging[p.uniqueId] = sourceName } + } + _patchToSourceName = tagging + + val allPatches = perFile.flatMap { (_, r) -> r.getOrNull().orEmpty() } + if (allPatches.isEmpty()) { + val firstError = perFile.firstNotNullOfOrNull { (_, r) -> r.exceptionOrNull() } + return@coroutineScope if (firstError != null) { + Result.failure(firstError) + } else { + Result.success(emptyList()) + } + } + Result.success(allPatches) + } + + /** patch.uniqueId → source display name. Empty in single-source mode. */ + private var _patchToSourceName: Map = emptyMap() + fun getSourceNameFor(patchId: String): String? = + _patchToSourceName[patchId]?.takeIf { _patchToSourceName.values.toSet().size > 1 } + fun togglePatch(patchId: String) { val current = _uiState.value.selectedPatches val newSelection = if (current.contains(patchId)) { @@ -292,6 +348,11 @@ class PatchSelectionViewModel( /** * Persist the current selection + option values as the user's saved preference * for this source+package. Called from createPatchConfig (auto-save on Patch click). + * + * TODO(multi-source): in multi-source mode this currently saves all selections + * under the primary [patchSourceName]. Once task 15 attaches sourceId to each + * GUI Patch, split [selectedPatches] by source and save each subset under its + * own source key (preferences repo already supports per-source schema). */ private fun saveCurrentSelection() { val state = _uiState.value @@ -370,7 +431,7 @@ class PatchSelectionViewModel( return PatchConfig( inputApkPath = apkPath, outputApkPath = outputPath, - patchesFilePath = actualPatchesFilePath, + patchesFilePaths = actualPatchesFilePaths, enabledPatches = selectedPatchNames, disabledPatches = disabledPatchNames, patchOptions = _uiState.value.patchOptionValues, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt index da8940c4..40a35446 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesViewModel.kt @@ -81,9 +81,11 @@ class PatchesViewModel( val stableReleases = releases.filter { !it.isDevRelease() } val devReleases = releases.filter { it.isDevRelease() } - // Check config for previously selected version - val config = configRepository.loadConfig() - val savedVersion = config.lastPatchesVersion + // Check config for previously selected version FOR THIS SOURCE + val activeSourceId = patchSourceManager?.getActiveSource()?.id + val savedVersion = activeSourceId?.let { + configRepository.getLastPatchesVersionsBySource()[it] + } // Find the saved release, or fall back to latest stable val initialRelease = if (savedVersion != null) { @@ -134,8 +136,10 @@ class PatchesViewModel( parseVersionParts(version) .fold(0L) { acc, part -> acc * 10000 + part } } - val config = configRepository.loadConfig() - val savedVersion = config.lastPatchesVersion + val activeSourceId = patchSourceManager?.getActiveSource()?.id + val savedVersion = activeSourceId?.let { + configRepository.getLastPatchesVersionsBySource()[it] + } // Pre-select the saved version, or fall back to the first (most recent) val initialRelease = if (savedVersion != null) { @@ -297,9 +301,13 @@ class PatchesViewModel( ) Logger.info("Patches downloaded: ${patchFile.absolutePath}") - // Save the selected version to config so HomeScreen can pick it up - configRepository.setLastPatchesVersion(release.tagName) - Logger.info("Saved selected patches version to config: ${release.tagName}") + // Save the selected version PER SOURCE so HomeScreen can pick it up + // without contaminating other enabled sources. + val activeSourceId = patchSourceManager?.getActiveSource()?.id + if (activeSourceId != null) { + configRepository.setLastPatchesVersionForSource(activeSourceId, release.tagName) + Logger.info("Saved selected patches version for source '$activeSourceId': ${release.tagName}") + } }, onFailure = { e -> _uiState.value = _uiState.value.copy( @@ -323,8 +331,11 @@ class PatchesViewModel( fun confirmSelection() { val release = _uiState.value.selectedRelease ?: return screenModelScope.launch { - configRepository.setLastPatchesVersion(release.tagName) - Logger.info("Confirmed patches selection: ${release.tagName}") + val activeSourceId = patchSourceManager?.getActiveSource()?.id + if (activeSourceId != null) { + configRepository.setLastPatchesVersionForSource(activeSourceId, release.tagName) + Logger.info("Confirmed patches selection for source '$activeSourceId': ${release.tagName}") + } } } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt index 386c17c5..0d4f1cb8 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt @@ -63,7 +63,7 @@ class PatchingViewModel( // Use PatchService for direct library patching val result = patchService.patch( - patchesFilePath = config.patchesFilePath, + patchesFilePaths = config.patchesFilePaths, inputApkPath = config.inputApkPath, outputApkPath = config.outputApkPath, enabledPatches = config.enabledPatches, diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt index eeb1bafb..0354e368 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchScreen.kt @@ -43,7 +43,10 @@ import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchSourceManager +import androidx.compose.ui.input.pointer.pointerHoverIcon import app.morphe.gui.ui.components.OfflineBanner +import app.morphe.gui.ui.components.SourceManagementSheet +import app.morphe.gui.ui.components.SourceSheetMode import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.components.morpheScrollbarStyle import app.morphe.gui.ui.screens.home.components.FullScreenDropZone @@ -84,6 +87,21 @@ class QuickPatchScreen : Screen { fun QuickPatchContent(viewModel: QuickPatchViewModel) { val uiState by viewModel.uiState.collectAsState() + // Source picker state — Quick Patch is single-source by design. The picker + // uses the same SourceManagementSheet as Expert mode but in SINGLE_SELECT + // mode (radio behavior). Users can also add/edit/remove sources from here, + // matching morphe-manager which doesn't gate source management on expert mode. + val patchSourceManager: PatchSourceManager = koinInject() + val allSources by patchSourceManager.allSources.collectAsState() + val pickerScope = rememberCoroutineScope() + var showSourcePicker by remember { mutableStateOf(false) } + var activeSourceId by remember { mutableStateOf(null) } + LaunchedEffect(uiState.patchSourceName, allSources) { + // Resolve the current active source's id by name for radio selection. + activeSourceId = allSources.firstOrNull { it.name == uiState.patchSourceName }?.id + ?: patchSourceManager.getActiveSource().id + } + val corners = LocalMorpheCorners.current val mono = LocalMorpheFont.current val accents = LocalMorpheAccents.current @@ -93,6 +111,26 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { var trailingWidthPx by remember { mutableIntStateOf(0) } val centerSidePadding = with(density) { maxOf(leadingWidthPx, trailingWidthPx).toDp() } + 16.dp + if (showSourcePicker) { + SourceManagementSheet( + sources = allSources, + mode = SourceSheetMode.SINGLE_SELECT, + activeSourceId = activeSourceId, + onSelectSingle = { id -> + showSourcePicker = false + pickerScope.launch { patchSourceManager.switchSource(id) } + }, + onToggleEnabled = { _, _ -> /* no-op in SINGLE_SELECT mode */ }, + onAdd = { src -> pickerScope.launch { patchSourceManager.addSource(src) } }, + onEdit = { src -> pickerScope.launch { patchSourceManager.updateSource(src) } }, + onRemove = { id -> pickerScope.launch { patchSourceManager.removeSource(id) } }, + onOpenPatches = { /* unused in SINGLE_SELECT mode */ }, + onDismiss = { showSourcePicker = false }, + enabled = uiState.phase != QuickPatchPhase.DOWNLOADING && + uiState.phase != QuickPatchPhase.PATCHING, + ) + } + FullScreenDropZone( isDragHovering = uiState.isDragHovering, onDragHoverChange = { viewModel.setDragHover(it) }, @@ -134,7 +172,9 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { BrandingLogo() } - // Patches version badge — centered + // Patches version badge — centered. Click opens the source-management + // sheet in SINGLE_SELECT mode so the user can pick which source Quick + // Patch uses (and add/edit/remove sources too). Box( modifier = Modifier .align(Alignment.Center) @@ -147,7 +187,8 @@ fun QuickPatchContent(viewModel: QuickPatchViewModel) { latestLabel = if (uiState.patchesVersion != null && uiState.patchesVersion == uiState.latestPatchesVersion) { "LATEST STABLE" - } else null + } else null, + onClick = { showSourcePicker = true }, ) } @@ -361,10 +402,12 @@ private fun PatchesVersionBadge( isLoading: Boolean, patchSourceName: String? = null, latestLabel: String? = null, + onClick: (() -> Unit)? = null, ) { val mono = LocalMorpheFont.current val corners = LocalMorpheCorners.current val accents = LocalMorpheAccents.current + val interactive = onClick != null if (isLoading) { Row( @@ -398,7 +441,13 @@ private fun PatchesVersionBadge( .clip(RoundedCornerShape(corners.small)) .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) .background(MaterialTheme.colorScheme.surface) - .padding(start = 12.dp, end = 4.dp), + .then( + if (interactive) Modifier + .pointerHoverIcon(androidx.compose.ui.input.pointer.PointerIcon.Hand) + .clickable(onClick = onClick!!) + else Modifier + ) + .padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Text( diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt index 20bf8b13..1fb1c7f5 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/quick/QuickPatchViewModel.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import net.dongliu.apk.parser.ApkFile import app.morphe.gui.util.ChecksumStatus +import app.morphe.gui.util.EnabledSourcesLoader import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService @@ -55,6 +56,20 @@ class QuickPatchViewModel( private var cachedPatches: List = emptyList() private var cachedSupportedApps: List = emptyList() private var cachedPatchesFile: File? = null + /** All successfully-resolved patch files across enabled sources. Single-element + * in single-source mode. Used by the patching call to feed the engine the + * union of patches when multiple sources are enabled. */ + private var cachedAllPatchFiles: List = emptyList() + + private fun currentResolvedPatchFiles(): List = + cachedAllPatchFiles.takeIf { it.isNotEmpty() } + ?: listOfNotNull(cachedPatchesFile) + + /** Snapshot of the most recent multi-source load. Used by the QuickPatchScreen + * header to render the same SourcesCountPill as Expert mode (no click action + * in Quick Patch — sources are managed only from Expert mode). */ + fun getResolvedSourcesSnapshot(): EnabledSourcesLoader.Result? = cachedSourcesResult + private var cachedSourcesResult: EnabledSourcesLoader.Result? = null init { // Load patches on startup to get dynamic app info @@ -132,170 +147,81 @@ class QuickPatchViewModel( } /** - * Load patches from GitHub and extract supported apps dynamically. + * Load patches from all enabled sources via [EnabledSourcesLoader] and build + * the union supported-apps list. Single-source case (default) produces output + * equivalent to the pre-multi-source flow. */ private fun loadPatchesAndSupportedApps() { loadJob?.cancel() loadJob = screenModelScope.launch { _uiState.value = _uiState.value.copy(isLoadingPatches = true, patchLoadError = null) - // LOCAL source: skip GitHub entirely, load directly from the .mpp file - if (localPatchFilePath != null) { - val localFile = File(localPatchFilePath) - if (localFile.exists()) { - loadPatchesFromFile(localFile, localFile.nameWithoutExtension, isOffline = false) - } else { + try { + // Quick Patch is intentionally single-source — multi-source belongs in + // Expert mode. The user picks WHICH single source via the source-picker + // sheet, which calls patchSourceManager.switchSource and updates + // activePatchSourceId. Quick Patch loads only that source regardless of + // Expert's enabled flags — the two modes operate independently. + val activeSource = patchSourceManager.getActiveSource() + val activeRepo = patchSourceManager.getRepositoryForSource(activeSource) + val pair: Pair = + activeSource to activeRepo + + val result = EnabledSourcesLoader.loadAll(listOf(pair), patchService) + + if (!result.anyLoaded) { + val firstError = result.resolved.firstNotNullOfOrNull { it.error } + ?: result.loaded.perSource.firstNotNullOfOrNull { it.error?.message } + ?: "Could not load any patches" _uiState.value = _uiState.value.copy( isLoadingPatches = false, - patchLoadError = "Local patch file not found: ${localFile.name}" + patchLoadError = firstError ) - } - return@launch - } - - try { - // Fetch releases - val releasesResult = patchRepository.fetchReleases() - val releases = releasesResult.getOrNull() - - if (releases.isNullOrEmpty()) { - // Try to fall back to cached .mpp file when offline - val config = configRepository.loadConfig() - val offlinePatchFile = findCachedPatchFile(config.lastPatchesVersion) - if (offlinePatchFile != null) { - loadPatchesFromFile(offlinePatchFile, versionFromFilename(offlinePatchFile)) - return@launch - } - Logger.warn("Quick mode: Could not fetch releases") - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Could not fetch releases. Check your internet connection.") return@launch } - // Quick mode always uses the latest stable release - val release = releases.firstOrNull { !it.isDevRelease() } - - if (release == null) { - Logger.warn("Quick mode: No suitable release found") - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "No suitable release found") - return@launch - } - - // Download patches - val patchFileResult = patchRepository.downloadPatches(release) - val patchFile = patchFileResult.getOrNull() - - if (patchFile == null) { - Logger.warn("Quick mode: Could not download patches") - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Could not download patches") - return@launch - } - - cachedPatchesFile = patchFile - - // Load patches using PatchService (direct library call) - val patchesResult = patchService.listPatches(patchFile.absolutePath) - val patches = patchesResult.getOrNull() - - if (patches.isNullOrEmpty()) { - Logger.warn("Quick mode: Could not load patches: ${patchesResult.exceptionOrNull()?.message}") - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Could not load patches") - return@launch - } - - cachedPatches = patches - - // Extract supported apps dynamically - val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) + val supportedApps = SupportedAppExtractor.extractSupportedApps(result.unionGuiPatches) + cachedPatches = result.unionGuiPatches cachedSupportedApps = supportedApps + val firstResolved = result.resolved.firstOrNull { it.patchFile != null } + cachedPatchesFile = firstResolved?.patchFile + cachedAllPatchFiles = result.resolved.mapNotNull { it.patchFile } + cachedSourcesResult = result + + Logger.info( + "Quick mode: Loaded ${supportedApps.size} supported apps from " + + "${result.resolved.count { it.patchFile != null }} source(s)" + ) - Logger.info("Quick mode: Loaded ${supportedApps.size} supported apps: ${supportedApps.map { "${it.displayName} (${it.recommendedVersion})" }}") + // Multi-source: only flag offline when EVERY resolved source is offline. + val resolvedSources = result.resolved.filter { it.patchFile != null } + val isOffline = resolvedSources.isNotEmpty() && resolvedSources.all { it.isOffline } + val displayVersion = firstResolved?.resolvedVersion + val sourceName = if (result.resolved.size == 1) { + firstResolved?.source?.name ?: patchSourceManager.getActiveSourceName() + } else { + "${result.resolved.count { it.patchFile != null }} sources" + } _uiState.value = _uiState.value.copy( isLoadingPatches = false, supportedApps = supportedApps, - patchesVersion = release.tagName, - latestPatchesVersion = release.tagName, - patchSourceName = patchSourceManager.getActiveSourceName(), + patchesVersion = displayVersion, + latestPatchesVersion = displayVersion, + patchSourceName = sourceName, patchLoadError = null, - isOffline = false + isOffline = isOffline ) } catch (e: Exception) { Logger.error("Quick mode: Failed to load patches", e) - // Try to fall back to cached .mpp file - val config = configRepository.loadConfig() - val offlinePatchFile = findCachedPatchFile(config.lastPatchesVersion) - if (offlinePatchFile != null) { - try { - loadPatchesFromFile(offlinePatchFile, versionFromFilename(offlinePatchFile)) - return@launch - } catch (inner: Exception) { - Logger.error("Quick mode: Failed to load cached patches fallback", inner) - } - } - _uiState.value = _uiState.value.copy(isLoadingPatches = false, patchLoadError = "Failed to load patches: ${e.message}") + _uiState.value = _uiState.value.copy( + isLoadingPatches = false, + patchLoadError = "Failed to load patches: ${e.message}" + ) } } } - /** - * Find any cached .mpp file when offline. - * Searches the per-source cache directory. - */ - private fun findCachedPatchFile(savedVersion: String?): File? { - val patchesDir = patchRepository.getCacheDir() - val patchFiles = patchesDir.listFiles { file -> - val ext = file.extension.lowercase() - ext == "mpp" || ext == "jar" - }?.filter { it.length() > 0 } ?: return null - - if (patchFiles.isEmpty()) return null - - return if (savedVersion != null) { - patchFiles.firstOrNull { it.name.contains(savedVersion, ignoreCase = true) } - ?: patchFiles.maxByOrNull { it.lastModified() } - } else { - patchFiles.maxByOrNull { it.lastModified() } - } - } - - private fun versionFromFilename(file: File): String { - val name = file.nameWithoutExtension - val match = Regex("""v?(\d+\.\d+\.\d+[^\s]*)""").find(name) - return match?.value ?: name - } - - /** - * Load patches from a local .mpp file (offline fallback). - */ - private suspend fun loadPatchesFromFile(patchFile: File, version: String, isOffline: Boolean = true) { - cachedPatchesFile = patchFile - - val patchesResult = patchService.listPatches(patchFile.absolutePath) - val patches = patchesResult.getOrNull() - - if (patches.isNullOrEmpty()) { - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - patchLoadError = "Could not load patches: ${patchesResult.exceptionOrNull()?.message}" - ) - return - } - - cachedPatches = patches - val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) - cachedSupportedApps = supportedApps - Logger.info("Quick mode: Loaded ${supportedApps.size} supported apps from ${if (isOffline) "cached" else "local"} patches: ${patchFile.name}") - - _uiState.value = _uiState.value.copy( - isLoadingPatches = false, - supportedApps = supportedApps, - patchesVersion = version, - patchSourceName = patchSourceManager.getActiveSourceName(), - patchLoadError = null, - isOffline = isOffline - ) - } - /** * Retry loading patches after a failure. */ @@ -540,7 +466,7 @@ class QuickPatchViewModel( // Use PatchService for direct library patching (no CLI subprocess) // exclusiveMode = false means the library's patch.use field determines defaults val patchResult = patchService.patch( - patchesFilePath = patchFile.absolutePath, + patchesFilePaths = currentResolvedPatchFiles().map { it.absolutePath }, inputApkPath = apkFile.absolutePath, outputApkPath = outputPath, enabledPatches = emptyList(), diff --git a/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt b/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt index da3715bc..df2f8dc9 100644 --- a/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt +++ b/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt @@ -95,6 +95,14 @@ private val MatchaAccents = MorpheAccentColors( warning = Color(0xFFB77833), // Toasted ochre ) +/** Deepspace — high-saturation cyan on near-black. Cyberdeck/dev-tool aesthetic. */ +private val DeepspaceAccents = MorpheAccentColors( + primary = Color(0xFF00D9FF), // Electric cyan — primary + secondary = Color(0xFF79E3A5), // Mint green — stable / success + tertiary = Color(0xFF7AB7FF), // Cool blue — structural + warning = Color(0xFFFFB347), // Warm amber — older / warning +) + // ════════════════════════════════════════════════════════════════════ // CORNER / SHAPE STYLE SYSTEM // ════════════════════════════════════════════════════════════════════ @@ -111,6 +119,24 @@ data class MorpheCornerStyle( val LocalMorpheCorners = compositionLocalOf { MorpheCornerStyle() } +/** + * Canonical control sizing across the app. Use these instead of hardcoded `.dp` + * values for buttons, text fields, search bars, and dialog action rows so the + * same dimensions apply everywhere — no per-screen drift. + * + * - [controlHeight]: standard interactive height (buttons, text fields, pills, + * search bars). Matches the height of OPEN LOGS / OPEN APP DATA action buttons. + * - [iconInControl]: icon size used inside controlHeight-sized affordances. + * - [controlHorizontalPadding]: standard horizontal padding inside a control. + */ +data class MorpheDimens( + val controlHeight: Dp = 36.dp, + val iconInControl: Dp = 14.dp, + val controlHorizontalPadding: Dp = 12.dp, +) + +val LocalMorpheDimens = compositionLocalOf { MorpheDimens() } + /** Sharp corners for cyberdeck/dev themes. */ private val SharpCorners = MorpheCornerStyle(small = 2.dp, medium = 2.dp, large = 2.dp) @@ -248,6 +274,25 @@ private val MatchaColorScheme = lightColorScheme( onError = Color.White ) +// ── Deepspace ── +// Cyberdeck dev-tool aesthetic: electric cyan + mint on near-black blue. +private val DeepspaceColorScheme = darkColorScheme( + primary = Color(0xFF00D9FF), // Electric cyan + secondary = Color(0xFF79E3A5), // Mint green + tertiary = Color(0xFF7AB7FF), // Cool blue + background = Color(0xFF0D1117), // Near-black blue + surface = Color(0xFF14191F), // Slightly raised + surfaceVariant = Color(0xFF1B2128), // Card surfaces + onPrimary = Color(0xFF001A22), // Deep cyan-black for high contrast on cyan + onSecondary = Color(0xFF0A2317), // Deep green-black on mint + onTertiary = Color(0xFF051628), // Deep blue-black + onBackground = Color(0xFFD6DEEB), // Warm light text + onSurface = Color(0xFFD6DEEB), + onSurfaceVariant = Color(0xFF8E97A6), // Muted text + error = Color(0xFFFF6B6B), + onError = Color(0xFF1E0707), +) + // ════════════════════════════════════════════════════════════════════ // THEME PREFERENCE // ════════════════════════════════════════════════════════════════════ @@ -260,11 +305,12 @@ enum class ThemePreference { CATPPUCCIN, SAKURA, MATCHA, + DEEPSPACE, SYSTEM; /** Whether this theme uses dark color scheme (for resource qualifiers). */ fun isDark(): Boolean = when (this) { - DARK, AMOLED, NORD, CATPPUCCIN -> true + DARK, AMOLED, NORD, CATPPUCCIN, DEEPSPACE -> true LIGHT, SAKURA, MATCHA -> false SYSTEM -> false // caller should check isSystemInDarkTheme() } @@ -293,6 +339,7 @@ fun MorpheTheme( ThemePreference.CATPPUCCIN -> CatppuccinMochaColorScheme ThemePreference.SAKURA -> SakuraColorScheme ThemePreference.MATCHA -> MatchaColorScheme + ThemePreference.DEEPSPACE -> DeepspaceColorScheme ThemePreference.SYSTEM -> { if (isSystemInDarkTheme()) MorpheDarkColorScheme else MorpheLightColorScheme } @@ -308,13 +355,15 @@ fun MorpheTheme( ThemePreference.CATPPUCCIN -> CatppuccinAccents ThemePreference.SAKURA -> SakuraAccents ThemePreference.MATCHA -> MatchaAccents + ThemePreference.DEEPSPACE -> DeepspaceAccents ThemePreference.SYSTEM -> if (isSystemInDarkTheme()) DarkAccents else LightAccents } CompositionLocalProvider( LocalMorpheCorners provides corners, LocalMorpheFont provides font, - LocalMorpheAccents provides accents + LocalMorpheAccents provides accents, + LocalMorpheDimens provides MorpheDimens(), ) { MaterialTheme( colorScheme = colorScheme, diff --git a/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt new file mode 100644 index 00000000..b94de74f --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/EnabledSourcesLoader.kt @@ -0,0 +1,217 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.util + +import app.morphe.engine.MultiSourceLoader +import app.morphe.gui.data.model.PatchSource +import app.morphe.gui.data.model.PatchSourceType +import app.morphe.gui.data.repository.PatchRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import java.io.File + +/** + * GUI-side orchestrator that resolves each enabled patch source to a downloaded + * `.mpp` file (LOCAL = read filePath, GITHUB = fetch latest release + download) + * in parallel, then hands the resulting files to [MultiSourceLoader] for the + * actual patch loading + union. + * + * The single-source case (one enabled source) produces output equivalent to the + * pre-multi-source per-ViewModel flow. Per-source version pinning via + * [preferredVersionsBySource] keeps each source independent — picking a tag in + * one source's PatchesScreen does NOT contaminate other sources. + */ +object EnabledSourcesLoader { + + /** + * Per-source resolution result before patch-loading. Successful sources have + * a [patchFile]; failed ones have an [error] message and the UI can render + * the failure inline. + */ + /** What channel the resolved release is on. Used by the home pill LEDs and + * the sheet's channel badge so we don't keep re-deriving from tag strings. */ + enum class Channel { STABLE_LATEST, STABLE_OLDER, DEV_LATEST, DEV_OLDER, UNKNOWN } + + data class ResolvedSource( + val source: PatchSource, + val patchFile: File? = null, + val resolvedVersion: String? = null, + val isOffline: Boolean = false, + val error: String? = null, + val channel: Channel = Channel.UNKNOWN, + ) + + data class Result( + /** Resolution outcome per source (success or failure). */ + val resolved: List, + /** MultiSourceLoader output across the successfully-resolved sources. */ + val loaded: MultiSourceLoader.Result, + /** Union of GUI patches across all sources, for SupportedAppExtractor / UI. */ + val unionGuiPatches: List, + /** GUI patches grouped by sourceId, for badging UI in PatchSelectionScreen. */ + val guiPatchesBySource: Map>, + ) { + val anyLoaded: Boolean get() = loaded.allPatches.isNotEmpty() + val anyFailed: Boolean get() = resolved.any { it.error != null } || loaded.hasErrors + } + + /** + * Resolve and load every enabled source in parallel. + * + * @param enabled list of (source, repository) pairs from + * [app.morphe.gui.data.repository.PatchSourceManager.getEnabledRepositories]. + * Repository is null for LOCAL sources. + */ + suspend fun loadAll( + enabled: List>, + patchService: PatchService, + preferredVersionsBySource: Map = emptyMap(), + ): Result = coroutineScope { + val resolved = enabled.map { (source, repo) -> + async(Dispatchers.IO) { resolve(source, repo, preferredVersionsBySource[source.id]) } + }.awaitAll() + + val inputs = resolved.mapNotNull { res -> + val file = res.patchFile ?: return@mapNotNull null + MultiSourceLoader.SourceInput( + sourceId = res.source.id, + sourceName = res.source.name, + patchFile = file, + ) + } + + val loaded = if (inputs.isEmpty()) { + MultiSourceLoader.Result( + perSource = emptyList(), + allPatches = emptySet(), + patchToSourceId = emptyMap(), + ) + } else { + MultiSourceLoader.load(inputs) + } + + // Convert library patches → GUI patches once. Both the union and per-source + // groupings are derived from this single conversion. + val unionGui = patchService.convertToGuiPatches(loaded.allPatches) + val guiBySource: Map> = + loaded.perSource.associate { src -> + src.sourceId to patchService.convertToGuiPatches(src.patches) + } + + Result( + resolved = resolved, + loaded = loaded, + unionGuiPatches = unionGui, + guiPatchesBySource = guiBySource, + ) + } + + private suspend fun resolve( + source: PatchSource, + repo: PatchRepository?, + preferredVersion: String?, + ): ResolvedSource = withContext(Dispatchers.IO) { + when (source.type) { + PatchSourceType.LOCAL -> resolveLocal(source) + PatchSourceType.DEFAULT, PatchSourceType.GITHUB -> resolveGithub(source, repo, preferredVersion) + } + } + + private fun resolveLocal(source: PatchSource): ResolvedSource { + val path = source.filePath + if (path.isNullOrBlank()) { + return ResolvedSource(source = source, error = "Local source has no file path configured") + } + val file = File(path) + if (!file.exists()) { + return ResolvedSource(source = source, error = "Local patch file not found: ${file.name}") + } + return ResolvedSource( + source = source, + patchFile = file, + resolvedVersion = file.nameWithoutExtension, + isOffline = false, + ) + } + + private suspend fun resolveGithub( + source: PatchSource, + repo: PatchRepository?, + preferredVersion: String?, + ): ResolvedSource { + if (repo == null) { + return ResolvedSource(source = source, error = "No repository configured for source") + } + + val releasesResult = repo.fetchReleases() + val releases = releasesResult.getOrNull() + + if (releases.isNullOrEmpty()) { + // Offline fallback: scan source's cache dir for any .mpp/.jar file + val cached = findCachedPatchFile(repo) + if (cached != null) { + return ResolvedSource( + source = source, + patchFile = cached, + resolvedVersion = versionFromFilename(cached), + isOffline = true, + ) + } + val errMsg = releasesResult.exceptionOrNull()?.message ?: "Could not fetch releases" + return ResolvedSource(source = source, error = errMsg) + } + + // Honor a user-pinned version if it exists in this source's releases. + // Otherwise pick latest stable, falling back to latest dev. + val release = preferredVersion + ?.let { pinned -> releases.find { it.tagName == pinned } } + ?: releases.firstOrNull { !it.isDevRelease() } + ?: releases.firstOrNull() + ?: return ResolvedSource(source = source, error = "No releases found") + + // Classify against this source's release list so the LED + badge can + // distinguish "latest stable" from "older stable" from "dev". + val latestStableTag = releases.firstOrNull { !it.isDevRelease() }?.tagName + val latestDevTag = releases.firstOrNull { it.isDevRelease() }?.tagName + val channel = when { + release.isDevRelease() && release.tagName == latestDevTag -> Channel.DEV_LATEST + release.isDevRelease() -> Channel.DEV_OLDER + release.tagName == latestStableTag -> Channel.STABLE_LATEST + else -> Channel.STABLE_OLDER + } + + val downloadResult = repo.downloadPatches(release) + val patchFile = downloadResult.getOrNull() + ?: return ResolvedSource( + source = source, + error = "Could not download patches: ${downloadResult.exceptionOrNull()?.message}", + ) + + return ResolvedSource( + source = source, + patchFile = patchFile, + resolvedVersion = release.tagName, + isOffline = false, + channel = channel, + ) + } + + private fun findCachedPatchFile(repo: PatchRepository): File? { + val cacheDir = repo.getCacheDir() + return cacheDir.listFiles { file -> + val ext = file.extension.lowercase() + (ext == "mpp" || ext == "jar") && file.length() > 0 + }?.maxByOrNull { it.lastModified() } + } + + private fun versionFromFilename(file: File): String { + val match = Regex("""v?(\d+\.\d+\.\d+[^\s]*)""").find(file.nameWithoutExtension) + return match?.value ?: file.nameWithoutExtension + } +} diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt index 53513874..9fd1f295 100644 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -77,7 +77,7 @@ class PatchService { * Delegates to PatchEngine for the actual pipeline. */ suspend fun patch( - patchesFilePath: String, + patchesFilePaths: List, inputApkPath: String, outputApkPath: String, enabledPatches: List = emptyList(), @@ -93,23 +93,29 @@ class PatchService { onProgress: (String) -> Unit = {} ): Result = withContext(Dispatchers.IO) { try { - val patchFile = File(patchesFilePath) + if (patchesFilePaths.isEmpty()) { + return@withContext Result.failure(Exception("No patches files supplied")) + } + val patchFiles = patchesFilePaths.map { File(it) } val inputApk = File(inputApkPath) val outputFile = File(outputApkPath) - if (!patchFile.exists()) { - return@withContext Result.failure(Exception("Patches file not found")) + patchFiles.firstOrNull { !it.exists() }?.let { + return@withContext Result.failure(Exception("Patches file not found: ${it.name}")) } if (!inputApk.exists()) { return@withContext Result.failure(Exception("Input APK not found")) } - // Load patches (copy to temp to avoid Windows file lock) + // Load patches (copy each to temp to avoid Windows file lock) onProgress("Loading patches...") - val patchTempCopy = File.createTempFile("morphe-patches-", ".mpp") + val tempCopies = patchFiles.map { src -> + val tmp = File.createTempFile("morphe-patches-", ".mpp") + src.copyTo(tmp, overwrite = true) + tmp + } try { - patchFile.copyTo(patchTempCopy, overwrite = true) - val loadedPatches = loadPatchesFromJar(setOf(patchTempCopy)) + val loadedPatches = loadPatchesFromJar(tempCopies.toSet()) // Convert GUI's flat "patchName.optionKey" -> value map // to engine's Map> format @@ -151,7 +157,7 @@ class PatchService { failedPatches = engineResult.failedPatches.map { it.name }, )) } finally { - patchTempCopy.delete() + tempCopies.forEach { runCatching { it.delete() } } } } catch (e: Exception) { Logger.error("Patching failed", e) @@ -159,25 +165,59 @@ class PatchService { } } + /** + * Convert a set of already-loaded library patches into GUI patches. + * Used by EnabledSourcesLoader / MultiSourceLoader paths so we don't have to + * re-open the .mpp file just to convert. + */ + fun convertToGuiPatches(loaded: Set>): List = + loaded.map { it.toGuiPatch() } + /** * Convert library Patch to GUI Patch model. + * + * Reads BOTH the new [compatibility] API and the deprecated [compatiblePackages] + * field — some forks (e.g. hoo-dles) compiled their patches against the older + * patcher API and only declare compatibility via the legacy field. Without the + * fallback, those patches would convert to a GUI Patch with empty + * compatiblePackages, which means SupportedAppExtractor under-counts apps and + * the per-source attribution map misses entire sources. */ + @Suppress("DEPRECATION") private fun LibraryPatch<*>.toGuiPatch(): Patch { - return Patch( - name = this.name ?: "Unknown", - description = this.description ?: "", - compatiblePackages = this.compatibility - ?.mapNotNull { compatibility -> - val packageName = compatibility.packageName ?: return@mapNotNull null - val (experimental, stable) = compatibility.targets.partition { it.isExperimental } + // Primary: new compatibility API (typed, with experimental flag, display name). + val fromNewApi: List = this.compatibility + ?.mapNotNull { compatibility -> + val packageName = compatibility.packageName ?: return@mapNotNull null + val (experimental, stable) = compatibility.targets.partition { it.isExperimental } + CompatiblePackage( + name = packageName, + displayName = compatibility.name, + versions = stable.mapNotNull { it.version }, + experimentalVersions = experimental.mapNotNull { it.version } + ) + } + ?: emptyList() + + // Fallback: legacy compatiblePackages field (Set>). + // No display name or experimental flag in the legacy schema — those stay null/empty. + val fromLegacyApi: List = if (fromNewApi.isEmpty()) { + this.compatiblePackages + ?.map { (pkgName, versions) -> CompatiblePackage( - name = packageName, - displayName = compatibility.name, - versions = stable.mapNotNull { it.version }, - experimentalVersions = experimental.mapNotNull { it.version } + name = pkgName, + displayName = null, + versions = versions?.toList() ?: emptyList(), + experimentalVersions = emptyList(), ) } - ?: emptyList(), + ?: emptyList() + } else emptyList() + + return Patch( + name = this.name ?: "Unknown", + description = this.description ?: "", + compatiblePackages = fromNewApi.ifEmpty { fromLegacyApi }, options = this.options.values.map { opt -> PatchOption( key = opt.key,