mirror of
https://github.com/we-promise/sure.git
synced 2026-07-19 08:15:21 +00:00
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:
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/llm-evals.yml
vendored
2
.github/workflows/llm-evals.yml
vendored
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
45
mobile/lib/theme/sure_colors.dart
Normal file
45
mobile/lib/theme/sure_colors.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
110
mobile/lib/widgets/money_text.dart
Normal file
110
mobile/lib/widgets/money_text.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
41
mobile/test/widgets/account_card_test.dart
Normal file
41
mobile/test/widgets/account_card_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
74
mobile/test/widgets/money_text_test.dart
Normal file
74
mobile/test/widgets/money_text_test.dart
Normal 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()));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user