Initial upload: fin65-mobile
This commit is contained in:
@@ -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