mirror of
https://github.com/we-promise/sure.git
synced 2026-07-18 07:45:24 +00:00
* 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.
226 lines
7.2 KiB
Dart
226 lines
7.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../theme/sure_colors.dart';
|
|
import '../theme/sure_spacing.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: SureSpacing.xl,
|
|
right: SureSpacing.xl,
|
|
bottom: SureSpacing.md,
|
|
),
|
|
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: SureSpacing.xl, vertical: 14),
|
|
child: Row(
|
|
children: [
|
|
if (leading != null) ...[leading!, const SizedBox(width: SureSpacing.lg)],
|
|
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: SureSpacing.lg),
|
|
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;
|
|
}
|
|
}
|