Files
sure/mobile/lib/widgets/sure_chip.dart
ghost 3a7dc3c346 design-system(mobile): SureSpacing + SureTypography scale tokens (#2438)
* feat(mobile): add SureSpacing + SureTypography scale tokens

Introduce hand-authored spacing and type-scale constants mirroring the
Tailwind defaults the web design system relies on, so widgets reference a
named step instead of a raw numeric EdgeInsets/SizedBox/fontSize.

- SureSpacing: xs..huge mapping to Tailwind space-1..space-8 (4..32px).
- SureTypography: xs..xxl mapping to Tailwind text-xs..text-2xl font sizes.

Both are hand-written rather than generated from sure.tokens.json because
spacing and the type ramp come from Tailwind's built-in scale, not the
canonical token file (consistent with the tracker's guidance).

Adopt them in the existing primitives (card padding, button metrics +
gap, chip/segmented/list-group gaps and padding, text-field padding +
label gap). All migrations are value-preserving — each token equals the
literal it replaces — so there is no layout change; off-scale one-offs
(control heights, hairlines, deliberate 14px field padding) stay literal.

flutter analyze: no new issues; full suite (166) green.

* docs(mobile): note off-scale SureChip inset; expose SureTypography line heights

Address review feedback (jjmata):

- SureChip: add an inline comment explaining the horizontal:14 content inset is
  deliberately off the SureSpacing scale (between lg=12 and xl=16) — a tuned
  FilterChip-parity dimension, not a spacing step — so it isn't "fixed" to a
  token later.
- SureTypography: expose the paired line heights (xsLineHeight … xxlLineHeight,
  logical px matching the Tailwind text-* defaults) that were previously only in
  doc comments, with a note on deriving Flutter's TextStyle.height multiplier
  (lineHeight / fontSize). No behavior change.
2026-06-30 07:23:24 +02:00

108 lines
3.6 KiB
Dart

import 'package:flutter/material.dart';
import '../theme/sure_colors.dart';
import '../theme/sure_spacing.dart';
/// Sure design-system filter chip — a tokenized selectable pill mirroring the web
/// DS pill: a rounded-full chip that reads as bordered/neutral when unselected
/// and filled (neutral `buttonPrimary` + inverse label) when selected.
///
/// Colors resolve from the active [SureColors] palette, so it's brightness-aware
/// and stays in lockstep with `sure.tokens.json` (and avoids the Material
/// `primaryContainer` tint the raw `FilterChip` falls back to).
///
/// ```dart
/// SureChip(
/// label: 'USD',
/// selected: isSelected,
/// onSelected: (next) => toggle(next),
/// )
/// ```
class SureChip extends StatelessWidget {
const SureChip({
super.key,
required this.label,
this.selected = false,
this.onSelected,
this.leading,
this.enabled = true,
});
final String label;
final bool selected;
/// Called with the next selected value when tapped. When null the chip is
/// non-interactive (still renders its selected/unselected state).
final ValueChanged<bool>? onSelected;
/// Optional leading widget (e.g. a color dot or icon).
final Widget? leading;
final bool enabled;
@override
Widget build(BuildContext context) {
final palette = SureColors.of(context).palette;
final theme = Theme.of(context);
final interactive = enabled && onSelected != null;
// Selected: filled neutral pill with an inverse label. Unselected: a
// transparent pill with a hairline border. The border lives on the shape so
// Material paints it and clips the ink to the stadium.
final shape = StadiumBorder(
side: selected
? BorderSide.none
: BorderSide(color: palette.borderSecondary),
);
final content = ConstrainedBox(
// Enforce a comfortable minimum tap target regardless of context
// (Material FilterChip parity); the chip still sizes to content otherwise.
constraints: const BoxConstraints(minHeight: 44),
child: Padding(
// horizontal 14 is intentionally off the SureSpacing scale (between
// lg=12 and xl=16): it's the chip's tuned content inset for the
// FilterChip-parity look, not a spacing-scale step — don't "fix" it to a
// token. Vertical stays on-scale.
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: SureSpacing.lg),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (leading != null) ...[leading!, const SizedBox(width: SureSpacing.sm)],
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
color: selected ? palette.textInverse : palette.textSecondary,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
),
],
),
),
);
return Semantics(
// Announce as a button only when it's actually tappable; a display-only
// chip is just a selected indicator, not a disabled button.
button: interactive ? true : null,
enabled: interactive ? true : null,
selected: selected,
child: Opacity(
opacity: enabled ? 1.0 : 0.5,
child: Material(
color: selected ? palette.buttonPrimary : const Color(0x00000000),
shape: shape,
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: interactive ? () => onSelected!(!selected) : null,
customBorder: shape,
child: content,
),
),
),
);
}
}