Files
fin65-mobile/backup_before_mobile_dashboard_scheduled_v27/home_screen.dart.bak
T

604 lines
17 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
),
),
);
}
}