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); + }); +}