feat(mobile): add SureListGroup/SureListRow primitives and migrate the More menu (#2376)

* feat(mobile): add SureListGroup/SureListRow primitives and migrate the More menu

Adds the grouped inset-list primitives that follow SureCard in the mobile
design-system sequence: SureListGroup (tokenized container — bg-container +
borderSecondary hairline + radiusLg corners + shadowXs, with clipped inset
dividers and an optional section header) and SureListRow (leading / title /
subtitle / trailing slots, a DS chevron disclosure affordance, and a
destructive variant). Both resolve from the active SureColors palette, so
they're brightness-aware and stay in lockstep with sure.tokens.json.

Migrates the More menu off raw Material ListTile/Divider (which fell back to
colorScheme defaults) as proof. Adds the lucide chevron-right asset and
SureIcons.chevronRight for the row disclosure indicator.

Part of #2235.

* fix(mobile): restore button semantics on SureListRow + harden grouped list

Adversarial + CodeRabbit review follow-ups on the SureListGroup primitives:

- a11y: a tappable SureListRow lost the button/enabled semantics the migrated
  Material ListTile exposed (a bare InkWell emits only a tap action). Wrap the
  tappable content in MergeSemantics + Semantics(button) so screen readers
  announce each row as a single button again. Adds a semantics regression test.
- SureListGroup collapses to SizedBox.shrink() for an empty children list
  instead of painting a 2px bordered/shadowed box.
- More menu: restore the leading icon-badge tint to textPrimary (the migration
  had silently dropped it to the lower-contrast textSecondary).
- Tests: extend divider / title / subtitle / destructive assertions to dark mode
  (CodeRabbit nitpick), plus the new semantics + empty-group cases.
This commit is contained in:
ghost
2026-06-20 12:43:29 -07:00
committed by GitHub
parent 56000d7834
commit 305c11d4a6
5 changed files with 483 additions and 65 deletions

View File

@@ -0,0 +1,13 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>

After

Width:  |  Height:  |  Size: 237 B

View File

@@ -1,4 +1,7 @@
import 'package:flutter/material.dart';
import '../theme/sure_colors.dart';
import '../theme/sure_tokens.dart';
import '../widgets/sure_list_group.dart';
import 'calendar_screen.dart';
import 'recent_transactions_screen.dart';
@@ -7,79 +10,61 @@ class MoreScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: ListView(
children: [
_buildMenuItem(
context: context,
icon: Icons.calendar_month,
title: 'Account Calendar',
subtitle: 'View monthly balance changes by account',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CalendarScreen(),
// Setting an explicit ListView padding opts out of the scroll view's
// automatic safe-area inset, so restore it with SafeArea (keeps the group
// clear of the status bar / home indicator).
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
SureListGroup(
children: [
SureListRow(
leading: _iconBadge(context, Icons.calendar_month),
title: 'Account Calendar',
subtitle: 'View monthly balance changes by account',
showChevron: true,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const CalendarScreen(),
),
);
},
),
);
},
),
Divider(height: 1, color: colorScheme.outlineVariant),
_buildMenuItem(
context: context,
icon: Icons.receipt_long,
title: 'Recent Transactions',
subtitle: 'View recent transactions across all accounts',
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const RecentTransactionsScreen(),
SureListRow(
leading: _iconBadge(context, Icons.receipt_long),
title: 'Recent Transactions',
subtitle: 'View recent transactions across all accounts',
showChevron: true,
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const RecentTransactionsScreen(),
),
);
},
),
);
},
),
],
],
),
],
),
),
);
}
Widget _buildMenuItem({
required BuildContext context,
required IconData icon,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
final colorScheme = Theme.of(context).colorScheme;
return ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: colorScheme.onPrimaryContainer,
),
Widget _iconBadge(BuildContext context, IconData icon) {
final palette = SureColors.of(context).palette;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: palette.surfaceInset,
borderRadius: BorderRadius.circular(SureTokens.radiusMd),
),
title: Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
subtitle: Text(
subtitle,
style: TextStyle(color: colorScheme.onSurfaceVariant),
),
trailing: Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
onTap: onTap,
child: Icon(icon, color: palette.textPrimary),
);
}
}

View File

@@ -100,4 +100,5 @@ abstract final class SureIcons {
static const String refresh = 'refresh-cw';
static const String chevronUp = 'chevron-up';
static const String chevronDown = 'chevron-down';
static const String chevronRight = 'chevron-right';
}

View File

