Files
sure/mobile/test/widgets/sure_list_group_test.dart
ghost 305c11d4a6 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.
2026-06-20 21:43:29 +02:00

200 lines
6.1 KiB
Dart

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