Files
sure/mobile/lib/widgets/money_text.dart
ghost e38632632c feat(mobile): standardize money typography and semantic amount color (#2331)
* feat(mobile): standardize money typography and semantic amount color

Add a brightness-aware SureColors theme extension and a MoneyText/SureMoney
primitive (semantic success/destructive/subdued tokens + tabular figures for
column-aligned digits), then migrate the transaction lists and balance cards
off raw Colors.green/red/grey.

Step 2 of the mobile design-system sequence (#2235), after #2237's theme
foundation. Primitive-first: screens consume shared tokens/typography.

* fix: review feedback — brightness-aware token fallback, de-flake Setting tests, Pipelock localhost FP

- SureColors.of falls back to the palette matching the active brightness (not
  always light) when the extension is missing, so dark surfaces stay correct.
- Clear the rails-settings-cached cache before each test; its in-memory cache
  survives the per-test transaction rollback, leaking Setting.* across tests and
  flaking Settings::HostingsControllerTest (stale empty string vs nil).
  Full unit suite: 4952 runs, 0 failures.
- Suppress the localhost test-DB DATABASE_URL false positive with line-level
  `# pipelock:ignore` in ci.yml + llm-evals.yml instead of excluding whole files,
  so those workflows stay scanned for real secrets.
2026-06-14 21:37:30 +02:00

111 lines
3.7 KiB
Dart

import 'package:flutter/material.dart';
import '../theme/sure_colors.dart';
/// Directional meaning of a monetary value, used to pick its semantic color.
/// This mirrors the app's existing in/out coloring (money in is positive, money
/// out is negative) but routes it through Sure design-system tokens instead of
/// raw [Colors.green]/[Colors.red].
enum MoneyTrend {
/// Money coming in (positive change, income, a gain). Uses `success`.
inflow,
/// Money going out (negative change, expense, a loss). Uses `destructive`.
outflow,
/// No directional meaning / unknown amount. Uses `textSubdued`.
neutral,
}
/// Shared money typography + semantic color, so every screen renders monetary
/// values the same way: tabular figures (digits stay aligned across rows) and a
/// design-system color chosen from the value's [MoneyTrend].
///
/// Convention for callers:
/// - Directionally-colored amounts (income/expense, +/- changes): use
/// [MoneyText] (or [style]) with a [MoneyTrend] — it applies the semantic
/// color *and* tabular figures, so do not also wrap the style in [tabular].
/// - Neutral amounts that are not directionally colored (account balances, net
/// worth, per-currency totals): use [tabular] to keep digits aligned without
/// changing the color. [MoneyTrend.neutral] is reserved for unknown/unparsed
/// amounts (renders in `textSubdued`).
class SureMoney {
const SureMoney._();
/// The design-system color for [trend] in the active theme.
static Color color(BuildContext context, MoneyTrend trend) {
final palette = SureColors.of(context).palette;
switch (trend) {
case MoneyTrend.inflow:
return palette.success;
case MoneyTrend.outflow:
return palette.destructive;
case MoneyTrend.neutral:
return palette.textSubdued;
}
}
/// Derive a [MoneyTrend] from a signed amount. `null` amounts are [neutral].
static MoneyTrend trendForAmount(double? amount) {
if (amount == null) {
return MoneyTrend.neutral;
}
return amount >= 0 ? MoneyTrend.inflow : MoneyTrend.outflow;
}
/// A money [TextStyle]: [base] (so callers keep their size) plus the semantic
/// color for [trend] and tabular figures for column-aligned digits.
static TextStyle style(
BuildContext context, {
required MoneyTrend trend,
TextStyle? base,
}) {
final resolved = base ?? const TextStyle();
return resolved.copyWith(
color: color(context, trend),
fontWeight: resolved.fontWeight ?? FontWeight.w600,
fontFeatures: const <FontFeature>[FontFeature.tabularFigures()],
);
}
/// Apply only money typography (tabular figures) to [base], leaving the color
/// untouched. For neutral balances/totals (e.g. net worth) that are not
/// directionally colored but should still keep digits column-aligned.
static TextStyle tabular(TextStyle? base) {
return (base ?? const TextStyle()).copyWith(
fontFeatures: const <FontFeature>[FontFeature.tabularFigures()],
);
}
}
/// Renders a pre-formatted monetary [text] with shared money typography and the
/// semantic color for [trend].
class MoneyText extends StatelessWidget {
const MoneyText(
this.text, {
super.key,
required this.trend,
this.style,
this.textAlign,
this.overflow,
});
final String text;
final MoneyTrend trend;
/// Base style (size/weight). Color and tabular figures are applied on top.
final TextStyle? style;
final TextAlign? textAlign;
final TextOverflow? overflow;
@override
Widget build(BuildContext context) {
return Text(
text,
textAlign: textAlign,
overflow: overflow,
style: SureMoney.style(context, trend: trend, base: style),
);
}
}