Initial upload: fin65-mobile
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import 'account.dart';
|
||||
|
||||
class ReconciliationStatus {
|
||||
final int accountId;
|
||||
final String accountName;
|
||||
final double appBalance;
|
||||
final bool checkedToday;
|
||||
final double? realBalance;
|
||||
final double? difference;
|
||||
|
||||
ReconciliationStatus({
|
||||
required this.accountId,
|
||||
required this.accountName,
|
||||
required this.appBalance,
|
||||
required this.checkedToday,
|
||||
this.realBalance,
|
||||
this.difference,
|
||||
});
|
||||
|
||||
factory ReconciliationStatus.fromJson(Map<String, dynamic> json) {
|
||||
return ReconciliationStatus(
|
||||
accountId: json['account_id'] as int,
|
||||
accountName: json['account_name'] as String,
|
||||
appBalance: (json['app_balance'] as num? ?? 0).toDouble(),
|
||||
checkedToday: json['checked_today'] is int
|
||||
? (json['checked_today'] as int) == 1
|
||||
: json['checked_today'] as bool? ?? false,
|
||||
realBalance: (json['real_balance'] as num?)?.toDouble(),
|
||||
difference: (json['difference'] as num?)?.toDouble(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'account_id': accountId,
|
||||
'account_name': accountName,
|
||||
'app_balance': appBalance,
|
||||
'checked_today': checkedToday,
|
||||
'real_balance': realBalance,
|
||||
'difference': difference,
|
||||
};
|
||||
}
|
||||
|
||||
ReconciliationStatus copyWith({
|
||||
double? appBalance,
|
||||
bool? checkedToday,
|
||||
double? realBalance,
|
||||
double? difference,
|
||||
}) {
|
||||
return ReconciliationStatus(
|
||||
accountId: accountId,
|
||||
accountName: accountName,
|
||||
appBalance: appBalance ?? this.appBalance,
|
||||
checkedToday: checkedToday ?? this.checkedToday,
|
||||
realBalance: realBalance ?? this.realBalance,
|
||||
difference: difference ?? this.difference,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardData {
|
||||
final String month;
|
||||
final double totalBalance;
|
||||
final double availableAfterReserves;
|
||||
final double monthlyIncome;
|
||||
final double monthlyExpenses;
|
||||
final double monthlyBalance;
|
||||
final double provisionsCurrentTotal;
|
||||
final double provisionsTargetTotal;
|
||||
final double monthlyChargesRemainingTotal;
|
||||
final double monthlyTransferRemainingTotal;
|
||||
final double monthlyProvisionOperationRemainingTotal;
|
||||
|
||||
// Nouveaux champs backend : échéancier / opérations prévues ponctuelles.
|
||||
final double scheduledExpensesRemainingTotal;
|
||||
final double scheduledIncomeRemainingTotal;
|
||||
final double scheduledTransferRemainingTotal;
|
||||
final double scheduledProvisionRemainingTotal;
|
||||
final double scheduledNetRemainingTotal;
|
||||
final double projectedMonthBalance;
|
||||
|
||||
final List<Account> accounts;
|
||||
final List<ReconciliationStatus> reconciliationsToday;
|
||||
|
||||
DashboardData({
|
||||
required this.month,
|
||||
required this.totalBalance,
|
||||
required this.availableAfterReserves,
|
||||
required this.monthlyIncome,
|
||||
required this.monthlyExpenses,
|
||||
required this.monthlyBalance,
|
||||
required this.provisionsCurrentTotal,
|
||||
required this.provisionsTargetTotal,
|
||||
required this.monthlyChargesRemainingTotal,
|
||||
required this.monthlyTransferRemainingTotal,
|
||||
required this.monthlyProvisionOperationRemainingTotal,
|
||||
required this.scheduledExpensesRemainingTotal,
|
||||
required this.scheduledIncomeRemainingTotal,
|
||||
required this.scheduledTransferRemainingTotal,
|
||||
required this.scheduledProvisionRemainingTotal,
|
||||
required this.scheduledNetRemainingTotal,
|
||||
required this.projectedMonthBalance,
|
||||
required this.accounts,
|
||||
required this.reconciliationsToday,
|
||||
});
|
||||
|
||||
double get plannedExpensesTotal =>
|
||||
monthlyChargesRemainingTotal + scheduledExpensesRemainingTotal;
|
||||
|
||||
double get plannedTransfersTotal =>
|
||||
monthlyTransferRemainingTotal + scheduledTransferRemainingTotal;
|
||||
|
||||
double get plannedProvisionsTotal =>
|
||||
monthlyProvisionOperationRemainingTotal + scheduledProvisionRemainingTotal;
|
||||
|
||||
bool get hasScheduledInfo =>
|
||||
scheduledExpensesRemainingTotal != 0 ||
|
||||
scheduledIncomeRemainingTotal != 0 ||
|
||||
scheduledTransferRemainingTotal != 0 ||
|
||||
scheduledProvisionRemainingTotal != 0;
|
||||
|
||||
factory DashboardData.fromJson(Map<String, dynamic> json) {
|
||||
final availableRaw = json['available_after_reserves'] ?? json['total_balance'] ?? 0;
|
||||
|
||||
final monthlyBalance = (json['monthly_balance'] as num? ?? 0).toDouble();
|
||||
final scheduledIncome = (json['scheduled_income_remaining_total'] as num? ?? 0).toDouble();
|
||||
final scheduledExpenses = (json['scheduled_expenses_remaining_total'] as num? ?? 0).toDouble();
|
||||
|
||||
return DashboardData(
|
||||
month: json['month'] as String? ?? '',
|
||||
totalBalance: (json['total_balance'] as num? ?? 0).toDouble(),
|
||||
availableAfterReserves: (availableRaw as num).toDouble(),
|
||||
monthlyIncome: (json['monthly_income'] as num? ?? 0).toDouble(),
|
||||
monthlyExpenses: (json['monthly_expenses'] as num? ?? 0).toDouble(),
|
||||
monthlyBalance: monthlyBalance,
|
||||
provisionsCurrentTotal: (json['provisions_current_total'] as num? ?? 0).toDouble(),
|
||||
provisionsTargetTotal: (json['provisions_target_total'] as num? ?? 0).toDouble(),
|
||||
monthlyChargesRemainingTotal: (json['monthly_charges_remaining_total'] as num? ?? 0).toDouble(),
|
||||
monthlyTransferRemainingTotal: (json['monthly_transfer_remaining_total'] as num? ?? 0).toDouble(),
|
||||
monthlyProvisionOperationRemainingTotal: (json['monthly_provision_operation_remaining_total'] as num? ?? 0).toDouble(),
|
||||
scheduledExpensesRemainingTotal: scheduledExpenses,
|
||||
scheduledIncomeRemainingTotal: scheduledIncome,
|
||||
scheduledTransferRemainingTotal: (json['scheduled_transfer_remaining_total'] as num? ?? 0).toDouble(),
|
||||
scheduledProvisionRemainingTotal: (json['scheduled_provision_remaining_total'] as num? ?? 0).toDouble(),
|
||||
scheduledNetRemainingTotal: (json['scheduled_net_remaining_total'] as num? ?? (scheduledIncome - scheduledExpenses)).toDouble(),
|
||||
projectedMonthBalance: (json['projected_month_balance'] as num? ?? (monthlyBalance + scheduledIncome - scheduledExpenses)).toDouble(),
|
||||
accounts: (json['accounts'] as List<dynamic>? ?? [])
|
||||
.map((e) => Account.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
reconciliationsToday: (json['reconciliations_today'] as List<dynamic>? ?? [])
|
||||
.map((e) => ReconciliationStatus.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'month': month,
|
||||
'total_balance': totalBalance,
|
||||
'available_after_reserves': availableAfterReserves,
|
||||
'monthly_income': monthlyIncome,
|
||||
'monthly_expenses': monthlyExpenses,
|
||||
'monthly_balance': monthlyBalance,
|
||||
'provisions_current_total': provisionsCurrentTotal,
|
||||
'provisions_target_total': provisionsTargetTotal,
|
||||
'monthly_charges_remaining_total': monthlyChargesRemainingTotal,
|
||||
'monthly_transfer_remaining_total': monthlyTransferRemainingTotal,
|
||||
'monthly_provision_operation_remaining_total': monthlyProvisionOperationRemainingTotal,
|
||||
'scheduled_expenses_remaining_total': scheduledExpensesRemainingTotal,
|
||||
'scheduled_income_remaining_total': scheduledIncomeRemainingTotal,
|
||||
'scheduled_transfer_remaining_total': scheduledTransferRemainingTotal,
|
||||
'scheduled_provision_remaining_total': scheduledProvisionRemainingTotal,
|
||||
'scheduled_net_remaining_total': scheduledNetRemainingTotal,
|
||||
'projected_month_balance': projectedMonthBalance,
|
||||
'accounts': accounts.map((e) => e.toJson()).toList(),
|
||||
'reconciliations_today': reconciliationsToday.map((e) => e.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
DashboardData copyWith({
|
||||
String? month,
|
||||
double? totalBalance,
|
||||
double? availableAfterReserves,
|
||||
double? monthlyIncome,
|
||||
double? monthlyExpenses,
|
||||
double? monthlyBalance,
|
||||
double? provisionsCurrentTotal,
|
||||
double? provisionsTargetTotal,
|
||||
double? monthlyChargesRemainingTotal,
|
||||
double? monthlyTransferRemainingTotal,
|
||||
double? monthlyProvisionOperationRemainingTotal,
|
||||
double? scheduledExpensesRemainingTotal,
|
||||
double? scheduledIncomeRemainingTotal,
|
||||
double? scheduledTransferRemainingTotal,
|
||||
double? scheduledProvisionRemainingTotal,
|
||||
double? scheduledNetRemainingTotal,
|
||||
double? projectedMonthBalance,
|
||||
List<Account>? accounts,
|
||||
List<ReconciliationStatus>? reconciliationsToday,
|
||||
}) {
|
||||
return DashboardData(
|
||||
month: month ?? this.month,
|
||||
totalBalance: totalBalance ?? this.totalBalance,
|
||||
availableAfterReserves: availableAfterReserves ?? this.availableAfterReserves,
|
||||
monthlyIncome: monthlyIncome ?? this.monthlyIncome,
|
||||
monthlyExpenses: monthlyExpenses ?? this.monthlyExpenses,
|
||||
monthlyBalance: monthlyBalance ?? this.monthlyBalance,
|
||||
provisionsCurrentTotal: provisionsCurrentTotal ?? this.provisionsCurrentTotal,
|
||||
provisionsTargetTotal: provisionsTargetTotal ?? this.provisionsTargetTotal,
|
||||
monthlyChargesRemainingTotal: monthlyChargesRemainingTotal ?? this.monthlyChargesRemainingTotal,
|
||||
monthlyTransferRemainingTotal: monthlyTransferRemainingTotal ?? this.monthlyTransferRemainingTotal,
|
||||
monthlyProvisionOperationRemainingTotal: monthlyProvisionOperationRemainingTotal ?? this.monthlyProvisionOperationRemainingTotal,
|
||||
scheduledExpensesRemainingTotal: scheduledExpensesRemainingTotal ?? this.scheduledExpensesRemainingTotal,
|
||||
scheduledIncomeRemainingTotal: scheduledIncomeRemainingTotal ?? this.scheduledIncomeRemainingTotal,
|
||||
scheduledTransferRemainingTotal: scheduledTransferRemainingTotal ?? this.scheduledTransferRemainingTotal,
|
||||
scheduledProvisionRemainingTotal: scheduledProvisionRemainingTotal ?? this.scheduledProvisionRemainingTotal,
|
||||
scheduledNetRemainingTotal: scheduledNetRemainingTotal ?? this.scheduledNetRemainingTotal,
|
||||
projectedMonthBalance: projectedMonthBalance ?? this.projectedMonthBalance,
|
||||
accounts: accounts ?? this.accounts,
|
||||
reconciliationsToday: reconciliationsToday ?? this.reconciliationsToday,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user