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.
This commit is contained in:
ghost
2026-06-14 12:37:30 -07:00
committed by GitHub
parent c701479aee
commit e38632632c
12 changed files with 347 additions and 51 deletions

View File

@@ -90,7 +90,7 @@ jobs:
env:
PLAID_CLIENT_ID: foo
PLAID_SECRET: bar
DATABASE_URL: postgres://postgres:postgres@localhost:5432
DATABASE_URL: postgres://postgres:postgres@localhost:5432 # pipelock:ignore
REDIS_URL: redis://localhost:6379
RAILS_ENV: test
@@ -141,7 +141,7 @@ jobs:
env:
PLAID_CLIENT_ID: foo
PLAID_SECRET: bar
DATABASE_URL: postgres://postgres:postgres@localhost:5432
DATABASE_URL: postgres://postgres:postgres@localhost:5432 # pipelock:ignore
REDIS_URL: redis://localhost:6379
RAILS_ENV: test

View File

@@ -11,7 +11,7 @@ permissions:
env:
EVAL_MODELS: gpt-4.1
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost:5432
DATABASE_URL: postgres://postgres:postgres@localhost:5432 # pipelock:ignore
REDIS_URL: redis://localhost:6379
PLAID_CLIENT_ID: foo
PLAID_SECRET: bar

View File

