diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9ab5d2c4..76d7c52a5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/llm-evals.yml b/.github/workflows/llm-evals.yml index aaa5a13a1..249afa96b 100644 --- a/.github/workflows/llm-evals.yml +++ b/.github/workflows/llm-evals.yml @@ -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 diff --git a/mobile/lib/screens/recent_transactions_screen.dart b/mobile/lib/screens/recent_transactions_screen.dart index 546cea286..ccf7a2768 100644 --- a/mobile/lib/screens/recent_transactions_screen.dart +++ b/mobile/lib/screens/recent_transactions_screen.dart @@ -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 { itemBuilder: (context, index) { final transaction = recentTransactions[index]; return _buildTransactionItem( + context, transaction, colorScheme, ); @@ -173,7 +175,8 @@ class _RecentTransactionsScreenState extends State { ); } - 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 { 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 { 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 { ], ], ), - 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, ), ), ); diff --git a/mobile/lib/screens/transactions_list_screen.dart b/mobile/lib/screens/transactions_list_screen.dart index 8e9cb4100..3bdca2e8c 100644 --- a/mobile/lib/screens/transactions_list_screen.dart +++ b/mobile/lib/screens/transactions_list_screen.dart @@ -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 { // 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 { 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 { ), ), 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, ), ), ), diff --git a/mobile/lib/theme/sure_colors.dart b/mobile/lib/theme/sure_colors.dart new file mode 100644 index 000000000..1438dcae9 --- /dev/null +++ b/mobile/lib/theme/sure_colors.dart @@ -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 { + 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( + theme.brightness == Brightness.dark + ? SureTokens.dark + : SureTokens.light, + ); + } + + @override + SureColors copyWith({SureTokenPalette? palette}) { + return SureColors(palette ?? this.palette); + } + + @override + SureColors lerp(ThemeExtension? 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; + } +} diff --git a/mobile/lib/theme/sure_theme.dart b/mobile/lib/theme/sure_theme.dart index 0973d9128..a327dbbff 100644 --- a/mobile/lib/theme/sure_theme.dart +++ b/mobile/lib/theme/sure_theme.dart @@ -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: >[SureColors(tokens)], appBarTheme: AppBarTheme( centerTitle: true, elevation: 0, diff --git a/mobile/lib/widgets/account_card.dart b/mobile/lib/widgets/account_card.dart index f3cbd8636..7e45adc33 100644 --- a/mobile/lib/widgets/account_card.dart +++ b/mobile/lib/widgets/account_card.dart @@ -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), diff --git a/mobile/lib/widgets/money_text.dart b/mobile/lib/widgets/money_text.dart new file mode 100644 index 000000000..8420b8fcb --- /dev/null +++ b/mobile/lib/widgets/money_text.dart @@ -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.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.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), + ); + } +} diff --git a/mobile/lib/widgets/net_worth_card.dart b/mobile/lib/widgets/net_worth_card.dart index 2d0ca03a0..2de81de89 100644 --- a/mobile/lib/widgets/net_worth_card.dart +++ b/mobile/lib/widgets/net_worth_card.dart @@ -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( @@ -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, + ), + ), ), ); }, diff --git a/mobile/test/widgets/account_card_test.dart b/mobile/test/widgets/account_card_test.dart new file mode 100644 index 000000000..3533bf034 --- /dev/null +++ b/mobile/test/widgets/account_card_test.dart @@ -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 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(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(find.text('USD 100.00')); + expect(balance.style?.color, isNot(SureTokens.light.destructive)); + }); +} diff --git a/mobile/test/widgets/money_text_test.dart b/mobile/test/widgets/money_text_test.dart new file mode 100644 index 000000000..d2d0b21e4 --- /dev/null +++ b/mobile/test/widgets/money_text_test.dart @@ -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 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(find.text('+\$10.00')); + expect(text.style?.color, SureTokens.light.success); + expect(text.style?.fontFeatures, + contains(const FontFeature.tabularFigures())); + }); + }); +} diff --git a/test/test_helper.rb b/test/test_helper.rb index 461ebe3a6..b70459708 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -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 }