Initial upload: fin65-mobile

This commit is contained in:
Abderrahim-Mouhcine
2026-08-16 19:59:55 +01:00
commit 46558a5ec8
242 changed files with 16334 additions and 0 deletions
+248
View File
@@ -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'),
),
],
),
),
),
),
);
}
}
+715
View File
@@ -0,0 +1,715 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/account.dart';
import '../models/category.dart';
import '../models/dashboard.dart';
import '../models/transaction_item.dart';
import '../services/sync_service.dart';
import 'quick_transaction_screen.dart';
import 'settings_screen.dart';
import 'sync_screen.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key, required this.syncService});
final SyncService syncService;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
DashboardData? _dashboard;
List<Account> _accounts = [];
List<Category> _categories = [];
List<TransactionItem> _transactions = [];
int _pendingCount = 0;
DateTime? _lastSyncAt;
bool _isOnline = false;
bool _loading = true;
bool _syncing = false;
String? _message;
final _money = NumberFormat.currency(locale: 'fr_FR', symbol: 'MAD ', decimalDigits: 2);
final _date = DateFormat('dd/MM/yyyy HH:mm', 'fr_FR');
@override
void initState() {
super.initState();
_bootstrap();
}
Future<void> _bootstrap() async {
try {
final local = await widget.syncService.loadLocalSnapshot(
isOnline: false,
message: 'Données locales chargées.',
);
_applySnapshot(local, loading: false);
await _synchronize();
} catch (e) {
if (!mounted) return;
setState(() {
_loading = false;
_message = 'Erreur locale : $e';
});
}
}
void _applySnapshot(SyncSnapshot snapshot, {bool? loading}) {
if (!mounted) return;
setState(() {
_dashboard = snapshot.dashboard;
_accounts = snapshot.accounts;
_categories = snapshot.categories;
_transactions = snapshot.transactions;
_pendingCount = snapshot.pendingCount;
_lastSyncAt = snapshot.lastSyncAt;
_isOnline = snapshot.isOnline;
_message = snapshot.message;
if (loading != null) _loading = loading;
});
}
Future<void> _synchronize() async {
if (_syncing) return;
setState(() {
_syncing = true;
_message = 'Synchronisation...';
});
try {
final snapshot = await widget.syncService.synchronize();
if (!mounted) return;
_applySnapshot(snapshot, loading: false);
} catch (e) {
if (!mounted) return;
setState(() {
_message = 'Erreur de synchronisation : $e';
_loading = false;
});
} finally {
if (mounted) {
setState(() => _syncing = false);
}
}
}
Future<void> _reloadLocalAfterSave() async {
try {
final snapshot = await widget.syncService.loadLocalSnapshot(
isOnline: _isOnline,
message: _isOnline
? 'Opération enregistrée.'
: 'Opération en attente de synchronisation.',
);
_applySnapshot(snapshot, loading: false);
} catch (e) {
if (!mounted) return;
setState(() {
_message = 'Opération enregistrée localement, mais rechargement écran échoué : $e';
_loading = false;
});
}
}
Future<void> _openQuickAdd() async {
if (_accounts.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Crée d’abord au moins un compte dans la version web, puis synchronise.')),
);
return;
}
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => QuickTransactionScreen(
syncService: widget.syncService,
accounts: _accounts,
categories: _categories,
onSaved: _reloadLocalAfterSave,
),
),
);
}
Future<void> _openSyncScreen() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SyncScreen(syncService: widget.syncService),
),
);
final snapshot = await widget.syncService.loadLocalSnapshot(
isOnline: _isOnline,
message: null,
);
_applySnapshot(snapshot, loading: false);
}
Future<void> _openSettings() async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => SettingsScreen(apiService: widget.syncService.apiService),
),
);
await _synchronize();
}
@override
Widget build(BuildContext context) {
final dashboard = _dashboard;
return Scaffold(
appBar: AppBar(
title: const Text('FIN65'),
actions: [
IconButton(
tooltip: 'Page synchronisation',
onPressed: _openSyncScreen,
icon: Badge(
isLabelVisible: _pendingCount > 0,
label: Text('$_pendingCount'),
child: const Icon(Icons.cloud_sync),
),
),
IconButton(
tooltip: 'Synchroniser',
onPressed: _syncing ? null : _synchronize,
icon: _syncing
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.sync),
),
IconButton(onPressed: _openSettings, icon: const Icon(Icons.settings)),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _openQuickAdd,
icon: const Icon(Icons.add),
label: const Text('Ajouter'),
),
body: RefreshIndicator(
onRefresh: _synchronize,
child: _loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
_SyncBanner(
isOnline: _isOnline,
pendingCount: _pendingCount,
lastSyncAt: _lastSyncAt,
formatter: _date,
message: _message,
),
const SizedBox(height: 12),
if (dashboard != null) ...[
_DashboardCards(
dashboard: dashboard,
money: _money,
hasPending: _pendingCount > 0,
),
const SizedBox(height: 16),
] else
const _EmptyCacheCard(),
_SectionTitle(title: 'Comptes', action: _pendingCount > 0 ? 'Solde estimé' : 'Solde app'),
const SizedBox(height: 8),
if (_accounts.isEmpty)
const Card(child: ListTile(title: Text('Aucun compte en cache. Synchronise avec le backend.')))
else
..._accounts.map((a) => _AccountTile(account: a, money: _money)),
const SizedBox(height: 16),
_SectionTitle(title: 'Dernières transactions'),
const SizedBox(height: 8),
if (_transactions.isEmpty)
const Card(child: ListTile(title: Text('Aucune transaction en cache.')))
else
..._transactions.map(
(t) => _TransactionTile(
transaction: t,
accounts: _accounts,
categories: _categories,
money: _money,
),
),
const SizedBox(height: 90),
],
),
),
);
}
}
class _SyncBanner extends StatelessWidget {
const _SyncBanner({
required this.isOnline,
required this.pendingCount,
required this.lastSyncAt,
required this.formatter,
this.message,
});
final bool isOnline;
final int pendingCount;
final DateTime? lastSyncAt;
final DateFormat formatter;
final String? message;
@override
Widget build(BuildContext context) {
final title = isOnline ? 'Connecté au backend' : 'Mode hors ligne';
final subtitleParts = <String>[];
if (lastSyncAt != null) {
subtitleParts.add('Dernière synchro : ${formatter.format(lastSyncAt!.toLocal())}');
} else {
subtitleParts.add('Aucune synchronisation réussie');
}
if (pendingCount > 0) {
subtitleParts.add('$pendingCount opération(s) en attente');
}
if (message != null && message!.isNotEmpty) {
subtitleParts.add(message!);
}
return Card(
child: ListTile(
leading: Icon(isOnline ? Icons.cloud_done : Icons.cloud_off),
title: Text(title),
subtitle: Text(subtitleParts.join('\n')),
),
);
}
}
class _EmptyCacheCard extends StatelessWidget {
const _EmptyCacheCard();
@override
Widget build(BuildContext context) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Aucune donnée locale. Lance le backend PC une première fois, configure l’URL API, puis synchronise.',
),
),
);
}
}
class _DashboardCards extends StatelessWidget {
const _DashboardCards({
required this.dashboard,
required this.money,
required this.hasPending,
});
final DashboardData dashboard;
final NumberFormat money;
final bool hasPending;
@override
Widget build(BuildContext context) {
final labelSuffix = hasPending ? ' estimé' : '';
return Column(
children: [
_MetricCard(
title: 'Disponible réel$labelSuffix',
value: money.format(dashboard.availableAfterReserves),
icon: Icons.account_balance_wallet,
backgroundColor: const Color(0xFFE8F5E9),
accentColor: const Color(0xFF2E7D32),
),
const SizedBox(height: 8),
_MetricCard(
title: 'Balance prévisionnelle du mois',
value: money.format(dashboard.projectedMonthBalance),
icon: Icons.insights,
backgroundColor: const Color(0xFFE3F2FD),
accentColor: const Color(0xFF1565C0),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MetricCard(
title: 'Solde total$labelSuffix',
value: money.format(dashboard.totalBalance),
icon: Icons.savings,
compact: true,
backgroundColor: const Color(0xFFF3E5F5),
accentColor: const Color(0xFF6A1B9A),
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricCard(
title: 'Balance réelle',
value: money.format(dashboard.monthlyBalance),
icon: Icons.trending_up,
compact: true,
backgroundColor: dashboard.monthlyBalance >= 0
? const Color(0xFFE8F5E9)
: const Color(0xFFFFEBEE),
accentColor: dashboard.monthlyBalance >= 0
? const Color(0xFF2E7D32)
: const Color(0xFFC62828),
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _MetricCard(
title: 'Revenus réels',
value: money.format(dashboard.monthlyIncome),
icon: Icons.arrow_downward,
compact: true,
backgroundColor: const Color(0xFFE8F5E9),
accentColor: const Color(0xFF2E7D32),
),
),
const SizedBox(width: 8),
Expanded(
child: _MetricCard(
title: 'Dépenses réelles',
value: money.format(dashboard.monthlyExpenses),
icon: Icons.arrow_upward,
compact: true,
backgroundColor: const Color(0xFFFFEBEE),
accentColor: const Color(0xFFC62828),
),
),
],
),
const SizedBox(height: 8),
_PlannedInfoCard(dashboard: dashboard, money: money),
],
);
}
}
class _PlannedInfoCard extends StatelessWidget {
const _PlannedInfoCard({
required this.dashboard,
required this.money,
});
final DashboardData dashboard;
final NumberFormat money;
@override
Widget build(BuildContext context) {
final rows = <_InfoRow>[
_InfoRow(
'Dépenses prévues restantes',
dashboard.plannedExpensesTotal,
Icons.event_busy,
const Color(0xFFFFEBEE),
const Color(0xFFC62828),
),
_InfoRow(
'Revenus prévus échéancier',
dashboard.scheduledIncomeRemainingTotal,
Icons.event_available,
const Color(0xFFE8F5E9),
const Color(0xFF2E7D32),
),
_InfoRow(
'Dépenses échéancier',
dashboard.scheduledExpensesRemainingTotal,
Icons.calendar_month,
const Color(0xFFFFF3E0),
const Color(0xFFEF6C00),
),
_InfoRow(
'Transferts prévus',
dashboard.plannedTransfersTotal,
Icons.swap_horiz,
const Color(0xFFE3F2FD),
const Color(0xFF1565C0),
),
_InfoRow(
'Provisions prévues',
dashboard.plannedProvisionsTotal,
Icons.inventory_2,
const Color(0xFFF3E5F5),
const Color(0xFF6A1B9A),
),
];
return Card(
color: const Color(0xFFFAFAFA),
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.event_note, color: Color(0xFF1565C0)),
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Prévisionnel du mois',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'Inclut les opérations mensuelles et l’échéancier ponctuel du backend.',
style: Theme.of(context).textTheme.bodySmall,
),
const Divider(height: 20),
...rows.map(
(row) => Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
decoration: BoxDecoration(
color: row.backgroundColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: row.accentColor.withOpacity(0.22)),
),
child: Row(
children: [
Icon(row.icon, size: 20, color: row.accentColor),
const SizedBox(width: 10),
Expanded(
child: Text(
row.label,
style: TextStyle(
color: row.accentColor,
fontWeight: FontWeight.w700,
),
),
),
Text(
money.format(row.value),
style: TextStyle(
color: row.accentColor,
fontWeight: FontWeight.w900,
),
),
],
),
),
),
],
),
),
);
}
}
class _InfoRow {
const _InfoRow(
this.label,
this.value,
this.icon,
this.backgroundColor,
this.accentColor,
);
final String label;
final double value;
final IconData icon;
final Color backgroundColor;
final Color accentColor;
}
class _MetricCard extends StatelessWidget {
const _MetricCard({
required this.title,
required this.value,
required this.icon,
required this.backgroundColor,
required this.accentColor,
this.compact = false,
});
final String title;
final String value;
final IconData icon;
final Color backgroundColor;
final Color accentColor;
final bool compact;
@override
Widget build(BuildContext context) {
return Card(
color: backgroundColor,
elevation: 1,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: EdgeInsets.all(compact ? 12 : 16),
child: Row(
children: [
Container(
width: compact ? 34 : 42,
height: compact ? 34 : 42,
decoration: BoxDecoration(
color: accentColor.withOpacity(0.12),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, size: compact ? 21 : 27, color: accentColor),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: accentColor,
fontWeight: FontWeight.w700,
),
),
Text(
value,
style: (compact
? Theme.of(context).textTheme.titleMedium
: Theme.of(context).textTheme.headlineSmall)
?.copyWith(
color: accentColor,
fontWeight: FontWeight.w900,
),
),
],
),
),
],
),
),
);
}
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title, this.action});
final String title;
final String? action;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
if (action != null) Text(action!, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}
class _AccountTile extends StatelessWidget {
const _AccountTile({required this.account, required this.money});
final Account account;
final NumberFormat money;
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: const Icon(Icons.account_balance),
title: Text(account.name),
subtitle: Text(account.type),
trailing: Text(
money.format(account.appBalance),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
);
}
}
class _TransactionTile extends StatelessWidget {
const _TransactionTile({
required this.transaction,
required this.accounts,
required this.categories,
required this.money,
});
final TransactionItem transaction;
final List<Account> accounts;
final List<Category> categories;
final NumberFormat money;
Account? _findAccount(int? id) {
if (id == null) return null;
for (final account in accounts) {
if (account.id == id) return account;
}
return null;
}
Category? _findCategory(int? id) {
if (id == null) return null;
for (final category in categories) {
if (category.id == id) return category;
}
return null;
}
@override
Widget build(BuildContext context) {
final account = _findAccount(transaction.accountId);
final target = _findAccount(transaction.targetAccountId);
final category = _findCategory(transaction.categoryId);
final typeLabel = switch (transaction.type) {
'expense' => 'Dépense',
'income' => 'Recette',
'transfer' => 'Transfert',
_ => transaction.type,
};
final accountLabel = transaction.type == 'transfer'
? '${account?.name ?? transaction.accountId} → ${target?.name ?? transaction.targetAccountId}'
: account?.name ?? 'Compte ${transaction.accountId}';
return Card(
child: ListTile(
leading: Icon(transaction.isPending ? Icons.schedule_send : Icons.receipt_long),
title: Text(transaction.label),
subtitle: Text(
'${transaction.isPending ? 'EN ATTENTE • ' : ''}$typeLabel • $accountLabel${category == null ? '' : ' • ${category.name}'}',
),
trailing: Text(
money.format(transaction.amount),
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
);
}
}
+452
View File
@@ -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,
),
],
);
}
}
+311
View File
@@ -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.',
),
),
),
],
),
);
}
}
+123
View File
@@ -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.',
),
),
),
],
),
);
}
}
+239
View File
@@ -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),
),
),
);
}
}