Flutter: Offline-Queue für Kontroll-Erfassung (Prompt 17-Härtung)
lib/offline/queue.dart (sqflite) analog zur React-PWA-Queue: enqueue statt Direkt-Senden, Status wartet/wird_uebertragen/fehler, Idempotenz, Sync bei Reconnect (connectivity_plus) + periodisch alle 15s. KontrollScreen gleicht per 2s-Reconciliation-Loop den Anzeigezustand ab, "Kein Netz"-Banner. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
import '../api/api_client.dart';
|
||||
|
||||
/// Gleiche Idee wie in der React-PWA (frontend/src/offline/queue.ts):
|
||||
/// Eingaben werden lokal zwischengespeichert, sobald eingegeben, nicht erst
|
||||
/// bei Übertragung verworfen, wenn die Verbindung kurz fehlt. sqflite statt
|
||||
/// IndexedDB, weil das die etablierte lokale-DB-Lösung für Flutter ist -
|
||||
/// gleiche Rolle, andere Plattform (Prompt 17-Härtung für den Android-Client).
|
||||
class QueueItem {
|
||||
QueueItem({
|
||||
required this.kontrolleId,
|
||||
required this.materialId,
|
||||
required this.istmenge,
|
||||
this.ablaufdatum,
|
||||
this.chargennummer,
|
||||
this.seriennummer,
|
||||
this.status = 'wartet',
|
||||
this.fehlerText,
|
||||
});
|
||||
|
||||
final String kontrolleId;
|
||||
final int materialId;
|
||||
final String istmenge;
|
||||
final String? ablaufdatum;
|
||||
final String? chargennummer;
|
||||
final String? seriennummer;
|
||||
String status; // wartet | wird_uebertragen | fehler
|
||||
String? fehlerText;
|
||||
|
||||
Map<String, dynamic> toRow() => {
|
||||
'kontrolle_id': kontrolleId,
|
||||
'material_id': materialId,
|
||||
'istmenge': istmenge,
|
||||
'ablaufdatum': ablaufdatum,
|
||||
'chargennummer': chargennummer,
|
||||
'seriennummer': seriennummer,
|
||||
'status': status,
|
||||
'fehler_text': fehlerText,
|
||||
};
|
||||
|
||||
factory QueueItem.fromRow(Map<String, dynamic> row) => QueueItem(
|
||||
kontrolleId: row['kontrolle_id'] as String,
|
||||
materialId: row['material_id'] as int,
|
||||
istmenge: row['istmenge'] as String,
|
||||
ablaufdatum: row['ablaufdatum'] as String?,
|
||||
chargennummer: row['chargennummer'] as String?,
|
||||
seriennummer: row['seriennummer'] as String?,
|
||||
status: row['status'] as String,
|
||||
fehlerText: row['fehler_text'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class OfflineQueue {
|
||||
OfflineQueue._();
|
||||
static final OfflineQueue instance = OfflineQueue._();
|
||||
|
||||
Database? _db;
|
||||
bool _synchronisierungLaeuft = false;
|
||||
|
||||
Future<Database> _database() async {
|
||||
final bestehend = _db;
|
||||
if (bestehend != null) return bestehend;
|
||||
final pfad = p.join(await getDatabasesPath(), 'mabea_offline_queue.db');
|
||||
final db = await openDatabase(
|
||||
pfad,
|
||||
version: 1,
|
||||
onCreate: (db, version) => db.execute('''
|
||||
CREATE TABLE queue (
|
||||
kontrolle_id TEXT NOT NULL,
|
||||
material_id INTEGER NOT NULL,
|
||||
istmenge TEXT NOT NULL,
|
||||
ablaufdatum TEXT,
|
||||
chargennummer TEXT,
|
||||
seriennummer TEXT,
|
||||
status TEXT NOT NULL,
|
||||
fehler_text TEXT,
|
||||
PRIMARY KEY (kontrolle_id, material_id)
|
||||
)
|
||||
'''),
|
||||
);
|
||||
_db = db;
|
||||
return db;
|
||||
}
|
||||
|
||||
Future<void> enqueue({
|
||||
required String kontrolleId,
|
||||
required int materialId,
|
||||
required String istmenge,
|
||||
String? ablaufdatum,
|
||||
String? chargennummer,
|
||||
String? seriennummer,
|
||||
}) async {
|
||||
final db = await _database();
|
||||
await db.insert(
|
||||
'queue',
|
||||
QueueItem(
|
||||
kontrolleId: kontrolleId,
|
||||
materialId: materialId,
|
||||
istmenge: istmenge,
|
||||
ablaufdatum: ablaufdatum,
|
||||
chargennummer: chargennummer,
|
||||
seriennummer: seriennummer,
|
||||
).toRow(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<QueueItem>> eintraegeFuerKontrolle(String kontrolleId) async {
|
||||
final db = await _database();
|
||||
final rows = await db.query('queue', where: 'kontrolle_id = ?', whereArgs: [kontrolleId]);
|
||||
return rows.map(QueueItem.fromRow).toList();
|
||||
}
|
||||
|
||||
Future<void> entferneFehlerEintrag(String kontrolleId, int materialId) async {
|
||||
final db = await _database();
|
||||
await db.delete(
|
||||
'queue',
|
||||
where: 'kontrolle_id = ? AND material_id = ?',
|
||||
whereArgs: [kontrolleId, materialId],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setzeStatus(
|
||||
String kontrolleId,
|
||||
int materialId,
|
||||
String status, {
|
||||
String? fehlerText,
|
||||
}) async {
|
||||
final db = await _database();
|
||||
await db.update(
|
||||
'queue',
|
||||
{'status': status, 'fehler_text': fehlerText},
|
||||
where: 'kontrolle_id = ? AND material_id = ?',
|
||||
whereArgs: [kontrolleId, materialId],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendeEintrag(ApiClient apiClient, QueueItem item) async {
|
||||
await _setzeStatus(item.kontrolleId, item.materialId, 'wird_uebertragen');
|
||||
try {
|
||||
await apiClient.request(
|
||||
'/kontrollen/${item.kontrolleId}/positionen/${item.materialId}',
|
||||
method: 'PUT',
|
||||
body: {
|
||||
'istmenge': item.istmenge,
|
||||
'ablaufdatum': item.ablaufdatum,
|
||||
'chargennummer': item.chargennummer,
|
||||
'seriennummer': item.seriennummer,
|
||||
},
|
||||
);
|
||||
// Idempotent (Backend-Upsert, siehe frontend/src/offline/queue.ts) - aus
|
||||
// Queue entfernen ist sicher, auch bei mehrfachem Sendeversuch vor Erfolg.
|
||||
final db = await _database();
|
||||
await db.delete(
|
||||
'queue',
|
||||
where: 'kontrolle_id = ? AND material_id = ?',
|
||||
whereArgs: [item.kontrolleId, item.materialId],
|
||||
);
|
||||
} on ApiException catch (error) {
|
||||
if (error.status >= 400 && error.status < 500) {
|
||||
await _setzeStatus(
|
||||
item.kontrolleId,
|
||||
item.materialId,
|
||||
'fehler',
|
||||
fehlerText: 'Server hat abgelehnt (${error.status}) - bitte Kontrolle neu prüfen.',
|
||||
);
|
||||
} else {
|
||||
await _setzeStatus(item.kontrolleId, item.materialId, 'wartet');
|
||||
}
|
||||
rethrow;
|
||||
} catch (error) {
|
||||
// Netzwerkfehler (kein ApiException) - bleibt "wartet", nächster Sync
|
||||
// versucht es erneut.
|
||||
await _setzeStatus(item.kontrolleId, item.materialId, 'wartet');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Wird bei Reconnect und periodisch aufgerufen. Bricht bei Netzwerkfehlern
|
||||
/// früh ab (weitere Versuche würden ohnehin scheitern, solange offline).
|
||||
Future<void> synchronisiere(ApiClient apiClient) async {
|
||||
if (_synchronisierungLaeuft) return;
|
||||
_synchronisierungLaeuft = true;
|
||||
try {
|
||||
final db = await _database();
|
||||
final rows = await db.query('queue');
|
||||
for (final row in rows) {
|
||||
final item = QueueItem.fromRow(row);
|
||||
if (item.status == 'fehler') continue; // braucht bewusste Nutzer-Aktion
|
||||
try {
|
||||
await _sendeEintrag(apiClient, item);
|
||||
} on ApiException {
|
||||
// Client-/Server-Fehler: schon in _sendeEintrag behandelt, weiter mit
|
||||
// dem nächsten Eintrag.
|
||||
} catch (_) {
|
||||
return; // vermutlich offline - restliche Queue in Ruhe lassen
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_synchronisierungLaeuft = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../api/api_client.dart';
|
||||
import '../offline/queue.dart';
|
||||
|
||||
/// Prompt 11 Screens 2-6 (kompakt). Erste Android-Fassung ohne Offline-Queue
|
||||
/// (siehe flutter_app/README.md) - Positionen werden direkt gesendet, bei
|
||||
/// Netzwerkfehler erscheint eine Fehlermeldung mit Wiederholen-Button statt
|
||||
/// automatischer Zwischenspeicherung wie in der React-PWA (Prompt 17).
|
||||
/// Prompt 11 Screens 2-6 + Prompt 17 Offline-Härtung: Eingaben laufen über
|
||||
/// OfflineQueue (sqflite), nie direkt blockierend - gleiches Muster wie die
|
||||
/// React-PWA (frontend/src/pages/kontrolle/useKontrolle.ts).
|
||||
class KontrollScreen extends StatefulWidget {
|
||||
const KontrollScreen({super.key, required this.apiClient, required this.objektId});
|
||||
final ApiClient apiClient;
|
||||
@@ -19,7 +22,7 @@ class _PositionZustand {
|
||||
_PositionZustand({required this.position, required this.material});
|
||||
final Map<String, dynamic> position;
|
||||
final Map<String, dynamic>? material;
|
||||
String status = 'unbestaetigt'; // unbestaetigt | wird_gesendet | gespeichert | fehler
|
||||
String status = 'unbestaetigt'; // unbestaetigt | wartet | wird_uebertragen | gespeichert | fehler
|
||||
String? fehlerText;
|
||||
late final TextEditingController eingabeController =
|
||||
TextEditingController(text: position['sollmenge_effektiv'] as String);
|
||||
@@ -33,12 +36,42 @@ class _KontrollScreenState extends State<KontrollScreen> {
|
||||
String? _startFehler;
|
||||
String? _abschlussFehler;
|
||||
bool _wirdAbgeschlossen = false;
|
||||
bool _online = true;
|
||||
|
||||
final _connectivity = Connectivity();
|
||||
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
|
||||
Timer? _reconciliationTimer;
|
||||
Timer? _periodischerSyncTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ladeObjekt();
|
||||
_starteKontrolle(false);
|
||||
_connectivity.checkConnectivity().then((ergebnis) {
|
||||
if (mounted) setState(() => _online = !ergebnis.contains(ConnectivityResult.none));
|
||||
});
|
||||
_connectivitySub = _connectivity.onConnectivityChanged.listen((ergebnis) {
|
||||
final online = !ergebnis.contains(ConnectivityResult.none);
|
||||
if (online && !_online) {
|
||||
unawaited(OfflineQueue.instance.synchronisiere(widget.apiClient));
|
||||
}
|
||||
if (mounted) setState(() => _online = online);
|
||||
});
|
||||
// Wie in der PWA (useOnlineStatus, 15s-Intervall): "online" laut System
|
||||
// heißt nicht zwingend, dass der Backend-Server erreichbar ist - der
|
||||
// periodische Sync-Versuch ist der eigentliche verlässliche Trigger.
|
||||
_periodischerSyncTimer = Timer.periodic(const Duration(seconds: 15), (_) {
|
||||
unawaited(OfflineQueue.instance.synchronisiere(widget.apiClient));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_connectivitySub?.cancel();
|
||||
_reconciliationTimer?.cancel();
|
||||
_periodischerSyncTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _ladeObjekt() async {
|
||||
@@ -59,6 +92,7 @@ class _KontrollScreenState extends State<KontrollScreen> {
|
||||
);
|
||||
setState(() => _kontrolle = kontrolle as Map<String, dynamic>);
|
||||
await _ladePositionen();
|
||||
_starteReconciliationLoop();
|
||||
} on ApiException catch (error) {
|
||||
if (error.status == 409) {
|
||||
setState(() => _sperre = error.detail as Map<String, dynamic>?);
|
||||
@@ -88,26 +122,53 @@ class _KontrollScreenState extends State<KontrollScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Gleicht den Anzeigezustand mit der Offline-Queue ab (Muster: PWA-
|
||||
/// useKontrolle-Reconciliation-Loop). Ein Eintrag, der aus der Queue
|
||||
/// verschwunden ist, wurde erfolgreich übertragen.
|
||||
void _starteReconciliationLoop() {
|
||||
_reconciliationTimer?.cancel();
|
||||
_reconciliationTimer = Timer.periodic(const Duration(seconds: 2), (_) async {
|
||||
final kontrolle = _kontrolle;
|
||||
if (kontrolle == null) return;
|
||||
final eintraege = await OfflineQueue.instance.eintraegeFuerKontrolle(kontrolle['id'] as String);
|
||||
final byMaterial = {for (final e in eintraege) e.materialId: e};
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
for (final zustand in _positionen) {
|
||||
final materialId = zustand.position['material_id'] as int;
|
||||
final inQueue = byMaterial[materialId];
|
||||
if (inQueue != null) {
|
||||
zustand.status = inQueue.status;
|
||||
zustand.fehlerText = inQueue.fehlerText;
|
||||
} else if (zustand.status == 'wartet' || zustand.status == 'wird_uebertragen') {
|
||||
zustand.status = 'gespeichert';
|
||||
zustand.fehlerText = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _positionSenden(_PositionZustand zustand) async {
|
||||
final kontrolle = _kontrolle;
|
||||
if (kontrolle == null) return;
|
||||
setState(() {
|
||||
zustand.status = 'wird_gesendet';
|
||||
zustand.fehlerText = null;
|
||||
});
|
||||
try {
|
||||
await widget.apiClient.request(
|
||||
'/kontrollen/${kontrolle['id']}/positionen/${zustand.position['material_id']}',
|
||||
method: 'PUT',
|
||||
body: {'istmenge': zustand.eingabeController.text},
|
||||
);
|
||||
setState(() => zustand.status = 'gespeichert');
|
||||
} catch (_) {
|
||||
setState(() {
|
||||
zustand.status = 'fehler';
|
||||
zustand.fehlerText = 'Senden fehlgeschlagen - erneut versuchen, sobald wieder Verbindung besteht.';
|
||||
});
|
||||
}
|
||||
await OfflineQueue.instance.enqueue(
|
||||
kontrolleId: kontrolle['id'] as String,
|
||||
materialId: zustand.position['material_id'] as int,
|
||||
istmenge: zustand.eingabeController.text,
|
||||
);
|
||||
setState(() => zustand.status = 'wartet');
|
||||
unawaited(OfflineQueue.instance.synchronisiere(widget.apiClient));
|
||||
}
|
||||
|
||||
Future<void> _erneutVersuchen(_PositionZustand zustand) async {
|
||||
final kontrolle = _kontrolle;
|
||||
if (kontrolle == null) return;
|
||||
await OfflineQueue.instance.entferneFehlerEintrag(
|
||||
kontrolle['id'] as String,
|
||||
zustand.position['material_id'] as int,
|
||||
);
|
||||
await _positionSenden(zustand);
|
||||
}
|
||||
|
||||
bool get _alleUebertragen =>
|
||||
@@ -184,55 +245,80 @@ class _KontrollScreenState extends State<KontrollScreen> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text(_objekt?['name'] as String? ?? 'Kontrolle')),
|
||||
body: ListView.builder(
|
||||
itemCount: _positionen.length,
|
||||
itemBuilder: (context, index) {
|
||||
final zustand = _positionen[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
zustand.material?['name'] as String? ??
|
||||
'Material ${zustand.position['material_id']}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
_StatusChip(status: zustand.status),
|
||||
],
|
||||
),
|
||||
Text('Soll: ${zustand.position['sollmenge_effektiv']} ${zustand.material?['einheit'] ?? ''}'),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: zustand.eingabeController,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: () => _positionSenden(zustand),
|
||||
child: const Text('Bestätigen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (zustand.status == 'fehler' && zustand.fehlerText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(zustand.fehlerText!, style: const TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
body: Column(
|
||||
children: [
|
||||
if (!_online)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Colors.amber.shade100,
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: const Text(
|
||||
'Kein Netz – Eingaben werden lokal gespeichert und automatisch übertragen, '
|
||||
'sobald wieder Verbindung besteht.',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _positionen.length,
|
||||
itemBuilder: (context, index) {
|
||||
final zustand = _positionen[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
zustand.material?['name'] as String? ??
|
||||
'Material ${zustand.position['material_id']}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
_StatusChip(status: zustand.status),
|
||||
],
|
||||
),
|
||||
Text('Soll: ${zustand.position['sollmenge_effektiv']} ${zustand.material?['einheit'] ?? ''}'),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: zustand.eingabeController,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
onPressed: () => _positionSenden(zustand),
|
||||
child: const Text('Bestätigen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (zustand.status == 'fehler' && zustand.fehlerText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(zustand.fehlerText!, style: const TextStyle(color: Colors.red)),
|
||||
TextButton(
|
||||
onPressed: () => _erneutVersuchen(zustand),
|
||||
child: const Text('Erneut versuchen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
@@ -266,7 +352,8 @@ class _StatusChip extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final konfiguration = {
|
||||
'unbestaetigt': ('offen', Colors.grey),
|
||||
'wird_gesendet': ('wird gesendet…', Colors.orange),
|
||||
'wartet': ('nicht gespeichert', Colors.orange),
|
||||
'wird_uebertragen': ('wird übertragen…', Colors.orange),
|
||||
'gespeichert': ('gespeichert', Colors.green),
|
||||
'fehler': ('Fehler', Colors.red),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user