Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/main/kotlin/app/morphe/gui/App.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import app.morphe.gui.ui.components.SakuraPetals
import app.morphe.gui.util.applyTitleBarTint
import cafe.adriel.voyager.navigator.Navigator
import cafe.adriel.voyager.transitions.SlideTransition
import app.morphe.gui.data.repository.ActiveMode
import app.morphe.gui.data.repository.ConfigRepository
import app.morphe.gui.data.repository.PatchSourceManager
import app.morphe.gui.di.appModule
Expand Down Expand Up @@ -100,7 +101,13 @@ private fun AppContent(
val config = configRepository.loadConfig()
themePreference = config.getThemePreference()
isSimplifiedMode = config.useSimplifiedMode

autoStartAdb = config.autoStartAdb
// Publish the initial active mode BEFORE the VMs subscribe so their
// activeMode listener fires with the correct value on first emit.
patchSourceManager.setActiveMode(
if (isSimplifiedMode) ActiveMode.QUICK else ActiveMode.EXPERT
)
isLoading = false
}

Expand All @@ -116,6 +123,13 @@ private fun AppContent(
// Callback for changing mode
val onModeChange: (Boolean) -> Unit = { simplified ->
isSimplifiedMode = simplified
// Update the manager immediately so the now-visible mode's VM
// starts reacting to source changes and the now-hidden one stops —
// prevents duplicate parallel loads and the cancellation cascade
// that comes with them.
patchSourceManager.setActiveMode(
if (simplified) ActiveMode.QUICK else ActiveMode.EXPERT
)
scope.launch {
configRepository.setUseSimplifiedMode(simplified)
Logger.info("Mode changed to: ${if (simplified) "Simplified" else "Full"}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

/**
* Which top-level UI mode is currently visible. Used by [PatchSourceManager]
* to gate per-VM patch loading so only the visible mode's VM does the work.
*/
enum class ActiveMode { QUICK, EXPERT }

/**
* Manages PatchRepository instances for different patch sources.
* Creates and caches a PatchRepository per GitHub-based source.
Expand Down Expand Up @@ -47,6 +53,25 @@ class PatchSourceManager(
private val _allSources = MutableStateFlow<List<PatchSource>>(emptyList())
val allSources: StateFlow<List<PatchSource>> = _allSources.asStateFlow()

/**
* Which mode's ViewModel is currently driving the UI. Used by both
* [HomeViewModel] (EXPERT) and [QuickPatchViewModel] (QUICK) to skip
* patch-loading when they're not visible — both VMs can be alive
* simultaneously (QuickVM is `remember`-scoped to App.kt; HomeVM is
* created by Voyager when the Navigator branch composes), and without
* this gate they'd race to download the same sources twice on every
* cache clear / source toggle.
*/
private val _activeMode = MutableStateFlow(ActiveMode.QUICK)
val activeMode: StateFlow<ActiveMode> = _activeMode.asStateFlow()

fun setActiveMode(mode: ActiveMode) {
if (_activeMode.value != mode) {
Logger.info("PatchSourceManager: active mode → $mode")
_activeMode.value = mode
}
}

/**
* Load the active source from config and cache its PatchRepository.
* Call once at app startup (from a LaunchedEffect).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package app.morphe.gui.ui.components

import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.VerticalScrollbar
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
Expand All @@ -14,6 +15,7 @@ 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.rememberScrollbarAdapter
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
Expand Down Expand Up @@ -106,12 +108,22 @@ fun SourceManagementSheet(
)
},
text = {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.widthIn(min = 360.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Hoisted so the scrollbar can share the same state as the
// scrolling Column. The scrollbar renders only when the
// content actually overflows (maxValue > 0) — keeps the
// dialog clean for the common case of a handful of sources.
val scrollState = rememberScrollState()
Box {
Column(
modifier = Modifier
.verticalScroll(scrollState)
.widthIn(min = 360.dp)
// Reserve space so rows don't get covered by the
// scrollbar when it appears, plus a bit of breathing
// room so the scrollbar isn't flush against the rows.
.padding(end = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = when {
!enabled -> "Disabled while patching"
Expand Down Expand Up @@ -170,6 +182,18 @@ fun SourceManagementSheet(
letterSpacing = 0.5.sp
)
}
}

if (scrollState.maxValue > 0) {
VerticalScrollbar(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.padding(vertical = 4.dp),
adapter = rememberScrollbarAdapter(scrollState),
style = morpheScrollbarStyle()
)
}
}
},
confirmButton = {
Expand Down
35 changes: 31 additions & 4 deletions src/main/kotlin/app/morphe/gui/ui/screens/home/HomeViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import app.morphe.engine.util.ApkManifestReader
Expand All @@ -29,6 +30,8 @@ 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.data.repository.ActiveMode
import app.morphe.gui.util.humanizePatchLoadError
import java.io.File

class HomeViewModel(
Expand Down Expand Up @@ -68,9 +71,6 @@ class HomeViewModel(
?: emptyList()

init {
// Auto-fetch patches on startup
loadPatchesAndSupportedApps()

// Background CLI update check — non-blocking, banner only.
screenModelScope.launch {
val config = configRepository.loadConfig()
Expand All @@ -85,9 +85,30 @@ class HomeViewModel(
)
}

// Load patches whenever EXPERT becomes the active mode. StateFlow
// emits its current value on subscribe, so this also covers the
// "VM was just created while EXPERT is active" case — replaces the
// unconditional init-block load that used to fire even when the
// user was actually in Quick mode (we don't construct HomeVM in
// pure Quick sessions today, but Voyager keeps it alive across
// mode switches, so the gate prevents wasted reloads on return).
screenModelScope.launch {
patchSourceManager.activeMode.collect { mode ->
if (mode == ActiveMode.EXPERT) {
loadPatchesAndSupportedApps()
}
}
}

// Observe source changes — drop(1) to skip the initial value
screenModelScope.launch {
patchSourceManager.sourceVersion.drop(1).collect {
// Skip when Quick mode is active — QuickPatchViewModel will
// handle the reload for its (single) active source. Without
// this gate both VMs fire parallel loads on every cache
// clear, doubling network traffic and tripling the
// cancellation cascade surface on slow connections.
if (patchSourceManager.activeMode.value != ActiveMode.EXPERT) return@collect
Logger.info("HomeVM: Source changed, reloading patches...")
patchRepository = patchSourceManager.getActiveRepositorySync()
localPatchFilePath = patchSourceManager.getLocalFilePath()
Expand Down Expand Up @@ -250,11 +271,17 @@ class HomeViewModel(
patchLoadError = null
)
reanalyzeSelectedApk()
} catch (e: CancellationException) {
// Cancellation is normal coroutine bookkeeping (a newer load
// superseded this one, or the screen left composition). Do NOT
// write UI state — otherwise a stale "Job was cancelled" can
// clobber the in-flight successor's loading/success state.
throw e
} catch (e: Exception) {
Logger.error("Failed to load patches and supported apps", e)
_uiState.value = _uiState.value.copy(
isLoadingPatches = false,
patchLoadError = e.message ?: "Unknown error"
patchLoadError = humanizePatchLoadError(e),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
Expand All @@ -34,6 +35,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerIcon
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
Expand Down Expand Up @@ -294,6 +296,7 @@ private fun ExpandedBody(
// "Other stable" = supported versions other than the recommended latest.
val otherStable = app.supportedVersions.filter { it != app.recommendedVersion }
val maxPills = 16
val uriHandler = LocalUriHandler.current

Column(
modifier = Modifier
Expand Down Expand Up @@ -332,7 +335,16 @@ private fun ExpandedBody(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
otherStable.take(maxPills).forEach { v ->
Pill(text = v, color = accents.secondary, mono = mono, cornerSmall = cornerSmall)
// URL is a pure function of package + version — compute
// per pill rather than pre-storing all of them on the model.
val url = remember(v) { SupportedApp.getDownloadUrl(app.packageName, v) }
Pill(
text = v,
color = accents.secondary,
mono = mono,
cornerSmall = cornerSmall,
onClick = url?.let { { uriHandler.openUri(it) } },
)
}
if (otherStable.size > maxPills) {
Text(
Expand All @@ -353,7 +365,14 @@ private fun ExpandedBody(
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
app.experimentalVersions.take(maxPills).forEach { v ->
Pill(text = v, color = accents.warning, mono = mono, cornerSmall = cornerSmall)
val url = remember(v) { SupportedApp.getDownloadUrl(app.packageName, v) }
Pill(
text = v,
color = accents.warning,
mono = mono,
cornerSmall = cornerSmall,
onClick = url?.let { { uriHandler.openUri(it) } },
)
}
if (app.experimentalVersions.size > maxPills) {
Text(
Expand Down Expand Up @@ -394,20 +413,54 @@ private fun Pill(
textColor: Color = color,
borderAlpha: Float = 0.3f,
backgroundAlpha: Float = 0.06f,
// When non-null, the pill becomes a tappable download link: gets a hand
// cursor, an OpenInNew icon, a subtle hover lift, and fires onClick on tap.
// detectTapGestures (not .clickable) so scroll wheel / two-finger gestures
// pass through on Linux/Skiko — same reason as the apps-cards Row.
onClick: (() -> Unit)? = null,
) {
val hoverSource = remember { MutableInteractionSource() }
val isHovered by hoverSource.collectIsHoveredAsState()
val isInteractive = onClick != null
val hoveredLift = if (isInteractive && isHovered) 0.20f else 0f
val effectiveBorderAlpha = (borderAlpha + hoveredLift).coerceAtMost(0.85f)
val effectiveBackgroundAlpha = (backgroundAlpha + hoveredLift / 2f).coerceAtMost(0.30f)

Box(
modifier = Modifier
.border(1.dp, color.copy(alpha = borderAlpha), RoundedCornerShape(cornerSmall))
.background(color.copy(alpha = backgroundAlpha), RoundedCornerShape(cornerSmall))
.hoverable(hoverSource)
.then(
if (isInteractive) Modifier
.pointerHoverIcon(PointerIcon.Hand)
.pointerInput(onClick) {
detectTapGestures(onTap = { onClick?.invoke() })
}
else Modifier
)
.border(1.dp, color.copy(alpha = effectiveBorderAlpha), RoundedCornerShape(cornerSmall))
.background(color.copy(alpha = effectiveBackgroundAlpha), RoundedCornerShape(cornerSmall))
.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Text(
text = text,
fontSize = 10.sp,
fontWeight = FontWeight.SemiBold,
fontFamily = mono,
color = textColor,
maxLines = 1,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = text,
fontSize = 10.sp,
fontWeight = FontWeight.SemiBold,
fontFamily = mono,
color = textColor,
maxLines = 1,
)
if (isInteractive) {
Icon(
imageVector = Icons.AutoMirrored.Filled.OpenInNew,
contentDescription = "Open download page",
tint = textColor.copy(alpha = if (isHovered) 0.9f else 0.5f),
modifier = Modifier.size(9.dp),
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ import app.morphe.gui.data.model.Patch
import app.morphe.gui.data.model.SupportedApp
import app.morphe.gui.data.repository.ConfigRepository
import app.morphe.gui.data.repository.PatchSourceManager
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import app.morphe.gui.ui.components.MorpheErrorBar
import app.morphe.gui.ui.components.OfflineBanner
import app.morphe.gui.ui.components.SourceManagementSheet
Expand Down Expand Up @@ -1731,10 +1733,16 @@ private fun SupportedAppsRow(
.fillMaxWidth()
.then(if (useScrolling) Modifier.horizontalScroll(cardsScrollState) else Modifier)
.height(IntrinsicSize.Max)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { focusManager.clearFocus() },
// detectTapGestures (not .clickable) so scroll-wheel /
// two-finger gestures over this Row aren't swallowed.
// .clickable wraps the modifier chain in a pointer-input
// node that consumes scroll events on Linux/Skiko,
// breaking both the inner horizontalScroll and the
// outer page-level verticalScroll. Taps still clear
// the search-bar focus.
.pointerInput(Unit) {
detectTapGestures(onTap = { focusManager.clearFocus() })
},
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
filteredApps.forEach { app ->
Expand Down
Loading
Loading