@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import '../theme/sure_colors.dart';
import '../theme/sure_tokens.dart';
import 'sure_icon.dart';
/// Sure design-system grouped list — a tokenized container that stacks
/// [SureListRow]s behind a single rounded surface with a hairline border, the
/// subtle DS shadow, and inset dividers between rows. Mirrors the web grouped
/// inset list (`bg-container` + `rounded-lg` + `shadow-border-xs`, rows clipped
/// and separated by `border-divider`).
///
/// Colors resolve from the active [SureColors] palette, so the chrome stays in
/// lockstep with `sure.tokens.json` and reads correctly in light and dark.
///
/// ```dart
/// SureListGroup(
/// header: 'Tools',
/// children: [
/// SureListRow(title: 'Calendar', subtitle: 'Monthly view', showChevron: true, onTap: ...),
/// SureListRow(title: 'Recent', showChevron: true, onTap: ...),
/// ],
/// )
/// ```
class SureListGroup extends StatelessWidget {
const SureListGroup({
super.key,
required this.children,
this.header,
this.margin,
});
/// Rows to stack — typically [SureListRow]s. Dividers are inserted between
/// them automatically (never before the first or after the last).
final List<Widget> children;
/// Optional uppercase section label rendered above the group.
final String? header;
/// Outer spacing around the group (e.g. separation between sections).
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context) {
if (children.isEmpty) return const SizedBox.shrink();
final palette = SureColors.of(context).palette;
final radius = BorderRadius.circular(SureTokens.radiusLg);
final rows = <Widget>[];
for (var i = 0; i < children.length; i++) {
if (i > 0) {
// Inset hairline between rows, lighter than the group's edge so the
// separators read as internal (classic grouped-list look).
rows.add(Divider(
height: 1,
thickness: 1,
indent: 16,
endIndent: 16,
color: palette.borderSubdued,
));
}
rows.add(children[i]);
}
final group = Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: palette.container,
borderRadius: radius,
border: Border.all(color: palette.borderSecondary),
boxShadow: palette.shadowXs,
),
child: Column(mainAxisSize: MainAxisSize.min, children: rows),
);
final content = header == null
? group
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
child: Text(
header!.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: palette.textSecondary,
fontWeight: FontWeight.w500,
letterSpacing: 0.4,
),
),
),
group,
],
);
if (margin == null) return content;
return Padding(padding: margin!, child: content);
}
}
/// A single row inside a [SureListGroup]. Lays out an optional [leading] widget,
/// a [title] (+ optional [subtitle]), and a trailing affordance — either an
/// explicit [trailing] widget or a DS chevron when [showChevron] is set.
///
/// The row paints no background or border of its own; it relies on the enclosing
/// [SureListGroup] for chrome, dividers, and corner clipping. When [onTap] is
/// provided the whole row is tappable with a flat ink response (clipped to the
/// group's rounded corners by the group's `antiAlias`).
class SureListRow extends StatelessWidget {
const SureListRow({
super.key,
required this.title,
this.subtitle,
this.leading,
this.trailing,
this.onTap,
this.showChevron = false,
this.destructive = false,
});
final String title;
final String? subtitle;
/// Leading widget (e.g. an icon badge). Caller's choice — the row is
/// icon-agnostic.
final Widget? leading;
/// Trailing widget (e.g. a value or switch). Takes precedence over
/// [showChevron] when both are provided.
final Widget? trailing;
/// When non-null, the row is tappable.
final VoidCallback? onTap;
/// Show a DS chevron disclosure indicator on the trailing edge. Ignored when
/// an explicit [trailing] is provided.
final bool showChevron;
/// Render the title in the destructive token (for delete/reset actions).
final bool destructive;
@override
Widget build(BuildContext context) {
final palette = SureColors.of(context).palette;
final theme = Theme.of(context);
Widget? trailingWidget = trailing;
if (trailingWidget == null && showChevron) {
trailingWidget = SureIcon(
SureIcons.chevronRight,
size: SureIconSize.md,
color: palette.textSubdued,
);
}
Widget content = Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 12)],
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
color:
destructive ? palette.destructive : palette.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
if (trailingWidget != null) ...[
const SizedBox(width: 12),
trailingWidget,
],
],
),
);
if (onTap != null) {
// Material(transparency) gives the InkWell a surface to paint on without
// covering the group's tokenized background. No borderRadius here — the
// group clips the ripple to its rounded corners via clipBehavior.
// MergeSemantics + Semantics(button) restores the button/enabled flags a
// Material ListTile exposes, so screen readers still announce the whole
// row as a single button (parity with the pre-migration ListTile rows).
content = MergeSemantics(
child: Semantics(
button: true,
enabled: true,
child: Material(
type: MaterialType.transparency,
child: InkWell(onTap: onTap, child: content),
),
),
);
}
return content;
}
}

View File

