716 lines
21 KiB
Dart
716 lines
21 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:intl/intl.dart';
|
||
|
||
import '../models/account.dart';
|
||
import '../models/category.dart';
|
||
import '../models/dashboard.dart';
|
||
import '../models/transaction_item.dart';
|
||
import '../services/sync_service.dart';
|
||
import 'quick_transaction_screen.dart';
|
||
import 'settings_screen.dart';
|
||
import 'sync_screen.dart';
|
||
|
||
class HomeScreen extends StatefulWidget {
|
||
const HomeScreen({super.key, required this.syncService});
|
||
|
||
final SyncService syncService;
|
||
|
||
@override
|
||
State<HomeScreen> createState() => _HomeScreenState();
|
||
}
|
||
|
||
class _HomeScreenState extends State<HomeScreen> {
|
||
DashboardData? _dashboard;
|
||
List<Account> _accounts = [];
|
||
List<Category> _categories = [];
|
||
List<TransactionItem> _transactions = [];
|
||
int _pendingCount = 0;
|
||
DateTime? _lastSyncAt;
|
||
bool _isOnline = false;
|
||
bool _loading = true;
|
||
bool _syncing = false;
|
||
String? _message;
|
||
|
||
final _money = NumberFormat.currency(locale: 'fr_FR', symbol: 'MAD ', decimalDigits: 2);
|
||
final _date = DateFormat('dd/MM/yyyy HH:mm', 'fr_FR');
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_bootstrap();
|
||
}
|
||
|
||
Future<void> _bootstrap() async {
|
||
try {
|
||
final local = await widget.syncService.loadLocalSnapshot(
|
||
isOnline: false,
|
||
message: 'Données locales chargées.',
|
||
);
|
||
_applySnapshot(local, loading: false);
|
||
await _synchronize();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_loading = false;
|
||
_message = 'Erreur locale : $e';
|
||
});
|
||
}
|
||
}
|
||
|
||
void _applySnapshot(SyncSnapshot snapshot, {bool? loading}) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_dashboard = snapshot.dashboard;
|
||
_accounts = snapshot.accounts;
|
||
_categories = snapshot.categories;
|
||
_transactions = snapshot.transactions;
|
||
_pendingCount = snapshot.pendingCount;
|
||
_lastSyncAt = snapshot.lastSyncAt;
|
||
_isOnline = snapshot.isOnline;
|
||
_message = snapshot.message;
|
||
if (loading != null) _loading = loading;
|
||
});
|
||
}
|
||
|
||
Future<void> _synchronize() async {
|
||
if (_syncing) return;
|
||
|
||
setState(() {
|
||
_syncing = true;
|
||
_message = 'Synchronisation...';
|
||
});
|
||
|
||
try {
|
||
final snapshot = await widget.syncService.synchronize();
|
||
if (!mounted) return;
|
||
_applySnapshot(snapshot, loading: false);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_message = 'Erreur de synchronisation : $e';
|
||
_loading = false;
|
||
});
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() => _syncing = false);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _reloadLocalAfterSave() async {
|
||
try {
|
||
final snapshot = await widget.syncService.loadLocalSnapshot(
|
||
isOnline: _isOnline,
|
||
message: _isOnline
|
||
? 'Opération enregistrée.'
|
||
: 'Opération en attente de synchronisation.',
|
||
);
|
||
_applySnapshot(snapshot, loading: false);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_message = 'Opération enregistrée localement, mais rechargement écran échoué : $e';
|
||
_loading = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _openQuickAdd() async {
|
||
if (_accounts.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('Crée d’abord au moins un compte dans la version web, puis synchronise.')),
|
||
);
|
||
return;
|
||
}
|
||
|
||
await Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (_) => QuickTransactionScreen(
|
||
syncService: widget.syncService,
|
||
accounts: _accounts,
|
||
categories: _categories,
|
||
onSaved: _reloadLocalAfterSave,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
|
||
Future<void> _openSyncScreen() async {
|
||
await Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (_) => SyncScreen(syncService: widget.syncService),
|
||
),
|
||
);
|
||
|
||
final snapshot = await widget.syncService.loadLocalSnapshot(
|
||
isOnline: _isOnline,
|
||
message: null,
|
||
);
|
||
_applySnapshot(snapshot, loading: false);
|
||
}
|
||
|
||
Future<void> _openSettings() async {
|
||
await Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (_) => SettingsScreen(apiService: widget.syncService.apiService),
|
||
),
|
||
);
|
||
await _synchronize();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final dashboard = _dashboard;
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('FIN65'),
|
||
actions: [
|
||
IconButton(
|
||
tooltip: 'Page synchronisation',
|
||
onPressed: _openSyncScreen,
|
||
icon: Badge(
|
||
isLabelVisible: _pendingCount > 0,
|
||
label: Text('$_pendingCount'),
|
||
child: const Icon(Icons.cloud_sync),
|
||
),
|
||
),
|
||
IconButton(
|
||
tooltip: 'Synchroniser',
|
||
onPressed: _syncing ? null : _synchronize,
|
||
icon: _syncing
|
||
? const SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Icon(Icons.sync),
|
||
),
|
||
IconButton(onPressed: _openSettings, icon: const Icon(Icons.settings)),
|
||
],
|
||
),
|
||
floatingActionButton: FloatingActionButton.extended(
|
||
onPressed: _openQuickAdd,
|
||
icon: const Icon(Icons.add),
|
||
label: const Text('Ajouter'),
|
||
),
|
||
body: RefreshIndicator(
|
||
onRefresh: _synchronize,
|
||
child: _loading
|
||
? const Center(child: CircularProgressIndicator())
|
||
: ListView(
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
_SyncBanner(
|
||
isOnline: _isOnline,
|
||
pendingCount: _pendingCount,
|
||
lastSyncAt: _lastSyncAt,
|
||
formatter: _date,
|
||
message: _message,
|
||
),
|
||
const SizedBox(height: 12),
|
||
if (dashboard != null) ...[
|
||
_DashboardCards(
|
||
dashboard: dashboard,
|
||
money: _money,
|
||
hasPending: _pendingCount > 0,
|
||
),
|
||
const SizedBox(height: 16),
|
||
] else
|
||
const _EmptyCacheCard(),
|
||
_SectionTitle(title: 'Comptes', action: _pendingCount > 0 ? 'Solde estimé' : 'Solde app'),
|
||
const SizedBox(height: 8),
|
||
if (_accounts.isEmpty)
|
||
const Card(child: ListTile(title: Text('Aucun compte en cache. Synchronise avec le backend.')))
|
||
else
|
||
..._accounts.map((a) => _AccountTile(account: a, money: _money)),
|
||
const SizedBox(height: 16),
|
||
_SectionTitle(title: 'Dernières transactions'),
|
||
const SizedBox(height: 8),
|
||
if (_transactions.isEmpty)
|
||
const Card(child: ListTile(title: Text('Aucune transaction en cache.')))
|
||
else
|
||
..._transactions.map(
|
||
(t) => _TransactionTile(
|
||
transaction: t,
|
||
accounts: _accounts,
|
||
categories: _categories,
|
||
money: _money,
|
||
),
|
||
),
|
||
const SizedBox(height: 90),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SyncBanner extends StatelessWidget {
|
||
const _SyncBanner({
|
||
required this.isOnline,
|
||
required this.pendingCount,
|
||
required this.lastSyncAt,
|
||
required this.formatter,
|
||
this.message,
|
||
});
|
||
|
||
final bool isOnline;
|
||
final int pendingCount;
|
||
final DateTime? lastSyncAt;
|
||
final DateFormat formatter;
|
||
final String? message;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final title = isOnline ? 'Connecté au backend' : 'Mode hors ligne';
|
||
final subtitleParts = <String>[];
|
||
|
||
if (lastSyncAt != null) {
|
||
subtitleParts.add('Dernière synchro : ${formatter.format(lastSyncAt!.toLocal())}');
|
||
} else {
|
||
subtitleParts.add('Aucune synchronisation réussie');
|
||
}
|
||
|
||
if (pendingCount > 0) {
|
||
subtitleParts.add('$pendingCount opération(s) en attente');
|
||
}
|
||
|
||
if (message != null && message!.isNotEmpty) {
|
||
subtitleParts.add(message!);
|
||
}
|
||
|
||
return Card(
|
||
child: ListTile(
|
||
leading: Icon(isOnline ? Icons.cloud_done : Icons.cloud_off),
|
||
title: Text(title),
|
||
subtitle: Text(subtitleParts.join('\n')),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _EmptyCacheCard extends StatelessWidget {
|
||
const _EmptyCacheCard();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return const Card(
|
||
child: Padding(
|
||
padding: EdgeInsets.all(16),
|
||
child: Text(
|
||
'Aucune donnée locale. Lance le backend PC une première fois, configure l’URL API, puis synchronise.',
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DashboardCards extends StatelessWidget {
|
||
const _DashboardCards({
|
||
required this.dashboard,
|
||
required this.money,
|
||
required this.hasPending,
|
||
});
|
||
|
||
final DashboardData dashboard;
|
||
final NumberFormat money;
|
||
final bool hasPending;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final labelSuffix = hasPending ? ' estimé' : '';
|
||
|
||
return Column(
|
||
children: [
|
||
_MetricCard(
|
||
title: 'Disponible réel$labelSuffix',
|
||
value: money.format(dashboard.availableAfterReserves),
|
||
icon: Icons.account_balance_wallet,
|
||
backgroundColor: const Color(0xFFE8F5E9),
|
||
accentColor: const Color(0xFF2E7D32),
|
||
),
|
||
const SizedBox(height: 8),
|
||
_MetricCard(
|
||
title: 'Balance prévisionnelle du mois',
|
||
value: money.format(dashboard.projectedMonthBalance),
|
||
icon: Icons.insights,
|
||
backgroundColor: const Color(0xFFE3F2FD),
|
||
accentColor: const Color(0xFF1565C0),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _MetricCard(
|
||
title: 'Solde total$labelSuffix',
|
||
value: money.format(dashboard.totalBalance),
|
||
icon: Icons.savings,
|
||
compact: true,
|
||
backgroundColor: const Color(0xFFF3E5F5),
|
||
accentColor: const Color(0xFF6A1B9A),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _MetricCard(
|
||
title: 'Balance réelle',
|
||
value: money.format(dashboard.monthlyBalance),
|
||
icon: Icons.trending_up,
|
||
compact: true,
|
||
backgroundColor: dashboard.monthlyBalance >= 0
|
||
? const Color(0xFFE8F5E9)
|
||
: const Color(0xFFFFEBEE),
|
||
accentColor: dashboard.monthlyBalance >= 0
|
||
? const Color(0xFF2E7D32)
|
||
: const Color(0xFFC62828),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _MetricCard(
|
||
title: 'Revenus réels',
|
||
value: money.format(dashboard.monthlyIncome),
|
||
icon: Icons.arrow_downward,
|
||
compact: true,
|
||
backgroundColor: const Color(0xFFE8F5E9),
|
||
accentColor: const Color(0xFF2E7D32),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _MetricCard(
|
||
title: 'Dépenses réelles',
|
||
value: money.format(dashboard.monthlyExpenses),
|
||
icon: Icons.arrow_upward,
|
||
compact: true,
|
||
backgroundColor: const Color(0xFFFFEBEE),
|
||
accentColor: const Color(0xFFC62828),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
_PlannedInfoCard(dashboard: dashboard, money: money),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PlannedInfoCard extends StatelessWidget {
|
||
const _PlannedInfoCard({
|
||
required this.dashboard,
|
||
required this.money,
|
||
});
|
||
|
||
final DashboardData dashboard;
|
||
final NumberFormat money;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final rows = <_InfoRow>[
|
||
_InfoRow(
|
||
'Dépenses prévues restantes',
|
||
dashboard.plannedExpensesTotal,
|
||
Icons.event_busy,
|
||
const Color(0xFFFFEBEE),
|
||
const Color(0xFFC62828),
|
||
),
|
||
_InfoRow(
|
||
'Revenus prévus échéancier',
|
||
dashboard.scheduledIncomeRemainingTotal,
|
||
Icons.event_available,
|
||
const Color(0xFFE8F5E9),
|
||
const Color(0xFF2E7D32),
|
||
),
|
||
_InfoRow(
|
||
'Dépenses échéancier',
|
||
dashboard.scheduledExpensesRemainingTotal,
|
||
Icons.calendar_month,
|
||
const Color(0xFFFFF3E0),
|
||
const Color(0xFFEF6C00),
|
||
),
|
||
_InfoRow(
|
||
'Transferts prévus',
|
||
dashboard.plannedTransfersTotal,
|
||
Icons.swap_horiz,
|
||
const Color(0xFFE3F2FD),
|
||
const Color(0xFF1565C0),
|
||
),
|
||
_InfoRow(
|
||
'Provisions prévues',
|
||
dashboard.plannedProvisionsTotal,
|
||
Icons.inventory_2,
|
||
const Color(0xFFF3E5F5),
|
||
const Color(0xFF6A1B9A),
|
||
),
|
||
];
|
||
|
||
return Card(
|
||
color: const Color(0xFFFAFAFA),
|
||
elevation: 1,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
width: 38,
|
||
height: 38,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFE3F2FD),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: const Icon(Icons.event_note, color: Color(0xFF1565C0)),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
'Prévisionnel du mois',
|
||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'Inclut les opérations mensuelles et l’échéancier ponctuel du backend.',
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
const Divider(height: 20),
|
||
...rows.map(
|
||
(row) => Container(
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
|
||
decoration: BoxDecoration(
|
||
color: row.backgroundColor,
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(color: row.accentColor.withOpacity(0.22)),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(row.icon, size: 20, color: row.accentColor),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Text(
|
||
row.label,
|
||
style: TextStyle(
|
||
color: row.accentColor,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
Text(
|
||
money.format(row.value),
|
||
style: TextStyle(
|
||
color: row.accentColor,
|
||
fontWeight: FontWeight.w900,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _InfoRow {
|
||
const _InfoRow(
|
||
this.label,
|
||
this.value,
|
||
this.icon,
|
||
this.backgroundColor,
|
||
this.accentColor,
|
||
);
|
||
|
||
final String label;
|
||
final double value;
|
||
final IconData icon;
|
||
final Color backgroundColor;
|
||
final Color accentColor;
|
||
}
|
||
|
||
class _MetricCard extends StatelessWidget {
|
||
const _MetricCard({
|
||
required this.title,
|
||
required this.value,
|
||
required this.icon,
|
||
required this.backgroundColor,
|
||
required this.accentColor,
|
||
this.compact = false,
|
||
});
|
||
|
||
final String title;
|
||
final String value;
|
||
final IconData icon;
|
||
final Color backgroundColor;
|
||
final Color accentColor;
|
||
final bool compact;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Card(
|
||
color: backgroundColor,
|
||
elevation: 1,
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||
child: Padding(
|
||
padding: EdgeInsets.all(compact ? 12 : 16),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: compact ? 34 : 42,
|
||
height: compact ? 34 : 42,
|
||
decoration: BoxDecoration(
|
||
color: accentColor.withOpacity(0.12),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Icon(icon, size: compact ? 21 : 27, color: accentColor),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||
color: accentColor,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
Text(
|
||
value,
|
||
style: (compact
|
||
? Theme.of(context).textTheme.titleMedium
|
||
: Theme.of(context).textTheme.headlineSmall)
|
||
?.copyWith(
|
||
color: accentColor,
|
||
fontWeight: FontWeight.w900,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SectionTitle extends StatelessWidget {
|
||
const _SectionTitle({required this.title, this.action});
|
||
|
||
final String title;
|
||
final String? action;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Row(
|
||
children: [
|
||
Text(title, style: Theme.of(context).textTheme.titleLarge),
|
||
const Spacer(),
|
||
if (action != null) Text(action!, style: Theme.of(context).textTheme.bodySmall),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _AccountTile extends StatelessWidget {
|
||
const _AccountTile({required this.account, required this.money});
|
||
|
||
final Account account;
|
||
final NumberFormat money;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Card(
|
||
child: ListTile(
|
||
leading: const Icon(Icons.account_balance),
|
||
title: Text(account.name),
|
||
subtitle: Text(account.type),
|
||
trailing: Text(
|
||
money.format(account.appBalance),
|
||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _TransactionTile extends StatelessWidget {
|
||
const _TransactionTile({
|
||
required this.transaction,
|
||
required this.accounts,
|
||
required this.categories,
|
||
required this.money,
|
||
});
|
||
|
||
final TransactionItem transaction;
|
||
final List<Account> accounts;
|
||
final List<Category> categories;
|
||
final NumberFormat money;
|
||
|
||
Account? _findAccount(int? id) {
|
||
if (id == null) return null;
|
||
for (final account in accounts) {
|
||
if (account.id == id) return account;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Category? _findCategory(int? id) {
|
||
if (id == null) return null;
|
||
for (final category in categories) {
|
||
if (category.id == id) return category;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final account = _findAccount(transaction.accountId);
|
||
final target = _findAccount(transaction.targetAccountId);
|
||
final category = _findCategory(transaction.categoryId);
|
||
|
||
final typeLabel = switch (transaction.type) {
|
||
'expense' => 'Dépense',
|
||
'income' => 'Recette',
|
||
'transfer' => 'Transfert',
|
||
_ => transaction.type,
|
||
};
|
||
|
||
final accountLabel = transaction.type == 'transfer'
|
||
? '${account?.name ?? transaction.accountId} → ${target?.name ?? transaction.targetAccountId}'
|
||
: account?.name ?? 'Compte ${transaction.accountId}';
|
||
|
||
return Card(
|
||
child: ListTile(
|
||
leading: Icon(transaction.isPending ? Icons.schedule_send : Icons.receipt_long),
|
||
title: Text(transaction.label),
|
||
subtitle: Text(
|
||
'${transaction.isPending ? 'EN ATTENTE • ' : ''}$typeLabel • $accountLabel${category == null ? '' : ' • ${category.name}'}',
|
||
),
|
||
trailing: Text(
|
||
money.format(transaction.amount),
|
||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|