Files
sure/mobile/lib/widgets/sure_button.dart
ghost 213bb0d6e8 design-system(mobile): polish Dashboard with Sure tokens (#2457)
* 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.

* design-system(mobile): polish Dashboard with Sure tokens

Align the dashboard with the Sure design system (no behavior changes).

NetWorthCard: the hero card adopts the canonical Sure card chrome — container
fill, hairline borderSecondary, radiusLg, and the subtle DS shadow (mirroring
SureCard/AccountCard) instead of Material surfaceContainerHighest/outline with
an ad-hoc radius and no elevation. Dividers, the Net Worth label/value, the
Outdated badge, asset/liability totals, and the currency-breakdown sheet all
resolve from the active SureColors palette (brightness-aware).

dashboard_screen.dart: empty/error states use SureButton + palette colors; the
account-type group header badge uses surfaceInset/textSecondary + SureTypography;
the sync success banner and sync/refresh snackbars use palette.success/
palette.destructive; spacing moves onto the SureSpacing scale.

Adds net_worth_card_test.dart asserting the hero card chrome resolves Sure
tokens in light and dark.

Builds on the SureSpacing/SureTypography scale tokens (#2438).

* fix(mobile): readable foreground on tokenized dashboard snackbars + keyed chrome test

Address review feedback on #2457:

- Snackbar contrast: the success/error snackbars switched their background to
  palette.success/palette.destructive but kept a white icon + default white
  text. In dark theme palette.success is a bright green (#32D583), so white was
  low-contrast. Set the icon and text foreground to palette.textInverse, which
  flips with the theme (#FFFFFF light / #171717 dark) and stays readable on both
  semantic fills.
- Test robustness: key the NetWorthCard chrome Container ('netWorthCardChrome')
  and look it up with find.byKey instead of the fragile first-descendant
  Container match.

* fix(mobile): SureButton owns leading-icon foreground via IconTheme

Address review feedback (jjmata): call sites shouldn't hardcode the button's
foreground on leading icons. Wrap SureButton's content in an IconTheme set to
the variant foreground, so leading icons (e.g. SureIcon) inherit it
automatically — mirroring how Material's ElevatedButton.icon propagates icon
color. Icons that pass an explicit color still win.

Drop the now-redundant `color: palette.textInverse` from the dashboard
empty/error-state button icons; they follow the button variant automatically.

Add a SureButton test asserting a leading icon inherits the variant foreground
(textInverse for primary, textPrimary for outline) via the ambient IconTheme.

* Fix net worth card mask merge regression

* Provide privacy state in net worth card tests

---------

Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
Co-authored-by: sure-admin <sure-admin@splashblot.com>
2026-07-30 04:12:30 +02:00

299 lines
9.8 KiB
Dart

import 'package:flutter/cupertino.dart' show CupertinoActivityIndicator;
import 'package:flutter/material.dart';
import '../theme/sure_colors.dart';
import '../theme/sure_spacing.dart';
import '../theme/sure_tokens.dart';
import '../theme/sure_typography.dart';
/// Sure design-system button variants, mirroring the web `DS::Button`
/// (`DS::Buttonish::VARIANTS`).
enum SureButtonVariant { primary, secondary, destructive, outline, ghost }
/// Button sizes, mirroring the web `DS::Buttonish::SIZES` (sm/md/lg ≈ 28/36/48).
enum SureButtonSize { sm, md, lg }
/// Sure design-system button — a custom, non-Material control mirroring the web
/// `DS::Button`: tokenized variant colors, `font-medium` label, sizes, and a
/// flat custom press feedback (no Material ripple). `onPressed: null` (or
/// [loading]) renders a disabled button.
///
/// Colors resolve from the active [SureColors] palette, so the button is
/// brightness-aware and stays in lockstep with `sure.tokens.json`.
class SureButton extends StatefulWidget {
final String label;
/// Tap handler. When null the button is disabled (50% opacity, no taps).
final VoidCallback? onPressed;
final SureButtonVariant variant;
final SureButtonSize size;
/// Optional leading widget (e.g. a `SureIcon`), kept decoupled so the button
/// doesn't depend on any specific icon implementation.
final Widget? leading;
/// Stretch to the available width (mirrors `full_width`).
final bool fullWidth;
/// Show an adaptive spinner in place of the leading slot and disable taps.
final bool loading;
const SureButton({
super.key,
required this.label,
required this.onPressed,
this.variant = SureButtonVariant.primary,
this.size = SureButtonSize.md,
this.leading,
this.fullWidth = false,
this.loading = false,
});
@override
State<SureButton> createState() => _SureButtonState();
}
class _SureButtonState extends State<SureButton> {
bool _pressed = false;
bool _focused = false;
bool get _enabled => widget.onPressed != null && !widget.loading;
@override
void didUpdateWidget(covariant SureButton oldWidget) {
super.didUpdateWidget(oldWidget);
// If the button is disabled mid-press, onTapUp/onTapCancel never fire — so
// clear the pressed state here to avoid it sticking on once re-enabled.
if (!_enabled) {
_pressed = false;
}
}
@override
Widget build(BuildContext context) {
final palette = SureColors.of(context).palette;
final style = _SureButtonStyle.resolve(widget.variant, palette);
final metrics = _SureButtonMetrics.resolve(widget.size);
final background = _pressed && _enabled ? style.pressedBackground : style.background;
final label = Text(
widget.label,
style: TextStyle(
color: style.foreground,
fontSize: metrics.fontSize,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
// Build the spinner per-platform so the foreground tint is honored on both:
// CircularProgressIndicator.adaptive renders a CupertinoActivityIndicator on
// Apple platforms, which ignores `valueColor`, so pass `color` to it directly.
final platform = Theme.of(context).platform;
final isCupertino =
platform == TargetPlatform.iOS || platform == TargetPlatform.macOS;
final leading = widget.loading
? SizedBox(
width: metrics.fontSize,
height: metrics.fontSize,
child: isCupertino
? CupertinoActivityIndicator(
color: style.foreground,
radius: metrics.fontSize / 2,
)
: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(style.foreground),
),
)
: widget.leading;
// The button owns the foreground color of its contents: wrap the row in an
// IconTheme so leading icons (e.g. a SureIcon) inherit the variant's
// foreground automatically — call sites shouldn't pass it (mirrors how
// Material's ElevatedButton.icon propagates icon color). Icons that set an
// explicit color still win.
final content = IconTheme.merge(
data: IconThemeData(color: style.foreground),
child: Row(
mainAxisSize: widget.fullWidth ? MainAxisSize.max : MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (leading != null) ...[
leading,
const SizedBox(width: SureSpacing.md),
],
// Flex only when full-width (bounded). A bare Flexible in a min-size
// Row asserts under unbounded horizontal constraints, so an inline
// button passes the self-sizing label directly.
if (widget.fullWidth) Flexible(child: label) else label,
],
),
);
final button = AnimatedContainer(
duration: const Duration(milliseconds: 80),
constraints: BoxConstraints(minHeight: metrics.height),
padding: EdgeInsets.symmetric(horizontal: metrics.horizontalPadding),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(metrics.radius),
border: style.border == null
? null
: Border.all(color: style.border!),
// Keyboard/switch-control focus ring (drawn as a non-displacing ring so
// it doesn't shift layout). borderPrimary is brightness-aware.
boxShadow: _focused
? [
BoxShadow(
color: palette.borderPrimary,
blurRadius: 0,
spreadRadius: 2,
),
]
: null,
),
child: Center(widthFactor: widget.fullWidth ? null : 1.0, child: content),
);
return Semantics(
button: true,
enabled: _enabled,
label: widget.loading ? '${widget.label}, loading' : widget.label,
child: FocusableActionDetector(
enabled: _enabled,
mouseCursor:
_enabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
onShowFocusHighlight: (value) => setState(() => _focused = value),
actions: <Type, Action<Intent>>{
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (_) {
if (_enabled) widget.onPressed?.call();
return null;
},
),
},
child: Opacity(
opacity: _enabled ? 1.0 : 0.5,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
// Keep all tap callbacks present (a stable gesture arena) and gate
// on `_enabled` inside them. Swapping them to null when the button
// is disabled mid-press would dispose the recognizer during the
// rebuild and fire onTapCancel -> setState() during build.
onTapDown: (_) {
if (_enabled) setState(() => _pressed = true);
},
onTapUp: (_) {
if (_pressed) setState(() => _pressed = false);
},
onTapCancel: () {
if (_pressed) setState(() => _pressed = false);
},
onTap: () {
if (_enabled) widget.onPressed?.call();
},
child: button,
),
),
),
);
}
}
class _SureButtonStyle {
const _SureButtonStyle({
required this.background,
required this.pressedBackground,
required this.foreground,
this.border,
});
final Color background;
final Color pressedBackground;
final Color foreground;
final Color? border;
static _SureButtonStyle resolve(
SureButtonVariant variant,
SureTokenPalette p,
) {
switch (variant) {
case SureButtonVariant.primary:
return _SureButtonStyle(
background: p.buttonPrimary,
pressedBackground: p.buttonPrimaryHover,
foreground: p.textInverse,
);
case SureButtonVariant.destructive:
return _SureButtonStyle(
background: p.buttonDestructive,
pressedBackground: p.buttonDestructiveHover,
foreground: p.textInverse,
);
case SureButtonVariant.secondary:
return _SureButtonStyle(
background: p.surfaceInset,
pressedBackground: p.surfaceInsetHover,
foreground: p.textPrimary,
);
case SureButtonVariant.outline:
return _SureButtonStyle(
background: const Color(0x00000000),
pressedBackground: p.surfaceHover,
foreground: p.textPrimary,
border: p.borderSecondary,
);
case SureButtonVariant.ghost:
return _SureButtonStyle(
background: const Color(0x00000000),
pressedBackground: p.surfaceHover,
foreground: p.textPrimary,
);
}
}
}
class _SureButtonMetrics {
const _SureButtonMetrics({
required this.height,
required this.horizontalPadding,
required this.fontSize,
required this.radius,
});
final double height;
final double horizontalPadding;
final double fontSize;
final double radius;
static _SureButtonMetrics resolve(SureButtonSize size) {
switch (size) {
case SureButtonSize.sm:
return const _SureButtonMetrics(
height: 28,
horizontalPadding: SureSpacing.lg,
fontSize: SureTypography.sm,
radius: SureTokens.radiusMd,
);
case SureButtonSize.md:
return const _SureButtonMetrics(
height: 36,
horizontalPadding: SureSpacing.xl,
fontSize: SureTypography.sm,
radius: SureTokens.radiusLg,
);
case SureButtonSize.lg:
return const _SureButtonMetrics(
height: 48,
horizontalPadding: SureSpacing.xxl,
fontSize: SureTypography.base,
radius: SureTokens.radiusLg,
);
}
}
}