Files
sure/mobile/lib/widgets/sure_icon.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

105 lines
4.1 KiB
Dart

import 'package:flutter/widgets.dart';
import 'package:flutter_svg/flutter_svg.dart';
/// Sure design-system icon primitive.
///
/// Renders a bundled Lucide SVG (stroke `currentColor`, 2px) tinted to a single
/// color, mirroring the web `icon` helper / `DS::FilledIcon` semantics: tokenized
/// size, color inherited from the surrounding [IconTheme] by default, and
/// accessibility that distinguishes decorative from meaningful icons.
///
/// Use [SureIcons] for the bundled icon names so call sites stay typo-safe and
/// only reference assets that actually ship in `assets/icons/lucide/`.
///
/// ```dart
/// const SureIcon(SureIcons.wallet, size: SureIconSize.lg) // decorative
/// const SureIcon(SureIcons.refresh, semanticLabel: 'Refresh') // meaningful
/// ```
class SureIcon extends StatelessWidget {
/// Lucide icon name (kebab-case), e.g. `wallet`. Prefer [SureIcons] constants.
final String name;
/// Square edge length in logical pixels. Prefer a [SureIconSize] token. When
/// null, inherits the ambient [IconTheme] size (Material-icon parity), falling
/// back to 24 — so dropping `SureIcon` in for `Icon` preserves sizing.
final double? size;
/// Tint applied to the icon. When null, inherits the ambient [IconTheme]
/// color (Material-icon parity), falling back to opaque black only if unset.
final Color? color;
/// When provided, the icon is exposed to assistive tech with this label
/// (meaningful icon). When null, the icon is decorative and excluded from the
/// semantics tree — use this only when an adjacent widget carries the name.
final String? semanticLabel;
const SureIcon(
this.name, {
super.key,
this.size,
this.color,
this.semanticLabel,
});
@override
Widget build(BuildContext context) {
final iconTheme = IconTheme.of(context);
final resolvedSize = size ?? iconTheme.size ?? 24;
final resolvedColor = color ?? iconTheme.color ?? const Color(0xFF000000);
// Mirror Material `Icon`: always paint the glyph at `resolvedSize`, even when
// a parent imposes tight constraints larger than the icon (e.g. a fixed-size
// Container). The outer SizedBox sets the intrinsic size; the Center loosens
// the constraints handed to SvgPicture so its width/height aren't stretched.
return SizedBox(
width: resolvedSize,
height: resolvedSize,
child: Center(
child: SvgPicture.asset(
'assets/icons/lucide/$name.svg',
width: resolvedSize,
height: resolvedSize,
colorFilter: ColorFilter.mode(resolvedColor, BlendMode.srcIn),
semanticsLabel: semanticLabel,
excludeFromSemantics: semanticLabel == null,
),
),
);
}
}
/// Canonical icon sizes, mirroring the web `icon` helper scale
/// (xs/sm/md/lg/xl/2xl → 12/16/20/24/28/32).
abstract final class SureIconSize {
static const double xs = 12;
static const double sm = 16;
static const double md = 20;
static const double lg = 24;
static const double xl = 28;
static const double xxl = 32;
}
/// Names of the Lucide icons bundled under `assets/icons/lucide/`.
///
/// Referencing a name here guarantees the asset ships; add the SVG (and a
/// constant) before using a new icon.
abstract final class SureIcons {
static const String landmark = 'landmark';
static const String creditCard = 'credit-card';
static const String trendingUp = 'trending-up';
static const String trendingDown = 'trending-down';
static const String receipt = 'receipt';
static const String house = 'house';
static const String car = 'car';
static const String bitcoin = 'bitcoin';
static const String shapes = 'shapes';
static const String handCoins = 'hand-coins';
static const String wallet = 'wallet';
static const String circleAlert = 'circle-alert';
static const String circleCheck = 'circle-check';
static const String cloudCheck = 'cloud-check';
static const String refresh = 'refresh-cw';
static const String chevronUp = 'chevron-up';
static const String chevronDown = 'chevron-down';
static const String chevronRight = 'chevron-right';
}