mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
Library: find save states in both data roots, and restore swipe-to-dismiss
Two follow-ups from testing the long-press menu. ★ SaveSlotLookup only searched ONE root. A device with a configured system directory has two — assetCopyRoot resolves to that one (typically the SD card, where ROMs and most saves live) while others stay under getExternalFilesDir. On the test device 13 states sat in one and 5 in the other, so either root alone under-reports, and the failure is silent: it reads as 'this game has no save states' rather than as a bug. This is the same two-root trap that once made a patches investigation report a false 'clean'. Both are searched now, and when a slot exists in both the newer file wins. Swipe-to-dismiss is back for bottom-aligned modals. PadModal replaced ModalBottomSheet because that is its own focused Android window and every row inside it was unreachable by pad; the swipe was the one thing given up in the trade. But a panel that rises from the bottom edge with a rounded top and a drag handle is PROMISING a swipe, so its absence reads as broken rather than as a deliberate omission. The panel now follows the finger and dismisses past a threshold, without giving up focus ownership. Downward only, and only for BottomCenter: dragging a bottom sheet up should not lift it off the edge it is anchored to, and on centred or anchored menus a vertical drag means nothing and would fight scrolling inside them. Worth recording that the originally reported symptom was NOT a bug: God of War II has no save states on the test device, so an absent row was correct. The root bug was real but found by inspection while checking that.
This commit is contained in:
@@ -29,9 +29,21 @@ object SaveSlotLookup {
|
|||||||
*/
|
*/
|
||||||
fun slotsFor(context: Context, serial: String?): List<Slot> {
|
fun slotsFor(context: Context, serial: String?): List<Slot> {
|
||||||
if (serial.isNullOrBlank()) return emptyList()
|
if (serial.isNullOrBlank()) return emptyList()
|
||||||
val root = runCatching { MainActivityRuntime.assetCopyRoot(context) }.getOrNull() ?: return emptyList()
|
// ★ BOTH roots, not one.
|
||||||
return listOf("sstates", "savestates")
|
//
|
||||||
.map { File(root, it) }
|
// A device with a configured system directory has two: assetCopyRoot resolves to that
|
||||||
|
// (typically the SD card, where ROMs and most saves live) while some states stay under
|
||||||
|
// getExternalFilesDir. On the test device 13 states were on one and 5 on the other, so
|
||||||
|
// checking either alone silently under-reports — which reads as "this game has no save
|
||||||
|
// states" rather than as a bug.
|
||||||
|
val roots = buildList {
|
||||||
|
runCatching { MainActivityRuntime.assetCopyRoot(context) }.getOrNull()?.let(::add)
|
||||||
|
runCatching { context.getExternalFilesDir(null)?.absolutePath }.getOrNull()?.let(::add)
|
||||||
|
}.distinct()
|
||||||
|
if (roots.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
return roots
|
||||||
|
.flatMap { root -> listOf("sstates", "savestates").map { File(root, it) } }
|
||||||
.filter { it.isDirectory }
|
.filter { it.isDirectory }
|
||||||
.flatMap { dir ->
|
.flatMap { dir ->
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -46,6 +58,9 @@ object SaveSlotLookup {
|
|||||||
?: return@mapNotNull null
|
?: return@mapNotNull null
|
||||||
Slot(slot, file, file.lastModified())
|
Slot(slot, file, file.lastModified())
|
||||||
}
|
}
|
||||||
|
// Same slot in both roots: keep the newer file, since that is the one the emulator
|
||||||
|
// most recently wrote.
|
||||||
|
.sortedByDescending { it.modified }
|
||||||
.distinctBy { it.slot }
|
.distinctBy { it.slot }
|
||||||
.sortedBy { it.slot }
|
.sortedBy { it.slot }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberUpdatedState
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -220,13 +225,48 @@ fun PadModalHost() {
|
|||||||
val anchor = entry.anchor.value
|
val anchor = entry.anchor.value
|
||||||
// Absorb taps on the panel itself, or the scrim's dismiss fires through it and
|
// Absorb taps on the panel itself, or the scrim's dismiss fires through it and
|
||||||
// the modal closes as you press its own buttons.
|
// the modal closes as you press its own buttons.
|
||||||
|
// Swipe-to-dismiss, for bottom-aligned panels only.
|
||||||
|
//
|
||||||
|
// PadModal replaced ModalBottomSheet because that is its own focused Android
|
||||||
|
// window and every row inside it was unreachable by pad. The one thing lost in the
|
||||||
|
// trade was the swipe — and a panel that rises from the bottom edge with a rounded
|
||||||
|
// top and a drag handle is *promising* a swipe, so its absence reads as broken
|
||||||
|
// rather than as a deliberate omission. Restored here without giving up focus.
|
||||||
|
//
|
||||||
|
// Only for BottomCenter: on a centred or anchored menu a downward drag means
|
||||||
|
// nothing, and hijacking it would break scrolling inside those panels.
|
||||||
|
val bottomAligned = entry.alignment.value == Alignment.BottomCenter
|
||||||
val absorbTaps = @Composable { inner: @Composable () -> Unit ->
|
val absorbTaps = @Composable { inner: @Composable () -> Unit ->
|
||||||
|
var dragOffset by remember { mutableFloatStateOf(0f) }
|
||||||
|
val dismissThresholdPx = with(LocalDensity.current) { 110.dp.toPx() }
|
||||||
Box(
|
Box(
|
||||||
Modifier.clickable(
|
Modifier
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
.clickable(
|
||||||
indication = null,
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
onClick = {},
|
indication = null,
|
||||||
),
|
onClick = {},
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
if (!bottomAligned) Modifier
|
||||||
|
else Modifier
|
||||||
|
// Follows the finger, so the gesture is visibly doing
|
||||||
|
// something before it commits.
|
||||||
|
.offset { IntOffset(0, dragOffset.roundToInt()) }
|
||||||
|
.pointerInput(entry.key) {
|
||||||
|
detectVerticalDragGestures(
|
||||||
|
onDragEnd = {
|
||||||
|
if (dragOffset > dismissThresholdPx) PadModals.dismissTop()
|
||||||
|
dragOffset = 0f
|
||||||
|
},
|
||||||
|
onDragCancel = { dragOffset = 0f },
|
||||||
|
) { change, delta ->
|
||||||
|
change.consume()
|
||||||
|
// Downward only — dragging a bottom sheet UP should
|
||||||
|
// not lift it off the edge it is anchored to.
|
||||||
|
dragOffset = (dragOffset + delta).coerceAtLeast(0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
CompositionLocalProvider(LocalNavLayer provides entry.key) { inner() }
|
CompositionLocalProvider(LocalNavLayer provides entry.key) { inner() }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user