mirror of
https://github.com/we-promise/sure.git
synced 2026-04-17 19:14:11 +00:00
* Move debug logs button from Home to Settings page, remove refresh/logout from Home AppBar - Remove Debug Logs, Refresh, and Sign Out buttons from DashboardScreen AppBar - Add Debug Logs ListTile entry in SettingsScreen under app info section - Remove unused _handleLogout method from DashboardScreen - Remove unused log_viewer_screen.dart import from DashboardScreen https://claude.ai/code/session_017XQZdaEwUuRS75tJMcHzB9 * Fix home page double AppBar inconsistency with settings/more pages The DashboardScreen had its own AppBar with a sync success icon, while MainNavigationScreen already provides a shared AppBar (logo + settings) for all tabs. This caused the home page to render a double top bar, inconsistent with the settings and more screens which have no extra AppBar. Remove the dashboard's AppBar and move the sync indicator into the body as an inline banner. https://claude.ai/code/session_0155XXsvt5zKLBpasmdkhPiF * Fix sync success cloud icon not appearing after sync The cloud icon only showed when pending transaction count decreased (local→server uploads). For normal pull-to-refresh syncs that download from server, pendingCount stays at 0 so the icon never triggered. Fix by also detecting when TransactionsProvider.isLoading transitions from true to false (any sync completion), and by triggering the icon directly after successful manual sync instead of showing a redundant snackbar. https://claude.ai/code/session_0155XXsvt5zKLBpasmdkhPiF * Address PR review: fix Timer leak, sync error check, false triggers 1. Timer leak (CodeRabbit): Replace Future.delayed with a cancellable Timer field (_syncSuccessTimer). Cancel existing timer before starting a new one, and clean up in dispose(). 2. Sync error not checked (CodeRabbit): _performManualSync now checks transactionsProvider.error after syncTransactions() returns. Shows error SnackBar on failure instead of false success indicator. 3. False positive triggers (Codex): Remove isLoading transition detection from _onTransactionsChanged since isLoading also toggles for fetchTransactions (non-sync paths). Keep only pendingDecreased for background sync detection; manual sync uses direct call path. https://claude.ai/code/session_0155XXsvt5zKLBpasmdkhPiF * Update mobile/lib/screens/dashboard_screen.dart Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Signed-off-by: Lazy Bone <89256478+dwvwdv@users.noreply.github.com> --------- Signed-off-by: Lazy Bone <89256478+dwvwdv@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
744 lines
23 KiB
Dart
744 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 'transaction_form_screen.dart';
|
|
import 'transactions_list_screen.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 accessToken = await authProvider.getValidAccessToken();
|
|
if (accessToken == null) {
|
|
await authProvider.logout();
|
|
return;
|
|
}
|
|
|
|
// Show syncing indicator
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
),
|
|
),
|
|
SizedBox(width: 12),
|
|
Text('Syncing data from server...'),
|
|
],
|
|
),
|
|
duration: 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 Icon(Icons.error, color: Colors.white),
|
|
const SizedBox(width: 12),
|
|
const Expanded(child: Text('Sync failed. Please try again.')),
|
|
],
|
|
),
|
|
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(
|
|
const SnackBar(
|
|
content: Row(
|
|
children: [
|
|
Icon(Icons.error, color: Colors.white),
|
|
SizedBox(width: 12),
|
|
Expanded(child: Text('Sync failed. Please try again.')),
|
|
],
|
|
),
|
|
backgroundColor: Colors.red,
|
|
duration: 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 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(
|
|
const SnackBar(
|
|
content: Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
|
),
|
|
),
|
|
SizedBox(width: 12),
|
|
Text('Refreshing accounts...'),
|
|
],
|
|
),
|
|
duration: 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(
|
|
const SnackBar(
|
|
content: Row(
|
|
children: [
|
|
Icon(Icons.check_circle, color: Colors.white),
|
|
SizedBox(width: 12),
|
|
Text('Accounts updated'),
|
|
],
|
|
),
|
|
backgroundColor: Colors.green,
|
|
duration: 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 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: const Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.cloud_done, color: Colors.green, size: 18),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'Synced',
|
|
style: 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: [
|
|
Icon(
|
|
Icons.error_outline,
|
|
size: 64,
|
|
color: colorScheme.error,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Failed to load accounts',
|
|
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 Icon(Icons.refresh),
|
|
label: const Text('Try Again'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Show empty state
|
|
if (accountsProvider.accounts.isEmpty) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.account_balance_wallet_outlined,
|
|
size: 64,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No accounts yet',
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'Add accounts in the web app to see them here.',
|
|
style: TextStyle(color: colorScheme.onSurfaceVariant),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 24),
|
|
ElevatedButton.icon(
|
|
onPressed: _handleRefresh,
|
|
icon: const Icon(Icons.refresh),
|
|
label: const Text('Refresh'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 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);
|
|
|
|
if (filteredAccounts.isEmpty) {
|
|
return [
|
|
SliverToBoxAdapter(
|
|
child: Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
children: [
|
|
Icon(
|
|
Icons.account_balance_wallet_outlined,
|
|
size: 48,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'No accounts match the current filter',
|
|
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,
|
|
});
|
|
|
|
IconData _getTypeIcon() {
|
|
switch (accountType) {
|
|
case 'depository':
|
|
return Icons.account_balance;
|
|
case 'credit_card':
|
|
return Icons.credit_card;
|
|
case 'investment':
|
|
return Icons.trending_up;
|
|
case 'loan':
|
|
return Icons.receipt_long;
|
|
case 'property':
|
|
return Icons.home;
|
|
case 'vehicle':
|
|
return Icons.directions_car;
|
|
case 'crypto':
|
|
return Icons.currency_bitcoin;
|
|
case 'other_asset':
|
|
return Icons.category;
|
|
case 'other_liability':
|
|
return Icons.payment;
|
|
default:
|
|
return Icons.account_balance_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: [
|
|
Icon(
|
|
_getTypeIcon(),
|
|
size: 18,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
title,
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
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: FontWeight.bold,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Icon(
|
|
isCollapsed ? Icons.expand_more : Icons.expand_less,
|
|
size: 20,
|
|
color: colorScheme.onSurfaceVariant,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|