Initial upload: fin65-mobile

This commit is contained in:
Abderrahim-Mouhcine
2026-08-16 19:59:55 +01:00
commit 46558a5ec8
242 changed files with 16334 additions and 0 deletions
+715
View File
@@ -0,0 +1,715 @@
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),
),
),
);
}
}