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..85a2caf 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,9 @@ dependencies { implementation(libs.voyager.koin) implementation(libs.voyager.transitions) + // -- JBR API (macOS title bar customization) ---------------------------- + implementation(libs.jbr.api) + // -- APK Parsing (GUI) ------------------------------------------------- implementation(libs.apk.parser) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f5e901..95f6397 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" +# JBR +jbr-api = "1.5.0" + # APK apk-parser = "2.6.10" @@ -65,6 +68,9 @@ kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines- # Serialization kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } +# JBR +jbr-api = { module = "org.jetbrains.runtime:jbr-api", version.ref = "jbr-api" } + # APK apk-parser = { module = "net.dongliu:apk-parser", version.ref = "apk-parser" } diff --git a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt index 7320a4e..682f363 100644 --- a/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt +++ b/src/main/kotlin/app/morphe/cli/command/OptionsCommand.kt @@ -16,7 +16,7 @@ import java.util.logging.Logger @Command( name = "options-create", - description = ["Create an options JSON file for the patches and options."], + description = ["Create an options JSON file for the patches and options."] , ) internal object OptionsCommand : Callable { diff --git a/src/main/kotlin/app/morphe/gui/App.kt b/src/main/kotlin/app/morphe/gui/App.kt index 7bbca70..140000c 100644 --- a/src/main/kotlin/app/morphe/gui/App.kt +++ b/src/main/kotlin/app/morphe/gui/App.kt @@ -6,15 +6,21 @@ package app.morphe.gui import androidx.compose.animation.Crossfade -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.* 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.LocalTitleBarInsets +import app.morphe.gui.ui.components.LottieAnimation +import app.morphe.gui.ui.components.SakuraPetals +import app.morphe.gui.ui.components.TitleBarInsets 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 +28,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 @@ -57,16 +64,16 @@ fun App(initialSimplifiedMode: Boolean = true) { @Composable 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 @@ -109,27 +116,75 @@ private fun AppContent(initialSimplifiedMode: Boolean) { } } + val titleBarInsets = remember { + val isMac = System.getProperty("os.name")?.lowercase()?.contains("mac") == true + if (isMac) TitleBarInsets(start = 80.dp, top = 0.dp) + else TitleBarInsets() + } + MorpheTheme(themePreference = themePreference) { CompositionLocalProvider( LocalThemeState provides themeState, - LocalModeState provides modeState + LocalModeState provides modeState, + LocalTitleBarInsets provides titleBarInsets ) { 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) + Box(modifier = Modifier.fillMaxSize()) { + 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) + } + } + } } - Crossfade(targetState = isSimplifiedMode) { simplified -> - if (simplified) { - // Quick/Simplified mode - QuickPatchContent(quickViewModel) - } else { - // Full mode - 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..e334acf 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 @@ -63,7 +65,36 @@ fun launchGui(args: Array) = application { icon = appIcon ) { window.minimumSize = java.awt.Dimension(600, 400) - App(initialSimplifiedMode = initialSimplifiedMode) + + // macOS: transparent title bar with expanded height so traffic lights + // align with our header row content. Uses JetBrains Runtime custom title bar API. + // Other OS: standard decorated window (no-op). + 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) + + // JBR: expand the title bar so traffic lights center with our header row. + // Height ~= header top padding (26dp) + half content height (~20dp) + buffer + // → traffic lights center vertically with our header icons/text. + try { + val decorations = com.jetbrains.JBR.getWindowDecorations() + val titleBar = decorations.createCustomTitleBar() + titleBar.height = 56f + titleBar.putProperty("controls.visible", true) + decorations.setCustomTitleBar(window, titleBar) + } catch (_: Exception) { + // Not running on JBR — traffic lights stay at default position + } + } + true + } + + CompositionLocalProvider(LocalFrameWindowScope provides this) { + App(initialSimplifiedMode = initialSimplifiedMode) + } } } 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..765a879 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/AppConfig.kt @@ -11,6 +11,15 @@ 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 +28,9 @@ 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" ) { fun getThemePreference(): ThemePreference { return try { @@ -38,6 +49,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..cb02d34 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/Patch.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/Patch.kt @@ -71,7 +71,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..3c16a0d 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 ) { @@ -62,6 +64,11 @@ data class ReleaseAsset( */ fun isMpp(): Boolean = name.endsWith(".mpp", ignoreCase = true) + /** + * Check if this is a patch file (.mpp or .jar) + */ + fun isPatchFile(): Boolean = isMpp() || isJar() + /** * 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..923bc3a 100644 --- a/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt +++ b/src/main/kotlin/app/morphe/gui/data/model/SupportedApp.kt @@ -23,16 +23,57 @@ data class SupportedApp( * 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", + "com.duolingo" to "Duolingo", + "com.myfitnesspal.android" to "MyFitnessPal", + "com.pandora.android" to "Pandora", + "ch.protonvpn.android" to "ProtonVPN", + "com.amazon.avod.thirdpartyclient" to "Prime Video", + "com.getmimo" to "Mimo", + "com.zombodroid.MemeGenerator" to "Meme Generator", + "com.sofascore.results" to "SofaScore", + "pl.solidexplorer2" to "Solid Explorer", + "com.bambuna.podcastaddict" to "Podcast Addict", + "com.wallpaperscraft.wallpaper" to "WallpapersCraft", + "cn.wps.moffice_eng" to "WPS Office", + "com.merriamwebster" to "Merriam-Webster", + "com.busuu.android.enc" to "Busuu", + "jp.ne.ibis.ibispaintx.app" to "ibisPaint X", + "com.laurencedawson.reddit_sync" to "Sync for Reddit", + "com.laurencedawson.reddit_sync.pro" to "Sync for Reddit Pro", + "com.laurencedawson.reddit_sync.dev" to "Sync for Reddit Dev", + "com.andrewshu.android.reddit" to "Reddit is Fun", + "free.reddit.news" to "Relay for Reddit", + "reddit.news" to "Relay for Reddit Pro", + "com.rubenmayayo.reddit" to "Boost for Reddit", + "o.o.joey" to "Joey for Reddit", + "o.o.joey.pro" to "Joey for Reddit Pro", + "o.o.joey.dev" to "Joey for Reddit Dev", + "com.onelouder.baconreader" to "BaconReader", + "com.onelouder.baconreader.premium" to "BaconReader Premium", + "me.edgan.redditslide" to "Slide for Reddit", + "io.syncapps.lemmy_sync" to "Sync for Lemmy", + "org.cygnusx1.continuum" to "Continuum for 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..9081142 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,64 @@ class ConfigRepository { saveConfig(current.copy(useSimplifiedMode = enabled)) } + /** + * 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..80d8e1e 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,29 @@ class PatchRepository( } /** - * Find the .mpp asset in a release. + * Find the patch asset (.mpp or .jar) in a release. */ - fun findMppAsset(release: Release): ReleaseAsset? { - return release.assets.find { it.isMpp() } + fun findPatchAsset(release: Release): ReleaseAsset? { + // Prefer .mpp, fall back to .jar + val asset = release.assets.find { it.isMpp() } + ?: release.assets.find { it.isJar() } + return asset } /** - * Download the .mpp patch file from a release. + * Download the patch file (.mpp or .jar) 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 patch file (.mpp or .jar) 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 +183,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) || name.endsWith(".jar", 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 +217,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 +231,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..26fc86a 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,15 @@ package app.morphe.gui.ui.components +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown @@ -24,54 +30,72 @@ 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.LocalMorpheFont +import app.morphe.gui.ui.theme.LocalMorpheCorners import app.morphe.gui.ui.theme.MorpheColors 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 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 -> MorpheColors.Teal + unauthorizedDevices.isNotEmpty() -> Color(0xFFFF9800) + 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 -> MorpheColors.Teal.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 +104,36 @@ 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) + 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 } ) { when { isAdbAvailable == false -> { - // ADB not found DropdownMenuItem( text = { Row( @@ -120,20 +143,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 +168,6 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { } monitorState.devices.isEmpty() -> { - // ADB available but no devices visible DropdownMenuItem( text = { Row( @@ -153,20 +177,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 +209,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 = MorpheColors.Blue.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, + fontFamily = mono, color = MorpheColors.Blue ) 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 +234,6 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { } else -> { - // Device list monitorState.devices.forEach { device -> val isSelected = device.id == selectedDevice?.id DropdownMenuItem( @@ -215,31 +242,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 -> MorpheColors.Teal + device.isReady -> MorpheColors.Blue + device.status == DeviceStatus.UNAUTHORIZED -> Color(0xFFFF9800) + 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,7 +279,8 @@ 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) @@ -269,7 +300,6 @@ fun DeviceIndicator(modifier: Modifier = Modifier) { ) } - // USB debugging hint HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) DropdownMenuItem( text = { @@ -280,19 +310,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/DraggableHeaderArea.kt b/src/main/kotlin/app/morphe/gui/ui/components/DraggableHeaderArea.kt new file mode 100644 index 0000000..da83667 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/DraggableHeaderArea.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2026 Morphe. + * https://github.com/MorpheApp/morphe-cli + */ + +package app.morphe.gui.ui.components + +import androidx.compose.foundation.window.WindowDraggableArea +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier + +/** + * Wraps content in a WindowDraggableArea on macOS so the header row + * can be used to drag the window. Interactive children (buttons, etc.) + * still receive clicks normally — only drags on empty space move the window. + * On non-macOS or when FrameWindowScope is unavailable, renders content directly. + */ +@Composable +fun DraggableHeaderArea( + modifier: Modifier = Modifier, + content: @Composable () -> Unit +) { + val frameScope = LocalFrameWindowScope.current + if (frameScope != null) { + with(frameScope) { + WindowDraggableArea(modifier = modifier) { + content() + } + } + } else { + content() + } +} 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..39c6691 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,92 @@ 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.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("") } - // Load config when dialog is shown LaunchedEffect(showSettingsDialog) { if (showSettingsDialog) { val config = configRepository.loadConfig() autoCleanupTempFiles = config.autoCleanupTempFiles + patchSources = config.patchSource + activePatchSourceId = config.activePatchSourceId } } - 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 +109,66 @@ 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() + } ) } } -/** - * 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..71ba3af 100644 --- a/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt +++ b/src/main/kotlin/app/morphe/gui/ui/components/SettingsDialog.kt @@ -6,15 +6,18 @@ 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.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -22,15 +25,23 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp 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.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 java.awt.Desktop +import java.awt.FileDialog +import java.awt.Frame import java.io.File +import java.util.UUID @Composable fun SettingsDialog( @@ -41,140 +52,156 @@ 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 = {} ) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.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 -> 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 = MorpheColors.Blue, + 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 = MorpheColors.Teal, + mono = mono, + enabled = !isPatching + ) - HorizontalDivider() + SettingsDivider(borderColor) - // Actions - Text( - text = "Actions", - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface + // ── Patch Sources ── + PatchSourcesSection( + sources = patchSources, + activeSourceId = activePatchSourceId, + onActiveChange = { id -> + onActivePatchSourceChange(id) + onDismiss() + }, + onRemove = onRemovePatchSource, + onEdit = { source -> editingSource = source }, + onAddClick = { showAddSourceDialog = true }, + mono = mono, + borderColor = borderColor, + enabled = !isPatching ) - // Export logs button - OutlinedButton( + 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 +211,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") - } + } + ) + + Spacer(Modifier.height(6.dp)) - // Open app data folder - OutlinedButton( + ActionButton( + label = "OPEN APP DATA", + icon = Icons.Default.FolderOpen, + mono = mono, + borderColor = borderColor, onClick = { try { val appDataDir = FileUtils.getAppDataDir() @@ -208,90 +230,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 + text = "${AppConstants.APP_NAME} v${AppConstants.APP_VERSION}", + 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,21 +327,699 @@ 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 + ) } } ) } + + 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 + } + ) + } +} + +// ── Shared building blocks ── + +@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 SettingsDivider(borderColor: Color) { + Spacer(Modifier.height(14.dp)) + HorizontalDivider(color = borderColor) + Spacer(Modifier.height(14.dp)) +} + +@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) + ) + ) + } +} + +@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, + borderColor: Color, + enabled: Boolean = true +) { + val corners = LocalMorpheCorners.current + val alpha = if (enabled) 1f else 0.4f + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + SectionLabel("PATCH SOURCES", mono) + Spacer(Modifier.height(2.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 -> MorpheColors.Blue.copy(alpha = 0.4f) + isHovered -> MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + else -> borderColor + }, + RoundedCornerShape(corners.medium) + ) + .background( + if (isActive) MorpheColors.Blue.copy(alpha = 0.05f) + 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) MorpheColors.Blue + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f), + RoundedCornerShape(1.dp) + ) + ) + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = source.name, + fontSize = 12.sp, + fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = when (source.type) { + PatchSourceType.DEFAULT -> "Default" + PatchSourceType.GITHUB -> source.url?.removePrefix("https://github.com/") ?: "GitHub" + PatchSourceType.LOCAL -> source.filePath?.let { File(it).name } ?: "Local file" + }, + fontSize = 10.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (source.deletable && enabled) { + IconButton( + onClick = { onEdit(source) }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = "Edit", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.size(14.dp) + ) + } + Spacer(Modifier.width(2.dp)) + IconButton( + onClick = { onRemove(source.id) }, + modifier = Modifier.size(24.dp) + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Remove", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.size(14.dp) + ) + } + } + } + } + Spacer(modifier = Modifier.height(4.dp)) + } + + // Add source + OutlinedButton( + onClick = onAddClick, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(corners.small), + border = BorderStroke(1.dp, borderColor), + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "ADD SOURCE", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 10.sp, + letterSpacing = 0.5.sp + ) + } + } +} + +// ── Add / Edit Source Dialogs ── + +@Composable +private fun AddPatchSourceDialog( + onDismiss: () -> Unit, + onAdd: (PatchSource) -> Unit +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.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) MorpheColors.Blue.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), + RoundedCornerShape(corners.small) + ) + .background( + if (isSelected) MorpheColors.Blue.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) MorpheColors.Blue + 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 = MorpheColors.Blue), + 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 + 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 = MorpheColors.Blue, + 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 = MorpheColors.Blue), + 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 + ) + } + } + ) } private fun ThemePreference.toDisplayName(): String { @@ -322,10 +1027,40 @@ private fun ThemePreference.toDisplayName(): String { 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(0xFFE8729A) + ThemePreference.MATCHA -> Color(0xFF6DAF5C) + 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() } @@ -341,25 +1076,13 @@ private fun calculateCacheSize(): String { private fun clearAllCache(): Boolean { return try { var failedCount = 0 - - // 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}") - } + try { if (!file.deleteRecursively()) throw Exception("Could not delete") } + catch (e: Exception) { failedCount++; Logger.error("Failed to delete ${file.name}: ${e.message}") } } - - // 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}") - } + 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() @@ -375,3 +1098,42 @@ private fun clearAllCache(): Boolean { 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/components/TitleBarInsets.kt b/src/main/kotlin/app/morphe/gui/ui/components/TitleBarInsets.kt new file mode 100644 index 0000000..707d3b6 --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/ui/components/TitleBarInsets.kt @@ -0,0 +1,25 @@ +package app.morphe.gui.ui.components + +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.FrameWindowScope + +/** + * Insets for the title bar region. On macOS with transparent title bar, + * the traffic lights occupy ~80dp on the left and some space from the top. + * Screens should apply these to their header rows so controls don't + * overlap with native window buttons. + */ +data class TitleBarInsets( + val start: androidx.compose.ui.unit.Dp = 0.dp, + val top: androidx.compose.ui.unit.Dp = 0.dp +) + +val LocalTitleBarInsets = compositionLocalOf { TitleBarInsets() } + +/** + * Provides FrameWindowScope so composables deep in the tree can use + * WindowDraggableArea for native window dragging. + */ +val LocalFrameWindowScope = staticCompositionLocalOf { 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..c068436 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,21 +5,34 @@ package app.morphe.gui.ui.screens.home +import androidx.compose.animation.animateColorAsState +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.collectIsHoveredAsState 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.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.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -31,6 +44,8 @@ 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.LocalThemeState import app.morphe.gui.ui.theme.ThemePreference import org.jetbrains.compose.resources.painterResource @@ -39,6 +54,8 @@ import cafe.adriel.voyager.koin.koinScreenModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import app.morphe.gui.data.model.SupportedApp +import app.morphe.gui.ui.components.DraggableHeaderArea +import app.morphe.gui.ui.components.LocalTitleBarInsets import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.screens.home.components.ApkInfoCard import app.morphe.gui.ui.screens.home.components.FullScreenDropZone @@ -47,6 +64,7 @@ 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 java.awt.FileDialog import java.awt.Frame import java.io.File @@ -67,14 +85,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,8 +111,8 @@ 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 @@ -105,7 +120,6 @@ fun HomeScreenContent( // Version warning dialog state var showVersionWarningDialog by remember { mutableStateOf(false) } - // Version warning dialog if (showVersionWarningDialog && uiState.apkInfo != null) { VersionWarningDialog( versionStatus = uiState.apkInfo!!.versionStatus, @@ -128,38 +142,53 @@ fun HomeScreenContent( ) } - val scrollState = rememberScrollState() + val useHorizontalHeader = maxWidth >= 600.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. + val scrollState = rememberScrollState() Column( modifier = Modifier .fillMaxSize() - .verticalScroll(scrollState) - .padding(padding), - verticalArrangement = Arrangement.SpaceBetween, + .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally ) { - // Top group: branding + patches version + middle content - Column(horizontalAlignment = Alignment.CenterHorizontally) { + // ── Header ── + 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) - // 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" - )) - }, + onChangePatchesClick = onChangePatchesClick, isCompact = isCompact, modifier = Modifier .widthIn(max = 400.dp) @@ -167,96 +196,83 @@ fun HomeScreenContent( ) } 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 - ) - } + 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 + .widthIn(max = 400.dp) + .padding(horizontal = if (isCompact) 8.dp else 16.dp) + ) } // Offline banner if (uiState.isOffline && !uiState.isLoadingPatches) { Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 12.dp)) OfflineBanner( - onRetry = { viewModel.retryLoadPatches() }, + onRetry = onRetry, modifier = Modifier .widthIn(max = 400.dp) .padding(horizontal = if (isCompact) 8.dp else 16.dp) ) } + } - Spacer(modifier = Modifier.height(if (isSmall) 16.dp else 32.dp)) - + // ── Main workspace area ── + Box( + modifier = Modifier + .fillMaxWidth() + .padding(padding), + contentAlignment = Alignment.Center + ) { MiddleContent( 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 - } - - 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 - )) - } - } - } + patchesLoaded = patchesLoaded, + onClearClick = onClearClick, + onChangeClick = onChangeClick, + onContinueClick = onContinueClick ) } - // Bottom group: supported apps section + // ── Supported apps ── Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(top = if (isSmall) 16.dp else 24.dp) + 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 = { viewModel.retryLoadPatches() } + onRetry = onRetry ) - Spacer(modifier = Modifier.height(if (isSmall) 8.dp else 16.dp)) } } - // 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) { + val titleInsets = LocalTitleBarInsets.current + TopBarRow( + modifier = Modifier + .align(Alignment.TopEnd) + .padding( + top = padding + titleInsets.top, + end = padding + ), + allowCacheClear = true + ) + } // Snackbar host SnackbarHost( @@ -273,6 +289,228 @@ 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.EXACT_MATCH && 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 titleInsets = LocalTitleBarInsets.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.10f) + + DraggableHeaderArea { + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 1f + ) + } + .padding( + start = 12.dp + titleInsets.start, + end = 12.dp, + top = 8.dp + titleInsets.top, + bottom = 8.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + // Logo — left-aligned, compact + BrandingSection(isCompact = true) + + Spacer(modifier = Modifier.weight(1f)) + + // Patches version inline — centered + if (!uiState.isLoadingPatches && uiState.patchesVersion != null) { + PatchesVersionInline( + patchesVersion = uiState.patchesVersion!!, + isLatest = uiState.isUsingLatestPatches, + onChangePatchesClick = onChangePatchesClick + ) + } else if (uiState.isLoadingPatches) { + PatchesLoadingIndicator() + } else if (uiState.patchLoadError != null) { + PatchesVersionInline( + patchesVersion = "NOT LOADED", + isLatest = false, + onChangePatchesClick = onChangePatchesClick + ) + } + + // Offline badge + if (uiState.isOffline && !uiState.isLoadingPatches) { + Spacer(modifier = Modifier.width(12.dp)) + OfflineBadge(onRetry = onRetry) + } + + Spacer(modifier = Modifier.weight(1f)) + + // Device indicator + settings — inline in the header + TopBarRow(allowCacheClear = true) + } + } +} + +/** + * Inline patches version for the header bar — compact, horizontal. + */ +@Composable +private fun PatchesVersionInline( + patchesVersion: String, + isLatest: Boolean, + onChangePatchesClick: () -> Unit +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val borderColor by animateColorAsState( + if (isHovered) MorpheColors.Blue.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.outline.copy(alpha = 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 = "PATCHES", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = patchesVersion, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MorpheColors.Blue + ) + if (isLatest) { + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = Modifier + .background(MorpheColors.Teal.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) + .border(1.dp, MorpheColors.Teal.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 = MorpheColors.Teal, + 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 = MorpheColors.Blue + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Loading patches…", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 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 +544,109 @@ 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 bracketColor = if (isDragHovering) MorpheColors.Blue.copy(alpha = 0.7f) + 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) MorpheColors.Blue + 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 = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 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, MorpheColors.Blue.copy(alpha = 0.4f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MorpheColors.Blue) + ) { + 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 = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f), + letterSpacing = 0.5.sp + ) + } + } + } +} + +// ════════════════════════════════════════════════════════════════════ +// APK SELECTED — Info card + action buttons +// ════════════════════════════════════════════════════════════════════ + @Composable private fun ApkSelectedSection( patchesLoaded: Boolean, @@ -315,6 +656,8 @@ private fun ApkSelectedSection( onChangeClick: () -> Unit, onContinueClick: () -> Unit ) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current val showWarning = apkInfo.versionStatus != VersionStatus.EXACT_MATCH && apkInfo.versionStatus != VersionStatus.UNKNOWN val warningColor = when (apkInfo.versionStatus) { @@ -322,6 +665,7 @@ private fun ApkSelectedSection( VersionStatus.OLDER_VERSION -> Color(0xFFFF9800) else -> MorpheColors.Blue } + val primaryColor = if (showWarning) warningColor else MorpheColors.Blue Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -333,127 +677,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 +745,103 @@ 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 + 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), + modifier = Modifier.size(if (isCompact) 28.dp else 32.dp), color = MorpheColors.Blue, - strokeWidth = 3.dp + 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 useVerticalLayout = maxWidth < 400.dp Column( @@ -652,19 +850,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 = MorpheColors.Cyan.copy(alpha = 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 = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), textAlign = TextAlign.Center, modifier = Modifier .widthIn(max = if (useVerticalLayout) 280.dp else 500.dp) @@ -675,91 +876,188 @@ 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 = MorpheColors.Cyan, + 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 = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 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 = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 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 = MorpheColors.Cyan + ), + border = BorderStroke(1.dp, MorpheColors.Cyan.copy(alpha = 0.4f)) ) { - 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 = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) ) } else -> { - // Display supported apps dynamically - if (useVerticalLayout) { + 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) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { + Text( + "Filter apps…", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f) + ) + }, + leadingIcon = { + Icon( + Icons.Default.Search, + contentDescription = null, + tint = MorpheColors.Cyan.copy(alpha = 0.5f), + modifier = Modifier.size(16.dp) + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + Icons.Default.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 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 = MorpheColors.Cyan.copy(alpha = 0.5f), + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f), + cursorColor = MorpheColors.Cyan + ) + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + val cardsMinHeight = if (useVerticalLayout) 120.dp else 80.dp + + if (filteredApps.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = cardsMinHeight), + contentAlignment = Alignment.Center + ) { + Text( + text = "No matching apps", + fontSize = 11.sp, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + ) + } + } else if (useVerticalLayout) { Column( - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .padding(horizontal = 16.dp) .widthIn(max = 300.dp) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { focusManager.clearFocus() } ) { - supportedApps.forEach { app -> + filteredApps.forEach { app -> SupportedAppCardDynamic( supportedApp = app, isCompact = isCompact, + showDownloadButton = isDefaultSource, + showPackageName = !isDefaultSource, modifier = Modifier.fillMaxWidth() ) } } } else { Row( - horizontalArrangement = Arrangement.spacedBy(if (isCompact) 12.dp else 16.dp), + horizontalArrangement = Arrangement.spacedBy(if (isCompact) 6.dp else 8.dp), verticalAlignment = Alignment.Top, modifier = Modifier .padding(horizontal = if (isCompact) 8.dp else 16.dp) - .widthIn(max = 700.dp) + .horizontalScroll(rememberScrollState()) + .height(IntrinsicSize.Max) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { focusManager.clearFocus() } ) { - supportedApps.forEach { app -> + filteredApps.forEach { app -> SupportedAppCardDynamic( supportedApp = app, isCompact = isCompact, - modifier = Modifier.weight(1f) + showDownloadButton = isDefaultSource, + showPackageName = !isDefaultSource, + modifier = Modifier.width(190.dp).fillMaxHeight() ) } } @@ -769,9 +1067,24 @@ private fun SupportedAppsSection( } } -/** - * 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 PatchesVersionCard( patchesVersion: String, @@ -780,52 +1093,63 @@ private fun PatchesVersionCard( isCompact: Boolean = false, modifier: Modifier = Modifier ) { - Card( + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val borderColor by animateColorAsState( + if (isHovered) MorpheColors.Blue.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), + animationSpec = tween(200) + ) + + Box( modifier = modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onChangePatchesClick), - colors = CardDefaults.cardColors( - containerColor = MorpheColors.Blue.copy(alpha = 0.1f) - ), - shape = RoundedCornerShape(12.dp) + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + .hoverable(hoverInteraction) + .clickable(onClick = onChangePatchesClick) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = if (isCompact) 10.dp else 12.dp), + .padding(horizontal = 16.dp, vertical = if (isCompact) 8.dp else 10.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Using patches", + text = "PATCHES", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = patchesVersion, fontSize = if (isCompact) 12.sp else 13.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MorpheColors.Blue ) - Spacer(modifier = Modifier.width(8.dp)) - Surface( - color = MorpheColors.Blue.copy(alpha = 0.2f), - shape = RoundedCornerShape(4.dp) - ) { - Text( - text = patchesVersion, - fontSize = if (isCompact) 11.sp else 12.sp, - fontWeight = FontWeight.SemiBold, - color = MorpheColors.Blue, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) - ) - } if (isLatest) { - Spacer(modifier = Modifier.width(6.dp)) - Surface( - color = MorpheColors.Teal.copy(alpha = 0.2f), - shape = RoundedCornerShape(4.dp) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .background(MorpheColors.Teal.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) + .border(1.dp, MorpheColors.Teal.copy(alpha = 0.2f), RoundedCornerShape(corners.small)) + .padding(horizontal = 6.dp, vertical = 2.dp) ) { Text( - text = "Latest", - fontSize = if (isCompact) 9.sp else 10.sp, + text = "LATEST", + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, color = MorpheColors.Teal, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + letterSpacing = 1.sp ) } } @@ -833,137 +1157,262 @@ private fun PatchesVersionCard( } } +@Composable +private fun VersionWarningDialog( + versionStatus: VersionStatus, + currentVersion: String, + suggestedVersion: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val warnColor = if (versionStatus == VersionStatus.NEWER_VERSION) + MaterialTheme.colorScheme.error else Color(0xFFFF9800) + + val (title, message) = when (versionStatus) { + VersionStatus.NEWER_VERSION -> Pair( + "VERSION MISMATCH", + "Current: v$currentVersion\nExpected: v$suggestedVersion\n\nPatching newer versions may cause failures or broken patches." + ) + VersionStatus.OLDER_VERSION -> Pair( + "OUTDATED VERSION", + "Current: v$currentVersion\nLatest patches target: v$suggestedVersion\n\nYou may be missing new features and fixes." + ) + else -> Pair("VERSION NOTICE", "Continue with v$currentVersion?") + } + + 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 = title, + fontWeight = FontWeight.Bold, + fontFamily = mono, + fontSize = 14.sp, + letterSpacing = 1.sp + ) + }, + 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( + "CONTINUE ANYWAY", + 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 + ) + } + } + ) +} + /** - * Dynamic supported app card that uses SupportedApp data from patches. + * Supported app card — sharp, technical, cyberdeck aesthetic. */ @Composable private fun SupportedAppCardDynamic( supportedApp: SupportedApp, isCompact: Boolean = false, + showDownloadButton: Boolean = true, + showPackageName: Boolean = false, modifier: Modifier = Modifier ) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current var showAllVersions by remember { mutableStateOf(false) } - val cardPadding = if (isCompact) 12.dp else 16.dp - val downloadUrl = supportedApp.apkDownloadUrl + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val borderColor by animateColorAsState( + if (isHovered) MorpheColors.Cyan.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), + animationSpec = tween(200) + ) - Card( - modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ), - shape = RoundedCornerShape(if (isCompact) 12.dp else 16.dp) + Box( + modifier = modifier + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, borderColor, RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surface) + .hoverable(hoverInteraction) ) { Column( modifier = Modifier .fillMaxWidth() - .padding(cardPadding), + .padding(if (isCompact) 12.dp else 14.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 + fontSize = if (isCompact) 13.sp else 14.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - Spacer(modifier = Modifier.height(if (isCompact) 6.dp else 8.dp)) + Text( + text = supportedApp.packageName, + fontSize = 9.sp, + fontFamily = mono, + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + letterSpacing = 0.3.sp + ) + + Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 10.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), + Column( modifier = Modifier - .clip(RoundedCornerShape(cornerRadius)) + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .background(MorpheColors.Teal.copy(alpha = 0.06f)) + .border( + 1.dp, + MorpheColors.Teal.copy(alpha = 0.15f), + RoundedCornerShape(corners.medium) + ) .clickable { showAllVersions = !showAllVersions } + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - 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 = "Recommended", - fontSize = if (isCompact) 9.sp else 10.sp, - color = MorpheColors.Teal.copy(alpha = 0.8f), - letterSpacing = 0.5.sp - ) + Text( + text = "RECOMMENDED", + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MorpheColors.Teal.copy(alpha = 0.6f), + letterSpacing = 1.5.sp + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "v${supportedApp.recommendedVersion}", + fontSize = if (isCompact) 13.sp else 14.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MorpheColors.Teal + ) + val otherVersionsCount = supportedApp.supportedVersions.count { it != supportedApp.recommendedVersion } + if (otherVersionsCount > 0) { + Spacer(modifier = Modifier.height(2.dp)) Text( - text = "v${supportedApp.recommendedVersion}", - fontSize = if (isCompact) 12.sp else 14.sp, - fontWeight = FontWeight.SemiBold, - color = MorpheColors.Teal + text = if (showAllVersions) "hide ${otherVersionsCount} more" else "+${otherVersionsCount} compatible", + fontSize = 9.sp, + fontFamily = mono, + color = MorpheColors.Teal.copy(alpha = 0.4f) ) - // 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(6.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.1f), RoundedCornerShape(corners.medium)) + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Column( - modifier = Modifier.padding(8.dp), - horizontalAlignment = Alignment.CenterHorizontally + Text( + text = "ALSO SUPPORTED", + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f), + letterSpacing = 1.sp + ) + @OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() ) { - Text( - text = "Other supported versions:", - fontSize = 9.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(4.dp)) - // Show versions in a compact grid-like format - val versionsText = otherVersions.joinToString(", ") { "v$it" } - Text( - text = versionsText, - fontSize = 10.sp, - color = MaterialTheme.colorScheme.onSurface, - textAlign = TextAlign.Center, - lineHeight = 14.sp - ) + otherVersions.forEach { version -> + Box( + modifier = Modifier + .border( + 1.dp, + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.12f), + RoundedCornerShape(corners.small) + ) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = "v$version", + fontSize = 10.sp, + fontFamily = mono, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + ) + } + } } } } } else { - // No specific version recommended - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(if (isCompact) 6.dp else 8.dp) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)) + .padding(horizontal = 10.dp, vertical = 8.dp), + contentAlignment = Alignment.Center ) { 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 = "ANY VERSION", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + letterSpacing = 1.sp ) } } - Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 12.dp)) - - // Download from APKMirror button (only if URL is configured) - if (downloadUrl != null) { + if (showDownloadButton && downloadUrl != null) { + Spacer(modifier = Modifier.height(if (isCompact) 8.dp else 10.dp)) val uriHandler = LocalUriHandler.current OutlinedButton( onClick = { @@ -972,71 +1421,79 @@ private fun SupportedAppCardDynamic( } }, 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 - ), + shape = RoundedCornerShape(corners.small), + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 6.dp), colors = ButtonDefaults.outlinedButtonColors( - contentColor = MorpheColors.Blue + contentColor = MorpheColors.Cyan + ), + border = BorderStroke( + 1.dp, + MorpheColors.Cyan.copy(alpha = 0.3f) ) ) { Text( - text = "Download original APK", - fontSize = if (isCompact) 11.sp else 12.sp, - fontWeight = FontWeight.Medium + text = "DOWNLOAD APK", + fontSize = 10.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + letterSpacing = 0.5.sp ) } - - Spacer(modifier = Modifier.height(if (isCompact) 6.dp else 8.dp)) } - - // Package name - Text( - text = supportedApp.packageName, - fontSize = if (isCompact) 9.sp else 10.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - textAlign = TextAlign.Center, - maxLines = 1 - ) } } } +// ════════════════════════════════════════════════════════════════════ +// DRAG OVERLAY +// ════════════════════════════════════════════════════════════════════ + @Composable private fun DragOverlay() { + val mono = LocalMorpheFont.current + val bracketColor = MorpheColors.Blue.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 = MorpheColors.Blue, + letterSpacing = 6.sp + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = ".apk · .apkm · .xapk · .apks", + fontSize = 11.sp, + fontFamily = mono, + color = MorpheColors.Blue.copy(alpha = 0.4f), + letterSpacing = 1.sp + ) } } } @@ -1044,7 +1501,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..a2a8267 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,45 @@ 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 app.morphe.gui.util.compareVersions 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 +74,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 +164,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 } @@ -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,20 +258,26 @@ 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, @@ -306,7 +359,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 +396,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 +418,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,13 +446,13 @@ 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) + ?: packageName // Get recommended version from dynamic patches data (no hardcoded fallback) val suggestedVersion = dynamicSupportedApp?.recommendedVersion @@ -412,8 +465,8 @@ class HomeViewModel( } // 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,6 +525,7 @@ 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 @@ -560,16 +547,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..872df02 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,38 @@ 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.util.VersionStatus +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.util.ChecksumStatus +import app.morphe.gui.util.DeviceMonitor @Composable fun ApkInfoCard( @@ -33,262 +44,252 @@ 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 accentColor = statusAccentColor(apkInfo) + 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() + .padding(start = 3.dp) ) { - // 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(horizontal = 20.dp, vertical = 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) + .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)) - - // 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) - ) + Spacer(Modifier.width(14.dp)) - // 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 = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 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 MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 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(Color(0xFFE65100).copy(alpha = 0.08f)) + .padding(horizontal = 20.dp, vertical = 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 = Color(0xFFE65100) + 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(horizontal = 20.dp, vertical = 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(horizontal = 20.dp, vertical = 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) MorpheColors.Blue.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f) + val tagBg = if (isDeviceArch) MorpheColors.Blue.copy(alpha = 0.08f) + else Color.Transparent + val tagColor = if (isDeviceArch) MorpheColors.Blue + 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 statusInfo = resolveStatus(apkInfo) + if (statusInfo != null) { + StatusBar( + statusInfo = statusInfo, + mono = mono, + borderColor = borderColor ) } } @@ -296,97 +297,160 @@ 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 ── + +private data class StatusInfo( + val color: Color, + val label: String, + val detail: String? = null +) + +@Composable +private fun resolveStatus(apkInfo: ApkInfo): StatusInfo? { + if (apkInfo.suggestedVersion != null && apkInfo.versionStatus != VersionStatus.EXACT_MATCH) { + return when (apkInfo.versionStatus) { + VersionStatus.OLDER_VERSION -> StatusInfo( + color = Color(0xFFFF9800), + label = "OUTDATED", + detail = "Patches target v${apkInfo.suggestedVersion}" + ) + VersionStatus.NEWER_VERSION -> StatusInfo( + color = MaterialTheme.colorScheme.error, + label = "VERSION MISMATCH", + detail = "Expected v${apkInfo.suggestedVersion} — patching may fail" + ) + else -> StatusInfo( + color = MaterialTheme.colorScheme.onSurfaceVariant, + label = "UNVERIFIED", + detail = "Suggested: v${apkInfo.suggestedVersion}" + ) + } + } + + if (apkInfo.versionStatus == VersionStatus.EXACT_MATCH) { + return when (apkInfo.checksumStatus) { + is ChecksumStatus.Verified -> StatusInfo( + color = MorpheColors.Teal, + label = "VERIFIED", + detail = "Checksum matches APKMirror" + ) + is ChecksumStatus.Mismatch -> StatusInfo( + color = MaterialTheme.colorScheme.error, + label = "CHECKSUM MISMATCH", + detail = "File may be corrupted — re-download from APKMirror" + ) + is ChecksumStatus.Error -> StatusInfo( + color = Color(0xFFFF9800), + label = "RECOMMENDED VERSION", + detail = "Checksum verification failed" + ) + is ChecksumStatus.NotConfigured -> StatusInfo( + color = MorpheColors.Teal, + label = "RECOMMENDED VERSION" + ) + is ChecksumStatus.NonRecommendedVersion -> null + } + } + + return null +} + @Composable -private fun VersionStatusBanner( - versionStatus: VersionStatus, - currentVersion: String, - suggestedVersion: String +private fun StatusBar( + statusInfo: StatusInfo, + 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(statusInfo.color.copy(alpha = 0.04f)) + .padding(horizontal = 20.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // Status dot + Box( + modifier = Modifier + .size(6.dp) + .background(statusInfo.color, RoundedCornerShape(1.dp)) ) - else -> Triple( - MaterialTheme.colorScheme.surfaceVariant, - MaterialTheme.colorScheme.onSurfaceVariant, - "Suggested version: v$suggestedVersion" + + Spacer(Modifier.width(10.dp)) + + Text( + text = statusInfo.label, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = statusInfo.color, + letterSpacing = 1.sp ) - } - Surface( - color = backgroundColor, - shape = RoundedCornerShape(8.dp) - ) { - Column( - modifier = Modifier.padding(12.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { + if (statusInfo.detail != null) { + Spacer(Modifier.width(12.dp)) Text( - text = message, - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = textColor, - textAlign = TextAlign.Center + text = statusInfo.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(", ") -//} +@Composable +private fun statusAccentColor(apkInfo: ApkInfo): Color { + if (apkInfo.suggestedVersion != null && apkInfo.versionStatus != VersionStatus.EXACT_MATCH) { + return when (apkInfo.versionStatus) { + VersionStatus.NEWER_VERSION -> MaterialTheme.colorScheme.error + VersionStatus.OLDER_VERSION -> Color(0xFFFF9800) + else -> MorpheColors.Blue + } + } + if (apkInfo.checksumStatus is ChecksumStatus.Mismatch) { + return MaterialTheme.colorScheme.error + } + if (apkInfo.versionStatus == VersionStatus.EXACT_MATCH) { + return MorpheColors.Teal + } + return MorpheColors.Blue +} 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..406fc13 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 @@ -49,15 +49,22 @@ import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import app.morphe.gui.data.model.Patch import org.koin.core.parameter.parametersOf +import app.morphe.gui.ui.components.DraggableHeaderArea +import app.morphe.gui.ui.components.LocalTitleBarInsets 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.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.MorpheColors 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,6 +90,8 @@ data class PatchSelectionScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current val navigator = LocalNavigator.currentOrThrow val uiState by viewModel.uiState.collectAsState() @@ -96,7 +105,6 @@ fun PatchSelectionScreenContent(viewModel: PatchSelectionViewModel) { } } - // Error dialog if (showErrorDialog && currentError != null) { ErrorDialog( title = "Error Loading Patches", @@ -114,220 +122,327 @@ 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 ── + val titleInsets = LocalTitleBarInsets.current + DraggableHeaderArea { + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = dividerColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 1f + ) + } + .padding( + start = 12.dp + titleInsets.start, + end = 12.dp, + top = 8.dp + titleInsets.top, + bottom = 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) MorpheColors.Blue.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) MorpheColors.Blue + else MorpheColors.Blue.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 -> MorpheColors.Teal.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( + MorpheColors.Teal.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) MorpheColors.Teal + 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) { + viewModel.getCommandPreview(cleanMode, continueOnError) + } 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 = MorpheColors.Blue, + 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 + val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState() - else -> { - // Patch list + 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 && + // Architecture selector — disabled for split APK bundles + 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) { @@ -347,43 +462,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 -> MorpheColors.Blue.copy(alpha = 0.1f) + isPatchHovered -> MorpheColors.Blue.copy(alpha = 0.9f) + else -> MorpheColors.Blue + }, + 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 +536,121 @@ 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 + 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) MorpheColors.Blue.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(MorpheColors.Blue), + 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 -> MorpheColors.Blue.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( + MorpheColors.Blue.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) ) { @@ -465,128 +659,501 @@ private fun SearchBar( imageVector = Icons.Default.Check, contentDescription = null, tint = MorpheColors.Blue, - modifier = Modifier.size(16.dp) + 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) MorpheColors.Blue + 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 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 -> MorpheColors.Blue.copy(alpha = 0.4f) + isSelected -> MorpheColors.Blue.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( + MorpheColors.Blue.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) MorpheColors.Blue else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + RoundedCornerShape(corners.small) + ) + .then( + if (isSelected) Modifier.background(MorpheColors.Blue, 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) + 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 -> MorpheColors.Teal.copy(alpha = 0.5f) + isGearHovered -> MorpheColors.Teal.copy(alpha = 0.3f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.12f) + }, + animationSpec = tween(150) + ) + val gearBg by animateColorAsState( + if (showOptions) MorpheColors.Teal.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 -> MorpheColors.Teal + isGearHovered -> MorpheColors.Teal.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(MorpheColors.Teal, 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 + + 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 = MorpheColors.Teal + ) + 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 = MorpheColors.Teal + ) + ) + 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) MorpheColors.Teal.copy(alpha = 0.6f) + else MorpheColors.Teal.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(MorpheColors.Teal), + 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) MorpheColors.Teal.copy(alpha = 0.5f) + else MorpheColors.Teal.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) MorpheColors.Teal else MorpheColors.Teal.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) MorpheColors.Teal.copy(alpha = 0.6f) + else MorpheColors.Teal.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(MorpheColors.Teal), + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { fieldFocused.value = it.isFocused } + ) + } + } + } + } + } +} + +// ── Default Disabled Info Card ── @Composable private fun DefaultDisabledInfoCard( @@ -594,50 +1161,55 @@ private fun DefaultDisabledInfoCard( 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 + + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.small)) + .border( + 1.dp, + MorpheColors.Blue.copy(alpha = 0.15f), + RoundedCornerShape(corners.small) + ) + .background(MorpheColors.Blue.copy(alpha = 0.04f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Row( + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MorpheColors.Blue.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 +1218,15 @@ 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 terminalGreen = MorpheColors.Teal + 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 +1234,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 +1365,118 @@ private fun ArchitectureSelectorCard( onToggleArchitecture: (String) -> Unit, modifier: Modifier = Modifier ) { - // Get connected device architecture for hint + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.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, + MorpheColors.Teal.copy(alpha = 0.15f), + RoundedCornerShape(corners.small) + ) + .background(MorpheColors.Teal.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(MorpheColors.Teal, 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 = MorpheColors.Teal.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 -> MorpheColors.Teal.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( + MorpheColors.Teal.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) MorpheColors.Teal + 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) MorpheColors.Teal + 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..dfcceb2 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 @@ -26,7 +26,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 +169,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). */ @@ -211,6 +234,7 @@ class PatchSelectionViewModel( patchesFilePath = actualPatchesFilePath, enabledPatches = selectedPatchNames, disabledPatches = disabledPatchNames, + patchOptions = _uiState.value.patchOptionValues, useExclusiveMode = true, keepArchitectures = striplibs, continueOnError = continueOnError @@ -312,9 +336,20 @@ class PatchSelectionViewModel( /** * 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 +398,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..edbe117 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 @@ -34,13 +42,17 @@ import cafe.adriel.voyager.navigator.currentOrThrow import app.morphe.gui.data.model.Release import org.koin.core.parameter.parametersOf import cafe.adriel.voyager.koin.koinScreenModel +import app.morphe.gui.ui.components.DraggableHeaderArea +import app.morphe.gui.ui.components.LocalTitleBarInsets import app.morphe.gui.ui.components.ErrorDialog 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.components.OfflineBanner +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 +76,10 @@ 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 var showErrorDialog by remember { mutableStateOf(false) } var currentError by remember { mutableStateOf(null) } @@ -95,110 +109,214 @@ 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 ── + val titleInsets = LocalTitleBarInsets.current + DraggableHeaderArea { + 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( + start = 12.dp + titleInsets.start, + end = 12.dp, + top = 8.dp + titleInsets.top, + bottom = 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) + ) + } + + 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( + if (isRefreshHovered) MorpheColors.Blue.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.outline.copy(alpha = 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)) } - // 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) + 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 = MorpheColors.Blue, + 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, MorpheColors.Blue.copy(alpha = 0.4f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MorpheColors.Blue) + ) { + Text( + "RETRY", + fontFamily = mono, + fontWeight = FontWeight.SemiBold, + fontSize = 11.sp, + letterSpacing = 1.sp + ) } } } } - else -> { // Releases list LazyColumn( @@ -206,7 +324,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 +345,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 +366,10 @@ fun PatchesScreenContent(viewModel: PatchesViewModel) { } } +// ═══════════════════════════════════════════════════════════════════ +// CHANNEL SELECTOR +// ═══════════════════════════════════════════════════════════════════ + @Composable private fun ChannelSelector( selectedChannel: ReleaseChannel, @@ -258,22 +378,26 @@ private fun ChannelSelector( devCount: Int, modifier: Modifier = Modifier ) { + val mono = LocalMorpheFont.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 = MorpheColors.Blue, modifier = Modifier.weight(1f) ) ChannelChip( - label = "Dev", + label = "DEV", count = devCount, isSelected = selectedChannel == ReleaseChannel.DEV, onClick = { onChannelSelected(ReleaseChannel.DEV) }, + accentColor = MorpheColors.Teal, modifier = Modifier.weight(1f) ) } @@ -285,50 +409,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 +485,203 @@ 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 accentColor = when { + isSelected && isDownloaded -> MorpheColors.Teal + isSelected -> MorpheColors.Blue + isDownloaded -> MorpheColors.Teal + 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) + isHovered -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.25f) + isDownloaded -> MorpheColors.Teal.copy(alpha = 0.2f) + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.08f) + }, + animationSpec = tween(150) + ) + + val bgColor = when { + isSelected -> accentColor.copy(alpha = 0.06f) + 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 = if (isSelected) accentColor else MaterialTheme.colorScheme.onSurface + ) + if (release.isDevRelease()) { + Box( + modifier = Modifier + .background(MorpheColors.Teal.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) + .border(1.dp, MorpheColors.Teal.copy(alpha = 0.2f), RoundedCornerShape(corners.small)) + .padding(horizontal = 5.dp, vertical = 1.dp) + ) { + Text( + text = "DEV", + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MorpheColors.Teal, + letterSpacing = 1.sp + ) + } + } + if (isDownloaded) { + Box( + modifier = Modifier + .background(MorpheColors.Teal.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) + .border(1.dp, MorpheColors.Teal.copy(alpha = 0.2f), RoundedCornerShape(corners.small)) + .padding(horizontal = 5.dp, vertical = 1.dp) + ) { + Text( + text = "CACHED", + fontSize = 8.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MorpheColors.Teal, + 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 +691,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 = MorpheColors.Blue.copy(alpha = 0.5f) ) 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 +775,10 @@ private fun cleanMarkdown(text: String): String { return result } +// ════════════════════════════════════════════════════════════════════ +// BOTTOM ACTION BAR +// ════════════════════════════════════════════════════════════════════ + @Composable private fun BottomActionBar( uiState: PatchesUiState, @@ -581,105 +786,187 @@ 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 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 = MorpheColors.Blue, + 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 = MorpheColors.Blue.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 = MorpheColors.Blue), + 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 = MorpheColors.Teal), + 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 = MorpheColors.Blue, + 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, MorpheColors.Blue.copy(alpha = 0.3f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MorpheColors.Blue) ) { 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 + + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(corners.medium)) + .border(1.dp, MorpheColors.Blue.copy(alpha = 0.2f), RoundedCornerShape(corners.medium)) + .background(MorpheColors.Blue.copy(alpha = 0.04f)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + ) { + // Left accent stripe + Box( + modifier = Modifier + .width(3.dp) + .fillMaxHeight() + .background(MorpheColors.Blue) + ) + + Row( + modifier = Modifier.padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Default.FolderOpen, + contentDescription = null, + tint = MorpheColors.Blue, + modifier = Modifier.size(20.dp) + ) + Column { + Text( + text = "LOCAL PATCH FILE", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MorpheColors.Blue, + 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..f720252 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 @@ -30,8 +38,12 @@ import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import app.morphe.gui.data.model.PatchConfig import org.koin.core.parameter.parametersOf +import app.morphe.gui.ui.components.DraggableHeaderArea +import app.morphe.gui.ui.components.LocalTitleBarInsets import app.morphe.gui.ui.components.TopBarRow import app.morphe.gui.ui.screens.result.ResultScreen +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.MorpheColors import app.morphe.gui.util.FileUtils import app.morphe.gui.util.Logger @@ -46,16 +58,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 navigator = LocalNavigator.currentOrThrow val uiState by viewModel.uiState.collectAsState() + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val titleInsets = LocalTitleBarInsets.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.10f) // Auto-start patching when screen loads LaunchedEffect(Unit) { @@ -79,167 +94,247 @@ 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) + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + ) { + // Header row + DraggableHeaderArea { + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 1f ) } - }, - navigationIcon = { - IconButton( - onClick = { navigator.pop() }, - enabled = !uiState.isInProgress + .padding( + start = 12.dp + titleInsets.start, + end = 12.dp, + top = 8.dp + titleInsets.top, + bottom = 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 = MorpheColors.Blue, + trackColor = MorpheColors.Blue.copy(alpha = 0.08f), + progress = { if (uiState.hasProgress) uiState.progress else 0f }, + ) + if (!uiState.hasProgress) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .height(3.dp), + color = MorpheColors.Blue, + 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 = MorpheColors.Blue, + 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(MorpheColors.Teal.copy(alpha = 0.04f)) + .padding(14.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MorpheColors.Teal + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = "PATCHING COMPLETED — LOADING RESULT...", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MorpheColors.Teal, + 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,6 +343,9 @@ 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 ) { @@ -256,53 +354,84 @@ private fun FailureBottomBar( 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) MorpheColors.Blue.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 +440,181 @@ 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 = MorpheColors.Blue, + 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) Color(0xFFFF9800).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 = Color(0xFFFF9800), + 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(MorpheColors.Teal.copy(alpha = 0.06f)) + .border(1.dp, MorpheColors.Teal.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 = MorpheColors.Teal, + 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) MorpheColors.Blue.copy(alpha = 0.9f) + else MorpheColors.Blue, + 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 color = when (entry.level) { LogLevel.SUCCESS -> MorpheColors.Teal LogLevel.ERROR -> MaterialTheme.colorScheme.error LogLevel.WARNING -> Color(0xFFFF9800) LogLevel.PROGRESS -> MorpheColors.Blue - LogLevel.INFO -> MaterialTheme.colorScheme.onSurfaceVariant + LogLevel.INFO -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) } val prefix = when (entry.level) { @@ -434,13 +627,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" @@ -458,6 +660,6 @@ private fun getStatusColor(status: PatchingStatus): Color { PatchingStatus.COMPLETED -> MorpheColors.Teal 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 98% 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..dde8b5e 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()) 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..bf9ea41 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,25 @@ 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.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.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.graphicsLayer +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 +32,992 @@ 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.DraggableHeaderArea import app.morphe.gui.ui.components.OfflineBanner +import app.morphe.gui.ui.components.LocalTitleBarInsets 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.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 titleInsets = LocalTitleBarInsets.current + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.10f) + + 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 ── + DraggableHeaderArea { + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 1f + ) + } + .padding( + start = 12.dp + titleInsets.start, + end = 12.dp, + top = 8.dp + titleInsets.top, + bottom = 8.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + // Logo — left-aligned + BrandingLogo() + + Spacer(modifier = Modifier.weight(1f)) + + // Patches version badge — centered + PatchesVersionBadge( + patchesVersion = uiState.patchesVersion, + isLoading = uiState.isLoadingPatches + ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.weight(1f)) - // Offline banner - if (uiState.isOffline && uiState.phase == QuickPatchPhase.IDLE) { - OfflineBanner( - onRetry = { viewModel.retryLoadPatches() }, - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) - ) + 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) Color(0xFF4A3000) else MaterialTheme.colorScheme.errorContainer + val contentColor = if (isUnsupportedWarning) Color(0xFFFFB74D) 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) { + val mono = LocalMorpheFont.current + val corners = LocalMorpheCorners.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 = MorpheColors.Blue + ) + 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 = "PATCHES", + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + letterSpacing = 1.5.sp + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = patchesVersion, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + fontFamily = mono, + color = MorpheColors.Blue + ) + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = Modifier + .background(MorpheColors.Teal.copy(alpha = 0.1f), RoundedCornerShape(corners.small)) + .border(1.dp, MorpheColors.Teal.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 = MorpheColors.Teal, + 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 bracketColor = if (isDragHovering) MorpheColors.Blue.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), + modifier = Modifier.size(40.dp), color = MorpheColors.Blue, 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) MorpheColors.Blue + 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) MorpheColors.Blue + 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 borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) + + val accentColor = when { + apkInfo.checksumStatus is ChecksumStatus.Mismatch -> MaterialTheme.colorScheme.error + apkInfo.isRecommendedVersion -> MorpheColors.Teal + apkInfo.versionStatus == VersionStatus.NEWER_VERSION -> MaterialTheme.colorScheme.error + apkInfo.versionStatus == VersionStatus.OLDER_VERSION -> Color(0xFFFF9800) + !apkInfo.isRecommendedVersion && apkInfo.recommendedVersion != null -> Color(0xFFFF9800) + else -> MorpheColors.Blue + } + + 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 statusText = when { + apkInfo.checksumStatus is ChecksumStatus.Verified -> "VERIFIED" + apkInfo.checksumStatus is ChecksumStatus.Mismatch -> "CHECKSUM MISMATCH" + apkInfo.versionStatus == VersionStatus.NEWER_VERSION -> "NEWER THAN RECOMMENDED" + apkInfo.versionStatus == VersionStatus.OLDER_VERSION -> "OLDER THAN RECOMMENDED" + !apkInfo.isRecommendedVersion && apkInfo.recommendedVersion != null -> "VERSION MISMATCH" + apkInfo.isRecommendedVersion -> "RECOMMENDED VERSION" + else -> null + } + val statusDetail = when { + apkInfo.checksumStatus is ChecksumStatus.Verified -> "Checksum matches APKMirror" + apkInfo.checksumStatus is ChecksumStatus.Mismatch -> "Re-download from APKMirror" + apkInfo.versionStatus == VersionStatus.NEWER_VERSION -> + "Patches target v${apkInfo.recommendedVersion} — may not be compatible" + apkInfo.versionStatus == VersionStatus.OLDER_VERSION -> + "Patches target v${apkInfo.recommendedVersion}" + !apkInfo.isRecommendedVersion && apkInfo.recommendedVersion != null -> + "Patches target v${apkInfo.recommendedVersion}" + else -> null + } + + 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) MorpheColors.Blue.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f) + val tagBg = if (isDeviceArch) MorpheColors.Blue.copy(alpha = 0.08f) + else Color.Transparent + val tagColor = if (isDeviceArch) MorpheColors.Blue + 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 = MorpheColors.Blue + ) + 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 = MorpheColors.Blue, + 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) MorpheColors.Blue.copy(alpha = 0.5f) + else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.15f) + val chipBg = if (isEnabled) MorpheColors.Blue.copy(alpha = 0.08f) + else Color.Transparent + val chipTextColor = if (isEnabled) MorpheColors.Blue + 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) MorpheColors.Blue.copy(alpha = 0.9f) else MorpheColors.Blue, + 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 + Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { CircularProgressIndicator( - modifier = Modifier.size(64.dp), - strokeWidth = 4.dp, + modifier = Modifier.size(48.dp), + strokeWidth = 3.dp, color = MorpheColors.Teal ) - 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 = MorpheColors.Teal, + 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) + ) -@Composable + 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 borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.15f) val outputFile = File(outputPath) val scope = rememberCoroutineScope() val adbManager = remember { AdbManager() } @@ -551,477 +1033,557 @@ 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(MorpheColors.Teal, 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 = MorpheColors.Teal, + letterSpacing = 1.sp ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(20.dp)) - Text( - text = outputFile.name, - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center - ) - - 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(MorpheColors.Teal) + .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 = MorpheColors.Teal + ) + } + } - 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) MorpheColors.Blue else MorpheColors.Blue.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(MorpheColors.Teal, 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 = MorpheColors.Teal, + 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 = MorpheColors.Blue + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "INSTALLING…", + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MorpheColors.Blue, + 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) MorpheColors.Teal.copy(alpha = 0.9f) else MorpheColors.Teal, + 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) MorpheColors.Blue.copy(alpha = 0.9f) else MorpheColors.Blue, + 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 = {} ) { - Column( - modifier = Modifier.fillMaxWidth() - ) { + val corners = LocalMorpheCorners.current + val mono = LocalMorpheFont.current + val uriHandler = LocalUriHandler.current + val focusManager = LocalFocusManager.current + + Column(modifier = Modifier.fillMaxWidth()) { + // Header Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( - text = "Download original APK", + text = if (isDefaultSource) "Download original APK" else "Supported apps", fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) ) - if (patchesVersion != null) { - Text( - text = "Patches: $patchesVersion", - fontSize = 11.sp, - color = MorpheColors.Blue.copy(alpha = 0.8f) - ) - } } Spacer(modifier = Modifier.height(8.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) - ) - } - } - } - } - } - } - } -} - -/** - * 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) - ) { + 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 = MorpheColors.Blue ) 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) - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "Using recommended version", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = MorpheColors.Teal + if (supportedApps.size > 4) { + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { + Text("Search apps…", style = MaterialTheme.typography.bodySmall) + }, + leadingIcon = { + Icon( + Icons.Default.Search, null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon( + Icons.Default.Clear, "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp) + ) + } + } + }, + singleLine = true, + textStyle = MaterialTheme.typography.bodySmall, + shape = RoundedCornerShape(corners.small), + modifier = Modifier.fillMaxWidth(), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MorpheColors.Blue, + unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) + ) ) + 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) - ) { + // Horizontal scrolling cards + val useScrolling = filteredApps.size > 4 Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically + 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.Info, - contentDescription = null, - tint = Color(0xFFFF9800), - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(8.dp)) - Column { - Text( - text = "Version $currentVersion", - fontSize = 13.sp, - fontWeight = FontWeight.Medium, - color = Color(0xFFFF9800) - ) - Text( - text = "Recommended: v$suggestedVersion. Patching may have issues.", - fontSize = 11.sp, - color = Color(0xFFFF9800).copy(alpha = 0.8f) + filteredApps.forEach { app -> + val url = app.apkDownloadUrl + val hoverInteraction = remember { MutableInteractionSource() } + val isHovered by hoverInteraction.collectIsHoveredAsState() + val borderColor by animateColorAsState( + if (isHovered) MorpheColors.Blue.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.outline.copy(alpha = 0.12f), + animationSpec = tween(200) ) + + Surface( + modifier = Modifier + .then( + if (useScrolling) Modifier.width(170.dp) + else Modifier.weight(1f) + ) + .fillMaxHeight() + .hoverable(hoverInteraction) + .then( + if (isDefaultSource && url != null) { + Modifier.clickable { + openUrlAndFollowRedirects(url) { resolved -> + uriHandler.openUri(resolved) + } + } + } else Modifier + ), + shape = RoundedCornerShape(corners.small), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, borderColor) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = app.displayName, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (app.recommendedVersion != null) { + Text( + text = "v${app.recommendedVersion}", + fontSize = 11.sp, + color = MorpheColors.Teal, + fontWeight = FontWeight.Medium + ) + } else { + Text( + text = "Any version", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + ) + } + if (isDefaultSource && url != null) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Download ↗", + fontSize = 10.sp, + color = MorpheColors.Blue.copy(alpha = 0.7f), + fontWeight = FontWeight.Medium + ) + } + } + } } } } } + } +} - // Checksum error - checksumStatus is ChecksumStatus.Error -> { - Surface( - modifier = Modifier.fillMaxWidth(), - color = Color(0xFFFF9800).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 = 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) - ) - } - } +// ════════════════════════════════════════════════════════════════════ +// DRAG OVERLAY +// ════════════════════════════════════════════════════════════════════ + +@Composable +private fun DragOverlay() { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background.copy(alpha = 0.92f)) + .border( + width = 2.dp, + color = MorpheColors.Blue.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 = MorpheColors.Blue + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Drop APK here", + fontSize = 18.sp, + fontWeight = FontWeight.SemiBold, + color = MorpheColors.Blue + ) } } } -/** - * Open native file picker. - */ +// ════════════════════════════════════════════════════════════════════ +// 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..a197d98 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,28 @@ 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 app.morphe.gui.util.compareVersions 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 +57,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() @@ -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,14 @@ 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, patchLoadError = null, - isOffline = true + isOffline = isOffline ) } @@ -214,10 +255,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 +278,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 +316,13 @@ class QuickPatchViewModel( } if (packageName !in supportedPackages) { + val appName = SupportedApp.getDisplayName(packageName) + 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 } @@ -285,14 +336,27 @@ class QuickPatchViewModel( // Version check val isRecommendedVersion = recommendedVersion != null && versionName == recommendedVersion + val versionStatus = if (recommendedVersion != null) { + compareVersions(versionName, recommendedVersion) + } else { + VersionStatus.UNKNOWN + } val versionWarning = if (!isRecommendedVersion && recommendedVersion != null) { - "Version $versionName may have compatibility issues. Recommended: $recommendedVersion" + when (versionStatus) { + VersionStatus.NEWER_VERSION -> "Version $versionName is newer than recommended $recommendedVersion — patches may not be compatible" + VersionStatus.OLDER_VERSION -> "Version $versionName is older than recommended $recommendedVersion" + else -> "Version $versionName may have compatibility issues. Recommended: $recommendedVersion" + } } else 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, @@ -302,8 +366,11 @@ class QuickPatchViewModel( displayName = displayName, recommendedVersion = recommendedVersion, isRecommendedVersion = isRecommendedVersion, + versionStatus = versionStatus, versionWarning = versionWarning, - checksumStatus = checksumStatus + checksumStatus = checksumStatus, + architectures = architectures, + minSdk = minSdk ) } } catch (e: Exception) { @@ -311,7 +378,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() } } @@ -472,6 +539,7 @@ class QuickPatchViewModel( patchingJob = null _uiState.value = QuickPatchUiState( // Preserve already-loaded patches data + isDefaultSource = isDefaultSource, isLoadingPatches = false, supportedApps = cachedSupportedApps, patchesVersion = _uiState.value.patchesVersion @@ -514,8 +582,11 @@ data class QuickApkInfo( val displayName: String, val recommendedVersion: 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 +602,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, @@ -543,5 +615,7 @@ data class QuickPatchUiState( val supportedApps: List = emptyList(), val patchesVersion: 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..2fd00fa 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 @@ -32,7 +40,11 @@ import cafe.adriel.voyager.navigator.currentOrThrow import app.morphe.gui.data.repository.ConfigRepository import kotlinx.coroutines.launch import org.koin.compose.koinInject +import app.morphe.gui.ui.components.DraggableHeaderArea +import app.morphe.gui.ui.components.LocalTitleBarInsets import app.morphe.gui.ui.components.TopBarRow +import app.morphe.gui.ui.theme.LocalMorpheCorners +import app.morphe.gui.ui.theme.LocalMorpheFont import app.morphe.gui.ui.theme.MorpheColors import app.morphe.gui.util.AdbDevice import app.morphe.gui.util.AdbException @@ -57,10 +69,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 titleInsets = LocalTitleBarInsets.current + val borderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.10f) + val outputFile = File(outputPath) val scope = rememberCoroutineScope() val adbManager = remember { AdbManager() } @@ -79,14 +95,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 +109,6 @@ fun ResultScreenContent(outputPath: String) { } } - // Install function fun installViaAdb() { val device = monitorState.selectedDevice ?: return scope.launch { @@ -123,307 +136,370 @@ 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, - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(32.dp) - ) { - // 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) + // Header row + DraggableHeaderArea { + Row( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawLine( + color = borderColor, + start = Offset(0f, size.height), + end = Offset(size.width, size.height), + strokeWidth = 1f + ) + } + .padding( + start = 12.dp + titleInsets.start, + end = 12.dp, + top = 8.dp + titleInsets.top, + bottom = 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 { 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(MorpheColors.Teal, 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 = MorpheColors.Teal, + 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 + val scrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // 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(MorpheColors.Teal) + .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) ) { - Column( - modifier = Modifier.padding(20.dp) + // File name + size + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "Output File", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(8.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 - ) - + 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 = 15.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + 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 + ) + } if (outputFile.exists()) { - Spacer(modifier = Modifier.height(8.dp)) Text( text = formatFileSize(outputFile.length()), fontSize = 13.sp, + fontWeight = FontWeight.Bold, + fontFamily = mono, color = MorpheColors.Teal ) } } - } - - 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 = 10.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) MorpheColors.Blue else MorpheColors.Blue.copy(alpha = 0.6f), + 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)) + .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 + ) + } } } + } - 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) MorpheColors.Blue.copy(alpha = 0.9f) else MorpheColors.Blue, + 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) + 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) + 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 = MorpheColors.Teal, + letterSpacing = 0.5.sp ) } } @@ -431,27 +507,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 +571,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 ) - 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 = MorpheColors.Blue, + 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 -> MorpheColors.Teal.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 -> MorpheColors.Teal.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 -> MorpheColors.Teal + enabled -> MorpheColors.Blue.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 -> MorpheColors.Teal + DeviceStatus.UNAUTHORIZED -> Color(0xFFFF9800) + 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 -> MorpheColors.Teal.copy(alpha = 0.3f) + isInstallHovered -> MorpheColors.Teal.copy(alpha = 0.9f) + else -> MorpheColors.Teal + }, + 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 +749,96 @@ 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 accentColor = if (tempFilesCleared) MorpheColors.Teal 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) MorpheColors.Teal.copy(alpha = 0.2f) else borderColor, + RoundedCornerShape(corners.medium) + ) + .background( + if (tempFilesCleared) MorpheColors.Teal.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) MorpheColors.Teal + 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) MorpheColors.Teal.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) Color(0xFFFF9800).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 = Color(0xFFFF9800), + letterSpacing = 0.5.sp ) } + } else if (tempFilesCleared) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = null, + tint = MorpheColors.Teal, + 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..007d4d1 100644 --- a/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt +++ b/src/main/kotlin/app/morphe/gui/ui/theme/Theme.kt @@ -6,11 +6,17 @@ package app.morphe.gui.ui.theme import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes 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 { @@ -24,6 +30,94 @@ 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 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(0xFF4A7FFF), // Brighter blue for pure black + secondary = Color(0xFF00BFA5), // Brighter teal +) + +/** 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(0xFF88C0D0), // Nord Frost + secondary = Color(0xFFA3BE8C), // Nord Aurora Green + warning = Color(0xFFEBCB8B), // Nord Aurora Yellow +) + +/** Catppuccin Mocha — native Catppuccin palette. Mauve + teal. */ +private val CatppuccinAccents = MorpheAccentColors( + primary = Color(0xFFCBA6F7), // Mauve + secondary = Color(0xFF94E2D5), // Teal + warning = Color(0xFFFAB387), // Peach +) + +/** Sakura — warm rose + dusty lavender. */ +private val SakuraAccents = MorpheAccentColors( + primary = Color(0xFFD4567A), // Deep rose + secondary = Color(0xFF9A6DAF), // Dusty lavender + warning = Color(0xFFE8874A), // Warm amber +) + +/** Matcha — forest green + sage. */ +private val MatchaAccents = MorpheAccentColors( + primary = Color(0xFF5A9A4E), // Forest green + secondary = Color(0xFF7AADAF), // Sage teal + warning = Color(0xFFD4944A), // Warm 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 +169,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 ── +// Soft pink, cute aesthetic — light theme with warm blush tones +private val SakuraColorScheme = lightColorScheme( + primary = Color(0xFFE8729A), // Rose pink + secondary = Color(0xFFC75088), // Deeper rose + tertiary = Color(0xFFF5A0C0), // Soft pink + background = Color(0xFFFFF5F7), // Blush white + surface = Color(0xFFFFE8EE), // Petal + surfaceVariant = Color(0xFFFFD6E0), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color(0xFF5A1A30), + onBackground = Color(0xFF4A2030), // Deep plum + onSurface = Color(0xFF4A2030), + onSurfaceVariant = Color(0xFF8A506A), + error = Color(0xFFD03050), + onError = Color.White +) + +// ── Matcha ── +// Pista green, cute aesthetic — light theme with fresh green tones +private val MatchaColorScheme = lightColorScheme( + primary = Color(0xFF6DAF5C), // Pista green + secondary = Color(0xFF8BC77E), // Fresh green + tertiary = Color(0xFFA3D99B), // Light mint + background = Color(0xFFF4F9F0), // Green-tinted white + surface = Color(0xFFE5F0DC), // Soft green + surfaceVariant = Color(0xFFD4E5C8), + onPrimary = Color.White, + onSecondary = Color(0xFF1E3318), + onTertiary = Color(0xFF1E3318), + onBackground = Color(0xFF1E3318), // Deep forest + onSurface = Color(0xFF1E3318), + onSurfaceVariant = Color(0xFF4A6B3D), + error = Color(0xFFC04040), + 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 +286,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..fd10759 100644 --- a/src/main/kotlin/app/morphe/gui/util/FileUtils.kt +++ b/src/main/kotlin/app/morphe/gui/util/FileUtils.kt @@ -147,22 +147,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 setOf("apk", "apkm", "xapk", "apks") } /** - * Extract base.apk from an .apkm file to a temp directory. + * Check if file is a split APK bundle (.apkm, .xapk, or .apks). + */ + fun isBundleFormat(file: File): Boolean { + return file.extension.lowercase() in setOf("apkm", "xapk", "apks") + } + + /** + * 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 +207,47 @@ 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()) { + val knownArchs = setOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64") + zip.entries().asSequence() + .map { it.name } + .filter { it.endsWith(".apk") } + .forEach { name -> + 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() + } + } } diff --git a/src/main/kotlin/app/morphe/gui/util/PatchService.kt b/src/main/kotlin/app/morphe/gui/util/PatchService.kt index 47d832e..68e0e19 100644 --- a/src/main/kotlin/app/morphe/gui/util/PatchService.kt +++ b/src/main/kotlin/app/morphe/gui/util/PatchService.kt @@ -162,7 +162,7 @@ class PatchService { 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 +174,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 +182,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..19b8982 100644 --- a/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt +++ b/src/main/kotlin/app/morphe/gui/util/SupportedAppExtractor.kt @@ -8,6 +8,7 @@ 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 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..3b8144a --- /dev/null +++ b/src/main/kotlin/app/morphe/gui/util/VersionUtils.kt @@ -0,0 +1,33 @@ +package app.morphe.gui.util + +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 +} + +/** + * Compares two version strings (e.g., "19.16.39" vs "20.40.45") + * Returns the version status of the current version relative to suggested. + */ +fun compareVersions(current: String, suggested: String): VersionStatus { + return try { + val currentParts = current.split(".").map { it.toInt() } + val suggestedParts = suggested.split(".").map { it.toInt() } + + 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 + } +} diff --git a/src/main/resources/cat2333s.json b/src/main/resources/cat2333s.json new file mode 100644 index 0000000..add2952 --- /dev/null +++ b/src/main/resources/cat2333s.json @@ -0,0 +1 @@ +{"nm": "cat", "ddd": 0, "h": 1080, "w": 1080, "meta": {"g": "@lottiefiles/toolkit-js 0.25.4"}, "layers": [{"ty": 4, "nm": "Layer 7 Outlines", "sr": 1, "st": 0, "op": 300.00001221925, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": {"a": {"a": 0, "k": [5.25, 5.25, 0], "ix": 1}, "s": {"a": 0, "k": [150, 150, 100], "ix": 6}, "sk": {"a": 0, "k": 0}, "p": {"a": 1, "k": [{"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [531, 418.5, 0], "t": 25, "ti": [0, -1.125, 0], "to": [0, 1.125, 0]}, {"o": {"x": 0.333, "y": 0.333}, "i": {"x": 0.667, "y": 0.667}, "s": [531, 425.25, 0], "t": 30, "ti": [0, 0, 0], "to": [0, 0, 0]}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [531, 425.25, 0], "t": 59, "ti": [-1.375, 1.25, 0], "to": [1.375, -1.25, 0]}, {"o": {"x": 0.333, "y": 0.333}, "i": {"x": 0.667, "y": 0.667}, "s": [539.25, 417.75, 0], "t": 64, "ti": [0, 0, 0], "to": [0, 0, 0]}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [539.25, 417.75, 0], "t": 105, "ti": [1.5, 0, 0], "to": [-1.5, 0, 0]}, {"o": {"x": 0.333, "y": 0.333}, "i": {"x": 0.667, "y": 0.667}, "s": [530.25, 417.75, 0], "t": 110, "ti": [0, 0, 0], "to": [0, 0, 0]}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [530.25, 417.75, 0], "t": 129, "ti": [0, -1.25, 0], "to": [0, 1.25, 0]}, {"o": {"x": 0.333, "y": 0.333}, "i": {"x": 0.667, "y": 0.667}, "s": [530.25, 425.25, 0], "t": 135, "ti": [0, 0, 0], "to": [0, 0, 0]}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [530.25, 425.25, 0], "t": 159, "ti": [-0.125, 1.125, 0], "to": [0.125, -1.125, 0]}, {"s": [531, 418.5, 0], "t": 165.000006720588}], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 10}, "sa": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100, "ix": 11}}, "ef": [], "shapes": [{"ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Group 1", "ix": 1, "cix": 2, "np": 2, "it": [{"ty": "sh", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Group", "nm": "Path 1", "ix": 1, "d": 1, "ks": {"a": 0, "k": {"c": true, "i": [[0, -2.761], [2.761, 0], [0, 2.761], [-2.761, 0]], "o": [[0, 2.761], [-2.761, 0], [0, -2.761], [2.761, 0]], "v": [[5, 0], [0, 5], [-5, 0], [0, -5]]}, "ix": 2}}, {"ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": {"a": 0, "k": [1, 1, 1], "ix": 4}, "r": 1, "o": {"a": 0, "k": 100, "ix": 5}}, {"ty": "tr", "a": {"a": 0, "k": [0, 0], "ix": 1}, "s": {"a": 0, "k": [100, 100], "ix": 3}, "sk": {"a": 0, "k": 0, "ix": 4}, "p": {"a": 0, "k": [5.25, 5.25], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 6}, "sa": {"a": 0, "k": 0, "ix": 5}, "o": {"a": 0, "k": 100, "ix": 7}}]}], "ind": 1}, {"ty": 4, "nm": "eye 1 Outlines", "sr": 1, "st": 0, "op": 300.00001221925, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": {"a": {"a": 0, "k": [5.25, 5.25, 0], "ix": 1}, "s": {"a": 0, "k": [150, 150, 100], "ix": 6}, "sk": {"a": 0, "k": 0}, "p": {"a": 1, "k": [{"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [501, 429, 0], "t": 25, "ti": [0.062, -1.312, 0], "to": [-0.062, 1.312, 0]}, {"o": {"x": 0.333, "y": 0.333}, "i": {"x": 0.667, "y": 0.667}, "s": [500.625, 436.875, 0], "t": 30, "ti": [0, 0, 0], "to": [0, 0, 0]}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [500.625, 436.875, 0], "t": 59, "ti": [-1.25, 1.375, 0], "to": [1.25, -1.375, 0]}, {"o": {"x": 0.333, "y": 0.333}, "i": {"x": 0.667, "y": 0.667}, "s": [508.125, 428.625, 0], "t": 64, "ti": [0, 0, 0], "to": [0, 0, 0]}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [508.125, 428.625, 0], "t": 105, "ti": [1.25, 0, 0], "to": [-1.25, 0, 0]}, {"o": {"x": 0.333, "y": 0.333}, "i": {"x": 0.667, "y": 0.667}, "s": [500.625, 428.625, 0], "t": 110, "ti": [0, 0, 0], "to": [0, 0, 0]}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [500.625, 428.625, 0], "t": 129, "ti": [0, -1.25, 0], "to": [0, 1.25, 0]}, {"o": {"x": 0.333, "y": 0.333}, "i": {"x": 0.667, "y": 0.667}, "s": [500.625, 436.125, 0], "t": 135, "ti": [0, 0, 0], "to": [0, 0, 0]}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [500.625, 436.125, 0], "t": 159, "ti": [-0.062, 1.188, 0], "to": [0.062, -1.188, 0]}, {"s": [501, 429, 0], "t": 165.000006720588}], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 10}, "sa": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100, "ix": 11}}, "ef": [], "shapes": [{"ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Group 1", "ix": 1, "cix": 2, "np": 2, "it": [{"ty": "sh", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Group", "nm": "Path 1", "ix": 1, "d": 1, "ks": {"a": 0, "k": {"c": true, "i": [[0, -2.761], [2.761, 0], [0, 2.761], [-2.761, 0]], "o": [[0, 2.761], [-2.761, 0], [0, -2.761], [2.761, 0]], "v": [[5, 0], [0, 5], [-5, 0], [0, -5]]}, "ix": 2}}, {"ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": {"a": 0, "k": [1, 1, 1], "ix": 4}, "r": 1, "o": {"a": 0, "k": 100, "ix": 5}}, {"ty": "tr", "a": {"a": 0, "k": [0, 0], "ix": 1}, "s": {"a": 0, "k": [100, 100], "ix": 3}, "sk": {"a": 0, "k": 0, "ix": 4}, "p": {"a": 0, "k": [5.25, 5.25], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 6}, "sa": {"a": 0, "k": 0, "ix": 5}, "o": {"a": 0, "k": 100, "ix": 7}}]}], "ind": 2}, {"ty": 4, "nm": "Layer 8 Outlines", "sr": 1, "st": 0, "op": 300.00001221925, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": {"a": {"a": 0, "k": [34.25, 68.25, 0], "ix": 1}, "s": {"a": 0, "k": [150, 150, 100], "ix": 6}, "sk": {"a": 0, "k": 0}, "p": {"a": 0, "k": [532.5, 486, 0], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 10}, "sa": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100, "ix": 11}}, "ef": [], "shapes": [{"ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Group 1", "ix": 1, "cix": 2, "np": 2, "it": [{"ty": "sh", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Group", "nm": "Path 1", "ix": 1, "d": 1, "ks": {"a": 0, "k": {"c": true, "i": [[0, -18.778], [18.777, 0], [0, 18.778], [-18.778, 0]], "o": [[0, 18.778], [-18.778, 0], [0, -18.778], [18.777, 0]], "v": [[34, 0], [0, 34], [-34, 0], [0, -34]]}, "ix": 2}}, {"ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": {"a": 0, "k": [0, 0, 0], "ix": 4}, "r": 1, "o": {"a": 0, "k": 100, "ix": 5}}, {"ty": "tr", "a": {"a": 0, "k": [0, 0], "ix": 1}, "s": {"a": 0, "k": [100, 100], "ix": 3}, "sk": {"a": 0, "k": 0, "ix": 4}, "p": {"a": 0, "k": [34.25, 34.25], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 6}, "sa": {"a": 0, "k": 0, "ix": 5}, "o": {"a": 0, "k": 100, "ix": 7}}]}], "ind": 3}, {"ty": 4, "nm": "ear2 Outlines", "sr": 1, "st": 0, "op": 300.00001221925, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": {"a": {"a": 0, "k": [7.25, 28.369, 0], "ix": 1}, "s": {"a": 0, "k": [150, 150, 100], "ix": 6}, "sk": {"a": 0, "k": 0}, "p": {"a": 1, "k": [{"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [537, 400.678, 0], "t": 25, "ti": [1.625, -0.375, 0], "to": [-1.625, 0.375, 0]}, {"o": {"x": 0.333, "y": 0.333}, "i": {"x": 0.667, "y": 0.667}, "s": [527.25, 402.928, 0], "t": 30, "ti": [0, 0, 0], "to": [0, 0, 0]}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [527.25, 402.928, 0], "t": 59, "ti": [-1.531, 0.469, 0], "to": [1.531, -0.469, 0]}, {"o": {"x": 0.333, "y": 0.333}, "i": {"x": 0.667, "y": 0.667}, "s": [536.437, 400.116, 0], "t": 64, "ti": [0, 0, 0], "to": [0, 0, 0]}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [536.437, 400.116, 0], "t": 129, "ti": [1, 0, 0], "to": [-1, 0, 0]}, {"o": {"x": 0.333, "y": 0.333}, "i": {"x": 0.667, "y": 0.667}, "s": [530.437, 400.116, 0], "t": 135, "ti": [0, 0, 0], "to": [0, 0, 0]}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [530.437, 400.116, 0], "t": 159, "ti": [-1.094, -0.094, 0], "to": [1.094, 0.094, 0]}, {"s": [537, 400.678, 0], "t": 165.000006720588}], "ix": 2}, "r": {"a": 1, "k": [{"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [0], "t": 25}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-9], "t": 30}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-9], "t": 59}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-7], "t": 64}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-7], "t": 72}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-3], "t": 74}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-12], "t": 75}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [5], "t": 77}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-12], "t": 81}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-5], "t": 82}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-18], "t": 85}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [5], "t": 88}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-8], "t": 91}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-8], "t": 105}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [1], "t": 110}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [1], "t": 129}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-19], "t": 135}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-19], "t": 159}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [0], "t": 165}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-7], "t": 177}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-3], "t": 179}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-12], "t": 180}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [5], "t": 182}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-12], "t": 186}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-5], "t": 187}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-18], "t": 190}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [5], "t": 193}, {"s": [0], "t": 196.000007983244}], "ix": 10}, "sa": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100, "ix": 11}}, "ef": [], "shapes": [{"ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Group 1", "ix": 1, "cix": 2, "np": 2, "it": [{"ty": "sh", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Group", "nm": "Path 1", "ix": 1, "d": 1, "ks": {"a": 0, "k": {"c": false, "i": [[0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0]], "v": [[-6.508, 14.059], [-7, -14.059], [7, 4.941]]}, "ix": 2}}, {"ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": {"a": 0, "k": [0, 0, 0], "ix": 4}, "r": 1, "o": {"a": 0, "k": 100, "ix": 5}}, {"ty": "tr", "a": {"a": 0, "k": [0, 0], "ix": 1}, "s": {"a": 0, "k": [100, 100], "ix": 3}, "sk": {"a": 0, "k": 0, "ix": 4}, "p": {"a": 0, "k": [7.25, 14.31], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 6}, "sa": {"a": 0, "k": 0, "ix": 5}, "o": {"a": 0, "k": 100, "ix": 7}}]}], "ind": 4}, {"ty": 4, "nm": "ear 1 Outlines", "sr": 1, "st": 0, "op": 300.00001221925, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": {"a": {"a": 0, "k": [7.25, 28.369, 0], "ix": 1}, "s": {"a": 0, "k": [150, 150, 100], "ix": 6}, "sk": {"a": 0, "k": 0}, "p": {"a": 1, "k": [{"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [498, 409.678, 0], "t": 25, "ti": [0.562, -2.188, 0], "to": [-0.562, 2.188, 0]}, {"o": {"x": 0.333, "y": 0.333}, "i": {"x": 0.667, "y": 0.667}, "s": [494.625, 422.803, 0], "t": 30, "ti": [0, 0, 0], "to": [0, 0, 0]}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [494.625, 422.803, 0], "t": 59, "ti": [-0.594, 1.156, 0], "to": [0.594, -1.156, 0]}, {"o": {"x": 0.333, "y": 0.333}, "i": {"x": 0.667, "y": 0.667}, "s": [498.187, 415.866, 0], "t": 64, "ti": [0, 0, 0], "to": [0, 0, 0]}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [498.187, 415.866, 0], "t": 129, "ti": [0.281, -0.594, 0], "to": [-0.281, 0.594, 0]}, {"o": {"x": 0.333, "y": 0.333}, "i": {"x": 0.667, "y": 0.667}, "s": [496.5, 419.428, 0], "t": 135, "ti": [0, 0, 0], "to": [0, 0, 0]}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [496.5, 419.428, 0], "t": 159, "ti": [-0.25, 1.625, 0], "to": [0.25, -1.625, 0]}, {"s": [498, 409.678, 0], "t": 165.000006720588}], "ix": 2}, "r": {"a": 1, "k": [{"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [0], "t": 25}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-12], "t": 30}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-12], "t": 59}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-5], "t": 64}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-5], "t": 105}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [2], "t": 110}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [2], "t": 129}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-20], "t": 135}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [-20], "t": 159}, {"s": [0], "t": 165.000006720588}], "ix": 10}, "sa": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100, "ix": 11}}, "ef": [], "shapes": [{"ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Group 1", "ix": 1, "cix": 2, "np": 2, "it": [{"ty": "sh", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Group", "nm": "Path 1", "ix": 1, "d": 1, "ks": {"a": 0, "k": {"c": false, "i": [[0, 0], [0, 0], [0, 0]], "o": [[0, 0], [0, 0], [0, 0]], "v": [[-6.508, 14.059], [-7, -14.059], [7, 4.941]]}, "ix": 2}}, {"ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": {"a": 0, "k": [0, 0, 0], "ix": 4}, "r": 1, "o": {"a": 0, "k": 100, "ix": 5}}, {"ty": "tr", "a": {"a": 0, "k": [0, 0], "ix": 1}, "s": {"a": 0, "k": [100, 100], "ix": 3}, "sk": {"a": 0, "k": 0, "ix": 4}, "p": {"a": 0, "k": [7.25, 14.31], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 6}, "sa": {"a": 0, "k": 0, "ix": 5}, "o": {"a": 0, "k": 100, "ix": 7}}]}], "ind": 5}, {"ty": 4, "nm": "foot Outlines", "sr": 1, "st": 0, "op": 300.00001221925, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": {"a": {"a": 0, "k": [77, 40, 0], "ix": 1}, "s": {"a": 0, "k": [150, 150, 100], "ix": 6}, "sk": {"a": 0, "k": 0}, "p": {"a": 0, "k": [540, 492, 0], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 10}, "sa": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100, "ix": 11}}, "ef": [], "shapes": [{"ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Group 1", "ix": 1, "cix": 2, "np": 2, "it": [{"ty": "sh", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Group", "nm": "Path 1", "ix": 1, "d": 1, "ks": {"a": 1, "k": [{"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [9.792, -1.935], [1.377, -4.256], [0.347, -4.168]], "o": [[0, 0], [-4.388, 0.866], [-0.777, 2.406], [0, 0]], "v": [[18.5, -10], [-7.311, -8.065], [-16.666, 0.223], [-18.5, 10]]}], "t": 30}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [9.792, -1.935], [10.583, -4.223], [2.167, -4.5]], "o": [[0, 0], [-4.388, 0.866], [-2.348, 0.937], [0, 0]], "v": [[18.5, -10], [-8.061, -13.815], [-28.166, -11.277], [-40.583, -1.083]]}], "t": 33}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [9.911, 1.186], [8.892, -7.125], [0.25, -3.917]], "o": [[0, 0], [-2.939, -0.352], [-7.834, 6.277], [0, 0]], "v": [[18.5, -10], [-8.061, -15.648], [-28.833, -12.61], [-35.25, -0.083]]}], "t": 36}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [9.911, 1.186], [1.666, -1.89], [0.25, -3.917]], "o": [[0, 0], [-2.939, -0.352], [-3.491, 3.96], [0, 0]], "v": [[18.5, -10], [-8.061, -15.648], [-22.499, -10.777], [-28.25, 1.417]]}], "t": 39}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [9.792, -1.935], [1.377, -4.256], [0.347, -4.168]], "o": [[0, 0], [-4.388, 0.866], [-0.777, 2.406], [0, 0]], "v": [[18.5, -10], [-7.311, -8.065], [-16.666, 0.223], [-18.5, 10]]}], "t": 42}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [9.792, -1.935], [1.377, -4.256], [0.347, -4.168]], "o": [[0, 0], [-4.388, 0.866], [-0.777, 2.406], [0, 0]], "v": [[18.5, -10], [-7.311, -8.065], [-16.666, 0.223], [-18.5, 10]]}], "t": 135}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [9.792, -1.935], [10.583, -4.223], [2.167, -4.5]], "o": [[0, 0], [-4.388, 0.866], [-2.348, 0.937], [0, 0]], "v": [[18.5, -10], [-8.061, -13.815], [-28.166, -11.277], [-40.583, -1.083]]}], "t": 138}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [9.911, 1.186], [8.892, -7.125], [0.25, -3.917]], "o": [[0, 0], [-2.939, -0.352], [-7.834, 6.277], [0, 0]], "v": [[18.5, -10], [-8.061, -15.648], [-28.833, -12.61], [-35.25, -0.083]]}], "t": 141}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [9.911, 1.186], [1.666, -1.89], [0.25, -3.917]], "o": [[0, 0], [-2.939, -0.352], [-3.491, 3.96], [0, 0]], "v": [[18.5, -10], [-8.061, -15.648], [-22.499, -10.777], [-28.25, 1.417]]}], "t": 144}, {"s": [{"c": false, "i": [[0, 0], [9.792, -1.935], [1.377, -4.256], [0.347, -4.168]], "o": [[0, 0], [-4.388, 0.866], [-0.777, 2.406], [0, 0]], "v": [[18.5, -10], [-7.311, -8.065], [-16.666, 0.223], [-18.5, 10]]}], "t": 147.000005987433}], "ix": 2}}, {"ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 2, "lj": 1, "ml": 10, "o": {"a": 0, "k": 100, "ix": 4}, "w": {"a": 0, "k": 16, "ix": 5}, "c": {"a": 0, "k": [0, 0, 0], "ix": 3}}, {"ty": "tr", "a": {"a": 0, "k": [0, 0], "ix": 1}, "s": {"a": 0, "k": [100, 100], "ix": 3}, "sk": {"a": 0, "k": 0, "ix": 4}, "p": {"a": 0, "k": [58.5, 50], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 6}, "sa": {"a": 0, "k": 0, "ix": 5}, "o": {"a": 0, "k": 100, "ix": 7}}]}], "ind": 6}, {"ty": 4, "nm": "tail Outlines", "sr": 1, "st": 0, "op": 300.00001221925, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": {"a": {"a": 0, "k": [104.011, 30, 0], "ix": 1}, "s": {"a": 0, "k": [150, 150, 100], "ix": 6}, "sk": {"a": 0, "k": 0}, "p": {"a": 0, "k": [714.017, 497.25, 0], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 10}, "sa": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100, "ix": 11}}, "ef": [], "shapes": [{"ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Group 1", "ix": 1, "cix": 2, "np": 2, "it": [{"ty": "sh", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Group", "nm": "Path 1", "ix": 1, "d": 1, "ks": {"a": 1, "k": [{"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [36.624, -19.156], [-16, 13]], "o": [[0, 0], [-7.025, 3.675], [0, 0]], "v": [[36.172, -45.78], [1.204, 42.105], [-21.828, 11.22]]}], "t": 0}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [-5.407, -37.51], [7.125, 38.75]], "o": [[0, 0], [4.093, 27.74], [0, 0]], "v": [[36.172, -45.78], [35.079, 37.98], [-16.703, 49.72]]}], "t": 9}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [-5.032, -32.385], [-9, -2.5]], "o": [[0, 0], [5.137, 33.062], [0, 0]], "v": [[36.172, -45.78], [22.704, 53.605], [64.672, 94.22]]}], "t": 12}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [-16.771, -28.108], [29.5, 20]], "o": [[0, 0], [17.968, 30.115], [0, 0]], "v": [[36.172, -45.78], [34.204, 34.605], [72.672, 14.22]]}], "t": 25}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [30.638, -59.753], [-18, 0.5]], "o": [[0, 0], [-9.032, 17.615], [0, 0]], "v": [[36.172, -45.78], [25.204, 41.105], [45.672, 78.22]]}], "t": 37}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [29.718, -25.385], [-14.083, -12]], "o": [[0, 0], [-12.782, 14.115], [0, 0]], "v": [[36.172, -45.78], [22.704, 37.355], [-3.495, 67.22]]}], "t": 39}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [27.118, -32.385], [-1.592, 32.8]], "o": [[0, 0], [-12.24, 16.493], [0, 0]], "v": [[36.172, -45.78], [18.554, 35.105], [-27.611, 23.795]]}], "t": 41}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [28.793, -31.635], [-37.858, 26.212]], "o": [[0, 0], [-14.207, 23.365], [0, 0]], "v": [[36.172, -45.78], [11.879, 34.105], [-19.97, 12.758]]}], "t": 45}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [36.624, -19.156], [-16, 13]], "o": [[0, 0], [-7.025, 3.675], [0, 0]], "v": [[36.172, -45.78], [1.204, 42.105], [-21.828, 11.22]]}], "t": 49}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [-5.407, -37.51], [7.125, 38.75]], "o": [[0, 0], [4.093, 27.74], [0, 0]], "v": [[36.172, -45.78], [35.079, 37.98], [-16.703, 49.72]]}], "t": 58}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [-5.032, -32.385], [-9, -2.5]], "o": [[0, 0], [5.137, 33.062], [0, 0]], "v": [[36.172, -45.78], [22.704, 53.605], [64.672, 94.22]]}], "t": 61}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [-16.771, -28.108], [29.5, 20]], "o": [[0, 0], [17.968, 30.115], [0, 0]], "v": [[36.172, -45.78], [34.204, 34.605], [72.672, 14.22]]}], "t": 74}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [30.638, -59.753], [-18, 0.5]], "o": [[0, 0], [-9.032, 17.615], [0, 0]], "v": [[36.172, -45.78], [25.204, 41.105], [45.672, 78.22]]}], "t": 86}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [29.718, -25.385], [-14.083, -12]], "o": [[0, 0], [-12.782, 14.115], [0, 0]], "v": [[36.172, -45.78], [22.704, 37.355], [-3.495, 67.22]]}], "t": 88}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [27.118, -32.385], [-1.592, 32.8]], "o": [[0, 0], [-12.24, 16.493], [0, 0]], "v": [[36.172, -45.78], [18.554, 35.105], [-27.611, 23.795]]}], "t": 90}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [28.793, -31.635], [-37.858, 26.212]], "o": [[0, 0], [-14.207, 23.365], [0, 0]], "v": [[36.172, -45.78], [11.879, 34.105], [-19.97, 12.758]]}], "t": 94}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [36.624, -19.156], [-16, 13]], "o": [[0, 0], [-7.025, 3.675], [0, 0]], "v": [[36.172, -45.78], [1.204, 42.105], [-21.828, 11.22]]}], "t": 98}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [-5.407, -37.51], [7.125, 38.75]], "o": [[0, 0], [4.093, 27.74], [0, 0]], "v": [[36.172, -45.78], [35.079, 37.98], [-16.703, 49.72]]}], "t": 107}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [-5.032, -32.385], [-9, -2.5]], "o": [[0, 0], [5.137, 33.062], [0, 0]], "v": [[36.172, -45.78], [22.704, 53.605], [64.672, 94.22]]}], "t": 110}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [-16.771, -28.108], [29.5, 20]], "o": [[0, 0], [17.968, 30.115], [0, 0]], "v": [[36.172, -45.78], [34.204, 34.605], [72.672, 14.22]]}], "t": 123}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [30.638, -59.753], [-18, 0.5]], "o": [[0, 0], [-9.032, 17.615], [0, 0]], "v": [[36.172, -45.78], [25.204, 41.105], [45.672, 78.22]]}], "t": 135}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [29.718, -25.385], [-14.083, -12]], "o": [[0, 0], [-12.782, 14.115], [0, 0]], "v": [[36.172, -45.78], [22.704, 37.355], [-3.495, 67.22]]}], "t": 137}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [27.118, -32.385], [-1.592, 32.8]], "o": [[0, 0], [-12.24, 16.493], [0, 0]], "v": [[36.172, -45.78], [18.554, 35.105], [-27.611, 23.795]]}], "t": 139}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [28.793, -31.635], [-37.858, 26.212]], "o": [[0, 0], [-14.207, 23.365], [0, 0]], "v": [[36.172, -45.78], [11.879, 34.105], [-19.97, 12.758]]}], "t": 143}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [36.624, -19.156], [-16, 13]], "o": [[0, 0], [-7.025, 3.675], [0, 0]], "v": [[36.172, -45.78], [1.204, 42.105], [-21.828, 11.22]]}], "t": 147}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [-5.407, -37.51], [7.125, 38.75]], "o": [[0, 0], [4.093, 27.74], [0, 0]], "v": [[36.172, -45.78], [35.079, 37.98], [-16.703, 49.72]]}], "t": 156}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [-5.032, -32.385], [-9, -2.5]], "o": [[0, 0], [5.137, 33.062], [0, 0]], "v": [[36.172, -45.78], [22.704, 53.605], [64.672, 94.22]]}], "t": 159}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [-16.771, -28.108], [29.5, 20]], "o": [[0, 0], [17.968, 30.115], [0, 0]], "v": [[36.172, -45.78], [34.204, 34.605], [72.672, 14.22]]}], "t": 172}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [30.638, -59.753], [-18, 0.5]], "o": [[0, 0], [-9.032, 17.615], [0, 0]], "v": [[36.172, -45.78], [25.204, 41.105], [45.672, 78.22]]}], "t": 184}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [29.718, -25.385], [-14.083, -12]], "o": [[0, 0], [-12.782, 14.115], [0, 0]], "v": [[36.172, -45.78], [22.704, 37.355], [-3.495, 67.22]]}], "t": 186}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [27.118, -32.385], [-1.592, 32.8]], "o": [[0, 0], [-12.24, 16.493], [0, 0]], "v": [[36.172, -45.78], [18.554, 35.105], [-27.611, 23.795]]}], "t": 188}, {"o": {"x": 0.333, "y": 0}, "i": {"x": 0.667, "y": 1}, "s": [{"c": false, "i": [[0, 0], [28.793, -31.635], [-37.858, 26.212]], "o": [[0, 0], [-14.207, 23.365], [0, 0]], "v": [[36.172, -45.78], [11.879, 34.105], [-19.97, 12.758]]}], "t": 192}, {"s": [{"c": false, "i": [[0, 0], [36.624, -19.156], [-16, 13]], "o": [[0, 0], [-7.025, 3.675], [0, 0]], "v": [[36.172, -45.78], [1.204, 42.105], [-21.828, 11.22]]}], "t": 196.000007983244}], "ix": 2}}, {"ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 2, "lj": 1, "ml": 10, "o": {"a": 0, "k": 100, "ix": 4}, "w": {"a": 0, "k": 12, "ix": 5}, "c": {"a": 0, "k": [0, 0, 0], "ix": 3}}, {"ty": "tr", "a": {"a": 0, "k": [0, 0], "ix": 1}, "s": {"a": 0, "k": [100, 100], "ix": 3}, "sk": {"a": 0, "k": 0, "ix": 4}, "p": {"a": 0, "k": [67.828, 75.78], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 6}, "sa": {"a": 0, "k": 0, "ix": 5}, "o": {"a": 0, "k": 100, "ix": 7}}]}], "ind": 7}, {"ty": 4, "nm": "Layer 1 Outlines", "sr": 1, "st": 0, "op": 300.00001221925, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": {"a": {"a": 0, "k": [275, 33, 0], "ix": 1}, "s": {"a": 0, "k": [150, 150, 100], "ix": 6}, "sk": {"a": 0, "k": 0}, "p": {"a": 0, "k": [583.5, 564, 0], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 10}, "sa": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100, "ix": 11}}, "ef": [], "shapes": [{"ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Group 1", "ix": 1, "cix": 2, "np": 2, "it": [{"ty": "sh", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Group", "nm": "Path 1", "ix": 1, "d": 1, "ks": {"a": 0, "k": {"c": false, "i": [[0, 0], [0, 0]], "o": [[0, 0], [0, 0]], "v": [[-200, 33], [1280, 33]]}, "ix": 2}}, {"ty": "st", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Stroke", "nm": "Stroke 1", "lc": 2, "lj": 1, "ml": 10, "o": {"a": 0, "k": 100, "ix": 4}, "w": {"a": 0, "k": 24, "ix": 5}, "c": {"a": 0, "k": [0.149, 0.2039, 0.1569], "ix": 3}}, {"ty": "tr", "a": {"a": 0, "k": [0, 0], "ix": 1}, "s": {"a": 0, "k": [100, 100], "ix": 3}, "sk": {"a": 0, "k": 0, "ix": 4}, "p": {"a": 0, "k": [0, 0], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 6}, "sa": {"a": 0, "k": 0, "ix": 5}, "o": {"a": 0, "k": 100, "ix": 7}}]}], "ind": 8}, {"ty": 4, "nm": "Layer 9 Outlines", "sr": 1, "st": 0, "op": 300.00001221925, "ip": 0, "hd": false, "ddd": 0, "bm": 0, "hasMask": false, "ao": 0, "ks": {"a": {"a": 0, "k": [70.25, 68.25, 0], "ix": 1}, "s": {"a": 0, "k": [85, 85, 100], "ix": 6}, "sk": {"a": 0, "k": 0}, "p": {"a": 0, "k": [618, 518, 0], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 10}, "sa": {"a": 0, "k": 0}, "o": {"a": 0, "k": 100, "ix": 11}}, "ef": [], "shapes": [{"ty": "gr", "bm": 0, "hd": false, "mn": "ADBE Vector Group", "nm": "Group 1", "ix": 1, "cix": 2, "np": 2, "it": [{"ty": "sh", "bm": 0, "hd": false, "mn": "ADBE Vector Shape - Group", "nm": "Path 1", "ix": 1, "d": 1, "ks": {"a": 0, "k": {"c": true, "i": [[0, -39.212], [39.212, 0], [0, 39.212], [-39.212, 0]], "o": [[0, 39.212], [-39.212, 0], [0, -39.212], [39.212, 0]], "v": [[70, -3], [-1, 68], [-70, 5], [7, -68]]}, "ix": 2}}, {"ty": "fl", "bm": 0, "hd": false, "mn": "ADBE Vector Graphic - Fill", "nm": "Fill 1", "c": {"a": 0, "k": [0, 0, 0], "ix": 4}, "r": 1, "o": {"a": 0, "k": 100, "ix": 5}}, {"ty": "tr", "a": {"a": 0, "k": [0, 0], "ix": 1}, "s": {"a": 0, "k": [100, 100], "ix": 3}, "sk": {"a": 0, "k": 0, "ix": 4}, "p": {"a": 0, "k": [70.25, 68.25], "ix": 2}, "r": {"a": 0, "k": 0, "ix": 6}, "sa": {"a": 0, "k": 0, "ix": 5}, "o": {"a": 0, "k": 100, "ix": 7}}]}], "ind": 9}], "v": "5.7.13", "fr": 29.9700012207031, "op": 197.000008023974, "ip": 0, "assets": []} \ No newline at end of file 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