Skip to content

Commit 3f621e4

Browse files
Material 3 Expressive menus and M3-style navigation transitions
Draw every long-press menu (scope rows, module rows, the modules filter/sort picker) as a Material 3 Expressive DropdownMenuPopup + DropdownMenuGroup using MenuDefaults.groupShapes() and itemShape(), for capsule-shaped items with a checkmark on the active one. Switch the menu icons to Material Symbols Outlined and pull in com.composables:icons-material-symbols-outlined-cmp. Align the manager with the M3 Expressive look the rest of the app already uses WeKit as its reference: Nuke-style navigation (predictive back fade-through), animated panel transitions, and the module API scale label reads LSPosed. The ignore-updates toggle on the module sheet now shows a check when on instead of a switch thumb.
1 parent 5e4dcb9 commit 3f621e4

24 files changed

Lines changed: 2278 additions & 736 deletions

File tree

CLAUDE.md

Lines changed: 287 additions & 0 deletions
Large diffs are not rendered by default.

gradle/libs.versions.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ coil = "3.5.0"
1212
m3 = "1.5.0-alpha26"
1313
nav3 = "1.1.6"
1414
navigationevent = "1.1.2"
15+
# Material Symbols (the M3 icon set) used by the manager UI, matching WeKit. These are
16+
# Compose Multiplatform artifacts; the `-cmp` Android variant is what we actually resolve.
17+
composablehorizons-symbols = "2.2.1"
1518
# Governs every androidx.compose.* artifact below; none of them pin a version.
1619
compose-bom = "2026.08.00"
1720

@@ -44,6 +47,7 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
4447
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "m3" }
4548
androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite", version.ref = "m3" }
4649
androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
50+
composablehorizons-material-symbols-outlined = { module = "com.composables:icons-material-symbols-outlined-cmp", version.ref = "composablehorizons-symbols" }
4751

