From fd4a6bdad6b29ec74e8e0d3b9988a2ea715cce63 Mon Sep 17 00:00:00 2001 From: Evan Koehler <72010109+EvanKoe@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:02:13 +0200 Subject: [PATCH 1/4] [FEAT] Pop up animation for notification when SWE enabled --- .../expressivecutout/overlay/DynamicIsland.kt | 30 +++++++++++++++++++ .../expressivecutout/overlay/IslandMotion.kt | 28 +++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/app/src/main/java/com/ekoehler/expressivecutout/overlay/DynamicIsland.kt b/app/src/main/java/com/ekoehler/expressivecutout/overlay/DynamicIsland.kt index 19a0845..68ce4d1 100644 --- a/app/src/main/java/com/ekoehler/expressivecutout/overlay/DynamicIsland.kt +++ b/app/src/main/java/com/ekoehler/expressivecutout/overlay/DynamicIsland.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.Spring @@ -468,6 +469,16 @@ fun DynamicIsland( ) } + // The normal cutout's icon pops in whenever a new event takes the pill over. What counts as + // "new" is deliberately not the event id: a live tile re-resolves on every refresh (a media + // progress tick, a timer second) and gets a fresh id each time, which would re-pop constantly. + // A notification's key survives its own updates, and a tile's label survives its lifetime. + val iconPop = remember { Animatable(1f) } + val iconPopKey = event?.let { it.notificationKey ?: it.label } + LaunchedEffect(iconPopKey) { + if (iconPopKey != null) motion.popIn(iconPop) + } + LaunchedEffect(emptyPill) { if (emptyPill) { tapExpanded = false @@ -810,6 +821,7 @@ fun DynamicIsland( heightDp = collapsed.heightDp, isStickToCamera = isStickToCamera, trailingInsetDp = collapsedTrailingInsetDp, + iconPop = iconPop, ) } } @@ -1008,6 +1020,10 @@ private fun albumArtStrokeFor(event: IslandEvent): Color? = * @param trailingInsetDp extra room to leave on the trailing edge, so whatever the caller draws * there — today the permission dots — isn't overlapped by the timer's remaining time or the * progress ring. + * @param iconPop scale for the badge's arrival pop, driven by the caller. Hoisted rather than owned + * here because the collapsed content re-enters composition every time the island collapses back + * from expanded, which would otherwise re-fire the pop on a collapse. Null (the settings preview) + * leaves the badge at rest. */ @Composable private fun CollapsedContent( @@ -1015,6 +1031,7 @@ private fun CollapsedContent( heightDp: Int, isStickToCamera: Boolean = false, trailingInsetDp: Int = 0, + iconPop: Animatable? = null, ) { // The music tile shows album art, the phone tile the caller's photo, on the normal cutout. val nowPlaying by NowPlayingBus.state.collectAsStateWithLifecycle() @@ -1024,12 +1041,25 @@ private fun CollapsedContent( val badgeSize = (heightDp * 0.72f).dp Box(modifier = Modifier.fillMaxSize()) { + // Scaled after the padding so the pop grows the badge about its own centre instead of + // dragging it in from the pill's edge, and read inside the layer block so each frame redraws + // without recomposing the pill. val placement = Modifier .align(if (isStickToCamera) Alignment.BottomCenter else Alignment.CenterStart) .padding( start = if (isStickToCamera) 0.dp else (heightDp * 0.16f).dp, bottom = if (isStickToCamera) (heightDp * 0.14f).dp else 0.dp, ) + .then( + if (iconPop != null) { + Modifier.graphicsLayer { + scaleX = iconPop.value + scaleY = iconPop.value + } + } else { + Modifier + } + ) when { albumArt != null -> AlbumArt( bitmap = albumArt, diff --git a/app/src/main/java/com/ekoehler/expressivecutout/overlay/IslandMotion.kt b/app/src/main/java/com/ekoehler/expressivecutout/overlay/IslandMotion.kt index b7b7ddb..78ef084 100644 --- a/app/src/main/java/com/ekoehler/expressivecutout/overlay/IslandMotion.kt +++ b/app/src/main/java/com/ekoehler/expressivecutout/overlay/IslandMotion.kt @@ -100,6 +100,24 @@ internal class IslandMotion( } } + /** + * Pops a freshly arrived icon into the normal cutout: it starts at [POP_IN_START_SCALE] and + * springs up to rest, overshooting slightly. When an event lands on a cutout that is already on + * screen — the resting "shows when empty" pill, or one notification replacing another — neither + * the reveal nor the size transition runs, so this is the only thing marking the arrival. + * + * Snaps to the start scale itself, so a second arrival mid-animation restarts the pop instead of + * springing on from wherever the previous one had got to. + */ + suspend fun popIn(scale: Animatable) { + scale.snapTo(POP_IN_START_SCALE) + scale.animateTo( + targetValue = REST_SCALE, + animationSpec = if (expressive) spatialSpec(speed, bounce, visibilityThreshold = 0.0005f) + else tween(durationMillis = scaled(POP_IN_MS), easing = EaseInOutEasing), + ) + } + /** Alpha / colour motion: critically damped (no overshoot), so fades never over-brighten. */ fun fade(): AnimationSpec = if (expressive) effectsSpec(speed) @@ -137,6 +155,16 @@ internal class IslandMotion( */ private const val POP_PEAK_RATIO = 0.5216f + /** + * Where [popIn] starts from. The badge is small enough on the pill that the growth has to be + * a good half of its resting size to register at all; the spring's overshoot scales with that + * travel too, so a lower start buys a taller pop at both ends of the arc. + */ + private const val POP_IN_START_SCALE = 0.55f + + /** [popIn]'s length under [AnimationStyle.EASE_IN_OUT], which has no spring to settle. */ + private const val POP_IN_MS = 200 + /** * A spatial spring based on the Material 3 expressive MotionScheme tokens: [speed] sets the * stiffness (the MotionScheme slow / default / fast values) and [bounce] the damping. From a67b8cb1ed03fe480c93e09234b15c6c967ae197 Mon Sep 17 00:00:00 2001 From: Evan Koehler <72010109+EvanKoe@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:51:49 +0200 Subject: [PATCH 2/4] [FEAT] Added Shared component for selector --- .../ui/components/OptionSelectionCard.kt | 213 ++++++++++++++++++ .../screen/SettingScreens/BehaviourScreen.kt | 120 +++------- 2 files changed, 246 insertions(+), 87 deletions(-) create mode 100644 app/src/main/java/com/ekoehler/expressivecutout/ui/components/OptionSelectionCard.kt diff --git a/app/src/main/java/com/ekoehler/expressivecutout/ui/components/OptionSelectionCard.kt b/app/src/main/java/com/ekoehler/expressivecutout/ui/components/OptionSelectionCard.kt new file mode 100644 index 0000000..ef9b227 --- /dev/null +++ b/app/src/main/java/com/ekoehler/expressivecutout/ui/components/OptionSelectionCard.kt @@ -0,0 +1,213 @@ +package com.ekoehler.expressivecutout.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.ripple +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp + +/** + * One entry of an [OptionSelectionCard]. [value] is what gets handed back on selection, so callers + * can pass an enum entry, an id, or any other key and stay type-safe. + */ +data class SelectableOption( + val value: T, + val title: String, + val description: String? = null, + val enabled: Boolean = true, +) + +/** + * A titled card holding a single-choice list of options, each with its own title and optional + * description. The selected row fills with the theme's secondary container so it reads as chosen in + * both light and dark dynamic-colour schemes; [onSelectionChange] fires with the tapped option's + * value. Rows already tapped don't re-emit, and the whole group is exposed as one radio group to + * accessibility services. Pass [shape] to slot the card into a grouped settings list. + */ +@Composable +fun OptionSelectionCard( + title: String, + options: List>, + selectedValue: T?, + onSelectionChange: (T) -> Unit, + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(32.dp), + containerColor: Color = MaterialTheme.colorScheme.surface, + enabled: Boolean = true, +) { + val haptics = LocalHapticFeedback.current + Surface( + modifier = modifier.fillMaxWidth(), + shape = shape, + color = containerColor, + ) { + Column( + modifier = Modifier.padding( + start = 12.dp, + end = 12.dp, + top = 16.dp, + bottom = 12.dp, + ), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + Column( + modifier = Modifier.selectableGroup(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + options.forEachIndexed { index, option -> + OptionRow( + option = option, + shape = optionShape( + isFirst = index == 0, + isLast = index == options.lastIndex, + ), + selected = option.value == selectedValue, + enabled = enabled && option.enabled, + onClick = { + haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onSelectionChange(option.value) + }, + ) + } + } + } + } +} + +/** Grouped-list row shape: the group's outer corners round, the ones between rows stay tight. */ +private fun optionShape(isFirst: Boolean, isLast: Boolean) = RoundedCornerShape( + topStart = if (isFirst) 24.dp else 4.dp, + topEnd = if (isFirst) 24.dp else 4.dp, + bottomStart = if (isLast) 24.dp else 4.dp, + bottomEnd = if (isLast) 24.dp else 4.dp, +) + +/** + * A single selectable row of [OptionSelectionCard]. Instead of the flat bounded ripple, pressing a + * row squashes it on a bouncy spring, then it settles into the selected container colour — the + * motion feedback Material 3 Expressive uses in place of a plain ripple wash. A soft + * secondary-tinted ripple rides along underneath so the touch point still reads. + */ +@Composable +private fun OptionRow( + option: SelectableOption, + shape: Shape, + selected: Boolean, + enabled: Boolean, + onClick: () -> Unit, +) { + val contentAlpha = if (enabled) 1f else 0.38f + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + val bouncy = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + + val containerColor by animateColorAsState( + targetValue = if (selected) { + MaterialTheme.colorScheme.secondaryContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + }, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "optionContainerColor", + ) + val titleColor by animateColorAsState( + targetValue = if (selected) { + MaterialTheme.colorScheme.onSecondaryContainer + } else { + MaterialTheme.colorScheme.onSurface + }, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "optionTitleColor", + ) + val scale by animateFloatAsState( + targetValue = if (pressed) 0.96f else 1f, + animationSpec = bouncy, + label = "optionScale", + ) + + Surface( + modifier = Modifier + .scale(scale) + .fillMaxWidth() + .clip(shape) + .selectable( + selected = selected, + interactionSource = interactionSource, + indication = ripple(color = MaterialTheme.colorScheme.secondary), + enabled = enabled && !selected, + role = Role.RadioButton, + onClick = onClick, + ), + shape = shape, + color = containerColor, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = option.title, + style = MaterialTheme.typography.titleSmall, + color = titleColor.copy(alpha = contentAlpha), + ) + option.description?.let { description -> + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = contentAlpha), + ) + } + } + Spacer(Modifier.width(16.dp)) + RadioButton( + selected = selected, + onClick = null, + enabled = enabled, + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary, + unselectedColor = MaterialTheme.colorScheme.outline, + ), + ) + } + } +} diff --git a/app/src/main/java/com/ekoehler/expressivecutout/ui/screen/SettingScreens/BehaviourScreen.kt b/app/src/main/java/com/ekoehler/expressivecutout/ui/screen/SettingScreens/BehaviourScreen.kt index d917470..904c554 100644 --- a/app/src/main/java/com/ekoehler/expressivecutout/ui/screen/SettingScreens/BehaviourScreen.kt +++ b/app/src/main/java/com/ekoehler/expressivecutout/ui/screen/SettingScreens/BehaviourScreen.kt @@ -25,13 +25,7 @@ import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.width -import androidx.compose.material3.RadioButton -import androidx.compose.ui.Alignment -import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.core.view.HapticFeedbackConstantsCompat @@ -43,6 +37,8 @@ import com.ekoehler.expressivecutout.data.SwipeDismissTarget import com.ekoehler.expressivecutout.overlay.expandedActionsExtraDp import com.ekoehler.expressivecutout.ui.AppViewModel import com.ekoehler.expressivecutout.ui.components.ExpressiveSegmentedRow +import com.ekoehler.expressivecutout.ui.components.OptionSelectionCard +import com.ekoehler.expressivecutout.ui.components.SelectableOption import kotlin.math.roundToInt /** Grouped-list item shape: large outer corners at the group ends, small between items. */ @@ -53,6 +49,24 @@ private fun groupedShape(isFirst: Boolean, isLast: Boolean) = RoundedCornerShape bottomEnd = if (isLast) 32.dp else 4.dp, ) +/** Label shown for each landscape cutout mode in the options card. */ +private val HorizontalCutoutMode.titleRes: Int + get() = when (this) { + HorizontalCutoutMode.HIDDEN -> R.string.horizontal_cutout_hidden + HorizontalCutoutMode.NORMAL_ONLY -> R.string.horizontal_cutout_normal_only + HorizontalCutoutMode.STICK_TO_CAMERA -> R.string.horizontal_cutout_stick_to_camera + HorizontalCutoutMode.CENTER -> R.string.horizontal_cutout_center + } + +/** One-line explanation shown under each landscape cutout mode. */ +private val HorizontalCutoutMode.descriptionRes: Int + get() = when (this) { + HorizontalCutoutMode.HIDDEN -> R.string.horizontal_cutout_hidden_desc + HorizontalCutoutMode.NORMAL_ONLY -> R.string.horizontal_cutout_normal_only_desc + HorizontalCutoutMode.STICK_TO_CAMERA -> R.string.horizontal_cutout_stick_to_camera_desc + HorizontalCutoutMode.CENTER -> R.string.horizontal_cutout_center_desc + } + @Composable internal fun BehaviourScreen( viewModel: AppViewModel, @@ -76,6 +90,19 @@ internal fun BehaviourScreen( .padding(contentPadding), verticalArrangement = Arrangement.spacedBy(4.dp), ) { + OptionSelectionCard( + modifier = Modifier.padding(bottom = 8.dp), + title = stringResource(R.string.behaviour_horizontal_cutout), + options = HorizontalCutoutMode.entries.map { mode -> + SelectableOption( + value = mode, + title = stringResource(mode.titleRes), + description = stringResource(mode.descriptionRes), + ) + }, + selectedValue = behaviour.horizontalCutoutMode, + onSelectionChange = viewModel::setHorizontalCutoutMode, + ) // Grouped list: the first item's top corners and the last item's bottom corners round. SettingsToggleCard( shape = groupedShape(isFirst = true, isLast = false), @@ -84,32 +111,6 @@ internal fun BehaviourScreen( checked = behaviour.hideOnLockscreen, onCheckedChange = viewModel::setHideOnLockscreen, ) - BehaviourRadioGroupCard( - shape = groupedShape(isFirst = false, isLast = false), - title = stringResource(R.string.behaviour_horizontal_cutout), - options = listOf( - RadioOption( - title = stringResource(R.string.horizontal_cutout_hidden), - description = stringResource(R.string.horizontal_cutout_hidden_desc), - ), - RadioOption( - title = stringResource(R.string.horizontal_cutout_normal_only), - description = stringResource(R.string.horizontal_cutout_normal_only_desc), - ), - RadioOption( - title = stringResource(R.string.horizontal_cutout_stick_to_camera), - description = stringResource(R.string.horizontal_cutout_stick_to_camera_desc), - ), - RadioOption( - title = stringResource(R.string.horizontal_cutout_center), - description = stringResource(R.string.horizontal_cutout_center_desc), - ), - ), - selectedIndex = behaviour.horizontalCutoutMode.ordinal, - onSelect = { index -> - viewModel.setHorizontalCutoutMode(HorizontalCutoutMode.entries[index]) - }, - ) BehaviourSliderRow( shape = groupedShape(isFirst = false, isLast = false), label = stringResource(R.string.behaviour_normal_duration), @@ -313,58 +314,3 @@ private fun BehaviourSliderRow( } } } - -/** One choice in a radio group: its title and the line of explanation under it. */ -private data class RadioOption( - val title: String, - val description: String, -) - -@Composable -private fun BehaviourRadioGroupCard( - shape: Shape, - title: String, - options: List, - selectedIndex: Int, - onSelect: (Int) -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = shape, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - ) { - Column( - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(text = title, style = MaterialTheme.typography.titleMedium) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - options.forEachIndexed { index, option -> - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable { onSelect(index) } - .padding(horizontal = 8.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = option.title, style = MaterialTheme.typography.bodyMedium) - Text( - text = option.description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.width(12.dp)) - RadioButton( - selected = (index == selectedIndex), - onClick = { onSelect(index) }, - ) - } - } - } - } - } -} - From ced5289ea956e0b672ca5703cd035c0ae653a7ff Mon Sep 17 00:00:00 2001 From: Evan Koehler <72010109+EvanKoe@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:06:54 +0200 Subject: [PATCH 3/4] [FEAT] Replaced selector with OptionSelector component --- .../ui/components/OptionSelectionCard.kt | 19 +- .../SettingScreens/Appearance/ButtonScreen.kt | 352 ++++++------------ 2 files changed, 139 insertions(+), 232 deletions(-) diff --git a/app/src/main/java/com/ekoehler/expressivecutout/ui/components/OptionSelectionCard.kt b/app/src/main/java/com/ekoehler/expressivecutout/ui/components/OptionSelectionCard.kt index ef9b227..f46a386 100644 --- a/app/src/main/java/com/ekoehler/expressivecutout/ui/components/OptionSelectionCard.kt +++ b/app/src/main/java/com/ekoehler/expressivecutout/ui/components/OptionSelectionCard.kt @@ -52,7 +52,9 @@ data class SelectableOption( * description. The selected row fills with the theme's secondary container so it reads as chosen in * both light and dark dynamic-colour schemes; [onSelectionChange] fires with the tapped option's * value. Rows already tapped don't re-emit, and the whole group is exposed as one radio group to - * accessibility services. Pass [shape] to slot the card into a grouped settings list. + * accessibility services. Clear [isFirst] / [isLast] to tighten the matching outer corners so the + * card stacks into a grouped settings list, and pass [header] to sit extra content — a preview, say + * — between the title and the rows. */ @Composable fun OptionSelectionCard( @@ -61,14 +63,16 @@ fun OptionSelectionCard( selectedValue: T?, onSelectionChange: (T) -> Unit, modifier: Modifier = Modifier, - shape: Shape = RoundedCornerShape(32.dp), + isFirst: Boolean = true, + isLast: Boolean = true, containerColor: Color = MaterialTheme.colorScheme.surface, enabled: Boolean = true, + header: (@Composable () -> Unit)? = null, ) { val haptics = LocalHapticFeedback.current Surface( modifier = modifier.fillMaxWidth(), - shape = shape, + shape = cardShape(isFirst = isFirst, isLast = isLast), color = containerColor, ) { Column( @@ -86,6 +90,7 @@ fun OptionSelectionCard( color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), ) + header?.invoke() Column( modifier = Modifier.selectableGroup(), verticalArrangement = Arrangement.spacedBy(4.dp), @@ -110,6 +115,14 @@ fun OptionSelectionCard( } } +/** Grouped-list card shape: the outer corners round only where the card ends the group. */ +private fun cardShape(isFirst: Boolean, isLast: Boolean) = RoundedCornerShape( + topStart = if (isFirst) 32.dp else 4.dp, + topEnd = if (isFirst) 32.dp else 4.dp, + bottomStart = if (isLast) 32.dp else 4.dp, + bottomEnd = if (isLast) 32.dp else 4.dp, +) + /** Grouped-list row shape: the group's outer corners round, the ones between rows stay tight. */ private fun optionShape(isFirst: Boolean, isLast: Boolean) = RoundedCornerShape( topStart = if (isFirst) 24.dp else 4.dp, diff --git a/app/src/main/java/com/ekoehler/expressivecutout/ui/screen/SettingScreens/Appearance/ButtonScreen.kt b/app/src/main/java/com/ekoehler/expressivecutout/ui/screen/SettingScreens/Appearance/ButtonScreen.kt index 1f1d73a..74b0a48 100644 --- a/app/src/main/java/com/ekoehler/expressivecutout/ui/screen/SettingScreens/Appearance/ButtonScreen.kt +++ b/app/src/main/java/com/ekoehler/expressivecutout/ui/screen/SettingScreens/Appearance/ButtonScreen.kt @@ -3,14 +3,12 @@ package com.ekoehler.expressivecutout.ui.screen import android.app.PendingIntent import android.content.Intent import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -32,7 +30,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -62,8 +59,76 @@ import com.ekoehler.expressivecutout.overlay.IslandIcon import com.ekoehler.expressivecutout.overlay.expandedActionsExtraDp import com.ekoehler.expressivecutout.ui.AppViewModel import com.ekoehler.expressivecutout.ui.components.ColorPickerCard +import com.ekoehler.expressivecutout.ui.components.OptionSelectionCard +import com.ekoehler.expressivecutout.ui.components.SelectableOption import kotlin.math.roundToInt +/** Label and supporting line shown for each chip style in its options card. */ +private val ActionButtonStyle.titleRes: Int + get() = when (this) { + ActionButtonStyle.EXPRESSIVE_TONAL -> R.string.style_expressive_tonal_title + ActionButtonStyle.EXPRESSIVE_FILLED -> R.string.style_expressive_filled_title + ActionButtonStyle.MATERIAL_YOU -> R.string.style_material_you_title + ActionButtonStyle.OUTLINED -> R.string.style_outlined_title + } + +private val ActionButtonStyle.descriptionRes: Int + get() = when (this) { + ActionButtonStyle.EXPRESSIVE_TONAL -> R.string.style_expressive_tonal_desc + ActionButtonStyle.EXPRESSIVE_FILLED -> R.string.style_expressive_filled_desc + ActionButtonStyle.MATERIAL_YOU -> R.string.style_material_you_desc + ActionButtonStyle.OUTLINED -> R.string.style_outlined_desc + } + +/** Label and supporting line shown for each chip alignment in its options card. */ +private val ActionButtonAlignment.titleRes: Int + get() = when (this) { + ActionButtonAlignment.LEFT -> R.string.action_buttons_align_left_title + ActionButtonAlignment.CENTER -> R.string.action_buttons_align_center_title + ActionButtonAlignment.RIGHT -> R.string.action_buttons_align_right_title + ActionButtonAlignment.FULL -> R.string.action_buttons_align_full_title + } + +private val ActionButtonAlignment.descriptionRes: Int + get() = when (this) { + ActionButtonAlignment.LEFT -> R.string.action_buttons_align_left_desc + ActionButtonAlignment.CENTER -> R.string.action_buttons_align_center_desc + ActionButtonAlignment.RIGHT -> R.string.action_buttons_align_right_desc + ActionButtonAlignment.FULL -> R.string.action_buttons_align_full_desc + } + +/** Label and supporting line shown for each reply-field style in its options card. */ +private val ReplyInputStyle.titleRes: Int + get() = when (this) { + ReplyInputStyle.EXPRESSIVE -> R.string.input_expressive_title + ReplyInputStyle.MATERIAL_YOU -> R.string.input_material_you_title + ReplyInputStyle.MATERIAL_2 -> R.string.input_material_2_title + ReplyInputStyle.SEGMENTED -> R.string.input_segmented_title + } + +private val ReplyInputStyle.descriptionRes: Int + get() = when (this) { + ReplyInputStyle.EXPRESSIVE -> R.string.input_expressive_desc + ReplyInputStyle.MATERIAL_YOU -> R.string.input_material_you_desc + ReplyInputStyle.MATERIAL_2 -> R.string.input_material_2_desc + ReplyInputStyle.SEGMENTED -> R.string.input_segmented_desc + } + +/** Label and supporting line shown for each "Sent" confirmation position in its options card. */ +private val SentAlignment.titleRes: Int + get() = when (this) { + SentAlignment.LEFT -> R.string.action_buttons_align_left_title + SentAlignment.CENTER -> R.string.action_buttons_align_center_title + SentAlignment.RIGHT -> R.string.action_buttons_align_right_title + } + +private val SentAlignment.descriptionRes: Int + get() = when (this) { + SentAlignment.LEFT -> R.string.action_buttons_sent_left_desc + SentAlignment.CENTER -> R.string.action_buttons_sent_center_desc + SentAlignment.RIGHT -> R.string.action_buttons_sent_right_desc + } + /** Accent used by the preview event, matching the accent shown on the sibling settings screens. */ private val PREVIEW_ACCENT = Color(0xFF60A5FA) @@ -183,36 +248,18 @@ internal fun ButtonScreen( ) // --- Chip style --- - OptionGroupCard(title = stringResource(R.string.action_buttons_style_title)) { - ButtonStyleOption( - title = stringResource(R.string.style_expressive_tonal_title), - description = stringResource(R.string.style_expressive_tonal_desc), - style = ActionButtonStyle.EXPRESSIVE_TONAL, - selected = appearance.actionButtonStyle, - onSelect = viewModel::setActionButtonStyle, - ) - ButtonStyleOption( - title = stringResource(R.string.style_expressive_filled_title), - description = stringResource(R.string.style_expressive_filled_desc), - style = ActionButtonStyle.EXPRESSIVE_FILLED, - selected = appearance.actionButtonStyle, - onSelect = viewModel::setActionButtonStyle, - ) - ButtonStyleOption( - title = stringResource(R.string.style_material_you_title), - description = stringResource(R.string.style_material_you_desc), - style = ActionButtonStyle.MATERIAL_YOU, - selected = appearance.actionButtonStyle, - onSelect = viewModel::setActionButtonStyle, - ) - ButtonStyleOption( - title = stringResource(R.string.style_outlined_title), - description = stringResource(R.string.style_outlined_desc), - style = ActionButtonStyle.OUTLINED, - selected = appearance.actionButtonStyle, - onSelect = viewModel::setActionButtonStyle, - ) - } + OptionSelectionCard( + title = stringResource(R.string.action_buttons_style_title), + options = ActionButtonStyle.entries.map { style -> + SelectableOption( + value = style, + title = stringResource(style.titleRes), + description = stringResource(style.descriptionRes), + ) + }, + selectedValue = appearance.actionButtonStyle, + onSelectionChange = viewModel::setActionButtonStyle, + ) // --- Chip colour (dynamic roles, custom, presets) --- // A null selection follows the notification's own accent (the historical default). @@ -245,74 +292,39 @@ internal fun ButtonScreen( } // --- Chip alignment --- - OptionGroupCard(title = stringResource(R.string.action_buttons_alignment_title)) { - AlignmentOption( - title = stringResource(R.string.action_buttons_align_left_title), - description = stringResource(R.string.action_buttons_align_left_desc), - alignment = ActionButtonAlignment.LEFT, - selected = appearance.actionButtonAlignment, - onSelect = viewModel::setActionButtonAlignment, - ) - AlignmentOption( - title = stringResource(R.string.action_buttons_align_center_title), - description = stringResource(R.string.action_buttons_align_center_desc), - alignment = ActionButtonAlignment.CENTER, - selected = appearance.actionButtonAlignment, - onSelect = viewModel::setActionButtonAlignment, - ) - AlignmentOption( - title = stringResource(R.string.action_buttons_align_right_title), - description = stringResource(R.string.action_buttons_align_right_desc), - alignment = ActionButtonAlignment.RIGHT, - selected = appearance.actionButtonAlignment, - onSelect = viewModel::setActionButtonAlignment, - ) - AlignmentOption( - title = stringResource(R.string.action_buttons_align_full_title), - description = stringResource(R.string.action_buttons_align_full_desc), - alignment = ActionButtonAlignment.FULL, - selected = appearance.actionButtonAlignment, - onSelect = viewModel::setActionButtonAlignment, - ) - } + OptionSelectionCard( + title = stringResource(R.string.action_buttons_alignment_title), + options = ActionButtonAlignment.entries.map { alignment -> + SelectableOption( + value = alignment, + title = stringResource(alignment.titleRes), + description = stringResource(alignment.descriptionRes), + ) + }, + selectedValue = appearance.actionButtonAlignment, + onSelectionChange = viewModel::setActionButtonAlignment, + ) // --- Reply field style --- - OptionGroupCard(title = stringResource(R.string.action_buttons_input_style_title)) { - ReplyInputPreview( - inputStyle = appearance.replyInputStyle, - cancelOnLeft = appearance.cancelButtonOnLeft, - heightDp = buttonHeight.roundToInt(), - ) - Spacer(Modifier.height(4.dp)) - ReplyStyleOption( - title = stringResource(R.string.input_expressive_title), - description = stringResource(R.string.input_expressive_desc), - style = ReplyInputStyle.EXPRESSIVE, - selected = appearance.replyInputStyle, - onSelect = viewModel::setReplyInputStyle, - ) - ReplyStyleOption( - title = stringResource(R.string.input_material_you_title), - description = stringResource(R.string.input_material_you_desc), - style = ReplyInputStyle.MATERIAL_YOU, - selected = appearance.replyInputStyle, - onSelect = viewModel::setReplyInputStyle, - ) - ReplyStyleOption( - title = stringResource(R.string.input_material_2_title), - description = stringResource(R.string.input_material_2_desc), - style = ReplyInputStyle.MATERIAL_2, - selected = appearance.replyInputStyle, - onSelect = viewModel::setReplyInputStyle, - ) - ReplyStyleOption( - title = stringResource(R.string.input_segmented_title), - description = stringResource(R.string.input_segmented_desc), - style = ReplyInputStyle.SEGMENTED, - selected = appearance.replyInputStyle, - onSelect = viewModel::setReplyInputStyle, - ) - } + OptionSelectionCard( + title = stringResource(R.string.action_buttons_input_style_title), + options = ReplyInputStyle.entries.map { style -> + SelectableOption( + value = style, + title = stringResource(style.titleRes), + description = stringResource(style.descriptionRes), + ) + }, + selectedValue = appearance.replyInputStyle, + onSelectionChange = viewModel::setReplyInputStyle, + header = { + ReplyInputPreview( + inputStyle = appearance.replyInputStyle, + cancelOnLeft = appearance.cancelButtonOnLeft, + heightDp = buttonHeight.roundToInt(), + ) + }, + ) // --- Cancel button placement --- SettingsToggleCard( @@ -324,29 +336,18 @@ internal fun ButtonScreen( ) // --- "Sent" confirmation placement --- - OptionGroupCard(title = stringResource(R.string.action_buttons_sent_alignment_title)) { - SentAlignmentOption( - title = stringResource(R.string.action_buttons_align_left_title), - description = stringResource(R.string.action_buttons_sent_left_desc), - alignment = SentAlignment.LEFT, - selected = appearance.sentAlignment, - onSelect = viewModel::setSentAlignment, - ) - SentAlignmentOption( - title = stringResource(R.string.action_buttons_align_center_title), - description = stringResource(R.string.action_buttons_sent_center_desc), - alignment = SentAlignment.CENTER, - selected = appearance.sentAlignment, - onSelect = viewModel::setSentAlignment, - ) - SentAlignmentOption( - title = stringResource(R.string.action_buttons_align_right_title), - description = stringResource(R.string.action_buttons_sent_right_desc), - alignment = SentAlignment.RIGHT, - selected = appearance.sentAlignment, - onSelect = viewModel::setSentAlignment, - ) - } + OptionSelectionCard( + title = stringResource(R.string.action_buttons_sent_alignment_title), + options = SentAlignment.entries.map { alignment -> + SelectableOption( + value = alignment, + title = stringResource(alignment.titleRes), + description = stringResource(alignment.descriptionRes), + ) + }, + selectedValue = appearance.sentAlignment, + onSelectionChange = viewModel::setSentAlignment, + ) // --- Send / cancel reply-button colours --- // Their colours default to the notification's accent (send) and a neutral tint (cancel); @@ -368,113 +369,6 @@ internal fun ButtonScreen( } } -/** A titled surface card that stacks a set of selectable option rows. */ -@Composable -private fun OptionGroupCard( - title: String, - content: @Composable () -> Unit, -) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(24.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text(text = title, style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(4.dp)) - content() - } - } -} - -@Composable -private fun ButtonStyleOption( - title: String, - description: String, - style: ActionButtonStyle, - selected: ActionButtonStyle, - onSelect: (ActionButtonStyle) -> Unit, -) = OptionRow( - title = title, - description = description, - selected = style == selected, - onClick = { onSelect(style) }, -) - -@Composable -private fun AlignmentOption( - title: String, - description: String, - alignment: ActionButtonAlignment, - selected: ActionButtonAlignment, - onSelect: (ActionButtonAlignment) -> Unit, -) = OptionRow( - title = title, - description = description, - selected = alignment == selected, - onClick = { onSelect(alignment) }, -) - -@Composable -private fun SentAlignmentOption( - title: String, - description: String, - alignment: SentAlignment, - selected: SentAlignment, - onSelect: (SentAlignment) -> Unit, -) = OptionRow( - title = title, - description = description, - selected = alignment == selected, - onClick = { onSelect(alignment) }, -) - -@Composable -private fun ReplyStyleOption( - title: String, - description: String, - style: ReplyInputStyle, - selected: ReplyInputStyle, - onSelect: (ReplyInputStyle) -> Unit, -) = OptionRow( - title = title, - description = description, - selected = style == selected, - onClick = { onSelect(style) }, -) - -/** A single-choice row: title, supporting text and a trailing radio, the whole row tappable. */ -@Composable -private fun OptionRow( - title: String, - description: String, - selected: Boolean, - onClick: () -> Unit, -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .clickable(onClick = onClick) - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = title, style = MaterialTheme.typography.bodyLarge) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.width(12.dp)) - RadioButton(selected = selected, onClick = onClick) - } -} - /** * An in-app rendition of the inline reply row, so the effect of the field style, height and the * cancel-button placement is visible without opening a notification. Uses app-theme colours rather From b411cc509e95bb521c39e93a6a8c3534ffe3cba5 Mon Sep 17 00:00:00 2001 From: Evan Koehler <72010109+EvanKoe@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:21:00 +0200 Subject: [PATCH 4/4] feature(ui): Shizuku screen UI --- .../ui/screen/SettingScreens/ShizukuScreen.kt | 68 +++++++++++-------- 1 file changed, 38 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/ekoehler/expressivecutout/ui/screen/SettingScreens/ShizukuScreen.kt b/app/src/main/java/com/ekoehler/expressivecutout/ui/screen/SettingScreens/ShizukuScreen.kt index 3ddbd53..f3e3027 100644 --- a/app/src/main/java/com/ekoehler/expressivecutout/ui/screen/SettingScreens/ShizukuScreen.kt +++ b/app/src/main/java/com/ekoehler/expressivecutout/ui/screen/SettingScreens/ShizukuScreen.kt @@ -56,6 +56,14 @@ import com.ekoehler.expressivecutout.ui.AppViewModel import com.ekoehler.expressivecutout.ui.components.ExpressiveSegmentedRow import java.nio.file.WatchEvent +/** Grouped-list item shape: rounded at the group's outer edges, tight between stacked items. */ +private fun groupedShape(isFirst: Boolean, isLast: Boolean) = RoundedCornerShape( + topStart = if (isFirst) 24.dp else 4.dp, + topEnd = if (isFirst) 24.dp else 4.dp, + bottomStart = if (isLast) 24.dp else 4.dp, + bottomEnd = if (isLast) 24.dp else 4.dp, +) + /** * "Shizuku options" screen (reached from the settings list). Houses the tweaks that need shell * privileges we can't hold ourselves: hiding the system status bar's notification icons so the @@ -115,41 +123,41 @@ internal fun ShizukuScreen( StatusBarPreview(hideIcons = hideIcons, hideSystem = hideSystemInfo, hideClock = hideClock) - SettingsToggleCard( - shape = RoundedCornerShape(24.dp), - title = stringResource(R.string.status_bar_hide_icons_title), - description = stringResource(R.string.status_bar_hide_icons_desc), - checked = ready && hideIcons, - onCheckedChange = viewModel::setHideNotificationIcons, - enabled = ready, + Text( + text = stringResource(R.string.status_bar_hide_icons_note), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), ) - AnimatedVisibility(visible = ready && hideIcons) { - Text( - text = stringResource(R.string.status_bar_hide_icons_note), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp), + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + SettingsToggleCard( + shape = groupedShape(isFirst = true, isLast = false), + title = stringResource(R.string.status_bar_hide_icons_title), + description = stringResource(R.string.status_bar_hide_icons_desc), + checked = ready && hideIcons, + onCheckedChange = viewModel::setHideNotificationIcons, + enabled = ready, ) - } - SettingsToggleCard( - shape = RoundedCornerShape(24.dp), - title = stringResource(R.string.status_bar_hide_system_info_title), - description = stringResource(R.string.status_bar_hide_system_info_desc), - checked = ready && hideSystemInfo, - onCheckedChange = viewModel::setHideSystemInfo, - enabled = ready, - ) + SettingsToggleCard( + shape = groupedShape(isFirst = false, isLast = false), + title = stringResource(R.string.status_bar_hide_system_info_title), + description = stringResource(R.string.status_bar_hide_system_info_desc), + checked = ready && hideSystemInfo, + onCheckedChange = viewModel::setHideSystemInfo, + enabled = ready, + ) - SettingsToggleCard( - shape = RoundedCornerShape(24.dp), - title = stringResource(R.string.status_bar_hide_clock_title), - description = stringResource(R.string.status_bar_hide_clock_desc), - checked = ready && hideClock, - onCheckedChange = viewModel::setHideClock, - enabled = ready, - ) + SettingsToggleCard( + shape = groupedShape(isFirst = false, isLast = true), + title = stringResource(R.string.status_bar_hide_clock_title), + description = stringResource(R.string.status_bar_hide_clock_desc), + checked = ready && hideClock, + onCheckedChange = viewModel::setHideClock, + enabled = ready, + ) + } SettingsToggleCard( shape = RoundedCornerShape(24.dp),