@@ -7,6 +7,7 @@ import '../providers/transactions_provider.dart';
import '../providers/accounts_provider.dart';
import '../providers/auth_provider.dart';
import '../utils/amount_parser.dart';
import '../widgets/money_text.dart';
class RecentTransactionsScreen extends StatefulWidget {
const RecentTransactionsScreen({super.key});
@@ -136,6 +137,7 @@ class _RecentTransactionsScreenState extends State<RecentTransactionsScreen> {
itemBuilder: (context, index) {
final transaction = recentTransactions[index];
return _buildTransactionItem(
context,
transaction,
colorScheme,
);
@@ -173,7 +175,8 @@ class _RecentTransactionsScreenState extends State<RecentTransactionsScreen> {
);
}
Widget _buildTransactionItem(Transaction transaction, ColorScheme colorScheme) {
Widget _buildTransactionItem(
BuildContext context, Transaction transaction, ColorScheme colorScheme) {
final account = _getAccount(transaction.accountId);
final accountName = account?.name ?? 'Unknown Account';
@@ -190,21 +193,17 @@ class _RecentTransactionsScreenState extends State<RecentTransactionsScreen> {
amount = -amount;
}
// Determine display properties based on final amount
// Determine display properties based on final amount. The semantic color
// comes from the Sure design-system tokens (success/destructive/subdued)
// via MoneyTrend, instead of raw Colors.green/red.
final isPositive = amount == null || amount >= 0;
Color amountColor;
String sign;
if (amount == null) {
amountColor = Colors.grey;
sign = '';
} else if (isPositive) {
amountColor = Colors.green.shade700;
sign = '+';
} else {
amountColor = Colors.red.shade700;
sign = '-';
}
final moneyTrend = SureMoney.trendForAmount(amount);
final amountColor = SureMoney.color(context, moneyTrend);
final sign = amount == null
? ''
: isPositive
? '+'
: '-';
String formattedDate;
try {
@@ -219,11 +218,7 @@ class _RecentTransactionsScreenState extends State<RecentTransactionsScreen> {
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: amount == null
? Colors.grey.withValues(alpha: 0.1)
: isPositive
? Colors.green.withValues(alpha: 0.1)
: Colors.red.withValues(alpha: 0.1),
color: amountColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
@@ -273,14 +268,14 @@ class _RecentTransactionsScreenState extends State<RecentTransactionsScreen> {
],
],
),
trailing: Text(
trailing: MoneyText(
amount == null
? transaction.amount
: '$sign${transaction.currency} ${_formatAmount(amount.abs())}',
style: TextStyle(
trend: moneyTrend,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: amountColor,
),
),
);

View File

@@ -13,6 +13,7 @@ import '../widgets/category_filter.dart';
import '../widgets/sync_status_badge.dart';
import '../services/log_service.dart';
import '../utils/amount_parser.dart';
import '../widgets/money_text.dart';
class TransactionsListScreen extends StatefulWidget {
final Account account;
@@ -58,11 +59,13 @@ class _TransactionsListScreenState extends State<TransactionsListScreen> {
// Determine if the final value is positive
bool isPositive = numericValue >= 0;
final trend = isPositive ? MoneyTrend.inflow : MoneyTrend.outflow;
return {
'isPositive': isPositive,
'displayAmount': parsed.displayText,
'color': isPositive ? Colors.green : Colors.red,
'trend': trend,
'color': SureMoney.color(context, trend),
'icon': isPositive ? Icons.arrow_upward : Icons.arrow_downward,
'prefix': isPositive ? '' : '-',
};
@@ -71,7 +74,8 @@ class _TransactionsListScreenState extends State<TransactionsListScreen> {
return {
'isPositive': true,
'displayAmount': amount,
'color': Colors.grey,
'trend': MoneyTrend.neutral,
'color': SureMoney.color(context, MoneyTrend.neutral),
'icon': Icons.help_outline,
'prefix': '',
};
@@ -655,12 +659,12 @@ class _TransactionsListScreenState extends State<TransactionsListScreen> {
),
),
Flexible(
child: Text(
child: MoneyText(
'${displayInfo['prefix']}${displayInfo['displayAmount']}',
trend: displayInfo['trend'] as MoneyTrend,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: displayInfo['color'] as Color,
),
),
),

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'sure_tokens.dart';
/// Exposes the full Sure design-system palette to widgets in a brightness-aware
/// way. The generated [SureTokenPalette] carries semantic colors (success,
/// destructive, textSubdued, …) that the base [ColorScheme] does not, so without
/// this extension widgets have to branch on `Theme.of(context).brightness` and
/// reach into `SureTokens.light`/`SureTokens.dark` by hand. Registering the
/// active palette here lets them resolve the correct token via
/// `SureColors.of(context).palette.success` instead.
@immutable
class SureColors extends ThemeExtension<SureColors> {
const SureColors(this.palette);
final SureTokenPalette palette;
/// The active palette for [context]. Falls back to the palette matching the
/// active brightness if the extension is missing (e.g. a widget built outside
/// [SureTheme] in a test), so dark surfaces don't get light-palette colors.
static SureColors of(BuildContext context) {
final theme = Theme.of(context);
return theme.extension<SureColors>() ??
SureColors(
theme.brightness == Brightness.dark
? SureTokens.dark
: SureTokens.light,
);
}
@override
SureColors copyWith({SureTokenPalette? palette}) {
return SureColors(palette ?? this.palette);
}
@override
SureColors lerp(ThemeExtension<SureColors>? other, double t) {
// Design tokens are a discrete light/dark pair; a mid-transition blend of
// every semantic color is not meaningful, so swap at the midpoint.
if (other is! SureColors) {
return this;
}
return t < 0.5 ? this : other;
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'sure_colors.dart';
import 'sure_tokens.dart';
class SureTheme {
@@ -38,6 +39,7 @@ class SureTheme {
colorScheme: colorScheme,
scaffoldBackgroundColor: tokens.surface,
useMaterial3: true,
extensions: <ThemeExtension<dynamic>>[SureColors(tokens)],
appBarTheme: AppBarTheme(
centerTitle: true,
elevation: 0,

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import '../models/account.dart';
import '../theme/sure_colors.dart';
import 'money_text.dart';
class AccountCard extends StatelessWidget {
final Account account;
@@ -42,9 +44,9 @@ class AccountCard extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
if (account.isAsset) {
return Colors.green;
return SureColors.of(context).palette.success;
} else if (account.isLiability) {
return Colors.red;
return SureColors.of(context).palette.destructive;
}
return colorScheme.primary;
}
@@ -109,9 +111,13 @@ class AccountCard extends StatelessWidget {
children: [
Text(
account.balance,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: account.isLiability ? Colors.red : null,
style: SureMoney.tabular(
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: account.isLiability
? SureColors.of(context).palette.destructive
: null,
),
),
),
const SizedBox(height: 4),

View File

@@ -0,0 +1,110 @@
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),
);
}
}

View File

@@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import '../theme/sure_colors.dart';
import 'money_text.dart';
enum AccountFilter { all, assets, liabilities }
class NetWorthCard extends StatelessWidget {
@@ -25,6 +28,7 @@ class NetWorthCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final sureColors = SureColors.of(context);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -74,10 +78,14 @@ class NetWorthCard extends StatelessWidget {
const SizedBox(height: 4),
Text(
netWorthFormatted ?? '--',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: isStale ? colorScheme.secondary : colorScheme.onSurface,
),
style: SureMoney.tabular(
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: isStale
? colorScheme.secondary
: colorScheme.onSurface,
),
),
),
],
),
@@ -97,7 +105,7 @@ class NetWorthCard extends StatelessWidget {
Expanded(
child: _FilterButton(
totals: assetTotalsByCurrency,
color: Colors.green,
color: sureColors.palette.success,
isSelected: currentFilter == AccountFilter.assets,
onTap: () {
if (currentFilter == AccountFilter.assets) {
@@ -110,7 +118,7 @@ class NetWorthCard extends StatelessWidget {
context,
'Assets',
assetTotalsByCurrency,
Colors.green,
sureColors.palette.success,
),
formatAmount: formatAmount,
),
@@ -126,7 +134,7 @@ class NetWorthCard extends StatelessWidget {
Expanded(
child: _FilterButton(
totals: liabilityTotalsByCurrency,
color: Colors.red,
color: sureColors.palette.destructive,
isSelected: currentFilter == AccountFilter.liabilities,
onTap: () {
if (currentFilter == AccountFilter.liabilities) {
@@ -139,7 +147,7 @@ class NetWorthCard extends StatelessWidget {
context,
'Liabilities',
liabilityTotalsByCurrency,
Colors.red,
sureColors.palette.destructive,
),
formatAmount: formatAmount,
),
@@ -304,10 +312,12 @@ class _FilterButton extends StatelessWidget {
? Center(
child: Text(
formatAmount(sortedEntries.first.key, sortedEntries.first.value),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
style: SureMoney.tabular(
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
)
: NotificationListener<ScrollNotification>(
@@ -324,10 +334,12 @@ class _FilterButton extends StatelessWidget {
return Center(
child: Text(
formatAmount(entry.key, entry.value),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
style: SureMoney.tabular(
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
),
);
},

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sure_mobile/models/account.dart';
import 'package:sure_mobile/theme/sure_theme.dart';
import 'package:sure_mobile/theme/sure_tokens.dart';
import 'package:sure_mobile/widgets/account_card.dart';
void main() {
Account account(String classification) => Account(
id: '1',
name: 'Test account',
balance: 'USD 100.00',
currency: 'USD',
accountType:
classification == 'liability' ? 'credit_card' : 'depository',
classification: classification,
);
Future<void> pump(WidgetTester tester, Account a) async {
await tester.pumpWidget(
MaterialApp(
theme: SureTheme.light,
home: Scaffold(body: AccountCard(account: a)),
),
);
}
testWidgets('liability balance uses the destructive design-system token',
(tester) async {
await pump(tester, account('liability'));
final balance = tester.widget<Text>(find.text('USD 100.00'));
expect(balance.style?.color, SureTokens.light.destructive);
});
testWidgets('asset balance keeps default (non-destructive) color',
(tester) async {
await pump(tester, account('asset'));
final balance = tester.widget<Text>(find.text('USD 100.00'));
expect(balance.style?.color, isNot(SureTokens.light.destructive));
});
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sure_mobile/theme/sure_theme.dart';
import 'package:sure_mobile/theme/sure_tokens.dart';
import 'package:sure_mobile/widgets/money_text.dart';
void main() {
group('SureMoney.trendForAmount', () {
test('null amount is neutral', () {
expect(SureMoney.trendForAmount(null), MoneyTrend.neutral);
});
test('positive and zero are inflow, negative is outflow', () {
expect(SureMoney.trendForAmount(12.5), MoneyTrend.inflow);
expect(SureMoney.trendForAmount(0), MoneyTrend.inflow);
expect(SureMoney.trendForAmount(-3), MoneyTrend.outflow);
});
});
Future<BuildContext> pumpContext(WidgetTester tester, ThemeData theme) async {
late BuildContext captured;
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: Builder(
builder: (context) {
captured = context;
return const SizedBox.shrink();
},
),
),
);
return captured;
}
group('SureMoney.color resolves design-system tokens', () {
testWidgets('light theme maps trends to success/destructive/subdued',
(tester) async {
final context = await pumpContext(tester, SureTheme.light);
expect(SureMoney.color(context, MoneyTrend.inflow),
SureTokens.light.success);
expect(SureMoney.color(context, MoneyTrend.outflow),
SureTokens.light.destructive);
expect(SureMoney.color(context, MoneyTrend.neutral),
SureTokens.light.textSubdued);
});
testWidgets('dark theme resolves the dark palette', (tester) async {
final context = await pumpContext(tester, SureTheme.dark);
expect(
SureMoney.color(context, MoneyTrend.inflow), SureTokens.dark.success);
expect(SureMoney.color(context, MoneyTrend.outflow),
SureTokens.dark.destructive);
});
});
group('MoneyText', () {
testWidgets('applies semantic color and tabular figures', (tester) async {
await tester.pumpWidget(
MaterialApp(
theme: SureTheme.light,
home: const Scaffold(
body: MoneyText('+\$10.00', trend: MoneyTrend.inflow),
),
),
);
final text = tester.widget<Text>(find.text('+\$10.00'));
expect(text.style?.color, SureTokens.light.success);
expect(text.style?.fontFeatures,
contains(const FontFeature.tabularFigures()));
});
});
}

View File

@@ -78,6 +78,13 @@ module ActiveSupport
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all
# rails-settings-cached keeps an in-memory cache that survives the per-test
# transaction rollback, so a Setting.* value written in one test leaks into
# later tests and causes order-dependent failures (e.g. Settings::Hostings
# reading a stale "" where nil is expected). Reset the cache before every
# test so each starts from the rolled-back DB state.
setup { Setting.clear_cache }
# Add more helper methods to be used by all tests here...
def sign_in(user)
post sessions_path, params: { email: user.email, password: user_password_test }