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
+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),
),
),
);
}
}