Files
sure/mobile/lib/screens/dashboard_screen.dart
Lazy Bone 81cf473862 fix: Use getValidAccessToken() in connectivity banner sync button (#851)
* feat(mobile): Add transaction display on calendar date tap

Implement two-tap interaction for calendar dates:
- First tap selects a date (highlighted with thicker primary color border)
- Second tap on same date shows AlertDialog with transactions for that day

Each transaction displays with:
- Color-coded icon (red minus for expenses, green plus for income)
- Transaction name as title
- Notes as subtitle (if present)
- Amount with color matching expense/income

Selection is cleared when changing account, account type, or month.

https://claude.ai/code/session_019m7ZrCakU6h9xLwD1NTx9i

* feat(mobile): optimize asset/liability display with filters

- Add NetWorthCard widget with placeholder for future net worth API
- Add side-by-side Assets/Liabilities display with tap-to-filter
- Implement CurrencyFilter widget for multi-select currency filtering
- Replace old _SummaryCard with new unified design
- Remove _CollapsibleSectionHeader in favor of filter-based navigation

The net worth section shows a placeholder as the API endpoint is not yet available.
Users can now filter accounts by type (assets/liabilities) and by currency.

https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3

* fix(mobile): remove unused variables and add const

- Remove unused _totalAssets, _totalLiabilities, _getPrimaryCurrency
- Add const to Text('All') widget

https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3

* feat(mobile): enhance dashboard with icons, long-press breakdown, and grouped view

- NetWorthCard: replace text labels with trending icons, add colored
  bottom borders for asset (green) and liability (red) sections
- Add long-press gesture on asset/liability areas to show full currency
  breakdown in a bottom sheet popup
- Add collapsible account type grouping (Crypto, Bank, Investment, etc.)
  with type-specific icons and expand/collapse headers
- Add PreferencesService for persisting display settings
- Add "Group by Account Type" toggle in Settings screen
- Wire settings change to dashboard via GlobalKey for live updates

https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3

* refactor(mobile): remove welcome header from dashboard

Strip the Welcome greeting and subtitle to let the financial
overview take immediate focus.

https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3

* feat(mobile): compact filter buttons with scroll-wheel currency switcher

- Remove trending icons from asset/liability filter buttons
- Increase amount font size to titleMedium bold
- Reduce Net Worth section and filter button padding
- Show single currency at a time with ListWheelScrollView for
  scrolling between currencies (wheel-picker style)
- Absorb scroll events via NotificationListener to prevent
  triggering pull-to-refresh
- Keep icons in the long-press currency breakdown popup

https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3

* feat: Add API key login option to mobile app

Add a "Via API Key Login" button on the login screen that opens a
dialog for entering an API key. The API key is validated by making a
test request to /api/v1/accounts with the X-Api-Key header, and on
success is persisted in secure storage. All HTTP services now use a
centralized ApiConfig.getAuthHeaders() helper that returns the correct
auth header (X-Api-Key or Bearer) based on the current auth mode.

https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH

* fix: Improve API key dialog context handling and controller disposal

- Use outer context for SnackBar so it displays on the main screen
  instead of behind the dialog
- Explicitly dispose TextEditingController to prevent memory leaks
- Close dialog on failure before showing error SnackBar for better UX
- Avoid StatefulBuilder context parameter shadowing

https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH

* fix: Use user-friendly error message in API key login catch block

Log the technical exception details via LogService.instance.error and
show a generic "Unable to connect" message to the user instead of
exposing the raw exception string.

https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH

* fix: Use getValidAccessToken() in connectivity banner sync button

Replace direct authProvider.tokens?.accessToken access with
getValidAccessToken() so the Sync Now button works in API-key
auth mode where _tokens is null.

https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH

* Revert "fix: Use getValidAccessToken() in connectivity banner sync button"

This reverts commit 7015c160f0.

* Reapply "fix: Use getValidAccessToken() in connectivity banner sync button"

This reverts commit b29e010de3.

* fix: Use getValidAccessToken() in connectivity banner sync button

Replace direct authProvider.tokens?.accessToken access with
getValidAccessToken() so the Sync Now button works in API-key
auth mode where _tokens is null.

https://claude.ai/code/session_01DnyCzdMjVpSsbBZK3XbzUH

* fix(mobile): prevent bottom sheet overflow with ConstrainedBox

Use ConstrainedBox + ListView.separated with shrinkWrap for the
currency breakdown popup. Few currencies: sheet sizes to content.
Many currencies: caps at 50% screen height and scrolls.

Also add isScrollControlled and useSafeArea to showModalBottomSheet.

https://claude.ai/code/session_01W8cQSCzmgTmTqwRJ8Ycpx3

* fix: Prevent multiple syncs and handle auth errors in connectivity banner

Set _isSyncing immediately on tap to disable the button during token
refresh, wrap getValidAccessToken() in try/catch with user-facing error
snackbar, and await _handleSync so errors propagate correctly.

https://claude.ai/code/session_01GgVgjqwyXhWMZN3eWfaMCk

---------

Signed-off-by: Lazy Bone <89256478+dwvwdv@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-01 23:16:02 +01:00

781 lines
24 KiB
Dart

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';
import 'log_viewer_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;
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() {
_transactionsProvider?.removeListener(_onTransactionsChanged);
super.dispose();
}
void _onTransactionsChanged() {
final transactionsProvider = _transactionsProvider;
if (transactionsProvider == null || !mounted) {
return;
}
final currentPendingCount = transactionsProvider.pendingCount;
// If pending count decreased, it means transactions were synced
if (_previousPendingCount > 0 && currentPendingCount < _previousPendingCount) {
setState(() {
_showSyncSuccess = true;
});
// Hide the success indicator after 3 seconds
Future.delayed(const Duration(seconds: 3), () {
if (mounted) {
setState(() {
_showSyncSuccess = false;
});
}
});
}
_previousPendingCount = currentPendingCount;
}
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();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Row(
children: [
Icon(Icons.check_circle, color: Colors.white),
SizedBox(width: 12),
Text('Sync completed successfully'),
],
),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
}
} 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();
}
}
Future<void> _handleLogout() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Sign Out'),
content: const Text('Are you sure you want to sign out?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Sign Out'),
),
],
),
);
if (confirmed == true && mounted) {
final authProvider = Provider.of<AuthProvider>(context, listen: false);
final accountsProvider = Provider.of<AccountsProvider>(context, listen: false);
accountsProvider.clearAccounts();
await authProvider.logout();
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Dashboard'),
actions: [
if (_showSyncSuccess)
Padding(
padding: const EdgeInsets.only(right: 8),
child: AnimatedOpacity(
opacity: _showSyncSuccess ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: const Icon(
Icons.cloud_done,
color: Colors.green,
size: 28,
),
),
),
Semantics(
label: 'Open debug logs',
button: true,
child: IconButton(
icon: const Icon(Icons.bug_report),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const LogViewerScreen()),
);
},
tooltip: 'Debug Logs',
),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _handleRefresh,
tooltip: 'Refresh',
),
IconButton(
icon: const Icon(Icons.logout),
onPressed: _handleLogout,
tooltip: 'Sign Out',
),
],
),
body: Column(
children: [
const ConnectivityBanner(),
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,
),
),
// 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,
),
],
),
),
);
}
}