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
@@ -0,0 +1,603 @@
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,
),
const SizedBox(height: 8),
_MetricCard(
title: 'Balance prévisionnelle du mois',
value: money.format(dashboard.projectedMonthBalance),
icon: Icons.insights,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MetricCard(
title: 'Solde total$labelSuffix',
value: money.format(dashboard.totalBalance),
icon: Icons.savings,
compact: true,
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricCard(
title: 'Balance réelle',
value: money.format(dashboard.monthlyBalance),
icon: Icons.trending_up,
compact: true,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MetricCard(
title: 'Revenus réels',
value: money.format(dashboard.monthlyIncome),
icon: Icons.arrow_downward,
compact: true,
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricCard(
title: 'Dépenses réelles',
value: money.format(dashboard.monthlyExpenses),
icon: Icons.arrow_upward,
compact: true,
),
),
],
),
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),
_InfoRow('Revenus prévus échéancier', dashboard.scheduledIncomeRemainingTotal),
_InfoRow('Dépenses échéancier', dashboard.scheduledExpensesRemainingTotal),
_InfoRow('Transferts prévus', dashboard.plannedTransfersTotal),
_InfoRow('Provisions prévues', dashboard.plannedProvisionsTotal),
];
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.event_note),
const SizedBox(width: 10),
Expanded(
child: Text(
'Prévisionnel du mois',
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
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) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(child: Text(row.label)),
Text(
money.format(row.value),
style: const TextStyle(fontWeight: FontWeight.w700),
),
],
),
),
),
],
),
),
);
}
}
class _InfoRow {
const _InfoRow(this.label, this.value);
final String label;
final double value;
}
class _MetricCard extends StatelessWidget {
const _MetricCard({
required this.title,
required this.value,
required this.icon,
this.compact = false,
});
final String title;
final String value;
final IconData icon;
final bool compact;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: EdgeInsets.all(compact ? 12 : 16),
child: Row(
children: [
Icon(icon, size: compact ? 24 : 32),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.bodySmall),
Text(
value,
style: compact
? Theme.of(context).textTheme.titleMedium
: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
],
),
),
);
}
}
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),
),
),
);
}
}