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
16 changes: 14 additions & 2 deletions app/src/main/java/app/morphe/manager/ui/screen/PatcherScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.snap
import androidx.compose.animation.core.tween
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
Expand Down Expand Up @@ -46,6 +50,7 @@ import app.morphe.manager.ui.screen.shared.LocalDialogSecondaryTextColor
import app.morphe.manager.ui.screen.shared.MorpheAnimations
import app.morphe.manager.ui.screen.shared.MorpheDialog
import app.morphe.manager.ui.screen.shared.MorpheDialogButtonRow
import app.morphe.manager.ui.screen.shared.rememberAccessibilityEnabled
import app.morphe.manager.ui.viewmodel.InstallViewModel
import app.morphe.manager.ui.viewmodel.PatcherViewModel
import app.morphe.manager.util.APK_MIMETYPE
Expand Down Expand Up @@ -107,9 +112,12 @@ fun PatcherScreen(
if (showSuccessScreen) miniGameState.pauseActiveGame()
}

// Skip the 1.5s tween on every progress tick when TalkBack is active so the main thread
// isn't constantly busy interpolating and can serve accessibility events instead
val reduceMotion = rememberAccessibilityEnabled()
val displayProgressAnimate by animateFloatAsState(
targetValue = displayProgress,
animationSpec = tween(durationMillis = 1500, easing = FastOutSlowInEasing),
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 1500, easing = FastOutSlowInEasing),
label = "progress_animation"
)