4852
# Navigation 3. Stable since Nov 2025; the back stack is a plain observable list
4953
# of NavKey objects rather than route strings.
@@ -85,6 +89,7 @@ compose = [
8589
"androidx-compose-material3",
8690
"androidx-compose-material3-adaptive-navigation-suite",
8791
"androidx-compose-material-icons-extended",
92+
"composablehorizons-material-symbols-outlined",
8893
"androidx-lifecycle-viewmodel-compose",
8994
"androidx-navigation3-runtime",
9095
"androidx-navigation3-ui",

manager-ui/src/main/kotlin/org/matrix/vector/ui/ApiBadge.kt

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package org.matrix.vector.ui
22

33
import androidx.compose.foundation.layout.Arrangement
4+
import androidx.compose.foundation.layout.IntrinsicSize
45
import androidx.compose.foundation.layout.Row
6+
import androidx.compose.foundation.layout.width
57
import androidx.compose.material3.MaterialTheme
68
import androidx.compose.material3.Text
79
import androidx.compose.runtime.Composable
810
import androidx.compose.ui.Alignment
11+
import androidx.compose.ui.Modifier
912
import androidx.compose.ui.text.font.FontWeight
1013
import androidx.compose.ui.unit.dp
1114
import androidx.compose.ui.unit.sp
@@ -24,17 +27,28 @@ import androidx.compose.ui.unit.sp
2427
@Composable
2528
fun ApiBadge(label: String, value: String, incompatible: Boolean = false) {
2629
val colors = MaterialTheme.colorScheme
27-
Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(3.dp)) {
30+
// Single line, never wrapping: the badge sits in a fixed-width column, and a wrapping badge
31+
// (e.g. "LSPosed\n102") would break the shared name/description start that the fixed column
32+
// exists to keep. The scale name and number stay on one line however narrow the column is.
33+
Row(
34+
verticalAlignment = Alignment.Bottom,
35+
horizontalArrangement = Arrangement.spacedBy(3.dp),
36+
modifier = Modifier.width(IntrinsicSize.Max),
37+
) {
2838
Text(
2939
text = label,
3040
style = MaterialTheme.typography.labelSmall.copy(fontSize = 8.sp),
3141
color = colors.onSurfaceVariant.copy(alpha = 0.7f),
42+
maxLines = 1,
43+
softWrap = false,
3244
)
3345
Text(
3446
text = value,
3547
style = MaterialTheme.typography.labelMedium,
3648
fontWeight = FontWeight.SemiBold,
3749
color = if (incompatible) colors.error else colors.primary,
50+
maxLines = 1,
51+
softWrap = false,
3852
)
3953
}
4054
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package org.matrix.vector.ui
2+
3+
import androidx.compose.foundation.layout.size
4+
import androidx.compose.material.icons.Icons
5+
import androidx.compose.material.icons.rounded.Check
6+
import androidx.compose.material.icons.rounded.Close
7+
import androidx.compose.material3.Icon
8+
import androidx.compose.material3.Switch
9+
import androidx.compose.material3.SwitchDefaults
10+
import androidx.compose.runtime.Composable
11+
import androidx.compose.ui.Modifier
12+
import androidx.compose.ui.graphics.Color
13+
14+
/**
15+
* A Material 3 switch that shows a **check when on and a cross when off** inside the thumb.
16+
*
17+
* The ordinary Material 3 `Switch` is a slider whose thumb colour alone says which state it is in;
18+
* a row where the thumb is the only clue is hard to read at a glance, and a module list with a
19+
* switch per row makes "read the colour" the whole interaction. Putting the mark in the thumb keeps
20+
* the switch's Material 3 skeleton (the track, the shape, the motion) while the state is legible in
21+
* the mark itself.
22+
*
23+
* It is a thin wrapper: all of the switch's own parameters are forwarded unchanged, and only
24+
* [thumbContent] is added. The mark colour follows the switch's `iconColor`, so the check and the
25+
* cross pick up the same themed colour the switch would have used for its thumb.
26+
*/
27+
@Composable
28+
fun CheckSwitch(
29+
checked: Boolean,
30+
onCheckedChange: ((Boolean) -> Unit)?,
31+
modifier: Modifier = Modifier,
32+
enabled: Boolean = true,
33+
) {
34+
Switch(
35+
checked = checked,
36+
onCheckedChange = onCheckedChange,
37+
modifier = modifier,
38+
enabled = enabled,
39+
thumbContent = {
40+
Icon(
41+
imageVector = if (checked) Icons.Rounded.Check else Icons.Rounded.Close,
42+
contentDescription = null,
43+
tint = Color.Unspecified,
44+
modifier = Modifier.size(SwitchDefaults.IconSize),
45+
)
46+
},
47+
)
48+
}

manager-ui/src/main/kotlin/org/matrix/vector/ui/ModuleRow.kt

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ import androidx.compose.ui.unit.dp
4343
/** The module's icon, and the slot it is drawn in whether or not it is selected. */
4444
private val ICON_SIZE = 48.dp
4545

46+
/**
47+
* The fixed width of the icon column (the icon plus the API badge under it).
48+
*
49+
* Deliberately wider than the icon: the badge reads "LSPosed 102" on a modern module and "Xposed
50+
* 93" on a legacy one, and if the column wrapped its contents the text column to its right would
51+
* start at a different x for every row — the names and descriptions would stop lining up. Fixing
52+
* the column width wide enough for the widest badge (single-line, so a "LSPosed 102" fits) is what
53+
* keeps the name/description edges shared across the list without the badge truncating.
54+
*/
55+
private val ICON_COLUMN_WIDTH = 66.dp
56+
4657
/** Room for a version and its mark. Anything longer scrolls past instead of pushing. */
4758
private val VERSION_WIDTH = 104.dp
4859

@@ -131,18 +142,28 @@ fun ModuleRow(
131142
// The icon is the selection handle. Double-tapping it is the host's chance to toggle without
132143
// leaving the list; a bare tap only reports state, since a one-tap toggle would fire whenever
133144
// a thumb brushed the list.
145+
//
146+
// The column is fixed at the icon's width so the API badge underneath it cannot widen it: a
147+
// wide badge (e.g. "liblsposed 102") would otherwise push the text column right for that row
148+
// alone, and the module names would no longer line up. The badge is laid out at its natural
149+
// width inside the fixed box — if it is wider than the icon it overflows to the right but the
150+
// text column keeps its fixed start, which is the alignment the badge would otherwise break.
134151
Column(
135152
modifier =
136-
if (onIconClick != null)
153+
(if (onIconClick != null)
137154
Modifier.contextClickable(onClick = onIconClick, onLongClick = onIconLongClick)
138-
else Modifier,
139-
// Against the text, not centred over the badge: the badge below is wider than the icon,
140-
// so centring left a gap between the icon and the edge the names all start from.
141-
horizontalAlignment = Alignment.End,
155+
else Modifier)
156+
.width(ICON_COLUMN_WIDTH),
157+
// Left-aligned with the text: the icon's own left edge sits on the same vertical line the
158+
// names start from, so every row's icon and title line up regardless of how the icon was
159+
// drawn (some module icons carry their own padding, which previously pushed them inward
160+
// and made the list look ragged).
161+
horizontalAlignment = Alignment.Start,
142162
) {
143163
// Fixed at the icon's size whatever is drawn inside, so selecting a module cannot resize
144-
// its row — a tick larger than the icon would grow this box and reflow the list.
145-
Box(modifier = Modifier.size(ICON_SIZE), contentAlignment = Alignment.Center) {
164+
// its row — a tick larger than the icon would grow this box and reflow the list. Pinned
165+
// to the top-start so the icon, not a centred one, is left-aligned against the edge.
166+
Box(modifier = Modifier.size(ICON_SIZE), contentAlignment = Alignment.TopStart) {
146167
icon()
147168
if (selected) {
148169
Box(
@@ -165,7 +186,7 @@ fun ModuleRow(
165186
apiBadge()
166187
}
167188

168-
Spacer(Modifier.width(16.dp))
189+
Spacer(Modifier.width(8.dp))
169190

170191
// A Box, not a third column: reserving a column for the version and reach would take width
171192
// from every line of the description whether or not anything was there. They overlap the text

manager-ui/src/main/kotlin/org/matrix/vector/ui/SheetParts.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import androidx.compose.material3.ListItem
1717
import androidx.compose.material3.ListItemColors
1818
import androidx.compose.material3.ListItemDefaults
1919
import androidx.compose.material3.MaterialTheme
20-
import androidx.compose.material3.Switch
2120
import androidx.compose.material3.Text
2221
import androidx.compose.material3.TextButton
2322
import androidx.compose.runtime.Composable
@@ -118,7 +117,7 @@ fun ToggleRow(
118117
),
119118
supportingContent = subtitle?.let { { Text(it) } },
120119
leadingContent = { Icon(icon, contentDescription = null) },
121-
trailingContent = { Switch(checked = checked, onCheckedChange = null) },
120+
trailingContent = { CheckSwitch(checked = checked, onCheckedChange = null) },
122121
colors = sheetRowColors,
123122
) { Text(title) }
124123
}

manager-ui/src/main/kotlin/org/matrix/vector/ui/logs/LogsScreen.kt

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ import androidx.compose.material.icons.rounded.UnfoldLess
4747
import androidx.compose.material.icons.rounded.UnfoldMore
4848
import androidx.compose.material.icons.automirrored.rounded.Label
4949
import androidx.compose.material.icons.rounded.SearchOff
50-
import androidx.compose.material.icons.rounded.VerticalAlignBottom
51-
import androidx.compose.material.icons.rounded.VerticalAlignTop
50+
import androidx.compose.material.icons.rounded.KeyboardDoubleArrowDown
51+
import androidx.compose.material.icons.rounded.KeyboardDoubleArrowUp
5252
import androidx.compose.material.icons.rounded.WarningAmber
5353
import androidx.compose.material.icons.automirrored.rounded.WrapText
5454
import androidx.compose.material3.CircularProgressIndicator
@@ -65,12 +65,11 @@ import androidx.compose.material3.MaterialTheme
6565
import androidx.compose.material3.ModalBottomSheet
6666
import androidx.compose.material3.InputChip
6767
import androidx.compose.material3.Scaffold
68-
import androidx.compose.material3.SmallFloatingActionButton
68+
import androidx.compose.material3.FilledTonalIconButton
6969
import androidx.compose.material3.SnackbarDuration
7070
import androidx.compose.material3.SnackbarHost
7171
import androidx.compose.material3.SnackbarHostState
7272
import androidx.compose.material3.SnackbarResult
73-
import androidx.compose.material3.Switch
7473
import androidx.compose.material3.Text
7574
import androidx.compose.material3.TextButton
7675
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
@@ -107,6 +106,7 @@ import androidx.lifecycle.ViewModelProvider
107106
import androidx.lifecycle.compose.collectAsStateWithLifecycle
108107
import androidx.lifecycle.viewmodel.compose.viewModel
109108
import kotlinx.coroutines.launch
109+
import org.matrix.vector.ui.CheckSwitch
110110
import org.matrix.vector.ui.LocalDialogLocalizer
111111
import org.matrix.vector.ui.PanelHeader
112112
import org.matrix.vector.ui.SearchField
@@ -355,7 +355,7 @@ private fun LogPane(
355355
LaunchedEffect(state.scroll?.token, jumpInset) {
356356
val command = state.scroll ?: return@LaunchedEffect
357357
if (state.rows.isNotEmpty()) {
358-
listState.scrollToItem(command.position.coerceIn(0, state.rows.lastIndex))
358+
listState.animateScrollToItem(command.position.coerceIn(0, state.rows.lastIndex))
359359
}
360360
}
361361

@@ -544,23 +544,32 @@ private fun LogList(
544544
// file: hiding one would change the container's height, which is the list's bottom inset,
545545
// and so shift the log under the reader as a side effect of scrolling.
546546
if (showJump) {
547-
Row(
547+
Column(
548548
modifier =
549549
Modifier.align(Alignment.BottomEnd)
550550
.onSizeChanged { onJumpInset(it.height) }
551551
.padding(12.dp),
552-
horizontalArrangement = Arrangement.spacedBy(8.dp),
552+
horizontalAlignment = Alignment.CenterHorizontally,
553+
verticalArrangement = Arrangement.spacedBy(8.dp),
553554
) {
554-
SmallFloatingActionButton(onClick = { viewModel.jumpToOldest(tab) }) {
555+
FilledTonalIconButton(
556+
onClick = { viewModel.jumpToOldest(tab) },
557+
modifier = Modifier.size(40.dp),
558+
) {
555559
Icon(
556-
Icons.Rounded.VerticalAlignTop,
560+
Icons.Rounded.KeyboardDoubleArrowUp,
557561
contentDescription = stringResource(R.string.logs_jump_oldest),
562+
modifier = Modifier.size(20.dp),
558563
)
559564
}
560-
SmallFloatingActionButton(onClick = { viewModel.jumpToNewest(tab) }) {
565+
FilledTonalIconButton(
566+
onClick = { viewModel.jumpToNewest(tab) },
567+
modifier = Modifier.size(40.dp),
568+
) {
561569
Icon(
562-
Icons.Rounded.VerticalAlignBottom,
570+
Icons.Rounded.KeyboardDoubleArrowDown,
563571
contentDescription = stringResource(R.string.logs_jump_newest),
572+
modifier = Modifier.size(20.dp),
564573
)
565574
}
566575
}
@@ -790,7 +799,7 @@ private fun LogSettingsSheet(
790799
)
791800
},
792801
trailingContent = {
793-
Switch(checked = enabled, onCheckedChange = { viewModel.setVerbose(it) })
802+
CheckSwitch(checked = enabled, onCheckedChange = { viewModel.setVerbose(it) })
794803
},
795804
colors = sheetRowColors,
796805
) { Text(stringResource(R.string.logs_verbose_switch)) }

0 commit comments

Comments
 (0)