Initial upload: fin65-mobile
This commit is contained in:
@@ -0,0 +1,248 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../services/security_service.dart';
|
||||
|
||||
class AppLockGate extends StatefulWidget {
|
||||
const AppLockGate({
|
||||
super.key,
|
||||
required this.securityService,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final SecurityService securityService;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<AppLockGate> createState() => _AppLockGateState();
|
||||
}
|
||||
|
||||
class _AppLockGateState extends State<AppLockGate> with WidgetsBindingObserver {
|
||||
bool _checking = true;
|
||||
bool _locked = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_loadLockState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) {
|
||||
_lockIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadLockState() async {
|
||||
final enabled = await widget.securityService.isPinEnabled();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_locked = enabled;
|
||||
_checking = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _lockIfNeeded() async {
|
||||
final enabled = await widget.securityService.isPinEnabled();
|
||||
if (!mounted || !enabled) return;
|
||||
setState(() => _locked = true);
|
||||
}
|
||||
|
||||
void _unlock() {
|
||||
setState(() => _locked = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_checking) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (_locked) {
|
||||
return PinUnlockScreen(
|
||||
securityService: widget.securityService,
|
||||
onUnlocked: _unlock,
|
||||
);
|
||||
}
|
||||
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
class PinUnlockScreen extends StatefulWidget {
|
||||
const PinUnlockScreen({
|
||||
super.key,
|
||||
required this.securityService,
|
||||
required this.onUnlocked,
|
||||
});
|
||||
|
||||
final SecurityService securityService;
|
||||
final VoidCallback onUnlocked;
|
||||
|
||||
@override
|
||||
State<PinUnlockScreen> createState() => _PinUnlockScreenState();
|
||||
}
|
||||
|
||||
class _PinUnlockScreenState extends State<PinUnlockScreen> {
|
||||
final _pinController = TextEditingController();
|
||||
bool _checking = false;
|
||||
bool _checkingBiometric = false;
|
||||
bool _biometricEnabled = false;
|
||||
bool _biometricAvailable = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadBiometricState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pinController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadBiometricState() async {
|
||||
final enabled = await widget.securityService.isBiometricEnabled();
|
||||
final available = await widget.securityService.isBiometricAvailable();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_biometricEnabled = enabled;
|
||||
_biometricAvailable = available;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _unlockWithBiometrics() async {
|
||||
if (_checkingBiometric) return;
|
||||
|
||||
setState(() {
|
||||
_checkingBiometric = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final ok = await widget.securityService.authenticateWithBiometrics();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (ok) {
|
||||
widget.onUnlocked();
|
||||
} else {
|
||||
setState(() {
|
||||
_checkingBiometric = false;
|
||||
_error = 'Empreinte refusée ou annulée. Utilise le PIN.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _unlock() async {
|
||||
if (_checking) return;
|
||||
|
||||
setState(() {
|
||||
_checking = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
final ok = await widget.securityService.verifyPin(_pinController.text.trim());
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (ok) {
|
||||
_pinController.clear();
|
||||
widget.onUnlocked();
|
||||
} else {
|
||||
HapticFeedback.vibrate();
|
||||
setState(() {
|
||||
_checking = false;
|
||||
_error = 'PIN incorrect.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final showBiometric = _biometricEnabled && _biometricAvailable;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
Icon(showBiometric ? Icons.fingerprint : Icons.lock, size: 64),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'FIN65 verrouillé',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
showBiometric
|
||||
? 'Déverrouille avec ton empreinte ou ton PIN.'
|
||||
: 'Entre ton PIN pour accéder aux données locales.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (showBiometric) ...[
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: _checkingBiometric ? null : _unlockWithBiometrics,
|
||||
icon: _checkingBiometric
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.fingerprint),
|
||||
label: Text(_checkingBiometric ? 'Vérification...' : 'Utiliser l’empreinte'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
TextField(
|
||||
controller: _pinController,
|
||||
autofocus: !showBiometric,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 8,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'PIN',
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _error,
|
||||
counterText: '',
|
||||
),
|
||||
onSubmitted: (_) => _checking ? null : _unlock(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _checking ? null : _unlock,
|
||||
icon: _checking
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.lock_open),
|
||||
label: Text(_checking ? 'Vérification...' : 'Déverrouiller avec le PIN'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/account.dart';
|
||||
import '../models/category.dart';
|
||||
import '../services/sync_service.dart';
|
||||
|
||||
class QuickTransactionScreen extends StatefulWidget {
|
||||
const QuickTransactionScreen({
|
||||
super.key,
|
||||
required this.syncService,
|
||||
required this.accounts,
|
||||
required this.categories,
|
||||
required this.onSaved,
|
||||
});
|
||||
|
||||
final SyncService syncService;
|
||||
final List<Account> accounts;
|
||||
final List<Category> categories;
|
||||
|
||||
/// Important: this is async because the home screen must reload the local
|
||||
/// SQLite snapshot before the quick form is closed.
|
||||
final Future<void> Function() onSaved;
|
||||
|
||||
@override
|
||||
State<QuickTransactionScreen> createState() => _QuickTransactionScreenState();
|
||||
}
|
||||
|
||||
class _QuickTransactionScreenState extends State<QuickTransactionScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _amountController = TextEditingController();
|
||||
final _labelController = TextEditingController();
|
||||
final _noteController = TextEditingController();
|
||||
|
||||
String _type = 'expense';
|
||||
int? _accountId;
|
||||
int? _targetAccountId;
|
||||
int? _categoryId;
|
||||
bool _saving = false;
|
||||
|
||||
static const _quickAmounts = <double>[10, 20, 50, 100, 200, 500, 1000];
|
||||
|
||||
static const _expenseTemplates = <_QuickTemplate>[
|
||||
_QuickTemplate(label: 'Courses', categoryHint: 'course'),
|
||||
_QuickTemplate(label: 'Essence', categoryHint: 'essence'),
|
||||
_QuickTemplate(label: 'Restaurant', categoryHint: 'restaurant'),
|
||||
_QuickTemplate(label: 'Café', categoryHint: 'café'),
|
||||
_QuickTemplate(label: 'Transport', categoryHint: 'transport'),
|
||||
_QuickTemplate(label: 'Santé', categoryHint: 'santé'),
|
||||
_QuickTemplate(label: 'Abonnement', categoryHint: 'abonnement'),
|
||||
_QuickTemplate(label: 'Retrait cash', categoryHint: 'cash'),
|
||||
];
|
||||
|
||||
static const _incomeTemplates = <_QuickTemplate>[
|
||||
_QuickTemplate(label: 'Salaire', categoryHint: 'salaire'),
|
||||
_QuickTemplate(label: 'Remboursement', categoryHint: 'remboursement'),
|
||||
_QuickTemplate(label: 'Prime', categoryHint: 'prime'),
|
||||
_QuickTemplate(label: 'Vente', categoryHint: 'vente'),
|
||||
];
|
||||
|
||||
static const _transferTemplates = <_QuickTemplate>[
|
||||
_QuickTemplate(label: 'Épargne'),
|
||||
_QuickTemplate(label: 'Provision'),
|
||||
_QuickTemplate(label: 'Banque → Cash'),
|
||||
_QuickTemplate(label: 'Cash → Banque'),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.accounts.isNotEmpty) {
|
||||
_accountId = widget.accounts.first.id;
|
||||
_targetAccountId = widget.accounts.length > 1 ? widget.accounts[1].id : null;
|
||||
}
|
||||
_setDefaultCategory();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_amountController.dispose();
|
||||
_labelController.dispose();
|
||||
_noteController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<_QuickTemplate> get _templates {
|
||||
if (_type == 'income') return _incomeTemplates;
|
||||
if (_type == 'transfer') return _transferTemplates;
|
||||
return _expenseTemplates;
|
||||
}
|
||||
|
||||
void _setDefaultCategory({String? preferredHint}) {
|
||||
final filtered = widget.categories.where((c) => c.type == _type).toList();
|
||||
|
||||
if (filtered.isEmpty) {
|
||||
_categoryId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (preferredHint != null && preferredHint.trim().isNotEmpty) {
|
||||
final normalizedHint = _normalize(preferredHint);
|
||||
for (final category in filtered) {
|
||||
final normalizedCategory = _normalize(category.name);
|
||||
if (normalizedCategory.contains(normalizedHint) ||
|
||||
normalizedHint.contains(normalizedCategory)) {
|
||||
_categoryId = category.id;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_categoryId = filtered.first.id;
|
||||
}
|
||||
|
||||
String _normalize(String value) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replaceAll('é', 'e')
|
||||
.replaceAll('è', 'e')
|
||||
.replaceAll('ê', 'e')
|
||||
.replaceAll('à', 'a')
|
||||
.replaceAll('â', 'a')
|
||||
.replaceAll('ù', 'u')
|
||||
.replaceAll('û', 'u')
|
||||
.replaceAll('î', 'i')
|
||||
.replaceAll('ï', 'i')
|
||||
.replaceAll('ô', 'o')
|
||||
.replaceAll('ç', 'c')
|
||||
.trim();
|
||||
}
|
||||
|
||||
List<Category> get _visibleCategories {
|
||||
return widget.categories.where((c) => c.type == _type).toList();
|
||||
}
|
||||
|
||||
void _applyTemplate(_QuickTemplate template) {
|
||||
if (_saving) return;
|
||||
|
||||
setState(() {
|
||||
_labelController.text = template.label;
|
||||
if (template.amount != null) {
|
||||
_amountController.text = _formatAmount(template.amount!);
|
||||
}
|
||||
if (_type != 'transfer') {
|
||||
_setDefaultCategory(preferredHint: template.categoryHint ?? template.label);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _applyAmount(double amount) {
|
||||
if (_saving) return;
|
||||
setState(() {
|
||||
_amountController.text = _formatAmount(amount);
|
||||
});
|
||||
}
|
||||
|
||||
String _formatAmount(double amount) {
|
||||
if (amount == amount.roundToDouble()) return amount.toInt().toString();
|
||||
return amount.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
|
||||
int? _firstOtherAccountId() {
|
||||
for (final account in widget.accounts) {
|
||||
if (account.id != _accountId) return account.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final accountId = _accountId;
|
||||
if (accountId == null) {
|
||||
_showSnack('Compte obligatoire.');
|
||||
return;
|
||||
}
|
||||
|
||||
final targetAccountId = _targetAccountId;
|
||||
if (_type == 'transfer') {
|
||||
if (targetAccountId == null) {
|
||||
_showSnack('Compte cible obligatoire.');
|
||||
return;
|
||||
}
|
||||
if (targetAccountId == accountId) {
|
||||
_showSnack('Source et cible doivent être différents.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState(() => _saving = true);
|
||||
|
||||
try {
|
||||
final synced = await widget.syncService.createTransactionSmart(
|
||||
label: _labelController.text.trim(),
|
||||
amount: double.parse(_amountController.text.replaceAll(',', '.')),
|
||||
type: _type,
|
||||
accountId: accountId,
|
||||
targetAccountId: _type == 'transfer' ? targetAccountId : null,
|
||||
categoryId: _type == 'transfer' ? null : _categoryId,
|
||||
note: _noteController.text.trim().isEmpty ? null : _noteController.text.trim(),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
await widget.onSaved();
|
||||
|
||||
if (!mounted) return;
|
||||
_showSnack(
|
||||
synced
|
||||
? 'Transaction synchronisée.'
|
||||
: 'Backend absent : opération gardée localement.',
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_showSnack('Erreur : $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnack(String text) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger?.clearSnackBars();
|
||||
messenger?.showSnackBar(SnackBar(content: Text(text)));
|
||||
}
|
||||
|
||||
Widget _buildSaveButton() {
|
||||
return FilledButton.icon(
|
||||
onPressed: _saving ? null : _save,
|
||||
icon: _saving
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save),
|
||||
label: Text(_saving ? 'Enregistrement...' : 'Enregistrer'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final categories = _visibleCategories;
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
appBar: AppBar(
|
||||
title: const Text('Ajout rapide'),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: _buildSaveButton(),
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
SegmentedButton<String>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 'expense', label: Text('Dépense')),
|
||||
ButtonSegment(value: 'income', label: Text('Recette')),
|
||||
ButtonSegment(value: 'transfer', label: Text('Transfert')),
|
||||
],
|
||||
selected: {_type},
|
||||
onSelectionChanged: _saving
|
||||
? null
|
||||
: (values) {
|
||||
setState(() {
|
||||
_type = values.first;
|
||||
_setDefaultCategory();
|
||||
if (_type == 'transfer' && _targetAccountId == _accountId) {
|
||||
_targetAccountId = _firstOtherAccountId();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_QuickChipsSection(
|
||||
title: _type == 'transfer'
|
||||
? 'Transferts rapides'
|
||||
: _type == 'income'
|
||||
? 'Recettes rapides'
|
||||
: 'Dépenses rapides',
|
||||
chips: _templates
|
||||
.map((template) => ActionChip(
|
||||
label: Text(template.label),
|
||||
onPressed: _saving ? null : () => _applyTemplate(template),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _amountController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Montant',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
validator: (value) {
|
||||
final parsed = double.tryParse((value ?? '').replaceAll(',', '.'));
|
||||
if (parsed == null || parsed <= 0) return 'Montant invalide';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_QuickChipsSection(
|
||||
title: 'Montants rapides',
|
||||
chips: _quickAmounts
|
||||
.map((amount) => ChoiceChip(
|
||||
label: Text(_formatAmount(amount)),
|
||||
selected: _amountController.text == _formatAmount(amount),
|
||||
onSelected: _saving ? null : (_) => _applyAmount(amount),
|
||||
))
|
||||
.toList(),
|
||||
dense: true,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _labelController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Libellé',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if ((value ?? '').trim().isEmpty) return 'Libellé obligatoire';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
value: _accountId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Compte source',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: widget.accounts
|
||||
.map((a) => DropdownMenuItem(value: a.id, child: Text(a.name)))
|
||||
.toList(),
|
||||
onChanged: _saving
|
||||
? null
|
||||
: (value) {
|
||||
setState(() {
|
||||
_accountId = value;
|
||||
if (_type == 'transfer' && _targetAccountId == _accountId) {
|
||||
_targetAccountId = _firstOtherAccountId();
|
||||
}
|
||||
});
|
||||
},
|
||||
validator: (value) => value == null ? 'Compte obligatoire' : null,
|
||||
),
|
||||
if (_type == 'transfer') ...[
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
value: _targetAccountId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Compte cible',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: widget.accounts
|
||||
.map((a) => DropdownMenuItem(value: a.id, child: Text(a.name)))
|
||||
.toList(),
|
||||
onChanged: _saving ? null : (value) => setState(() => _targetAccountId = value),
|
||||
validator: (value) {
|
||||
if (value == null) return 'Compte cible obligatoire';
|
||||
if (value == _accountId) return 'Source et cible doivent être différents';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
if (_type != 'transfer') ...[
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<int>(
|
||||
value: _categoryId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Catégorie',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: categories
|
||||
.map((c) => DropdownMenuItem(value: c.id, child: Text(c.name)))
|
||||
.toList(),
|
||||
onChanged: _saving ? null : (value) => setState(() => _categoryId = value),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: _noteController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Note',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
minLines: 2,
|
||||
maxLines: 4,
|
||||
),
|
||||
const SizedBox(height: 120),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickTemplate {
|
||||
const _QuickTemplate({
|
||||
required this.label,
|
||||
this.categoryHint,
|
||||
this.amount,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String? categoryHint;
|
||||
final double? amount;
|
||||
}
|
||||
|
||||
class _QuickChipsSection extends StatelessWidget {
|
||||
const _QuickChipsSection({
|
||||
required this.title,
|
||||
required this.chips,
|
||||
this.dense = false,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final List<Widget> chips;
|
||||
final bool dense;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (chips.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: dense
|
||||
? Theme.of(context).textTheme.bodyMedium
|
||||
: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
SizedBox(height: dense ? 4 : 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: dense ? 4 : 8,
|
||||
children: chips,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
import '../services/security_service.dart';
|
||||
|
||||
class SecurityScreen extends StatefulWidget {
|
||||
const SecurityScreen({
|
||||
super.key,
|
||||
required this.securityService,
|
||||
});
|
||||
|
||||
final SecurityService securityService;
|
||||
|
||||
@override
|
||||
State<SecurityScreen> createState() => _SecurityScreenState();
|
||||
}
|
||||
|
||||
class _SecurityScreenState extends State<SecurityScreen> {
|
||||
final _currentPinController = TextEditingController();
|
||||
final _newPinController = TextEditingController();
|
||||
final _confirmPinController = TextEditingController();
|
||||
|
||||
bool _loading = true;
|
||||
bool _saving = false;
|
||||
bool _enabled = false;
|
||||
bool _biometricAvailable = false;
|
||||
bool _biometricEnabled = false;
|
||||
List<BiometricType> _availableBiometrics = const [];
|
||||
String? _message;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_currentPinController.dispose();
|
||||
_newPinController.dispose();
|
||||
_confirmPinController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final enabled = await widget.securityService.isPinEnabled();
|
||||
final biometricAvailable = await widget.securityService.isBiometricAvailable();
|
||||
final biometricEnabled = await widget.securityService.isBiometricEnabled();
|
||||
final availableBiometrics = await widget.securityService.getAvailableBiometrics();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_enabled = enabled;
|
||||
_biometricAvailable = biometricAvailable;
|
||||
_biometricEnabled = biometricEnabled;
|
||||
_availableBiometrics = availableBiometrics;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _verifyCurrentPinFromField() async {
|
||||
final current = _currentPinController.text.trim();
|
||||
final ok = await widget.securityService.verifyPin(current);
|
||||
if (!ok) {
|
||||
setState(() => _message = 'PIN actuel incorrect.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _savePin() async {
|
||||
final newPin = _newPinController.text.trim();
|
||||
final confirm = _confirmPinController.text.trim();
|
||||
|
||||
if (newPin != confirm) {
|
||||
setState(() => _message = 'Le nouveau PIN et la confirmation ne correspondent pas.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_message = null;
|
||||
});
|
||||
|
||||
try {
|
||||
if (_enabled) {
|
||||
final currentOk = await _verifyCurrentPinFromField();
|
||||
if (!currentOk) return;
|
||||
}
|
||||
|
||||
await widget.securityService.setPin(newPin);
|
||||
|
||||
_currentPinController.clear();
|
||||
_newPinController.clear();
|
||||
_confirmPinController.clear();
|
||||
|
||||
await _load();
|
||||
|
||||
setState(() {
|
||||
_message = 'Verrouillage PIN activé.';
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _message = 'Erreur : $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disablePin() async {
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_message = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final currentOk = await _verifyCurrentPinFromField();
|
||||
if (!currentOk) return;
|
||||
|
||||
await widget.securityService.disablePin();
|
||||
|
||||
_currentPinController.clear();
|
||||
_newPinController.clear();
|
||||
_confirmPinController.clear();
|
||||
|
||||
await _load();
|
||||
|
||||
setState(() {
|
||||
_message = 'Verrouillage PIN et biométrie désactivés.';
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _message = 'Erreur : $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleBiometrics(bool value) async {
|
||||
if (_saving) return;
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_message = null;
|
||||
});
|
||||
|
||||
try {
|
||||
if (!_enabled) {
|
||||
setState(() => _message = 'Active d’abord un PIN avant la biométrie.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && !_biometricAvailable) {
|
||||
setState(() => _message = 'Aucune empreinte/biométrie disponible sur ce téléphone.');
|
||||
return;
|
||||
}
|
||||
|
||||
final currentOk = await _verifyCurrentPinFromField();
|
||||
if (!currentOk) return;
|
||||
|
||||
await widget.securityService.setBiometricEnabled(value);
|
||||
_currentPinController.clear();
|
||||
|
||||
await _load();
|
||||
|
||||
setState(() {
|
||||
_message = value
|
||||
? 'Déverrouillage par empreinte activé. Verrouille l’app puis teste avec le bouton empreinte.'
|
||||
: 'Déverrouillage par empreinte désactivé.';
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _message = 'Erreur : $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _biometricLabel() {
|
||||
if (_availableBiometrics.isEmpty) return 'Aucune biométrie détectée.';
|
||||
|
||||
final labels = _availableBiometrics.map((type) {
|
||||
return switch (type) {
|
||||
BiometricType.fingerprint => 'empreinte',
|
||||
BiometricType.face => 'visage',
|
||||
BiometricType.iris => 'iris',
|
||||
BiometricType.strong => 'biométrie forte',
|
||||
BiometricType.weak => 'biométrie faible',
|
||||
};
|
||||
}).toSet().join(', ');
|
||||
|
||||
return 'Disponible : $labels.';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Sécurité')),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Sécurité')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
value: _enabled,
|
||||
onChanged: null,
|
||||
secondary: Icon(_enabled ? Icons.lock : Icons.lock_open),
|
||||
title: const Text('Verrouillage PIN'),
|
||||
subtitle: Text(
|
||||
_enabled
|
||||
? 'Activé. L’app se verrouille à la fermeture ou au changement d’application.'
|
||||
: 'Désactivé. Tes données locales sont accessibles sans PIN.',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_enabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
value: _biometricEnabled,
|
||||
onChanged: _saving || !_biometricAvailable ? null : _toggleBiometrics,
|
||||
secondary: const Icon(Icons.fingerprint),
|
||||
title: const Text('Déverrouillage par empreinte'),
|
||||
subtitle: Text(
|
||||
_biometricAvailable
|
||||
? '${_biometricLabel()} Entre le PIN actuel ci-dessous puis active l’interrupteur.'
|
||||
: 'Aucune empreinte/biométrie disponible ou configurée sur ce téléphone.',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (_enabled) ...[
|
||||
TextField(
|
||||
controller: _currentPinController,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 8,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'PIN actuel',
|
||||
border: OutlineInputBorder(),
|
||||
helperText: 'Nécessaire pour changer le PIN, désactiver le PIN ou activer/désactiver l’empreinte.',
|
||||
counterText: '',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
TextField(
|
||||
controller: _newPinController,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 8,
|
||||
decoration: InputDecoration(
|
||||
labelText: _enabled ? 'Nouveau PIN' : 'PIN',
|
||||
border: const OutlineInputBorder(),
|
||||
helperText: '4 à 8 chiffres.',
|
||||
counterText: '',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _confirmPinController,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 8,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Confirmer le PIN',
|
||||
border: OutlineInputBorder(),
|
||||
counterText: '',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _saving ? null : _savePin,
|
||||
icon: const Icon(Icons.save),
|
||||
label: Text(_enabled ? 'Changer le PIN' : 'Activer le PIN'),
|
||||
),
|
||||
if (_enabled) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _saving ? null : _disablePin,
|
||||
icon: const Icon(Icons.lock_open),
|
||||
label: const Text('Désactiver le PIN'),
|
||||
),
|
||||
],
|
||||
if (_message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(_message!),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Le PIN protège l’accès local à l’application. L’empreinte utilise la sécurité biométrique du téléphone. Le PIN reste le secours si l’empreinte échoue.',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
import '../services/security_service.dart';
|
||||
|
||||
class SecurityScreen extends StatefulWidget {
|
||||
const SecurityScreen({
|
||||
super.key,
|
||||
required this.securityService,
|
||||
});
|
||||
|
||||
final SecurityService securityService;
|
||||
|
||||
@override
|
||||
State<SecurityScreen> createState() => _SecurityScreenState();
|
||||
}
|
||||
|
||||
class _SecurityScreenState extends State<SecurityScreen> {
|
||||
final _currentPinController = TextEditingController();
|
||||
final _newPinController = TextEditingController();
|
||||
final _confirmPinController = TextEditingController();
|
||||
|
||||
bool _loading = true;
|
||||
bool _saving = false;
|
||||
bool _enabled = false;
|
||||
bool _biometricAvailable = false;
|
||||
bool _biometricEnabled = false;
|
||||
List<BiometricType> _availableBiometrics = const [];
|
||||
String? _message;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_currentPinController.dispose();
|
||||
_newPinController.dispose();
|
||||
_confirmPinController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final enabled = await widget.securityService.isPinEnabled();
|
||||
final biometricAvailable = await widget.securityService.isBiometricAvailable();
|
||||
final biometricEnabled = await widget.securityService.isBiometricEnabled();
|
||||
final availableBiometrics = await widget.securityService.getAvailableBiometrics();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_enabled = enabled;
|
||||
_biometricAvailable = biometricAvailable;
|
||||
_biometricEnabled = biometricEnabled;
|
||||
_availableBiometrics = availableBiometrics;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _verifyCurrentPinIfNeeded() async {
|
||||
if (!_enabled) return true;
|
||||
|
||||
final current = _currentPinController.text.trim();
|
||||
final ok = await widget.securityService.verifyPin(current);
|
||||
if (!ok) {
|
||||
setState(() => _message = 'PIN actuel incorrect.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _savePin() async {
|
||||
final newPin = _newPinController.text.trim();
|
||||
final confirm = _confirmPinController.text.trim();
|
||||
|
||||
if (newPin != confirm) {
|
||||
setState(() => _message = 'Le nouveau PIN et la confirmation ne correspondent pas.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_message = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final currentOk = await _verifyCurrentPinIfNeeded();
|
||||
if (!currentOk) return;
|
||||
|
||||
await widget.securityService.setPin(newPin);
|
||||
|
||||
_currentPinController.clear();
|
||||
_newPinController.clear();
|
||||
_confirmPinController.clear();
|
||||
|
||||
await _load();
|
||||
|
||||
setState(() {
|
||||
_message = 'Verrouillage PIN activé.';
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _message = 'Erreur : $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disablePin() async {
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_message = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final currentOk = await _verifyCurrentPinIfNeeded();
|
||||
if (!currentOk) return;
|
||||
|
||||
await widget.securityService.disablePin();
|
||||
|
||||
_currentPinController.clear();
|
||||
_newPinController.clear();
|
||||
_confirmPinController.clear();
|
||||
|
||||
await _load();
|
||||
|
||||
setState(() {
|
||||
_message = 'Verrouillage PIN et biométrie désactivés.';
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _message = 'Erreur : $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleBiometrics(bool value) async {
|
||||
setState(() {
|
||||
_saving = true;
|
||||
_message = null;
|
||||
});
|
||||
|
||||
try {
|
||||
if (value) {
|
||||
final currentOk = await _verifyCurrentPinIfNeeded();
|
||||
if (!currentOk) return;
|
||||
|
||||
final ok = await widget.securityService.authenticateWithBiometrics();
|
||||
|
||||
// If biometric was not enabled yet, authenticateWithBiometrics returns false by design.
|
||||
// So enable first after checking availability, then immediately ask Android dialog.
|
||||
if (!ok) {
|
||||
await widget.securityService.setBiometricEnabled(true);
|
||||
final check = await widget.securityService.authenticateWithBiometrics();
|
||||
if (!check) {
|
||||
await widget.securityService.setBiometricEnabled(false);
|
||||
setState(() => _message = 'Empreinte annulée ou refusée. Biométrie non activée.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await widget.securityService.setBiometricEnabled(true);
|
||||
}
|
||||
} else {
|
||||
await widget.securityService.setBiometricEnabled(false);
|
||||
}
|
||||
|
||||
await _load();
|
||||
setState(() {
|
||||
_message = value ? 'Déverrouillage par empreinte activé.' : 'Déverrouillage par empreinte désactivé.';
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _message = 'Erreur : $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
String _biometricLabel() {
|
||||
if (_availableBiometrics.isEmpty) return 'Aucune biométrie détectée.';
|
||||
final labels = _availableBiometrics.map((type) {
|
||||
return switch (type) {
|
||||
BiometricType.fingerprint => 'empreinte',
|
||||
BiometricType.face => 'visage',
|
||||
BiometricType.iris => 'iris',
|
||||
BiometricType.strong => 'biométrie forte',
|
||||
BiometricType.weak => 'biométrie faible',
|
||||
};
|
||||
}).toSet().join(', ');
|
||||
return 'Disponible : $labels.';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Sécurité')),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Sécurité')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
value: _enabled,
|
||||
onChanged: null,
|
||||
secondary: Icon(_enabled ? Icons.lock : Icons.lock_open),
|
||||
title: const Text('Verrouillage PIN'),
|
||||
subtitle: Text(
|
||||
_enabled
|
||||
? 'Activé. L’app se verrouille à la fermeture ou au changement d’application.'
|
||||
: 'Désactivé. Tes données locales sont accessibles sans PIN.',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_enabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
Card(
|
||||
child: SwitchListTile(
|
||||
value: _biometricEnabled,
|
||||
onChanged: _saving || !_biometricAvailable ? null : _toggleBiometrics,
|
||||
secondary: const Icon(Icons.fingerprint),
|
||||
title: const Text('Déverrouillage par empreinte'),
|
||||
subtitle: Text(
|
||||
_biometricAvailable
|
||||
? '${_biometricLabel()} Le PIN reste disponible en secours.'
|
||||
: 'Aucune empreinte/biométrie disponible ou configurée sur ce téléphone.',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
if (_enabled) ...[
|
||||
TextField(
|
||||
controller: _currentPinController,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 8,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'PIN actuel',
|
||||
border: OutlineInputBorder(),
|
||||
helperText: 'Demandé pour changer le PIN, désactiver ou activer l’empreinte.',
|
||||
counterText: '',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
TextField(
|
||||
controller: _newPinController,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 8,
|
||||
decoration: InputDecoration(
|
||||
labelText: _enabled ? 'Nouveau PIN' : 'PIN',
|
||||
border: const OutlineInputBorder(),
|
||||
helperText: '4 à 8 chiffres.',
|
||||
counterText: '',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _confirmPinController,
|
||||
obscureText: true,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 8,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Confirmer le PIN',
|
||||
border: OutlineInputBorder(),
|
||||
counterText: '',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _saving ? null : _savePin,
|
||||
icon: const Icon(Icons.save),
|
||||
label: Text(_enabled ? 'Changer le PIN' : 'Activer le PIN'),
|
||||
),
|
||||
if (_enabled) ...[
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _saving ? null : _disablePin,
|
||||
icon: const Icon(Icons.lock_open),
|
||||
label: const Text('Désactiver le PIN'),
|
||||
),
|
||||
],
|
||||
if (_message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(_message!),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'Le PIN protège l’accès local à l’application. L’empreinte utilise la sécurité biométrique du téléphone. Le PIN reste le secours si l’empreinte échoue.',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/api_service.dart';
|
||||
import '../services/security_service.dart';
|
||||
import 'security_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key, required this.apiService});
|
||||
|
||||
final ApiService apiService;
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final _controller = TextEditingController();
|
||||
bool _testing = false;
|
||||
String? _message;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
_controller.text = await widget.apiService.getBaseUrl();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
await widget.apiService.setBaseUrl(_controller.text);
|
||||
if (mounted) {
|
||||
setState(() => _message = 'URL enregistrée localement.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _test() async {
|
||||
setState(() {
|
||||
_testing = true;
|
||||
_message = null;
|
||||
});
|
||||
|
||||
try {
|
||||
await _save();
|
||||
final ok = await widget.apiService.healthCheck();
|
||||
setState(() => _message = ok ? 'Connexion OK.' : 'Réponse inattendue du serveur.');
|
||||
} catch (e) {
|
||||
setState(() => _message = 'Erreur : $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _testing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Paramètres'),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text(
|
||||
'URL du backend FIN65',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Émulateur Android : http://10.0.2.2:8000\nTéléphone réel : http://IP_DU_PC:8000',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'URL API',
|
||||
border: OutlineInputBorder(),
|
||||
hintText: 'http://192.168.1.25:8000',
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: _testing ? null : _test,
|
||||
child: Text(_testing ? 'Test...' : 'Enregistrer et tester'),
|
||||
),
|
||||
if (_message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(_message!),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.lock),
|
||||
title: const Text('Sécurité'),
|
||||
subtitle: const Text('Activer, changer ou désactiver le verrouillage PIN.'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SecurityScreen(
|
||||
securityService: SecurityService(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: Text(
|
||||
'L’URL est stockée dans la base SQLite locale du mobile. '
|
||||
'Elle reste donc disponible même sans backend.',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../models/pending_operation.dart';
|
||||
import '../services/sync_service.dart';
|
||||
|
||||
class SyncScreen extends StatefulWidget {
|
||||
const SyncScreen({
|
||||
super.key,
|
||||
required this.syncService,
|
||||
});
|
||||
|
||||
final SyncService syncService;
|
||||
|
||||
@override
|
||||
State<SyncScreen> createState() => _SyncScreenState();
|
||||
}
|
||||
|
||||
class _SyncScreenState extends State<SyncScreen> {
|
||||
final _date = DateFormat('dd/MM/yyyy HH:mm', 'fr_FR');
|
||||
final _money = NumberFormat.currency(locale: 'fr_FR', symbol: 'MAD ', decimalDigits: 2);
|
||||
|
||||
bool _loading = true;
|
||||
bool _syncing = false;
|
||||
bool _isOnline = false;
|
||||
String? _apiUrl;
|
||||
String? _message;
|
||||
DateTime? _lastSyncAt;
|
||||
List<PendingOperation> _pending = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_message = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final apiUrl = await widget.syncService.getApiUrl();
|
||||
final isOnline = await widget.syncService.testBackend();
|
||||
final snapshot = await widget.syncService.loadLocalSnapshot(
|
||||
isOnline: isOnline,
|
||||
message: null,
|
||||
);
|
||||
final pending = await widget.syncService.loadPendingOperations();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_apiUrl = apiUrl;
|
||||
_isOnline = isOnline;
|
||||
_lastSyncAt = snapshot.lastSyncAt;
|
||||
_pending = pending;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _message = 'Erreur locale : $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _syncNow() async {
|
||||
if (_syncing) return;
|
||||
|
||||
setState(() {
|
||||
_syncing = true;
|
||||
_message = 'Synchronisation en cours...';
|
||||
});
|
||||
|
||||
try {
|
||||
final snapshot = await widget.syncService.synchronize();
|
||||
final pending = await widget.syncService.loadPendingOperations();
|
||||
final apiUrl = await widget.syncService.getApiUrl();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_apiUrl = apiUrl;
|
||||
_isOnline = snapshot.isOnline;
|
||||
_lastSyncAt = snapshot.lastSyncAt;
|
||||
_pending = pending;
|
||||
_message = snapshot.isOnline
|
||||
? 'Synchronisation terminée.'
|
||||
: 'Backend indisponible. Les opérations restent en attente.';
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _message = 'Erreur de synchronisation : $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _syncing = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pendingCount = _pending.length;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Synchronisation'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _loading || _syncing ? null : _load,
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
minimum: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||
child: FilledButton.icon(
|
||||
onPressed: _syncing ? null : _syncNow,
|
||||
icon: _syncing
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.sync),
|
||||
label: Text(_syncing ? 'Synchronisation...' : 'Synchroniser maintenant'),
|
||||
),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 110),
|
||||
children: [
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: Icon(_isOnline ? Icons.cloud_done : Icons.cloud_off),
|
||||
title: Text(_isOnline ? 'Backend joignable' : 'Backend indisponible'),
|
||||
subtitle: Text(_apiUrl ?? 'URL API inconnue'),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.schedule),
|
||||
title: const Text('Dernière synchronisation réussie'),
|
||||
subtitle: Text(
|
||||
_lastSyncAt == null
|
||||
? 'Aucune synchronisation réussie'
|
||||
: _date.format(_lastSyncAt!.toLocal()),
|
||||
),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: Icon(pendingCount == 0 ? Icons.check_circle : Icons.pending_actions),
|
||||
title: Text('$pendingCount opération(s) en attente'),
|
||||
subtitle: Text(
|
||||
pendingCount == 0
|
||||
? 'Aucune opération locale à envoyer.'
|
||||
: 'Ces opérations seront envoyées quand le backend sera disponible.',
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_message != null && _message!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(_message!),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text('File d’attente', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 8),
|
||||
if (_pending.isEmpty)
|
||||
const Card(
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.inbox),
|
||||
title: Text('Aucune opération en attente'),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._pending.map((operation) => _PendingOperationTile(
|
||||
operation: operation,
|
||||
date: _date,
|
||||
money: _money,
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PendingOperationTile extends StatelessWidget {
|
||||
const _PendingOperationTile({
|
||||
required this.operation,
|
||||
required this.date,
|
||||
required this.money,
|
||||
});
|
||||
|
||||
final PendingOperation operation;
|
||||
final DateFormat date;
|
||||
final NumberFormat money;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final payload = operation.payload;
|
||||
final label = payload['label']?.toString() ?? 'Opération mobile';
|
||||
final amount = (payload['amount'] as num? ?? 0).toDouble();
|
||||
final type = payload['type']?.toString() ?? operation.operationType;
|
||||
final accountId = payload['account_id']?.toString() ?? '?';
|
||||
final targetAccountId = payload['target_account_id']?.toString();
|
||||
|
||||
final typeLabel = switch (type) {
|
||||
'expense' => 'Dépense',
|
||||
'income' => 'Recette',
|
||||
'transfer' => 'Transfert',
|
||||
_ => type,
|
||||
};
|
||||
|
||||
final accountLabel = type == 'transfer' && targetAccountId != null
|
||||
? 'Compte $accountId → compte $targetAccountId'
|
||||
: 'Compte $accountId';
|
||||
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.schedule_send),
|
||||
title: Text(label),
|
||||
subtitle: Text(
|
||||
'$typeLabel • $accountLabel\nCréée le ${date.format(operation.createdAt.toLocal())}'
|
||||
'${operation.error == null ? '' : '\nDernière erreur : ${operation.error}'}',
|
||||
),
|
||||
isThreeLine: true,
|
||||
trailing: Text(
|
||||
money.format(amount),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user