mirror of
https://github.com/we-promise/sure.git
synced 2026-07-19 08:15:21 +00:00
* feat(mobile): add SureFontWeights design tokens Introduce named font-weight tokens so widgets reference a DS tier instead of a raw FontWeight, mirroring the web design system's Tailwind weight utilities (font-medium / font-semibold). - design/tokens/sure.tokens.json: add font.weight.medium (500) and .semibold (600) as the canonical source. - generate_sure_tokens.mjs: emit SureTokens.weightMedium / weightSemibold (with a font-weight resolver + validation), regenerate sure_tokens.dart. - sure_tokens_test.dart: assert the generated weights match canonical. - Migrate the dashboard-area money/hierarchy sites onto the tokens (net_worth_card, account_card, dashboard, recent_transactions, transactions_list): FontWeight.w500 -> SureTokens.weightMedium, FontWeight.w600 -> SureTokens.weightSemibold. Weight-only; other screens adopt the tokens in their own polish PRs. 166 tests pass; flutter analyze: no new issues; token generator --check is clean. * build(tokens): regenerate web _generated.css for new font weights Adding font.weight.medium/semibold to sure.tokens.json also flows through the web token generator (bin/tokens.mjs), which emits --font-weight-medium and --font-weight-semibold. Commit the regenerated _generated.css so the tokens:check gate stays green (node bin/tokens.mjs + git diff --quiet). * test(system): de-flake account edit via account-menu AccountsTest#assert_account_created opened the account menu and clicked Edit immediately after visiting the account page. That page issues a Turbo morph refresh shortly after load (turbo_refreshes_with :morph reacting to a family-stream broadcast), which can detach the menu node mid-click — "Selenium::WebDriver::Error: Node with given id does not belong to the document" — or wipe the just-opened edit form. Capybara does not auto-retry that inspector error, so the test flaked intermittently in CI. Extract open_account_edit_dialog, which retries the menu interaction until the edit form is present, rescuing the transient detach/stale errors. Mirrors the existing PropertyTest#open_account_edit_dialog precedent for the same morph race, but also tolerates the click itself raising while the refresh is in flight.
755 lines
23 KiB
Dart
755 lines
23 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_icon.dart';
|
|
import '../theme/sure_tokens.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 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: [
|
|
const SureIcon(SureIcons.circleAlert, color: Colors.white),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: Text(l.dashboardSyncFailed)),
|
|
],
|
|
),
|
|
backgroundColor: Colors.red,
|
|
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: [
|
|
const SureIcon(SureIcons.circleAlert, color: Colors.white),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: Text(l.dashboardSyncError)),
|
|
],
|
|
),
|
|
backgroundColor: Colors.red,
|
|
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 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: [
|
|
const SureIcon(SureIcons.circleCheck, color: Colors.white),
|
|
const SizedBox(width: 12),
|
|
Text(l.dashboardAccountsUpdated),
|
|
],
|
|
),
|
|
backgroundColor: Colors.green,
|
|
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 colorScheme = Theme.of(context).colorScheme;
|
|
|
|
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: 16, vertical: 6),
|
|
color: Colors.green.withValues(alpha: 0.1),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const SureIcon(
|
|
SureIcons.cloudCheck,
|
|
color: Colors.green,
|
|
size: 18,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l.dashboardSynced,
|
|
style: const TextStyle(color: Colors.green, fontSize: 13),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
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: colorScheme.error,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
l.dashboardErrorLoadingAccounts,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
accountsProvider.errorMessage!,
|
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 24),
|
|
ElevatedButton.icon(
|
|
onPressed: _handleRefresh,
|
|
icon: const SureIcon(SureIcons.refresh),
|
|
label: Text(l.commonTryAgain),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 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: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
l.dashboardNoAccounts,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l.dashboardNoAccountsSubtitle,
|
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 24),
|
|
ElevatedButton.icon(
|
|
onPressed: _handleRefresh,
|
|
icon: const SureIcon(SureIcons.refresh),
|
|
label: Text(l.commonRefresh),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 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);
|
|
|
|
if (filteredAccounts.isEmpty) {
|
|
return [
|
|
SliverToBoxAdapter(
|
|
child: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
children: [
|
|
SureIcon(
|
|
SureIcons.wallet,
|
|
size: 48,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
l.dashboardFilterEmpty,
|
|
style: TextStyle(
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
// 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 colorScheme = Theme.of(context).colorScheme;
|
|
|
|
return InkWell(
|
|
onTap: onToggle,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
SureIcon(
|
|
_getTypeIconName(),
|
|
size: 18,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
fontWeight: SureTokens.weightMedium,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 1),
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primaryContainer.withValues(alpha: 0.5),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text(
|
|
count.toString(),
|
|
style: TextStyle(
|
|
color: colorScheme.onPrimaryContainer,
|
|
fontWeight: SureTokens.weightMedium,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
SureIcon(
|
|
isCollapsed ? SureIcons.chevronDown : SureIcons.chevronUp,
|
|
size: SureIconSize.md,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|