@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sure_mobile/theme/sure_theme.dart';
import 'package:sure_mobile/theme/sure_tokens.dart';
import 'package:sure_mobile/widgets/sure_icon.dart';
import 'package:sure_mobile/widgets/sure_list_group.dart';
void main() {
Future<void> pump(WidgetTester tester, Widget child,
{Brightness brightness = Brightness.light}) {
return tester.pumpWidget(
MaterialApp(
theme:
brightness == Brightness.light ? SureTheme.light : SureTheme.dark,
home: Scaffold(body: child),
),
);
}
BoxDecoration groupDecorationOf(WidgetTester tester) => tester
.widget<Container>(
find
.descendant(
of: find.byType(SureListGroup),
matching: find.byType(Container),
)
.first,
)
.decoration as BoxDecoration;
// Brightness-aware by contract — assert the chrome resolves the right palette
// in both themes so a token regression in either mode is caught.
for (final (brightness, tokens) in [
(Brightness.light, SureTokens.light),
(Brightness.dark, SureTokens.dark),
]) {
testWidgets('paints the group chrome from tokens (${brightness.name})',
(tester) async {
await pump(
tester,
const SureListGroup(children: [SureListRow(title: 'Only row')]),
brightness: brightness,
);
final deco = groupDecorationOf(tester);
expect(deco.color, tokens.container);
expect((deco.border as Border).top.color, tokens.borderSecondary);
expect(deco.borderRadius, BorderRadius.circular(SureTokens.radiusLg));
expect(deco.boxShadow, tokens.shadowXs);
});
}
for (final (brightness, tokens) in [
(Brightness.light, SureTokens.light),
(Brightness.dark, SureTokens.dark),
]) {
testWidgets(
'inserts subdued interior dividers but not at the edges (${brightness.name})',
(tester) async {
await pump(
tester,
const SureListGroup(children: [
SureListRow(title: 'One'),
SureListRow(title: 'Two'),
SureListRow(title: 'Three'),
]),
brightness: brightness,
);
// Three rows => exactly two interior dividers.
final dividers =
tester.widgetList<Divider>(find.byType(Divider)).toList();
expect(dividers, hasLength(2));
expect(dividers.first.color, tokens.borderSubdued);
});
}
testWidgets('renders an uppercased header above the group', (tester) async {
await pump(
tester,
const SureListGroup(
header: 'Tools',
children: [SureListRow(title: 'Calendar')],
),
);
expect(find.text('TOOLS'), findsOneWidget);
});
for (final (brightness, tokens) in [
(Brightness.light, SureTokens.light),
(Brightness.dark, SureTokens.dark),
]) {
testWidgets(
'row renders title and subtitle with tokenized colors (${brightness.name})',
(tester) async {
await pump(
tester,
const SureListGroup(
children: [SureListRow(title: 'Calendar', subtitle: 'Monthly view')],
),
brightness: brightness,
);
final title = tester.widget<Text>(find.text('Calendar'));
final subtitle = tester.widget<Text>(find.text('Monthly view'));
expect(title.style?.color, tokens.textPrimary);
expect(subtitle.style?.color, tokens.textSecondary);
});
testWidgets(
'destructive row paints the title in the destructive token (${brightness.name})',
(tester) async {
await pump(
tester,
const SureListGroup(
children: [SureListRow(title: 'Delete account', destructive: true)],
),
brightness: brightness,
);
final title = tester.widget<Text>(find.text('Delete account'));
expect(title.style?.color, tokens.destructive);
});
}
testWidgets(
'showChevron renders the DS chevron, suppressed by explicit trailing',
(tester) async {
await pump(
tester,
const SureListGroup(
children: [SureListRow(title: 'Go', showChevron: true)]),
);
final chevron = tester.widget<SureIcon>(find.byType(SureIcon));
expect(chevron.name, SureIcons.chevronRight);
await pump(
tester,
const SureListGroup(children: [
SureListRow(
title: 'Go',
showChevron: true,
trailing: Text('value'),
),
]),
);
expect(find.byType(SureIcon), findsNothing);
expect(find.text('value'), findsOneWidget);
});
testWidgets('onTap fires and the tappable row uses an InkWell',
(tester) async {
var taps = 0;
await pump(
tester,
SureListGroup(
children: [SureListRow(title: 'Tap me', onTap: () => taps++)],
),
);
expect(find.byType(InkWell), findsOneWidget);
await tester.tap(find.text('Tap me'));
expect(taps, 1);
});
testWidgets('a row without onTap is non-interactive (no InkWell)',
(tester) async {
await pump(
tester,
const SureListGroup(children: [SureListRow(title: 'Static')]),
);
expect(find.byType(InkWell), findsNothing);
});
testWidgets('a tappable row exposes button semantics (ListTile parity)',
(tester) async {
final handle = tester.ensureSemantics();
await pump(
tester,
SureListGroup(children: [SureListRow(title: 'Go', onTap: () {})]),
);
final node = tester.getSemantics(find.text('Go'));
expect(node.hasFlag(SemanticsFlag.isButton), isTrue);
expect(node.hasFlag(SemanticsFlag.isEnabled), isTrue);
handle.dispose();
});
testWidgets('an empty group builds no chrome', (tester) async {
await pump(tester, const SureListGroup(children: []));
// No decorated container and no dividers — it collapses rather than
// painting an empty bordered/shadowed box.
expect(
find.descendant(
of: find.byType(SureListGroup),
matching: find.byType(Container),
),
findsNothing,
);
expect(find.byType(Divider), findsNothing);
});
}