diff --git a/mobile/assets/icons/lucide/chevron-right.svg b/mobile/assets/icons/lucide/chevron-right.svg
new file mode 100644
index 000000000..db62a0c20
--- /dev/null
+++ b/mobile/assets/icons/lucide/chevron-right.svg
@@ -0,0 +1,13 @@
+
diff --git a/mobile/lib/screens/more_screen.dart b/mobile/lib/screens/more_screen.dart
index 970b20eab..3c41c9f81 100644
--- a/mobile/lib/screens/more_screen.dart
+++ b/mobile/lib/screens/more_screen.dart
@@ -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),
);
}
}
diff --git a/mobile/lib/widgets/sure_icon.dart b/mobile/lib/widgets/sure_icon.dart
index e332350ed..8510b8dfe 100644
--- a/mobile/lib/widgets/sure_icon.dart
+++ b/mobile/lib/widgets/sure_icon.dart
@@ -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';
}
diff --git a/mobile/lib/widgets/sure_list_group.dart b/mobile/lib/widgets/sure_list_group.dart
new file mode 100644
index 000000000..3ac7bc7ad
--- /dev/null
+++ b/mobile/lib/widgets/sure_list_group.dart
@@ -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 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 = [];
+ 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;
+ }
+}
diff --git a/mobile/test/widgets/sure_list_group_test.dart b/mobile/test/widgets/sure_list_group_test.dart
new file mode 100644
index 000000000..53f5bb566
--- /dev/null
+++ b/mobile/test/widgets/sure_list_group_test.dart
@@ -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 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(
+ 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(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(find.text('Calendar'));
+ final subtitle = tester.widget(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(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(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);
+ });
+}