Files
sure/mobile/lib/screens/dashboard_screen.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

779 lines
24 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/account.dart';
import '../providers/auth_provider.dart';
import '../providers/accounts_provider.dart';
import '../providers/transactions_provider.dart';
import '../services/log_service.dart';
import '../services/preferences_service.dart';
import '../widgets/account_card.dart';
import '../widgets/connectivity_banner.dart';
import '../widgets/net_worth_card.dart';
import '../widgets/currency_filter.dart';
import '../widgets/sure_button.dart';
import '../widgets/sure_icon.dart';
import '../theme/sure_colors.dart';
import '../theme/sure_spacing.dart';
import '../theme/sure_tokens.dart';
import '../theme/sure_typography.dart';
import 'transaction_form_screen.dart';
import 'transactions_list_screen.dart';
import '../l10n/app_localizations.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
DashboardScreenState createState() => DashboardScreenState();
}
class DashboardScreenState extends State<DashboardScreen> {
final LogService _log = LogService.instance;
bool _showSyncSuccess = false;
int _previousPendingCount = 0;
Timer? _syncSuccessTimer;
TransactionsProvider? _transactionsProvider;
// Filter state
AccountFilter _accountFilter = AccountFilter.all;
Set<String> _selectedCurrencies = {};
// Group by type state
bool _groupByType = false;
final Set<String> _collapsedGroups = {};
@override
void initState() {
super.initState();
_loadAccounts();
_loadPreferences();
// Listen for sync completion to show success indicator
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_transactionsProvider = Provider.of<TransactionsProvider>(context, listen: false);
_previousPendingCount = _transactionsProvider?.pendingCount ?? 0;
_transactionsProvider?.addListener(_onTransactionsChanged);
});
}
@override
void dispose() {
_syncSuccessTimer?.cancel();
_transactionsProvider?.removeListener(_onTransactionsChanged);
super.dispose();
}
void _onTransactionsChanged() {
final transactionsProvider = _transactionsProvider;
if (transactionsProvider == null || !mounted) {
return;
}
final currentPendingCount = transactionsProvider.pendingCount;
// Show sync success when pending count decreased (local transactions uploaded)
if (_previousPendingCount > 0 && currentPendingCount < _previousPendingCount) {
_showSyncSuccessIndicator();
}
_previousPendingCount = currentPendingCount;
}
void _showSyncSuccessIndicator() {
_syncSuccessTimer?.cancel();
setState(() {
_showSyncSuccess = true;
});
_syncSuccessTimer = Timer(const Duration(seconds: 3), () {
if (mounted) {
setState(() {
_showSyncSuccess = false;
});
}
});
}
Future<void> _loadAccounts() async {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final accountsProvider = Provider.of<AccountsProvider>(context, listen: false);
final accessToken = await authProvider.getValidAccessToken();
if (accessToken == null) {
// Token is invalid, redirect to login
await authProvider.logout();
return;
}
await accountsProvider.fetchAccounts(accessToken: accessToken);
// Check if unauthorized
if (accountsProvider.errorMessage == 'unauthorized') {
await authProvider.logout();
}
}
Future<void> _loadPreferences() async {
final groupByType = await PreferencesService.instance.getGroupByType();
if (mounted) {
setState(() {
_groupByType = groupByType;
});
}
}
void reloadPreferences() {
_loadPreferences();
}
Future<void> _handleRefresh() async {
await _performManualSync();
}
Future<void> _performManualSync() async {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final transactionsProvider = Provider.of<TransactionsProvider>(context, listen: false);
final l = AppLocalizations.of(context);
final palette = SureColors.of(context).palette;
final accessToken = await authProvider.getValidAccessToken();
if (accessToken == null) {
await authProvider.logout();
return;
}
// Show syncing indicator
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
const SizedBox(width: 12),
Expanded(child: Text(l.dashboardSyncing)),
],
),
duration: const Duration(seconds: 30),
),
);
}
try {
// Perform full sync: upload pending, download from server, sync accounts
await transactionsProvider.syncTransactions(accessToken: accessToken);
// Reload accounts to show updated balances
await _loadAccounts();
if (mounted) {
ScaffoldMessenger.of(context).clearSnackBars();
if (transactionsProvider.error != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
SureIcon(SureIcons.circleAlert, color: palette.textInverse),
const SizedBox(width: 12),
Expanded(
child: Text(
l.dashboardSyncFailed,
style: TextStyle(color: palette.textInverse),
),
),
],
),
backgroundColor: palette.destructive,
duration: const Duration(seconds: 3),
),
);
} else {
_showSyncSuccessIndicator();
}
}
} catch (e) {
_log.error('DashboardScreen', 'Error in _performManualSync: $e');
if (mounted) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
SureIcon(SureIcons.circleAlert, color: palette.textInverse),
const SizedBox(width: 12),
Expanded(
child: Text(
l.dashboardSyncError,
style: TextStyle(color: palette.textInverse),
),
),
],
),
backgroundColor: palette.destructive,
duration: const Duration(seconds: 3),
),
);
}
}
}
String _formatAmount(String currency, double amount) {
final symbol = _getCurrencySymbol(currency);
final isSmallAmount = amount.abs() < 1 && amount != 0;
final formattedAmount = amount.toStringAsFixed(isSmallAmount ? 4 : 0);
// Split into integer and decimal parts
final parts = formattedAmount.split('.');
final integerPart = parts[0].replaceAllMapped(
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
(Match m) => '${m[1]},',
);
final finalAmount = parts.length > 1 ? '$integerPart.${parts[1]}' : integerPart;
return '$symbol$finalAmount $currency';
}
Set<String> _getAllCurrencies(AccountsProvider accountsProvider) {
final currencies = <String>{};
for (var account in accountsProvider.accounts) {
currencies.add(account.currency);
}
return currencies;
}
List<Account> _getFilteredAccounts(AccountsProvider accountsProvider) {
var accounts = accountsProvider.accounts.toList();
// Filter by account type
switch (_accountFilter) {
case AccountFilter.assets:
accounts = accounts.where((a) => a.isAsset).toList();
break;
case AccountFilter.liabilities:
accounts = accounts.where((a) => a.isLiability).toList();
break;
case AccountFilter.all:
// Show all accounts (assets and liabilities)
accounts = accounts.where((a) => a.isAsset || a.isLiability).toList();
break;
}
// Filter by currency if any selected
if (_selectedCurrencies.isNotEmpty) {
accounts = accounts.where((a) => _selectedCurrencies.contains(a.currency)).toList();
}
return accounts;
}
String _getCurrencySymbol(String currency) {
switch (currency.toUpperCase()) {
case 'USD':
return '\$';
case 'TWD':
return '\$';
case 'BTC':
return '';
case 'ETH':
return 'Ξ';
case 'EUR':
return '';
case 'GBP':
return '£';
case 'JPY':
return '¥';
case 'CNY':
return '¥';
default:
return ' ';
}
}
Future<void> _handleAccountTap(Account account) async {
final l = AppLocalizations.of(context);
final palette = SureColors.of(context).palette;
final result = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => TransactionFormScreen(account: account),
);
// Refresh accounts if transaction was created successfully
if (result == true && mounted) {
// Show loading indicator
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
const SizedBox(width: 12),
Expanded(child: Text(l.dashboardRefreshing)),
],
),
duration: const Duration(seconds: 2),
),
);
// Small delay to ensure smooth UI transition
await Future.delayed(const Duration(milliseconds: 50));
// Refresh the accounts
await _loadAccounts();
// Hide loading snackbar and show success
if (mounted) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
SureIcon(SureIcons.circleCheck, color: palette.textInverse),
const SizedBox(width: 12),
Text(
l.dashboardAccountsUpdated,
style: TextStyle(color: palette.textInverse),
),
],
),
backgroundColor: palette.success,
duration: const Duration(seconds: 1),
),
);
}
}
}
Future<void> _handleAccountSwipe(Account account) async {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TransactionsListScreen(account: account),
),
);
// Refresh accounts when returning from transaction list
if (mounted) {
await _loadAccounts();
}
}
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context);
final palette = SureColors.of(context).palette;
return Scaffold(
body: Column(
children: [
const ConnectivityBanner(),
if (_showSyncSuccess)
AnimatedOpacity(
opacity: _showSyncSuccess ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: SureSpacing.xl, vertical: SureSpacing.sm),
color: palette.success.withValues(alpha: 0.1),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SureIcon(
SureIcons.cloudCheck,
color: palette.success,
size: 18,
),
const SizedBox(width: SureSpacing.md),
Text(
l.dashboardSynced,
style: TextStyle(
color: palette.success, fontSize: SureTypography.sm),
),
],
),
),
),
Expanded(
child: Consumer2<AuthProvider, AccountsProvider>(
builder: (context, authProvider, accountsProvider, _) {
// Show loading state during initialization or when loading
if (accountsProvider.isInitializing || accountsProvider.isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
}
// Show error state
if (accountsProvider.errorMessage != null &&
accountsProvider.errorMessage != 'unauthorized') {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SureIcon(
SureIcons.circleAlert,
size: 64,
color: palette.destructive,
),
const SizedBox(height: SureSpacing.xl),
Text(
l.dashboardErrorLoadingAccounts,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: SureSpacing.md),
Text(
accountsProvider.errorMessage!,
style: TextStyle(color: palette.textSecondary),
textAlign: TextAlign.center,
),
const SizedBox(height: SureSpacing.xxxl),
SureButton(
label: l.commonTryAgain,
onPressed: _handleRefresh,
leading: const SureIcon(SureIcons.refresh, size: 18),
),
],
),
),
);
}
// Show empty state
if (accountsProvider.accounts.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SureIcon(
SureIcons.wallet,
size: 64,
color: palette.textSubdued,
),
const SizedBox(height: SureSpacing.xl),
Text(
l.dashboardNoAccounts,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: SureSpacing.md),
Text(
l.dashboardNoAccountsSubtitle,
style: TextStyle(color: palette.textSecondary),
textAlign: TextAlign.center,
),
const SizedBox(height: SureSpacing.xxxl),
SureButton(
label: l.commonRefresh,
onPressed: _handleRefresh,
leading: const SureIcon(SureIcons.refresh, size: 18),
),
],
),
),
);
}
// Show accounts list
return RefreshIndicator(
onRefresh: _handleRefresh,
child: CustomScrollView(
slivers: [
// Net Worth Card with Asset/Liability filter
SliverToBoxAdapter(
child: NetWorthCard(
assetTotalsByCurrency: accountsProvider.assetTotalsByCurrency,
liabilityTotalsByCurrency: accountsProvider.liabilityTotalsByCurrency,
currentFilter: _accountFilter,
onFilterChanged: (filter) {
setState(() {
_accountFilter = filter;
});
},
formatAmount: _formatAmount,
netWorthFormatted: accountsProvider.netWorthFormatted,
isStale: accountsProvider.isBalanceSheetStale,
),
),
// Currency filter
SliverToBoxAdapter(
child: CurrencyFilter(
availableCurrencies: _getAllCurrencies(accountsProvider),
selectedCurrencies: _selectedCurrencies,
onSelectionChanged: (currencies) {
setState(() {
_selectedCurrencies = currencies;
});
},
),
),
// Spacing
const SliverToBoxAdapter(
child: SizedBox(height: 8),
),
// Filtered accounts section
..._buildFilteredAccountsSection(accountsProvider),
// Bottom padding
const SliverToBoxAdapter(
child: SizedBox(height: 24),
),
],
),
);
},
),
),
],
),
);
}
List<Widget> _buildFilteredAccountsSection(AccountsProvider accountsProvider) {
final filteredAccounts = _getFilteredAccounts(accountsProvider);
final l = AppLocalizations.of(context);
final palette = SureColors.of(context).palette;
if (filteredAccounts.isEmpty) {
return [
SliverToBoxAdapter(
child: Center(
child: Padding(
padding: const EdgeInsets.all(SureSpacing.huge),
child: Column(
children: [
SureIcon(
SureIcons.wallet,
size: 48,
color: palette.textSubdued,
),
const SizedBox(height: SureSpacing.xl),
Text(
l.dashboardFilterEmpty,
style: TextStyle(
color: palette.textSecondary,
),
),
],
),
),
),
),
];
}
// Sort accounts: by type, then currency, then balance
filteredAccounts.sort((a, b) {
if (a.isAsset && !b.isAsset) return -1;
if (!a.isAsset && b.isAsset) return 1;
int typeComparison = a.accountType.compareTo(b.accountType);
if (typeComparison != 0) return typeComparison;
int currencyComparison = a.currency.compareTo(b.currency);
if (currencyComparison != 0) return currencyComparison;
return b.balanceAsDouble.compareTo(a.balanceAsDouble);
});
if (_groupByType) {
return _buildGroupedAccountsList(filteredAccounts);
}
return _buildFlatAccountsList(filteredAccounts);
}
List<Widget> _buildFlatAccountsList(List<Account> accounts) {
return [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final account = accounts[index];
return AccountCard(
account: account,
onTap: () => _handleAccountTap(account),
onSwipe: () => _handleAccountSwipe(account),
);
},
childCount: accounts.length,
),
),
),
];
}
List<Widget> _buildGroupedAccountsList(List<Account> accounts) {
// Group accounts by accountType
final groups = <String, List<Account>>{};
for (final account in accounts) {
groups.putIfAbsent(account.accountType, () => []).add(account);
}
final slivers = <Widget>[];
for (final entry in groups.entries) {
final accountType = entry.key;
final groupAccounts = entry.value;
final isCollapsed = _collapsedGroups.contains(accountType);
// Use first account to get display name and icon
final displayName = groupAccounts.first.displayAccountType;
slivers.add(
SliverToBoxAdapter(
child: _CollapsibleTypeHeader(
title: displayName,
count: groupAccounts.length,
accountType: accountType,
isCollapsed: isCollapsed,
onToggle: () {
setState(() {
if (isCollapsed) {
_collapsedGroups.remove(accountType);
} else {
_collapsedGroups.add(accountType);
}
});
},
),
),
);
if (!isCollapsed) {
slivers.add(
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final account = groupAccounts[index];
return AccountCard(
account: account,
onTap: () => _handleAccountTap(account),
onSwipe: () => _handleAccountSwipe(account),
);
},
childCount: groupAccounts.length,
),
),
),
);
}
}
return slivers;
}
}
class _CollapsibleTypeHeader extends StatelessWidget {
final String title;
final int count;
final String accountType;
final bool isCollapsed;
final VoidCallback onToggle;
const _CollapsibleTypeHeader({
required this.title,
required this.count,
required this.accountType,
required this.isCollapsed,
required this.onToggle,
});
String _getTypeIconName() {
switch (accountType) {
case 'depository':
return SureIcons.landmark;
case 'credit_card':
return SureIcons.creditCard;
case 'investment':
return SureIcons.trendingUp;
case 'loan':
return SureIcons.receipt;
case 'property':
return SureIcons.house;
case 'vehicle':
return SureIcons.car;
case 'crypto':
return SureIcons.bitcoin;
case 'other_asset':
return SureIcons.shapes;
case 'other_liability':
return SureIcons.handCoins;
default:
return SureIcons.wallet;
}
}
@override
Widget build(BuildContext context) {
final palette = SureColors.of(context).palette;
return InkWell(
onTap: onToggle,
child: Padding(
padding: const EdgeInsets.fromLTRB(
SureSpacing.xl, SureSpacing.xl, SureSpacing.xl, SureSpacing.md),
child: Row(
children: [
SureIcon(
_getTypeIconName(),
size: 18,
color: palette.textSubdued,
),
const SizedBox(width: SureSpacing.lg),
Text(
title,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: SureTokens.weightMedium,
),
),
const SizedBox(width: SureSpacing.md),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
decoration: BoxDecoration(
color: palette.surfaceInset,
borderRadius: BorderRadius.circular(SureTokens.radiusLg),
),
child: Text(
count.toString(),
style: TextStyle(
color: palette.textSecondary,
fontWeight: SureTokens.weightMedium,
fontSize: SureTypography.xs,
),
),
),
const Spacer(),
SureIcon(
isCollapsed ? SureIcons.chevronDown : SureIcons.chevronUp,
size: SureIconSize.md,
color: palette.textSubdued,
),
],
),
),
);
}
}