mirror of
https://github.com/we-promise/sure.git
synced 2026-07-19 16:25:24 +00:00
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:
13
mobile/assets/icons/lucide/chevron-right.svg
Normal file
13
mobile/assets/icons/lucide/chevron-right.svg
Normal 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 |
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
220
mobile/lib/widgets/sure_list_group.dart
Normal file
220
mobile/lib/widgets/sure_list_group.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
199
mobile/test/widgets/sure_list_group_test.dart
Normal file
199
mobile/test/widgets/sure_list_group_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user