diff --git a/.github/workflows/build_pull_request.yml b/.github/workflows/build_pull_request.yml index 9e23ed2..8df70d0 100644 --- a/.github/workflows/build_pull_request.yml +++ b/.github/workflows/build_pull_request.yml @@ -14,12 +14,19 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] + steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + - name: Cache Gradle uses: burrunan/gradle-cache-action@v1 @@ -35,4 +42,3 @@ jobs: with: archive: false path: build/libs/morphe-cli-*-all.jar - diff --git a/.gitignore b/.gitignore index 0900c7f..3c72673 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ build/ # Local configuration file (sdk path, etc) local.properties +old_build.gradle.kts # Log/OS Files *.log diff --git a/build.gradle.kts b/build.gradle.kts index d6176db..923da53 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,7 +19,7 @@ group = "app.morphe" kotlin { jvmToolchain { languageVersion.set(JavaLanguageVersion.of(17)) - vendor.set(JvmVendorSpec.ADOPTIUM) + vendor.set(JvmVendorSpec.JETBRAINS) } compilerOptions { jvmTarget.set(JvmTarget.JVM_17) @@ -100,6 +100,10 @@ dependencies { implementation(libs.voyager.koin) implementation(libs.voyager.transitions) + // -- JNA (Windows DWM title bar tinting) ------------------------------- + implementation(libs.jna) + implementation(libs.jna.platform) + // -- APK Parsing (GUI) ------------------------------------------------- implementation(libs.apk.parser) @@ -154,6 +158,8 @@ tasks { exclude(dependency("io.insert-koin:.*")) // Coroutines Swing provides Dispatchers.Main via ServiceLoader exclude(dependency("org.jetbrains.kotlinx:kotlinx-coroutines-swing")) + // JNA uses reflection + native loading for DWM title bar tinting + exclude(dependency("net.java.dev.jna:.*")) } mergeServiceFiles() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6eb1934..3a0ab91 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,9 @@ voyager = "1.1.0-beta03" coroutines = "1.10.2" kotlinx-serialization = "1.9.0" +# JNA (Windows DWM title bar tinting) +jna = "5.14.0" + # APK apk-parser = "2.6.10" @@ -65,6 +68,10 @@ kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- # Serialization kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +# JNA (Windows DWM title bar tinting) +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } +jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" } + # APK apk-parser = { module = "net.dongliu:apk-parser", version.ref = "apk-parser" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 3394958..3b18cbf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,9 +1,9 @@ pluginManagement { repositories { + gradlePluginPortal() maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") google() mavenCentral() - gradlePluginPortal() } } diff --git a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt index 4f74c7c..f589fd6 100644 --- a/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/PatchCommand.kt @@ -22,6 +22,7 @@ import app.morphe.cli.command.model.withUpdatedBundle import app.morphe.engine.PatchEngine import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD +import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_SIGNER_NAME import app.morphe.engine.PatchEngine.Config.Companion.LEGACY_KEYSTORE_ALIAS import app.morphe.engine.PatchEngine.Config.Companion.LEGACY_KEYSTORE_PASSWORD import app.morphe.engine.UpdateChecker @@ -219,7 +220,7 @@ internal object PatchCommand : Callable { description = ["The name of the signer to sign the patched APK file with."], showDefaultValue = ALWAYS, ) - private var signer = "Morphe" + private var signer = DEFAULT_SIGNER_NAME @CommandLine.Option( names = ["-t", "--temporary-files-path"], diff --git a/src/main/kotlin/app/morphe/engine/PatchEngine.kt b/src/main/kotlin/app/morphe/engine/PatchEngine.kt index ec6c9fa..31f7aa2 100644 --- a/src/main/kotlin/app/morphe/engine/PatchEngine.kt +++ b/src/main/kotlin/app/morphe/engine/PatchEngine.kt @@ -50,7 +50,7 @@ object PatchEngine { val forceCompatibility: Boolean = false, val patchOptions: Map> = emptyMap(), val unsigned: Boolean = false, - val signerName: String = "Morphe", + val signerName: String = DEFAULT_SIGNER_NAME, val keystoreDetails: ApkUtils.KeyStoreDetails? = null, val architecturesToKeep: Set = emptySet(), val aaptBinaryPath: File? = null, @@ -60,6 +60,7 @@ object PatchEngine { companion object { internal const val DEFAULT_KEYSTORE_ALIAS = "Morphe" internal const val DEFAULT_KEYSTORE_PASSWORD = "Morphe" + internal const val DEFAULT_SIGNER_NAME = "Morphe" internal const val LEGACY_KEYSTORE_ALIAS = "Morphe Key" internal const val LEGACY_KEYSTORE_PASSWORD = "" } @@ -222,7 +223,19 @@ object PatchEngine { // 7. Sign APK (unless unsigned) val tempOutput = File(tempDir, config.outputApk.name) if (!config.unsigned) { - onProgress("Signing APK...") + val keystoreDetails = config.keystoreDetails ?: ApkUtils.KeyStoreDetails( + File(tempDir, "morphe.keystore"), + null, + Config.DEFAULT_KEYSTORE_ALIAS, + Config.DEFAULT_KEYSTORE_PASSWORD, + ) + + if (config.keystoreDetails != null) { + onProgress("Signing APK with custom keystore: ${keystoreDetails.keyStore.name}") + } else { + onProgress("Signing APK...") + } + try { fun signApk(details: ApkUtils.KeyStoreDetails) { ApkUtils.signApk( @@ -233,16 +246,9 @@ object PatchEngine { ) } - val keystoreDetails = config.keystoreDetails ?: ApkUtils.KeyStoreDetails( - File(tempDir, "morphe.keystore"), - null, - Config.DEFAULT_KEYSTORE_ALIAS, - Config.DEFAULT_KEYSTORE_PASSWORD, - ) - try { signApk(keystoreDetails) - } catch (e: Exception){ + } catch (e: Exception) { // Retry with legacy keystore defaults. if (config.keystoreDetails == null && keystoreDetails.keyStore.exists()) { logger.info("Using legacy keystore credentials") diff --git a/src/main/kotlin/app/morphe/gui/App.kt b/src/main/kotlin/app/morphe/gui/App.kt index 7bbca70..e56d7a8 100644 --- a/src/main/kotlin/app/morphe/gui/App.kt +++ b/src/main/kotlin/app/morphe/gui/App.kt @@ -6,15 +6,24 @@ package app.morphe.gui import androidx.compose.animation.Crossfade -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.window.WindowDraggableArea +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.unit.dp +import app.morphe.gui.ui.components.LocalFrameWindowScope +import app.morphe.gui.ui.components.LottieAnimation +import app.morphe.gui.ui.components.SakuraPetals +import app.morphe.gui.util.applyTitleBarTint import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.transitions.SlideTransition import app.morphe.gui.data.repository.ConfigRepository -import app.morphe.gui.data.repository.PatchRepository -import app.morphe.gui.util.PatchService +import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.di.appModule import kotlinx.coroutines.launch import org.koin.compose.KoinApplication @@ -22,6 +31,7 @@ import org.koin.compose.koinInject import app.morphe.gui.ui.screens.home.HomeScreen import app.morphe.gui.ui.screens.quick.QuickPatchContent import app.morphe.gui.ui.screens.quick.QuickPatchViewModel +import app.morphe.gui.util.PatchService import app.morphe.gui.ui.theme.LocalThemeState import app.morphe.gui.ui.theme.MorpheTheme import app.morphe.gui.ui.theme.ThemePreference @@ -42,7 +52,9 @@ val LocalModeState = staticCompositionLocalOf { } @Composable -fun App(initialSimplifiedMode: Boolean = true) { +fun App( + initialSimplifiedMode: Boolean = true +) { LaunchedEffect(Unit) { Logger.init() } @@ -50,23 +62,25 @@ fun App(initialSimplifiedMode: Boolean = true) { KoinApplication(application = { modules(appModule) }) { - AppContent(initialSimplifiedMode) + AppContent(initialSimplifiedMode = initialSimplifiedMode) } } @Composable -private fun AppContent(initialSimplifiedMode: Boolean) { +private fun AppContent( + initialSimplifiedMode: Boolean +) { val configRepository: ConfigRepository = koinInject() - val patchRepository: PatchRepository = koinInject() - val patchService: PatchService = koinInject() + val patchSourceManager: PatchSourceManager = koinInject() val scope = rememberCoroutineScope() var themePreference by remember { mutableStateOf(ThemePreference.SYSTEM) } var isSimplifiedMode by remember { mutableStateOf(initialSimplifiedMode) } var isLoading by remember { mutableStateOf(true) } - // Load config on startup + // Initialize PatchSourceManager and load config on startup LaunchedEffect(Unit) { + patchSourceManager.initialize() val config = configRepository.loadConfig() themePreference = config.getThemePreference() isSimplifiedMode = config.useSimplifiedMode @@ -114,25 +128,97 @@ private fun AppContent(initialSimplifiedMode: Boolean) { LocalThemeState provides themeState, LocalModeState provides modeState ) { + // Tint the OS title bar (Windows DWM caption color, macOS traffic + // light contrast) to match the active theme's surface color. + val titleBarColor = MaterialTheme.colorScheme.surface + val frameScope = LocalFrameWindowScope.current + LaunchedEffect(titleBarColor, frameScope) { + frameScope?.window?.let { applyTitleBarTint(it, titleBarColor) } + } + + // macOS only: render a 28dp colored band at the very top of the + // window, sitting underneath the (now-transparent) OS title bar. + // The traffic lights overlay this band at their default position. + // Wrapped in WindowDraggableArea so the band acts as a drag region. + val isMac = remember { + System.getProperty("os.name")?.lowercase()?.contains("mac") == true + } + Surface(modifier = Modifier.fillMaxSize()) { - if (!isLoading) { - // Create QuickPatchViewModel outside Crossfade so it persists across mode switches. - // Otherwise every expert→simplified switch creates a new VM that re-fetches from GitHub. - val quickViewModel = remember { - QuickPatchViewModel(patchRepository, patchService, configRepository) + Column(modifier = Modifier.fillMaxSize()) { + if (isMac && frameScope != null) { + with(frameScope) { + WindowDraggableArea { + Box( + modifier = Modifier + .fillMaxWidth() + .height(16.dp) + .background(titleBarColor) + ) + } + } } - Crossfade(targetState = isSimplifiedMode) { simplified -> - if (simplified) { - // Quick/Simplified mode - QuickPatchContent(quickViewModel) - } else { - // Full mode - Navigator(HomeScreen()) { navigator -> - SlideTransition(navigator) + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + if (!isLoading) { + val patchService: PatchService = koinInject() + val quickViewModel = remember { + QuickPatchViewModel(patchSourceManager, patchService, configRepository) + } + + Crossfade(targetState = isSimplifiedMode) { simplified -> + if (simplified) { + QuickPatchContent(quickViewModel) + } else { + Navigator(HomeScreen()) { navigator -> + SlideTransition(navigator) + } + } + } + } + + // Falling petals — on top of everything (Sakura) + SakuraPetals( + enabled = themePreference == ThemePreference.SAKURA + ) + + // Matcha cat — top-right corner + if (themePreference == ThemePreference.MATCHA) { + val catJson = remember { + try { + object {}.javaClass.getResourceAsStream("/cat2333s.json") + ?.bufferedReader()?.readText() + } catch (e: Exception) { + null + } + } + catJson?.let { json -> + // 1080px canvas, rendered at 350dp (1dp ≈ 3.086 canvas px). + // Ears ~y385 → 125dp, bar bottom ~y576 → 187dp. + // Body shrunk to 85% so it hides behind bar. + // Clip from 120dp to 192dp (72dp visible) — ears to just past bar. + val renderSize = 350.dp + val clipTop = 120.dp // just above ears + val clipHeight = 72.dp // ears → just past bar bottom + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 24.dp, end = 16.dp) + .requiredWidth(renderSize) + .requiredHeight(clipHeight) + .clipToBounds() + ) { + LottieAnimation( + jsonString = json, + modifier = Modifier + .requiredSize(renderSize) + .offset(y = -clipTop), + alpha = 0.28f + ) } } } + } } } } diff --git a/src/main/kotlin/app/morphe/gui/GuiMain.kt b/src/main/kotlin/app/morphe/gui/GuiMain.kt index 8109d5b..002e2c9 100644 --- a/src/main/kotlin/app/morphe/gui/GuiMain.kt +++ b/src/main/kotlin/app/morphe/gui/GuiMain.kt @@ -5,9 +5,11 @@ package app.morphe.gui +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.graphics.painter.BitmapPainter +import app.morphe.gui.ui.components.LocalFrameWindowScope import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -58,12 +60,33 @@ fun launchGui(args: Array) = application { Window( onCloseRequest = ::exitApplication, - title = "Morphe", + title = "", state = windowState, icon = appIcon ) { window.minimumSize = java.awt.Dimension(600, 400) - App(initialSimplifiedMode = initialSimplifiedMode) + + // macOS: hide the OS-drawn title bar so a Compose-rendered colored + // band can take its place. Traffic lights stay where the OS draws + // them (top-left of the client area, ~12px from each edge), and the + // colored band sits behind them. These three Apple AWT properties + // ship with every macOS JDK — no JetBrains Runtime needed. + // + // Windows / Linux: standard decorated window. The OS title bar is + // drawn above the client area as normal. On Windows, its color is + // tinted to match the active theme via DWM (see WindowTitleBarTint). + remember { + val isMac = System.getProperty("os.name")?.lowercase()?.contains("mac") == true + if (isMac) { + window.rootPane.putClientProperty("apple.awt.fullWindowContent", true) + window.rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + window.rootPane.putClientProperty("apple.awt.windowTitleVisible", false) + } + } + + CompositionLocalProvider(LocalFrameWindowScope provides this) { + App(initialSimplifiedMode = initialSimplifiedMode) + } } } @@ -119,3 +142,4 @@ private fun loadAppIcon(): BitmapPainter? { } return null } + diff --git a/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt index 6e17d4a..3fa406c 100644 --- a/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt +++ b/src/main/kotlin/app/morphe/gui/data/constants/AppConstants.kt @@ -5,6 +5,8 @@ package app.morphe.gui.data.constants +import java.util.Properties + /** * Centralized configuration for supported apps. * This file is massively outdated. Could be used for other things in the future but kinda useless now. @@ -19,7 +21,14 @@ object AppConstants { } val APP_VERSION: String by lazy { - pkg?.implementationVersion?.let { "v$it" } ?: "dev" + val resourceVersion = AppConstants::class.java + .getResourceAsStream("/app/morphe/cli/version.properties") + ?.use { stream -> + Properties().apply { load(stream) }.getProperty("version") + } + + val resolvedVersion = resourceVersion ?: pkg?.implementationVersion + resolvedVersion?.let { "v$it" } ?: "dev" } // ==================== API ==================== 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 e15a8f7..6bfae3c 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt @@ -5,12 +5,23 @@ package app.morphe.gui.data.model +import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS +import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD import kotlinx.serialization.Serializable import app.morphe.gui.ui.theme.ThemePreference /** * Application configuration stored in config.json */ + +val DEFAULT_PATCH_SOURCE = PatchSource( + id = "morphe-default", + name = "Morphe Patches", + type = PatchSourceType.DEFAULT, + url = "https://github.com/MorpheApp/morphe-patches", + deletable = false +) + @Serializable data class AppConfig( val themePreference: String = ThemePreference.SYSTEM.name, @@ -19,7 +30,13 @@ data class AppConfig( val preferredPatchChannel: String = PatchChannel.STABLE.name, val defaultOutputDirectory: String? = null, val autoCleanupTempFiles: Boolean = true, // Default ON - val useSimplifiedMode: Boolean = true // Default to Quick/Simplified mode + val useSimplifiedMode: Boolean = true, // Default to Quick/Simplified mode + val patchSource: List = listOf(DEFAULT_PATCH_SOURCE), + val activePatchSourceId: String = "morphe-default", + val keystorePath: String? = null, + val keystorePassword: String? = null, + val keystoreAlias: String = DEFAULT_KEYSTORE_ALIAS, + val keystoreEntryPassword: String = DEFAULT_KEYSTORE_PASSWORD ) { fun getThemePreference(): ThemePreference { return try { @@ -38,6 +55,21 @@ data class AppConfig( } } +@Serializable +data class PatchSource ( + val id: String, + val name: String, + 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 +) + +@Serializable +enum class PatchSourceType{ + DEFAULT, GITHUB, LOCAL +} + enum class PatchChannel { STABLE, DEV 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 b2eadb3..87e8381 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt @@ -51,7 +51,9 @@ data class Patch( @Serializable data class CompatiblePackage( val name: String, - val versions: List = emptyList() + val displayName: String? = null, + val versions: List = emptyList(), + val experimentalVersions: List = emptyList() ) @Serializable @@ -71,7 +73,8 @@ enum class PatchOptionType { INT, LONG, FLOAT, - LIST + LIST, + FILE } /** diff --git a/src/main/kotlin/app/morphe/gui/data/model/Release.kt b/src/main/kotlin/app/morphe/gui/data/model/Release.kt index 07c763f..50d5463 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/Release.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/Release.kt @@ -16,12 +16,14 @@ data class Release( val id: Long, @SerialName("tag_name") val tagName: String, - val name: String, + val name: String? = null, @SerialName("prerelease") - val isPrerelease: Boolean, + val isPrerelease: Boolean = false, val draft: Boolean = false, @SerialName("published_at") - val publishedAt: String, + val publishedAt: String? = null, + @SerialName("created_at") + val createdAt: String? = null, val assets: List = emptyList(), val body: String? = null ) { @@ -53,14 +55,9 @@ data class ReleaseAsset( val contentType: String ) { /** - * Check if this is a JAR file - */ - fun isJar(): Boolean = name.endsWith(".jar", ignoreCase = true) - - /** - * Check if this is an MPP (Morphe Patches) file + * Check if this is a patch file (.mpp) */ - fun isMpp(): Boolean = name.endsWith(".mpp", ignoreCase = true) + fun isPatchFile(): Boolean = name.endsWith(".mpp", ignoreCase = true) /** * Get human-readable file size diff --git a/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt b/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt index 81899d3..34c60a6 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt @@ -15,24 +15,41 @@ data class SupportedApp( val packageName: String, val displayName: String, val supportedVersions: List, + val experimentalVersions: List = emptyList(), val recommendedVersion: String?, - val apkDownloadUrl: String? = null + val apkDownloadUrl: String? = null, + val experimentalDownloadUrl: String? = null ) { companion object { + fun resolveDisplayName(packageName: String, providedName: String?): String { + return providedName?.takeIf { it.isNotBlank() } ?: getDisplayName(packageName) + } + /** * Derive display name from package name. */ fun getDisplayName(packageName: String): String { - return when (packageName) { - "com.google.android.youtube" -> "YouTube" - "com.google.android.apps.youtube.music" -> "YouTube Music" - "com.reddit.frontpage" -> "Reddit" - else -> { - // Fallback: Extract last part of package name and capitalize - packageName.substringAfterLast(".") - .replaceFirstChar { it.uppercase() } - } - } + // Well-known package name mappings + val knownNames = mapOf( + "com.google.android.youtube" to "YouTube", + "com.google.android.apps.youtube.music" to "YouTube Music", + "com.reddit.frontpage" to "Reddit", + ) + knownNames[packageName]?.let { return it } + + // Smart fallback: use the most meaningful part of the package name + val parts = packageName.split(".") + // Skip common prefixes: com, org, net, android, app, etc. + val skipParts = setOf("com", "org", "net", "io", "me", "app", "android", "apps", "free") + val meaningful = parts.filter { it.lowercase() !in skipParts && it.length > 1 } + // Use the last meaningful part, or the full last segment + val name = meaningful.lastOrNull() ?: parts.last() + // Split camelCase and underscores, capitalize + return name + .replace("_", " ") + .replace(Regex("([a-z])([A-Z])")) { "${it.groupValues[1]} ${it.groupValues[2]}" } + .split(" ") + .joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } } } /** 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 4a3e25d..38ee7ba 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/ConfigRepository.kt @@ -6,7 +6,9 @@ package app.morphe.gui.data.repository import app.morphe.gui.data.model.AppConfig +import app.morphe.gui.data.model.DEFAULT_PATCH_SOURCE import app.morphe.gui.data.model.PatchChannel +import app.morphe.gui.data.model.PatchSource import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -125,6 +127,90 @@ class ConfigRepository { saveConfig(current.copy(useSimplifiedMode = enabled)) } + /** + * Update keystore path only (used for auto-remember on first creation). + */ + suspend fun setKeystorePath(path: String?) { + val current = loadConfig() + saveConfig(current.copy(keystorePath = path)) + } + + /** + * Update all keystore details at once. + */ + suspend fun setKeystoreDetails( + path: String?, + password: String?, + alias: String, + entryPassword: String + ) { + val current = loadConfig() + saveConfig(current.copy( + keystorePath = path, + keystorePassword = password, + keystoreAlias = alias, + keystoreEntryPassword = entryPassword + )) + } + + /** + * Get the currently active patch source. + */ + suspend fun getActivePatchSource(): PatchSource { + val config = loadConfig() + return config.patchSource.find { it.id == config.activePatchSourceId } + ?: DEFAULT_PATCH_SOURCE + } + + /** + * Set the active patch source by ID. + */ + suspend fun setActivePatchSource(id: String) { + val current = loadConfig() + if (current.patchSource.any { it.id == id }) { + saveConfig(current.copy(activePatchSourceId = id)) + } + } + + /** + * Add a new patch source. + */ + suspend fun addPatchSource(source: PatchSource) { + val current = loadConfig() + val updated = current.copy(patchSource = current.patchSource + source) + saveConfig(updated) + } + + /** + * Update an existing patch source. Cannot update non-deletable sources. + */ + suspend fun updatePatchSource(updated: PatchSource) { + val current = loadConfig() + val existing = current.patchSource.find { it.id == updated.id } + if (existing == null || !existing.deletable) return + + val updatedSources = current.patchSource.map { if (it.id == updated.id) updated else it } + saveConfig(current.copy(patchSource = updatedSources)) + } + + /** + * Remove a patch source by ID. Cannot remove non-deletable sources. + */ + suspend fun removePatchSource(id: String) { + val current = loadConfig() + val source = current.patchSource.find { it.id == id } + if (source == null || !source.deletable) return + + val updatedSources = current.patchSource.filter { it.id != id } + // If we removed the active source, fall back to default + val newActiveId = if (current.activePatchSourceId == id) { + DEFAULT_PATCH_SOURCE.id + } else { + current.activePatchSourceId + } + saveConfig(current.copy(patchSource = updatedSources, activePatchSourceId = newActiveId)) + } + /** * Clear cached config (for testing). */ diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt index d199f21..fcd0308 100644 --- a/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchRepository.kt @@ -19,18 +19,21 @@ import app.morphe.gui.util.Logger import java.io.File /** - * Repository for fetching Morphe patches from GitHub releases. + * Repository for fetching patches from GitHub releases. + * @param repoPath GitHub repo in "owner/repo" format (e.g. "MorpheApp/morphe-patches") */ class PatchRepository( - private val httpClient: HttpClient + private val httpClient: HttpClient, + private val repoPath: String = DEFAULT_REPO ) { companion object { private const val GITHUB_API_BASE = "https://api.github.com" - private const val PATCHES_REPO = "MorpheApp/morphe-patches" - private const val RELEASES_ENDPOINT = "$GITHUB_API_BASE/repos/$PATCHES_REPO/releases" + private const val DEFAULT_REPO = "MorpheApp/morphe-patches" private const val CACHE_TTL_MS = 5 * 60 * 1000L // 5 minutes } + private val releasesEndpoint = "$GITHUB_API_BASE/repos/$repoPath/releases" + // In-memory cache so multiple callers (both modes) don't re-fetch from GitHub private var cachedReleases: List? = null private var cacheTimestamp: Long = 0L @@ -48,8 +51,8 @@ class PatchRepository( } try { - Logger.info("Fetching releases from $RELEASES_ENDPOINT") - val response: HttpResponse = httpClient.get(RELEASES_ENDPOINT) { + Logger.info("Fetching releases from $releasesEndpoint") + val response: HttpResponse = httpClient.get(releasesEndpoint) { headers { append(HttpHeaders.Accept, "application/vnd.github+json") append("X-GitHub-Api-Version", "2022-11-28") @@ -58,7 +61,7 @@ class PatchRepository( if (response.status.isSuccess()) { val releases: List = response.body() - Logger.info("Fetched ${releases.size} releases") + Logger.info("Fetched ${releases.size} releases from $releasesEndpoint") cachedReleases = releases cacheTimestamp = System.currentTimeMillis() Result.success(releases) @@ -113,25 +116,26 @@ class PatchRepository( } /** - * Find the .mpp asset in a release. + * Find the patch .mpp asset in a release. */ - fun findMppAsset(release: Release): ReleaseAsset? { - return release.assets.find { it.isMpp() } + fun findPatchAsset(release: Release): ReleaseAsset? { + return release.assets.find { it.isPatchFile() } } /** - * Download the .mpp patch file from a release. + * Download the patch .mpp file from a release. * Returns the path to the downloaded file. */ suspend fun downloadPatches(release: Release, onProgress: (Float) -> Unit = {}): Result = withContext(Dispatchers.IO) { - val asset = findMppAsset(release) + val asset = findPatchAsset(release) if (asset == null) { - val error = "No .mpp file found in release ${release.tagName}" + val error = "No .mpp patch files found in release ${release.tagName}" Logger.error(error) return@withContext Result.failure(Exception(error)) } - val patchesDir = FileUtils.getPatchesDir() + val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) + patchesDir.mkdirs() val targetFile = File(patchesDir, asset.name) // Check if already cached @@ -176,18 +180,31 @@ class PatchRepository( * Get cached patch file for a specific version. */ fun getCachedPatches(version: String): File? { - val patchesDir = FileUtils.getPatchesDir() + val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) return patchesDir.listFiles()?.find { - it.name.contains(version) && it.name.endsWith(".mpp") + it.name.contains(version) && isPatchFileName(it.name) } } + private fun isPatchFileName(name: String): Boolean { + return name.endsWith(".mpp", ignoreCase = true) + } + /** * List all cached patch versions. */ fun listCachedPatches(): List { - val patchesDir = FileUtils.getPatchesDir() - return patchesDir.listFiles()?.filter { it.name.endsWith(".mpp") } ?: emptyList() + val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) + return patchesDir.listFiles()?.filter { isPatchFileName(it.name) } ?: emptyList() + } + + /** + * Get the per-source cache directory for this repository. + */ + fun getCacheDir(): File { + val dir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) + dir.mkdirs() + return dir } /** @@ -197,10 +214,11 @@ class PatchRepository( cachedReleases = null cacheTimestamp = 0L return try { + val patchesDir = File(FileUtils.getPatchesDir(), repoPath.replace("/", "-")) var failedCount = 0 - FileUtils.getPatchesDir().listFiles()?.forEach { file -> + patchesDir.listFiles()?.forEach { file -> try { - java.nio.file.Files.delete(file.toPath()) + if (!file.deleteRecursively()) throw Exception("Could not delete") } catch (e: Exception) { failedCount++ Logger.error("Failed to delete ${file.name}: ${e.message}") @@ -210,7 +228,7 @@ class PatchRepository( Logger.error("Patches cache clear incomplete: $failedCount file(s) locked") false } else { - Logger.info("Patches cache cleared") + Logger.info("Patches cache cleared for $repoPath") true } } catch (e: Exception) { diff --git a/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt new file mode 100644 index 0000000..0a540b0 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/data/repository/PatchSourceManager.kt @@ -0,0 +1,156 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.data.repository + +import app.morphe.gui.data.model.PatchSource +import app.morphe.gui.data.model.PatchSourceType +import app.morphe.gui.util.Logger +import io.ktor.client.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages PatchRepository instances for different patch sources. + * Creates and caches a PatchRepository per GitHub-based source. + * Emits [sourceVersion] whenever the active source changes so the UI can react. + */ +class PatchSourceManager( + private val httpClient: HttpClient, + private val configRepository: ConfigRepository +) { + private val repositories = mutableMapOf() + + // Cached active state for synchronous access + private var cachedActiveRepo: PatchRepository? = null + private var cachedActiveSource: PatchSource? = null + + // Incremented on every source switch so Compose can key on it + private val _sourceVersion = MutableStateFlow(0) + val sourceVersion: StateFlow = _sourceVersion.asStateFlow() + + /** + * Load the active source from config and cache its PatchRepository. + * Call once at app startup (from a LaunchedEffect). + */ + suspend fun initialize() { + val source = configRepository.getActivePatchSource() + cachedActiveSource = source + cachedActiveRepo = getRepositoryForSource(source) + Logger.info("PatchSourceManager initialized with source '${source.name}' (type=${source.type})") + } + + /** + * Switch the active source, persist it, and signal the UI. + */ + suspend fun switchSource(id: String) { + configRepository.setActivePatchSource(id) + val source = configRepository.getActivePatchSource() + cachedActiveSource = source + cachedActiveRepo = getRepositoryForSource(source) + _sourceVersion.value++ + Logger.info("Switched active patch source to '${source.name}' (type=${source.type})") + } + + /** + * Whether the current active source is a local .mpp file. + */ + fun isLocalSource(): Boolean { + return cachedActiveSource?.type == PatchSourceType.LOCAL + } + + /** + * Get the local .mpp file path if the active source is LOCAL, null otherwise. + */ + fun getLocalFilePath(): String? { + val source = cachedActiveSource ?: return null + return if (source.type == PatchSourceType.LOCAL) source.filePath else null + } + + /** + * Get the display name of the active source. + */ + fun getActiveSourceName(): String { + return cachedActiveSource?.name ?: "Morphe Patches" + } + + /** + * Whether the active source is the built-in Morphe default. + */ + fun isDefaultSource(): Boolean { + return cachedActiveSource?.type == PatchSourceType.DEFAULT + } + + /** + * Get the cached active PatchRepository synchronously. + * Returns null for LOCAL sources (no GitHub API needed). + * Falls back to default repo if not yet initialized and source is not LOCAL. + */ + fun getActiveRepositorySync(): PatchRepository { + return cachedActiveRepo ?: PatchRepository(httpClient).also { + if (!isLocalSource()) cachedActiveRepo = it + } + } + + /** + * Get the PatchRepository for the currently active source (suspend version). + * For LOCAL sources, returns null (caller should use the file path directly). + */ + suspend fun getActiveRepository(): PatchRepository? { + val source = configRepository.getActivePatchSource() + return getRepositoryForSource(source) + } + + /** + * Get the PatchRepository for a specific source. + * Returns null for LOCAL sources (no GitHub API needed). + */ + fun getRepositoryForSource(source: PatchSource): PatchRepository? { + if (source.type == PatchSourceType.LOCAL) return null + + return repositories.getOrPut(source.id) { + val repoPath = extractRepoPath(source) + Logger.info("Creating PatchRepository for source '${source.name}' (repo=$repoPath)") + PatchRepository(httpClient, repoPath) + } + } + + /** + * Get the active patch source config. + */ + suspend fun getActiveSource(): PatchSource { + return configRepository.getActivePatchSource() + } + + /** + * Extract "owner/repo" from a PatchSource's URL. + * e.g. "https://github.com/MorpheApp/morphe-patches" -> "MorpheApp/morphe-patches" + */ + private fun extractRepoPath(source: PatchSource): String { + val url = source.url ?: return "MorpheApp/morphe-patches" + return url + .removePrefix("https://github.com/") + .removePrefix("http://github.com/") + .removeSuffix("/") + .removeSuffix(".git") + } + + /** + * Clear all cached repository instances (e.g. after source list changes). + */ + fun clearAll() { + repositories.clear() + } + + /** + * Notify that cached patch files were deleted (e.g. via "Clear Cache" in settings). + * Clears cached repo state and bumps [sourceVersion] so ViewModels reload. + */ + fun notifyCacheCleared() { + cachedActiveRepo?.clearCache() + _sourceVersion.value++ + } +} diff --git a/src/main/kotlin/app/morphe/gui/di/AppModule.kt b/src/main/kotlin/app/morphe/gui/di/AppModule.kt index a407d12..3d3aa5d 100644 --- a/src/main/kotlin/app/morphe/gui/di/AppModule.kt +++ b/src/main/kotlin/app/morphe/gui/di/AppModule.kt @@ -6,7 +6,7 @@ package app.morphe.gui.di import app.morphe.gui.data.repository.ConfigRepository -import app.morphe.gui.data.repository.PatchRepository +import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.util.PatchService import io.ktor.client.* import io.ktor.client.engine.cio.* @@ -18,7 +18,7 @@ import org.koin.dsl.module import app.morphe.gui.ui.screens.home.HomeViewModel import app.morphe.gui.ui.screens.patches.PatchesViewModel import app.morphe.gui.ui.screens.patches.PatchSelectionViewModel -import app.morphe.gui.ui.screens.patching.PatchingScreenModel +import app.morphe.gui.ui.screens.patching.PatchingViewModel /** * Main Koin module for dependency injection. @@ -57,12 +57,21 @@ val appModule = module { // Repositories and Services single { ConfigRepository() } - single { PatchRepository(get()) } + single { PatchSourceManager(get(), get()) } single { PatchService() } // ViewModels (ScreenModels) - factory { HomeViewModel(get(), get(), get()) } - factory { params -> PatchesViewModel(params.get(), params.get(), get(), get()) } - factory { params -> PatchSelectionViewModel(params.get(), params.get(), params.get(), params.get(), params.get(), get(), get()) } - factory { params -> PatchingScreenModel(params.get(), get()) } + // ViewModels observe PatchSourceManager.sourceVersion and reload on source changes. + factory { + HomeViewModel(get(), get(), get()) + } + factory { params -> + val psm = get() + PatchesViewModel(params.get(), params.get(), psm.getActiveRepositorySync(), get(), psm.getLocalFilePath(), psm) + } + factory { params -> + val psm = get() + PatchSelectionViewModel(params.get(), params.get(), params.get(), params.get(), params.get(), get(), psm.getActiveRepositorySync(), psm.getLocalFilePath()) + } + factory { params -> PatchingViewModel(params.get(), get(), get()) } } diff --git a/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt b/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt index 1123e2e..1b3c0bd 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/DeviceIndicator.kt @@ -5,9 +5,16 @@ package app.morphe.gui.ui.components +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown @@ -24,54 +31,73 @@ 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.ui.theme.MorpheColors +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheFont +import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.util.DeviceMonitor import app.morphe.gui.util.DeviceStatus @Composable fun DeviceIndicator(modifier: Modifier = Modifier) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current val monitorState by DeviceMonitor.state.collectAsState() val isAdbAvailable = monitorState.isAdbAvailable val readyDevices = monitorState.devices.filter { it.isReady } val unauthorizedDevices = monitorState.devices.filter { it.status == DeviceStatus.UNAUTHORIZED } val selectedDevice = monitorState.selectedDevice - val hasDevices = monitorState.devices.isNotEmpty() var showPopup by remember { mutableStateOf(false) } + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + + val dotColor = when { + isAdbAvailable == false -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) + selectedDevice != null && selectedDevice.isReady -> accents.secondary + unauthorizedDevices.isNotEmpty() -> accents.warning + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f) + } + + val borderColor by animateColorAsState( + when { + isHovered -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + selectedDevice != null && selectedDevice.isReady -> accents.secondary.copy(alpha = 0.2f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + }, + animationSpec = tween(150) + ) + Box(modifier = modifier) { - Surface( - onClick = { showPopup = !showPopup }, - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + Box( + modifier = Modifier + .height(34.dp) + .hoverable(hoverInteraction) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)) + .clickable { showPopup = !showPopup } ) { Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + modifier = Modifier + .fillMaxHeight() + .padding(horizontal = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { // Status dot - val dotColor = when { - isAdbAvailable == false -> MaterialTheme.colorScheme.error.copy(alpha = 0.6f) - selectedDevice != null && selectedDevice.isReady -> MorpheColors.Teal - unauthorizedDevices.isNotEmpty() -> Color(0xFFFF9800) - else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) - } - Box( modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(dotColor) + .size(6.dp) + .background(dotColor, RoundedCornerShape(1.dp)) ) - // Display text val displayText = when { - isAdbAvailable == null -> "Checking..." + isAdbAvailable == null -> "Checking…" isAdbAvailable == false -> "No ADB" selectedDevice != null -> { - val arch = selectedDevice.architecture?.let { " \u2022 $it" } ?: "" + val arch = selectedDevice.architecture?.let { " · $it" } ?: "" "${selectedDevice.displayName}$arch" } unauthorizedDevices.isNotEmpty() -> "Unauthorized" @@ -80,37 +106,39 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { Text( text = displayText, - fontSize = 12.sp, + fontSize = 11.sp, fontWeight = FontWeight.Medium, + fontFamily = mono, color = when { isAdbAvailable == false -> MaterialTheme.colorScheme.error.copy(alpha = 0.7f) selectedDevice != null -> MaterialTheme.colorScheme.onSurface - unauthorizedDevices.isNotEmpty() -> Color(0xFFFF9800) - else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + unauthorizedDevices.isNotEmpty() -> accents.warning + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) }, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = 180.dp) ) - // Always show dropdown arrow — popup has useful info in every state Icon( imageVector = Icons.Default.ArrowDropDown, contentDescription = "Device details", - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) } } - // Popup with device list / status info + // Popup DropdownMenu( expanded = showPopup, - onDismissRequest = { showPopup = false } + onDismissRequest = { showPopup = false }, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.12f)) ) { when { isAdbAvailable == false -> { - // ADB not found DropdownMenuItem( text = { Row( @@ -120,20 +148,22 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { Icon( imageVector = Icons.Default.UsbOff, contentDescription = null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.error ) Column { Text( text = "ADB not found", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, color = MaterialTheme.colorScheme.error ) Text( text = "Install Android SDK Platform Tools", - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) ) } } @@ -143,7 +173,6 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { } monitorState.devices.isEmpty() -> { - // ADB available but no devices visible DropdownMenuItem( text = { Row( @@ -153,20 +182,22 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { Icon( imageVector = Icons.Default.PhoneAndroid, contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) ) Column { Text( text = "No devices detected", - fontSize = 13.sp, + fontSize = 12.sp, fontWeight = FontWeight.Medium, + fontFamily = mono, color = MaterialTheme.colorScheme.onSurfaceVariant ) Text( - text = "Only devices with USB debugging enabled will appear here", - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + text = "Connect a device with USB debugging enabled", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) ) } } @@ -183,20 +214,22 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { Icon( imageVector = Icons.Default.Info, contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MorpheColors.Blue.copy(alpha = 0.7f) + modifier = Modifier.size(14.dp), + tint = accents.primary.copy(alpha = 0.6f) ) Column { Text( - text = "How to enable USB debugging", - fontSize = 12.sp, + text = "Enable USB debugging", + fontSize = 11.sp, fontWeight = FontWeight.Medium, - color = MorpheColors.Blue + fontFamily = mono, + color = accents.primary ) Text( - text = "Settings > Developer Options > USB Debugging", - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + text = "Settings → Developer Options → USB Debugging", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) ) } } @@ -206,7 +239,6 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { } else -> { - // Device list monitorState.devices.forEach { device -> val isSelected = device.id == selectedDevice?.id DropdownMenuItem( @@ -215,31 +247,34 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon( - imageVector = Icons.Default.PhoneAndroid, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = when { - isSelected -> MorpheColors.Teal - device.isReady -> MorpheColors.Blue - device.status == DeviceStatus.UNAUTHORIZED -> Color(0xFFFF9800) - else -> MaterialTheme.colorScheme.error - } + // Device status dot + Box( + modifier = Modifier + .size(6.dp) + .background( + when { + isSelected -> accents.secondary + device.isReady -> accents.primary + device.status == DeviceStatus.UNAUTHORIZED -> accents.warning + else -> MaterialTheme.colorScheme.error + }, + RoundedCornerShape(1.dp) + ) ) Column(modifier = Modifier.weight(1f)) { Text( text = device.displayName, - fontSize = 13.sp, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + fontFamily = mono ) - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { device.architecture?.let { arch -> Text( text = arch, - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) ) } Text( @@ -249,10 +284,11 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { DeviceStatus.OFFLINE -> "Offline" DeviceStatus.UNKNOWN -> "Unknown" }, - fontSize = 11.sp, + fontSize = 10.sp, + fontFamily = mono, color = when (device.status) { - DeviceStatus.DEVICE -> MorpheColors.Teal - DeviceStatus.UNAUTHORIZED -> Color(0xFFFF9800) + DeviceStatus.DEVICE -> accents.secondary + DeviceStatus.UNAUTHORIZED -> accents.warning else -> MaterialTheme.colorScheme.error } ) @@ -269,7 +305,6 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { ) } - // USB debugging hint HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) DropdownMenuItem( text = { @@ -280,19 +315,21 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { Icon( imageVector = Icons.Default.Info, contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) ) Column { Text( - text = "Device connected but not listed?", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "Device not listed?", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) ) Text( text = "Enable USB Debugging in Developer Options", - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) } } diff --git a/src/main/kotlin/app/morphe/gui/ui/components/FrameWindowScope.kt b/src/main/kotlin/app/morphe/gui/ui/components/FrameWindowScope.kt new file mode 100644 index 0000000..c70ecb3 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/FrameWindowScope.kt @@ -0,0 +1,16 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.window.FrameWindowScope + +/** + * Provides FrameWindowScope so composables deep in the tree can use + * WindowDraggableArea for native window dragging (e.g. the macOS coloured + * title bar band in `App.kt`). + */ +val LocalFrameWindowScope = staticCompositionLocalOf { null } diff --git a/src/main/kotlin/app/morphe/gui/ui/components/LottieAnimation.kt b/src/main/kotlin/app/morphe/gui/ui/components/LottieAnimation.kt new file mode 100644 index 0000000..8ffd9c2 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/LottieAnimation.kt @@ -0,0 +1,94 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.unit.dp +import org.jetbrains.skia.Rect as SkiaRect +import org.jetbrains.skia.skottie.Animation + +/** + * THIS IS STILL A WORK IN PROGRESS. THIS ANIMATION IS STILL NOT GOOD ENOUGH. NEEDS MUCH REWORK. + * Plays a Lottie JSON animation using Skia's built-in Skottie renderer. + * No extra dependencies needed — Compose Desktop includes Skottie via Skiko. + * + * @param jsonString The raw Lottie JSON content + * @param modifier Layout modifier + * @param alpha Opacity of the animation (0f–1f) + * @param iterations Number of loops (0 = infinite) + */ +@Composable +fun LottieAnimation( + jsonString: String, + modifier: Modifier = Modifier, + alpha: Float = 1f, + iterations: Int = 0 +) { + val animation = remember(jsonString) { + try { + Animation.makeFromString(jsonString) + } catch (e: Exception) { + null + } + } ?: return + + val duration = animation.duration + var progress by remember { mutableFloatStateOf(0f) } + var loopCount by remember { mutableIntStateOf(0) } + + LaunchedEffect(animation) { + val startTime = withFrameNanos { it } + var lastNanos = startTime + + while (true) { + withFrameNanos { nanos -> + val elapsed = (nanos - lastNanos) / 1_000_000_000.0 + lastNanos = nanos + + progress += (elapsed / duration).toFloat() + if (progress >= 1f) { + loopCount++ + if (iterations > 0 && loopCount >= iterations) { + progress = 1f + } else { + progress %= 1f + } + } + } + } + } + + Canvas(modifier = modifier) { + drawIntoCanvas { canvas -> + animation.seekFrameTime((progress * duration)) + canvas.save() + if (alpha < 1f) { + canvas.nativeCanvas.save() + // Apply alpha via layer + val paint = org.jetbrains.skia.Paint().apply { + this.alpha = (alpha * 255).toInt() + } + canvas.nativeCanvas.saveLayer( + SkiaRect.makeWH(size.width, size.height), + paint + ) + } + animation.render( + canvas.nativeCanvas, + SkiaRect.makeWH(size.width, size.height) + ) + if (alpha < 1f) { + canvas.nativeCanvas.restore() + } + canvas.restore() + } + } +} diff --git a/src/main/kotlin/app/morphe/gui/ui/components/OfflineBanner.kt b/src/main/kotlin/app/morphe/gui/ui/components/OfflineBanner.kt index 47877fa..9430502 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/OfflineBanner.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/OfflineBanner.kt @@ -5,6 +5,7 @@ package app.morphe.gui.ui.components +import androidx.compose.foundation.border import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -22,67 +23,72 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import app.morphe.gui.ui.theme.LocalMorpheFont +import app.morphe.gui.ui.theme.LocalMorpheCorners @Composable fun OfflineBanner( onRetry: () -> Unit, modifier: Modifier = Modifier ) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() - - val buttonColor = if (isHovered) { - MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f) - } else { - MaterialTheme.colorScheme.onErrorContainer - } + val shape = RoundedCornerShape(corners.medium) Surface( - modifier = modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.errorContainer, - shape = RoundedCornerShape(12.dp) + modifier = modifier + .fillMaxWidth() + .border(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.2f), shape), + color = MaterialTheme.colorScheme.error.copy(alpha = 0.06f), + shape = shape ) { Row( - modifier = Modifier.padding(start = 16.dp, top = 10.dp, bottom = 10.dp, end = 8.dp), + modifier = Modifier.padding(start = 14.dp, top = 8.dp, bottom = 8.dp, end = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { Icon( imageVector = Icons.Default.WifiOff, contentDescription = null, - tint = MaterialTheme.colorScheme.onErrorContainer, - modifier = Modifier.size(18.dp) + tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f), + modifier = Modifier.size(16.dp) ) Text( - text = "Offline — showing cached patches", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, + text = "Offline — using cached patches", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.weight(1f) ) - Surface( + OutlinedButton( onClick = onRetry, - modifier = Modifier.hoverable(interactionSource), - color = buttonColor, - shape = RoundedCornerShape(8.dp) + modifier = Modifier.hoverable(interactionSource).height(28.dp), + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp), + border = androidx.compose.foundation.BorderStroke( + 1.dp, + if (isHovered) MaterialTheme.colorScheme.error.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.error.copy(alpha = 0.2f) + ), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error.copy(alpha = 0.8f) + ) ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - tint = MaterialTheme.colorScheme.errorContainer, - modifier = Modifier.size(14.dp) - ) - Text( - text = "Retry", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.errorContainer - ) - } + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(12.dp) + ) + Spacer(Modifier.width(4.dp)) + Text( + text = "RETRY", + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + letterSpacing = 0.5.sp + ) } } } diff --git a/src/main/kotlin/app/morphe/gui/ui/components/SakuraPetals.kt b/src/main/kotlin/app/morphe/gui/ui/components/SakuraPetals.kt new file mode 100644 index 0000000..8ba918a --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/SakuraPetals.kt @@ -0,0 +1,345 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.drawscope.Stroke +import kotlin.math.cos +import kotlin.math.sin +import kotlin.random.Random + +private data class Petal( + var x: Float, + var y: Float, + val size: Float, // 6–14px + var rotation: Float, // degrees + val rotationSpeed: Float, // degrees per frame + val fallSpeed: Float, // px per frame + val driftAmplitude: Float, // horizontal sway amplitude + val driftFrequency: Float, // sway frequency + val alpha: Float, // 0.15–0.5 + val color: Color, + var age: Float = 0f // accumulator for drift sin wave +) + +/** + * Subtle falling sakura petals overlay. + * Draws 12–18 petals drifting down with gentle rotation and horizontal sway. + * Designed to be layered behind interactive content (no pointer input). + */ +@Composable +fun SakuraPetals( + modifier: Modifier = Modifier, + petalCount: Int = 15, + enabled: Boolean = true +) { + if (!enabled) return + + val petalColors = remember { + listOf( + Color(0xFFE8729A), // primary pink + Color(0xFFF2A0BA), // lighter pink + Color(0xFFD4607E), // deeper rose + Color(0xFFF7C4D4), // pale blush + ) + } + + var petals by remember { + mutableStateOf>(emptyList()) + } + + var canvasWidth by remember { mutableFloatStateOf(0f) } + var canvasHeight by remember { mutableFloatStateOf(0f) } + + // Animate frame-by-frame + LaunchedEffect(enabled) { + if (!enabled) return@LaunchedEffect + while (true) { + withFrameNanos { _ -> + if (canvasWidth <= 0f || canvasHeight <= 0f) return@withFrameNanos + + // Initialize petals if empty + if (petals.isEmpty()) { + petals = List(petalCount) { + createPetal(canvasWidth, canvasHeight, petalColors, scattered = true) + } + } + + // Update each petal + petals = petals.map { petal -> + val newAge = petal.age + 0.02f + val newY = petal.y + petal.fallSpeed + val drift = sin(newAge * petal.driftFrequency) * petal.driftAmplitude + val newX = petal.x + drift * 0.3f + val newRotation = petal.rotation + petal.rotationSpeed + + // Recycle if off-screen + if (newY > canvasHeight + 30f) { + createPetal(canvasWidth, canvasHeight, petalColors, scattered = false) + } else { + petal.copy( + x = newX, + y = newY, + rotation = newRotation, + age = newAge + ) + } + } + } + } + } + + Canvas( + modifier = modifier.fillMaxSize() + ) { + canvasWidth = size.width + canvasHeight = size.height + + petals.forEach { petal -> + drawPetal(petal) + } + } +} + +private fun createPetal( + width: Float, + height: Float, + colors: List, + scattered: Boolean +): Petal { + return Petal( + x = Random.nextFloat() * width, + y = if (scattered) Random.nextFloat() * height else Random.nextFloat() * -200f - 20f, + size = Random.nextFloat() * 8f + 6f, + rotation = Random.nextFloat() * 360f, + rotationSpeed = (Random.nextFloat() - 0.5f) * 1.5f, + fallSpeed = Random.nextFloat() * 0.4f + 0.2f, + driftAmplitude = Random.nextFloat() * 1.2f + 0.3f, + driftFrequency = Random.nextFloat() * 2f + 1f, + alpha = Random.nextFloat() * 0.3f + 0.15f, + color = colors.random() + ) +} + +private fun DrawScope.drawPetal(petal: Petal) { + translate(left = petal.x, top = petal.y) { + rotate(degrees = petal.rotation, pivot = Offset.Zero) { + val s = petal.size + val path = Path().apply { + moveTo(0f, -s) + cubicTo(s * 0.8f, -s * 0.6f, s * 0.6f, s * 0.3f, 0f, s * 0.5f) + cubicTo(-s * 0.6f, s * 0.3f, -s * 0.8f, -s * 0.6f, 0f, -s) + close() + } + drawPath( + path = path, + color = petal.color.copy(alpha = petal.alpha) + ) + } + } +} + +// ════════════════════════════════════════════════════════════════════ +// CHERRY BLOSSOM TREE — decorative background branch +// ════════════════════════════════════════════════════════════════════ + +private val BranchBrown = Color(0xFF8B6F5E) +private val BlossomPink = Color(0xFFE8729A) +private val BlossomLight = Color(0xFFF2A0BA) +private val BlossomPale = Color(0xFFF7C4D4) +private val BlossomCenter = Color(0xFFFFE0B2) + +/** + * Decorative cherry blossom branch growing from the bottom-right corner. + * Very low opacity — atmospheric, not distracting. + */ +@Composable +fun SakuraTree( + modifier: Modifier = Modifier, + enabled: Boolean = true +) { + if (!enabled) return + + Canvas(modifier = modifier.fillMaxSize()) { + val w = size.width + val h = size.height + + // All coordinates are relative to canvas size so it scales with the window + val branchAlpha = 0.10f + val blossomAlpha = 0.13f + + // ── Main trunk: curves up from bottom-right ── + val trunk = Path().apply { + moveTo(w + 10f, h + 20f) + cubicTo( + w - 40f, h - 80f, + w - 60f, h - 200f, + w - 90f, h - 320f + ) + cubicTo( + w - 110f, h - 400f, + w - 100f, h - 480f, + w - 130f, h - 540f + ) + } + drawPath( + path = trunk, + color = BranchBrown.copy(alpha = branchAlpha), + style = Stroke(width = 6f, cap = StrokeCap.Round, join = StrokeJoin.Round) + ) + + // ── Branch 1: sweeps left from mid-trunk ── + val branch1 = Path().apply { + moveTo(w - 80f, h - 280f) + cubicTo( + w - 140f, h - 310f, + w - 200f, h - 300f, + w - 260f, h - 330f + ) + } + drawPath( + path = branch1, + color = BranchBrown.copy(alpha = branchAlpha), + style = Stroke(width = 3.5f, cap = StrokeCap.Round, join = StrokeJoin.Round) + ) + + // ── Branch 1 twig ── + val twig1a = Path().apply { + moveTo(w - 200f, h - 300f) + cubicTo( + w - 220f, h - 330f, + w - 240f, h - 340f, + w - 270f, h - 350f + ) + } + drawPath( + path = twig1a, + color = BranchBrown.copy(alpha = branchAlpha * 0.8f), + style = Stroke(width = 2f, cap = StrokeCap.Round) + ) + + // ── Branch 2: sweeps right-upward from upper trunk ── + val branch2 = Path().apply { + moveTo(w - 110f, h - 420f) + cubicTo( + w - 70f, h - 460f, + w - 50f, h - 500f, + w - 80f, h - 560f + ) + } + drawPath( + path = branch2, + color = BranchBrown.copy(alpha = branchAlpha), + style = Stroke(width = 3f, cap = StrokeCap.Round, join = StrokeJoin.Round) + ) + + // ── Branch 3: small twig from lower trunk ── + val branch3 = Path().apply { + moveTo(w - 55f, h - 160f) + cubicTo( + w - 90f, h - 180f, + w - 120f, h - 170f, + w - 150f, h - 200f + ) + } + drawPath( + path = branch3, + color = BranchBrown.copy(alpha = branchAlpha * 0.8f), + style = Stroke(width = 2.5f, cap = StrokeCap.Round) + ) + + // ── Branch 4: top crown ── + val branch4 = Path().apply { + moveTo(w - 125f, h - 520f) + cubicTo( + w - 170f, h - 540f, + w - 210f, h - 530f, + w - 240f, h - 560f + ) + } + drawPath( + path = branch4, + color = BranchBrown.copy(alpha = branchAlpha * 0.7f), + style = Stroke(width = 2f, cap = StrokeCap.Round) + ) + + // ── Blossom clusters ── + // Each cluster: a few overlapping petals + a center dot + + // Cluster positions along the branches + val clusters = listOf( + // branch 1 clusters + Triple(w - 180f, h - 305f, 12f), + Triple(w - 240f, h - 325f, 10f), + Triple(w - 260f, h - 335f, 14f), + Triple(w - 270f, h - 350f, 9f), + // branch 1 twig + Triple(w - 255f, h - 345f, 11f), + // branch 2 clusters + Triple(w - 75f, h - 470f, 11f), + Triple(w - 65f, h - 510f, 13f), + Triple(w - 80f, h - 550f, 10f), + // branch 3 clusters + Triple(w - 120f, h - 175f, 10f), + Triple(w - 145f, h - 195f, 12f), + // branch 4 clusters + Triple(w - 190f, h - 535f, 11f), + Triple(w - 230f, h - 555f, 13f), + // trunk clusters + Triple(w - 95f, h - 340f, 10f), + Triple(w - 115f, h - 450f, 12f), + Triple(w - 130f, h - 530f, 9f), + ) + + clusters.forEach { (cx, cy, r) -> + drawBlossom(cx, cy, r, blossomAlpha) + } + } +} + +/** + * Draws a single cherry blossom: 5 petals arranged radially + center dot. + */ +private fun DrawScope.drawBlossom(cx: Float, cy: Float, radius: Float, alpha: Float) { + val petalColors = listOf(BlossomPink, BlossomLight, BlossomPale, BlossomLight, BlossomPink) + + // 5 petals at 72° intervals + for (i in 0 until 5) { + val angle = Math.toRadians((i * 72.0 + 18.0)) // offset 18° so it's not axis-aligned + val px = cx + cos(angle).toFloat() * radius * 0.5f + val py = cy + sin(angle).toFloat() * radius * 0.5f + + val petalPath = Path().apply { + val s = radius * 0.55f + moveTo(px, py - s) + cubicTo(px + s * 0.7f, py - s * 0.5f, px + s * 0.5f, py + s * 0.2f, px, py + s * 0.3f) + cubicTo(px - s * 0.5f, py + s * 0.2f, px - s * 0.7f, py - s * 0.5f, px, py - s) + close() + } + drawPath( + path = petalPath, + color = petalColors[i].copy(alpha = alpha) + ) + } + + // Center dot + drawCircle( + color = BlossomCenter.copy(alpha = alpha * 1.2f), + radius = radius * 0.15f, + center = Offset(cx, cy) + ) +} 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 571423a..f649cca 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsButton.kt @@ -6,66 +6,102 @@ package app.morphe.gui.ui.components import app.morphe.gui.LocalModeState +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.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.draw.clip import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Settings +import androidx.compose.foundation.layout.Box import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface 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.unit.dp +import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS +import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD +import app.morphe.gui.data.model.PatchSource import app.morphe.gui.data.repository.ConfigRepository +import app.morphe.gui.data.repository.PatchSourceManager +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.koin.compose.koinInject +import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.LocalThemeState -/** - * Reusable settings button that can be placed on any screen. - * @param allowCacheClear Whether to allow cache clearing (disable on patches screen and beyond) - */ @Composable fun SettingsButton( modifier: Modifier = Modifier, - allowCacheClear: Boolean = true + allowCacheClear: Boolean = true, + isPatching: Boolean = false ) { + val corners = LocalMorpheCorners.current val themeState = LocalThemeState.current val modeState = LocalModeState.current val configRepository: ConfigRepository = koinInject() + val patchSourceManager: PatchSourceManager = koinInject() val scope = rememberCoroutineScope() var showSettingsDialog by remember { mutableStateOf(false) } var autoCleanupTempFiles by remember { mutableStateOf(true) } + 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) } + var keystoreEntryPassword by remember { mutableStateOf(DEFAULT_KEYSTORE_PASSWORD) } - // Load config when dialog is shown LaunchedEffect(showSettingsDialog) { if (showSettingsDialog) { val config = configRepository.loadConfig() autoCleanupTempFiles = config.autoCleanupTempFiles + patchSources = config.patchSource + activePatchSourceId = config.activePatchSourceId + keystorePath = config.keystorePath + keystorePassword = config.keystorePassword + keystoreAlias = config.keystoreAlias + keystoreEntryPassword = config.keystoreEntryPassword } } - Surface( - onClick = { showSettingsDialog = true }, - shape = RoundedCornerShape(12.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val borderColor by animateColorAsState( + if (isHovered) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), + animationSpec = tween(150) + ) + + Box( modifier = modifier - ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = "Settings", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(8.dp) - ) - } + .size(34.dp) + .hoverable(hoverInteraction) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)) + .clickable { showSettingsDialog = true }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Settings", + tint = if (isHovered) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(16.dp) + ) + } if (showSettingsDialog) { SettingsDialog( @@ -83,26 +119,87 @@ fun SettingsButton( modeState.onChange(!enabled) }, onDismiss = { showSettingsDialog = false }, - allowCacheClear = allowCacheClear + 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() + }, + keystorePath = keystorePath, + keystorePassword = keystorePassword, + keystoreAlias = keystoreAlias, + keystoreEntryPassword = keystoreEntryPassword, + onKeystorePathChange = { path -> + keystorePath = path + scope.launch { configRepository.setKeystorePath(path) } + }, + onKeystoreCredentialsChange = { pwd, alias, entryPwd -> + keystorePassword = pwd + keystoreAlias = alias + keystoreEntryPassword = entryPwd + scope.launch { + configRepository.setKeystoreDetails( + path = keystorePath, + password = pwd, + alias = alias, + entryPassword = entryPwd + ) + } + } ) } } -/** - * Top bar row that places DeviceIndicator + SettingsButton together. - * Use this instead of standalone SettingsButton on screens. - */ @Composable fun TopBarRow( modifier: Modifier = Modifier, allowCacheClear: Boolean = true, + isPatching: Boolean = false, ) { + val corners = LocalMorpheCorners.current + val isSoft = corners.small >= 8.dp Row( modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(if (isSoft) 12.dp else 6.dp), verticalAlignment = Alignment.CenterVertically ) { DeviceIndicator() - SettingsButton(allowCacheClear = allowCacheClear) + SettingsButton(allowCacheClear = allowCacheClear, isPatching = isPatching) } } 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 3e811a2..9d3c420 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -6,31 +6,52 @@ 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.BugReport -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.FolderOpen +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.* 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.graphicsLayer 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.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS +import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD 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.LocalMorpheFont +import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.MorpheColors import app.morphe.gui.ui.theme.ThemePreference import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger +import app.morphe.patcher.apk.ApkSigner import java.awt.Desktop +import java.awt.FileDialog +import java.awt.Frame import java.io.File +import java.security.KeyStore +import java.security.MessageDigest +import java.security.cert.X509Certificate +import java.text.SimpleDateFormat +import java.util.UUID @Composable fun SettingsDialog( @@ -41,140 +62,180 @@ fun SettingsDialog( useExpertMode: Boolean, onExpertModeChange: (Boolean) -> Unit, onDismiss: () -> Unit, - allowCacheClear: Boolean = true + 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, + keystoreAlias: String = DEFAULT_KEYSTORE_ALIAS, + keystoreEntryPassword: String = DEFAULT_KEYSTORE_PASSWORD, + onKeystorePathChange: (String?) -> Unit = {}, + onKeystoreCredentialsChange: (password: String?, alias: String, entryPassword: String) -> Unit = { _, _, _ -> } ) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + var showClearCacheConfirm 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, - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, title = { Text( - text = "Settings", - fontWeight = FontWeight.SemiBold + text = "SETTINGS", + fontWeight = FontWeight.Bold, + fontFamily = mono, + fontSize = 13.sp, + letterSpacing = 2.sp, + color = MaterialTheme.colorScheme.onSurface ) }, text = { Column( modifier = Modifier .verticalScroll(rememberScrollState()) - .widthIn(min = 300.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .widthIn(min = 340.dp), + verticalArrangement = Arrangement.spacedBy(0.dp) ) { - // Theme selection - Text( - text = "Theme", - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface - ) - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) + // ── Theme ── + SectionLabel("THEME", mono) + Spacer(Modifier.height(8.dp)) + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - ThemePreference.entries.forEach { theme -> + ThemePreference.entries.filter { it != ThemePreference.MATCHA }.forEach { theme -> val isSelected = currentTheme == theme - Surface( - shape = RoundedCornerShape(8.dp), - color = if (isSelected) MorpheColors.Blue.copy(alpha = 0.15f) - else Color.Transparent, - border = BorderStroke( - width = 1.dp, - color = if (isSelected) MorpheColors.Blue.copy(alpha = 0.5f) - else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - ), + val themeAccent = theme.accentColor() + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + Row( modifier = Modifier - .clip(RoundedCornerShape(8.dp)) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + when { + isSelected -> themeAccent.copy(alpha = 0.5f) + isHovered -> MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + else -> borderColor + }, + RoundedCornerShape(corners.small) + ) + .background( + if (isSelected) themeAccent.copy(alpha = 0.08f) + else Color.Transparent + ) + .hoverable(hoverInteraction) .clickable { onThemeChange(theme) } + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) ) { + // Themed icon Text( - text = theme.toDisplayName(), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - fontSize = 13.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - color = if (isSelected) MorpheColors.Blue + text = theme.iconSymbol(), + fontSize = 11.sp, + color = themeAccent + ) + Text( + text = theme.toDisplayName().uppercase(), + fontSize = 10.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + fontFamily = mono, + letterSpacing = 0.5.sp, + color = if (isSelected) themeAccent else MaterialTheme.colorScheme.onSurfaceVariant ) } } } - HorizontalDivider() + SettingsDivider(borderColor) - // Expert mode setting - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Expert mode", - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "Full control over patch selection and configuration", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = useExpertMode, - onCheckedChange = onExpertModeChange, - colors = SwitchDefaults.colors( - checkedThumbColor = MorpheColors.Blue, - checkedTrackColor = MorpheColors.Blue.copy(alpha = 0.5f) - ) - ) - } + // ── Expert Mode ── + SettingToggleRow( + label = "Expert mode", + description = "Full control over patch selection and configuration", + checked = useExpertMode, + onCheckedChange = onExpertModeChange, + accentColor = accents.primary, + mono = mono, + enabled = !isPatching + ) - HorizontalDivider() + SettingsDivider(borderColor) - // Auto-cleanup setting - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Auto-cleanup temp files", - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "Automatically delete temporary files after patching", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - Switch( - checked = autoCleanupTempFiles, - onCheckedChange = onAutoCleanupChange, - colors = SwitchDefaults.colors( - checkedThumbColor = MorpheColors.Teal, - checkedTrackColor = MorpheColors.Teal.copy(alpha = 0.5f) - ) - ) - } + // ── Auto Cleanup ── + SettingToggleRow( + label = "Auto-cleanup temp files", + description = "Delete temporary files after patching", + checked = autoCleanupTempFiles, + onCheckedChange = onAutoCleanupChange, + accentColor = accents.primary, + mono = mono, + enabled = !isPatching + ) - HorizontalDivider() + SettingsDivider(borderColor) - // Actions - Text( - text = "Actions", - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface + // ── Signing / Keystore ── + SigningSection( + keystorePath = keystorePath, + keystorePassword = keystorePassword, + keystoreAlias = keystoreAlias, + keystoreEntryPassword = keystoreEntryPassword, + onKeystorePathChange = onKeystorePathChange, + onCredentialsChange = onKeystoreCredentialsChange, + mono = mono, + accentColor = accents.primary, + borderColor = borderColor, + enabled = !isPatching ) - // Export logs button - OutlinedButton( + 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 + ) + + SettingsDivider(borderColor) + + // ── Actions ── + SectionLabel("ACTIONS", mono) + Spacer(Modifier.height(8.dp)) + + ActionButton( + label = "OPEN LOGS", + icon = Icons.Default.BugReport, + mono = mono, + borderColor = borderColor, onClick = { try { val logsDir = FileUtils.getLogsDir() @@ -184,21 +245,16 @@ fun SettingsDialog( } catch (e: Exception) { Logger.error("Failed to open logs folder", e) } - }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp) - ) { - Icon( - imageVector = Icons.Default.BugReport, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Open Logs Folder") - } + } + ) - // Open app data folder - OutlinedButton( + Spacer(Modifier.height(6.dp)) + + ActionButton( + label = "OPEN APP DATA", + icon = Icons.Default.FolderOpen, + mono = mono, + borderColor = borderColor, onClick = { try { val appDataDir = FileUtils.getAppDataDir() @@ -208,90 +264,95 @@ fun SettingsDialog( } catch (e: Exception) { Logger.error("Failed to open app data folder", e) } - }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp) - ) { - Icon( - imageVector = Icons.Default.FolderOpen, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Open App Data Folder") - } + } + ) - // Clear cache button - OutlinedButton( - onClick = { showClearCacheConfirm = true }, - enabled = allowCacheClear && !cacheCleared, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(8.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = when { - cacheCleared -> MorpheColors.Teal - cacheClearFailed -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.error - }, - disabledContentColor = if (cacheCleared) MorpheColors.Teal.copy(alpha = 0.7f) - else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - when { - !allowCacheClear -> "Clear Cache (disabled during patching)" - cacheCleared -> "Cache Cleared" - cacheClearFailed -> "Clear Cache Failed (files in use)" - else -> "Clear Cache" - } - ) + Spacer(Modifier.height(6.dp)) + + // Clear cache + val cacheColor = when { + cacheCleared -> MorpheColors.Teal + cacheClearFailed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.error } + ActionButton( + label = when { + !allowCacheClear -> "CLEAR CACHE (DISABLED)" + cacheCleared -> "CACHE CLEARED" + cacheClearFailed -> "CLEAR FAILED" + else -> "CLEAR CACHE" + }, + icon = Icons.Default.Delete, + mono = mono, + borderColor = if (cacheCleared) MorpheColors.Teal.copy(alpha = 0.3f) + else MaterialTheme.colorScheme.error.copy(alpha = 0.2f), + contentColor = cacheColor, + enabled = allowCacheClear && !cacheCleared, + onClick = { showClearCacheConfirm = true } + ) + + Spacer(Modifier.height(4.dp)) - // Cache info val cacheSize = calculateCacheSize() Text( - text = "Cache: $cacheSize (Patches + Logs)", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "Cache: $cacheSize (patches + logs)", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) - HorizontalDivider() + SettingsDivider(borderColor) - // About + // ── About ── Text( text = "${AppConstants.APP_NAME} ${AppConstants.APP_VERSION}", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) } }, confirmButton = { OutlinedButton( onClick = onDismiss, - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor) ) { Text( - "Close", - color = MaterialTheme.colorScheme.error + "CLOSE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } } ) - // Clear cache confirmation dialog + // Clear cache confirmation if (showClearCacheConfirm) { AlertDialog( onDismissRequest = { showClearCacheConfirm = false }, - shape = RoundedCornerShape(16.dp), - title = { Text("Clear Cache?") }, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + "CLEAR CACHE?", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 1.sp + ) + }, text = { - Text("This will delete downloaded patch files and log files. Patches will be re-downloaded when needed.") + Text( + "This will delete downloaded patches and log files. Patches will be re-downloaded when needed.", + fontFamily = mono, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp + ) }, confirmButton = { Button( @@ -300,78 +361,1631 @@ fun SettingsDialog( cacheCleared = success cacheClearFailed = !success showClearCacheConfirm = false + if (success) onCacheCleared() }, colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error - ) + ), + shape = RoundedCornerShape(corners.small) ) { - Text("Clear") + Text( + "CLEAR", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) } }, dismissButton = { TextButton(onClick = { showClearCacheConfirm = false }) { - Text("Cancel") + Text( + "CANCEL", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) } } ) } -} -private fun ThemePreference.toDisplayName(): String { - return when (this) { - ThemePreference.LIGHT -> "Light" - ThemePreference.DARK -> "Dark" - ThemePreference.AMOLED -> "AMOLED" - ThemePreference.SYSTEM -> "System" + if (showAddSourceDialog) { + AddPatchSourceDialog( + onDismiss = { showAddSourceDialog = false }, + onAdd = { source -> + onAddPatchSource(source) + showAddSourceDialog = false + } + ) + } + + editingSource?.let { source -> + EditPatchSourceDialog( + source = source, + onDismiss = { editingSource = null }, + onSave = { updated -> + onEditPatchSource(updated) + editingSource = null + } + ) } } -private fun calculateCacheSize(): String { - val patchesSize = FileUtils.getPatchesDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } - val logsSize = FileUtils.getLogsDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } - val totalSize = patchesSize + logsSize +// ── Shared building blocks ── - return when { - totalSize < 1024 -> "$totalSize B" - totalSize < 1024 * 1024 -> "%.1f KB".format(totalSize / 1024.0) - else -> "%.1f MB".format(totalSize / (1024.0 * 1024.0)) +@Composable +private fun SectionLabel( + text: String, + mono: androidx.compose.ui.text.font.FontFamily +) { + Text( + text = text, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) +} + +@Composable +private fun CollapsibleSection( + title: String, + mono: androidx.compose.ui.text.font.FontFamily, + initiallyExpanded: Boolean = false, + content: @Composable () -> Unit +) { + val corners = LocalMorpheCorners.current + var expanded by remember { mutableStateOf(initiallyExpanded) } + val rotationAngle by androidx.compose.animation.core.animateFloatAsState( + targetValue = if (expanded) -90f else 0f, + animationSpec = androidx.compose.animation.core.tween(200) + ) + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .hoverable(hoverInteraction) + .background( + if (isHovered) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.04f) + else Color.Transparent + ) + .clickable { expanded = !expanded } + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.6f else 0.4f), + letterSpacing = 1.5.sp + ) + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = if (expanded) "Collapse" else "Expand", + modifier = Modifier + .size(16.dp) + .graphicsLayer { rotationZ = rotationAngle }, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = if (isHovered) 0.5f else 0.3f) + ) + } + + androidx.compose.animation.AnimatedVisibility( + visible = expanded, + enter = androidx.compose.animation.expandVertically( + expandFrom = Alignment.Top, + animationSpec = androidx.compose.animation.core.tween(200) + ) + androidx.compose.animation.fadeIn(animationSpec = androidx.compose.animation.core.tween(200)), + exit = androidx.compose.animation.shrinkVertically( + shrinkTowards = Alignment.Top, + animationSpec = androidx.compose.animation.core.tween(200) + ) + androidx.compose.animation.fadeOut(animationSpec = androidx.compose.animation.core.tween(150)) + ) { + Column { + Spacer(Modifier.height(8.dp)) + content() + } } } -private fun clearAllCache(): Boolean { - return try { - var failedCount = 0 +@Composable +private fun SettingsDivider(borderColor: Color) { + Spacer(Modifier.height(14.dp)) + HorizontalDivider(color = borderColor) + Spacer(Modifier.height(14.dp)) +} - // Delete patch files - FileUtils.getPatchesDir().listFiles()?.forEach { file -> - try { - java.nio.file.Files.delete(file.toPath()) - } catch (e: Exception) { - failedCount++ - Logger.error("Failed to delete ${file.name}: ${e.message}") - } +@Composable +private fun SettingToggleRow( + label: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + accentColor: Color, + mono: androidx.compose.ui.text.font.FontFamily, + enabled: Boolean = true +) { + val alpha = if (enabled) 1f else 0.4f + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = alpha) + ) + Spacer(Modifier.height(2.dp)) + Text( + text = if (!enabled) "Disabled while patching" else description, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f * alpha) + ) } + Spacer(Modifier.width(12.dp)) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + colors = SwitchDefaults.colors( + checkedThumbColor = accentColor, + checkedTrackColor = accentColor.copy(alpha = 0.3f) + ) + ) + } +} - // Delete log files - FileUtils.getLogsDir().listFiles()?.forEach { file -> - try { - java.nio.file.Files.delete(file.toPath()) - } catch (e: Exception) { - failedCount++ - Logger.error("Failed to delete log ${file.name}: ${e.message}") +@Composable +private fun ActionButton( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, + contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + enabled: Boolean = true, + onClick: () -> Unit +) { + val corners = LocalMorpheCorners.current + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + + OutlinedButton( + onClick = onClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth().hoverable(hoverInteraction), + shape = RoundedCornerShape(corners.small), + border = BorderStroke( + 1.dp, + if (isHovered && enabled) contentColor.copy(alpha = 0.3f) + else borderColor + ), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = contentColor, + disabledContentColor = contentColor.copy(alpha = 0.4f) + ) + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + label, + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + letterSpacing = 0.5.sp, + modifier = Modifier.weight(1f) + ) + } +} + +// ── 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 +) { + val corners = LocalMorpheCorners.current + val alpha = if (enabled) 1f else 0.4f + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + CollapsibleSection("PATCH SOURCES", mono) { + 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)) } - FileUtils.cleanupAllTempDirs() - if (failedCount > 0) { - Logger.error("Cache clear incomplete: $failedCount file(s) could not be deleted (may be locked)") - false - } else { - Logger.info("Cache cleared successfully") - true + // 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 + ) } - } catch (e: Exception) { - Logger.error("Failed to clear cache", e) - false + } // 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 + ) + } + } + ) +} + +// ── Signing / Keystore Section ── + +@Composable +private fun SigningSection( + keystorePath: String?, + keystorePassword: String?, + keystoreAlias: String, + keystoreEntryPassword: String, + onKeystorePathChange: (String?) -> Unit, + onCredentialsChange: (password: String?, alias: String, entryPassword: String) -> Unit, + mono: androidx.compose.ui.text.font.FontFamily, + accentColor: Color, + borderColor: Color, + enabled: Boolean = true +) { + val corners = LocalMorpheCorners.current + val alpha = if (enabled) 1f else 0.4f + + var localPassword by remember(keystorePassword) { mutableStateOf(keystorePassword ?: "") } + var localAlias by remember(keystoreAlias) { mutableStateOf(keystoreAlias) } + var localEntryPassword by remember(keystoreEntryPassword) { mutableStateOf(keystoreEntryPassword) } + var showPassword by remember { mutableStateOf(false) } + var showEntryPassword by remember { mutableStateOf(false) } + var showKeystoreInfo by remember { mutableStateOf(false) } + var keystoreError by remember { mutableStateOf(null) } + + val keystoreFile = keystorePath?.let { File(it) } + val keystoreExists = keystoreFile?.exists() == true + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + CollapsibleSection("SIGNING", mono) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = if (!enabled) "Disabled while patching" + else "Keystore used to sign patched APKs", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + + Spacer(Modifier.height(8.dp)) + + // Keystore path row + Row( + modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)) + .padding(horizontal = 10.dp), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = if (keystorePath != null) { + keystoreFile?.name ?: keystorePath + } else "Default (auto-generated)", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f * alpha), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + OutlinedButton( + onClick = { + val dialog = FileDialog(null as Frame?, "Select Keystore", FileDialog.LOAD).apply { + setFilenameFilter { _, n -> + n.lowercase().let { + it.endsWith(".keystore") || it.endsWith(".jks") || + it.endsWith(".bks") || it.endsWith(".p12") || it.endsWith(".pfx") + } + } + isVisible = true + } + if (dialog.directory != null && dialog.file != null) { + val selected = File(dialog.directory, dialog.file) + val validExtensions = listOf(".keystore", ".jks", ".bks", ".p12", ".pfx") + if (validExtensions.any { selected.name.lowercase().endsWith(it) }) { + keystoreError = null + onKeystorePathChange(selected.absolutePath) + } else { + keystoreError = "Invalid file type. Expected: ${validExtensions.joinToString(", ")}" + } + } + }, + enabled = enabled, + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor), + contentPadding = PaddingValues(horizontal = 10.dp), + modifier = Modifier.fillMaxHeight() + ) { + Text( + "BROWSE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 9.sp, + letterSpacing = 0.5.sp + ) + } + + if (keystorePath != null) { + OutlinedButton( + onClick = { onKeystorePathChange(null) }, + enabled = enabled, + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor), + contentPadding = PaddingValues(horizontal = 10.dp), + modifier = Modifier.fillMaxHeight() + ) { + Text( + "RESET", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 9.sp, + letterSpacing = 0.5.sp + ) + } + } + } + + // Warning if keystore path set but file doesn't exist + if (keystorePath != null && !keystoreExists) { + Text( + text = "Keystore not found — will be created on next patch", + fontSize = 10.sp, + fontFamily = mono, + color = Color(0xFFE0A030) + ) + } + + // Error for invalid file type selection + keystoreError?.let { + Text( + text = it, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.error + ) + } + + // Full path tooltip + if (keystorePath != null) { + Text( + text = keystorePath, + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + 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)) + + // 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) + ) + + Spacer(Modifier.height(4.dp)) + + // 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) + ) + + // Verify credentials button + var verifyResult by remember { mutableStateOf(null) } + var verifySuccess by remember { mutableStateOf(false) } + + if (keystoreExists) { + Spacer(Modifier.height(6.dp)) + OutlinedButton( + onClick = { + verifyResult = null + verifySuccess = false + val path = keystorePath ?: return@OutlinedButton + val result = readKeystoreInfo( + path, + localPassword.ifEmpty { null }, + localAlias.ifEmpty { DEFAULT_KEYSTORE_ALIAS }, + localEntryPassword.ifEmpty { DEFAULT_KEYSTORE_PASSWORD } + ) + if (result == null) { + verifyResult = "Could not open keystore — check keystore password" + verifySuccess = false + } else if (result.warnings.isNotEmpty()) { + verifyResult = result.warnings.first() + verifySuccess = false + } else { + verifyResult = "Credentials valid" + verifySuccess = true + } + }, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(corners.small), + border = BorderStroke( + 1.dp, + when { + verifySuccess -> MorpheColors.Teal.copy(alpha = 0.4f) + verifyResult != null -> Color(0xFFE0A030).copy(alpha = 0.4f) + else -> borderColor + } + ), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp) + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(12.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + "VERIFY CREDENTIALS", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 9.sp, + letterSpacing = 0.5.sp + ) + } + + verifyResult?.let { + Spacer(Modifier.height(4.dp)) + Text( + text = it, + fontSize = 10.sp, + fontFamily = mono, + color = if (verifySuccess) MorpheColors.Teal else Color(0xFFE0A030), + modifier = Modifier.fillMaxWidth(), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + } + + Spacer(Modifier.height(8.dp)) + + // Generate button (only when no keystore exists yet) + var generateError by remember { mutableStateOf(null) } + var generateSuccess by remember { mutableStateOf(false) } + + if (!keystoreExists) { + OutlinedButton( + onClick = { + generateError = null + generateSuccess = false + + // If no path set, ask the user where to save + val path = keystorePath ?: run { + val dialog = FileDialog(null as Frame?, "Save Keystore", FileDialog.SAVE).apply { + file = "morphe.keystore" + isVisible = true + } + if (dialog.directory != null && dialog.file != null) { + val chosen = File(dialog.directory, dialog.file).absolutePath + onKeystorePathChange(chosen) + chosen + } else { + return@OutlinedButton // user cancelled + } + } + + try { + val file = File(path) + file.parentFile?.mkdirs() + val keyPair = ApkSigner.newPrivateKeyCertificatePair( + "Morphe", + java.util.Date(System.currentTimeMillis() + 8L * 365 * 24 * 60 * 60 * 1000)) + val ks = ApkSigner.newKeyStore(setOf( + ApkSigner.KeyStoreEntry( + localAlias.ifEmpty { DEFAULT_KEYSTORE_ALIAS }, + localEntryPassword.ifEmpty { DEFAULT_KEYSTORE_PASSWORD }, + keyPair + ) + )) + file.outputStream().use { + ks.store(it, localPassword.ifEmpty { null }?.toCharArray()) + } + // Save credentials to config + onCredentialsChange( + localPassword.ifEmpty { null }, + localAlias.ifEmpty { DEFAULT_KEYSTORE_ALIAS }, + localEntryPassword.ifEmpty { DEFAULT_KEYSTORE_PASSWORD } + ) + generateSuccess = true + } catch (e: Exception) { + generateError = "Failed to generate: ${e.message}" + Logger.error("Failed to generate keystore", e) + } + }, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + 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) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = if (generateSuccess) MorpheColors.Teal else accentColor + ) + Spacer(Modifier.width(6.dp)) + Text( + if (generateSuccess) "KEYSTORE GENERATED" else "GENERATE KEYSTORE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 9.sp, + letterSpacing = 0.5.sp, + color = if (generateSuccess) MorpheColors.Teal else accentColor + ) + } + + generateError?.let { + Text( + text = it, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.error + ) + } + + if (!generateSuccess) { + Text( + text = "Uses the credentials entered above", + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f), + modifier = Modifier.fillMaxWidth(), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) + } + + Spacer(Modifier.height(4.dp)) + } + + // Action buttons row + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Certificate info + OutlinedButton( + onClick = { showKeystoreInfo = true }, + enabled = enabled && keystoreExists, + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + modifier = Modifier.size(12.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + "CERTIFICATE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 9.sp, + letterSpacing = 0.5.sp + ) + } + + // Export + OutlinedButton( + onClick = { + val sourceFile = keystoreFile ?: return@OutlinedButton + if (!sourceFile.exists()) return@OutlinedButton + val dialog = FileDialog(null as Frame?, "Export Keystore", FileDialog.SAVE).apply { + file = sourceFile.name + isVisible = true + } + if (dialog.directory != null && dialog.file != null) { + try { + sourceFile.copyTo(File(dialog.directory, dialog.file), overwrite = true) + } catch (e: Exception) { + Logger.error("Failed to export keystore", e) + } + } + }, + enabled = enabled && keystoreExists, + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 6.dp), + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Default.Share, + contentDescription = null, + modifier = Modifier.size(12.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + "EXPORT", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 9.sp, + letterSpacing = 0.5.sp + ) + } + } + } // inner Column + } // CollapsibleSection + } + + // Certificate info dialog + if (showKeystoreInfo && keystorePath != null) { + KeystoreInfoDialog( + keystorePath = keystorePath, + password = keystorePassword, + alias = keystoreAlias, + entryPassword = keystoreEntryPassword, + onDismiss = { showKeystoreInfo = false } + ) + } +} + +@Composable +private fun KeystoreInfoDialog( + keystorePath: String, + password: String?, + alias: String, + entryPassword: String, + onDismiss: () -> Unit +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + + val info = remember(keystorePath, password, alias, entryPassword) { + readKeystoreInfo(keystorePath, password, alias, entryPassword) + } + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + title = { + Text( + "CERTIFICATE INFO", + fontFamily = mono, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + letterSpacing = 1.sp + ) + }, + text = { + if (info != null) { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier.widthIn(min = 300.dp) + ) { + // Show warnings first if there are any + if (info.warnings.isNotEmpty()) { + info.warnings.forEach { warning -> + Text( + text = warning, + fontSize = 10.sp, + fontFamily = mono, + color = Color(0xFFE0A030), + lineHeight = 14.sp + ) + } + // If no cert data (alias not found), stop here + if (info.sha256Fingerprint.isEmpty()) return@Column + HorizontalDivider(color = borderColor) + } + + CertInfoRow("Alias", info.alias, mono) + CertInfoRow("Issuer", info.issuer, mono) + CertInfoRow("Valid from", info.validFrom, mono) + CertInfoRow("Valid until", info.validTo, mono) + + HorizontalDivider(color = borderColor) + + Text( + "SHA-256 FINGERPRINT", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.sp + ) + androidx.compose.foundation.text.selection.SelectionContainer { + Text( + text = info.sha256Fingerprint, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + lineHeight = 16.sp + ) + } + + HorizontalDivider(color = borderColor) + + Text( + "SHA-1 FINGERPRINT", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.sp + ) + androidx.compose.foundation.text.selection.SelectionContainer { + Text( + text = info.sha1Fingerprint, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f), + lineHeight = 16.sp + ) + } + } + } else { + Text( + text = "Could not read keystore. Check the password and alias.", + fontSize = 12.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.error + ) + } + }, + confirmButton = { + OutlinedButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor) + ) { + Text( + "CLOSE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + ) +} + +@Composable +private fun CertInfoRow( + label: String, + value: String, + mono: androidx.compose.ui.text.font.FontFamily +) { + Column { + Text( + text = label.uppercase(), + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.sp + ) + Text( + text = value, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + ) + } +} + +private data class KeystoreInfoResult( + val alias: String, + val issuer: String, + val validFrom: String, + val validTo: String, + val sha256Fingerprint: String, + val sha1Fingerprint: String, + val warnings: List = emptyList() +) + +private fun readKeystoreInfo( + keystorePath: String, + password: String?, + alias: String, + entryPassword: String? = null +): KeystoreInfoResult? { + val file = File(keystorePath) + if (!file.exists()) return null + + val passwordChars = password?.toCharArray() ?: charArrayOf() + val dateFormat = SimpleDateFormat("yyyy-MM-dd") + + // Ensure BouncyCastle provider is registered (needed for BKS keystores) + try { + if (java.security.Security.getProvider("BC") == null) { + java.security.Security.addProvider( + Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider") + .getDeclaredConstructor().newInstance() as java.security.Provider + ) + } + } catch (_: Exception) { + // BC not on classpath — BKS keystores won't be readable, but JKS/PKCS12 still work + } + + // Try multiple keystore types: BKS (what Morphe generates), then JKS, then PKCS12 + // BKS requires BouncyCastle provider — try with provider name, fall back without + val types = listOf("BKS" to "BC", "BKS" to null, "JKS" to null, "PKCS12" to null) + for ((type, provider) in types) { + try { + val ks = if (provider != null) { + KeyStore.getInstance(type, provider) + } else { + KeyStore.getInstance(type) + } + + file.inputStream().use { ks.load(it, passwordChars) } + + val warnings = mutableListOf() + + // Alias must match exactly + if (!ks.containsAlias(alias)) { + return KeystoreInfoResult( + alias = alias, + issuer = "", + validFrom = "", + validTo = "", + sha256Fingerprint = "", + sha1Fingerprint = "", + warnings = listOf("Alias \"$alias\" not found in keystore") + ) + } + + val cert = ks.getCertificate(alias) as? X509Certificate ?: continue + + // Verify the entry password actually works + try { + ks.getKey(alias, entryPassword?.toCharArray() ?: charArrayOf()) + } catch (_: Exception) { + return KeystoreInfoResult( + alias = alias, + issuer = "", + validFrom = "", + validTo = "", + sha256Fingerprint = "", + sha1Fingerprint = "", + warnings = listOf("Key password is incorrect for alias \"$alias\"") + ) + } + + val sha256 = MessageDigest.getInstance("SHA-256") + .digest(cert.encoded) + .joinToString(":") { "%02X".format(it) } + + val sha1 = MessageDigest.getInstance("SHA-1") + .digest(cert.encoded) + .joinToString(":") { "%02X".format(it) } + + return KeystoreInfoResult( + alias = alias, + issuer = cert.issuerDN.name, + validFrom = dateFormat.format(cert.notBefore), + validTo = dateFormat.format(cert.notAfter), + sha256Fingerprint = sha256, + sha1Fingerprint = sha1, + warnings = warnings + ) + } catch (_: Exception) { + continue + } + } + return null +} + +private fun ThemePreference.toDisplayName(): String { + return when (this) { + ThemePreference.LIGHT -> "Light" + ThemePreference.DARK -> "Dark" + ThemePreference.AMOLED -> "AMOLED" + ThemePreference.NORD -> "Nord" + ThemePreference.CATPPUCCIN -> "Catppuccin" + ThemePreference.SAKURA -> "Sakura" + ThemePreference.MATCHA -> "Matcha" + ThemePreference.SYSTEM -> "System" + } +} + +private fun ThemePreference.iconSymbol(): String { + return when (this) { + ThemePreference.LIGHT -> "☀" + ThemePreference.DARK -> "☾" + ThemePreference.AMOLED -> "◆" + ThemePreference.NORD -> "❄" + ThemePreference.CATPPUCCIN -> "🐱" + ThemePreference.SAKURA -> "🌸" + ThemePreference.MATCHA -> "🍵" + ThemePreference.SYSTEM -> "⚙" + } +} + +private fun ThemePreference.accentColor(): Color { + return when (this) { + ThemePreference.LIGHT -> MorpheColors.Blue + ThemePreference.DARK -> MorpheColors.Blue + ThemePreference.AMOLED -> MorpheColors.Cyan + ThemePreference.NORD -> Color(0xFF88C0D0) + ThemePreference.CATPPUCCIN -> Color(0xFFCBA6F7) + ThemePreference.SAKURA -> Color(0xFFB43A67) + ThemePreference.MATCHA -> Color(0xFF4C7A35) + ThemePreference.SYSTEM -> MorpheColors.Blue + } +} + +private fun calculateCacheSize(): String { + val patchesSize = FileUtils.getPatchesDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } + val logsSize = FileUtils.getLogsDir().walkTopDown().filter { it.isFile }.sumOf { it.length() } + val totalSize = patchesSize + logsSize + + return when { + totalSize < 1024 -> "$totalSize B" + totalSize < 1024 * 1024 -> "%.1f KB".format(totalSize / 1024.0) + else -> "%.1f MB".format(totalSize / (1024.0 * 1024.0)) + } +} + +private fun clearAllCache(): Boolean { + return try { + var failedCount = 0 + FileUtils.getPatchesDir().listFiles()?.forEach { file -> + try { if (!file.deleteRecursively()) throw Exception("Could not delete") } + catch (e: Exception) { failedCount++; Logger.error("Failed to delete ${file.name}: ${e.message}") } + } + FileUtils.getLogsDir().listFiles()?.forEach { file -> + try { if (!file.deleteRecursively()) throw Exception("Could not delete") } + catch (e: Exception) { failedCount++; Logger.error("Failed to delete log ${file.name}: ${e.message}") } + } + + FileUtils.cleanupAllTempDirs() + if (failedCount > 0) { + Logger.error("Cache clear incomplete: $failedCount file(s) could not be deleted (may be locked)") + false + } else { + Logger.info("Cache cleared successfully") + true + } + } catch (e: Exception) { + Logger.error("Failed to clear cache", e) + false + } +} + +/** + * 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 +} 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 c92cdb8..0774f75 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 @@ -5,22 +5,42 @@ package app.morphe.gui.ui.screens.home +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image 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.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.layout.* +import androidx.compose.foundation.horizontalScroll +import androidx.compose.ui.text.style.TextOverflow 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.automirrored.filled.OpenInNew +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.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush +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.layout.onSizeChanged import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp @@ -31,6 +51,9 @@ import androidx.compose.ui.platform.LocalUriHandler 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.LocalMorpheFont +import app.morphe.gui.ui.theme.LocalMorpheAccents import app.morphe.gui.ui.theme.LocalThemeState import app.morphe.gui.ui.theme.ThemePreference import org.jetbrains.compose.resources.painterResource @@ -45,8 +68,11 @@ import app.morphe.gui.ui.screens.home.components.FullScreenDropZone import app.morphe.gui.ui.components.OfflineBanner import app.morphe.gui.ui.screens.patches.PatchesScreen import app.morphe.gui.ui.screens.patches.PatchSelectionScreen -import app.morphe.gui.ui.theme.MorpheColors import app.morphe.gui.util.DownloadUrlResolver.openUrlAndFollowRedirects +import app.morphe.gui.util.VersionStatus +import app.morphe.gui.util.resolveStatusColorType +import app.morphe.gui.util.resolveVersionWarningContent +import app.morphe.gui.util.toColor import java.awt.FileDialog import java.awt.Frame import java.io.File @@ -67,14 +93,11 @@ fun HomeScreenContent( val navigator = LocalNavigator.currentOrThrow val uiState by viewModel.uiState.collectAsState() - // Refresh patches when returning from PatchesScreen (in case user selected a different version) - // Use navigator.items.size as key so this triggers when navigation stack changes (e.g., pop back) val navStackSize = navigator.items.size LaunchedEffect(navStackSize) { viewModel.refreshPatchesIfNeeded() } - // Show error snackbar val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(uiState.error) { uiState.error?.let { error -> @@ -96,16 +119,16 @@ fun HomeScreenContent( BoxWithConstraints( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background) ) { + val useSplitLayout = maxWidth >= 720.dp val isCompact = maxWidth < 500.dp val isSmall = maxHeight < 600.dp val padding = if (isCompact) 16.dp else 24.dp + val outerMaxWidth = maxWidth // Version warning dialog state var showVersionWarningDialog by remember { mutableStateOf(false) } - // Version warning dialog if (showVersionWarningDialog && uiState.apkInfo != null) { VersionWarningDialog( versionStatus = uiState.apkInfo!!.versionStatus, @@ -128,135 +151,237 @@ fun HomeScreenContent( ) } - val scrollState = rememberScrollState() + val useHorizontalHeader = maxWidth >= 600.dp + val pinSupportedAppsToBottom = useHorizontalHeader && maxHeight >= 760.dp + val patchesLoaded = !uiState.isLoadingPatches && viewModel.getCachedPatchesFile() != null + val onChangePatchesClick: () -> Unit = { + navigator.push(PatchesScreen( + apkPath = uiState.apkInfo?.filePath ?: "", + apkName = uiState.apkInfo?.appName ?: "Select APK first" + )) + } + val onRetry: () -> Unit = { viewModel.retryLoadPatches() } + val onClearClick: () -> Unit = { viewModel.clearSelection() } + val onChangeClick: () -> Unit = { + openFilePicker()?.let { file -> + viewModel.onFileSelected(file) + } + } + val onContinueClick: () -> Unit = { + handleContinue(uiState, viewModel, navigator) { + showVersionWarningDialog = true + } + } - Box(modifier = Modifier.fillMaxSize()) { - // SpaceBetween + fillMaxSize pushes supported apps to the bottom - // when there's room; verticalScroll kicks in when content overflows. - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) + val headerContent: @Composable ColumnScope.() -> Unit = { + if (useHorizontalHeader) { + HeaderBar( + uiState = uiState, + isSmall = isSmall, + onChangePatchesClick = onChangePatchesClick, + onRetry = onRetry + ) + } else { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) + BrandingSection(isCompact = isCompact) + + if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) + PatchesVersionCard( + patchesVersion = uiState.patchesVersion!!, + isLatest = uiState.isUsingLatestPatches, + onChangePatchesClick = onChangePatchesClick, + patchSourceName = uiState.patchSourceName, + isCompact = isCompact, + modifier = Modifier + .padding(horizontal = if (isCompact) 8.dp else 16.dp) + ) + } else if (uiState.isLoadingPatches) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) + PatchesLoadingIndicator() + } else if (uiState.patchLoadError != null) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) + PatchesVersionCard( + patchesVersion = "NOT LOADED", + isLatest = false, + onChangePatchesClick = onChangePatchesClick, + isCompact = isCompact, + modifier = Modifier + .padding(horizontal = if (isCompact) 8.dp else 16.dp) + ) + } + + if (uiState.isOffline && !uiState.isLoadingPatches) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) + OfflineBanner( + onRetry = onRetry, + modifier = Modifier + .padding(horizontal = if (isCompact) 8.dp else 16.dp) + ) + } + } + } + + val workspaceContent: @Composable (Modifier) -> Unit = { modifier -> + Box( + modifier = modifier + .fillMaxWidth() .padding(padding), - verticalArrangement = Arrangement.SpaceBetween, - horizontalAlignment = Alignment.CenterHorizontally + contentAlignment = Alignment.Center ) { - // Top group: branding + patches version + middle content - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) - BrandingSection(isCompact = isCompact) - - // Patches version selector card - right under logo - if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - PatchesVersionCard( - patchesVersion = uiState.patchesVersion!!, - isLatest = uiState.isUsingLatestPatches, - onChangePatchesClick = { - // Navigate to patches version selection screen - // Pass empty apk info since user hasn't selected an APK yet - navigator.push(PatchesScreen( - apkPath = uiState.apkInfo?.filePath ?: "", - apkName = uiState.apkInfo?.appName ?: "Select APK first" - )) - }, - isCompact = isCompact, - modifier = Modifier - .widthIn(max = 400.dp) - .padding(horizontal = if (isCompact) 8.dp else 16.dp) - ) - } else if (uiState.isLoadingPatches) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(14.dp), - strokeWidth = 2.dp, - color = MorpheColors.Blue - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Loading patches...", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - // Offline banner - if (uiState.isOffline && !uiState.isLoadingPatches) { - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) - OfflineBanner( - onRetry = { viewModel.retryLoadPatches() }, - modifier = Modifier - .widthIn(max = 400.dp) - .padding(horizontal = if (isCompact) 8.dp else 16.dp) - ) - } + MiddleContent( + uiState = uiState, + isCompact = isCompact, + patchesLoaded = patchesLoaded, + onClearClick = onClearClick, + onChangeClick = onChangeClick, + onContinueClick = onContinueClick + ) + } + } - Spacer(modifier = Modifier.height(if (isSmall) 16.dp else 32.dp)) + val supportedAppsContent: @Composable () -> Unit = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding( + start = padding, + end = padding, + bottom = if (isSmall) 8.dp else 16.dp + ) + ) { + SupportedAppsSection( + isCompact = isCompact, + maxWidth = this@BoxWithConstraints.maxWidth, + isLoading = uiState.isLoadingPatches, + isDefaultSource = uiState.isDefaultSource, + supportedApps = uiState.supportedApps, + loadError = uiState.patchLoadError, + onRetry = onRetry + ) + } + } - MiddleContent( + Box(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.fillMaxSize()) { + // ── Pinned header (not scrollable) ── + if (useHorizontalHeader) { + HeaderBar( uiState = uiState, - isCompact = isCompact, - patchesLoaded = !uiState.isLoadingPatches && viewModel.getCachedPatchesFile() != null, - onClearClick = { viewModel.clearSelection() }, - onChangeClick = { - openFilePicker()?.let { file -> - viewModel.onFileSelected(file) - } - }, - onContinueClick = { - val patchesFile = viewModel.getCachedPatchesFile() - if (patchesFile == null) { - // Patches not ready yet - return@MiddleContent - } + isSmall = isSmall, + onChangePatchesClick = onChangePatchesClick, + onRetry = onRetry + ) + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) + BrandingSection(isCompact = isCompact) + + if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) + PatchesVersionCard( + patchesVersion = uiState.patchesVersion!!, + isLatest = uiState.isUsingLatestPatches, + onChangePatchesClick = onChangePatchesClick, + isCompact = isCompact, + modifier = Modifier + .padding(horizontal = if (isCompact) 8.dp else 16.dp) + ) + } else if (uiState.isLoadingPatches) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) + PatchesLoadingIndicator() + } else if (uiState.patchLoadError != null) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) + PatchesVersionCard( + patchesVersion = "NOT LOADED", + isLatest = false, + onChangePatchesClick = onChangePatchesClick, + isCompact = isCompact, + modifier = Modifier + .padding(horizontal = if (isCompact) 8.dp else 16.dp) + ) + } - val versionStatus = uiState.apkInfo?.versionStatus - if (versionStatus != null && versionStatus != VersionStatus.EXACT_MATCH && versionStatus != VersionStatus.UNKNOWN) { - showVersionWarningDialog = true - } else { - uiState.apkInfo?.let { info -> - navigator.push(PatchSelectionScreen( - apkPath = info.filePath, - apkName = info.appName, - patchesFilePath = patchesFile.absolutePath, - packageName = info.packageName, - apkArchitectures = info.architectures - )) - } - } + // Offline banner + if (uiState.isOffline && !uiState.isLoadingPatches) { + Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) + OfflineBanner( + onRetry = onRetry, + modifier = Modifier + .widthIn(max = 400.dp) + .padding(horizontal = if (isCompact) 8.dp else 16.dp) + ) } - ) + } } - // Bottom group: supported apps section - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(top = if (isSmall) 16.dp else 24.dp) + // ── Scrollable body ── + BoxWithConstraints( + modifier = Modifier + .weight(1f) + .fillMaxWidth() ) { - SupportedAppsSection( - isCompact = isCompact, - maxWidth = this@BoxWithConstraints.maxWidth, - isLoading = uiState.isLoadingPatches, - supportedApps = uiState.supportedApps, - loadError = uiState.patchLoadError, - onRetry = { viewModel.retryLoadPatches() } - ) - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) + 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 + ) { + // ── Main workspace area ── + Box( + modifier = Modifier + .fillMaxWidth() + .padding(padding), + contentAlignment = Alignment.Center + ) { + MiddleContent( + uiState = uiState, + isCompact = isCompact, + patchesLoaded = patchesLoaded, + onClearClick = onClearClick, + onChangeClick = onChangeClick, + onContinueClick = onContinueClick + ) + } + + // ── Supported apps ── + 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 + ) + } + } } } - // Top bar (device indicator + settings) in top-right corner - TopBarRow( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(padding), - allowCacheClear = true - ) + // Top bar — only floated when not using horizontal header + if (!useHorizontalHeader) { + TopBarRow( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = padding, end = padding), + allowCacheClear = true + ) + } // Snackbar host SnackbarHost( @@ -273,6 +398,251 @@ fun HomeScreenContent( } } +private fun handleContinue( + uiState: HomeUiState, + viewModel: HomeViewModel, + navigator: cafe.adriel.voyager.navigator.Navigator, + showWarning: () -> Unit +) { + val patchesFile = viewModel.getCachedPatchesFile() ?: return + val versionStatus = uiState.apkInfo?.versionStatus + if (versionStatus != null && versionStatus != VersionStatus.LATEST_STABLE && versionStatus != VersionStatus.UNKNOWN) { + showWarning() + } else { + uiState.apkInfo?.let { info -> + navigator.push(PatchSelectionScreen( + apkPath = info.filePath, + apkName = info.appName, + patchesFilePath = patchesFile.absolutePath, + packageName = info.packageName, + apkArchitectures = info.architectures + )) + } + } +} + +// ════════════════════════════════════════════════════════════════════ +// HEADER BAR — Logo + patches version + status, horizontal +// ════════════════════════════════════════════════════════════════════ + +@Composable +private fun HeaderBar( + uiState: HomeUiState, + isSmall: Boolean, + onChangePatchesClick: () -> Unit, + onRetry: () -> Unit +) { + val mono = LocalMorpheFont.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.10f) + val density = androidx.compose.ui.platform.LocalDensity.current + var leadingWidthPx by remember { mutableIntStateOf(0) } + var trailingWidthPx by remember { mutableIntStateOf(0) } + val centerSidePadding = with(density) { maxOf(leadingWidthPx, trailingWidthPx).toDp() } + 16.dp + + Box( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 1f + ) + } + .padding(vertical = 8.dp) + ) { + // Logo — left-aligned, compact + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 12.dp) + .onSizeChanged { leadingWidthPx = it.width } + ) { + BrandingSection(isCompact = true) + } + + // Patches version inline — centered + Box( + modifier = Modifier + .align(Alignment.Center) + .padding(start = centerSidePadding, end = centerSidePadding) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { + PatchesVersionInline( + patchesVersion = uiState.patchesVersion!!, + isLatest = uiState.isUsingLatestPatches, + onChangePatchesClick = onChangePatchesClick, + patchSourceName = uiState.patchSourceName + ) + } else if (uiState.isLoadingPatches) { + PatchesLoadingIndicator() + } else if (uiState.patchLoadError != null) { + PatchesVersionInline( + patchesVersion = "NOT LOADED", + isLatest = false, + onChangePatchesClick = onChangePatchesClick + ) + } + + if (uiState.isOffline && !uiState.isLoadingPatches) { + Spacer(modifier = Modifier.width(12.dp)) + OfflineBadge(onRetry = onRetry) + } + } + } + + + // Device indicator + settings — inline in the header + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 12.dp) + .onSizeChanged { trailingWidthPx = it.width } + ) { + TopBarRow(allowCacheClear = true) + } + } +} + +/** + * Inline patches version for the header bar — compact, horizontal. + */ +@Composable +private fun PatchesVersionInline( + patchesVersion: String, + isLatest: Boolean, + onChangePatchesClick: () -> Unit, + patchSourceName: String? = null +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val borderColor by animateColorAsState( + MaterialTheme.colorScheme.outline.copy(alpha = if (isHovered) 0.24f else 0.1f), + animationSpec = tween(200) + ) + + Row( + modifier = Modifier + .height(34.dp) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)) + .background(MaterialTheme.colorScheme.surface) + .hoverable(hoverInteraction) + .clickable(onClick = onChangePatchesClick) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = patchSourceName?.uppercase() ?: "PATCHES", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = homeMutedTextColor(0.4f), + letterSpacing = 1.5.sp + ) + Text( + text = " · ", + fontSize = 10.sp, + fontFamily = mono, + color = homeMutedTextColor(0.25f) + ) + Text( + text = patchesVersion, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = homeAccentTextColor(accents.primary) + ) + if (isLatest) { + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = Modifier + .background(accents.secondary.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) + .border(1.dp, accents.secondary.copy(alpha = 0.2f), RoundedCornerShape(corners.small)) + .padding(horizontal = 5.dp, vertical = 1.dp) + ) { + Text( + text = "LATEST", + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary, + letterSpacing = 1.sp + ) + } + } + } +} + +@Composable +private fun PatchesLoadingIndicator() { + val mono = LocalMorpheFont.current + Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Loading patches…", + fontSize = 11.sp, + fontFamily = mono, + color = homeMutedTextColor(0.5f) + ) + } +} + +@Composable +private fun OfflineBadge(onRetry: () -> Unit) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val borderColor by animateColorAsState( + if (isHovered) MaterialTheme.colorScheme.error.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.error.copy(alpha = 0.2f), + animationSpec = tween(200) + ) + + Row( + modifier = Modifier + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)) + .hoverable(hoverInteraction) + .clickable(onClick = onRetry) + .padding(horizontal = 10.dp, vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(6.dp) + .background(MaterialTheme.colorScheme.error, RoundedCornerShape(1.dp)) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "OFFLINE", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.error, + letterSpacing = 1.sp + ) + } +} + +// ════════════════════════════════════════════════════════════════════ +// MIDDLE CONTENT — Drop zone / APK info / Analyzing +// ════════════════════════════════════════════════════════════════════ + @Composable private fun MiddleContent( uiState: HomeUiState, @@ -306,6 +676,110 @@ private fun MiddleContent( } } +// ════════════════════════════════════════════════════════════════════ +// DROP ZONE — Corner brackets, scanner/targeting aesthetic +// ════════════════════════════════════════════════════════════════════ + +@Composable +private fun DropPromptSection( + isDragHovering: Boolean, + isCompact: Boolean = false, + onBrowseClick: () -> Unit +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val bracketColor = if (isDragHovering) accents.primary.copy(alpha = 0.72f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f) + val bracketLen = if (isCompact) 24f else 32f + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .widthIn(max = 440.dp) + .fillMaxWidth() + ) { + // Drop zone with corner brackets + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(if (isCompact) 1.6f else 1.4f) + .drawBehind { + val strokeWidth = 2f + val len = bracketLen.dp.toPx() + val inset = 0f + + // Top-left corner + drawLine(bracketColor, Offset(inset, inset), Offset(inset + len, inset), strokeWidth) + drawLine(bracketColor, Offset(inset, inset), Offset(inset, inset + len), strokeWidth) + // Top-right corner + drawLine(bracketColor, Offset(size.width - inset, inset), Offset(size.width - inset - len, inset), strokeWidth) + drawLine(bracketColor, Offset(size.width - inset, inset), Offset(size.width - inset, inset + len), strokeWidth) + // Bottom-left corner + drawLine(bracketColor, Offset(inset, size.height - inset), Offset(inset + len, size.height - inset), strokeWidth) + drawLine(bracketColor, Offset(inset, size.height - inset), Offset(inset, size.height - inset - len), strokeWidth) + // Bottom-right corner + drawLine(bracketColor, Offset(size.width - inset, size.height - inset), Offset(size.width - inset - len, size.height - inset), strokeWidth) + drawLine(bracketColor, Offset(size.width - inset, size.height - inset), Offset(size.width - inset, size.height - inset - len), strokeWidth) + }, + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = if (isDragHovering) "RELEASE TO DROP" else "DROP APK HERE", + fontSize = if (isCompact) 16.sp else 20.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = if (isDragHovering) accents.primary + else MaterialTheme.colorScheme.onSurface, + letterSpacing = 3.sp + ) + + Spacer(modifier = Modifier.height(if (isCompact) 12.dp else 16.dp)) + + Text( + text = "or", + fontSize = 11.sp, + fontFamily = mono, + color = homeMutedTextColor(0.3f) + ) + + Spacer(modifier = Modifier.height(if (isCompact) 12.dp else 16.dp)) + + OutlinedButton( + onClick = onBrowseClick, + modifier = Modifier.height(if (isCompact) 38.dp else 42.dp), + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, accents.primary.copy(alpha = 0.4f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = homeAccentTextColor(accents.primary)) + ) { + Text( + text = "BROWSE FILES", + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + letterSpacing = 1.5.sp + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + Text( + text = ".apk · .apkm · .xapk · .apks", + fontSize = 10.sp, + fontFamily = mono, + color = homeMutedTextColor(0.25f), + letterSpacing = 0.5.sp + ) + } + } + } +} + +// ════════════════════════════════════════════════════════════════════ +// APK SELECTED — Info card + action buttons +// ════════════════════════════════════════════════════════════════════ + @Composable private fun ApkSelectedSection( patchesLoaded: Boolean, @@ -315,13 +789,13 @@ private fun ApkSelectedSection( onChangeClick: () -> Unit, onContinueClick: () -> Unit ) { - val showWarning = apkInfo.versionStatus != VersionStatus.EXACT_MATCH && + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val showWarning = apkInfo.versionStatus != VersionStatus.LATEST_STABLE && apkInfo.versionStatus != VersionStatus.UNKNOWN - val warningColor = when (apkInfo.versionStatus) { - VersionStatus.NEWER_VERSION -> MaterialTheme.colorScheme.error - VersionStatus.OLDER_VERSION -> Color(0xFFFF9800) - else -> MorpheColors.Blue - } + val accents = LocalMorpheAccents.current + val warningColor = resolveStatusColorType(apkInfo.versionStatus, apkInfo.checksumStatus).toColor() + val primaryColor = if (showWarning) warningColor else accents.primary Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -333,127 +807,67 @@ private fun ApkSelectedSection( modifier = Modifier.fillMaxWidth() ) - Spacer(modifier = Modifier.height(if (isCompact) 16.dp else 24.dp)) + Spacer(modifier = Modifier.height(if (isCompact) 16.dp else 20.dp)) - // Action buttons - stack vertically on compact if (isCompact) { Column( - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth() ) { Button( onClick = onContinueClick, enabled = patchesLoaded, - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - colors = ButtonDefaults.buttonColors( - containerColor = if (showWarning) warningColor else MorpheColors.Blue - ), - shape = RoundedCornerShape(12.dp) + modifier = Modifier.fillMaxWidth().height(44.dp), + colors = ButtonDefaults.buttonColors(containerColor = primaryColor), + shape = RoundedCornerShape(corners.small) ) { - if (!patchesLoaded) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - "Loading patches...", - fontSize = 15.sp, - fontWeight = FontWeight.Medium - ) - } else { - if (showWarning) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = "Warning", - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - } - Text( - "Continue", - fontSize = 15.sp, - fontWeight = FontWeight.Medium - ) - } + ActionButtonContent(patchesLoaded, showWarning, mono) } OutlinedButton( onClick = onChangeClick, - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - shape = RoundedCornerShape(12.dp), + modifier = Modifier.fillMaxWidth().height(44.dp), + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)), colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.onSurfaceVariant ) ) { Text( - "Change APK", - fontSize = 15.sp, - fontWeight = FontWeight.Medium + "CHANGE APK", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + letterSpacing = 1.sp ) } } } else { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedButton( onClick = onChangeClick, - modifier = Modifier.height(48.dp), - shape = RoundedCornerShape(12.dp), + modifier = Modifier.height(44.dp), + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f)), colors = ButtonDefaults.outlinedButtonColors( contentColor = MaterialTheme.colorScheme.onSurfaceVariant ) ) { Text( - "Change APK", - fontSize = 15.sp, - fontWeight = FontWeight.Medium + "CHANGE APK", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + letterSpacing = 1.sp ) } - Button( onClick = onContinueClick, enabled = patchesLoaded, - modifier = Modifier - .widthIn(min = 160.dp) - .height(48.dp), - colors = ButtonDefaults.buttonColors( - containerColor = if (showWarning) warningColor else MorpheColors.Blue - ), - shape = RoundedCornerShape(12.dp) + modifier = Modifier.widthIn(min = 160.dp).height(44.dp), + colors = ButtonDefaults.buttonColors(containerColor = primaryColor), + shape = RoundedCornerShape(corners.small) ) { - if (!patchesLoaded) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - "Loading...", - fontSize = 15.sp, - fontWeight = FontWeight.Medium - ) - } else { - if (showWarning) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = "Warning", - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - } - Text( - "Continue", - fontSize = 15.sp, - fontWeight = FontWeight.Medium - ) - } + ActionButtonContent(patchesLoaded, showWarning, mono) } } } @@ -461,189 +875,105 @@ private fun ApkSelectedSection( } @Composable -private fun VersionWarningDialog( - versionStatus: VersionStatus, - currentVersion: String, - suggestedVersion: String, - onConfirm: () -> Unit, - onDismiss: () -> Unit +private fun ActionButtonContent( + patchesLoaded: Boolean, + showWarning: Boolean, + mono: androidx.compose.ui.text.font.FontFamily ) { - val (title, message) = when (versionStatus) { - VersionStatus.NEWER_VERSION -> Pair( - "Version Too New", - "You're using v$currentVersion, but the recommended version is v$suggestedVersion.\n\n" + - "Patching newer versions may cause issues or some patches might not work correctly.\n\n" + - "Do you want to continue anyway?" + if (!patchesLoaded) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary ) - VersionStatus.OLDER_VERSION -> Pair( - "Older Version Detected", - "You're using v$currentVersion, but newer patches are available for v$suggestedVersion.\n\n" + - "You may be missing out on new features and bug fixes.\n\n" + - "Do you want to continue with this version?" + Spacer(modifier = Modifier.width(8.dp)) + Text( + "LOADING…", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + letterSpacing = 1.sp ) - else -> Pair("Version Notice", "Continue with v$currentVersion?") - } - - AlertDialog( - onDismissRequest = onDismiss, - shape = RoundedCornerShape(16.dp), - icon = { + } else { + if (showWarning) { Icon( imageVector = Icons.Default.Warning, - contentDescription = null, - tint = if (versionStatus == VersionStatus.NEWER_VERSION) - MaterialTheme.colorScheme.error - else - Color(0xFFFF9800), - modifier = Modifier.size(32.dp) - ) - }, - title = { - Text( - text = title, - fontWeight = FontWeight.SemiBold - ) - }, - text = { - Text( - text = message, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - }, - confirmButton = { - Button( - onClick = onConfirm, - colors = ButtonDefaults.buttonColors( - containerColor = if (versionStatus == VersionStatus.NEWER_VERSION) - MaterialTheme.colorScheme.error - else - Color(0xFFFF9800) - ) - ) { - Text("Continue Anyway") - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text("Cancel") - } - } - ) -} - -@Composable -private fun BrandingSection(isCompact: Boolean = false) { - val themeState = LocalThemeState.current - val isDark = when (themeState.current) { - ThemePreference.DARK, ThemePreference.AMOLED -> true - ThemePreference.LIGHT -> false - ThemePreference.SYSTEM -> isSystemInDarkTheme() - } - Image( - painter = painterResource(if (isDark) Res.drawable.morphe_dark else Res.drawable.morphe_light), - contentDescription = "Morphe Logo", - modifier = Modifier.height(if (isCompact) 48.dp else 60.dp) - ) -} - -@Composable -private fun DropPromptSection( - isDragHovering: Boolean, - isCompact: Boolean = false, - onBrowseClick: () -> Unit -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(horizontal = if (isCompact) 16.dp else 32.dp) - ) { - Text( - text = if (isDragHovering) "Release to drop" else "Drop your APK here", - fontSize = if (isCompact) 18.sp else 22.sp, - fontWeight = FontWeight.Medium, - color = if (isDragHovering) - MorpheColors.Blue - else - MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center - ) - - Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) - - Text( - text = "or", - fontSize = if (isCompact) 12.sp else 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) - - OutlinedButton( - onClick = onBrowseClick, - modifier = Modifier.height(if (isCompact) 44.dp else 48.dp), - shape = RoundedCornerShape(12.dp), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MorpheColors.Blue - ) - ) { - Text( - "Browse Files", - fontSize = if (isCompact) 14.sp else 16.sp, - fontWeight = FontWeight.Medium + contentDescription = "Warning", + modifier = Modifier.size(16.dp) ) + Spacer(modifier = Modifier.width(8.dp)) } - - Spacer(modifier = Modifier.height(if (isCompact) 12.dp else 16.dp)) - Text( - text = "Supported: .apk and .apkm files", - fontSize = if (isCompact) 11.sp else 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + "CONTINUE", + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + letterSpacing = 1.sp ) } } +// ════════════════════════════════════════════════════════════════════ +// ANALYZING STATE +// ════════════════════════════════════════════════════════════════════ + @Composable private fun AnalyzingSection(isCompact: Boolean = false) { + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(horizontal = if (isCompact) 16.dp else 32.dp) ) { CircularProgressIndicator( - modifier = Modifier.size(if (isCompact) 36.dp else 44.dp), - color = MorpheColors.Blue, - strokeWidth = 3.dp + modifier = Modifier.size(if (isCompact) 28.dp else 32.dp), + color = accents.primary, + strokeWidth = 2.dp ) Spacer(modifier = Modifier.height(if (isCompact) 12.dp else 16.dp)) Text( - text = "Analyzing APK...", - fontSize = if (isCompact) 16.sp else 18.sp, - fontWeight = FontWeight.Medium, + text = "ANALYZING", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center + letterSpacing = 2.sp ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(6.dp)) Text( - text = "Reading app information", - fontSize = if (isCompact) 12.sp else 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "Reading app metadata…", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) ) } } +// ════════════════════════════════════════════════════════════════════ +// SUPPORTED APPS — Bottom section, horizontal scrolling cards +// ════════════════════════════════════════════════════════════════════ + +/** + * Bottom section — horizontal scrolling cards. + */ @Composable private fun SupportedAppsSection( isCompact: Boolean = false, maxWidth: Dp = 800.dp, isLoading: Boolean = false, + isDefaultSource: Boolean = true, supportedApps: List = emptyList(), loadError: String? = null, onRetry: () -> Unit = {} ) { - // Stack vertically if very narrow + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current val useVerticalLayout = maxWidth < 400.dp Column( @@ -652,19 +982,22 @@ private fun SupportedAppsSection( ) { Text( text = "SUPPORTED APPS", - fontSize = if (isCompact) 11.sp else 12.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurfaceVariant, - letterSpacing = 2.sp + fontSize = if (isCompact) 10.sp else 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = homeMutedTextColor(0.7f), + letterSpacing = 3.sp ) - Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) + Spacer(modifier = Modifier.height(6.dp)) - // Important notice about APK handling Text( - text = "Download the exact version from APKMirror and drop it here directly.", + text = if (isDefaultSource) "Download the exact version from APKMirror and drop it here." + else "Drop the APK for a supported app here.", fontSize = if (isCompact) 10.sp else 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + fontFamily = mono, + fontWeight = FontWeight.Normal, + color = homeMutedTextColor(0.5f), textAlign = TextAlign.Center, modifier = Modifier .widthIn(max = if (useVerticalLayout) 280.dp else 500.dp) @@ -675,368 +1008,994 @@ private fun SupportedAppsSection( when { isLoading -> { - // Loading state Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(32.dp) ) { CircularProgressIndicator( - modifier = Modifier.size(32.dp), - color = MorpheColors.Blue, - strokeWidth = 3.dp + modifier = Modifier.size(24.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + strokeWidth = 2.dp ) Spacer(modifier = Modifier.height(12.dp)) Text( text = "Loading patches...", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontSize = 11.sp, + fontFamily = mono, + color = homeMutedTextColor(0.5f) ) } } loadError != null -> { - // Error state Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp) ) { Text( - text = "Could not load supported apps", - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.error + text = "LOAD FAILED", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.error, + letterSpacing = 1.sp ) Spacer(modifier = Modifier.height(4.dp)) Text( text = loadError, - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, + fontSize = 11.sp, + fontFamily = mono, + color = homeMutedTextColor(0.6f), textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(12.dp)) OutlinedButton( onClick = onRetry, - shape = RoundedCornerShape(8.dp) + shape = RoundedCornerShape(corners.small), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant + ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)) ) { - Text("Retry") + Text( + "RETRY", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 1.sp + ) } } } supportedApps.isEmpty() -> { - // Empty state (shouldn't happen normally) Text( text = "No supported apps found", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontSize = 11.sp, + fontFamily = mono, + color = homeMutedTextColor(0.5f) ) } else -> { - // Display supported apps dynamically - if (useVerticalLayout) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - horizontalAlignment = Alignment.CenterHorizontally, + val focusManager = LocalFocusManager.current + var searchQuery by remember { mutableStateOf("") } + val filteredApps = if (searchQuery.isBlank()) supportedApps + else supportedApps.filter { + it.displayName.contains(searchQuery, ignoreCase = true) || + it.packageName.contains(searchQuery, ignoreCase = true) + } + + 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 + ) + } + Spacer(modifier = Modifier.height(12.dp)) + } + + var selectedApp by remember { mutableStateOf(null) } + // Clear selection if the selected app is filtered out + LaunchedEffect(searchQuery, filteredApps) { + if (selectedApp != null && filteredApps.none { it.packageName == selectedApp?.packageName }) { + selectedApp = null + } + } + + if (filteredApps.isEmpty()) { + Box( modifier = Modifier - .padding(horizontal = 16.dp) - .widthIn(max = 300.dp) + .fillMaxWidth() + .heightIn(min = 120.dp), + contentAlignment = Alignment.Center ) { - supportedApps.forEach { app -> - SupportedAppCardDynamic( - supportedApp = app, - isCompact = isCompact, - modifier = Modifier.fillMaxWidth() - ) - } + Text( + text = "No matching apps", + fontSize = 11.sp, + fontFamily = mono, + color = homeMutedTextColor(0.3f) + ) } } else { - Row( - horizontalArrangement = Arrangement.spacedBy(if (isCompact) 12.dp else 16.dp), - verticalAlignment = Alignment.Top, + SupportedAppsMasterDetail( + apps = filteredApps, + selectedApp = selectedApp, + onSelect = { app -> + selectedApp = if (selectedApp?.packageName == app.packageName) null else app + }, + onClose = { selectedApp = null }, + isDefaultSource = isDefaultSource, + useVerticalLayout = useVerticalLayout, modifier = Modifier + .fillMaxWidth() .padding(horizontal = if (isCompact) 8.dp else 16.dp) - .widthIn(max = 700.dp) - ) { - supportedApps.forEach { app -> - SupportedAppCardDynamic( - supportedApp = app, - isCompact = isCompact, - modifier = Modifier.weight(1f) - ) - } - } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { focusManager.clearFocus() } + ) } } } } } -/** - * Card showing current patches version with option to change. - */ +// ════════════════════════════════════════════════════════════════════ +// SHARED COMPONENTS +// ════════════════════════════════════════════════════════════════════ + +@Composable +private fun BrandingSection(isCompact: Boolean = false) { + val themeState = LocalThemeState.current + val isDark = when (themeState.current) { + ThemePreference.SYSTEM -> isSystemInDarkTheme() + else -> themeState.current.isDark() + } + Image( + painter = painterResource(if (isDark) Res.drawable.morphe_dark else Res.drawable.morphe_light), + contentDescription = "Morphe Logo", + modifier = Modifier.height(if (isCompact) 36.dp else 60.dp) + ) +} + +@Composable +private fun homeMutedTextColor(alpha: Float): Color { + return MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha) +} + +@Composable +private fun homeAccentTextColor(accent: Color): Color { + return accent +} + @Composable private fun PatchesVersionCard( patchesVersion: String, isLatest: Boolean, onChangePatchesClick: () -> Unit, + patchSourceName: String? = null, isCompact: Boolean = false, modifier: Modifier = Modifier ) { - Card( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onChangePatchesClick), - colors = CardDefaults.cardColors( - containerColor = MorpheColors.Blue.copy(alpha = 0.1f) - ), - shape = RoundedCornerShape(12.dp) + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val borderColor by animateColorAsState( + if (isHovered) accents.primary.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), + animationSpec = tween(200) + ) + + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center ) { - Row( + Box( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = if (isCompact) 10.dp else 12.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + .hoverable(hoverInteraction) + .clickable(onClick = onChangePatchesClick) ) { + // Source name + version + badge — single row + Row( + modifier = Modifier + .padding(vertical = if (isCompact) 8.dp else 10.dp) + .padding(start = 12.dp, end = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = patchSourceName?.uppercase() ?: "PATCHES", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Text( + text = " · ", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f) + ) + Text( + text = patchesVersion, + fontSize = if (isCompact) 12.sp else 13.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = accents.primary + ) + if (isLatest) { + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .background(accents.secondary.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) + .border(1.dp, accents.secondary.copy(alpha = 0.2f), RoundedCornerShape(corners.small)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = "LATEST", + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary, + letterSpacing = 1.sp + ) + } + } + } + } + } +} + +@Composable +private fun VersionWarningDialog( + versionStatus: VersionStatus, + currentVersion: String, + suggestedVersion: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val warningContent = resolveVersionWarningContent(versionStatus, currentVersion, suggestedVersion) + val warnColor = warningContent.colorType.toColor() + val title = warningContent.title + val message = warningContent.message + + AlertDialog( + onDismissRequest = onDismiss, + shape = RoundedCornerShape(corners.medium), + containerColor = MaterialTheme.colorScheme.surface, + icon = { + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = warnColor, + modifier = Modifier.size(28.dp) + ) + }, + title = { Text( - text = "Using patches", - fontSize = if (isCompact) 12.sp else 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = title, + fontWeight = FontWeight.Bold, + fontFamily = mono, + fontSize = 14.sp, + letterSpacing = 1.sp ) - Spacer(modifier = Modifier.width(8.dp)) - Surface( - color = MorpheColors.Blue.copy(alpha = 0.2f), - shape = RoundedCornerShape(4.dp) + }, + text = { + Text( + text = message, + fontFamily = mono, + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + lineHeight = 18.sp + ) + }, + confirmButton = { + Button( + onClick = onConfirm, + colors = ButtonDefaults.buttonColors(containerColor = warnColor), + shape = RoundedCornerShape(corners.small) ) { Text( - text = patchesVersion, - fontSize = if (isCompact) 11.sp else 12.sp, + "CONTINUE ANYWAY", + fontFamily = mono, fontWeight = FontWeight.SemiBold, - color = MorpheColors.Blue, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + fontSize = 11.sp, + letterSpacing = 0.5.sp ) } - if (isLatest) { - Spacer(modifier = Modifier.width(6.dp)) - Surface( - color = MorpheColors.Teal.copy(alpha = 0.2f), - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = "Latest", - fontSize = if (isCompact) 9.sp else 10.sp, - color = MorpheColors.Teal, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + shape = RoundedCornerShape(corners.small) + ) { + Text( + "CANCEL", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 0.5.sp + ) + } + } + ) +} + +/** + * Renders the supported apps as a centered, horizontally-scrolling row of + * vertical cards. The selected card expands its own width to reveal more + * detail without becoming a separate panel. + */ +@Composable +private fun SupportedAppsMasterDetail( + apps: List, + selectedApp: SupportedApp?, + onSelect: (SupportedApp) -> Unit, + onClose: () -> Unit, + isDefaultSource: Boolean, + useVerticalLayout: Boolean, + modifier: Modifier = Modifier +) { + val cardSpacing = 10.dp + + BoxWithConstraints(modifier = modifier) { + val parentWidth = maxWidth + val scrollState = rememberScrollState() + + Row( + modifier = Modifier + .horizontalScroll(scrollState) + .widthIn(min = parentWidth) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(cardSpacing, Alignment.CenterHorizontally), + verticalAlignment = Alignment.Top + ) { + apps.forEach { app -> + SupportedAppVerticalCard( + app = app, + isSelected = app.packageName == selectedApp?.packageName, + onClick = { onSelect(app) }, + isDefaultSource = isDefaultSource + ) } } } } /** - * Dynamic supported app card that uses SupportedApp data from patches. + * Single morphing card for a supported app. The card shows badge, name, + * both versions and a download button at all times. When selected, it + * expands its own width (same height) to reveal extended details on the + * right — there is no separate panel; the card itself grows. + * + * For third-party (non-Morphe) patch sources we hide the experimental row + * and download buttons (no Morphe API URL exists for them) and shrink the + * card height accordingly. */ @Composable -private fun SupportedAppCardDynamic( - supportedApp: SupportedApp, - isCompact: Boolean = false, +private fun SupportedAppVerticalCard( + app: SupportedApp, + isSelected: Boolean, + onClick: () -> Unit, + isDefaultSource: Boolean, modifier: Modifier = Modifier ) { - var showAllVersions by remember { mutableStateOf(false) } + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + + // ── Dimensions ── + val collapsedWidth = 188.dp + val expandedExtraWidth = 320.dp + val cardHeight = if (isDefaultSource) 250.dp else 190.dp + + // ── Animations ── + val animatedExtraWidth by animateDpAsState( + targetValue = if (isSelected) expandedExtraWidth else 0.dp, + animationSpec = tween(durationMillis = 340, easing = FastOutSlowInEasing), + label = "extraWidth" + ) - val cardPadding = if (isCompact) 12.dp else 16.dp + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() - val downloadUrl = supportedApp.apkDownloadUrl + val backgroundColor by animateColorAsState( + when { + isSelected -> accents.primary.copy(alpha = 0.08f) + isHovered -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f) + else -> MaterialTheme.colorScheme.surface + }, + animationSpec = tween(200), + label = "cardBg" + ) + val borderColor by animateColorAsState( + when { + isSelected -> accents.primary.copy(alpha = 0.6f) + isHovered -> MaterialTheme.colorScheme.outline.copy(alpha = 0.28f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + }, + animationSpec = tween(200), + label = "cardBorder" + ) - Card( - modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(if (isCompact) 12.dp else 16.dp) + val initial = app.displayName.firstOrNull()?.uppercase() ?: "?" + val hasExperimental = app.experimentalVersions.isNotEmpty() + val latestExperimental = app.experimentalVersions.firstOrNull() + val otherStable = app.supportedVersions.filter { it != app.recommendedVersion } + val downloadUrl = app.apkDownloadUrl + + // The whole card is one Row sharing a single border. The left section is + // always visible and clickable; the right section is conditionally rendered + // with an animated width. + Row( + modifier = modifier + .height(cardHeight) + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(backgroundColor) ) { + // ════════════════════════════════════════════════ + // LEFT: always-visible card content + // ════════════════════════════════════════════════ Column( modifier = Modifier - .fillMaxWidth() - .padding(cardPadding), + .width(collapsedWidth) + .fillMaxHeight() + .hoverable(hoverInteraction) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { // App name Text( - text = supportedApp.displayName, - fontSize = if (isCompact) 14.sp else 16.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + text = app.displayName, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth() ) - Spacer(modifier = Modifier.height(if (isCompact) 6.dp else 8.dp)) - - // Recommended version badge (dynamic from patches) - if (supportedApp.recommendedVersion != null) { - val cornerRadius = if (isCompact) 6.dp else 8.dp - Surface( - color = MorpheColors.Teal.copy(alpha = 0.15f), - shape = RoundedCornerShape(cornerRadius), - modifier = Modifier - .clip(RoundedCornerShape(cornerRadius)) - .clickable { showAllVersions = !showAllVersions } + Spacer(modifier = Modifier.height(12.dp)) + + // Initial badge + Box( + modifier = Modifier + .size(46.dp) + .border( + 1.dp, + accents.primary.copy(alpha = if (isSelected) 0.7f else 0.4f), + RoundedCornerShape(corners.small) + ) + .background( + accents.primary.copy(alpha = if (isSelected) 0.15f else 0.06f), + RoundedCornerShape(corners.small) + ), + contentAlignment = Alignment.Center + ) { + Text( + text = initial, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Stable: "STABLE LATEST" + version button + VersionWithDownload( + channelLabel = "STABLE LATEST", + channelColor = accents.primary, + version = app.recommendedVersion, + downloadUrl = downloadUrl, + mono = mono, + corners = corners, + 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 + ) + } + } + + // ════════════════════════════════════════════════ + // RIGHT: expanded detail (animated width) + // ════════════════════════════════════════════════ + if (animatedExtraWidth > 0.dp) { + // Internal vertical divider connecting the two halves of the same card + Box( + modifier = Modifier + .width(1.dp) + .fillMaxHeight() + .background(borderColor) + ) + + Column( + modifier = Modifier + .width((animatedExtraWidth - 1.dp).coerceAtLeast(0.dp)) + .fillMaxHeight() + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + // ── Package name + close ── + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - Column( - modifier = Modifier.padding( - horizontal = if (isCompact) 10.dp else 12.dp, - vertical = if (isCompact) 6.dp else 8.dp - ), - horizontalAlignment = Alignment.CenterHorizontally + Text( + text = app.packageName, + fontSize = 10.sp, + fontFamily = mono, + color = homeMutedTextColor(0.55f), + letterSpacing = 0.3.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + Spacer(Modifier.width(8.dp)) + + val closeHover = remember { MutableInteractionSource() } + val isCloseHovered by closeHover.collectIsHoveredAsState() + val closeBg by animateColorAsState( + if (isCloseHovered) MaterialTheme.colorScheme.error.copy(alpha = 0.12f) + else Color.Transparent, + animationSpec = tween(150), + label = "closeBg" + ) + val closeBorderColor by animateColorAsState( + if (isCloseHovered) MaterialTheme.colorScheme.error.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.18f), + animationSpec = tween(150), + label = "closeBorder" + ) + Box( + modifier = Modifier + .size(24.dp) + .hoverable(closeHover) + .clip(RoundedCornerShape(corners.small)) + .background(closeBg, RoundedCornerShape(corners.small)) + .border(1.dp, closeBorderColor, RoundedCornerShape(corners.small)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center ) { - Text( - text = "Recommended", - fontSize = if (isCompact) 9.sp else 10.sp, - color = MorpheColors.Teal.copy(alpha = 0.8f), - letterSpacing = 0.5.sp - ) - Text( - text = "v${supportedApp.recommendedVersion}", - fontSize = if (isCompact) 12.sp else 14.sp, - fontWeight = FontWeight.SemiBold, - color = MorpheColors.Teal + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Close", + tint = if (isCloseHovered) MaterialTheme.colorScheme.error + else homeMutedTextColor(0.55f), + modifier = Modifier.size(12.dp) ) - // Show version count if more than 1 (excluding recommended) - val otherVersionsCount = supportedApp.supportedVersions.count { it != supportedApp.recommendedVersion } - if (otherVersionsCount > 0) { - Text( - text = if (showAllVersions) "▲ Hide versions" else "▼ +$otherVersionsCount more", - fontSize = if (isCompact) 9.sp else 10.sp, - color = MorpheColors.Teal.copy(alpha = 0.6f) - ) - } } } - // Expandable versions list (excluding recommended version) - val otherVersions = supportedApp.supportedVersions.filter { it != supportedApp.recommendedVersion } - if (showAllVersions && otherVersions.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Surface( - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.8f), - shape = RoundedCornerShape(6.dp) + Spacer(modifier = Modifier.height(12.dp)) + + // ── ALSO STABLE tags ── + Text( + text = "ALSO STABLE", + 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 (otherStable.isNotEmpty()) { + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() ) { - Column( - modifier = Modifier.padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Other supported versions:", - fontSize = 9.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + otherStable.take(8).forEach { version -> + VersionPill( + version = version, + color = accents.primary, + mono = mono, + corners = corners ) - Spacer(modifier = Modifier.height(4.dp)) - // Show versions in a compact grid-like format - val versionsText = otherVersions.joinToString(", ") { "v$it" } + } + if (otherStable.size > 8) { Text( - text = versionsText, + text = "+${otherStable.size - 8}", fontSize = 10.sp, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - lineHeight = 14.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) + ) } - } else { - // No specific version recommended - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(if (isCompact) 6.dp else 8.dp) - ) { + + if (isDefaultSource) { + Spacer(modifier = Modifier.height(14.dp)) + + // ── EXPERIMENTAL tags (Morphe-source patches only) ── Text( - text = "Any version", - fontSize = if (isCompact) 11.sp else 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding( - horizontal = if (isCompact) 10.dp else 12.dp, - vertical = if (isCompact) 6.dp else 8.dp - ) + 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 = "none", + fontSize = 10.sp, + fontFamily = mono, + color = homeMutedTextColor(0.35f) + ) + } } } + } + } +} - Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) +/** + * A small "channel label + version button" used inside the collapsed card. + * The label tells the user which channel (e.g. STABLE LATEST), and the button + * below shows the version string with an open-in-new icon, doubling as the + * download link to that specific version's page. + */ +@Composable +private fun VersionWithDownload( + channelLabel: String, + channelColor: Color, + version: String?, + downloadUrl: String?, + mono: androidx.compose.ui.text.font.FontFamily, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + nullLabel: String = "—" +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = channelLabel, + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = channelColor.copy(alpha = 0.85f), + letterSpacing = 1.2.sp + ) - // Download from APKMirror button (only if URL is configured) - if (downloadUrl != null) { - val uriHandler = LocalUriHandler.current - OutlinedButton( - onClick = { - openUrlAndFollowRedirects(downloadUrl) { urlResolved -> - uriHandler.openUri(urlResolved) - } - }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(if (isCompact) 6.dp else 8.dp), - contentPadding = PaddingValues( - horizontal = if (isCompact) 8.dp else 12.dp, - vertical = if (isCompact) 6.dp else 8.dp - ), - colors = ButtonDefaults.outlinedButtonColors( - contentColor = MorpheColors.Blue - ) +// Spacer(modifier = Modifier.height(3.dp)) + + if (downloadUrl != null) { + val uriHandler = LocalUriHandler.current + OutlinedButton( + onClick = { + openUrlAndFollowRedirects(downloadUrl) { urlResolved -> + uriHandler.openUri(urlResolved) + } + }, + modifier = Modifier.fillMaxWidth().height(28.dp), + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = channelColor), + border = BorderStroke(1.dp, channelColor.copy(alpha = 0.4f)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() ) { Text( - text = "Download original APK", - fontSize = if (isCompact) 11.sp else 12.sp, - fontWeight = FontWeight.Medium + text = version?.let { "v$it" } ?: nullLabel, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.width(6.dp)) + Icon( + imageVector = Icons.AutoMirrored.Filled.OpenInNew, + contentDescription = null, + modifier = Modifier.size(11.dp) ) } - - Spacer(modifier = Modifier.height(if (isCompact) 6.dp else 8.dp)) } - - // Package name + } else { + // No download URL — show the version as plain text. Either the + // version is genuinely absent (e.g. no experimental version exists, + // version == null) in which case we render a faint placeholder, or + // this is a third-party patch source where we don't provide download + // links but the version is real and should look like the primary + // information on the card. Text( - text = supportedApp.packageName, - fontSize = if (isCompact) 9.sp else 10.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + text = version?.let { "v$it" } ?: nullLabel, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = if (version != null) channelColor + else channelColor.copy(alpha = 0.3f), textAlign = TextAlign.Center, - maxLines = 1 + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 0.dp) ) } } } +/** + * Slim, elongated search field used when third-party patches are loaded. + * Built on BasicTextField so we can drop below the 56dp minimum height that + * Material 3's OutlinedTextField enforces internally. Visually mirrors the + * default OutlinedTextField (border, leading search icon, trailing clear, + * mono placeholder), just thinner and wider. + */ +@Composable +private fun SlimSearchField( + value: String, + onValueChange: (String) -> Unit, + mono: androidx.compose.ui.text.font.FontFamily, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + accents: app.morphe.gui.ui.theme.MorpheAccentColors +) { + val muted = MaterialTheme.colorScheme.onSurfaceVariant + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + val borderColor by animateColorAsState( + if (isFocused) MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), + animationSpec = tween(150), + label = "slimSearchBorder" + ) + + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + interactionSource = interactionSource, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = mono, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(accents.primary), + modifier = Modifier + .widthIn(max = 360.dp) + .fillMaxWidth() + .height(34.dp) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)), + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Search, + contentDescription = null, + tint = muted.copy(alpha = 0.55f), + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Box(modifier = Modifier.weight(1f)) { + if (value.isEmpty()) { + Text( + "Filter apps…", + fontSize = 11.sp, + fontFamily = mono, + color = muted.copy(alpha = 0.4f) + ) + } + innerTextField() + } + if (value.isNotEmpty()) { + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = Modifier + .size(18.dp) + .clip(RoundedCornerShape(corners.small)) + .clickable { onValueChange("") }, + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + tint = muted.copy(alpha = 0.5f), + modifier = Modifier.size(12.dp) + ) + } + } + } + } + ) +} + +/** + * Small version pill used inside the expanded card section. + */ +@Composable +private fun VersionPill( + version: String, + color: Color, + mono: androidx.compose.ui.text.font.FontFamily, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle +) { + Box( + modifier = Modifier + .border(1.dp, color.copy(alpha = 0.25f), RoundedCornerShape(corners.small)) + .background(color.copy(alpha = 0.05f), RoundedCornerShape(corners.small)) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = "v$version", + fontSize = 10.sp, + fontFamily = mono, + fontWeight = FontWeight.Medium, + color = color.copy(alpha = 0.85f) + ) + } +} + +// ════════════════════════════════════════════════════════════════════ +// DRAG OVERLAY +// ════════════════════════════════════════════════════════════════════ + @Composable private fun DragOverlay() { + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val bracketColor = accents.primary.copy(alpha = 0.6f) + Box( modifier = Modifier .fillMaxSize() - .background( - Brush.radialGradient( - colors = listOf( - MorpheColors.Blue.copy(alpha = 0.15f), - MorpheColors.Blue.copy(alpha = 0.05f) - ) - ) - ), + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.9f)) + .drawBehind { + val strokeWidth = 3f + val len = 48.dp.toPx() + val inset = 24.dp.toPx() + + // Top-left + drawLine(bracketColor, Offset(inset, inset), Offset(inset + len, inset), strokeWidth) + drawLine(bracketColor, Offset(inset, inset), Offset(inset, inset + len), strokeWidth) + // Top-right + drawLine(bracketColor, Offset(size.width - inset, inset), Offset(size.width - inset - len, inset), strokeWidth) + drawLine(bracketColor, Offset(size.width - inset, inset), Offset(size.width - inset, inset + len), strokeWidth) + // Bottom-left + drawLine(bracketColor, Offset(inset, size.height - inset), Offset(inset + len, size.height - inset), strokeWidth) + drawLine(bracketColor, Offset(inset, size.height - inset), Offset(inset, size.height - inset - len), strokeWidth) + // Bottom-right + drawLine(bracketColor, Offset(size.width - inset, size.height - inset), Offset(size.width - inset - len, size.height - inset), strokeWidth) + drawLine(bracketColor, Offset(size.width - inset, size.height - inset), Offset(size.width - inset, size.height - inset - len), strokeWidth) + }, contentAlignment = Alignment.Center ) { - Card( - modifier = Modifier.padding(32.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - shape = RoundedCornerShape(24.dp) - ) { - Column( - modifier = Modifier.padding(48.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Drop APK here", - fontSize = 24.sp, - fontWeight = FontWeight.Medium, - color = MorpheColors.Blue - ) - } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "DROP APK", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 6.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = ".apk · .apkm · .xapk · .apks", + fontSize = 11.sp, + fontFamily = mono, + color = accents.primary.copy(alpha = 0.4f), + letterSpacing = 1.sp + ) } } } @@ -1044,7 +2003,7 @@ private fun DragOverlay() { private fun openFilePicker(): File? { val fileDialog = FileDialog(null as Frame?, "Select APK File", FileDialog.LOAD).apply { isMultipleMode = false - setFilenameFilter { _, name -> name.lowercase().let { it.endsWith(".apk") || it.endsWith(".apkm") } } + setFilenameFilter { _, name -> name.lowercase().let { it.endsWith(".apk") || it.endsWith(".apkm") || it.endsWith(".xapk") || it.endsWith(".apks") } } isVisible = true } 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 8686be3..cee236f 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 @@ -11,10 +11,13 @@ 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.PatchRepository +import app.morphe.gui.data.repository.PatchSourceManager import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import net.dongliu.apk.parser.ApkFile @@ -22,24 +25,44 @@ import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService import app.morphe.gui.util.SupportedAppExtractor +import app.morphe.gui.util.VersionStatus import java.io.File class HomeViewModel( - private val patchRepository: PatchRepository, + private val patchSourceManager: PatchSourceManager, private val patchService: PatchService, private val configRepository: ConfigRepository ) : ScreenModel { - private val _uiState = MutableStateFlow(HomeUiState()) + private var patchRepository: PatchRepository = patchSourceManager.getActiveRepositorySync() + private var localPatchFilePath: String? = patchSourceManager.getLocalFilePath() + private var isDefaultSource: Boolean = patchSourceManager.isDefaultSource() + + private val _uiState = MutableStateFlow(HomeUiState(isDefaultSource = isDefaultSource)) val uiState: StateFlow = _uiState.asStateFlow() // Cached patches and supported apps private var cachedPatches: List = emptyList() private var cachedPatchesFile: File? = null + private var loadJob: Job? = null init { // Auto-fetch patches on startup loadPatchesAndSupportedApps() + + // Observe source changes — drop(1) to skip the initial value + screenModelScope.launch { + patchSourceManager.sourceVersion.drop(1).collect { + Logger.info("HomeVM: Source changed, reloading patches...") + patchRepository = patchSourceManager.getActiveRepositorySync() + localPatchFilePath = patchSourceManager.getLocalFilePath() + isDefaultSource = patchSourceManager.isDefaultSource() + lastLoadedVersion = null + cachedPatchesFile = null + _uiState.value = HomeUiState(isDefaultSource = isDefaultSource) + loadPatchesAndSupportedApps(forceRefresh = true) + } + } } // Track the last loaded version to avoid reloading unnecessarily @@ -50,9 +73,24 @@ class HomeViewModel( * If a saved version exists in config, load that version instead of latest. */ private fun loadPatchesAndSupportedApps(forceRefresh: Boolean = false) { - screenModelScope.launch { + 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() @@ -125,9 +163,15 @@ class HomeViewModel( 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 = "Could not load patches: ${patchesResult.exceptionOrNull()?.message}" + patchLoadError = friendlyError ) return@launch } @@ -144,6 +188,7 @@ class HomeViewModel( supportedApps = supportedApps, patchesVersion = release.tagName, latestPatchesVersion = latestVersion, + patchSourceName = patchSourceManager.getActiveSourceName(), patchLoadError = null ) } catch (e: Exception) { @@ -170,22 +215,24 @@ class HomeViewModel( /** * Find any cached .mpp file when offline. * Prefers the file matching savedVersion from config. + * Searches the per-source cache directory. */ private fun findCachedPatchFile(savedVersion: String?): File? { - val patchesDir = FileUtils.getPatchesDir() - val mppFiles = patchesDir.listFiles { file -> file.extension.equals("mpp", ignoreCase = true) } - ?.filter { it.length() > 0 } - ?: return null + 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 (mppFiles.isEmpty()) 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") - mppFiles.firstOrNull { it.name.contains(versionNumber, ignoreCase = true) } - ?: mppFiles.maxByOrNull { it.lastModified() } + patchFiles.firstOrNull { it.name.contains(versionNumber, ignoreCase = true) } + ?: patchFiles.maxByOrNull { it.lastModified() } } else { - mppFiles.maxByOrNull { it.lastModified() } + patchFiles.maxByOrNull { it.lastModified() } } } @@ -203,7 +250,7 @@ class HomeViewModel( * 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?) { + private suspend fun loadPatchesFromFile(patchFile: File, version: String, latestVersion: String?, isOffline: Boolean = true) { cachedPatchesFile = patchFile lastLoadedVersion = version @@ -211,23 +258,30 @@ class HomeViewModel( 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 = "Could not load cached patches: ${patchesResult.exceptionOrNull()?.message}" + patchLoadError = friendlyError ) return } cachedPatches = patches val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) - Logger.info("Loaded ${supportedApps.size} supported apps from cached patches: ${patchFile.name}") + Logger.info("Loaded ${supportedApps.size} supported apps from ${if (isOffline) "cached" else "local"} patches: ${patchFile.name}") _uiState.value = _uiState.value.copy( isLoadingPatches = false, - isOffline = true, + isOffline = isOffline, supportedApps = supportedApps, patchesVersion = version, latestPatchesVersion = latestVersion, + patchSourceName = patchSourceManager.getActiveSourceName(), patchLoadError = null ) } @@ -306,7 +360,7 @@ class HomeViewModel( onFileSelected(apkFile) } else { _uiState.value = _uiState.value.copy( - error = "Please drop a valid .apk or .apkm file", + error = "Please drop a valid .apk, .apkm, .xapk, or .apks file", isReady = false ) } @@ -343,7 +397,7 @@ class HomeViewModel( } if (!FileUtils.isApkFile(file)) { - return ApkValidationResult(false, errorMessage = "File must have .apk or .apkm extension") + return ApkValidationResult(false, errorMessage = "File must have .apk, .apkm, .xapk, or .apks extension") } if (file.length() < 1024) { @@ -365,11 +419,11 @@ class HomeViewModel( * This works with APKs from any source, not just APKMirror. */ private fun parseApkManifest(file: File): ApkInfo? { - // For .apkm files, extract base.apk first - val isApkm = file.extension.equals("apkm", ignoreCase = true) - val apkToParse = if (isApkm) { - FileUtils.extractBaseApkFromApkm(file) ?: run { - Logger.error("Failed to extract base.apk from APKM: ${file.name}") + // For split APK bundles (.apkm, .xapk, .apks), extract base.apk first + val isBundleFormat = FileUtils.isBundleFormat(file) + val apkToParse = if (isBundleFormat) { + FileUtils.extractBaseApkFromBundle(file) ?: run { + Logger.error("Failed to extract base APK from bundle: ${file.name}") return null } } else { @@ -393,27 +447,26 @@ class HomeViewModel( ) if (!isSupported) { - Logger.warn("Unsupported package: $packageName") - return null + Logger.warn("Unsupported package: $packageName — no compatible patches found") } - // Get app display name - prefer dynamic, fallback to hardcoded + // Get app display name - prefer dynamic, fallback to hardcoded, then package name val appName = dynamicSupportedApp?.displayName - ?: SupportedApp.getDisplayName(packageName) - - // Get recommended version from dynamic patches data (no hardcoded fallback) - val suggestedVersion = dynamicSupportedApp?.recommendedVersion + ?: SupportedApp.resolveDisplayName(packageName, meta.label) - // Compare versions if we have a suggested version - val versionStatus = if (suggestedVersion != null) { - compareVersions(versionName, suggestedVersion) + // Resolve the version against the supported app's stable + + // experimental version lists. + val versionResolution = if (dynamicSupportedApp != null) { + app.morphe.gui.util.resolveVersionStatus(versionName, dynamicSupportedApp) } else { - VersionStatus.UNKNOWN + app.morphe.gui.util.VersionResolution(VersionStatus.UNKNOWN, null) } + val suggestedVersion = versionResolution.suggestedVersion + val versionStatus = versionResolution.status // Get supported architectures from native libraries - // For .apkm files, scan the original bundle (splits contain the native libs, not base.apk) - val architectures = extractArchitectures(if (isApkm) file else apkToParse) + // For split bundles, scan the original bundle (splits contain the native libs, not base.apk) + val architectures = FileUtils.extractArchitectures(if (isBundleFormat) file else apkToParse) // TODO: Re-enable when checksums are provided via .mpp files val checksumStatus = app.morphe.gui.util.ChecksumStatus.NotConfigured @@ -432,58 +485,15 @@ class HomeViewModel( minSdk = minSdk, suggestedVersion = suggestedVersion, versionStatus = versionStatus, - checksumStatus = checksumStatus + checksumStatus = checksumStatus, + isUnsupportedApp = !isSupported ) } } catch (e: Exception) { Logger.error("Failed to parse APK manifest", e) null } finally { - if (isApkm) apkToParse.delete() - } - } - - /** - * Extract supported CPU architectures from native libraries in the APK. - * Uses ZipFile to scan for lib// directories. - */ - private fun extractArchitectures(file: File): List { - return try { - java.util.zip.ZipFile(file).use { zip -> - val archDirs = mutableSetOf() - - // Scan for lib// entries directly (regular APK or merged APK) - zip.entries().asSequence() - .map { it.name } - .filter { it.startsWith("lib/") } - .mapNotNull { path -> - val parts = path.split("/") - if (parts.size >= 2) parts[1] else null - } - .forEach { archDirs.add(it) } - - // For .apkm bundles: also detect arch from split APK names - // e.g. split_config.arm64_v8a.apk -> arm64-v8a - if (archDirs.isEmpty()) { - val knownArchs = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") - zip.entries().asSequence() - .map { it.name } - .filter { it.endsWith(".apk") } - .forEach { name -> - // Convert split_config.arm64_v8a.apk format to arm64-v8a - val normalized = name.replace("_", "-") - knownArchs.filter { arch -> normalized.contains(arch) } - .forEach { archDirs.add(it) } - } - } - - archDirs.toList().ifEmpty { - listOf("universal") - } - } - } catch (e: Exception) { - Logger.warn("Could not extract architectures: ${e.message}") - emptyList() + if (isBundleFormat) apkToParse.delete() } } @@ -502,31 +512,7 @@ class HomeViewModel( } } - /** - * Compares two version strings (e.g., "19.16.39" vs "20.40.45") - * Returns the version status of the current version relative to suggested. - */ - private fun compareVersions(current: String, suggested: String): VersionStatus { - return try { - val currentParts = current.split(".").map { it.toInt() } - val suggestedParts = suggested.split(".").map { it.toInt() } - - // Compare each part - for (i in 0 until maxOf(currentParts.size, suggestedParts.size)) { - val currentPart = currentParts.getOrElse(i) { 0 } - val suggestedPart = suggestedParts.getOrElse(i) { 0 } - - when { - currentPart > suggestedPart -> return VersionStatus.NEWER_VERSION - currentPart < suggestedPart -> return VersionStatus.OLDER_VERSION - } - } - VersionStatus.EXACT_MATCH - } catch (e: Exception) { - Logger.warn("Failed to compare versions: $current vs $suggested") - VersionStatus.UNKNOWN - } - } + // compareVersions and VersionStatus moved to app.morphe.gui.util.VersionUtils } data class HomeUiState( @@ -539,9 +525,11 @@ data class HomeUiState( // Dynamic patches data val isLoadingPatches: Boolean = true, val isOffline: Boolean = false, + val isDefaultSource: Boolean = true, val supportedApps: List = emptyList(), val patchesVersion: String? = null, - val latestPatchesVersion: String? = null, // Track the latest available version + val latestPatchesVersion: String? = null, + val patchSourceName: String? = null, val patchLoadError: String? = null ) { val isUsingLatestPatches: Boolean @@ -560,16 +548,10 @@ data class ApkInfo( val minSdk: Int? = null, val suggestedVersion: String? = null, val versionStatus: VersionStatus = VersionStatus.UNKNOWN, - val checksumStatus: app.morphe.gui.util.ChecksumStatus = app.morphe.gui.util.ChecksumStatus.NotConfigured + val checksumStatus: app.morphe.gui.util.ChecksumStatus = app.morphe.gui.util.ChecksumStatus.NotConfigured, + val isUnsupportedApp: Boolean = false ) -enum class VersionStatus { - EXACT_MATCH, // Using the suggested version - OLDER_VERSION, // Using an older version (newer patches available) - NEWER_VERSION, // Using a newer version (might have issues) - UNKNOWN // Could not determine -} - data class ApkValidationResult( val isValid: Boolean, val apkInfo: ApkInfo? = null, 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 00c7e5c..3702001 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 @@ -5,27 +5,41 @@ package app.morphe.gui.ui.screens.home.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.Close +import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import app.morphe.gui.ui.screens.home.ApkInfo -import app.morphe.gui.ui.screens.home.VersionStatus -import app.morphe.gui.ui.theme.MorpheColors +import app.morphe.gui.util.VersionStatus +import app.morphe.gui.ui.theme.LocalMorpheFont +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.util.ChecksumStatus +import app.morphe.gui.util.DeviceMonitor +import app.morphe.gui.util.resolveStatusColorType +import app.morphe.gui.util.resolveVersionStatusDisplay +import app.morphe.gui.util.toColor @Composable fun ApkInfoCard( @@ -33,262 +47,256 @@ fun ApkInfoCard( onClearClick: () -> Unit, modifier: Modifier = Modifier ) { - Card( - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(16.dp) + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val accentColor = resolveStatusColorType(apkInfo.versionStatus, apkInfo.checksumStatus).toColor() + val cardShape = RoundedCornerShape(corners.medium) + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) + + Box( + modifier = modifier + .fillMaxWidth() + .clip(cardShape) + .border(1.dp, borderColor, cardShape) + .background(MaterialTheme.colorScheme.surface) ) { + // Left accent stripe + Box( + modifier = Modifier + .width(3.dp) + .fillMaxHeight() + .background(accentColor) + .align(Alignment.CenterStart) + ) + Column( - modifier = Modifier.padding(20.dp) + modifier = Modifier.fillMaxWidth() ) { - // Header with app icon and close button + // ── Header: app identity + dismiss ── Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top + modifier = Modifier + .fillMaxWidth() + .padding(start = 23.dp, end = 20.dp, top = 16.dp, bottom = 16.dp), + verticalAlignment = Alignment.CenterVertically ) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.weight(1f) - ) { - Box( - modifier = Modifier - .size(64.dp) - .clip(RoundedCornerShape(14.dp)) - .background(Color.White), - contentAlignment = Alignment.Center - ) { - Text( - text = apkInfo.appName.first().toString(), - fontSize = 24.sp, - fontWeight = FontWeight.Bold, - color = MorpheColors.Blue - ) - } - - Column { - // App name - Text( - text = apkInfo.appName, - fontSize = 22.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - - Spacer(modifier = Modifier.height(2.dp)) - - // Version - Text( - text = "v${apkInfo.versionName}", - fontSize = 15.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - // Close button - IconButton( - onClick = onClearClick, + // App initial — monospace, bold, in accent + Box( modifier = Modifier - .size(32.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)) + .size(44.dp) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, accentColor.copy(alpha = 0.5f), RoundedCornerShape(corners.small)) + .background(accentColor.copy(alpha = 0.08f)), + contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Remove", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp) + Text( + text = apkInfo.appName.first().uppercase(), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accentColor ) } - } - Spacer(modifier = Modifier.height(20.dp)) + Spacer(Modifier.width(14.dp)) - // Info grid - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - // Size - InfoColumn( - label = "Size", - value = apkInfo.formattedSize, - modifier = Modifier.weight(1f) - ) - - // Architecture - InfoColumn( - label = "Architecture", - value = if (apkInfo.architectures.isEmpty()) "Unknown" else apkInfo.architectures.joinToString(", "), - modifier = Modifier.weight(1f) - ) - - // Min SDK - if (apkInfo.minSdk != null) { - InfoColumn( - label = "Min SDK", - value = "API ${apkInfo.minSdk}", - modifier = Modifier.weight(1f) + Column(modifier = Modifier.weight(1f)) { + Text( + text = apkInfo.appName, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - } - } - - // Version and checksum status section - Spacer(modifier = Modifier.height(16.dp)) - - HorizontalDivider( - color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - ) - - Spacer(modifier = Modifier.height(16.dp)) - - // Version status - if (apkInfo.suggestedVersion != null && apkInfo.versionStatus != VersionStatus.EXACT_MATCH) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - VersionStatusBanner( - versionStatus = apkInfo.versionStatus, - currentVersion = apkInfo.versionName, - suggestedVersion = apkInfo.suggestedVersion + Spacer(Modifier.height(2.dp)) + Text( + text = apkInfo.packageName, + fontSize = 11.sp, + fontFamily = mono, + color = homeCardMutedTextColor(0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + letterSpacing = 0.3.sp ) } - Spacer(modifier = Modifier.height(8.dp)) + // Dismiss button + val closeHover = remember { MutableInteractionSource() } + val isCloseHovered by closeHover.collectIsHoveredAsState() + val closeBg by animateColorAsState( + if (isCloseHovered) MaterialTheme.colorScheme.error.copy(alpha = 0.12f) + else Color.Transparent, + animationSpec = tween(150) + ) + val closeBorder by animateColorAsState( + if (isCloseHovered) MaterialTheme.colorScheme.error.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f), + animationSpec = tween(150) + ) - // Checksum warning for non-recommended versions Box( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .size(44.dp) + .hoverable(closeHover) + .clip(RoundedCornerShape(corners.small)) + .background(closeBg, RoundedCornerShape(corners.small)) + .border(1.dp, closeBorder, RoundedCornerShape(corners.small)) + .clickable(onClick = onClearClick), contentAlignment = Alignment.Center ) { - Text( - text = "Checksum verification unavailable for non-recommended versions", - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - textAlign = TextAlign.Center + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove APK", + tint = if (isCloseHovered) MaterialTheme.colorScheme.error + else homeCardMutedTextColor(0.5f), + modifier = Modifier.size(18.dp) ) } - } else if (apkInfo.versionStatus == VersionStatus.EXACT_MATCH) { - // Show checksum status for recommended version - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - ChecksumStatusBanner(checksumStatus = apkInfo.checksumStatus) - } } - } - } -} -@Composable -private fun ChecksumStatusBanner(checksumStatus: ChecksumStatus) { - when (checksumStatus) { - is ChecksumStatus.Verified -> { - Surface( - color = MorpheColors.Teal.copy(alpha = 0.15f), - shape = RoundedCornerShape(8.dp) - ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally + // ── Unsupported app warning ── + if (apkInfo.isUnsupportedApp) { + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(20.dp.toPx(), 0f), + end = Offset(size.width - 20.dp.toPx(), 0f), + strokeWidth = 1f + ) + } + .background(accents.warning.copy(alpha = 0.08f)) + .padding(start = 23.dp, end = 20.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text( - text = "Recommended version - Verified", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = MorpheColors.Teal + val warningOrange = accents.warning + Icon( + imageVector = Icons.Default.Warning, + contentDescription = null, + tint = warningOrange, + modifier = Modifier.size(16.dp) ) Text( - text = "Checksum matches APKMirror", - fontSize = 10.sp, - color = MorpheColors.Teal.copy(alpha = 0.8f) + text = "No compatible patches found for this app. You can still proceed, but patching may have no effect.", + fontSize = 11.sp, + color = warningOrange, + lineHeight = 14.sp ) } } - } - is ChecksumStatus.Mismatch -> { - Surface( - color = MaterialTheme.colorScheme.error.copy(alpha = 0.15f), - shape = RoundedCornerShape(8.dp) + // ── Technical data grid ── + 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 = 14.dp, bottom = 14.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp) ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "Checksum Mismatch", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.error - ) - Text( - text = "File may be corrupted or modified. Re-download from APKMirror.", - fontSize = 10.sp, - color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f), - textAlign = TextAlign.Center + TechDataCell( + label = "VERSION", + value = apkInfo.versionName, + mono = mono, + modifier = Modifier.weight(1f) + ) + TechDataCell( + label = "SIZE", + value = apkInfo.formattedSize, + mono = mono, + modifier = Modifier.weight(1f) + ) + if (apkInfo.minSdk != null) { + TechDataCell( + label = "MIN SDK", + value = "API ${apkInfo.minSdk}", + mono = mono, + modifier = Modifier.weight(1f) ) } } - } - is ChecksumStatus.NotConfigured -> { - Surface( - color = MorpheColors.Teal.copy(alpha = 0.15f), - shape = RoundedCornerShape(8.dp) - ) { - Text( - text = "Using recommended version", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = MorpheColors.Teal, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) - ) - } - } + // ── Architectures — shown as individual tags, device arch highlighted ── + if (apkInfo.architectures.isNotEmpty()) { + val deviceState by DeviceMonitor.state.collectAsState() + val deviceArch = deviceState.selectedDevice?.architecture + val hasMultipleArchs = apkInfo.architectures.size > 1 + // Highlight the device's arch when connected and APK has multiple archs + val highlightArch = if (hasMultipleArchs && deviceArch != null) deviceArch else null - is ChecksumStatus.Error -> { - Surface( - color = Color(0xFFFF9800).copy(alpha = 0.15f), - shape = RoundedCornerShape(8.dp) - ) { - Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally + 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 = "Using recommended version", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = Color(0xFFFF9800) - ) - Text( - text = "Could not verify checksum", - fontSize = 10.sp, - color = Color(0xFFFF9800).copy(alpha = 0.8f) + text = "ARCH", + 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)) + apkInfo.architectures.forEach { arch -> + val isDeviceArch = highlightArch != null && arch == highlightArch + val tagBorder = if (isDeviceArch) accents.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f) + val tagBg = if (isDeviceArch) accents.primary.copy(alpha = 0.08f) + else Color.Transparent + val tagColor = if (isDeviceArch) accents.primary + else MaterialTheme.colorScheme.onSurface + val dimmed = highlightArch != null && !isDeviceArch + + Box( + modifier = Modifier + .border(1.dp, tagBorder, RoundedCornerShape(corners.small)) + .background(tagBg, RoundedCornerShape(corners.small)) + .padding(horizontal = 8.dp, vertical = 3.dp) + ) { + Text( + text = arch, + fontSize = 11.sp, + fontWeight = if (isDeviceArch) FontWeight.Bold else FontWeight.Medium, + fontFamily = mono, + color = if (dimmed) tagColor.copy(alpha = 0.35f) else tagColor + ) + } + } } } - } - is ChecksumStatus.NonRecommendedVersion -> { - // This shouldn't happen in this branch, but handle it gracefully - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(8.dp) - ) { - Text( - text = "Using non-recommended version", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp) + // ── Status bar ── + val statusDisplay = resolveVersionStatusDisplay( + apkInfo.versionStatus, apkInfo.checksumStatus, apkInfo.suggestedVersion + ) + if (statusDisplay != null) { + StatusBar( + label = statusDisplay.label, + detail = statusDisplay.detail, + color = statusDisplay.colorType.toColor(), + mono = mono, + borderColor = borderColor ) } } @@ -296,97 +304,92 @@ private fun ChecksumStatusBanner(checksumStatus: ChecksumStatus) { } @Composable -private fun InfoColumn( +private fun TechDataCell( label: String, value: String, + mono: androidx.compose.ui.text.font.FontFamily, modifier: Modifier = Modifier ) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.Start - ) { + Column(modifier = modifier) { Text( text = label, - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp ) - Spacer(modifier = Modifier.height(4.dp)) + Spacer(Modifier.height(3.dp)) Text( text = value, fontSize = 14.sp, fontWeight = FontWeight.Medium, + fontFamily = mono, color = MaterialTheme.colorScheme.onSurface, - maxLines = 2, + maxLines = 1, overflow = TextOverflow.Ellipsis ) } } +// ── Status ── + +@Composable +private fun homeCardMutedTextColor(alpha: Float): Color { + return MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = alpha) +} + @Composable -private fun VersionStatusBanner( - versionStatus: VersionStatus, - currentVersion: String, - suggestedVersion: String +private fun StatusBar( + label: String, + detail: String?, + color: Color, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color ) { - val (backgroundColor, textColor, message) = when (versionStatus) { - VersionStatus.OLDER_VERSION -> Triple( - Color(0xFFFF9800).copy(alpha = 0.15f), - Color(0xFFFF9800), - "Newer patches available for v$suggestedVersion" - ) - VersionStatus.NEWER_VERSION -> Triple( - MaterialTheme.colorScheme.error.copy(alpha = 0.15f), - MaterialTheme.colorScheme.error, - "Version too new. Recommended: v$suggestedVersion" + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(20.dp.toPx(), 0f), + end = Offset(size.width - 20.dp.toPx(), 0f), + strokeWidth = 1f + ) + } + .background(color.copy(alpha = 0.04f)) + .padding(horizontal = 20.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(6.dp) + .background(color, RoundedCornerShape(1.dp)) ) - else -> Triple( - MaterialTheme.colorScheme.surfaceVariant, - MaterialTheme.colorScheme.onSurfaceVariant, - "Suggested version: v$suggestedVersion" + + Spacer(Modifier.width(10.dp)) + + Text( + text = label, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = color, + letterSpacing = 1.sp ) - } - Surface( - color = backgroundColor, - shape = RoundedCornerShape(8.dp) - ) { - Column( - modifier = Modifier.padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { + if (detail != null) { + Spacer(Modifier.width(12.dp)) Text( - text = message, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = textColor, - textAlign = TextAlign.Center + text = detail, + fontSize = 11.sp, + fontFamily = mono, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - if (versionStatus == VersionStatus.NEWER_VERSION) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Patching may not work correctly with newer versions", - fontSize = 11.sp, - color = textColor.copy(alpha = 0.8f), - textAlign = TextAlign.Center - ) - } } } } - -//private fun formatArchitectures(archs: List): String { -// if (archs.isEmpty()) return "Unknown" -// -// // Show full architecture names for clarity -// val formatted = archs.map { arch -> -// when (arch) { -// "arm64-v8a" -> "arm64-v8a" -// "armeabi-v7a" -> "armeabi-v7a" -// "x86_64" -> "x86_64" -// "x86" -> "x86" -// else -> arch -// } -// } -// -// return formatted.joinToString(", ") -//} 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 57374c2..fad0961 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 @@ -1,15 +1,12 @@ -/* - * Copyright 2026 Morphe. - * https://github.com/MorpheApp/morphe-cli - */ - package app.morphe.gui.ui.screens.patches 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.shrinkVertically -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.hoverable @@ -20,7 +17,6 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -29,6 +25,7 @@ import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.PlaylistRemove import androidx.compose.material.icons.filled.Terminal @@ -37,6 +34,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -48,16 +48,23 @@ import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import app.morphe.gui.data.model.Patch +import org.koin.compose.koinInject import org.koin.core.parameter.parametersOf import app.morphe.gui.ui.components.ErrorDialog -import app.morphe.gui.ui.components.TopBarRow +import app.morphe.gui.ui.components.DeviceIndicator +import app.morphe.gui.ui.components.SettingsButton import app.morphe.gui.ui.components.getErrorType import app.morphe.gui.ui.components.getFriendlyErrorMessage import app.morphe.gui.ui.screens.patching.PatchingScreen -import app.morphe.gui.ui.theme.MorpheColors +import app.morphe.gui.data.repository.ConfigRepository +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.DeviceMonitor +import java.awt.FileDialog import java.awt.Toolkit import java.awt.datatransfer.StringSelection +import java.io.File /** * Screen for selecting which patches to apply. @@ -83,9 +90,26 @@ data class PatchSelectionScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current val navigator = LocalNavigator.currentOrThrow + val configRepository: ConfigRepository = koinInject() val uiState by viewModel.uiState.collectAsState() + // Load keystore config for CLI preview + var keystorePath by remember { mutableStateOf(null) } + var keystorePassword by remember { mutableStateOf(null) } + var keystoreAlias by remember { mutableStateOf(null) } + var keystoreEntryPassword by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + val config = configRepository.loadConfig() + keystorePath = config.keystorePath + keystorePassword = config.keystorePassword + keystoreAlias = config.keystoreAlias + keystoreEntryPassword = config.keystoreEntryPassword + } + var showErrorDialog by remember { mutableStateOf(false) } var currentError by remember { mutableStateOf(null) } @@ -96,7 +120,6 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { } } - // Error dialog if (showErrorDialog && currentError != null) { ErrorDialog( title = "Error Loading Patches", @@ -114,231 +137,331 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { ) } - // State for command preview var cleanMode by remember { mutableStateOf(false) } var showCommandPreview by remember { mutableStateOf(false) } var continueOnError by remember { mutableStateOf(false) } - Scaffold( - topBar = { - TopAppBar( - title = { - Column { - Text("Select Patches", fontWeight = FontWeight.SemiBold) - Text( - text = "${uiState.selectedCount} of ${uiState.totalCount} selected", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - navigationIcon = { - IconButton(onClick = { navigator.pop() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - actions = { - // Select all / Deselect all - TextButton( - onClick = { - if (uiState.selectedPatches.size == uiState.allPatches.size) { - viewModel.deselectAll() - } else { - viewModel.selectAll() - } - }, - shape = RoundedCornerShape(12.dp) - ) { - Text( - if (uiState.selectedPatches.size == uiState.allPatches.size) "Deselect All" else "Select All", - color = MorpheColors.Blue - ) - } + val dividerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.08f) - Spacer(Modifier.width(12.dp)) - - // Command preview toggle & continue-on-error toggle - if (!uiState.isLoading && uiState.allPatches.isNotEmpty()) { - val isActive = showCommandPreview - Surface( - onClick = { showCommandPreview = !showCommandPreview }, - shape = RoundedCornerShape(8.dp), - color = if (isActive) MorpheColors.Teal.copy(alpha = 0.15f) - else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - border = BorderStroke( - width = 1.dp, - color = if (isActive) MorpheColors.Teal.copy(alpha = 0.5f) - else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - ) - ) { - Icon( - imageVector = Icons.Default.Terminal, - contentDescription = "Command Preview", - tint = if (isActive) MorpheColors.Teal else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(8.dp).size(20.dp) - ) - } + Column(modifier = Modifier.fillMaxSize()) { + // ── Header bar ── + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = dividerColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 1f + ) + } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Back button + val backHover = remember { MutableInteractionSource() } + val isBackHovered by backHover.collectIsHoveredAsState() + val backBorder by animateColorAsState( + if (isBackHovered) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), + animationSpec = tween(150) + ) - Spacer(Modifier.width(6.dp)) + Box( + modifier = Modifier + .size(34.dp) + .hoverable(backHover) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, backBorder, RoundedCornerShape(corners.small)) + .clickable { navigator.pop() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + } - // Continue on error toggle - TooltipBox( - positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), - tooltip = { - PlainTooltip { - Text("Continue patching even if a patch fails") - } - }, - state = rememberTooltipState() - ) { - Surface( - onClick = { continueOnError = !continueOnError }, - shape = RoundedCornerShape(8.dp), - color = if (continueOnError) MaterialTheme.colorScheme.error.copy(alpha = 0.15f) - else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - border = BorderStroke( - width = 1.dp, - color = if (continueOnError) MaterialTheme.colorScheme.error.copy(alpha = 0.5f) - else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - ) - ) { - Icon( - imageVector = Icons.Default.PlaylistRemove, - contentDescription = "Continue on error", - tint = if (continueOnError) MaterialTheme.colorScheme.error - else MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(8.dp).size(20.dp) - ) - } - } - } + Spacer(modifier = Modifier.width(14.dp)) - Spacer(Modifier.width(12.dp)) + // Title block + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(0.dp) + ) { + Text( + text = "SELECT PATCHES", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + letterSpacing = 1.5.sp, + lineHeight = 14.sp + ) + Text( + text = "${uiState.selectedCount} of ${uiState.totalCount} selected", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + letterSpacing = 0.3.sp, + lineHeight = 8.sp + ) + } - TopBarRow(allowCacheClear = false) + // Select/Deselect all + val selectAllHover = remember { MutableInteractionSource() } + val isSelectAllHovered by selectAllHover.collectIsHoveredAsState() + val allSelected = uiState.selectedPatches.size == uiState.allPatches.size + val selectAllBorder by animateColorAsState( + if (isSelectAllHovered) accents.primary.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), + animationSpec = tween(150) + ) - Spacer(Modifier.width(12.dp)) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface + Box( + modifier = Modifier + .height(34.dp) + .hoverable(selectAllHover) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, selectAllBorder, RoundedCornerShape(corners.small)) + .clickable { + if (allSelected) viewModel.deselectAll() else viewModel.selectAll() + } + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = if (allSelected) "DESELECT ALL" else "SELECT ALL", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = if (isSelectAllHovered) accents.primary + else accents.primary.copy(alpha = 0.7f), + letterSpacing = 1.sp ) - ) - }, - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - // Command preview - collapsible via top bar button + } + + Spacer(modifier = Modifier.width(6.dp)) + + // Command preview toggle if (!uiState.isLoading && uiState.allPatches.isNotEmpty()) { - val commandPreview = remember(uiState.selectedPatches, uiState.selectedArchitectures, cleanMode, continueOnError) { - viewModel.getCommandPreview(cleanMode, continueOnError) - } - AnimatedVisibility( - visible = showCommandPreview, - enter = expandVertically(), - exit = shrinkVertically() + val cmdHover = remember { MutableInteractionSource() } + val isCmdHovered by cmdHover.collectIsHoveredAsState() + val cmdActive = showCommandPreview + val cmdBorder by animateColorAsState( + when { + cmdActive -> accents.secondary.copy(alpha = 0.5f) + isCmdHovered -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.1f) + }, + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .size(34.dp) + .hoverable(cmdHover) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, cmdBorder, RoundedCornerShape(corners.small)) + .then( + if (cmdActive) Modifier.background( + accents.secondary.copy(alpha = 0.08f), + RoundedCornerShape(corners.small) + ) else Modifier + ) + .clickable { showCommandPreview = !showCommandPreview }, + contentAlignment = Alignment.Center ) { - CommandPreview( - command = commandPreview, - cleanMode = cleanMode, - onToggleMode = { cleanMode = !cleanMode }, - onCopy = { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(commandPreview), null) - }, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + Icon( + imageVector = Icons.Default.Terminal, + contentDescription = "Command Preview", + tint = if (cmdActive) accents.secondary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(16.dp) ) } - } - // Search bar - SearchBar( - query = uiState.searchQuery, - onQueryChange = { viewModel.setSearchQuery(it) }, - showOnlySelected = uiState.showOnlySelected, - onShowOnlySelectedChange = { viewModel.setShowOnlySelected(it) }, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) - ) + Spacer(modifier = Modifier.width(6.dp)) + + // Continue on error toggle + val errHover = remember { MutableInteractionSource() } + val isErrHovered by errHover.collectIsHoveredAsState() + val errBorder by animateColorAsState( + when { + continueOnError -> MaterialTheme.colorScheme.error.copy(alpha = 0.5f) + isErrHovered -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.1f) + }, + animationSpec = tween(150) + ) + + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text( + "Continue patching even if a patch fails", + fontFamily = mono, + fontSize = 11.sp + ) + } + }, + state = rememberTooltipState() + ) { + Box( + modifier = Modifier + .size(34.dp) + .hoverable(errHover) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, errBorder, RoundedCornerShape(corners.small)) + .then( + if (continueOnError) Modifier.background( + MaterialTheme.colorScheme.error.copy(alpha = 0.08f), + RoundedCornerShape(corners.small) + ) else Modifier + ) + .clickable { continueOnError = !continueOnError }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.PlaylistRemove, + contentDescription = "Continue on error", + tint = if (continueOnError) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.size(16.dp) + ) + } + } - // Info card about default-disabled patches - val defaultDisabledCount = remember(uiState.allPatches) { - viewModel.getDefaultDisabledCount() + Spacer(modifier = Modifier.width(6.dp)) } - var infoDismissed by remember { mutableStateOf(false) } + DeviceIndicator() + Spacer(modifier = Modifier.width(6.dp)) + SettingsButton(allowCacheClear = false) + } + + // Command preview — collapsible + if (!uiState.isLoading && uiState.allPatches.isNotEmpty()) { + val commandPreview = remember(uiState.selectedPatches, uiState.selectedArchitectures, cleanMode, continueOnError, keystorePath) { + viewModel.getCommandPreview(cleanMode, continueOnError, keystorePath, keystorePassword, keystoreAlias, keystoreEntryPassword) + } AnimatedVisibility( - visible = defaultDisabledCount > 0 && !infoDismissed && !uiState.isLoading, + visible = showCommandPreview, enter = expandVertically(), exit = shrinkVertically() ) { - DefaultDisabledInfoCard( - count = defaultDisabledCount, - onDismiss = { infoDismissed = true }, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + CommandPreview( + command = commandPreview, + cleanMode = cleanMode, + onToggleMode = { cleanMode = !cleanMode }, + onCopy = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(commandPreview), null) + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) ) } + } - when { - uiState.isLoading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CircularProgressIndicator(color = MorpheColors.Blue) - Text( - text = "Loading patches...", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } + // Search bar + PatchSearchBar( + query = uiState.searchQuery, + onQueryChange = { viewModel.setSearchQuery(it) }, + showOnlySelected = uiState.showOnlySelected, + onShowOnlySelectedChange = { viewModel.setShowOnlySelected(it) }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) + ) - uiState.filteredPatches.isEmpty() && !uiState.isLoading -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center + // Info card about default-disabled patches + val defaultDisabledCount = remember(uiState.allPatches) { + viewModel.getDefaultDisabledCount() + } + var infoDismissed by remember { mutableStateOf(false) } + + AnimatedVisibility( + visible = defaultDisabledCount > 0 && !infoDismissed && !uiState.isLoading, + enter = expandVertically(), + exit = shrinkVertically() + ) { + DefaultDisabledInfoCard( + count = defaultDisabledCount, + onDismiss = { infoDismissed = true }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp) + ) + } + + when { + uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) ) { + CircularProgressIndicator( + color = accents.primary, + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp) + ) Text( - text = if (uiState.searchQuery.isNotBlank()) "No patches match your search" else "No patches found", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "LOADING PATCHES", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + letterSpacing = 1.5.sp ) } } + } + + uiState.filteredPatches.isEmpty() && !uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = if (uiState.searchQuery.isNotBlank()) "No patches match your search" + else "No patches found", + fontSize = 12.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + } - else -> { - // Patch list + else -> { + // Patch list + val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState() + + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { LazyColumn( - modifier = Modifier - .weight(1f) - .fillMaxWidth(), + state = lazyListState, + modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(6.dp) ) { - // Architecture selector at the top of the list - // Disabled for .apkm files until properly tested with merged APKs - val isApkm = viewModel.getApkPath().endsWith(".apkm", ignoreCase = true) - val showArchSelector = !isApkm && - uiState.apkArchitectures.size > 1 && - !(uiState.apkArchitectures.size == 1 && uiState.apkArchitectures[0] == "universal") - if (showArchSelector) { - item(key = "arch_selector") { - ArchitectureSelectorCard( - architectures = uiState.apkArchitectures, - selectedArchitectures = uiState.selectedArchitectures, - onToggleArchitecture = { viewModel.toggleArchitecture(it) } - ) - } - } + // TODO: Enable the strip libs feature here after patcher's X issue is fixed. + // Architecture selector. Disabled for split APK bundles for now. Maybe we enable in the future? +// val isBundleFormat = viewModel.getApkPath().lowercase().let { it.endsWith(".apkm") || it.endsWith(".xapk") || it.endsWith(".apks") } +// val showArchSelector = !isBundleFormat && +// uiState.apkArchitectures.size > 1 && +// !(uiState.apkArchitectures.size == 1 && uiState.apkArchitectures[0] == "universal") +// if (showArchSelector) { +// item(key = "arch_selector") { +// ArchitectureSelectorCard( +// architectures = uiState.apkArchitectures, +// selectedArchitectures = uiState.selectedArchitectures, +// onToggleArchitecture = { viewModel.toggleArchitecture(it) } +// ) +// } +// } items( items = uiState.filteredPatches, @@ -347,43 +470,73 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { PatchListItem( patch = patch, isSelected = uiState.selectedPatches.contains(patch.uniqueId), - onToggle = { viewModel.togglePatch(patch.uniqueId) } + onToggle = { viewModel.togglePatch(patch.uniqueId) }, + getOptionValue = { optionKey, default -> + viewModel.getOptionValue(patch.name, optionKey, default) + }, + onOptionValueChange = { optionKey, value -> + viewModel.setOptionValue(patch.name, optionKey, value) + } ) } } - // Bottom action bar - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 3.dp - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Button( - onClick = { + androidx.compose.foundation.VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + adapter = androidx.compose.foundation.rememberScrollbarAdapter(lazyListState) + ) + } + + // ── Bottom action bar ── + Box( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = dividerColor, + start = Offset(0f, 0f), + end = Offset(size.width, 0f), + strokeWidth = 1f + ) + } + .padding(16.dp) + ) { + val patchHover = remember { MutableInteractionSource() } + val isPatchHovered by patchHover.collectIsHoveredAsState() + val patchEnabled = uiState.selectedPatches.isNotEmpty() + val patchBg by animateColorAsState( + when { + !patchEnabled -> accents.primary.copy(alpha = 0.1f) + isPatchHovered -> accents.primary.copy(alpha = 0.9f) + else -> accents.primary + }, + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(42.dp) + .hoverable(patchHover) + .clip(RoundedCornerShape(corners.small)) + .background(patchBg, RoundedCornerShape(corners.small)) + .then( + if (patchEnabled) Modifier.clickable { val config = viewModel.createPatchConfig(continueOnError) navigator.push(PatchingScreen(config)) - }, - enabled = uiState.selectedPatches.isNotEmpty(), - modifier = Modifier - .weight(1f) - .height(48.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MorpheColors.Blue - ), - shape = RoundedCornerShape(12.dp) - ) { - Text( - text = "Patch (${uiState.selectedCount})", - fontWeight = FontWeight.Medium - ) - } - } + } else Modifier + ), + contentAlignment = Alignment.Center + ) { + Text( + text = "PATCH (${uiState.selectedCount})", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = if (patchEnabled) Color.White + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + letterSpacing = 1.5.sp + ) } } } @@ -391,72 +544,122 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { } } +// ── Search Bar ── + @Composable -private fun SearchBar( +private fun PatchSearchBar( query: String, onQueryChange: (String) -> Unit, showOnlySelected: Boolean, onShowOnlySelectedChange: (Boolean) -> Unit, modifier: Modifier = Modifier ) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - OutlinedTextField( - value = query, - onValueChange = onQueryChange, - modifier = Modifier.weight(1f).height(48.dp), - placeholder = { Text("Search patches...", style = MaterialTheme.typography.bodySmall) }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp) + // Custom compact search field + val searchFocused = remember { mutableStateOf(false) } + val searchBorderColor by animateColorAsState( + if (searchFocused.value) accents.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), + animationSpec = tween(150) + ) + + Row( + modifier = Modifier + .weight(1f) + .height(38.dp) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, searchBorderColor, RoundedCornerShape(corners.small)) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + imageVector = Icons.Default.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.size(16.dp) + ) + + Box(modifier = Modifier.weight(1f)) { + if (query.isEmpty()) { + Text( + "Search patches…", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f) + ) + } + androidx.compose.foundation.text.BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = LocalTextStyle.current.copy( + fontSize = 12.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(accents.primary), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { searchFocused.value = it.isFocused } ) - }, - trailingIcon = { - if (query.isNotEmpty()) { - IconButton(onClick = { onQueryChange("") }) { - Icon( - imageVector = Icons.Default.Clear, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp) - ) - } + } + + if (query.isNotEmpty()) { + Box( + modifier = Modifier + .size(24.dp) + .clip(RoundedCornerShape(corners.small)) + .clickable { onQueryChange("") }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(14.dp) + ) } + } + } + + // "Selected" filter chip + val chipHover = remember { MutableInteractionSource() } + val isChipHovered by chipHover.collectIsHoveredAsState() + val chipBorder by animateColorAsState( + when { + showOnlySelected -> accents.primary.copy(alpha = 0.5f) + isChipHovered -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - textStyle = MaterialTheme.typography.bodySmall, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MorpheColors.Blue, - unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) - ) + animationSpec = tween(150) ) - val chipInteractionSource = remember { MutableInteractionSource() } - val chipHovered by chipInteractionSource.collectIsHoveredAsState() - Surface( + Box( modifier = Modifier - .hoverable(chipInteractionSource) - .clickable(interactionSource = chipInteractionSource, indication = null) { - onShowOnlySelectedChange(!showOnlySelected) - }, - shape = RoundedCornerShape(8.dp), - color = if (showOnlySelected) MorpheColors.Blue.copy(alpha = if (chipHovered) 0.22f else 0.12f) - else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (chipHovered) 0.7f else 0.4f), - border = BorderStroke( - width = 1.dp, - color = if (showOnlySelected) MorpheColors.Blue.copy(alpha = 0.5f) - else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - ) + .height(38.dp) + .hoverable(chipHover) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, chipBorder, RoundedCornerShape(corners.small)) + .then( + if (showOnlySelected) Modifier.background( + accents.primary.copy(alpha = 0.08f), + RoundedCornerShape(corners.small) + ) else Modifier + ) + .clickable { onShowOnlySelectedChange(!showOnlySelected) } + .padding(horizontal = 12.dp), + contentAlignment = Alignment.Center ) { Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { @@ -464,180 +667,563 @@ private fun SearchBar( Icon( imageVector = Icons.Default.Check, contentDescription = null, - tint = MorpheColors.Blue, - modifier = Modifier.size(16.dp) + tint = accents.primary, + modifier = Modifier.size(14.dp) ) } Text( - text = "Selected", - fontSize = 14.sp, - color = if (showOnlySelected) MorpheColors.Blue else MaterialTheme.colorScheme.onSurfaceVariant + text = "SELECTED", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = if (showOnlySelected) accents.primary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + letterSpacing = 1.sp ) } } } } +// ── Patch List Item ── + @Composable private fun PatchListItem( patch: Patch, isSelected: Boolean, - onToggle: () -> Unit + onToggle: () -> Unit, + getOptionValue: (optionKey: String, default: String?) -> String = { _, d -> d ?: "" }, + onOptionValueChange: (optionKey: String, value: String) -> Unit = { _, _ -> } ) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() - val backgroundColor = if (isSelected) { - MorpheColors.Blue.copy(alpha = if (isHovered) 0.17f else 0.1f) - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (isHovered) 0.5f else 0.3f) - } - Card( + val borderColor by animateColorAsState( + when { + isSelected && isHovered -> accents.primary.copy(alpha = 0.4f) + isSelected -> accents.primary.copy(alpha = 0.2f) + isHovered -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.08f) + }, + animationSpec = tween(150) + ) + + var showOptions by remember { mutableStateOf(false) } + val hasOptions = patch.options.isNotEmpty() + + Column( modifier = Modifier .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)) + .then( + if (isSelected) Modifier.background( + accents.primary.copy(alpha = 0.04f), + RoundedCornerShape(corners.small) + ) else Modifier + ) .hoverable(interactionSource) - .clickable(interactionSource = interactionSource, indication = null, onClick = onToggle), - colors = CardDefaults.cardColors(containerColor = backgroundColor), - shape = RoundedCornerShape(12.dp) ) { + // Header — clicking toggles patch Row( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .clickable(interactionSource = interactionSource, indication = null, onClick = onToggle) + .padding(14.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Checkbox( - checked = isSelected, - onCheckedChange = null, - colors = CheckboxDefaults.colors( - checkedColor = MorpheColors.Blue, - uncheckedColor = MaterialTheme.colorScheme.onSurfaceVariant - ) - ) + // Custom checkbox + Box( + modifier = Modifier + .size(18.dp) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.5.dp, + if (isSelected) accents.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + RoundedCornerShape(corners.small) + ) + .then( + if (isSelected) Modifier.background(accents.primary, RoundedCornerShape(corners.small)) + else Modifier + ), + contentAlignment = Alignment.Center + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(12.dp) + ) + } + } Column(modifier = Modifier.weight(1f)) { - Text( - text = patch.name, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface - ) - - if (patch.description.isNotBlank()) { - Spacer(modifier = Modifier.height(4.dp)) + // Name + app chips on same line + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { Text( - text = patch.description, + text = patch.name, fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis + fontWeight = FontWeight.Medium, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) ) - } - // Show compatible packages if any - if (patch.compatiblePackages.isNotEmpty()) { - val genericSegments = setOf("com", "org", "net", "android", "google", "apps", "app", "www") - Spacer(modifier = Modifier.height(4.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { + if (patch.compatiblePackages.isNotEmpty()) { + val genericSegments = setOf("com", "org", "net", "android", "google", "apps", "app", "www") patch.compatiblePackages.take(2).forEach { pkg -> - val meaningful = pkg.name.split(".").filter { it !in genericSegments } - val displayName = meaningful.takeLast(2).joinToString(" ") - .replaceFirstChar { it.uppercase() } - Surface( - color = if (isSelected) MorpheColors.Blue.copy(alpha = 0.18f) - else MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(4.dp) + val displayName = pkg.displayName?.takeIf { it.isNotBlank() } ?: run { + val meaningful = pkg.name.split(".").filter { it !in genericSegments } + meaningful.takeLast(2).joinToString(" ") + .replaceFirstChar { it.uppercase() } + } + Box( + modifier = Modifier + .border( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), + RoundedCornerShape(corners.small) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) ) { Text( text = displayName, - fontSize = 10.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + fontSize = 9.sp, + fontFamily = mono, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + letterSpacing = 0.3.sp ) } } } } - // Show options if patch has any - if (patch.options.isNotEmpty()) { - Spacer(modifier = Modifier.height(4.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - patch.options.forEach { option -> - Surface( - color = MorpheColors.Teal.copy(alpha = 0.1f), - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = option.title.ifBlank { option.key }, - fontSize = 10.sp, - color = MorpheColors.Teal, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } - } - } - } - } - } - } + if (patch.description.isNotBlank()) { + Spacer(modifier = Modifier.height(3.dp)) + Text( + text = patch.description, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } + + // Gear button for options + if (hasOptions) { + val gearHover = remember { MutableInteractionSource() } + val isGearHovered by gearHover.collectIsHoveredAsState() + val gearBorder by animateColorAsState( + when { + showOptions -> accents.secondary.copy(alpha = 0.5f) + isGearHovered -> accents.secondary.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + }, + animationSpec = tween(150) + ) + val gearBg by animateColorAsState( + if (showOptions) accents.secondary.copy(alpha = 0.08f) + else Color.Transparent, + animationSpec = tween(150) + ) + + // Wrapper box — no clip, allows badge to overflow + Box( + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center + ) { + // Gear button + Box( + modifier = Modifier + .size(44.dp) + .hoverable(gearHover) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, gearBorder, RoundedCornerShape(corners.small)) + .background(gearBg, RoundedCornerShape(corners.small)) + .clickable { showOptions = !showOptions }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Configure options", + tint = when { + showOptions -> accents.secondary + isGearHovered -> accents.secondary.copy(alpha = 0.7f) + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + }, + modifier = Modifier.size(22.dp) + ) + } + // Options count badge — outside clip + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 3.dp, y = (-3).dp) + .size(18.dp) + .background(accents.secondary, RoundedCornerShape(9.dp)), + contentAlignment = Alignment.Center + ) { + Text( + text = "${patch.options.size}", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = Color.White, + lineHeight = 9.sp + ) + } + } + } + } + + // Expandable options section + if (hasOptions) { + val optionDivider = MaterialTheme.colorScheme.outline.copy(alpha = 0.06f) + + AnimatedVisibility( + visible = showOptions, + enter = expandVertically(), + exit = shrinkVertically() + ) { + Column( + modifier = Modifier + .drawBehind { + drawLine( + color = optionDivider, + start = Offset(14.dp.toPx(), 0f), + end = Offset(size.width - 14.dp.toPx(), 0f), + strokeWidth = 1f + ) + } + .padding(start = 14.dp, end = 14.dp, bottom = 10.dp, top = 6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + patch.options.forEach { option -> + PatchOptionEditor( + option = option, + value = getOptionValue(option.key, option.default), + onValueChange = { onOptionValueChange(option.key, it) } + ) + } + } + } + } + } +} + +// ── Patch Option Editor ── + +@Composable +private fun PatchOptionEditor( + option: app.morphe.gui.data.model.PatchOption, + value: String, + onValueChange: (String) -> Unit +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = option.title.ifBlank { option.key }, + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + fontFamily = mono, + color = accents.secondary + ) + if (option.required) { + Text( + text = "*", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.error + ) + } + } + if (option.description.isNotBlank()) { + Text( + text = option.description, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + when (option.type) { + app.morphe.gui.data.model.PatchOptionType.BOOLEAN -> { + var localChecked by remember(option.key) { mutableStateOf(value.equals("true", ignoreCase = true)) } + LaunchedEffect(value) { + val v = value.equals("true", ignoreCase = true) + if (localChecked != v) localChecked = v + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Switch( + checked = localChecked, + onCheckedChange = { newChecked -> + localChecked = newChecked + onValueChange(newChecked.toString()) + }, + colors = SwitchDefaults.colors( + checkedTrackColor = accents.secondary + ) + ) + Text( + text = if (localChecked) "Enabled" else "Disabled", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + } + app.morphe.gui.data.model.PatchOptionType.FILE -> { + var localPath by remember(option.key) { mutableStateOf(value) } + LaunchedEffect(value) { + if (localPath != value) localPath = value + } + + // Detect if this is an image file option from key/title + val keyLower = option.key.lowercase() + " " + option.title.lowercase() + val isImage = keyLower.contains("icon") || keyLower.contains("image") || + keyLower.contains("logo") || keyLower.contains("banner") || + keyLower.contains("png") || keyLower.contains("jpg") + val fileFilterDesc = if (isImage) "Image files" else "All files" + val fileExtensions = if (isImage) "png,jpg,jpeg,webp" else "*" + + val fieldFocused = remember { mutableStateOf(false) } + val fieldBorder by animateColorAsState( + if (fieldFocused.value) accents.secondary.copy(alpha = 0.6f) + else accents.secondary.copy(alpha = 0.2f), + animationSpec = tween(150) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .height(32.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + // Path text field + Row( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, fieldBorder, RoundedCornerShape(corners.small)) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.weight(1f)) { + if (localPath.isEmpty()) { + Text( + text = if (isImage) "Select image…" else "Select file…", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + ) + } + androidx.compose.foundation.text.BasicTextField( + value = localPath, + onValueChange = { newPath -> + localPath = newPath + onValueChange(newPath) + }, + singleLine = true, + textStyle = LocalTextStyle.current.copy( + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(accents.secondary), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { fieldFocused.value = it.isFocused } + ) + } + } + + // Browse button + val browseHover = remember { MutableInteractionSource() } + val isBrowseHovered by browseHover.collectIsHoveredAsState() + val browseBorder by animateColorAsState( + if (isBrowseHovered) accents.secondary.copy(alpha = 0.5f) + else accents.secondary.copy(alpha = 0.2f), + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .fillMaxHeight() + .hoverable(browseHover) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, browseBorder, RoundedCornerShape(corners.small)) + .clickable { + val dialog = FileDialog(null as java.awt.Frame?, fileFilterDesc, FileDialog.LOAD) + if (isImage) { + // setFile pattern works on macOS; setFilenameFilter works on Linux/Windows + dialog.file = "*.png;*.jpg;*.jpeg;*.webp" + dialog.setFilenameFilter { _, name -> + val lower = name.lowercase() + lower.endsWith(".png") || lower.endsWith(".jpg") || + lower.endsWith(".jpeg") || lower.endsWith(".webp") + } + } + dialog.isVisible = true + val selected = dialog.file + if (selected != null) { + val fullPath = File(dialog.directory, selected).absolutePath + localPath = fullPath + onValueChange(fullPath) + } + } + .padding(horizontal = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "BROWSE", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = if (isBrowseHovered) accents.secondary else accents.secondary.copy(alpha = 0.7f), + letterSpacing = 1.sp + ) + } + } + } + else -> { + var localText by remember(option.key) { mutableStateOf(value) } + LaunchedEffect(value) { + if (localText != value) localText = value + } + + val fieldFocused = remember { mutableStateOf(false) } + val fieldBorder by animateColorAsState( + if (fieldFocused.value) accents.secondary.copy(alpha = 0.6f) + else accents.secondary.copy(alpha = 0.2f), + animationSpec = tween(150) + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .height(32.dp) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, fieldBorder, RoundedCornerShape(corners.small)) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.weight(1f)) { + if (localText.isEmpty()) { + Text( + text = option.default ?: option.type.name.lowercase(), + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + ) + } + androidx.compose.foundation.text.BasicTextField( + value = localText, + onValueChange = { newText -> + localText = newText + onValueChange(newText) + }, + singleLine = true, + textStyle = LocalTextStyle.current.copy( + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = androidx.compose.ui.graphics.SolidColor(accents.secondary), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { fieldFocused.value = it.isFocused } + ) + } + } + } + } + } } +// ── Default Disabled Info Card ── + @Composable private fun DefaultDisabledInfoCard( count: Int, onDismiss: () -> Unit, modifier: Modifier = Modifier ) { - Card( - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MorpheColors.Blue.copy(alpha = 0.08f) - ), - shape = RoundedCornerShape(12.dp) + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + accents.primary.copy(alpha = 0.15f), + RoundedCornerShape(corners.small) + ) + .background(accents.primary.copy(alpha = 0.04f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Row( + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = accents.primary.copy(alpha = 0.6f), + modifier = Modifier.size(16.dp) + ) + Text( + text = "$count patch${if (count > 1) "es are" else " is"} unselected by default as they may cause issues.", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier.weight(1f) + ) + Box( modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + .size(24.dp) + .clip(RoundedCornerShape(corners.small)) + .clickable(onClick = onDismiss), + contentAlignment = Alignment.Center ) { Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - tint = MorpheColors.Blue, - modifier = Modifier.size(18.dp) + imageVector = Icons.Default.Close, + contentDescription = "Dismiss", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.size(14.dp) ) - Text( - text = "$count patch${if (count > 1) "es are" else " is"} unselected by default as they may cause issues or are not recommended by the patches team.", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f) - ) - IconButton( - onClick = onDismiss, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Dismiss", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp) - ) - } } } } -/** - * Terminal-style command preview showing the CLI command that will be executed. - */ +// ── Command Preview ── + @Composable private fun CommandPreview( command: String, @@ -646,14 +1232,16 @@ private fun CommandPreview( onCopy: () -> Unit, modifier: Modifier = Modifier ) { - val terminalBackground = Color(0xFF1E1E1E) - val terminalGreen = Color(0xFF6A9955) - val terminalText = Color(0xFFD4D4D4) - val terminalDim = Color(0xFF6A9955) + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + + val terminalGreen = accents.secondary + val terminalText = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f) + val terminalBg = MaterialTheme.colorScheme.surface var showCopied by remember { mutableStateOf(false) } - // Reset "Copied!" message after a delay LaunchedEffect(showCopied) { if (showCopied) { kotlinx.coroutines.delay(1500) @@ -661,111 +1249,130 @@ private fun CommandPreview( } } - Card( - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = terminalBackground), - shape = RoundedCornerShape(8.dp) + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + terminalGreen.copy(alpha = 0.15f), + RoundedCornerShape(corners.small) + ) + .background(terminalBg) + .padding(12.dp) ) { - Column( - modifier = Modifier.padding(12.dp) + // Header + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - // Header with terminal icon and controls Row( - modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - // Left side - icon and title - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon( - imageVector = Icons.Default.Terminal, - contentDescription = null, - tint = terminalGreen, - modifier = Modifier.size(14.dp) - ) - Text( - text = "Command Preview", - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - color = terminalGreen - ) - } + Icon( + imageVector = Icons.Default.Terminal, + contentDescription = null, + tint = terminalGreen.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp) + ) + Text( + text = "COMMAND PREVIEW", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = terminalGreen.copy(alpha = 0.7f), + letterSpacing = 1.sp + ) + } - // Right side - controls - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // Copy button - Surface( - onClick = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Copy button + val copyHover = remember { MutableInteractionSource() } + val isCopyHovered by copyHover.collectIsHoveredAsState() + + Box( + modifier = Modifier + .hoverable(copyHover) + .clip(RoundedCornerShape(corners.small)) + .clickable { onCopy() showCopied = true - }, - color = Color.Transparent, - shape = RoundedCornerShape(4.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) { - Icon( - imageVector = Icons.Default.ContentCopy, - contentDescription = "Copy", - tint = if (showCopied) terminalGreen else terminalDim, - modifier = Modifier.size(12.dp) - ) - Text( - text = if (showCopied) "Copied!" else "Copy", - fontSize = 12.sp, - fontWeight = FontWeight.Bold, - color = if (showCopied) terminalGreen else terminalDim - ) } - } - - // Mode toggle - Surface( - onClick = onToggleMode, - color = Color.Transparent, - shape = RoundedCornerShape(4.dp) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy", + tint = if (showCopied) terminalGreen + else terminalGreen.copy(alpha = if (isCopyHovered) 0.8f else 0.4f), + modifier = Modifier.size(12.dp) + ) Text( - text = if (cleanMode) "Compact" else "Expand", - fontSize = 12.sp, + text = if (showCopied) "COPIED" else "COPY", + fontSize = 9.sp, fontWeight = FontWeight.Bold, - color = terminalDim, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + fontFamily = mono, + color = if (showCopied) terminalGreen + else terminalGreen.copy(alpha = if (isCopyHovered) 0.8f else 0.4f), + letterSpacing = 0.5.sp ) } } - } - Spacer(modifier = Modifier.height(8.dp)) + // Mode toggle + val modeHover = remember { MutableInteractionSource() } + val isModeHovered by modeHover.collectIsHoveredAsState() - // Vertically scrollable command text with max height - Box( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 120.dp) - .verticalScroll(rememberScrollState()) - ) { - Text( - text = command, - fontSize = 11.sp, - fontFamily = FontFamily.Monospace, - color = terminalText, - lineHeight = 16.sp - ) + Box( + modifier = Modifier + .hoverable(modeHover) + .clip(RoundedCornerShape(corners.small)) + .clickable(onClick = onToggleMode) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = if (cleanMode) "COMPACT" else "EXPAND", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = terminalGreen.copy(alpha = if (isModeHovered) 0.8f else 0.4f), + letterSpacing = 0.5.sp + ) + } } } + + Spacer(modifier = Modifier.height(8.dp)) + + // Command text + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 120.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = command, + fontSize = 11.sp, + fontFamily = FontFamily.Monospace, + color = terminalText, + lineHeight = 16.sp + ) + } } } +// ── Architecture Selector ── + @Composable private fun ArchitectureSelectorCard( architectures: List, @@ -773,106 +1380,119 @@ private fun ArchitectureSelectorCard( onToggleArchitecture: (String) -> Unit, modifier: Modifier = Modifier ) { - // Get connected device architecture for hint + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current val deviceState by DeviceMonitor.state.collectAsState() val deviceArch = deviceState.selectedDevice?.architecture - Card( - modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MorpheColors.Teal.copy(alpha = 0.08f) - ), - shape = RoundedCornerShape(12.dp) + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + accents.secondary.copy(alpha = 0.15f), + RoundedCornerShape(corners.small) + ) + .background(accents.secondary.copy(alpha = 0.03f)) + .padding(12.dp) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - tint = MorpheColors.Teal, - modifier = Modifier.size(18.dp) - ) - Text( - text = "Strip native libraries", - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface - ) - } + Box( + modifier = Modifier + .size(6.dp) + .background(accents.secondary, RoundedCornerShape(1.dp)) + ) + Text( + text = "STRIP NATIVE LIBRARIES", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + letterSpacing = 1.sp + ) + } + + Spacer(modifier = Modifier.height(4.dp)) - Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Uncheck architectures to remove from the output APK and reduce file size.", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + if (deviceArch != null) { + Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Uncheck architectures to remove from the output APK and reduce file size.", - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "Your device's CPU architecture: $deviceArch", + fontSize = 10.sp, + fontWeight = FontWeight.Medium, + fontFamily = mono, + color = accents.secondary.copy(alpha = 0.8f) ) + } - if (deviceArch != null) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = "Your device: $deviceArch", - fontSize = 11.sp, - fontWeight = FontWeight.Medium, - color = MorpheColors.Teal - ) - } + Spacer(modifier = Modifier.height(8.dp)) - Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + architectures.forEach { arch -> + val isSelected = selectedArchitectures.contains(arch) + val archHover = remember { MutableInteractionSource() } + val isArchHovered by archHover.collectIsHoveredAsState() + val archBorder by animateColorAsState( + when { + isSelected -> accents.secondary.copy(alpha = 0.4f) + isArchHovered -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.1f) + }, + animationSpec = tween(150) + ) - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - architectures.forEach { arch -> - val isSelected = selectedArchitectures.contains(arch) - val archInteractionSource = remember { MutableInteractionSource() } - val archHovered by archInteractionSource.collectIsHoveredAsState() - Surface( - modifier = Modifier - .hoverable(archInteractionSource) - .clickable(interactionSource = archInteractionSource, indication = null) { - onToggleArchitecture(arch) - }, - shape = RoundedCornerShape(8.dp), - color = if (isSelected) MorpheColors.Teal.copy(alpha = if (archHovered) 0.28f else 0.2f) - else if (archHovered) MorpheColors.Teal.copy(alpha = 0.1f) - else Color.Transparent, - border = BorderStroke( - width = 0.5.dp, - color = if (isSelected) MorpheColors.Teal.copy(alpha = 0.5f) - else MaterialTheme.colorScheme.outline.copy(alpha = 0.1f) + Box( + modifier = Modifier + .hoverable(archHover) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, archBorder, RoundedCornerShape(corners.small)) + .then( + if (isSelected) Modifier.background( + accents.secondary.copy(alpha = 0.08f), + RoundedCornerShape(corners.small) + ) else Modifier ) + .clickable { onToggleArchitecture(arch) } + .padding(horizontal = 10.dp, vertical = 6.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - Box( - modifier = Modifier - .size(6.dp) - .clip(CircleShape) - .background( - if (isSelected) MorpheColors.Teal - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f) - ) - ) - Text( - text = arch, - fontSize = 12.sp, - color = if (isSelected) MorpheColors.Teal else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) - ) - } + Box( + modifier = Modifier + .size(6.dp) + .background( + if (isSelected) accents.secondary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f), + RoundedCornerShape(1.dp) + ) + ) + Text( + text = arch, + fontSize = 11.sp, + fontFamily = mono, + fontWeight = FontWeight.Medium, + color = if (isSelected) accents.secondary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) } } } 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 314d384..e75c391 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 @@ -5,6 +5,8 @@ package app.morphe.gui.ui.screens.patches +import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_ALIAS +import app.morphe.engine.PatchEngine.Config.Companion.DEFAULT_KEYSTORE_PASSWORD import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import app.morphe.gui.data.model.Patch @@ -26,7 +28,8 @@ class PatchSelectionViewModel( private val packageName: String, private val apkArchitectures: List, private val patchService: PatchService, - private val patchRepository: PatchRepository + private val patchRepository: PatchRepository, + private val localPatchFilePath: String? = null ) : ScreenModel { // Actual path to use - may differ from patchesFilePath if we had to re-download @@ -168,6 +171,28 @@ class PatchSelectionViewModel( _uiState.value = _uiState.value.copy(selectedArchitectures = newSelection) } + /** + * Set a patch option value. Key format: "patchName.optionKey" + */ + fun setOptionValue(patchName: String, optionKey: String, value: String) { + val key = "$patchName.$optionKey" + val current = _uiState.value.patchOptionValues.toMutableMap() + if (value.isBlank()) { + current.remove(key) + } else { + current[key] = value + } + _uiState.value = _uiState.value.copy(patchOptionValues = current) + } + + /** + * Get a patch option value. Returns the user-set value, or the default if not set. + */ + fun getOptionValue(patchName: String, optionKey: String, default: String?): String { + val key = "$patchName.$optionKey" + return _uiState.value.patchOptionValues[key] ?: default ?: "" + } + /** * Count of patches that are disabled by default (from .mpp metadata). */ @@ -198,9 +223,14 @@ class PatchSelectionViewModel( .filter { !_uiState.value.selectedPatches.contains(it.uniqueId) } .map { it.name } - // Only set riplibs if user deselected any architecture (keeps = selected ones) + // Only set striplibs if user deselected any architecture (keeps = selected ones). + // Note: selectedArchitectures stores display strings like "arm64-v8a" (with + // hyphens), so use valueOfOrNull which matches against the enum's `.arch` + // property — plain valueOf() only accepts the underscored Kotlin constant name. val striplibs = if (_uiState.value.selectedArchitectures.size < apkArchitectures.size && apkArchitectures.size > 1) { - _uiState.value.selectedArchitectures.map { CpuArchitecture.valueOf(it) }.toSet() + _uiState.value.selectedArchitectures + .mapNotNull { CpuArchitecture.valueOfOrNull(it) } + .toSet() } else { emptySet() } @@ -211,6 +241,7 @@ class PatchSelectionViewModel( patchesFilePath = actualPatchesFilePath, enabledPatches = selectedPatchNames, disabledPatches = disabledPatchNames, + patchOptions = _uiState.value.patchOptionValues, useExclusiveMode = true, keepArchitectures = striplibs, continueOnError = continueOnError @@ -239,7 +270,14 @@ class PatchSelectionViewModel( * Generate a preview of the CLI command that will be executed. * @param cleanMode If true, formats with newlines for readability. If false, compact single-line format. */ - fun getCommandPreview(cleanMode: Boolean = false, continueOnError: Boolean = false): String { + fun getCommandPreview( + cleanMode: Boolean = false, + continueOnError: Boolean = false, + keystorePath: String? = null, + keystorePassword: String? = null, + keystoreAlias: String? = null, + keystoreEntryPassword: String? = null + ): String { val inputFile = File(apkPath) val patchesFile = File(actualPatchesFilePath) val appFolderName = apkName.replace(" ", "-") @@ -266,39 +304,55 @@ class PatchSelectionViewModel( null } + // Keystore flags (only if custom keystore is set) + val hasCustomKeystore = keystorePath != null + return if (cleanMode) { - val sb = StringBuilder() - sb.append("java -jar morphe-cli.jar patch \\\n") - sb.append(" -p ${patchesFile.name} \\\n") - sb.append(" -o ${outputFileName} \\\n") - sb.append(" --force \\\n") - - if (continueOnError) { - sb.append(" --continue-on-error \\\n") - } + buildString { + appendLine( + """ + java -jar morphe-cli.jar patch \ + -p ${patchesFile.name} \ + -o $outputFileName \ + --force \ + """.trimIndent() + ) + + if (continueOnError) { + appendLine(" --continue-on-error \\") + } - if (useExclusive) { - sb.append(" --exclusive \\\n") - } + if (useExclusive) { + appendLine(" --exclusive \\") + } - if (striplibsArg != null) { - sb.append(" --striplibs $striplibsArg \\\n") - } + striplibsArg?.let { + appendLine(" --striplibs $it \\") + } - val flagPatches = if (useExclusive) selectedPatchNames else disabledPatchNames - val flag = if (useExclusive) "-e" else "-d" + if (hasCustomKeystore) { + appendLine(" --keystore \"$keystorePath\" \\") + keystorePassword?.let { + appendLine(" --keystore-password \"$it\" \\") + } + if (keystoreAlias != null && keystoreAlias != DEFAULT_KEYSTORE_ALIAS) { + appendLine(" --keystore-entry-alias \"$keystoreAlias\" \\") + } + if (keystoreEntryPassword != null && keystoreEntryPassword != DEFAULT_KEYSTORE_PASSWORD) { + appendLine(" --keystore-entry-password \"$keystoreEntryPassword\" \\") + } + } - flagPatches.forEachIndexed { index, patch -> - val isLast = index == flagPatches.lastIndex - sb.append(" $flag \"$patch\"") - if (!isLast) { - sb.append(" \\") + val flagPatches = if (useExclusive) selectedPatchNames else disabledPatchNames + val flag = if (useExclusive) "-e" else "-d" + + flagPatches.forEachIndexed { index, patch -> + val suffix = if (index == flagPatches.lastIndex) "" else " \\" + appendLine(" $flag \"$patch\"$suffix") } - sb.append("\n") - } - sb.append(" ${inputFile.name}") - sb.toString() + append(" ${inputFile.name}") + } } else { val flagPatches = if (useExclusive) selectedPatchNames else disabledPatchNames val flag = if (useExclusive) "-e" else "-d" @@ -306,15 +360,33 @@ class PatchSelectionViewModel( val exclusivePart = if (useExclusive) " --exclusive" else "" val striplibsPart = if (striplibsArg != null) " --striplibs $striplibsArg" else "" val continueOnErrorPart = if (continueOnError) " --continue-on-error" else "" - "java -jar morphe-cli.jar patch -p ${patchesFile.name} -o $outputFileName --force$continueOnErrorPart$exclusivePart$striplibsPart $patches ${inputFile.name}" + val keystorePart = if (hasCustomKeystore) { + val parts = mutableListOf(" --keystore \"$keystorePath\"") + if (keystorePassword != null) parts.add("--keystore-password \"$keystorePassword\"") + if (keystoreAlias != null && keystoreAlias != "Morphe") parts.add("--keystore-entry-alias \"$keystoreAlias\"") + if (keystoreEntryPassword != null && keystoreEntryPassword != "Morphe") parts.add("--keystore-entry-password \"$keystoreEntryPassword\"") + parts.joinToString(" ") + } else "" + "java -jar morphe-cli.jar patch -p ${patchesFile.name} -o $outputFileName --force$continueOnErrorPart$exclusivePart$striplibsPart$keystorePart $patches ${inputFile.name}" } } /** * Download patches file if it's missing (e.g., after cache clear). + * For LOCAL sources, uses the local file directly. * Tries to find a release matching the expected filename, or falls back to latest stable. */ private suspend fun downloadMissingPatches(expectedFilename: String): Result { + // LOCAL source: use the local file directly instead of downloading + if (localPatchFilePath != null) { + val localFile = File(localPatchFilePath) + return if (localFile.exists()) { + Result.success(localFile) + } else { + Result.failure(Exception("Local patch file not found: ${localFile.name}")) + } + } + // Try to extract version from filename (e.g., "morphe-patches-1.9.0.mpp" -> "1.9.0") val versionRegex = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") val versionMatch = versionRegex.find(expectedFilename) @@ -363,7 +435,8 @@ data class PatchSelectionUiState( val showOnlySelected: Boolean = false, val error: String? = null, val apkArchitectures: List = emptyList(), - val selectedArchitectures: Set = emptySet() + val selectedArchitectures: Set = emptySet(), + val patchOptionValues: Map = emptyMap() ) { val selectedCount: Int get() = selectedPatches.size val totalCount: Int get() = allPatches.size diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt index 1d4c6a5..1d5845b 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patches/PatchesScreen.kt @@ -5,7 +5,11 @@ package app.morphe.gui.ui.screens.patches +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +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 @@ -18,14 +22,18 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropUp -import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.Refresh 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.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen @@ -40,7 +48,10 @@ import app.morphe.gui.ui.components.SettingsButton import app.morphe.gui.ui.components.getErrorType import app.morphe.gui.ui.components.getFriendlyErrorMessage import app.morphe.gui.ui.components.OfflineBanner +import app.morphe.gui.ui.theme.LocalMorpheAccents +import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.MorpheColors +import app.morphe.gui.ui.theme.LocalMorpheFont import java.awt.FileDialog import java.awt.Frame import java.io.File @@ -64,8 +75,11 @@ data class PatchesScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable fun PatchesScreenContent(viewModel: PatchesViewModel) { + val corners = LocalMorpheCorners.current val navigator = LocalNavigator.currentOrThrow val uiState by viewModel.uiState.collectAsState() + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current var showErrorDialog by remember { mutableStateOf(false) } var currentError by remember { mutableStateOf(null) } @@ -95,110 +109,205 @@ fun PatchesScreenContent(viewModel: PatchesViewModel) { ) } - Scaffold( - topBar = { - TopAppBar( - title = { - Column { - Text("Select Patches", fontWeight = FontWeight.SemiBold) - Text( - text = viewModel.getApkName(), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - }, - navigationIcon = { - IconButton(onClick = { navigator.pop() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) - } - }, - actions = { - DeviceIndicator() - IconButton( - onClick = { viewModel.loadReleases() }, - enabled = !uiState.isLoading - ) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = "Refresh" - ) - } - SettingsButton(allowCacheClear = true) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) - }, - ) { paddingValues -> - Column( + val dividerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.08f) + + Column( + modifier = Modifier + .fillMaxSize() + ) { + // ── Header bar ── + Row( modifier = Modifier - .fillMaxSize() - .padding(paddingValues) + .fillMaxWidth() + .drawBehind { + drawLine( + color = dividerColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 1f + ) + } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically ) { - // Channel selector (hidden when offline) - if (!uiState.isOffline) { - ChannelSelector( - selectedChannel = uiState.selectedChannel, - onChannelSelected = { viewModel.setChannel(it) }, - stableCount = uiState.stableReleases.size, - devCount = uiState.devReleases.size, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + // Back button + val backHover = remember { MutableInteractionSource() } + val isBackHovered by backHover.collectIsHoveredAsState() + val backBorder by animateColorAsState( + if (isBackHovered) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .size(34.dp) + .hoverable(backHover) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, backBorder, RoundedCornerShape(corners.small)) + .clickable { navigator.pop() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) ) } - // Offline banner - if (uiState.isOffline && uiState.currentReleases.isNotEmpty()) { - OfflineBanner( - onRetry = { viewModel.loadReleases() }, - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 0.dp) + Spacer(modifier = Modifier.width(14.dp)) + + // Title block + Column(modifier = Modifier.weight(1f)) { + Text( + text = "SELECT PATCHES", + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + letterSpacing = 1.5.sp, + lineHeight = 14.sp ) + if (viewModel.getApkName().isNotBlank()) { + Text( + text = viewModel.getApkName(), + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + letterSpacing = 0.3.sp, + lineHeight = 8.sp + ) + } + } + + // Actions + val refreshHover = remember { MutableInteractionSource() } + val isRefreshHovered by refreshHover.collectIsHoveredAsState() + val refreshBorder by animateColorAsState( + MaterialTheme.colorScheme.outline.copy(alpha = if (isRefreshHovered) 0.24f else 0.1f), + animationSpec = tween(150) + ) + + if (!uiState.isLocalSource) { + Box( + modifier = Modifier + .size(34.dp) + .hoverable(refreshHover) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, refreshBorder, RoundedCornerShape(corners.small)) + .then( + if (!uiState.isLoading) Modifier.clickable { viewModel.loadReleases() } + else Modifier + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Refresh", + tint = if (uiState.isLoading) MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + } + Spacer(modifier = Modifier.width(6.dp)) + } + + DeviceIndicator() + Spacer(modifier = Modifier.width(6.dp)) + SettingsButton(allowCacheClear = true) + } + + // ── Content area ── + Column(modifier = Modifier.fillMaxSize()) { + // Local source banner + if (uiState.isLocalSource) { + LocalSourceBanner( + patchFile = uiState.downloadedPatchFile, + modifier = Modifier.padding(16.dp) + ) + } else { + // Channel selector + if (!uiState.isOffline) { + ChannelSelector( + selectedChannel = uiState.selectedChannel, + onChannelSelected = { viewModel.setChannel(it) }, + stableCount = uiState.stableReleases.size, + devCount = uiState.devReleases.size, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp) + ) + } + + // Offline banner + if (uiState.isOffline && uiState.currentReleases.isNotEmpty()) { + OfflineBanner( + onRetry = { viewModel.loadReleases() }, + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 0.dp) + ) + } } when { + uiState.isLocalSource -> { + Spacer(modifier = Modifier.weight(1f)) + } uiState.isLoading -> { Box( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - CircularProgressIndicator(color = MorpheColors.Blue) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.height(14.dp)) Text( - text = "Fetching releases...", - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "FETCHING RELEASES", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + letterSpacing = 2.sp ) } } } - uiState.currentReleases.isEmpty() && !uiState.isLoading -> { Box( modifier = Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = "No releases found", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "NO RELEASES FOUND", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + letterSpacing = 1.5.sp ) - OutlinedButton(onClick = { viewModel.loadReleases() }) { - Text("Retry") + Spacer(modifier = Modifier.height(12.dp)) + OutlinedButton( + onClick = { viewModel.loadReleases() }, + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.25f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.onSurfaceVariant) + ) { + Text( + "RETRY", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 1.sp + ) } } } } - else -> { // Releases list LazyColumn( @@ -206,7 +315,7 @@ fun PatchesScreenContent(viewModel: PatchesViewModel) { .weight(1f) .fillMaxWidth(), contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(6.dp) ) { items( items = uiState.currentReleases, @@ -227,9 +336,7 @@ fun PatchesScreenContent(viewModel: PatchesViewModel) { uiState = uiState, onDownloadClick = { viewModel.downloadPatches() }, onSelectClick = { - // Save the selected version to config before navigating back viewModel.confirmSelection() - // Go back to HomeScreen - the new patches file is now cached navigator.pop() }, onExportJsonClick = { @@ -250,6 +357,10 @@ fun PatchesScreenContent(viewModel: PatchesViewModel) { } } +// ═══════════════════════════════════════════════════════════════════ +// CHANNEL SELECTOR +// ═══════════════════════════════════════════════════════════════════ + @Composable private fun ChannelSelector( selectedChannel: ReleaseChannel, @@ -258,22 +369,26 @@ private fun ChannelSelector( devCount: Int, modifier: Modifier = Modifier ) { + val accents = LocalMorpheAccents.current + Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { ChannelChip( - label = "Stable", + label = "STABLE", count = stableCount, isSelected = selectedChannel == ReleaseChannel.STABLE, onClick = { onChannelSelected(ReleaseChannel.STABLE) }, + accentColor = accents.primary, modifier = Modifier.weight(1f) ) ChannelChip( - label = "Dev", + label = "DEV", count = devCount, isSelected = selectedChannel == ReleaseChannel.DEV, onClick = { onChannelSelected(ReleaseChannel.DEV) }, + accentColor = accents.primary, modifier = Modifier.weight(1f) ) } @@ -285,50 +400,74 @@ private fun ChannelChip( count: Int, isSelected: Boolean, onClick: () -> Unit, + accentColor: Color, modifier: Modifier = Modifier ) { - val backgroundColor = if (isSelected) { - MorpheColors.Blue.copy(alpha = 0.15f) - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - } - - val borderColor = if (isSelected) { - MorpheColors.Blue - } else { - MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - } + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + + val borderColor by animateColorAsState( + when { + isSelected -> accentColor.copy(alpha = 0.5f) + isHovered -> accentColor.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + }, + animationSpec = tween(150) + ) + val bgColor = if (isSelected) accentColor.copy(alpha = 0.08f) else Color.Transparent - Surface( + Box( modifier = modifier - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onClick), - color = backgroundColor, - shape = RoundedCornerShape(12.dp), - border = androidx.compose.foundation.BorderStroke(1.dp, borderColor) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)) + .background(bgColor) + .hoverable(hoverInteraction) + .clickable(onClick = onClick) ) { Row( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 10.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { + // Selection dot + if (isSelected) { + Box( + modifier = Modifier + .size(6.dp) + .background(accentColor, RoundedCornerShape(1.dp)) + ) + Spacer(modifier = Modifier.width(8.dp)) + } Text( text = label, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - color = if (isSelected) MorpheColors.Blue else MaterialTheme.colorScheme.onSurface + fontSize = 11.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + fontFamily = mono, + color = if (isSelected) accentColor else MaterialTheme.colorScheme.onSurface, + letterSpacing = 1.5.sp ) if (count > 0) { Spacer(modifier = Modifier.width(8.dp)) Text( - text = "($count)", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "$count", + fontSize = 10.sp, + fontFamily = mono, + color = if (isSelected) accentColor.copy(alpha = 0.6f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) } } } } +// ════════════════════════════════════════════════════════════════════ +// RELEASE CARD +// ════════════════════════════════════════════════════════════════════ + @Composable private fun ReleaseCard( release: Release, @@ -337,156 +476,210 @@ private fun ReleaseCard( isOffline: Boolean = false, onClick: () -> Unit ) { - val titleColor = MaterialTheme.colorScheme.onSurface - val subtitleColor = MaterialTheme.colorScheme.onSurfaceVariant - val dateColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - val accentColor = if (isSelected && isDownloaded) MorpheColors.Teal else MorpheColors.Blue - val devBadgeColor = MorpheColors.Teal + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val selectedColor = accents.primary + val downloadedColor = accents.secondary + val accentColor = when { + isSelected -> selectedColor + isDownloaded -> downloadedColor + else -> MaterialTheme.colorScheme.onSurfaceVariant + } var isExpanded by remember { mutableStateOf(false) } val hasNotes = !release.body.isNullOrBlank() val interactionSource = remember { MutableInteractionSource() } val isHovered by interactionSource.collectIsHoveredAsState() - val cardBackground = when { - isSelected && isDownloaded -> MorpheColors.Teal.copy(alpha = if (isHovered) 0.22f else 0.15f) - isSelected -> MorpheColors.Blue.copy(alpha = if (isHovered) 0.22f else 0.15f) - else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (isHovered) 0.7f else 0.25f) + + val borderColor by animateColorAsState( + when { + isSelected -> accentColor.copy(alpha = 0.5f) + isDownloaded -> downloadedColor.copy(alpha = 0.32f) + isHovered -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.08f) + }, + animationSpec = tween(150) + ) + + val bgColor = when { + isSelected -> selectedColor.copy(alpha = 0.07f) + isDownloaded -> downloadedColor.copy(alpha = 0.045f) + else -> MaterialTheme.colorScheme.surface } - Card( + Box( modifier = Modifier .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(bgColor) .hoverable(interactionSource) - .clickable(interactionSource = interactionSource, indication = null) { onClick() }, - colors = CardDefaults.cardColors(containerColor = cardBackground), - shape = RoundedCornerShape(12.dp) + .clickable(interactionSource = interactionSource, indication = null) { onClick() } ) { Row(modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min)) { - // Green ribbon for downloaded (non-selected) cards - if (isDownloaded && !isSelected) { + // Left accent stripe + if (isSelected || isDownloaded) { Box( modifier = Modifier - .width(4.dp) + .width(3.dp) .fillMaxHeight() - .background( - MorpheColors.Teal, - RoundedCornerShape(topStart = 12.dp, bottomStart = 12.dp) - ) + .background(accentColor) ) } Column(modifier = Modifier.weight(1f)) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = release.tagName, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = titleColor - ) - if (release.isDevRelease()) { - Surface( - color = devBadgeColor.copy(alpha = 0.2f), - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = "DEV", - fontSize = 10.sp, - fontWeight = FontWeight.Bold, - color = devBadgeColor, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = release.tagName, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = when { + isSelected -> selectedColor + isDownloaded -> downloadedColor + else -> MaterialTheme.colorScheme.onSurface + } + ) + if (release.isDevRelease()) { + Box( + modifier = Modifier + .background(accents.primary.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) + .border(1.dp, accents.primary.copy(alpha = 0.22f), RoundedCornerShape(corners.small)) + .padding(horizontal = 5.dp, vertical = 1.dp) + ) { + Text( + text = "DEV", + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 1.sp + ) + } + } + if (isDownloaded) { + Box( + modifier = Modifier + .background(downloadedColor.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) + .border(1.dp, downloadedColor.copy(alpha = 0.24f), RoundedCornerShape(corners.small)) + .padding(horizontal = 5.dp, vertical = 1.dp) + ) { + Text( + text = "CACHED", + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = downloadedColor, + letterSpacing = 1.sp + ) + } } } - } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(4.dp)) - // Show .mpp file info if available - release.assets.find { it.isMpp() }?.let { mppAsset -> - Text( - text = "${mppAsset.name} (${mppAsset.getFormattedSize()})", - fontSize = 13.sp, - color = subtitleColor - ) - } + // Patch file info + release.assets.find { it.isPatchFile() }?.let { patchAsset -> + Text( + text = "${patchAsset.name} (${patchAsset.getFormattedSize()})", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + letterSpacing = 0.3.sp + ) + } - val formattedDate = formatDate(release.publishedAt) - if (formattedDate.isNotEmpty()) { - Text( - text = "${if (isOffline) "Cached:" else "Published:"} $formattedDate", - fontSize = 12.sp, - color = dateColor - ) - } + val formattedDate = release.publishedAt?.let { formatDate(it) } ?: "" + if (formattedDate.isNotEmpty()) { + Text( + text = "${if (isOffline) "Cached:" else "Published:"} $formattedDate", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + } + + if (hasNotes) { + Spacer(modifier = Modifier.height(6.dp)) + val noteHover = remember { MutableInteractionSource() } + val isNoteHovered by noteHover.collectIsHoveredAsState() + val noteBorder by animateColorAsState( + if (isNoteHovered) accentColor.copy(alpha = 0.3f) + else accentColor.copy(alpha = 0.15f), + animationSpec = tween(150) + ) - if (hasNotes) { - Spacer(modifier = Modifier.height(4.dp)) - Surface( - color = accentColor.copy(alpha = 0.1f), - shape = RoundedCornerShape(6.dp), - modifier = Modifier - .clip(RoundedCornerShape(6.dp)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null - ) { isExpanded = !isExpanded } - ) { Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + modifier = Modifier + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, noteBorder, RoundedCornerShape(corners.small)) + .hoverable(noteHover) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { isExpanded = !isExpanded } + .padding(horizontal = 8.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = if (isExpanded) "Hide patch notes" else "Patch notes", - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - color = accentColor + text = if (isExpanded) "HIDE NOTES" else "PATCH NOTES", + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = accentColor, + letterSpacing = 0.5.sp ) Icon( imageVector = if (isExpanded) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown, contentDescription = null, tint = accentColor, - modifier = Modifier.size(16.dp) + modifier = Modifier.size(14.dp) ) } } } } - } - - // Expandable release notes - if (isExpanded && hasNotes) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) - ) - FormattedReleaseNotes( - markdown = release.body.orEmpty(), - modifier = Modifier.padding(16.dp) - ) - } + // Expandable release notes + if (isExpanded && hasNotes) { + val notesDividerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.06f) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(1.dp) + .background(notesDividerColor) + ) + FormattedReleaseNotes( + markdown = release.body.orEmpty(), + modifier = Modifier.padding(16.dp) + ) + } } } } } -/** - * Renders GitHub release notes markdown as formatted Compose text. - */ +// ════════════════════════════════════════════════════════════════════ +// RELEASE NOTES +// ════════════════════════════════════════════════════════════════════ + @Composable private fun FormattedReleaseNotes(markdown: String, modifier: Modifier = Modifier) { + val mono = LocalMorpheFont.current val lines = parseMarkdown(markdown) Column( modifier = modifier, @@ -496,36 +689,42 @@ private fun FormattedReleaseNotes(markdown: String, modifier: Modifier = Modifie when (line) { is MdLine.Header -> Text( text = line.text, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + letterSpacing = 0.5.sp ) is MdLine.SubHeader -> Text( text = line.text, - fontSize = 13.sp, + fontSize = 11.sp, fontWeight = FontWeight.SemiBold, + fontFamily = mono, color = MaterialTheme.colorScheme.onSurface ) is MdLine.Bullet -> { Row { Text( - text = "\u2022 ", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "· ", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f) ) Text( text = line.text, - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = 18.sp + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + lineHeight = 17.sp ) } } is MdLine.Plain -> Text( text = line.text, - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - lineHeight = 18.sp + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + lineHeight = 17.sp ) } } @@ -574,6 +773,10 @@ private fun cleanMarkdown(text: String): String { return result } +// ════════════════════════════════════════════════════════════════════ +// BOTTOM ACTION BAR +// ════════════════════════════════════════════════════════════════════ + @Composable private fun BottomActionBar( uiState: PatchesUiState, @@ -581,105 +784,189 @@ private fun BottomActionBar( onSelectClick: () -> Unit, onExportJsonClick: () -> Unit, ) { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 3.dp - ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - // Download progress - if (uiState.isDownloading) { - LinearProgressIndicator( - progress = { uiState.downloadProgress }, - modifier = Modifier - .fillMaxWidth() - .height(4.dp) - .clip(RoundedCornerShape(2.dp)), - color = MorpheColors.Blue, - ) - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = "Downloading patches...", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val dividerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.08f) + + Column( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = dividerColor, + start = Offset(0f, 0f), + end = Offset(size.width, 0f), + strokeWidth = 1f ) - Spacer(modifier = Modifier.height(12.dp)) } + .background(MaterialTheme.colorScheme.surface) + .padding(16.dp) + ) { + // Download progress + if (uiState.isDownloading) { + LinearProgressIndicator( + progress = { uiState.downloadProgress }, + modifier = Modifier + .fillMaxWidth() + .height(3.dp) + .clip(RoundedCornerShape(1.dp)), + color = MaterialTheme.colorScheme.onSurfaceVariant, + trackColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.1f) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "DOWNLOADING…", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + letterSpacing = 1.5.sp + ) + Spacer(modifier = Modifier.height(12.dp)) + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + if (uiState.downloadedPatchFile == null) { // Download button - if (uiState.downloadedPatchFile == null) { - Button( - onClick = onDownloadClick, - enabled = uiState.selectedRelease != null && !uiState.isDownloading, - modifier = Modifier - .weight(1f) - .height(48.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MorpheColors.Blue - ), - shape = RoundedCornerShape(12.dp) + Button( + onClick = onDownloadClick, + enabled = uiState.selectedRelease != null && !uiState.isDownloading, + modifier = Modifier + .weight(1f) + .height(44.dp), + colors = ButtonDefaults.buttonColors(containerColor = accents.primary), + shape = RoundedCornerShape(corners.small) + ) { + Text( + text = if (uiState.isDownloading) "DOWNLOADING…" else "DOWNLOAD", + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + fontSize = 12.sp, + letterSpacing = 1.sp + ) + } + } else { + // Select button + Button( + onClick = onSelectClick, + modifier = Modifier + .weight(1f) + .height(44.dp), + colors = ButtonDefaults.buttonColors(containerColor = accents.primary), + shape = RoundedCornerShape(corners.small) + ) { + Text( + text = "SELECT", + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + fontSize = 12.sp, + letterSpacing = 1.sp + ) + } + + // Export JSON + if (uiState.isExporting) { + Box( + modifier = Modifier.height(44.dp).width(44.dp), + contentAlignment = Alignment.Center ) { - Text( - text = if (uiState.isDownloading) "Downloading..." else "Download Patches", - fontWeight = FontWeight.Medium + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + strokeWidth = 2.dp ) } } else { - // Select button (patches downloaded) - Button( - onClick = onSelectClick, - modifier = Modifier - .weight(1f) - .height(48.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MorpheColors.Teal - ), - shape = RoundedCornerShape(12.dp) + OutlinedButton( + onClick = onExportJsonClick, + modifier = Modifier.height(44.dp), + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, accents.primary.copy(alpha = 0.3f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = accents.primary) ) { Text( - text = "Select", - fontWeight = FontWeight.Medium + text = "EXPORT JSON", + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + fontSize = 11.sp, + letterSpacing = 0.5.sp ) } + } + } + } + } +} - // Export JSON button / spinner - if (uiState.isExporting) { - Box( - modifier = Modifier.height(48.dp).width(48.dp), - contentAlignment = Alignment.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MorpheColors.Blue, - strokeWidth = 2.dp - ) - } - } else { - OutlinedButton( - onClick = onExportJsonClick, - modifier = Modifier.height(48.dp), - shape = RoundedCornerShape(12.dp), - border = androidx.compose.foundation.BorderStroke( - 1.dp, - MorpheColors.Blue - ), - ) { - Text( - text = "Export JSON", - fontWeight = FontWeight.Medium, - color = MorpheColors.Blue - ) - } +// ════════════════════════════════════════════════════════════════════ +// LOCAL SOURCE BANNER +// ════════════════════════════════════════════════════════════════════ + +@Composable +private fun LocalSourceBanner( + patchFile: File?, + modifier: Modifier = Modifier +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.16f), RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.18f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + // Left accent stripe + Box( + modifier = Modifier + .width(3.dp) + .fillMaxHeight() + .background(accents.primary) + ) + + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = null, + tint = accents.primary, + modifier = Modifier.size(20.dp) + ) + Column { + Text( + text = "LOCAL PATCH FILE", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 1.5.sp + ) + if (patchFile != null) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = patchFile.name, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + letterSpacing = 0.3.sp + ) } } } - } } } 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 ff03975..da8940c 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 @@ -12,6 +12,8 @@ import cafe.adriel.voyager.core.model.screenModelScope import app.morphe.gui.data.model.Release import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository +import app.morphe.gui.data.repository.PatchSourceManager +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -27,7 +29,9 @@ class PatchesViewModel( private val apkPath: String, private val apkName: String, private val patchRepository: PatchRepository, - private val configRepository: ConfigRepository + private val configRepository: ConfigRepository, + private val localPatchFilePath: String? = null, + private val patchSourceManager: PatchSourceManager? = null ) : ScreenModel { private val _uiState = MutableStateFlow(PatchesUiState()) @@ -35,12 +39,41 @@ class PatchesViewModel( init { loadReleases() + + // Observe cache clears / source changes + patchSourceManager?.let { psm -> + screenModelScope.launch { + psm.sourceVersion.drop(1).collect { + Logger.info("PatchesVM: Source changed, reloading...") + _uiState.value = PatchesUiState() + loadReleases() + } + } + } } fun loadReleases() { screenModelScope.launch { _uiState.value = _uiState.value.copy(isLoading = true, error = null) + // LOCAL source: skip GitHub, use the file directly + if (localPatchFilePath != null) { + val localFile = File(localPatchFilePath) + if (localFile.exists()) { + _uiState.value = _uiState.value.copy( + isLoading = false, + isLocalSource = true, + downloadedPatchFile = localFile + ) + } else { + _uiState.value = _uiState.value.copy( + isLoading = false, + error = "Local patch file not found: ${localFile.name}" + ) + } + return@launch + } + val result = patchRepository.fetchReleases() result.fold( @@ -144,7 +177,7 @@ class PatchesViewModel( // In offline mode, find the cached file by matching the asset name val assetName = release.assets.firstOrNull()?.name if (assetName != null) { - val patchesDir = app.morphe.gui.util.FileUtils.getPatchesDir() + val patchesDir = patchRepository.getCacheDir() val file = File(patchesDir, assetName) if (file.exists()) file else null } else null @@ -160,13 +193,14 @@ class PatchesViewModel( } /** - * Find all cached .mpp files in the patches directory. + * Find all cached .mpp files in the per-source cache directory. */ private fun findAllCachedPatchFiles(): List { - val patchesDir = app.morphe.gui.util.FileUtils.getPatchesDir() - return patchesDir.listFiles { file -> file.extension.equals("mpp", ignoreCase = true) } - ?.filter { it.length() > 0 } - ?: emptyList() + val patchesDir = patchRepository.getCacheDir() + return patchesDir.listFiles { file -> + val ext = file.extension.lowercase() + ext == "mpp" || ext == "jar" + }?.filter { it.length() > 0 } ?: emptyList() } private val versionRegex = Regex("""(\d+\.\d+\.\d+(?:-dev\.\d+)?)""") @@ -210,8 +244,8 @@ class PatchesViewModel( * Check if patches for a release are already downloaded and valid. */ private fun checkCachedPatches(release: Release): File? { - val asset = patchRepository.findMppAsset(release) ?: return null - val patchesDir = app.morphe.gui.util.FileUtils.getPatchesDir() + val asset = patchRepository.findPatchAsset(release) ?: return null + val patchesDir = patchRepository.getCacheDir() val cachedFile = File(patchesDir, asset.name) // Verify file exists and size matches (size check acts as basic integrity verification) @@ -334,6 +368,7 @@ enum class ReleaseChannel { data class PatchesUiState( val isLoading: Boolean = false, val isOffline: Boolean = false, + val isLocalSource: Boolean = false, val offlineReleases: List = emptyList(), val stableReleases: List = emptyList(), val devReleases: List = emptyList(), diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt index 1beeca4..33b2aeb 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreen.kt @@ -5,7 +5,14 @@ package app.morphe.gui.ui.screens.patching +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.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -19,8 +26,9 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -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.sp @@ -32,7 +40,9 @@ import app.morphe.gui.data.model.PatchConfig import org.koin.core.parameter.parametersOf import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.screens.result.ResultScreen -import app.morphe.gui.ui.theme.MorpheColors +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.FileUtils import app.morphe.gui.util.Logger import java.awt.Desktop @@ -46,16 +56,19 @@ data class PatchingScreen( @Composable override fun Content() { - val viewModel = koinScreenModel { parametersOf(config) } + val viewModel = koinScreenModel { parametersOf(config) } PatchingScreenContent(viewModel = viewModel) } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -fun PatchingScreenContent(viewModel: PatchingScreenModel) { +fun PatchingScreenContent(viewModel: PatchingViewModel) { + val accents = LocalMorpheAccents.current val navigator = LocalNavigator.currentOrThrow val uiState by viewModel.uiState.collectAsState() + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.10f) // Auto-start patching when screen loads LaunchedEffect(Unit) { @@ -79,167 +92,240 @@ fun PatchingScreenContent(viewModel: PatchingScreenModel) { } } - Scaffold( - topBar = { - TopAppBar( - title = { - Column { - Text("Patching", fontWeight = FontWeight.SemiBold) - Text( - text = getStatusText(uiState.status), - style = MaterialTheme.typography.bodySmall, - color = getStatusColor(uiState.status) - ) - } - }, - navigationIcon = { - IconButton( - onClick = { navigator.pop() }, - enabled = !uiState.isInProgress + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + // Header row + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 1f + ) + } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Back button + val backHover = remember { MutableInteractionSource() } + val isBackHovered by backHover.collectIsHoveredAsState() + val backBg by animateColorAsState( + if (isBackHovered) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + else Color.Transparent, + animationSpec = tween(150) + ) + Box( + modifier = Modifier + .size(32.dp) + .hoverable(backHover) + .clip(RoundedCornerShape(corners.small)) + .background(backBg) + .clickable(enabled = !uiState.isInProgress) { navigator.pop() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(18.dp), + tint = if (uiState.isInProgress) + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f) + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + + Spacer(Modifier.width(12.dp)) + + // Title + status + Column { + Text( + text = "PATCHING", + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + letterSpacing = 1.sp + ) + Text( + text = getStatusText(uiState.status).uppercase(), + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = getStatusColor(uiState.status), + letterSpacing = 1.sp + ) + } + + Spacer(Modifier.weight(1f)) + + // Cancel button + if (uiState.canCancel) { + val cancelHover = remember { MutableInteractionSource() } + val isCancelHovered by cancelHover.collectIsHoveredAsState() + val cancelBg by animateColorAsState( + if (isCancelHovered) MaterialTheme.colorScheme.error.copy(alpha = 0.12f) + else Color.Transparent, + animationSpec = tween(150) + ) + val cancelBorder by animateColorAsState( + if (isCancelHovered) MaterialTheme.colorScheme.error.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f), + animationSpec = tween(150) + ) + + Row( + modifier = Modifier + .hoverable(cancelHover) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, cancelBorder, RoundedCornerShape(corners.small)) + .background(cancelBg) + .clickable { viewModel.cancelPatching() } + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" + imageVector = Icons.Default.Close, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.error + ) + Text( + text = "CANCEL", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.error, + letterSpacing = 0.5.sp ) } - }, - actions = { - if (uiState.canCancel) { - TextButton( - onClick = { viewModel.cancelPatching() }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(4.dp)) - Text("Cancel") - } - } - TopBarRow(allowCacheClear = false) - Spacer(Modifier.width(12.dp)) - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface - ) - ) + + Spacer(Modifier.width(8.dp)) + } + + TopBarRow(allowCacheClear = false, isPatching = true) } - ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - ) { - // Progress indicator - if (uiState.isInProgress) { - Column { - if (uiState.hasProgress) { - // Show determinate progress when we have progress info - LinearProgressIndicator( - progress = { uiState.progress }, - modifier = Modifier - .fillMaxWidth() - .height(4.dp), - color = MorpheColors.Blue, + + // Progress section + if (uiState.isInProgress) { + Column { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(3.dp), + color = accents.primary, + trackColor = accents.primary.copy(alpha = 0.08f), + progress = { if (uiState.hasProgress) uiState.progress else 0f }, + ) + if (!uiState.hasProgress) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(3.dp), + color = accents.primary, + trackColor = Color.Transparent + ) + } + + if (uiState.hasProgress) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = uiState.currentPatch ?: "Applying patches...", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + maxLines = 1, + modifier = Modifier.weight(1f) ) - // Show progress text - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = uiState.currentPatch ?: "Applying patches...", - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - modifier = Modifier.weight(1f) - ) - Text( - text = "${uiState.patchedCount}/${uiState.totalPatches}", - fontSize = 11.sp, - color = MorpheColors.Blue, - fontWeight = FontWeight.Medium - ) - } - } else { - // Show indeterminate progress when we don't have progress info - LinearProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .height(4.dp), - color = MorpheColors.Blue + Text( + text = "${uiState.patchedCount}/${uiState.totalPatches}", + fontSize = 10.sp, + fontFamily = mono, + color = accents.primary, + fontWeight = FontWeight.Bold ) } } } + } - // Log output - LazyColumn( - state = listState, - modifier = Modifier - .weight(1f) - .fillMaxWidth() - .padding(16.dp) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)), - contentPadding = PaddingValues(12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - items(uiState.logs, key = { it.id }) { entry -> - LogEntryRow(entry) - } + // Log output + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.15f)), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + items(uiState.logs, key = { it.id }) { entry -> + LogEntryRow(entry, mono) } + } - // Bottom action bar (only for failed/cancelled - success auto-navigates) - when (uiState.status) { - PatchingStatus.COMPLETED -> { - // Show brief success message while auto-navigating - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 3.dp - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MorpheColors.Teal - ) - Spacer(modifier = Modifier.width(12.dp)) - Text( - text = "Patching completed! Loading result...", - color = MorpheColors.Teal, - fontWeight = FontWeight.Medium + // Bottom action bar + when (uiState.status) { + PatchingStatus.COMPLETED -> { + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, 0f), + end = Offset(size.width, 0f), + strokeWidth = 1f ) } - } - } - - PatchingStatus.FAILED, PatchingStatus.CANCELLED -> { - FailureBottomBar( - status = uiState.status, - error = uiState.error, - onStartOver = { navigator.popUntilRoot() }, - onGoBack = { navigator.pop() } + .background(accents.secondary.copy(alpha = 0.04f)) + .padding(14.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = accents.secondary + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = "PATCHING COMPLETED — LOADING RESULT...", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary, + letterSpacing = 0.5.sp ) } + } - else -> { - // Show nothing for in-progress states - } + PatchingStatus.FAILED, PatchingStatus.CANCELLED -> { + FailureBottomBar( + status = uiState.status, + error = uiState.error, + corners = corners, + mono = mono, + borderColor = borderColor, + onStartOver = { navigator.popUntilRoot() }, + onGoBack = { navigator.pop() } + ) } + + else -> {} } } } @@ -248,61 +334,96 @@ fun PatchingScreenContent(viewModel: PatchingScreenModel) { private fun FailureBottomBar( status: PatchingStatus, error: String?, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, onStartOver: () -> Unit, onGoBack: () -> Unit ) { + val accents = LocalMorpheAccents.current var tempFilesCleared by remember { mutableStateOf(false) } val hasTempFiles = remember { FileUtils.hasTempFiles() } val tempFilesSize = remember { FileUtils.getTempDirSize() } val logFile = remember { Logger.getLogFile() } - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 3.dp + Column( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, 0f), + end = Offset(size.width, 0f), + strokeWidth = 1f + ) + } + .background(MaterialTheme.colorScheme.surface) + .padding(16.dp) ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - // Error message + // Error message + Text( + text = (if (status == PatchingStatus.CANCELLED) "PATCHING CANCELLED" else "PATCHING FAILED").uppercase(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.error, + letterSpacing = 1.sp + ) + if (error != null && status != PatchingStatus.CANCELLED) { + Spacer(modifier = Modifier.height(4.dp)) Text( - text = if (status == PatchingStatus.CANCELLED) - "Patching was cancelled" - else - error ?: "Patching failed", - color = MaterialTheme.colorScheme.error, - fontWeight = FontWeight.Medium + text = error, + fontSize = 12.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.error.copy(alpha = 0.7f) ) + } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(12.dp)) - // Log file location - if (logFile != null && logFile.exists()) { - Row( + // Log file location + if (logFile != null && logFile.exists()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "LOG FILE", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.sp + ) + Spacer(Modifier.height(2.dp)) + Text( + text = logFile.absolutePath, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + maxLines = 1 + ) + } + + val openHover = remember { MutableInteractionSource() } + val isOpenHovered by openHover.collectIsHoveredAsState() + val openBg by animateColorAsState( + if (isOpenHovered) accents.primary.copy(alpha = 0.1f) else Color.Transparent, + animationSpec = tween(150) + ) + Box( modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Log file", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = logFile.absolutePath, - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - fontFamily = FontFamily.Monospace, - maxLines = 1 - ) - } - TextButton( - onClick = { + .hoverable(openHover) + .clip(RoundedCornerShape(corners.small)) + .background(openBg) + .clickable { try { if (Desktop.isDesktopSupported()) { Desktop.getDesktop().open(logFile.parentFile) @@ -311,117 +432,182 @@ private fun FailureBottomBar( Logger.error("Failed to open logs folder", e) } } - ) { - Text("Open", fontSize = 12.sp) - } + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = "OPEN", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 0.5.sp + ) } - - Spacer(modifier = Modifier.height(12.dp)) } - // Cleanup option - if (hasTempFiles && !tempFilesCleared) { - Row( + Spacer(modifier = Modifier.height(8.dp)) + } + + // Cleanup option + if (hasTempFiles && !tempFilesCleared) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, borderColor, RoundedCornerShape(corners.small)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "TEMPORARY FILES", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.sp + ) + Spacer(Modifier.height(2.dp)) + Text( + text = "${formatFileSize(tempFilesSize)} can be freed", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + + val cleanHover = remember { MutableInteractionSource() } + val isCleanHovered by cleanHover.collectIsHoveredAsState() + val cleanBg by animateColorAsState( + if (isCleanHovered) accents.warning.copy(alpha = 0.1f) else Color.Transparent, + animationSpec = tween(150) + ) + Box( modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = "Temporary files", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "${formatFileSize(tempFilesSize)} can be freed", - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) - } - TextButton( - onClick = { + .hoverable(cleanHover) + .clip(RoundedCornerShape(corners.small)) + .background(cleanBg) + .clickable { FileUtils.cleanupAllTempDirs() tempFilesCleared = true Logger.info("Cleaned temp files after failed patching") } - ) { - Text("Clean up", fontSize = 12.sp) - } - } - - Spacer(modifier = Modifier.height(12.dp)) - } else if (tempFilesCleared) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(MorpheColors.Teal.copy(alpha = 0.1f)) - .padding(12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + .padding(horizontal = 10.dp, vertical = 4.dp) ) { Text( - text = "Temp files cleaned", - fontSize = 12.sp, - color = MorpheColors.Teal + text = "CLEAN UP", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.warning, + letterSpacing = 0.5.sp ) } - - Spacer(modifier = Modifier.height(12.dp)) } - // Action buttons + Spacer(modifier = Modifier.height(8.dp)) + } else if (tempFilesCleared) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .background(accents.secondary.copy(alpha = 0.06f)) + .border(1.dp, accents.secondary.copy(alpha = 0.2f), RoundedCornerShape(corners.small)) + .padding(12.dp) ) { - OutlinedButton( - onClick = onStartOver, - modifier = Modifier - .weight(1f) - .height(48.dp), - shape = RoundedCornerShape(12.dp) - ) { - Text("Start Over") - } - Button( - onClick = onGoBack, - modifier = Modifier - .weight(1f) - .height(48.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MorpheColors.Blue - ), - shape = RoundedCornerShape(12.dp) - ) { - Text("Go Back", fontWeight = FontWeight.Medium) - } + Text( + text = "TEMP FILES CLEANED", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary, + letterSpacing = 0.5.sp + ) } + + Spacer(modifier = Modifier.height(8.dp)) } - } -} -private fun formatFileSize(bytes: Long): String { - return when { - bytes < 1024 -> "$bytes B" - bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0) - bytes < 1024 * 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) - else -> "%.2f GB".format(bytes / (1024.0 * 1024.0 * 1024.0)) + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + // Start Over — outlined + val startOverHover = remember { MutableInteractionSource() } + val isStartOverHovered by startOverHover.collectIsHoveredAsState() + val startOverBorder by animateColorAsState( + if (isStartOverHovered) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .hoverable(startOverHover) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, startOverBorder, RoundedCornerShape(corners.small)) + .clickable(onClick = onStartOver), + contentAlignment = Alignment.Center + ) { + Text( + text = "START OVER", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + letterSpacing = 0.5.sp + ) + } + + // Go Back — filled + val goBackHover = remember { MutableInteractionSource() } + val isGoBackHovered by goBackHover.collectIsHoveredAsState() + val goBackBg by animateColorAsState( + if (isGoBackHovered) accents.primary.copy(alpha = 0.9f) + else accents.primary, + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .weight(1f) + .height(40.dp) + .hoverable(goBackHover) + .clip(RoundedCornerShape(corners.small)) + .background(goBackBg, RoundedCornerShape(corners.small)) + .clickable(onClick = onGoBack), + contentAlignment = Alignment.Center + ) { + Text( + text = "GO BACK", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = Color.White, + letterSpacing = 0.5.sp + ) + } + } } } @Composable -private fun LogEntryRow(entry: LogEntry) { +private fun LogEntryRow( + entry: LogEntry, + mono: androidx.compose.ui.text.font.FontFamily +) { + val accents = LocalMorpheAccents.current val color = when (entry.level) { - LogLevel.SUCCESS -> MorpheColors.Teal + LogLevel.SUCCESS -> accents.secondary LogLevel.ERROR -> MaterialTheme.colorScheme.error - LogLevel.WARNING -> Color(0xFFFF9800) - LogLevel.PROGRESS -> MorpheColors.Blue - LogLevel.INFO -> MaterialTheme.colorScheme.onSurfaceVariant + LogLevel.WARNING -> accents.warning + LogLevel.PROGRESS -> accents.primary + LogLevel.INFO -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) } val prefix = when (entry.level) { @@ -434,13 +620,22 @@ private fun LogEntryRow(entry: LogEntry) { Text( text = "$prefix ${entry.message}", - fontFamily = FontFamily.Monospace, - fontSize = 12.sp, + fontFamily = mono, + fontSize = 11.sp, color = color, - lineHeight = 18.sp + lineHeight = 16.sp ) } +private fun formatFileSize(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "%.1f KB".format(bytes / 1024.0) + bytes < 1024 * 1024 * 1024 -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + else -> "%.2f GB".format(bytes / (1024.0 * 1024.0 * 1024.0)) + } +} + private fun getStatusText(status: PatchingStatus): String { return when (status) { PatchingStatus.IDLE -> "Ready" @@ -454,10 +649,11 @@ private fun getStatusText(status: PatchingStatus): String { @Composable private fun getStatusColor(status: PatchingStatus): Color { + val accents = LocalMorpheAccents.current return when (status) { - PatchingStatus.COMPLETED -> MorpheColors.Teal + PatchingStatus.COMPLETED -> accents.secondary PatchingStatus.FAILED -> MaterialTheme.colorScheme.error PatchingStatus.CANCELLED -> MaterialTheme.colorScheme.error - else -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) } } diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreenModel.kt b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt similarity index 90% rename from src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreenModel.kt rename to src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt index ed85fb8..5c27ddc 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingScreenModel.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/patching/PatchingViewModel.kt @@ -8,6 +8,7 @@ package app.morphe.gui.ui.screens.patching import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.screenModelScope import app.morphe.gui.data.model.PatchConfig +import app.morphe.gui.data.repository.ConfigRepository import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -17,9 +18,10 @@ import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService import java.io.File -class PatchingScreenModel( +class PatchingViewModel( private val config: PatchConfig, - private val patchService: PatchService + private val patchService: PatchService, + private val configRepository: ConfigRepository ) : ScreenModel { private val _uiState = MutableStateFlow(PatchingUiState()) @@ -50,6 +52,15 @@ class PatchingScreenModel( addLog("Output: ${File(config.outputApkPath).name}", LogLevel.INFO) addLog("Patches: ${config.enabledPatches.size} enabled", LogLevel.INFO) + // Resolve keystore: use saved path, or derive from output APK location + val appConfig = configRepository.loadConfig() + val resolvedKeystorePath = appConfig.keystorePath + ?: File(config.outputApkPath).let { out -> + out.resolveSibling(out.nameWithoutExtension + ".keystore").absolutePath + }.also { path -> + configRepository.setKeystorePath(path) + } + // Use PatchService for direct library patching val result = patchService.patch( patchesFilePath = config.patchesFilePath, @@ -61,6 +72,10 @@ class PatchingScreenModel( exclusiveMode = config.useExclusiveMode, keepArchitectures = config.keepArchitectures, continueOnError = config.continueOnError, + keystorePath = resolvedKeystorePath, + keystorePassword = appConfig.keystorePassword, + keystoreAlias = appConfig.keystoreAlias, + keystoreEntryPassword = appConfig.keystoreEntryPassword, onProgress = { message -> parseAndAddLog(message) } 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 0c88ae6..5589780 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 @@ -6,28 +6,29 @@ package app.morphe.gui.ui.screens.quick import androidx.compose.animation.* -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +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.foundation.text.BasicTextField import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.OpenInNew import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draganddrop.DragAndDropEvent -import androidx.compose.ui.draganddrop.DragAndDropTarget -import androidx.compose.ui.draganddrop.awtTransferable import androidx.compose.ui.draw.clip +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.graphics.graphicsLayer +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -35,507 +36,997 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen -import androidx.compose.foundation.isSystemInDarkTheme 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.LocalThemeState -import app.morphe.gui.ui.theme.ThemePreference +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.PatchRepository -import app.morphe.gui.util.PatchService -import org.jetbrains.compose.resources.painterResource -import org.koin.compose.koinInject +import app.morphe.gui.data.repository.PatchSourceManager import app.morphe.gui.ui.components.OfflineBanner import app.morphe.gui.ui.components.TopBarRow -import app.morphe.gui.ui.theme.MorpheColors -import androidx.compose.runtime.rememberCoroutineScope +import app.morphe.gui.ui.screens.home.components.FullScreenDropZone +import app.morphe.gui.ui.theme.* +import app.morphe.gui.util.ChecksumStatus +import app.morphe.gui.util.StatusColorType +import app.morphe.gui.util.resolveStatusColorType +import app.morphe.gui.util.resolveVersionStatusDisplay +import app.morphe.gui.util.toColor +import app.morphe.gui.util.DownloadUrlResolver.openUrlAndFollowRedirects +import app.morphe.gui.util.VersionStatus +import app.morphe.gui.util.PatchService import app.morphe.gui.util.AdbManager import app.morphe.gui.util.DeviceMonitor import kotlinx.coroutines.launch -import app.morphe.gui.util.ChecksumStatus -import app.morphe.gui.util.DownloadUrlResolver.openUrlAndFollowRedirects +import org.jetbrains.compose.resources.painterResource +import org.koin.compose.koinInject import java.awt.Desktop -import java.awt.datatransfer.DataFlavor -import java.io.File import java.awt.FileDialog import java.awt.Frame +import java.io.File -/** - * Quick Patch Mode - Single screen simplified patching. - */ class QuickPatchScreen : Screen { @Composable override fun Content() { - val patchRepository: PatchRepository = koinInject() + val patchSourceManager: PatchSourceManager = koinInject() val patchService: PatchService = koinInject() val configRepository: ConfigRepository = koinInject() - val viewModel = remember { - QuickPatchViewModel(patchRepository, patchService, configRepository) + QuickPatchViewModel(patchSourceManager, patchService, configRepository) } - QuickPatchContent(viewModel) } } -@OptIn(ExperimentalComposeUiApi::class) @Composable fun QuickPatchContent(viewModel: QuickPatchViewModel) { val uiState by viewModel.uiState.collectAsState() - val uriHandler = LocalUriHandler.current - // Compose drag and drop target - val dragAndDropTarget = remember { - object : DragAndDropTarget { - override fun onStarted(event: DragAndDropEvent) { - viewModel.setDragHover(true) - } - - override fun onEnded(event: DragAndDropEvent) { - viewModel.setDragHover(false) - } - - override fun onExited(event: DragAndDropEvent) { - viewModel.setDragHover(false) - } - - override fun onEntered(event: DragAndDropEvent) { - viewModel.setDragHover(true) - } - - override fun onDrop(event: DragAndDropEvent): Boolean { - viewModel.setDragHover(false) - val transferable = event.awtTransferable - return try { - if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) { - @Suppress("UNCHECKED_CAST") - val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as List - val apkFile = files.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) || it.name.endsWith(".apkm", ignoreCase = true) } - if (apkFile != null) { - viewModel.onFileSelected(apkFile) - true - } else { - false - } - } else { - false - } - } catch (e: Exception) { - false - } - } - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .dragAndDropTarget( - shouldStartDragAndDrop = { true }, - target = dragAndDropTarget - ) + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.10f) + val density = androidx.compose.ui.platform.LocalDensity.current + var leadingWidthPx by remember { mutableIntStateOf(0) } + var trailingWidthPx by remember { mutableIntStateOf(0) } + val centerSidePadding = with(density) { maxOf(leadingWidthPx, trailingWidthPx).toDp() } + 16.dp + + FullScreenDropZone( + isDragHovering = uiState.isDragHovering, + onDragHoverChange = { viewModel.setDragHover(it) }, + onFilesDropped = { files -> + files.firstOrNull { + it.name.endsWith(".apk", ignoreCase = true) || + it.name.endsWith(".apkm", ignoreCase = true) || + it.name.endsWith(".xapk", ignoreCase = true) || + it.name.endsWith(".apks", ignoreCase = true) + }?.let { viewModel.onFileSelected(it) } + }, + enabled = uiState.phase != QuickPatchPhase.ANALYZING ) { Box(modifier = Modifier.fillMaxSize()) { Column( - modifier = Modifier - .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally + modifier = Modifier.fillMaxSize() ) { - // Branding - Spacer(modifier = Modifier.height(8.dp)) - val themeState = LocalThemeState.current - val isDark = when (themeState.current) { - ThemePreference.DARK, ThemePreference.AMOLED -> true - ThemePreference.LIGHT -> false - ThemePreference.SYSTEM -> isSystemInDarkTheme() - } - Image( - painter = painterResource(if (isDark) Res.drawable.morphe_dark else Res.drawable.morphe_light), - contentDescription = "Morphe Logo", - modifier = Modifier.height(48.dp) - ) - Text( - text = "Quick Patch", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + // ── Header row — matches expert mode ── + Box( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 1f + ) + } + .padding(vertical = 8.dp) + ) { + // Logo — left-aligned + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 12.dp) + .onSizeChanged { leadingWidthPx = it.width } + ) { + BrandingLogo() + } - Spacer(modifier = Modifier.height(16.dp)) + // Patches version badge — centered + Box( + modifier = Modifier + .align(Alignment.Center) + .padding(start = centerSidePadding, end = centerSidePadding) + ) { + PatchesVersionBadge( + patchesVersion = uiState.patchesVersion, + isLoading = uiState.isLoadingPatches, + patchSourceName = uiState.patchSourceName + ) + } - // Offline banner - if (uiState.isOffline && uiState.phase == QuickPatchPhase.IDLE) { - OfflineBanner( - onRetry = { viewModel.retryLoadPatches() }, - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) - ) + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 12.dp) + .onSizeChanged { trailingWidthPx = it.width } + ) { + TopBarRow( + allowCacheClear = false, + isPatching = uiState.phase == QuickPatchPhase.DOWNLOADING || uiState.phase == QuickPatchPhase.PATCHING + ) + } } - // Main content based on phase - // Remember last valid data for safe animation transitions - val lastApkInfo = remember(uiState.apkInfo) { uiState.apkInfo } - val lastOutputPath = remember(uiState.outputPath) { uiState.outputPath } - - AnimatedContent( - targetState = uiState.phase, - modifier = Modifier.weight(1f) - ) { phase -> - when (phase) { - QuickPatchPhase.IDLE, QuickPatchPhase.ANALYZING -> { - IdleContent( - isAnalyzing = phase == QuickPatchPhase.ANALYZING, - isDragHovering = uiState.isDragHovering, - error = uiState.error, - onFileSelected = { viewModel.onFileSelected(it) }, - onDragHover = { viewModel.setDragHover(it) }, - onClearError = { viewModel.clearError() } - ) + // ── Content ── + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Offline banner + if (uiState.isOffline && uiState.phase == QuickPatchPhase.IDLE) { + OfflineBanner( + onRetry = { viewModel.retryLoadPatches() }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) + } + + // ── Main content ── + val lastApkInfo = remember(uiState.apkInfo) { uiState.apkInfo } + val lastOutputPath = remember(uiState.outputPath) { uiState.outputPath } + + AnimatedContent( + targetState = uiState.phase, + modifier = Modifier.weight(1f), + transitionSpec = { + fadeIn(tween(200)) togetherWith fadeOut(tween(200)) } - QuickPatchPhase.READY -> { - // Use current or last known apkInfo to prevent crash during animation - val apkInfo = uiState.apkInfo ?: lastApkInfo - if (apkInfo != null) { - ReadyContent( - apkInfo = apkInfo, - error = uiState.error, - onPatch = { viewModel.startPatching() }, - onClear = { viewModel.reset() }, - onClearError = { viewModel.clearError() } + ) { phase -> + when (phase) { + QuickPatchPhase.IDLE, QuickPatchPhase.ANALYZING -> { + IdleContent( + isAnalyzing = phase == QuickPatchPhase.ANALYZING, + isDragHovering = uiState.isDragHovering, + onBrowse = { openFilePicker()?.let { viewModel.onFileSelected(it) } } ) } - } - QuickPatchPhase.DOWNLOADING, QuickPatchPhase.PATCHING -> { - PatchingContent( - phase = phase, - statusMessage = uiState.statusMessage, - onCancel = { viewModel.cancelPatching() } - ) - } - QuickPatchPhase.COMPLETED -> { - val apkInfo = uiState.apkInfo ?: lastApkInfo - val outputPath = uiState.outputPath ?: lastOutputPath - if (apkInfo != null && outputPath != null) { - CompletedContent( - outputPath = outputPath, - apkInfo = apkInfo, - onPatchAnother = { viewModel.reset() } + QuickPatchPhase.READY -> { + val info = uiState.apkInfo ?: lastApkInfo + if (info != null) { + ReadyContent( + apkInfo = info, + compatiblePatches = uiState.compatiblePatches, + onPatch = { viewModel.startPatching() }, + onClear = { viewModel.reset() } + ) + } + } + QuickPatchPhase.DOWNLOADING, QuickPatchPhase.PATCHING -> { + PatchingContent( + phase = phase, + statusMessage = uiState.statusMessage, + onCancel = { viewModel.cancelPatching() } ) } + QuickPatchPhase.COMPLETED -> { + val info = uiState.apkInfo ?: lastApkInfo + val output = uiState.outputPath ?: lastOutputPath + if (info != null && output != null) { + CompletedContent( + outputPath = output, + apkInfo = info, + onPatchAnother = { viewModel.reset() } + ) + } + } } } - } - // Bottom app cards (only show in IDLE phase) - if (uiState.phase == QuickPatchPhase.IDLE) { - Spacer(modifier = Modifier.height(16.dp)) - SupportedAppsRow( - supportedApps = uiState.supportedApps, - isLoading = uiState.isLoadingPatches, - loadError = uiState.patchLoadError, - patchesVersion = uiState.patchesVersion, - onOpenUrl = { url -> - openUrlAndFollowRedirects(url) { urlResolved -> - uriHandler.openUri(urlResolved) - } - }, - onRetry = { viewModel.retryLoadPatches() } - ) + // ── Supported apps (idle only) ── + if (uiState.phase == QuickPatchPhase.IDLE) { + Spacer(modifier = Modifier.height(16.dp)) + SupportedAppsRow( + supportedApps = uiState.supportedApps, + isLoading = uiState.isLoadingPatches, + loadError = uiState.patchLoadError, + isDefaultSource = uiState.isDefaultSource, + onRetry = { viewModel.retryLoadPatches() } + ) + } } } - // Top bar (device indicator + settings) in top-right corner - TopBarRow( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(24.dp) - ) + // Drag overlay + if (uiState.isDragHovering) { + DragOverlay() + } - // Error snackbar + // Error/warning snackbar uiState.error?.let { error -> + val mono = LocalMorpheFont.current + val isUnsupportedWarning = error.contains("not supported in Quick Patch") + val containerColor = if (isUnsupportedWarning) accents.warning.copy(alpha = 0.15f) else MaterialTheme.colorScheme.errorContainer + val contentColor = if (isUnsupportedWarning) accents.warning else MaterialTheme.colorScheme.onErrorContainer Snackbar( modifier = Modifier .align(Alignment.BottomCenter) - .padding(16.dp), + .padding(horizontal = 24.dp, vertical = 20.dp), action = { TextButton(onClick = { viewModel.clearError() }) { - Text("Dismiss", color = MaterialTheme.colorScheme.inversePrimary) + Text("Dismiss", color = contentColor.copy(alpha = 0.8f), fontFamily = mono, fontSize = 12.sp) } }, - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer + containerColor = containerColor, + contentColor = contentColor, + shape = RoundedCornerShape(corners.small) ) { - Text(error) + Text(error, fontFamily = mono, fontSize = 12.sp, lineHeight = 16.sp, modifier = Modifier.padding(vertical = 4.dp)) } } } } } +// ════════════════════════════════════════════════════════════════════ +// BRANDING — Logo + patches version badge +// ════════════════════════════════════════════════════════════════════ + +@Composable +private fun BrandingLogo() { + val themeState = LocalThemeState.current + val isDark = when (themeState.current) { + ThemePreference.SYSTEM -> isSystemInDarkTheme() + else -> themeState.current.isDark() + } + + Image( + painter = painterResource(if (isDark) Res.drawable.morphe_dark else Res.drawable.morphe_light), + contentDescription = "Morphe Logo", + modifier = Modifier.height(28.dp) + ) +} + +@Composable +private fun PatchesVersionBadge(patchesVersion: String?, isLoading: Boolean, patchSourceName: String? = null) { + val mono = LocalMorpheFont.current + val corners = LocalMorpheCorners.current + val accents = LocalMorpheAccents.current + + if (isLoading) { + Row( + modifier = Modifier + .height(34.dp) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 1.5.dp, + color = accents.primary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "LOADING…", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.sp + ) + } + } else if (patchesVersion != null) { + Row( + modifier = Modifier + .height(34.dp) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = patchSourceName?.uppercase() ?: "PATCHES", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Text( + text = " · ", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f) + ) + Text( + text = patchesVersion, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = accents.primary + ) + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = Modifier + .background(accents.secondary.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) + .border(1.dp, accents.secondary.copy(alpha = 0.2f), RoundedCornerShape(corners.small)) + .padding(horizontal = 5.dp, vertical = 1.dp) + ) { + Text( + text = "LATEST", + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary, + letterSpacing = 1.sp + ) + } + } + } +} + +// ════════════════════════════════════════════════════════════════════ +// IDLE — Simple drop zone +// ════════════════════════════════════════════════════════════════════ + @Composable private fun IdleContent( isAnalyzing: Boolean, isDragHovering: Boolean, - error: String?, - onFileSelected: (File) -> Unit, - onDragHover: (Boolean) -> Unit, - onClearError: () -> Unit + onBrowse: () -> Unit ) { - val dropZoneColor = when { - isDragHovering -> MorpheColors.Blue.copy(alpha = 0.2f) - else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - } - - val borderColor = when { - isDragHovering -> MorpheColors.Blue - else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - } + val corners = LocalMorpheCorners.current + val accents = LocalMorpheAccents.current + val bracketColor = if (isDragHovering) accents.primary.copy(alpha = 0.7f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f) Box( modifier = Modifier .fillMaxSize() - .clip(RoundedCornerShape(16.dp)) - .background(dropZoneColor) - .border(2.dp, borderColor, RoundedCornerShape(16.dp)) - .clickable(enabled = !isAnalyzing) { - openFilePicker()?.let { onFileSelected(it) } + .clickable(enabled = !isAnalyzing) { onBrowse() } + .drawBehind { + val strokeWidth = 2f + val len = 32.dp.toPx() + val inset = 0f + + // Top-left + drawLine(bracketColor, Offset(inset, inset), Offset(inset + len, inset), strokeWidth) + drawLine(bracketColor, Offset(inset, inset), Offset(inset, inset + len), strokeWidth) + // Top-right + drawLine(bracketColor, Offset(size.width - inset, inset), Offset(size.width - inset - len, inset), strokeWidth) + drawLine(bracketColor, Offset(size.width - inset, inset), Offset(size.width - inset, inset + len), strokeWidth) + // Bottom-left + drawLine(bracketColor, Offset(inset, size.height - inset), Offset(inset + len, size.height - inset), strokeWidth) + drawLine(bracketColor, Offset(inset, size.height - inset), Offset(inset, size.height - inset - len), strokeWidth) + // Bottom-right + drawLine(bracketColor, Offset(size.width - inset, size.height - inset), Offset(size.width - inset - len, size.height - inset), strokeWidth) + drawLine(bracketColor, Offset(size.width - inset, size.height - inset), Offset(size.width - inset, size.height - inset - len), strokeWidth) }, contentAlignment = Alignment.Center ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { if (isAnalyzing) { CircularProgressIndicator( - modifier = Modifier.size(48.dp), - color = MorpheColors.Blue, + modifier = Modifier.size(40.dp), + color = accents.primary, strokeWidth = 3.dp ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Analyzing APK...", - fontSize = 16.sp, + text = "Analyzing APK…", + fontSize = 15.sp, color = MaterialTheme.colorScheme.onSurfaceVariant ) } else { Icon( imageVector = Icons.Default.CloudUpload, contentDescription = null, - modifier = Modifier.size(48.dp), - tint = if (isDragHovering) MorpheColors.Blue else MaterialTheme.colorScheme.onSurfaceVariant + modifier = Modifier.size(44.dp), + tint = if (isDragHovering) accents.primary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) ) Spacer(modifier = Modifier.height(16.dp)) Text( text = "Drop APK here", - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + color = if (isDragHovering) accents.primary + else MaterialTheme.colorScheme.onSurface ) Spacer(modifier = Modifier.height(4.dp)) Text( text = "or click to browse", - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontSize = 13.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = ".apk · .apkm · .xapk · .apks", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) ) } } } } +// ════════════════════════════════════════════════════════════════════ +// READY — Compact APK card + patch button +// ════════════════════════════════════════════════════════════════════ + @Composable +@OptIn(ExperimentalLayoutApi::class) private fun ReadyContent( apkInfo: QuickApkInfo, - error: String?, + compatiblePatches: List, onPatch: () -> Unit, - onClear: () -> Unit, - onClearError: () -> Unit + onClear: () -> Unit ) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) + + val statusColorType = resolveStatusColorType(apkInfo.versionStatus, apkInfo.checksumStatus) + val accentColor = if (statusColorType == StatusColorType.PRIMARY) accents.secondary + else statusColorType.toColor() + + val enabledPatches = compatiblePatches.filter { it.isEnabled } + val disabledPatches = compatiblePatches.filter { !it.isEnabled } + var isPatchListExpanded by remember { mutableStateOf(false) } + var patchSearchQuery by remember { mutableStateOf("") } + Column( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { - // APK Info Card - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(12.dp) + Spacer(modifier = Modifier.weight(1f)) + + // APK info card — bordered box with accent stripe + Box( + modifier = Modifier + .widthIn(max = 640.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + .drawBehind { + drawRect( + color = accentColor, + size = androidx.compose.ui.geometry.Size(3.dp.toPx(), size.height) + ) + } ) { - Row( + Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically + .padding(start = 3.dp) ) { - // App icon: first letter of display name - Box( + // Header: app identity + dismiss + Row( modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(8.dp)) - .background(Color.White), - contentAlignment = Alignment.Center + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = apkInfo.displayName.first().toString(), - fontSize = 20.sp, - fontWeight = FontWeight.Bold, - color = MorpheColors.Blue - ) - } + // App initial + Box( + modifier = Modifier + .size(44.dp) + .border(1.dp, accentColor.copy(alpha = 0.5f), RoundedCornerShape(corners.small)) + .background(accentColor.copy(alpha = 0.08f)), + contentAlignment = Alignment.Center + ) { + Text( + text = apkInfo.displayName.first().uppercase(), + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accentColor + ) + } - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(14.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = apkInfo.displayName, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "v${apkInfo.versionName} • ${apkInfo.formattedSize}", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + Column(modifier = Modifier.weight(1f)) { + Text( + text = apkInfo.displayName, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "v${apkInfo.versionName} · ${apkInfo.formattedSize}", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + letterSpacing = 0.3.sp + ) + } + + // Dismiss button + val closeHover = remember { MutableInteractionSource() } + val isCloseHovered by closeHover.collectIsHoveredAsState() + val closeBg by animateColorAsState( + if (isCloseHovered) MaterialTheme.colorScheme.error.copy(alpha = 0.12f) + else Color.Transparent, + animationSpec = tween(150) ) - } - // Checksum status - when (apkInfo.checksumStatus) { - is ChecksumStatus.Verified -> { + Box( + modifier = Modifier + .size(36.dp) + .hoverable(closeHover) + .clip(RoundedCornerShape(corners.small)) + .background(closeBg) + .clickable(onClick = onClear), + contentAlignment = Alignment.Center + ) { Icon( - imageVector = Icons.Default.VerifiedUser, - contentDescription = "Verified", - tint = MorpheColors.Teal, - modifier = Modifier.size(24.dp) + imageVector = Icons.Default.Close, + contentDescription = "Clear", + tint = if (isCloseHovered) MaterialTheme.colorScheme.error + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.size(16.dp) ) } - is ChecksumStatus.Mismatch -> { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = "Checksum mismatch", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(24.dp) + } + + // Status bar + val statusDisplay = resolveVersionStatusDisplay( + apkInfo.versionStatus, apkInfo.checksumStatus, apkInfo.suggestedVersion + ) + val statusText = statusDisplay?.label + val statusDetail = statusDisplay?.detail + + if (statusText != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(20.dp.toPx(), 0f), + end = Offset(size.width - 20.dp.toPx(), 0f), + strokeWidth = 1f + ) + } + .background(accentColor.copy(alpha = 0.04f)) + .padding(horizontal = 20.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(6.dp) + .background(accentColor, RoundedCornerShape(1.dp)) + ) + Spacer(Modifier.width(10.dp)) + Text( + text = statusText, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accentColor, + letterSpacing = 1.sp ) + if (statusDetail != null) { + Spacer(Modifier.width(12.dp)) + Text( + text = statusDetail, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } } - else -> {} } - Spacer(modifier = Modifier.width(8.dp)) + // ── Info row: architectures, package, minSdk ── + 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(horizontal = 20.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // Architectures + if (apkInfo.architectures.isNotEmpty()) { + val deviceState by DeviceMonitor.state.collectAsState() + val deviceArch = deviceState.selectedDevice?.architecture + val hasMultipleArchs = apkInfo.architectures.size > 1 + val highlightArch = if (hasMultipleArchs && deviceArch != null) deviceArch else null + + Text( + text = "ARCH", + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + apkInfo.architectures.forEach { arch -> + val isDeviceArch = highlightArch != null && arch == highlightArch + val tagBorder = if (isDeviceArch) accents.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f) + val tagBg = if (isDeviceArch) accents.primary.copy(alpha = 0.08f) + else Color.Transparent + val tagColor = if (isDeviceArch) accents.primary + else MaterialTheme.colorScheme.onSurface + val dimmed = highlightArch != null && !isDeviceArch + + Box( + modifier = Modifier + .border(1.dp, tagBorder, RoundedCornerShape(corners.small)) + .background(tagBg, RoundedCornerShape(corners.small)) + .padding(horizontal = 8.dp, vertical = 3.dp) + ) { + Text( + text = arch, + fontSize = 11.sp, + fontWeight = if (isDeviceArch) FontWeight.Bold else FontWeight.Medium, + fontFamily = mono, + color = if (dimmed) tagColor.copy(alpha = 0.35f) else tagColor + ) + } + } + } + + // MinSdk + if (apkInfo.minSdk != null) { + Spacer(Modifier.width(4.dp)) + Text( + text = "MIN SDK", + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Text( + text = "${apkInfo.minSdk}", + fontSize = 11.sp, + fontWeight = FontWeight.Medium, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface + ) + } + } - IconButton(onClick = onClear) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.onSurfaceVariant + // ── Patches summary — collapsible ── + if (compatiblePatches.isNotEmpty()) { + val chevronRotation by animateFloatAsState( + if (isPatchListExpanded) 180f else 0f, + animationSpec = tween(200) ) + + // Summary header — clickable to expand + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(20.dp.toPx(), 0f), + end = Offset(size.width - 20.dp.toPx(), 0f), + strokeWidth = 1f + ) + } + .clickable { isPatchListExpanded = !isPatchListExpanded } + .padding(horizontal = 20.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "PATCHES", + fontSize = 9.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(Modifier.width(10.dp)) + Text( + text = "${enabledPatches.size} enabled", + fontSize = 11.sp, + fontFamily = mono, + fontWeight = FontWeight.Medium, + color = accents.primary + ) + if (disabledPatches.isNotEmpty()) { + Spacer(Modifier.width(6.dp)) + Text( + text = "·", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + ) + Spacer(Modifier.width(6.dp)) + Text( + text = "${disabledPatches.size} disabled", + fontSize = 11.sp, + fontFamily = mono, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + Spacer(Modifier.weight(1f)) + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = if (isPatchListExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier + .size(18.dp) + .graphicsLayer { rotationZ = chevronRotation } + ) + } + + // Expanded patch list + AnimatedVisibility( + visible = isPatchListExpanded, + enter = expandVertically(tween(200)) + fadeIn(tween(200)), + exit = shrinkVertically(tween(200)) + fadeOut(tween(200)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 14.dp) + ) { + // Search bar + OutlinedTextField( + value = patchSearchQuery, + onValueChange = { patchSearchQuery = it }, + placeholder = { + Text("Search patches…", fontSize = 11.sp, fontFamily = mono) + }, + leadingIcon = { + Icon( + Icons.Default.Search, null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.size(14.dp) + ) + }, + trailingIcon = { + if (patchSearchQuery.isNotEmpty()) { + IconButton(onClick = { patchSearchQuery = "" }) { + Icon( + Icons.Default.Clear, "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(12.dp) + ) + } + } + }, + singleLine = true, + textStyle = LocalTextStyle.current.copy(fontSize = 11.sp, fontFamily = mono), + shape = RoundedCornerShape(corners.small), + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = accents.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) + ) + ) + + Spacer(Modifier.height(10.dp)) + + // Filter patches by search + val filteredPatches = if (patchSearchQuery.isBlank()) { + compatiblePatches + } else { + compatiblePatches.filter { + it.name.contains(patchSearchQuery, ignoreCase = true) || + it.description.contains(patchSearchQuery, ignoreCase = true) + } + } + + // Chips in flow layout + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth() + ) { + filteredPatches.forEach { patch -> + val isEnabled = patch.isEnabled + val chipBorder = if (isEnabled) accents.primary.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f) + val chipBg = if (isEnabled) accents.primary.copy(alpha = 0.08f) + else Color.Transparent + val chipTextColor = if (isEnabled) accents.primary + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f) + + Box( + modifier = Modifier + .border(1.dp, chipBorder, RoundedCornerShape(corners.small)) + .background(chipBg, RoundedCornerShape(corners.small)) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = patch.name, + fontSize = 10.sp, + fontWeight = if (isEnabled) FontWeight.Medium else FontWeight.Normal, + fontFamily = mono, + color = chipTextColor, + maxLines = 1 + ) + } + } + } + + if (filteredPatches.isEmpty() && patchSearchQuery.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Text( + text = "No patches matching \"$patchSearchQuery\"", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + } + } + } } } } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(20.dp)) - // Verification status banner - VerificationStatusBanner( - checksumStatus = apkInfo.checksumStatus, - isRecommendedVersion = apkInfo.isRecommendedVersion, - currentVersion = apkInfo.versionName, - suggestedVersion = apkInfo.recommendedVersion ?: "Unknown" + // Patch button + val patchHover = remember { MutableInteractionSource() } + val isPatchHovered by patchHover.collectIsHoveredAsState() + val patchBg by animateColorAsState( + if (isPatchHovered) accents.primary.copy(alpha = 0.9f) else accents.primary, + animationSpec = tween(150) ) - Spacer(modifier = Modifier.weight(1f)) - - // Patch button - Button( - onClick = onPatch, + Box( modifier = Modifier + .widthIn(max = 480.dp) .fillMaxWidth() - .height(52.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MorpheColors.Blue - ), - shape = RoundedCornerShape(12.dp) + .height(46.dp) + .hoverable(patchHover) + .clip(RoundedCornerShape(corners.small)) + .background(patchBg, RoundedCornerShape(corners.small)) + .clickable(onClick = onPatch), + contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Default.AutoFixHigh, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Patch with Defaults", - fontSize = 16.sp, - fontWeight = FontWeight.Medium + text = "PATCH WITH DEFAULTS", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = Color.White, + letterSpacing = 1.sp ) } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(6.dp)) Text( - text = "Uses latest patches with recommended settings", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = "${enabledPatches.size} patches will be applied" + + if (disabledPatches.isNotEmpty()) " · ${disabledPatches.size} excluded" else "", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), textAlign = TextAlign.Center ) + + Spacer(modifier = Modifier.weight(1f)) } } +// ════════════════════════════════════════════════════════════════════ +// PATCHING — Progress +// ════════════════════════════════════════════════════════════════════ + @Composable private fun PatchingContent( phase: QuickPatchPhase, statusMessage: String, onCancel: () -> Unit ) { + val mono = LocalMorpheFont.current + val corners = LocalMorpheCorners.current + val accents = LocalMorpheAccents.current + Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { CircularProgressIndicator( - modifier = Modifier.size(64.dp), - strokeWidth = 4.dp, - color = MorpheColors.Teal + modifier = Modifier.size(48.dp), + strokeWidth = 3.dp, + color = accents.secondary ) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(20.dp)) Text( text = when (phase) { - QuickPatchPhase.DOWNLOADING -> "Preparing..." - QuickPatchPhase.PATCHING -> "Patching..." + QuickPatchPhase.DOWNLOADING -> "PREPARING" + QuickPatchPhase.PATCHING -> "PATCHING" else -> "" }, - fontSize = 18.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface + fontSize = 13.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary, + letterSpacing = 1.sp ) Spacer(modifier = Modifier.height(8.dp)) Text( text = statusMessage, - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + maxLines = 2, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 16.dp) + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp) ) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(20.dp)) - TextButton(onClick = onCancel) { - Text("Cancel", color = MaterialTheme.colorScheme.error) - } - } + val cancelHover = remember { MutableInteractionSource() } + val isCancelHovered by cancelHover.collectIsHoveredAsState() + val cancelBg by animateColorAsState( + if (isCancelHovered) MaterialTheme.colorScheme.error.copy(alpha = 0.1f) else Color.Transparent, + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .hoverable(cancelHover) + .clip(RoundedCornerShape(corners.small)) + .background(cancelBg) + .clickable(onClick = onCancel) + .padding(horizontal = 16.dp, vertical = 6.dp) + ) { + Text( + text = "CANCEL", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.error, + letterSpacing = 0.5.sp + ) + } + } } +// ════════════════════════════════════════════════════════════════════ +// COMPLETED — Success +// ════════════════════════════════════════════════════════════════════ + @Composable private fun CompletedContent( outputPath: String, apkInfo: QuickApkInfo, onPatchAnother: () -> Unit ) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) val outputFile = File(outputPath) val scope = rememberCoroutineScope() val adbManager = remember { AdbManager() } @@ -551,477 +1042,646 @@ private fun CompletedContent( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = "Success", - tint = MorpheColors.Teal, - modifier = Modifier.size(64.dp) + // Success indicator + Box( + modifier = Modifier + .size(8.dp) + .background(accents.secondary, RoundedCornerShape(2.dp)) ) - - Spacer(modifier = Modifier.height(16.dp)) - + Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Patching Complete!", - fontSize = 22.sp, + text = "PATCHING COMPLETE", + fontSize = 13.sp, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + fontFamily = mono, + color = accents.secondary, + letterSpacing = 1.sp ) - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = outputFile.name, - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) + Spacer(modifier = Modifier.height(20.dp)) - if (outputFile.exists()) { - Text( - text = formatFileSize(outputFile.length()), - fontSize = 13.sp, - color = MorpheColors.Teal + // Output file card + Box( + modifier = Modifier + .widthIn(max = 480.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + ) { + Box( + modifier = Modifier + .width(3.dp) + .fillMaxHeight() + .background(accents.secondary) + .align(Alignment.CenterStart) ) - } - - Spacer(modifier = Modifier.height(24.dp)) - // Action buttons - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedButton( - onClick = { - try { - val folder = outputFile.parentFile - if (folder != null && Desktop.isDesktopSupported()) { - Desktop.getDesktop().open(folder) - } - } catch (e: Exception) { } - }, - shape = RoundedCornerShape(8.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 3.dp) ) { - Icon( - imageVector = Icons.Default.FolderOpen, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text("Open Folder") - } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "OUTPUT FILE", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(Modifier.height(4.dp)) + Text( + text = outputFile.name, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (outputFile.exists()) { + Text( + text = formatFileSize(outputFile.length()), + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary + ) + } + } - Button( - onClick = onPatchAnother, - colors = ButtonDefaults.buttonColors( - containerColor = MorpheColors.Blue - ), - shape = RoundedCornerShape(8.dp) - ) { - Text("Patch Another") + // Open folder link + 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(horizontal = 20.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val folderHover = remember { MutableInteractionSource() } + val isFolderHovered by folderHover.collectIsHoveredAsState() + val folderColor by animateColorAsState( + if (isFolderHovered) accents.primary else accents.primary.copy(alpha = 0.6f), + animationSpec = tween(150) + ) + Box( + modifier = Modifier + .hoverable(folderHover) + .clip(RoundedCornerShape(corners.small)) + .clickable { + try { + val folder = outputFile.parentFile + if (folder != null && Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(folder) + } + } catch (_: Exception) {} + } + .padding(vertical = 2.dp) + ) { + Text( + text = "OPEN FOLDER →", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = folderColor, + letterSpacing = 0.5.sp + ) + } + } } } + // ADB install if (monitorState.isAdbAvailable == true) { - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(12.dp)) val readyDevices = monitorState.devices.filter { it.isReady } val selectedDevice = monitorState.selectedDevice - if (installSuccess) { - Surface( - color = MorpheColors.Teal.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp) - ) { - Text( - text = "Installed successfully!", - fontSize = 13.sp, - color = MorpheColors.Teal, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) - ) + when { + installSuccess -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .size(6.dp) + .background(accents.secondary, RoundedCornerShape(1.dp)) + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "INSTALLED ON ${(selectedDevice?.displayName ?: "DEVICE").uppercase()}", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary, + letterSpacing = 0.5.sp + ) + } } - } else if (isInstalling) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MorpheColors.Blue - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Installing...", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + isInstalling -> { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = accents.primary + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "INSTALLING…", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 0.5.sp + ) + } + } + readyDevices.isNotEmpty() -> { + val device = selectedDevice ?: readyDevices.first() + val installHover = remember { MutableInteractionSource() } + val isInstallHovered by installHover.collectIsHoveredAsState() + val installBg by animateColorAsState( + if (isInstallHovered) accents.secondary.copy(alpha = 0.9f) else accents.secondary, + animationSpec = tween(150) ) + + Box( + modifier = Modifier + .widthIn(max = 480.dp) + .fillMaxWidth() + .height(38.dp) + .hoverable(installHover) + .clip(RoundedCornerShape(corners.small)) + .background(installBg, RoundedCornerShape(corners.small)) + .clickable { + scope.launch { + isInstalling = true + installError = null + val result = adbManager.installApk( + apkPath = outputPath, + deviceId = device.id + ) + result.fold( + onSuccess = { installSuccess = true }, + onFailure = { installError = it.message } + ) + isInstalling = false + } + }, + contentAlignment = Alignment.Center + ) { + Text( + text = "INSTALL ON ${device.displayName.uppercase()}", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = Color.White, + letterSpacing = 0.5.sp + ) + } } - } else if (readyDevices.isNotEmpty()) { - val device = selectedDevice ?: readyDevices.first() - Button( - onClick = { - scope.launch { - isInstalling = true - installError = null - val result = adbManager.installApk( - apkPath = outputPath, - deviceId = device.id - ) - result.fold( - onSuccess = { installSuccess = true }, - onFailure = { installError = it.message } - ) - isInstalling = false - } - }, - colors = ButtonDefaults.buttonColors(containerColor = MorpheColors.Teal), - shape = RoundedCornerShape(8.dp) - ) { - Icon( - imageVector = Icons.Default.PhoneAndroid, - contentDescription = null, - modifier = Modifier.size(18.dp) + else -> { + Text( + text = "Connect a device via USB to install with ADB", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) ) - Spacer(modifier = Modifier.width(6.dp)) - Text("Install on ${device.displayName}") } - } else { - Text( - text = "Connect your device via USB to install with ADB", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) } installError?.let { error -> Spacer(modifier = Modifier.height(8.dp)) Text( text = error, - fontSize = 12.sp, + fontSize = 10.sp, + fontFamily = mono, color = MaterialTheme.colorScheme.error, textAlign = TextAlign.Center ) } } + + Spacer(modifier = Modifier.height(16.dp)) + + // Patch another button + val patchAnotherHover = remember { MutableInteractionSource() } + val isPatchAnotherHovered by patchAnotherHover.collectIsHoveredAsState() + val patchAnotherBg by animateColorAsState( + if (isPatchAnotherHovered) accents.primary.copy(alpha = 0.9f) else accents.primary, + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .widthIn(max = 480.dp) + .fillMaxWidth() + .height(42.dp) + .hoverable(patchAnotherHover) + .clip(RoundedCornerShape(corners.small)) + .background(patchAnotherBg, RoundedCornerShape(corners.small)) + .clickable(onClick = onPatchAnother), + contentAlignment = Alignment.Center + ) { + Text( + text = "PATCH ANOTHER", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = Color.White, + letterSpacing = 1.sp + ) + } } } +// ════════════════════════════════════════════════════════════════════ +// SUPPORTED APPS — Simple row at the bottom +// ════════════════════════════════════════════════════════════════════ + @Composable private fun SupportedAppsRow( - supportedApps: List, + supportedApps: List, isLoading: Boolean, loadError: String? = null, - patchesVersion: String?, - onOpenUrl: (String) -> Unit, + isDefaultSource: Boolean = true, onRetry: () -> Unit = {} ) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val uriHandler = LocalUriHandler.current + val focusManager = LocalFocusManager.current + Column( - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = "Download original APK", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (patchesVersion != null) { - Text( - text = "Patches: $patchesVersion", - fontSize = 11.sp, - color = MorpheColors.Blue.copy(alpha = 0.8f) - ) - } - } + Text( + text = "SUPPORTED APPS", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + letterSpacing = 3.sp + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(6.dp)) - if (isLoading) { - // Loading state - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MorpheColors.Blue - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Loading supported apps...", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } else if (loadError != null || supportedApps.isEmpty()) { - // Error or no apps loaded - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = loadError ?: "Could not load supported apps", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.width(8.dp)) - OutlinedButton( - onClick = onRetry, - shape = RoundedCornerShape(8.dp), - contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) - ) { - Text("Retry", fontSize = 12.sp) - } - } - } else { - // Show supported apps dynamically - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - supportedApps.forEach { app -> - val url = app.apkDownloadUrl - if (url != null) { - OutlinedCard( - onClick = { onOpenUrl(url) }, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(8.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = app.displayName, - fontSize = 13.sp, - fontWeight = FontWeight.Medium - ) - app.recommendedVersion?.let { version -> - Text( - text = "v$version", - fontSize = 10.sp, - color = MorpheColors.Teal - ) - } - } - Icon( - imageVector = Icons.AutoMirrored.Filled.OpenInNew, - contentDescription = "Open", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp) - ) - } - } - } - } - } - } - } -} + Text( + text = if (isDefaultSource) "Download the exact version from APKMirror and drop it here." + else "Drop the APK for a supported app here.", + fontSize = 11.sp, + fontFamily = mono, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + textAlign = TextAlign.Center, + modifier = Modifier + .widthIn(max = 500.dp) + .padding(horizontal = 16.dp) + ) -/** - * Shows verification status (version + checksum) in a compact banner. - */ -@Composable -private fun VerificationStatusBanner( - checksumStatus: ChecksumStatus, - isRecommendedVersion: Boolean, - currentVersion: String, - suggestedVersion: String -) { - when { - // Recommended version with verified checksum - checksumStatus is ChecksumStatus.Verified -> { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MorpheColors.Teal.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp) - ) { + Spacer(modifier = Modifier.height(12.dp)) + + when { + isLoading -> { Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Default.VerifiedUser, - contentDescription = null, - tint = MorpheColors.Teal, - modifier = Modifier.size(18.dp) + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = accents.primary ) Spacer(modifier = Modifier.width(8.dp)) - Column { - Text( - text = "Recommended version • Verified", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = MorpheColors.Teal - ) - Text( - text = "Checksum matches APKMirror", - fontSize = 11.sp, - color = MorpheColors.Teal.copy(alpha = 0.8f) - ) - } + Text( + text = "Loading supported apps…", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) } } - } - - // Checksum mismatch - warning - checksumStatus is ChecksumStatus.Mismatch -> { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.error.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp) - ) { + loadError != null || supportedApps.isEmpty() -> { Row( - modifier = Modifier.padding(12.dp), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Default.Warning, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(18.dp) + Text( + text = loadError ?: "Could not load supported apps", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.width(8.dp)) - Column { - Text( - text = "Checksum mismatch", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.error - ) + val retryHover = remember { MutableInteractionSource() } + val isRetryHovered by retryHover.collectIsHoveredAsState() + Box( + modifier = Modifier + .hoverable(retryHover) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (isRetryHovered) 0.3f else 0.12f + ), + RoundedCornerShape(corners.small) + ) + .clickable(onClick = onRetry) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { Text( - text = "File may be corrupted. Re-download from APKMirror.", - fontSize = 11.sp, - color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f) + text = "RETRY", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + letterSpacing = 0.5.sp ) } } } - } + else -> { + // Search bar for many apps + var searchQuery by remember { mutableStateOf("") } + val filteredApps = if (searchQuery.isBlank()) supportedApps + else supportedApps.filter { + it.displayName.contains(searchQuery, ignoreCase = true) || + it.packageName.contains(searchQuery, ignoreCase = true) + } - // Recommended version but no checksum configured - isRecommendedVersion && checksumStatus is ChecksumStatus.NotConfigured -> { - Surface( - modifier = Modifier.fillMaxWidth(), - color = MorpheColors.Teal.copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp) - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center - ) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = null, - tint = MorpheColors.Teal, - modifier = Modifier.size(18.dp) + if (supportedApps.size > 4) { + val muted = MaterialTheme.colorScheme.onSurfaceVariant + val searchInteraction = remember { MutableInteractionSource() } + val isSearchFocused by searchInteraction.collectIsFocusedAsState() + val searchBorder by animateColorAsState( + if (isSearchFocused) MaterialTheme.colorScheme.outline.copy(alpha = 0.35f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), + animationSpec = tween(150) ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Using recommended version", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = MorpheColors.Teal + + BasicTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + singleLine = true, + interactionSource = searchInteraction, + textStyle = MaterialTheme.typography.bodySmall.copy( + fontFamily = mono, + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurface + ), + cursorBrush = SolidColor(accents.primary), + modifier = Modifier + .widthIn(max = 260.dp) + .fillMaxWidth() + .height(32.dp) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, searchBorder, RoundedCornerShape(corners.small)), + decorationBox = { innerTextField -> + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Search, + contentDescription = null, + tint = muted.copy(alpha = 0.55f), + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Box(modifier = Modifier.weight(1f)) { + if (searchQuery.isEmpty()) { + Text( + "Filter apps…", + fontSize = 11.sp, + fontFamily = mono, + color = muted.copy(alpha = 0.4f) + ) + } + innerTextField() + } + if (searchQuery.isNotEmpty()) { + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = Modifier + .size(18.dp) + .clip(RoundedCornerShape(corners.small)) + .clickable { searchQuery = "" }, + contentAlignment = Alignment.Center + ) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + tint = muted.copy(alpha = 0.5f), + modifier = Modifier.size(12.dp) + ) + } + } + } + } ) + Spacer(modifier = Modifier.height(8.dp)) } - } - } - // Non-recommended version (older or newer) - !isRecommendedVersion -> { - Surface( - modifier = Modifier.fillMaxWidth(), - color = Color(0xFFFF9800).copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp) - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Info, - contentDescription = null, - tint = Color(0xFFFF9800), - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Column { + if (filteredApps.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 120.dp), + contentAlignment = Alignment.Center + ) { Text( - text = "Version $currentVersion", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = Color(0xFFFF9800) - ) - Text( - text = "Recommended: v$suggestedVersion. Patching may have issues.", + text = "No matching apps", fontSize = 11.sp, - color = Color(0xFFFF9800).copy(alpha = 0.8f) + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) ) } + return@Column } - } - } - // Checksum error - checksumStatus is ChecksumStatus.Error -> { - Surface( - modifier = Modifier.fillMaxWidth(), - color = Color(0xFFFF9800).copy(alpha = 0.1f), - shape = RoundedCornerShape(8.dp) - ) { + // Horizontal scrolling cards + val useScrolling = filteredApps.size > 4 Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + modifier = Modifier + .fillMaxWidth() + .then(if (useScrolling) Modifier.horizontalScroll(rememberScrollState()) else Modifier) + .height(IntrinsicSize.Max) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { focusManager.clearFocus() }, + horizontalArrangement = Arrangement.spacedBy(10.dp) ) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = null, - tint = Color(0xFFFF9800), - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Recommended version (checksum unavailable)", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = Color(0xFFFF9800) - ) + filteredApps.forEach { app -> + val url = app.apkDownloadUrl + + Surface( + modifier = Modifier + .then( + if (useScrolling) Modifier.width(170.dp) + else Modifier.weight(1f) + ) + .fillMaxHeight(), + shape = RoundedCornerShape(corners.small), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + Text( + text = app.displayName, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (!isDefaultSource) { + Spacer(modifier = Modifier.weight(1f)) + } + + Text( + text = if (app.recommendedVersion != null) "STABLE" else "ANY VERSION", + fontSize = 9.sp, + fontFamily = mono, + fontWeight = FontWeight.Medium, + letterSpacing = 1.2.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f) + ) + + if (url != null) { + val pillInteraction = remember { MutableInteractionSource() } + val isPillHovered by pillInteraction.collectIsHoveredAsState() + val pillBg by animateColorAsState( + if (isPillHovered) accents.primary.copy(alpha = 0.15f) + else Color.Transparent, + animationSpec = tween(150) + ) + val pillBorder by animateColorAsState( + if (isPillHovered) accents.primary.copy(alpha = 0.7f) + else accents.primary.copy(alpha = 0.35f), + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .hoverable(pillInteraction) + .clip(RoundedCornerShape(corners.small)) + .background(pillBg, RoundedCornerShape(corners.small)) + .border( + 1.dp, + pillBorder, + RoundedCornerShape(corners.small) + ) + .clickable { + openUrlAndFollowRedirects(url) { resolved -> + uriHandler.openUri(resolved) + } + } + .padding(horizontal = 10.dp, vertical = 5.dp) + ) { + Text( + text = app.recommendedVersion?.let { "v$it ↗" } ?: "Download ↗", + fontSize = 11.sp, + fontFamily = mono, + color = accents.primary, + fontWeight = FontWeight.Medium + ) + } + } + } + } + } } } } } } -/** - * Open native file picker. - */ +// ════════════════════════════════════════════════════════════════════ +// DRAG OVERLAY +// ════════════════════════════════════════════════════════════════════ + +@Composable +private fun DragOverlay() { + val accents = LocalMorpheAccents.current + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.92f)) + .border( + width = 2.dp, + color = accents.primary.copy(alpha = 0.5f), + shape = RoundedCornerShape(0.dp) + ), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + imageVector = Icons.Default.CloudUpload, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = accents.primary + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Drop APK here", + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = accents.primary + ) + } + } +} + +// ════════════════════════════════════════════════════════════════════ +// UTILITIES +// ════════════════════════════════════════════════════════════════════ + private fun openFilePicker(): File? { val fileDialog = FileDialog(null as Frame?, "Select APK", FileDialog.LOAD).apply { isMultipleMode = false - setFilenameFilter { _, name -> name.lowercase().let { it.endsWith(".apk") || it.endsWith(".apkm") } } + setFilenameFilter { _, name -> name.lowercase().let { it.endsWith(".apk") || it.endsWith(".apkm") || it.endsWith(".xapk") || it.endsWith(".apks") } } isVisible = true } - val directory = fileDialog.directory val file = fileDialog.file - - return if (directory != null && file != null) { - File(directory, file) - } else null + return if (directory != null && file != null) File(directory, file) else null } private fun formatFileSize(bytes: Long): String { 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 4d62889..2c2e4f7 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 @@ -13,10 +13,12 @@ import app.morphe.gui.data.model.PatchConfig import app.morphe.gui.data.model.SupportedApp import app.morphe.gui.data.repository.ConfigRepository import app.morphe.gui.data.repository.PatchRepository +import app.morphe.gui.data.repository.PatchSourceManager import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import net.dongliu.apk.parser.ApkFile import app.morphe.gui.util.ChecksumStatus @@ -24,21 +26,27 @@ import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger import app.morphe.gui.util.PatchService import app.morphe.gui.util.SupportedAppExtractor +import app.morphe.gui.util.VersionStatus import java.io.File /** * ViewModel for Quick Patch mode - handles the entire flow in one screen. */ class QuickPatchViewModel( - private val patchRepository: PatchRepository, + private val patchSourceManager: PatchSourceManager, private val patchService: PatchService, private val configRepository: ConfigRepository ) : ScreenModel { - private val _uiState = MutableStateFlow(QuickPatchUiState()) + private var patchRepository: PatchRepository = patchSourceManager.getActiveRepositorySync() + private var localPatchFilePath: String? = patchSourceManager.getLocalFilePath() + private var isDefaultSource: Boolean = patchSourceManager.isDefaultSource() + + private val _uiState = MutableStateFlow(QuickPatchUiState(isDefaultSource = isDefaultSource)) val uiState: StateFlow = _uiState.asStateFlow() private var patchingJob: Job? = null + private var loadJob: Job? = null // Cached dynamic data from patches private var cachedPatches: List = emptyList() @@ -48,15 +56,45 @@ class QuickPatchViewModel( init { // Load patches on startup to get dynamic app info loadPatchesAndSupportedApps() + + // Observe source changes + screenModelScope.launch { + patchSourceManager.sourceVersion.drop(1).collect { + Logger.info("QuickVM: Source changed, reloading patches...") + patchRepository = patchSourceManager.getActiveRepositorySync() + localPatchFilePath = patchSourceManager.getLocalFilePath() + isDefaultSource = patchSourceManager.isDefaultSource() + cachedPatchesFile = null + cachedPatches = emptyList() + cachedSupportedApps = emptyList() + _uiState.value = QuickPatchUiState(isDefaultSource = isDefaultSource) + loadPatchesAndSupportedApps() + } + } } /** * Load patches from GitHub and extract supported apps dynamically. */ private fun loadPatchesAndSupportedApps() { - screenModelScope.launch { + 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 { + _uiState.value = _uiState.value.copy( + isLoadingPatches = false, + patchLoadError = "Local patch file not found: ${localFile.name}" + ) + } + return@launch + } + try { // Fetch releases val releasesResult = patchRepository.fetchReleases() @@ -118,6 +156,7 @@ class QuickPatchViewModel( isLoadingPatches = false, supportedApps = supportedApps, patchesVersion = release.tagName, + patchSourceName = patchSourceManager.getActiveSourceName(), patchLoadError = null, isOffline = false ) @@ -141,20 +180,22 @@ class QuickPatchViewModel( /** * Find any cached .mpp file when offline. + * Searches the per-source cache directory. */ private fun findCachedPatchFile(savedVersion: String?): File? { - val patchesDir = FileUtils.getPatchesDir() - val mppFiles = patchesDir.listFiles { file -> file.extension.equals("mpp", ignoreCase = true) } - ?.filter { it.length() > 0 } - ?: return null + 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 (mppFiles.isEmpty()) return null + if (patchFiles.isEmpty()) return null return if (savedVersion != null) { - mppFiles.firstOrNull { it.name.contains(savedVersion, ignoreCase = true) } - ?: mppFiles.maxByOrNull { it.lastModified() } + patchFiles.firstOrNull { it.name.contains(savedVersion, ignoreCase = true) } + ?: patchFiles.maxByOrNull { it.lastModified() } } else { - mppFiles.maxByOrNull { it.lastModified() } + patchFiles.maxByOrNull { it.lastModified() } } } @@ -167,7 +208,7 @@ class QuickPatchViewModel( /** * Load patches from a local .mpp file (offline fallback). */ - private suspend fun loadPatchesFromFile(patchFile: File, version: String) { + private suspend fun loadPatchesFromFile(patchFile: File, version: String, isOffline: Boolean = true) { cachedPatchesFile = patchFile val patchesResult = patchService.listPatches(patchFile.absolutePath) @@ -176,7 +217,7 @@ class QuickPatchViewModel( if (patches.isNullOrEmpty()) { _uiState.value = _uiState.value.copy( isLoadingPatches = false, - patchLoadError = "Could not load cached patches: ${patchesResult.exceptionOrNull()?.message}" + patchLoadError = "Could not load patches: ${patchesResult.exceptionOrNull()?.message}" ) return } @@ -184,14 +225,15 @@ class QuickPatchViewModel( cachedPatches = patches val supportedApps = SupportedAppExtractor.extractSupportedApps(patches) cachedSupportedApps = supportedApps - Logger.info("Quick mode: Loaded ${supportedApps.size} supported apps from cached patches: ${patchFile.name}") + 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 = true + isOffline = isOffline ) } @@ -214,10 +256,15 @@ class QuickPatchViewModel( val result = analyzeApk(file) if (result != null) { + // Filter patches compatible with this package (ignore version — patcher will attempt all) + val compatible = cachedPatches.filter { + it.isCompatibleWith(result.packageName) + } _uiState.value = _uiState.value.copy( phase = QuickPatchPhase.READY, apkFile = file, - apkInfo = result + apkInfo = result, + compatiblePatches = compatible ) } else { _uiState.value = _uiState.value.copy( @@ -232,16 +279,16 @@ class QuickPatchViewModel( * Analyze the APK file using dynamic data from patches. */ private suspend fun analyzeApk(file: File): QuickApkInfo? { - if (!file.exists() || !(file.name.endsWith(".apk", ignoreCase = true) || file.name.endsWith(".apkm", ignoreCase = true))) { - _uiState.value = _uiState.value.copy(error = "Please drop a valid .apk or .apkm file") + if (!file.exists() || !FileUtils.isApkFile(file)) { + _uiState.value = _uiState.value.copy(error = "Please drop a valid .apk, .apkm, .xapk, or .apks file") return null } - // For .apkm files, extract base.apk first - val isApkm = file.extension.equals("apkm", ignoreCase = true) - val apkToParse = if (isApkm) { - FileUtils.extractBaseApkFromApkm(file) ?: run { - _uiState.value = _uiState.value.copy(error = "Failed to extract base.apk from APKM bundle") + // For split APK bundles (.apkm, .xapk, .apks), extract base.apk first + val isBundleFormat = FileUtils.isBundleFormat(file) + val apkToParse = if (isBundleFormat) { + FileUtils.extractBaseApkFromBundle(file) ?: run { + _uiState.value = _uiState.value.copy(error = "Failed to extract base APK from bundle") return null } } else { @@ -270,8 +317,13 @@ class QuickPatchViewModel( } if (packageName !in supportedPackages) { + val appName = SupportedApp.resolveDisplayName(packageName, meta.label) + val supportedNames = cachedSupportedApps.map { it.displayName } + .ifEmpty { listOf("YouTube", "YouTube Music", "Reddit") } + .joinToString(", ") _uiState.value = _uiState.value.copy( - error = "Unsupported app: $packageName\n\nSupported apps: ${cachedSupportedApps.map { it.displayName }.ifEmpty { listOf("YouTube", "YouTube Music", "Reddit") }.joinToString(", ")}" + error = "$appName is not supported in Quick Patch mode. Supported apps: $supportedNames. Use Normal mode for unsupported apps.", + phase = QuickPatchPhase.IDLE ) return null } @@ -279,20 +331,44 @@ class QuickPatchViewModel( // Get display name and recommended version from dynamic data, fallback to constants val displayName = dynamicAppInfo?.displayName - ?: SupportedApp.getDisplayName(packageName) + ?: SupportedApp.resolveDisplayName(packageName, meta.label) val recommendedVersion = dynamicAppInfo?.recommendedVersion - // Version check - val isRecommendedVersion = recommendedVersion != null && versionName == recommendedVersion - val versionWarning = if (!isRecommendedVersion && recommendedVersion != null) { - "Version $versionName may have compatibility issues. Recommended: $recommendedVersion" - } else null + // Resolve version status against the supported app's stable + + // experimental version lists. + val versionResolution = if (dynamicAppInfo != null) { + app.morphe.gui.util.resolveVersionStatus(versionName, dynamicAppInfo) + } else { + app.morphe.gui.util.VersionResolution(VersionStatus.UNKNOWN, null) + } + val versionStatus = versionResolution.status + val isRecommendedVersion = versionStatus == VersionStatus.LATEST_STABLE + val versionWarning = when (versionStatus) { + VersionStatus.OLDER_STABLE -> + "Older stable build — newer stable v${versionResolution.suggestedVersion} available" + VersionStatus.LATEST_EXPERIMENTAL -> + "Experimental build — supported, but may not work properly" + VersionStatus.OLDER_EXPERIMENTAL -> + "Older experimental build — newer experimental v${versionResolution.suggestedVersion} available" + VersionStatus.TOO_NEW -> + "Version too new — not officially supported, patches will most likely fail" + VersionStatus.TOO_OLD -> + "Version too old — not officially supported, patches will most likely fail" + VersionStatus.UNSUPPORTED_BETWEEN -> + "Unsupported version — patches will most likely fail" + VersionStatus.LATEST_STABLE, + VersionStatus.UNKNOWN -> null + } // TODO: Re-enable when checksums are provided via .mpp files val checksumStatus = ChecksumStatus.NotConfigured - Logger.info("Quick mode: Analyzed $displayName v$versionName (recommended: $recommendedVersion)") + // Extract architectures — scan the original file (bundles have splits with native libs) + val architectures = FileUtils.extractArchitectures(if (isBundleFormat) file else apkToParse) + val minSdk = meta.minSdkVersion?.toIntOrNull() + + Logger.info("Quick mode: Analyzed $displayName v$versionName (recommended: $recommendedVersion, status: $versionStatus, archs: $architectures)") QuickApkInfo( fileName = file.name, @@ -301,9 +377,13 @@ class QuickPatchViewModel( fileSize = file.length(), displayName = displayName, recommendedVersion = recommendedVersion, + suggestedVersion = versionResolution.suggestedVersion, isRecommendedVersion = isRecommendedVersion, + versionStatus = versionStatus, versionWarning = versionWarning, - checksumStatus = checksumStatus + checksumStatus = checksumStatus, + architectures = architectures, + minSdk = minSdk ) } } catch (e: Exception) { @@ -311,7 +391,7 @@ class QuickPatchViewModel( _uiState.value = _uiState.value.copy(error = "Failed to read APK: ${e.message}") null } finally { - if (isApkm) apkToParse.delete() + if (isBundleFormat) apkToParse.delete() } } @@ -386,6 +466,15 @@ class QuickPatchViewModel( val outputFileName = "$baseName-Morphe-${apkInfo.versionName}${patchesSuffix}.apk" val outputPath = File(outputDir, outputFileName).absolutePath + // Resolve keystore: use saved path, or derive from output APK location + val appConfig = configRepository.loadConfig() + val resolvedKeystorePath = appConfig.keystorePath + ?: File(outputPath).let { out -> + out.resolveSibling(out.nameWithoutExtension + ".keystore").absolutePath + }.also { path -> + configRepository.setKeystorePath(path) + } + // Use PatchService for direct library patching (no CLI subprocess) // exclusiveMode = false means the library's patch.use field determines defaults val patchResult = patchService.patch( @@ -396,6 +485,10 @@ class QuickPatchViewModel( disabledPatches = emptyList(), options = emptyMap(), exclusiveMode = false, + keystorePath = resolvedKeystorePath, + keystorePassword = appConfig.keystorePassword, + keystoreAlias = appConfig.keystoreAlias, + keystoreEntryPassword = appConfig.keystoreEntryPassword, onProgress = { message -> _uiState.value = _uiState.value.copy(statusMessage = message.take(60)) parseProgress(message) @@ -472,6 +565,7 @@ class QuickPatchViewModel( patchingJob = null _uiState.value = QuickPatchUiState( // Preserve already-loaded patches data + isDefaultSource = isDefaultSource, isLoadingPatches = false, supportedApps = cachedSupportedApps, patchesVersion = _uiState.value.patchesVersion @@ -513,9 +607,13 @@ data class QuickApkInfo( val fileSize: Long, val displayName: String, val recommendedVersion: String?, + val suggestedVersion: String?, val isRecommendedVersion: Boolean, + val versionStatus: VersionStatus = VersionStatus.UNKNOWN, val versionWarning: String?, - val checksumStatus: ChecksumStatus + val checksumStatus: ChecksumStatus, + val architectures: List = emptyList(), + val minSdk: Int? = null ) { val formattedSize: String get() = when { @@ -531,6 +629,7 @@ data class QuickApkInfo( */ data class QuickPatchUiState( val phase: QuickPatchPhase = QuickPatchPhase.IDLE, + val isDefaultSource: Boolean = true, val apkFile: File? = null, val apkInfo: QuickApkInfo? = null, val error: String? = null, @@ -542,6 +641,9 @@ data class QuickPatchUiState( val isLoadingPatches: Boolean = true, val supportedApps: List = emptyList(), val patchesVersion: String? = null, + val patchSourceName: String? = null, val patchLoadError: String? = null, - val isOffline: Boolean = false + val isOffline: Boolean = false, + // Compatible patches for the loaded APK + val compatiblePatches: List = emptyList() ) diff --git a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt index e9fc2f5..a39db1b 100644 --- a/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt +++ b/src/main/kotlin/app/morphe/gui/ui/screens/result/ResultScreen.kt @@ -5,25 +5,33 @@ package app.morphe.gui.ui.screens.result -import androidx.compose.foundation.BorderStroke +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.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.ui.graphics.Color +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.PhoneAndroid -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Usb 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.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import cafe.adriel.voyager.core.screen.Screen @@ -33,7 +41,9 @@ import app.morphe.gui.data.repository.ConfigRepository import kotlinx.coroutines.launch import org.koin.compose.koinInject import app.morphe.gui.ui.components.TopBarRow -import app.morphe.gui.ui.theme.MorpheColors +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.AdbDevice import app.morphe.gui.util.AdbException import app.morphe.gui.util.AdbManager @@ -57,10 +67,14 @@ data class ResultScreen( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ResultScreenContent(outputPath: String) { val navigator = LocalNavigator.currentOrThrow + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val accents = LocalMorpheAccents.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.10f) + val outputFile = File(outputPath) val scope = rememberCoroutineScope() val adbManager = remember { AdbManager() } @@ -79,14 +93,12 @@ fun ResultScreenContent(outputPath: String) { var tempFilesCleared by remember { mutableStateOf(false) } var autoCleanupEnabled by remember { mutableStateOf(false) } - // Check for temp files and auto-cleanup setting LaunchedEffect(Unit) { val config = configRepository.loadConfig() autoCleanupEnabled = config.autoCleanupTempFiles hasTempFiles = FileUtils.hasTempFiles() tempFilesSize = FileUtils.getTempDirSize() - // Auto-cleanup if enabled if (autoCleanupEnabled && hasTempFiles) { FileUtils.cleanupAllTempDirs() hasTempFiles = false @@ -95,7 +107,6 @@ fun ResultScreenContent(outputPath: String) { } } - // Install function fun installViaAdb() { val device = monitorState.selectedDevice ?: return scope.launch { @@ -123,307 +134,380 @@ fun ResultScreenContent(outputPath: String) { } } - Box( + Column( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { - BoxWithConstraints( - modifier = Modifier.fillMaxSize() - ) { - val scrollState = rememberScrollState() - - // Estimate content height for dynamic spacing - val contentHeight = 600.dp // Approximate height of all content - val extraSpace = (maxHeight - contentHeight).coerceAtLeast(0.dp) - - Column( - horizontalAlignment = Alignment.CenterHorizontally, + // Header row + Row( modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(32.dp) + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 1f + ) + } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically ) { - // Add top spacing to center content on large screens - Spacer(modifier = Modifier.height(extraSpace / 2)) - // Success icon - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = "Success", - tint = MorpheColors.Teal, - modifier = Modifier.size(80.dp) + // Back button + val backHover = remember { MutableInteractionSource() } + val isBackHovered by backHover.collectIsHoveredAsState() + val backBg by animateColorAsState( + if (isBackHovered) MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f) + else Color.Transparent, + animationSpec = tween(150) ) + Box( + modifier = Modifier + .size(32.dp) + .hoverable(backHover) + .clip(RoundedCornerShape(corners.small)) + .background(backBg) + .clickable { navigator.popUntilRoot() }, + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f) + ) + } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(Modifier.width(12.dp)) + // Title + success indicator + Box( + modifier = Modifier + .size(8.dp) + .background(accents.secondary, RoundedCornerShape(2.dp)) + ) + Spacer(Modifier.width(8.dp)) Text( - text = "Patching Complete!", - fontSize = 28.sp, + text = "PATCHING COMPLETE", + fontSize = 13.sp, fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface + fontFamily = mono, + color = accents.secondary, + letterSpacing = 1.sp ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(Modifier.weight(1f)) - Text( - text = "Your patched APK is ready", - fontSize = 16.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + TopBarRow(allowCacheClear = false) + } - Spacer(modifier = Modifier.height(32.dp)) + // Content — vertically centered when it fits, scrollable when it overflows + BoxWithConstraints( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) { + val bodyMaxHeight = this.maxHeight + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(scrollState) + .heightIn(min = bodyMaxHeight) + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically) + ) { + // Output file info + Box( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + ) { + // Teal left stripe + Box( + modifier = Modifier + .width(3.dp) + .fillMaxHeight() + .background(accents.secondary) + .align(Alignment.CenterStart) + ) - // Output file info card - Card( - modifier = Modifier.widthIn(max = 500.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(16.dp) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 3.dp) ) { + // File name (first line) + size (second line) Column( - modifier = Modifier.padding(20.dp) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp) ) { Text( - text = "Output File", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "OUTPUT FILE", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(Modifier.height(4.dp)) Text( text = outputFile.name, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = outputFile.parent ?: "", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - if (outputFile.exists()) { - Spacer(modifier = Modifier.height(8.dp)) + Spacer(Modifier.height(4.dp)) Text( text = formatFileSize(outputFile.length()), - fontSize = 13.sp, - color = MorpheColors.Teal + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary ) } + Spacer(Modifier.height(2.dp)) + Text( + text = outputFile.parent ?: "", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // ADB Install Section - if (monitorState.isAdbAvailable == true) { - AdbInstallSection( - devices = monitorState.devices, - selectedDevice = monitorState.selectedDevice, - isLoadingDevices = false, - isInstalling = isInstalling, - installProgress = installProgress, - installError = installError, - installSuccess = installSuccess, - onDeviceSelected = { DeviceMonitor.selectDevice(it) }, - onRefreshDevices = { }, - onInstallClick = { installViaAdb() }, - onRetryClick = { - installError = null - installSuccess = false - installViaAdb() - }, - onDismissError = { installError = null } - ) - - Spacer(modifier = Modifier.height(16.dp)) - } - // Cleanup section - if (hasTempFiles || tempFilesCleared) { - CleanupSection( - hasTempFiles = hasTempFiles, - tempFilesSize = tempFilesSize, - tempFilesCleared = tempFilesCleared, - autoCleanupEnabled = autoCleanupEnabled, - onCleanupClick = { - FileUtils.cleanupAllTempDirs() - hasTempFiles = false - tempFilesCleared = true - Logger.info("Manually cleaned temp files after patching") - } - ) - - Spacer(modifier = Modifier.height(16.dp)) - } - - // Action buttons - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedButton( - onClick = { - try { - val folder = outputFile.parentFile - if (folder != null && Desktop.isDesktopSupported()) { - Desktop.getDesktop().open(folder) - } - } catch (e: Exception) { - // Ignore errors + // Open folder button row + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(20.dp.toPx(), 0f), + end = Offset(size.width - 20.dp.toPx(), 0f), + strokeWidth = 1f + ) } - }, - modifier = Modifier.height(48.dp), - shape = RoundedCornerShape(12.dp) + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Default.FolderOpen, - contentDescription = null, - modifier = Modifier.size(18.dp) + val folderHover = remember { MutableInteractionSource() } + val isFolderHovered by folderHover.collectIsHoveredAsState() + val folderColor by animateColorAsState( + if (isFolderHovered) accents.primary else accents.primary.copy(alpha = 0.7f), + animationSpec = tween(150) + ) + val folderBg by animateColorAsState( + if (isFolderHovered) accents.primary.copy(alpha = 0.1f) else Color.Transparent, + animationSpec = tween(150) ) - Spacer(modifier = Modifier.width(8.dp)) - Text("Open Folder") - } - Button( - onClick = { navigator.popUntilRoot() }, - modifier = Modifier.height(48.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MorpheColors.Blue - ), - shape = RoundedCornerShape(12.dp) - ) { - Text("Patch Another", fontWeight = FontWeight.Medium) + Box( + modifier = Modifier + .hoverable(folderHover) + .clip(RoundedCornerShape(corners.small)) + .background(folderBg, RoundedCornerShape(corners.small)) + .border( + 1.dp, + accents.primary.copy(alpha = if (isFolderHovered) 0.5f else 0.3f), + RoundedCornerShape(corners.small) + ) + .clickable { + try { + val folder = outputFile.parentFile + if (folder != null && Desktop.isDesktopSupported()) { + Desktop.getDesktop().open(folder) + } + } catch (_: Exception) {} + } + .padding(horizontal = 14.dp, vertical = 8.dp) + ) { + Text( + text = "OPEN FOLDER →", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = folderColor, + letterSpacing = 0.5.sp + ) + } } } + } - Spacer(modifier = Modifier.height(24.dp)) + // ADB Install section + if (monitorState.isAdbAvailable == true) { + AdbInstallSection( + devices = monitorState.devices, + selectedDevice = monitorState.selectedDevice, + isInstalling = isInstalling, + installProgress = installProgress, + installError = installError, + installSuccess = installSuccess, + corners = corners, + mono = mono, + borderColor = borderColor, + onDeviceSelected = { DeviceMonitor.selectDevice(it) }, + onInstallClick = { installViaAdb() }, + onRetryClick = { + installError = null + installSuccess = false + installViaAdb() + }, + onDismissError = { installError = null } + ) + } - // Help text (only show when ADB is not available) - if (monitorState.isAdbAvailable == false) { - Text( - text = "ADB not found. Install Android SDK Platform Tools to enable direct installation.", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - textAlign = TextAlign.Center - ) - } else if (monitorState.isAdbAvailable == null) { - Text( - text = "Checking for ADB...", - fontSize = 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - textAlign = TextAlign.Center - ) - } + // Cleanup section + if (hasTempFiles || tempFilesCleared) { + CleanupSection( + hasTempFiles = hasTempFiles, + tempFilesSize = tempFilesSize, + tempFilesCleared = tempFilesCleared, + autoCleanupEnabled = autoCleanupEnabled, + corners = corners, + mono = mono, + borderColor = borderColor, + onCleanupClick = { + FileUtils.cleanupAllTempDirs() + hasTempFiles = false + tempFilesCleared = true + Logger.info("Manually cleaned temp files after patching") + } + ) + } - // Bottom spacing to center content on large screens - Spacer(modifier = Modifier.height(extraSpace / 2)) + // ADB help text + if (monitorState.isAdbAvailable == false) { + Text( + text = "ADB not found. Install Android SDK Platform Tools to enable direct installation.", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + textAlign = TextAlign.Center, + modifier = Modifier.widthIn(max = 520.dp) + ) } - } - // Top bar (device indicator + settings) in top-right corner - TopBarRow( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(24.dp), - allowCacheClear = false - ) + // Patch Another button + Spacer(Modifier.height(4.dp)) + + val patchAnotherHover = remember { MutableInteractionSource() } + val isPatchAnotherHovered by patchAnotherHover.collectIsHoveredAsState() + val patchAnotherBg by animateColorAsState( + if (isPatchAnotherHovered) accents.primary.copy(alpha = 0.9f) else accents.primary, + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .height(42.dp) + .hoverable(patchAnotherHover) + .clip(RoundedCornerShape(corners.small)) + .background(patchAnotherBg, RoundedCornerShape(corners.small)) + .clickable { navigator.popUntilRoot() }, + contentAlignment = Alignment.Center + ) { + Text( + text = "PATCH ANOTHER", + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = Color.White, + letterSpacing = 1.sp + ) + } + + Spacer(Modifier.height(8.dp)) + } + } } } +// ═══════════════════════════════════════════════════════════════════ +// ADB INSTALL SECTION +// ═══════════════════════════════════════════════════════════════════ + @Composable private fun AdbInstallSection( devices: List, selectedDevice: AdbDevice?, - isLoadingDevices: Boolean, isInstalling: Boolean, installProgress: String, installError: String?, installSuccess: Boolean, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, onDeviceSelected: (AdbDevice) -> Unit, - onRefreshDevices: () -> Unit, onInstallClick: () -> Unit, onRetryClick: () -> Unit, onDismissError: () -> Unit ) { - Card( - modifier = Modifier.widthIn(max = 500.dp), - colors = CardDefaults.cardColors( - containerColor = when { - installSuccess -> MorpheColors.Teal.copy(alpha = 0.1f) - installError != null -> MaterialTheme.colorScheme.error.copy(alpha = 0.1f) - else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - } - ), - shape = RoundedCornerShape(12.dp) + val accents = LocalMorpheAccents.current + Box( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) ) { Column( modifier = Modifier .fillMaxWidth() - .padding(16.dp) + .padding(20.dp) ) { // Header Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Default.Usb, - contentDescription = null, - tint = MorpheColors.Blue, - modifier = Modifier.size(20.dp) - ) - Text( - text = "Install via ADB", - fontWeight = FontWeight.SemiBold, - fontSize = 15.sp - ) - } - // Refresh button - IconButton( - onClick = onRefreshDevices, - enabled = !isLoadingDevices && !isInstalling - ) { - if (isLoadingDevices) { - CircularProgressIndicator( - modifier = Modifier.size(18.dp), - strokeWidth = 2.dp - ) - } else { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = "Refresh devices", - modifier = Modifier.size(20.dp) - ) - } - } + Text( + text = "ADB INSTALL", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(Modifier.height(12.dp)) when { installSuccess -> { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = Icons.Default.CheckCircle, contentDescription = null, - tint = MorpheColors.Teal, - modifier = Modifier.size(24.dp) + tint = accents.secondary, + modifier = Modifier.size(18.dp) ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(Modifier.width(8.dp)) Text( - text = "Installed successfully on ${selectedDevice?.displayName ?: "device"}!", - fontWeight = FontWeight.Medium, - color = MorpheColors.Teal + text = "INSTALLED ON ${(selectedDevice?.displayName ?: "DEVICE").uppercase()}", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.secondary, + letterSpacing = 0.5.sp ) } } @@ -431,27 +515,63 @@ private fun AdbInstallSection( installError != null -> { Text( text = installError, + fontSize = 11.sp, + fontFamily = mono, color = MaterialTheme.colorScheme.error, - fontSize = 14.sp, - textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(Modifier.height(10.dp)) Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - TextButton(onClick = onDismissError) { - Text("Dismiss") - } - Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = onRetryClick, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error + val dismissHover = remember { MutableInteractionSource() } + val isDismissHovered by dismissHover.collectIsHoveredAsState() + Box( + modifier = Modifier + .hoverable(dismissHover) + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (isDismissHovered) 0.3f else 0.12f + ), + RoundedCornerShape(corners.small) + ) + .clickable(onClick = onDismissError) + .padding(horizontal = 12.dp, vertical = 6.dp) + ) { + Text( + text = "DISMISS", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + letterSpacing = 0.5.sp ) + } + + val retryHover = remember { MutableInteractionSource() } + val isRetryHovered by retryHover.collectIsHoveredAsState() + Box( + modifier = Modifier + .hoverable(retryHover) + .clip(RoundedCornerShape(corners.small)) + .background( + if (isRetryHovered) MaterialTheme.colorScheme.error.copy(alpha = 0.9f) + else MaterialTheme.colorScheme.error, + RoundedCornerShape(corners.small) + ) + .clickable(onClick = onRetryClick) + .padding(horizontal = 12.dp, vertical = 6.dp) ) { - Text("Retry") + Text( + text = "RETRY", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = Color.White, + letterSpacing = 0.5.sp + ) } } } @@ -459,94 +579,175 @@ private fun AdbInstallSection( isInstalling -> { Row( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center + verticalAlignment = Alignment.CenterVertically ) { CircularProgressIndicator( - modifier = Modifier.size(24.dp), + modifier = Modifier.size(16.dp), strokeWidth = 2.dp, - color = MorpheColors.Blue + color = accents.primary ) - Spacer(modifier = Modifier.width(12.dp)) + Spacer(Modifier.width(10.dp)) Text( - text = installProgress.ifEmpty { "Installing..." }, - color = MaterialTheme.colorScheme.onSurface + text = installProgress.ifEmpty { "Installing..." }.uppercase(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.primary, + letterSpacing = 0.5.sp ) } } else -> { - // Device list val readyDevices = devices.filter { it.isReady } val notReadyDevices = devices.filter { !it.isReady } if (devices.isEmpty()) { - // No devices - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = "No devices connected", - color = MaterialTheme.colorScheme.onSurfaceVariant, - fontSize = 14.sp - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Connect your Android device via USB with USB debugging enabled", - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - fontSize = 12.sp, - textAlign = TextAlign.Center - ) - } - } else { - // Show device list Text( - text = if (readyDevices.size == 1) "Connected device:" else "Select a device:", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "No devices connected", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) ) - Spacer(modifier = Modifier.height(8.dp)) - - // Ready devices - readyDevices.forEach { device -> - DeviceRow( - device = device, - isSelected = selectedDevice?.id == device.id, - onClick = { onDeviceSelected(device) } + Spacer(Modifier.height(2.dp)) + Text( + text = "Connect via USB with USB debugging enabled", + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + ) + } else { + // Device list + (readyDevices + notReadyDevices).forEach { device -> + val isSelected = selectedDevice?.id == device.id + val enabled = device.isReady + val deviceHover = remember { MutableInteractionSource() } + val isDeviceHovered by deviceHover.collectIsHoveredAsState() + + val deviceBorder by animateColorAsState( + when { + isSelected -> accents.secondary.copy(alpha = 0.5f) + isDeviceHovered && enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) + else -> borderColor + }, + animationSpec = tween(150) ) - Spacer(modifier = Modifier.height(6.dp)) - } - - // Not ready devices (unauthorized/offline) - notReadyDevices.forEach { device -> - DeviceRow( - device = device, - isSelected = false, - onClick = { }, - enabled = false + val deviceBg by animateColorAsState( + when { + isSelected -> accents.secondary.copy(alpha = 0.06f) + else -> Color.Transparent + }, + animationSpec = tween(150) ) - Spacer(modifier = Modifier.height(6.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 6.dp) + .hoverable(deviceHover) + .clip(RoundedCornerShape(corners.small)) + .border(1.dp, deviceBorder, RoundedCornerShape(corners.small)) + .background(deviceBg, RoundedCornerShape(corners.small)) + .then( + if (enabled) Modifier.clickable { onDeviceSelected(device) } + else Modifier + ) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = Icons.Default.PhoneAndroid, + contentDescription = null, + tint = when { + isSelected -> accents.secondary + enabled -> accents.primary.copy(alpha = 0.6f) + else -> MaterialTheme.colorScheme.error.copy(alpha = 0.4f) + }, + modifier = Modifier.size(20.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = device.displayName, + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium, + fontFamily = mono, + color = if (enabled) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + ) + Text( + text = device.id, + fontSize = 9.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + } + // Status tag + val statusColor = when (device.status) { + DeviceStatus.DEVICE -> accents.secondary + DeviceStatus.UNAUTHORIZED -> accents.warning + else -> MaterialTheme.colorScheme.error + } + Box( + modifier = Modifier + .border(1.dp, statusColor.copy(alpha = 0.3f), RoundedCornerShape(corners.small)) + .background(statusColor.copy(alpha = 0.06f), RoundedCornerShape(corners.small)) + .padding(horizontal = 8.dp, vertical = 3.dp) + ) { + Text( + text = when (device.status) { + DeviceStatus.DEVICE -> "READY" + DeviceStatus.UNAUTHORIZED -> "UNAUTH" + DeviceStatus.OFFLINE -> "OFFLINE" + DeviceStatus.UNKNOWN -> "UNKNOWN" + }, + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = statusColor, + letterSpacing = 0.5.sp + ) + } + } } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(Modifier.height(6.dp)) // Install button - Button( - onClick = onInstallClick, - modifier = Modifier.fillMaxWidth(), - enabled = selectedDevice != null, - colors = ButtonDefaults.buttonColors( - containerColor = MorpheColors.Teal - ), - shape = RoundedCornerShape(8.dp) + val installHover = remember { MutableInteractionSource() } + val isInstallHovered by installHover.collectIsHoveredAsState() + val installBg by animateColorAsState( + when { + selectedDevice == null -> accents.secondary.copy(alpha = 0.3f) + isInstallHovered -> accents.secondary.copy(alpha = 0.9f) + else -> accents.secondary + }, + animationSpec = tween(150) + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(38.dp) + .hoverable(installHover) + .clip(RoundedCornerShape(corners.small)) + .background(installBg, RoundedCornerShape(corners.small)) + .then( + if (selectedDevice != null) Modifier.clickable(onClick = onInstallClick) + else Modifier + ), + contentAlignment = Alignment.Center ) { Text( text = if (selectedDevice != null) - "Install on ${selectedDevice.displayName}" + "INSTALL ON ${selectedDevice.displayName.uppercase()}" else - "Select a device to install", - fontWeight = FontWeight.Medium + "SELECT A DEVICE", + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = Color.White, + letterSpacing = 0.5.sp ) } } @@ -556,158 +757,97 @@ private fun AdbInstallSection( } } +// ═══════════════════════════════════════════════════════════════════ +// CLEANUP SECTION +// ═══════════════════════════════════════════════════════════════════ + @Composable private fun CleanupSection( hasTempFiles: Boolean, tempFilesSize: Long, tempFilesCleared: Boolean, autoCleanupEnabled: Boolean, + corners: app.morphe.gui.ui.theme.MorpheCornerStyle, + mono: androidx.compose.ui.text.font.FontFamily, + borderColor: Color, onCleanupClick: () -> Unit ) { - Card( - modifier = Modifier.widthIn(max = 500.dp), - colors = CardDefaults.cardColors( - containerColor = if (tempFilesCleared) - MorpheColors.Teal.copy(alpha = 0.1f) - else - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - ), - shape = RoundedCornerShape(12.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = if (tempFilesCleared) "Temp files cleaned" else "Temporary files", - fontWeight = FontWeight.Medium, - fontSize = 14.sp, - color = if (tempFilesCleared) - MorpheColors.Teal - else - MaterialTheme.colorScheme.onSurface - ) - Text( - text = when { - tempFilesCleared && autoCleanupEnabled -> "Auto-cleanup is enabled" - tempFilesCleared -> "Freed up ${formatFileSize(tempFilesSize)}" - else -> "${formatFileSize(tempFilesSize)} can be freed" - }, - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - if (hasTempFiles && !tempFilesCleared) { - OutlinedButton( - onClick = onCleanupClick, - shape = RoundedCornerShape(8.dp), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp) - ) { - Text("Clean up", fontSize = 13.sp) - } - } else if (tempFilesCleared) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = null, - tint = MorpheColors.Teal, - modifier = Modifier.size(24.dp) - ) - } - } - } -} + val accents = LocalMorpheAccents.current + val accentColor = if (tempFilesCleared) accents.secondary else MaterialTheme.colorScheme.onSurfaceVariant -@Composable -private fun DeviceRow( - device: AdbDevice, - isSelected: Boolean, - onClick: () -> Unit, - enabled: Boolean = true -) { - OutlinedCard( - onClick = onClick, - modifier = Modifier.fillMaxWidth(), - enabled = enabled, - shape = RoundedCornerShape(8.dp), - border = BorderStroke( - width = if (isSelected) 2.dp else 1.dp, - color = when { - isSelected -> MorpheColors.Teal - !enabled -> MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) - else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) - } - ), - colors = CardDefaults.outlinedCardColors( - containerColor = if (isSelected) - MorpheColors.Teal.copy(alpha = 0.08f) - else - MaterialTheme.colorScheme.surface - ) + Row( + modifier = Modifier + .widthIn(max = 520.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border( + 1.dp, + if (tempFilesCleared) accents.secondary.copy(alpha = 0.2f) else borderColor, + RoundedCornerShape(corners.medium) + ) + .background( + if (tempFilesCleared) accents.secondary.copy(alpha = 0.04f) + else MaterialTheme.colorScheme.surface + ) + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - Icon( - imageVector = Icons.Default.PhoneAndroid, - contentDescription = null, - tint = when { - isSelected -> MorpheColors.Teal - device.isReady -> MorpheColors.Blue - else -> MaterialTheme.colorScheme.error.copy(alpha = 0.6f) - }, - modifier = Modifier.size(24.dp) + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (tempFilesCleared) "TEMP FILES CLEANED" else "TEMPORARY FILES", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = if (tempFilesCleared) accents.secondary + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.sp ) - Column(modifier = Modifier.weight(1f)) { - Text( - text = device.displayName, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, - color = if (enabled) - MaterialTheme.colorScheme.onSurface - else - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f), - fontSize = 14.sp - ) - Text( - text = device.id, - fontSize = 11.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - } - // Status badge - Surface( - color = when (device.status) { - DeviceStatus.DEVICE -> MorpheColors.Teal.copy(alpha = 0.15f) - DeviceStatus.UNAUTHORIZED -> Color(0xFFFF9800).copy(alpha = 0.15f) - else -> MaterialTheme.colorScheme.error.copy(alpha = 0.15f) + Spacer(Modifier.height(2.dp)) + Text( + text = when { + tempFilesCleared && autoCleanupEnabled -> "Auto-cleanup is enabled" + tempFilesCleared -> "Freed ${formatFileSize(tempFilesSize)}" + else -> "${formatFileSize(tempFilesSize)} can be freed" }, - shape = RoundedCornerShape(4.dp) + fontSize = 11.sp, + fontFamily = mono, + color = if (tempFilesCleared) accents.secondary.copy(alpha = 0.7f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + + if (hasTempFiles && !tempFilesCleared) { + val cleanHover = remember { MutableInteractionSource() } + val isCleanHovered by cleanHover.collectIsHoveredAsState() + val cleanBg by animateColorAsState( + if (isCleanHovered) accents.warning.copy(alpha = 0.1f) else Color.Transparent, + animationSpec = tween(150) + ) + Box( + modifier = Modifier + .hoverable(cleanHover) + .clip(RoundedCornerShape(corners.small)) + .background(cleanBg) + .clickable(onClick = onCleanupClick) + .padding(horizontal = 10.dp, vertical = 4.dp) ) { Text( - text = when (device.status) { - DeviceStatus.DEVICE -> "Ready" - DeviceStatus.UNAUTHORIZED -> "Unauthorized" - DeviceStatus.OFFLINE -> "Offline" - DeviceStatus.UNKNOWN -> "Unknown" - }, + text = "CLEAN UP", fontSize = 10.sp, - fontWeight = FontWeight.Medium, - color = when (device.status) { - DeviceStatus.DEVICE -> MorpheColors.Teal - DeviceStatus.UNAUTHORIZED -> Color(0xFFFF9800) - else -> MaterialTheme.colorScheme.error - }, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = accents.warning, + letterSpacing = 0.5.sp ) } + } else if (tempFilesCleared) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = accents.secondary, + modifier = Modifier.size(18.dp) + ) } } } diff --git a/src/main/kotlin/app/morphe/gui/ui/theme/MorpheTypography.kt b/src/main/kotlin/app/morphe/gui/ui/theme/MorpheTypography.kt new file mode 100644 index 0000000..f3c3a4e --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/theme/MorpheTypography.kt @@ -0,0 +1,41 @@ +package app.morphe.gui.ui.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.platform.Font + +/** + * JetBrains Mono — the monospace face for all technical data: + * versions, package names, architectures, checksums, console output. + */ +val JetBrainsMono: FontFamily + @Composable + get() = FontFamily( + Font(resource = "fonts/JetBrainsMono-Light.ttf", weight = FontWeight.Light), + Font(resource = "fonts/JetBrainsMono-Regular.ttf", weight = FontWeight.Normal), + Font(resource = "fonts/JetBrainsMono-Medium.ttf", weight = FontWeight.Medium), + Font(resource = "fonts/JetBrainsMono-SemiBold.ttf", weight = FontWeight.SemiBold), + Font(resource = "fonts/JetBrainsMono-Bold.ttf", weight = FontWeight.Bold), + ) + +/** + * Nunito — soft, rounded sans-serif for cute themes (Sakura, Matcha). + * Generous x-height, fully rounded terminals, pillowy feel. + */ +val Nunito: FontFamily + @Composable + get() = FontFamily( + Font(resource = "fonts/Nunito-Light.ttf", weight = FontWeight.Light), + Font(resource = "fonts/Nunito-Regular.ttf", weight = FontWeight.Normal), + Font(resource = "fonts/Nunito-Medium.ttf", weight = FontWeight.Medium), + Font(resource = "fonts/Nunito-SemiBold.ttf", weight = FontWeight.SemiBold), + Font(resource = "fonts/Nunito-Bold.ttf", weight = FontWeight.Bold), + ) + +/** + * Theme-aware font provider. Sharp themes get JetBrains Mono, + * soft/cute themes (Sakura, Matcha) get Nunito. + */ +val LocalMorpheFont = compositionLocalOf { FontFamily.Default } 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 420ca7e..da3715b 100644 --- a/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt +++ b/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt @@ -10,12 +10,16 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp // Morphe Brand Colors object MorpheColors { - val Blue = Color(0xFF2D62DD) - val Teal = Color(0xFF00A797) + val Blue = Color(0xFF3B7BF7) + val Teal = Color(0xFF00D1B2) val Cyan = Color(0xFF62E1FF) val DeepBlack = Color(0xFF121212) val SurfaceDark = Color(0xFF1E1E1E) @@ -24,6 +28,99 @@ object MorpheColors { val TextDark = Color(0xFF1C1C1C) } +// ════════════════════════════════════════════════════════════════════ +// ACCENT COLOR SYSTEM +// ════════════════════════════════════════════════════════════════════ + +/** + * Per-theme accent colors. Components should read from LocalMorpheAccents + * instead of using MorpheColors.Blue/Teal directly. + */ +data class MorpheAccentColors( + val primary: Color, // Buttons, selections, links (replaces MorpheColors.Blue) + val secondary: Color, // Badges, options, success states (replaces MorpheColors.Teal) + val tertiary: Color = Color(0xFF5C6BC0), // Structural emphasis, info accents + val warning: Color = Color(0xFFFF9800), // Warning states (was hardcoded everywhere) +) + +val LocalMorpheAccents = compositionLocalOf { MorpheAccentColors(MorpheColors.Blue, MorpheColors.Teal) } + +/** Morphe Dark — brand blue + teal on dark gray. */ +private val DarkAccents = MorpheAccentColors( + primary = MorpheColors.Blue, + secondary = MorpheColors.Teal, +) + +/** Amoled — slightly brighter accents to pop on pure black. */ +private val AmoledAccents = MorpheAccentColors( + primary = Color(0xFF5B9AFF), // Punchy blue for pure black + secondary = Color(0xFF00E8C6), // Vivid teal for pure black +) + +/** Morphe Light — brand colors work fine on light backgrounds. */ +private val LightAccents = MorpheAccentColors( + primary = MorpheColors.Blue, + secondary = MorpheColors.Teal, +) + +/** Nord — native Nord palette. Arctic frost + aurora. */ +private val NordAccents = MorpheAccentColors( + primary = Color(0xFF5EC4DB), // Nord Frost — saturated + secondary = Color(0xFF8FD46E), // Nord Aurora Green — vivid + tertiary = Color(0xFF6AA3D9), // Nord Frost Blue — punchy + warning = Color(0xFFE8BF5A), // Nord Aurora Yellow — stronger +) + +/** Catppuccin Mocha — native Catppuccin palette. Mauve + teal. */ +private val CatppuccinAccents = MorpheAccentColors( + primary = Color(0xFFB47BFF), // Mauve — saturated, less pastel + secondary = Color(0xFF4EECD5), // Teal — vivid + tertiary = Color(0xFF6A9FFF), // Blue — punchy + warning = Color(0xFFFF9A5C), // Peach — stronger +) + +/** Sakura — triadic: cherry blossom pink, spring sage, wisteria dusk. */ +private val SakuraAccents = MorpheAccentColors( + primary = Color(0xFFD44B76), // Cherry blossom pink + secondary = Color(0xFF5B8A72), // Spring-leaf sage (complementary green) + tertiary = Color(0xFF8B6B99), // Wisteria dusk (purple structural accent) + warning = Color(0xFFD89A2B), // Golden stamen amber +) + +/** Matcha — forest green + sage. */ +private val MatchaAccents = MorpheAccentColors( + primary = Color(0xFF4C7A35), // Tea-leaf green + secondary = Color(0xFF4C7871), // Muted jade + tertiary = Color(0xFF7D6A9B), // Soft plum contrast + warning = Color(0xFFB77833), // Toasted ochre +) + +// ════════════════════════════════════════════════════════════════════ +// CORNER / SHAPE STYLE SYSTEM +// ════════════════════════════════════════════════════════════════════ + +/** + * Defines the corner radius style for the current theme. + * Sharp themes use 2dp, soft/cute themes use larger radii. + */ +data class MorpheCornerStyle( + val small: Dp = 2.dp, + val medium: Dp = 2.dp, + val large: Dp = 2.dp, +) + +val LocalMorpheCorners = compositionLocalOf { MorpheCornerStyle() } + +/** Sharp corners for cyberdeck/dev themes. */ +private val SharpCorners = MorpheCornerStyle(small = 2.dp, medium = 2.dp, large = 2.dp) + +/** Soft rounded corners for cute/warm themes. */ +private val SoftCorners = MorpheCornerStyle(small = 10.dp, medium = 14.dp, large = 18.dp) + +// ════════════════════════════════════════════════════════════════════ +// COLOR SCHEMES +// ════════════════════════════════════════════════════════════════════ + private val MorpheDarkColorScheme = darkColorScheme( primary = MorpheColors.Blue, secondary = MorpheColors.Teal, @@ -75,13 +172,114 @@ private val MorpheLightColorScheme = lightColorScheme( onError = Color.White ) +// ── Nord ── +// Arctic, cool-toned dark theme inspired by nordtheme.com +private val NordColorScheme = darkColorScheme( + primary = Color(0xFF88C0D0), // Frost + secondary = Color(0xFFA3BE8C), // Aurora Green + tertiary = Color(0xFF81A1C1), // Frost Blue + background = Color(0xFF2E3440), // Polar Night + surface = Color(0xFF3B4252), // Polar Night lighter + surfaceVariant = Color(0xFF434C5E), + onPrimary = Color(0xFF2E3440), + onSecondary = Color(0xFF2E3440), + onTertiary = Color(0xFF2E3440), + onBackground = Color(0xFFECEFF4), // Snow Storm + onSurface = Color(0xFFECEFF4), + onSurfaceVariant = Color(0xFFD8DEE9), + error = Color(0xFFBF616A), // Aurora Red + onError = Color(0xFFECEFF4) +) + +// ── Catppuccin Mocha ── +// Warm, soothing pastel dark theme +private val CatppuccinMochaColorScheme = darkColorScheme( + primary = Color(0xFFCBA6F7), // Mauve + secondary = Color(0xFFF5C2E7), // Pink + tertiary = Color(0xFF89B4FA), // Blue + background = Color(0xFF1E1E2E), // Base + surface = Color(0xFF313244), // Surface0 + surfaceVariant = Color(0xFF45475A), // Surface1 + onPrimary = Color(0xFF1E1E2E), + onSecondary = Color(0xFF1E1E2E), + onTertiary = Color(0xFF1E1E2E), + onBackground = Color(0xFFCDD6F4), // Text + onSurface = Color(0xFFCDD6F4), + onSurfaceVariant = Color(0xFFBAC2DE), // Subtext1 + error = Color(0xFFF38BA8), // Red + onError = Color(0xFF1E1E2E) +) + +// ── Sakura ── +// Triadic cherry blossom: pink + sage + wisteria on warm petal surfaces +private val SakuraColorScheme = lightColorScheme( + primary = Color(0xFFD44B76), // Cherry blossom pink + secondary = Color(0xFF5B8A72), // Spring-leaf sage + tertiary = Color(0xFF8B6B99), // Wisteria dusk + background = Color(0xFFFFF0EA), // Warm blossom paper + surface = Color(0xFFFFE4DC), // Pink petal surface + surfaceVariant = Color(0xFFF5D5CC), // Deeper blush for emphasis + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF3D2832), // Plum-tinted ink (not pure black) + onSurface = Color(0xFF3D2832), + onSurfaceVariant = Color(0xFF7A5562), // Plum-brown (sakura bark tone) + error = Color(0xFFC03048), + onError = Color.White +) + +// ── Matcha ── +// Pista green, cute aesthetic — light theme with fresh green tones +private val MatchaColorScheme = lightColorScheme( + primary = Color(0xFF4C7A35), // Tea leaf green + secondary = Color(0xFF5E8554), // Deep herb + tertiary = Color(0xFF92B887), // Soft matcha + background = Color(0xFFF6F8F1), // Green-tinted white + surface = Color(0xFFEAF1E1), // Pale leaf + surfaceVariant = Color(0xFFD6E2C9), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color(0xFF21321B), + onBackground = Color(0xFF21321B), // Deep forest + onSurface = Color(0xFF21321B), + onSurfaceVariant = Color(0xFF476042), + error = Color(0xFFAA3A3A), + onError = Color.White +) + +// ════════════════════════════════════════════════════════════════════ +// THEME PREFERENCE +// ════════════════════════════════════════════════════════════════════ + enum class ThemePreference { LIGHT, DARK, AMOLED, - SYSTEM + NORD, + CATPPUCCIN, + SAKURA, + MATCHA, + SYSTEM; + + /** Whether this theme uses dark color scheme (for resource qualifiers). */ + fun isDark(): Boolean = when (this) { + DARK, AMOLED, NORD, CATPPUCCIN -> true + LIGHT, SAKURA, MATCHA -> false + SYSTEM -> false // caller should check isSystemInDarkTheme() + } + + /** Whether this theme uses soft/rounded corners. */ + fun isSoft(): Boolean = when (this) { + SAKURA, MATCHA -> true + else -> false + } } +// ════════════════════════════════════════════════════════════════════ +// THEME COMPOSABLE +// ════════════════════════════════════════════════════════════════════ + @Composable fun MorpheTheme( themePreference: ThemePreference = ThemePreference.SYSTEM, @@ -91,13 +289,36 @@ fun MorpheTheme( ThemePreference.DARK -> MorpheDarkColorScheme ThemePreference.AMOLED -> MorpheAmoledColorScheme ThemePreference.LIGHT -> MorpheLightColorScheme + ThemePreference.NORD -> NordColorScheme + ThemePreference.CATPPUCCIN -> CatppuccinMochaColorScheme + ThemePreference.SAKURA -> SakuraColorScheme + ThemePreference.MATCHA -> MatchaColorScheme ThemePreference.SYSTEM -> { if (isSystemInDarkTheme()) MorpheDarkColorScheme else MorpheLightColorScheme } } - MaterialTheme( - colorScheme = colorScheme, - content = content - ) + val corners = if (themePreference.isSoft()) SoftCorners else SharpCorners + val font = if (themePreference.isSoft()) Nunito else JetBrainsMono + val accents = when (themePreference) { + ThemePreference.DARK -> DarkAccents + ThemePreference.AMOLED -> AmoledAccents + ThemePreference.LIGHT -> LightAccents + ThemePreference.NORD -> NordAccents + ThemePreference.CATPPUCCIN -> CatppuccinAccents + ThemePreference.SAKURA -> SakuraAccents + ThemePreference.MATCHA -> MatchaAccents + ThemePreference.SYSTEM -> if (isSystemInDarkTheme()) DarkAccents else LightAccents + } + + CompositionLocalProvider( + LocalMorpheCorners provides corners, + LocalMorpheFont provides font, + LocalMorpheAccents provides accents + ) { + MaterialTheme( + colorScheme = colorScheme, + content = content + ) + } } diff --git a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt index bce46e1..0b4914a 100644 --- a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt +++ b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt @@ -6,7 +6,6 @@ package app.morphe.gui.util import java.io.File -import java.nio.file.Path import java.nio.file.Paths import java.util.zip.ZipFile @@ -18,6 +17,14 @@ object FileUtils { private const val APP_NAME = "morphe-gui" + /** + * All modern Android architectures. Obsolete architectures such as Mips are not included. + */ + private val ANDROID_ARCHITECTURES = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") + + private val EXTENSION_APK_BUNDLES = setOf("apkm", "xapk", "apks") + private val EXTENSION_APK_ANY = EXTENSION_APK_BUNDLES + "apk" + /** * Get the app data directory based on OS. * - Windows: %APPDATA%/morphe-gui @@ -147,22 +154,54 @@ object FileUtils { } /** - * Check if file is an APK or APKM. + * Check if file is an APK or split APK bundle (APKM, XAPK, APKS). */ fun isApkFile(file: File): Boolean { val ext = getExtension(file) - return file.isFile && (ext == "apk" || ext == "apkm") + return file.isFile && ext in EXTENSION_APK_ANY + } + + /** + * Check if file is a split APK bundle (.apkm, .xapk, or .apks). + */ + fun isBundleFormat(file: File): Boolean { + return file.extension.lowercase() in EXTENSION_APK_BUNDLES } /** - * Extract base.apk from an .apkm file to a temp directory. + * Extract base.apk from a split APK bundle (.apkm, .xapk, or .apks) to a temp directory. + * For XAPK files, the base APK may not be named "base.apk" — falls back to the + * first non-split .apk entry or the largest by compressed size. * Returns the extracted base.apk file, or null if extraction fails. * Caller is responsible for cleaning up the returned temp file. */ - fun extractBaseApkFromApkm(apkmFile: File): File? { + fun extractBaseApkFromBundle(bundleFile: File): File? { return try { - ZipFile(apkmFile).use { zip -> - val baseEntry = zip.getEntry("base.apk") ?: return null + ZipFile(bundleFile).use { zip -> + val allEntries = zip.entries().asSequence().toList() + + // Try "base.apk" first (APKM format) + var baseEntry = zip.getEntry("base.apk") + + // For XAPK: find the base APK among all .apk entries. + // Splits are named like "config.arm64_v8a.apk", "split_config.en.apk", etc. + // The base APK is typically the package name (e.g., "com.google.android.youtube.apk"). + if (baseEntry == null) { + val apkEntries = allEntries + .filter { !it.isDirectory && it.name.endsWith(".apk", ignoreCase = true) } + + val splitPatterns = listOf("split_config", "config.", "split_") + baseEntry = apkEntries + .firstOrNull { entry -> + val name = entry.name.substringAfterLast('/').lowercase() + splitPatterns.none { name.startsWith(it) } + } + // Final fallback: largest .apk by compressed size + ?: apkEntries.maxByOrNull { it.compressedSize } + } + + if (baseEntry == null) return null + val tempFile = File(getTempDir(), "base-${System.currentTimeMillis()}.apk") zip.getInputStream(baseEntry).use { input -> tempFile.outputStream().use { output -> @@ -175,4 +214,46 @@ object FileUtils { null } } + + @Deprecated("Use extractBaseApkFromBundle instead", ReplaceWith("extractBaseApkFromBundle(apkmFile)")) + fun extractBaseApkFromApkm(apkmFile: File): File? = extractBaseApkFromBundle(apkmFile) + + /** + * Extract supported CPU architectures from native libraries in an APK or bundle. + * Scans for lib// directories, and for bundles also detects arch from split APK names. + */ + fun extractArchitectures(file: File): List { + return try { + ZipFile(file).use { zip -> + val archDirs = mutableSetOf() + + // Scan for lib// entries + zip.entries().asSequence() + .map { it.name } + .filter { it.startsWith("lib/") } + .mapNotNull { path -> + val parts = path.split("/") + if (parts.size >= 2) parts[1] else null + } + .forEach { archDirs.add(it) } + + // For bundles: detect arch from split APK names (e.g. split_config.arm64_v8a.apk) + if (archDirs.isEmpty()) { + zip.entries().asSequence() + .map { it.name } + .filter { it.endsWith(".apk") } + .forEach { name -> + val normalized = name.replace("_", "-") + ANDROID_ARCHITECTURES.filter { arch -> normalized.contains(arch) } + .forEach { archDirs.add(it) } + } + } + + archDirs.toList().ifEmpty { listOf("universal") } + } + } catch (e: Exception) { + Logger.warn("Could not extract architectures: ${e.message}") + emptyList() + } + } } diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt index 47d832e..5351387 100644 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -14,6 +14,7 @@ import app.morphe.patcher.patch.loadPatchesFromJar import app.morphe.patcher.resource.CpuArchitecture import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import app.morphe.patcher.apk.ApkUtils import java.io.File import kotlin.reflect.KType import app.morphe.patcher.patch.Patch as LibraryPatch @@ -85,6 +86,10 @@ class PatchService { exclusiveMode: Boolean = false, keepArchitectures: Set = emptySet(), continueOnError: Boolean = false, + keystorePath: String? = null, + keystorePassword: String? = null, + keystoreAlias: String? = null, + keystoreEntryPassword: String? = null, onProgress: (String) -> Unit = {} ): Result = withContext(Dispatchers.IO) { try { @@ -114,6 +119,15 @@ class PatchService { .mapValues { it.value as Any? } }.filter { it.value.isNotEmpty() } + val keystoreDetails = if (keystorePath != null) { + ApkUtils.KeyStoreDetails( + keyStore = File(keystorePath), + keyStorePassword = keystorePassword, + alias = keystoreAlias ?: PatchEngine.Config.DEFAULT_KEYSTORE_ALIAS, + password = keystoreEntryPassword ?: PatchEngine.Config.DEFAULT_KEYSTORE_PASSWORD, + ) + } else null + val config = PatchEngine.Config( inputApk = inputApk, patches = loadedPatches, @@ -125,6 +139,7 @@ class PatchService { patchOptions = patchOptions, architecturesToKeep = keepArchitectures, failOnError = !continueOnError, + keystoreDetails = keystoreDetails, ) val engineResult = PatchEngine.patch(config, onProgress) @@ -151,18 +166,24 @@ class PatchService { return Patch( name = this.name ?: "Unknown", description = this.description ?: "", - compatiblePackages = this.compatiblePackages?.map { (name, versions) -> - CompatiblePackage( - name = name, - versions = versions?.toList() ?: emptyList() - ) - } ?: emptyList(), + compatiblePackages = 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(), options = this.options.values.map { opt -> PatchOption( key = opt.key, title = opt.title ?: opt.key, description = opt.description ?: "", - type = mapKTypeToOptionType(opt.type), + type = mapKTypeToOptionType(opt.type, opt.key, opt.title ?: opt.key), default = opt.default?.toString(), required = opt.required ) @@ -174,7 +195,7 @@ class PatchService { /** * Map Kotlin KType to GUI PatchOptionType. */ - private fun mapKTypeToOptionType(kType: KType): PatchOptionType { + private fun mapKTypeToOptionType(kType: KType, key: String, title: String): PatchOptionType { val typeName = kType.toString() return when { typeName.contains("Boolean") -> PatchOptionType.BOOLEAN @@ -182,7 +203,12 @@ class PatchService { typeName.contains("Long") -> PatchOptionType.LONG typeName.contains("Float") || typeName.contains("Double") -> PatchOptionType.FLOAT typeName.contains("List") || typeName.contains("Array") || typeName.contains("Set") -> PatchOptionType.LIST - else -> PatchOptionType.STRING + typeName.contains("File") || typeName.contains("Path") || typeName.contains("InputStream") -> PatchOptionType.FILE + else -> { + val combined = "$key $title".lowercase() + val fileKeywords = listOf("icon", "image", "logo", "banner", "path", "file", "png", "jpg") + if (fileKeywords.any { it in combined }) PatchOptionType.FILE else PatchOptionType.STRING + } } } } diff --git a/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt b/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt index 0752610..56a71bd 100644 --- a/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt +++ b/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt @@ -8,29 +8,36 @@ package app.morphe.gui.util import app.morphe.gui.data.model.Patch import app.morphe.gui.data.model.SupportedApp + /** * Extracts supported apps from parsed patch data. * This allows the app to dynamically determine which apps are supported * based on the .mpp file contents rather than hardcoding. */ -object SupportedAppExtractor { +object SupportedAppExtractor { /** * Extract all supported apps from a list of patches. * Groups patches by package name and collects all supported versions. */ fun extractSupportedApps(patches: List): List { - // Collect all package names and their versions from all patches + // Collect all package names and their stable + experimental versions from all patches val packageVersionsMap = mutableMapOf>() + val packageExperimentalMap = mutableMapOf>() + val packageDisplayNames = mutableMapOf() for (patch in patches) { for (compatiblePackage in patch.compatiblePackages) { val packageName = compatiblePackage.name - val versions = compatiblePackage.versions if (packageName.isNotBlank()) { - val existingVersions = packageVersionsMap.getOrPut(packageName) { mutableSetOf() } - existingVersions.addAll(versions) + packageVersionsMap.getOrPut(packageName) { mutableSetOf() } + .addAll(compatiblePackage.versions) + packageExperimentalMap.getOrPut(packageName) { mutableSetOf() } + .addAll(compatiblePackage.experimentalVersions) + compatiblePackage.displayName + ?.takeIf { it.isNotBlank() } + ?.let { packageDisplayNames.putIfAbsent(packageName, it) } } } } @@ -38,13 +45,22 @@ object SupportedAppExtractor { // Convert to SupportedApp list return packageVersionsMap.map { (packageName, versions) -> val versionList = versions.toList().sortedDescending() + val experimentalList = (packageExperimentalMap[packageName] ?: emptySet()) + .minus(versions) // Remove any that are also stable + .toList().sortedDescending() val recommendedVersion = SupportedApp.getRecommendedVersion(versionList) + val latestExperimental = experimentalList.firstOrNull() SupportedApp( packageName = packageName, - displayName = SupportedApp.getDisplayName(packageName), + displayName = SupportedApp.resolveDisplayName( + packageName = packageName, + providedName = packageDisplayNames[packageName] + ), supportedVersions = versionList, + experimentalVersions = experimentalList, recommendedVersion = recommendedVersion, - apkDownloadUrl = SupportedApp.getDownloadUrl(packageName, recommendedVersion) + apkDownloadUrl = SupportedApp.getDownloadUrl(packageName, recommendedVersion ?: "any"), + experimentalDownloadUrl = SupportedApp.getDownloadUrl(packageName, latestExperimental) ) }.sortedBy { it.displayName } } diff --git a/src/main/kotlin/app/morphe/gui/util/VersionStatusInfo.kt b/src/main/kotlin/app/morphe/gui/util/VersionStatusInfo.kt new file mode 100644 index 0000000..df44c91 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/VersionStatusInfo.kt @@ -0,0 +1,196 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.util + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import app.morphe.gui.ui.theme.LocalMorpheAccents + +// ----------------------------- +// STATUS COLOR TYPE +// ----------------------------- + +enum class StatusColorType { PRIMARY, WARNING, ERROR } + +@Composable +fun StatusColorType.toColor(): Color = when (this) { + StatusColorType.PRIMARY -> LocalMorpheAccents.current.primary + StatusColorType.WARNING -> LocalMorpheAccents.current.warning + StatusColorType.ERROR -> MaterialTheme.colorScheme.error +} + +// ----------------------------- +// STATUS DISPLAY (label + detail for status bars) +// ----------------------------- + +data class VersionStatusDisplay( + val label: String, + val detail: String?, + val colorType: StatusColorType +) + +fun resolveVersionStatusDisplay( + versionStatus: VersionStatus, + checksumStatus: ChecksumStatus, + suggestedVersion: String? = null +): VersionStatusDisplay? { + return when (versionStatus) { + VersionStatus.LATEST_STABLE -> when (checksumStatus) { + is ChecksumStatus.Verified -> VersionStatusDisplay( + label = "LATEST STABLE", + detail = "Checksum matches APKMirror", + colorType = StatusColorType.PRIMARY + ) + is ChecksumStatus.Mismatch -> VersionStatusDisplay( + label = "CHECKSUM MISMATCH", + detail = "File may be corrupted, re-download from APKMirror", + colorType = StatusColorType.ERROR + ) + is ChecksumStatus.Error -> VersionStatusDisplay( + label = "LATEST STABLE", + detail = "Checksum verification failed", + colorType = StatusColorType.WARNING + ) + is ChecksumStatus.NotConfigured -> VersionStatusDisplay( + label = "LATEST STABLE", + detail = null, + colorType = StatusColorType.PRIMARY + ) + is ChecksumStatus.NonRecommendedVersion -> null + } + + VersionStatus.OLDER_STABLE -> VersionStatusDisplay( + label = "OLDER STABLE", + detail = suggestedVersion + ?.let { "Newer stable v$it available" } + ?: "A newer stable version is available", + colorType = StatusColorType.WARNING + ) + + VersionStatus.LATEST_EXPERIMENTAL -> VersionStatusDisplay( + label = "EXPERIMENTAL", + detail = "Supported, but may not work properly", + colorType = StatusColorType.WARNING + ) + + VersionStatus.OLDER_EXPERIMENTAL -> VersionStatusDisplay( + label = "OLDER EXPERIMENTAL", + detail = suggestedVersion + ?.let { "Newer experimental v$it available" } + ?: "A newer experimental build is available", + colorType = StatusColorType.WARNING + ) + + VersionStatus.TOO_NEW -> VersionStatusDisplay( + label = "VERSION TOO NEW", + detail = "Not officially supported — patches will most likely fail", + colorType = StatusColorType.ERROR + ) + + VersionStatus.TOO_OLD -> VersionStatusDisplay( + label = "VERSION TOO OLD", + detail = "Not officially supported — patches will most likely fail", + colorType = StatusColorType.ERROR + ) + + VersionStatus.UNSUPPORTED_BETWEEN -> VersionStatusDisplay( + label = "UNSUPPORTED VERSION", + detail = "Not officially supported — patches will most likely fail", + colorType = StatusColorType.ERROR + ) + + VersionStatus.UNKNOWN -> null + } +} + +// ----------------------------- +// STATUS ACCENT COLOR (for card stripes, dots, initials) +// ----------------------------- + +fun resolveStatusColorType( + versionStatus: VersionStatus, + checksumStatus: ChecksumStatus +): StatusColorType { + if (checksumStatus is ChecksumStatus.Mismatch) { + return StatusColorType.ERROR + } + return when (versionStatus) { + VersionStatus.LATEST_STABLE, + VersionStatus.UNKNOWN -> StatusColorType.PRIMARY + + VersionStatus.OLDER_STABLE, + VersionStatus.LATEST_EXPERIMENTAL, + VersionStatus.OLDER_EXPERIMENTAL -> StatusColorType.WARNING + + VersionStatus.TOO_NEW, + VersionStatus.TOO_OLD, + VersionStatus.UNSUPPORTED_BETWEEN -> StatusColorType.ERROR + } +} + +// ----------------------------- +// WARNING DIALOG CONTENT (title + body for version warning dialogs) +// ----------------------------- + +data class VersionWarningContent( + val title: String, + val message: String, + val colorType: StatusColorType +) + +fun resolveVersionWarningContent( + versionStatus: VersionStatus, + currentVersion: String, + suggestedVersion: String +): VersionWarningContent { + val (title, message) = when (versionStatus) { + VersionStatus.OLDER_STABLE -> Pair( + "OLDER STABLE VERSION", + "Current: v$currentVersion\nLatest stable: v$suggestedVersion\n\n" + + "This version is supported, but a newer stable version is available. " + + "You may be missing recent fixes." + ) + VersionStatus.LATEST_EXPERIMENTAL -> Pair( + "DO YOU WANT TO EXPERIMENT? \uD83E\uDDEA", + "Current: v$currentVersion\n\n" + + "This version has early experimental support\n\n" + + "\uD83D\uDD27 Expect quirky app behavior or unidentified bugs as the " + + "patches are refined for this app version." + ) + VersionStatus.OLDER_EXPERIMENTAL -> Pair( + "OLDER EXPERIMENTAL VERSION.\nDO YOU WANT TO EXPERIMENT? \uD83E\uDDEA", + "Current: v$currentVersion\nLatest experimental: v$suggestedVersion\n\n" + + "This is a supported experimental build, but a newer experimental " + + "version is available. Expect quirky app behavior or unidentified" + + " bugs as the patches are refined for this app version." + ) + VersionStatus.TOO_NEW -> Pair( + "DO YOU WANT TO EXPERIMENT? \uD83E\uDDEA", + "Current: v$currentVersion\nNewest known: v$suggestedVersion\n\n" + + "This version has early experimental support\n\n" + + "\uD83D\uDD27 Expect quirky app behavior or unidentified bugs as the " + + "patches are refined for this app version." + ) + VersionStatus.TOO_OLD -> Pair( + "VERSION TOO OLD", + "Current: v$currentVersion\nOldest supported: v$suggestedVersion\n\n" + + "This isn't an officially supported version. Patches will most likely fail." + ) + VersionStatus.UNSUPPORTED_BETWEEN -> Pair( + "UNSUPPORTED VERSION", + "Current: v$currentVersion\n\n" + + "This isn't an officially supported version. Patches will most likely fail." + ) + else -> Pair("VERSION NOTICE", "Continue with v$currentVersion?") + } + + val isHardError = versionStatus == VersionStatus.TOO_OLD || + versionStatus == VersionStatus.UNSUPPORTED_BETWEEN + val colorType = if (isHardError) StatusColorType.ERROR else StatusColorType.WARNING + + return VersionWarningContent(title, message, colorType) +} diff --git a/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt b/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt new file mode 100644 index 0000000..14cfa5f --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt @@ -0,0 +1,119 @@ +package app.morphe.gui.util + +import app.morphe.gui.data.model.SupportedApp + +/** + * The "bucket" an APK's version falls into relative to a [SupportedApp]'s + * stable + experimental version lists. + */ +enum class VersionStatus { + /** Current version is the latest stable. Happy path. */ + LATEST_STABLE, + + /** In the stable list but older than the latest stable. */ + OLDER_STABLE, + + /** Current version is the latest experimental. */ + LATEST_EXPERIMENTAL, + + /** In the experimental list but older than the latest experimental. */ + OLDER_EXPERIMENTAL, + + /** Newer than every known version (stable + experimental). */ + TOO_NEW, + + /** Older than every known stable version. */ + TOO_OLD, + + /** Between supported versions but not in either list. */ + UNSUPPORTED_BETWEEN, + + /** No patch metadata, can't determine. */ + UNKNOWN +} + +/** + * The result of resolving a current APK version against a [SupportedApp]. + * + * @param status which bucket the current version falls into. + * @param suggestedVersion the version most relevant to surface in UI for this + * status — e.g. the latest stable for [VersionStatus.OLDER_STABLE], the + * latest experimental for [VersionStatus.OLDER_EXPERIMENTAL], the newest + * known version for [VersionStatus.TOO_NEW], etc. + */ +data class VersionResolution( + val status: VersionStatus, + val suggestedVersion: String? +) + +/** + * Numeric comparator for dotted version strings (e.g. "20.40.45" vs "21.01.23"). + * Returns negative if v1 < v2, 0 if equal, positive if v1 > v2. + * Returns 0 if either string can't be parsed. + */ +fun compareVersionStrings(v1: String, v2: String): Int { + return try { + val p1 = v1.split(".").map { it.toIntOrNull() ?: 0 } + val p2 = v2.split(".").map { it.toIntOrNull() ?: 0 } + for (i in 0 until maxOf(p1.size, p2.size)) { + val a = p1.getOrElse(i) { 0 } + val b = p2.getOrElse(i) { 0 } + if (a != b) return a.compareTo(b) + } + 0 + } catch (e: Exception) { + Logger.warn("Failed to compare versions: $v1 vs $v2") + 0 + } +} + +/** + * Determine the status of [currentVersion] relative to the stable and + * experimental versions known for [app]. + */ +fun resolveVersionStatus(currentVersion: String, app: SupportedApp): VersionResolution { + val stableList = app.supportedVersions + val experimentalList = app.experimentalVersions + + val latestStable = stableList.firstOrNull() + val oldestStable = stableList.lastOrNull() + val latestExperimental = experimentalList.firstOrNull() + + if (latestStable == null && latestExperimental == null) { + return VersionResolution(VersionStatus.UNKNOWN, null) + } + + // Exact matches in either bucket + if (latestStable != null && currentVersion == latestStable) { + return VersionResolution(VersionStatus.LATEST_STABLE, latestStable) + } + if (latestExperimental != null && currentVersion == latestExperimental) { + return VersionResolution(VersionStatus.LATEST_EXPERIMENTAL, latestExperimental) + } + if (currentVersion in stableList) { + return VersionResolution(VersionStatus.OLDER_STABLE, latestStable) + } + if (currentVersion in experimentalList) { + return VersionResolution(VersionStatus.OLDER_EXPERIMENTAL, latestExperimental) + } + + // Not in either list — figure out where it sits relative to known range. + val newestKnown = when { + latestStable == null -> latestExperimental + latestExperimental == null -> latestStable + compareVersionStrings(latestStable, latestExperimental) >= 0 -> latestStable + else -> latestExperimental + } + + if (newestKnown != null && compareVersionStrings(currentVersion, newestKnown) > 0) { + return VersionResolution(VersionStatus.TOO_NEW, newestKnown) + } + if (oldestStable != null && compareVersionStrings(currentVersion, oldestStable) < 0) { + return VersionResolution(VersionStatus.TOO_OLD, oldestStable) + } + + return VersionResolution( + VersionStatus.UNSUPPORTED_BETWEEN, + latestStable ?: latestExperimental + ) +} diff --git a/src/main/kotlin/app/morphe/gui/util/WindowTitleBarTint.kt b/src/main/kotlin/app/morphe/gui/util/WindowTitleBarTint.kt new file mode 100644 index 0000000..138d553 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/WindowTitleBarTint.kt @@ -0,0 +1,120 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.util + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import com.sun.jna.Native +import com.sun.jna.platform.win32.WinDef +import com.sun.jna.ptr.IntByReference +import com.sun.jna.win32.StdCallLibrary +import java.awt.Window + +/** + * Cross-platform title bar tinting helpers. + * + * - **macOS**: uses Apple's `apple.awt.appearance` AWT property to switch the + * traffic light contrast between light and dark. Available on every macOS + * JDK (no JBR required). The actual title bar fill is rendered by Compose + * in `App.kt` (a 28dp coloured band sitting under the transparent OS title + * bar), so this only affects the traffic light icons themselves. + * + * - **Windows**: uses JNA to call `DwmSetWindowAttribute` with two attributes: + * - `DWMWA_USE_IMMERSIVE_DARK_MODE` (Win 10 build 19041+) — binary dark/light. + * - `DWMWA_CAPTION_COLOR` (Win 11 build 22000+) — arbitrary RGB caption fill. + * On older builds these calls silently no-op. + * + * - **Linux**: window manager owns the title bar; nothing we can do portably. + */ + +private interface Dwmapi : StdCallLibrary { + fun DwmSetWindowAttribute( + hwnd: WinDef.HWND, + dwAttribute: Int, + pvAttribute: IntByReference, + cbAttribute: Int + ): Int + + companion object { + const val DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + const val DWMWA_CAPTION_COLOR = 35 + + val INSTANCE: Dwmapi? by lazy { + try { + Native.load("dwmapi", Dwmapi::class.java) + } catch (_: Throwable) { + null + } + } + } +} + +private val isMac: Boolean by lazy { + System.getProperty("os.name")?.lowercase()?.contains("mac") == true +} + +private val isWindows: Boolean by lazy { + System.getProperty("os.name")?.lowercase()?.contains("win") == true +} + +/** + * Apply the current theme's title bar colour to the OS window. + * + * On macOS, this only switches the traffic light contrast (the band is drawn + * by Compose). On Windows, this sets the actual OS-drawn caption fill colour. + * On Linux, this is a no-op. + */ +fun applyTitleBarTint(window: Window, color: Color) { + val isDark = color.luminance() < 0.5f + when { + isMac -> applyMacOSAppearance(window, isDark) + isWindows -> applyWindowsCaptionColor(window, color, isDark) + } +} + +private fun applyMacOSAppearance(window: Window, isDark: Boolean) { + try { + val rootPane = (window as? javax.swing.JFrame)?.rootPane ?: return + rootPane.putClientProperty( + "apple.awt.appearance", + if (isDark) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua" + ) + } catch (_: Throwable) { + // Ignore — older JDKs may not support this property. + } +} + +private fun applyWindowsCaptionColor(window: Window, color: Color, isDark: Boolean) { + val dwm = Dwmapi.INSTANCE ?: return + try { + val pointer = Native.getComponentPointer(window) ?: return + val hwnd = WinDef.HWND(pointer) + + // Always set the dark mode hint as a fallback for builds where + // DWMWA_CAPTION_COLOR isn't supported (Win 10, early Win 11). + dwm.DwmSetWindowAttribute( + hwnd, + Dwmapi.DWMWA_USE_IMMERSIVE_DARK_MODE, + IntByReference(if (isDark) 1 else 0), + 4 + ) + + // Try arbitrary caption colour (Win 11 22H2+, build 22000+). + // COLORREF is little-endian: 0x00BBGGRR. + val r = (color.red * 255f).toInt().coerceIn(0, 255) + val g = (color.green * 255f).toInt().coerceIn(0, 255) + val b = (color.blue * 255f).toInt().coerceIn(0, 255) + val colorref = (b shl 16) or (g shl 8) or r + dwm.DwmSetWindowAttribute( + hwnd, + Dwmapi.DWMWA_CAPTION_COLOR, + IntByReference(colorref), + 4 + ) + } catch (_: Throwable) { + // Ignore — DWM call failures should not crash the app. + } +} diff --git a/src/main/resources/fonts/JetBrainsMono-Bold.ttf b/src/main/resources/fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..8c93043 Binary files /dev/null and b/src/main/resources/fonts/JetBrainsMono-Bold.ttf differ diff --git a/src/main/resources/fonts/JetBrainsMono-Light.ttf b/src/main/resources/fonts/JetBrainsMono-Light.ttf new file mode 100644 index 0000000..15f15a2 Binary files /dev/null and b/src/main/resources/fonts/JetBrainsMono-Light.ttf differ diff --git a/src/main/resources/fonts/JetBrainsMono-Medium.ttf b/src/main/resources/fonts/JetBrainsMono-Medium.ttf new file mode 100644 index 0000000..9767115 Binary files /dev/null and b/src/main/resources/fonts/JetBrainsMono-Medium.ttf differ diff --git a/src/main/resources/fonts/JetBrainsMono-Regular.ttf b/src/main/resources/fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..dff66cc Binary files /dev/null and b/src/main/resources/fonts/JetBrainsMono-Regular.ttf differ diff --git a/src/main/resources/fonts/JetBrainsMono-SemiBold.ttf b/src/main/resources/fonts/JetBrainsMono-SemiBold.ttf new file mode 100644 index 0000000..a70e69b Binary files /dev/null and b/src/main/resources/fonts/JetBrainsMono-SemiBold.ttf differ diff --git a/src/main/resources/fonts/Nunito-Bold.ttf b/src/main/resources/fonts/Nunito-Bold.ttf new file mode 100644 index 0000000..063f39a Binary files /dev/null and b/src/main/resources/fonts/Nunito-Bold.ttf differ diff --git a/src/main/resources/fonts/Nunito-Light.ttf b/src/main/resources/fonts/Nunito-Light.ttf new file mode 100644 index 0000000..9116ac3 Binary files /dev/null and b/src/main/resources/fonts/Nunito-Light.ttf differ diff --git a/src/main/resources/fonts/Nunito-Medium.ttf b/src/main/resources/fonts/Nunito-Medium.ttf new file mode 100644 index 0000000..dd75bae Binary files /dev/null and b/src/main/resources/fonts/Nunito-Medium.ttf differ diff --git a/src/main/resources/fonts/Nunito-Regular.ttf b/src/main/resources/fonts/Nunito-Regular.ttf new file mode 100644 index 0000000..6401d72 Binary files /dev/null and b/src/main/resources/fonts/Nunito-Regular.ttf differ diff --git a/src/main/resources/fonts/Nunito-SemiBold.ttf b/src/main/resources/fonts/Nunito-SemiBold.ttf new file mode 100644 index 0000000..69fdf83 Binary files /dev/null and b/src/main/resources/fonts/Nunito-SemiBold.ttf differ