Expand Down Expand Up @@ -499,7 +507,11 @@ fun PatcherScreen(

AnimatedContent(
targetState = if (showSuccessScreen) state.currentPatcherState else PatcherState.IN_PROGRESS,
transitionSpec = MorpheAnimations.fadeCrossfade(800),
transitionSpec = if (reduceMotion) {
{ EnterTransition.None togetherWith ExitTransition.None }
} else {
MorpheAnimations.fadeCrossfade(800)
},
label = "patcher_state_animation"
) { patcherState ->
when (patcherState) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1980,16 +1980,16 @@ fun ExpandableSurface(
Surface(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.clickable { expanded = !expanded },
.clip(RoundedCornerShape(12.dp)),
shape = RoundedCornerShape(12.dp),
color = headerTint.copy(alpha = 0.05f)
) {
Column(modifier = Modifier.fillMaxWidth()) {
// Header
// Click target only on the header so expanded content stays independently focusable for screen readers
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,11 @@ fun MainAppsSection(
localOrder.mapNotNull { byPackage[it] }
}

// Polite TalkBack announcement after a screen-reader-triggered Move action.
// Empty until the first move; cleared by the next compose if needed
var moveAnnouncement by remember { mutableStateOf("") }
val moveAnnouncementFormat = stringResource(R.string.accessibility_app_moved_announcement)

// True empty state: loaded, no apps from any bundle (no sources / all disabled)
val isNoSourcesState = !stableLoadingState.value && homeAppItems.isEmpty() && hiddenAppItems.isEmpty()
// All-hidden state: apps exist but all are hidden
Expand All @@ -961,6 +966,14 @@ fun MainAppsSection(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
// Hidden polite live region used to announce the result of TalkBack Move up/down actions
Spacer(
modifier = Modifier.semantics {
liveRegion = LiveRegionMode.Polite
contentDescription = moveAnnouncement
}
)

AnimatedContent(
targetState = isEmptyState,
transitionSpec = MorpheAnimations.fadeCrossfade(300),
Expand Down Expand Up @@ -1062,6 +1075,9 @@ fun MainAppsSection(
}
}
} else {
// Direct reorder a11y actions are exposed only when there's no search
// filter and no multi-select active so the indices match localOrder
val directReorderAllowed = searchQuery.isBlank() && !isMultiSelectMode.value
itemsIndexed(
items = filteredItems,
key = { _, item -> item.packageName }
Expand Down Expand Up @@ -1097,6 +1113,36 @@ fun MainAppsSection(
else
selectedPackages.value + item.packageName
},
onMoveUp = if (directReorderAllowed && index > 0) {
{
val current = localOrder.toMutableList()
val from = current.indexOf(item.packageName)
if (from > 0) {
val moved = current.removeAt(from)
current.add(from - 1, moved)
localOrder = current
onSaveOrder(current)
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
moveAnnouncement = moveAnnouncementFormat
.format(item.displayName, from, current.size)
}
}
} else null,
onMoveDown = if (directReorderAllowed && index < filteredItems.size - 1) {
{
val current = localOrder.toMutableList()
val from = current.indexOf(item.packageName)
if (from in 0 until current.size - 1) {
val moved = current.removeAt(from)
current.add(from + 1, moved)
localOrder = current
onSaveOrder(current)
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
moveAnnouncement = moveAnnouncementFormat
.format(item.displayName, from + 2, current.size)
}
}
} else null,
modifier = Modifier
.animateItem()
.then(
Expand Down Expand Up @@ -1478,7 +1524,9 @@ private fun DynamicAppCard(
isSelected: Boolean = false,
isMultiSelectMode: Boolean = false,
onLongPress: () -> Unit = {},
dragHandleModifier: Modifier? = null
dragHandleModifier: Modifier? = null,
onMoveUp: (() -> Unit)? = null,
onMoveDown: (() -> Unit)? = null
) {
val showHideDialog = remember { mutableStateOf(false) }
val density = LocalDensity.current
Expand Down Expand Up @@ -1510,6 +1558,8 @@ private fun DynamicAppCard(

val hideLabel = stringResource(R.string.hide)
val patchesLabel = stringResource(R.string.patches)
val moveUpLabel = stringResource(R.string.accessibility_move_up)
val moveDownLabel = stringResource(R.string.accessibility_move_down)
val errorContainer = MaterialTheme.colorScheme.errorContainer
val onErrorContainer = MaterialTheme.colorScheme.onErrorContainer
val primaryContainer = MaterialTheme.colorScheme.primaryContainer
Expand All @@ -1533,10 +1583,16 @@ private fun DynamicAppCard(
}

Box(modifier = modifier.fillMaxWidth().semantics {
customActions = listOf(
CustomAccessibilityAction(hideLabel) { showHideDialog.value = true; true },
CustomAccessibilityAction(patchesLabel) { onShowPatches(); true }
)
customActions = buildList {
add(CustomAccessibilityAction(hideLabel) { showHideDialog.value = true; true })
add(CustomAccessibilityAction(patchesLabel) { onShowPatches(); true })
if (onMoveUp != null) {
add(CustomAccessibilityAction(moveUpLabel) { onMoveUp(); true })
}
if (onMoveDown != null) {
add(CustomAccessibilityAction(moveDownLabel) { onMoveDown(); true })
}
}
}) {
SwipeableCardContainer(
offsetX = offsetX,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
Expand Down Expand Up @@ -468,22 +469,8 @@ private fun BundleManagementCard(
}
}

Column(modifier = Modifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (!forceExpanded) onToggleExpanded()
}
.semantics {
if (!forceExpanded) {
role = Role.Button
stateDescription = if (expanded) expandedState else collapsedState
}
this.contentDescription = contentDesc
}
.padding(16.dp)) {
// Header
Column(modifier = Modifier.padding(16.dp)) {
// Click target only on the header so expanded children stay independently focusable for screen readers
BundleCardHeader(
bundle = bundle,
updateInfo = updateInfo,
Expand All @@ -492,6 +479,19 @@ private fun BundleManagementCard(
enabled = isEnabled,
metadataFetchError = metadataFetchError,
modifier = longPressModifier
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
if (!forceExpanded) onToggleExpanded()
}
.semantics(mergeDescendants = true) {
if (!forceExpanded) {
role = Role.Button
stateDescription = if (expanded) expandedState else collapsedState
}
this.contentDescription = contentDesc
}
)

// Expanded content
Expand Down Expand Up @@ -591,7 +591,15 @@ private fun BundleManagementCard(
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onPrereleasesToggle(!currentUsePrerelease) }
.toggleable(
value = currentUsePrerelease,
role = Role.Switch,
enabled = !isUpdating,
onValueChange = onPrereleasesToggle
)
.semantics {
stateDescription = if (currentUsePrerelease) enabledState else disabledState
}
.padding(vertical = 4.dp)
.then(
if (onPrereleaseBtnPositioned != null)
Expand All @@ -618,10 +626,28 @@ private fun BundleManagementCard(

Spacer(Modifier.width(8.dp))

MorpheSwitch(
checked = currentUsePrerelease,
onCheckedChange = onPrereleasesToggle
)
Crossfade(
targetState = isUpdating,
modifier = Modifier.size(width = 52.dp, height = 32.dp),
label = "prerelease_toggle_loading"
) { updating ->
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (updating) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
} else {
MorpheSwitch(
checked = currentUsePrerelease,
onCheckedChange = null
)
}
}
}
}
}

Expand All @@ -636,8 +662,13 @@ private fun BundleManagementCard(
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
onExperimentalVersionsToggle?.invoke(!useExperimentalVersions)
.toggleable(
value = useExperimentalVersions,
role = Role.Switch,
onValueChange = { onExperimentalVersionsToggle?.invoke(it) }
)
.semantics {
stateDescription = if (useExperimentalVersions) enabledState else disabledState
}
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
Expand All @@ -660,7 +691,7 @@ private fun BundleManagementCard(

MorpheSwitch(
checked = useExperimentalVersions,
onCheckedChange = { onExperimentalVersionsToggle?.invoke(it) }
onCheckedChange = null
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,11 +270,9 @@ private fun ProgressDetailsSection(
*/
@Composable
private fun AnimatedMessage(messageResId: Int) {
AnimatedContent(
targetState = stringResource(messageResId),
transitionSpec = MorpheAnimations.fadeCrossfade(1000),
label = "message_animation"
) { message ->
val reduceMotion = rememberAccessibilityEnabled()
val message = stringResource(messageResId)
if (reduceMotion) {
Text(
text = message,
style = MaterialTheme.typography.titleLarge,
Expand All @@ -284,6 +282,22 @@ private fun AnimatedMessage(messageResId: Int) {
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
} else {
AnimatedContent(
targetState = message,
transitionSpec = MorpheAnimations.fadeCrossfade(1000),
label = "message_animation"
) { rotatingMessage ->
Text(
text = rotatingMessage,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onBackground,
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
}
}
}

Expand Down Expand Up @@ -367,23 +381,40 @@ fun CurrentStepIndicator(
patcherViewModel.steps.firstOrNull { it.state == State.RUNNING }
}
}
val reduceMotion = rememberAccessibilityEnabled()
val stepName = currentStep?.name

AnimatedContent(
targetState = currentStep?.name,
transitionSpec = MorpheAnimations.fadeCrossfade(400),
label = "step_animation"
) { stepName ->
val stepStyle = when (windowSize.widthSizeClass) {
WindowWidthSizeClass.Compact -> MaterialTheme.typography.bodyLarge
else -> MaterialTheme.typography.titleMedium
}

if (reduceMotion) {
// Skip crossfade so the main thread isn't busy animating when TalkBack tries to announce
if (stepName != null) {
Text(
text = stepName,
style = when (windowSize.widthSizeClass) {
WindowWidthSizeClass.Compact -> MaterialTheme.typography.bodyLarge
else -> MaterialTheme.typography.titleMedium
},
style = stepStyle,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
} else {
AnimatedContent(
targetState = stepName,
transitionSpec = MorpheAnimations.fadeCrossfade(400),
label = "step_animation"
) { name ->
if (name != null) {
Text(
text = name,
style = stepStyle,
color = MaterialTheme.colorScheme.primary,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
Loading
Loading