From c68d39b45f5bfe968e570fd932093e77f7f0aeb0 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 4 Sep 2026 18:36:10 +0200 Subject: [PATCH] =?UTF-8?q?Flutter:=20Offline-Queue=20f=C3=BCr=20Kontroll-?= =?UTF-8?q?Erfassung=20(Prompt=2017-H=C3=A4rtung)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV --- DEVLOG.md | 30 +++ flutter_app/DEVLOG.md | 15 ++ flutter_app/README.md | 18 +- flutter_app/lib/offline/queue.dart | 205 +++++++++++++++++ flutter_app/lib/screens/kontroll_screen.dart | 227 +++++++++++++------ flutter_app/pubspec.yaml | 3 + 6 files changed, 421 insertions(+), 77 deletions(-) create mode 100644 flutter_app/lib/offline/queue.dart diff --git a/DEVLOG.md b/DEVLOG.md index 9450f50..27cf7a4 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2753,3 +2753,33 @@ Keine Commits in dieser Session. - frontend/playwright.config.ts | 21 ++++++++ --- +## 2026-09-04 18:27 – 18:29 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +- c71df1a Karte 12: systemd-Timer für den täglichen Eskalations-Prüflauf + +### Geänderte Dateien +- DEVLOG.md | 63 ++++++++++++++++++++++++++++++++ +- backend/scripts/eskalation_pruefen.py | 37 +++++++++++++++++++ +- deploy/README.md | 16 ++++++++ +- deploy/mabea-eskalation.service.template | 17 +++++++++ +- deploy/mabea-eskalation.timer.template | 12 ++++++ + +--- +## 2026-09-04 18:33 – 18:33 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 63 ++++++++++++++++++++++++++++++++ +- backend/scripts/eskalation_pruefen.py | 37 +++++++++++++++++++ +- deploy/README.md | 16 ++++++++ +- deploy/mabea-eskalation.service.template | 17 +++++++++ +- deploy/mabea-eskalation.timer.template | 12 ++++++ + +--- diff --git a/flutter_app/DEVLOG.md b/flutter_app/DEVLOG.md index a99b35b..100bd4d 100644 --- a/flutter_app/DEVLOG.md +++ b/flutter_app/DEVLOG.md @@ -16,3 +16,18 @@ Keine Commits in dieser Session. - frontend/src/styles/global.css | 24 +++++++++++ --- +## 2026-09-04 18:33 – 18:35 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 63 ++++++++++++++++++++++++++++++++ +- backend/scripts/eskalation_pruefen.py | 37 +++++++++++++++++++ +- deploy/README.md | 16 ++++++++ +- deploy/mabea-eskalation.service.template | 17 +++++++++ +- deploy/mabea-eskalation.timer.template | 12 ++++++ + +--- diff --git a/flutter_app/README.md b/flutter_app/README.md index 3007136..9374529 100644 --- a/flutter_app/README.md +++ b/flutter_app/README.md @@ -2,7 +2,8 @@ Zweiter Client derselben API (Prompt 19 API-first-Prinzip) neben der React-PWA (`frontend/`). Deckt Login, Objektliste, Kontroll-Screen (Start/Sperre-Übernahme/ -Positionen/Abschluss/Abbruch) ab. +Positionen/Abschluss/Abbruch) sowie eine Offline-Queue (Prompt 17-Härtung, +`lib/offline/queue.dart`, sqflite) ab. ## Wichtig: Plattform-Ordner fehlen noch @@ -44,13 +45,16 @@ Für iOS entsprechend in `ios/Runner/Info.plist`: Wird zum Scannen von Objekt-/Geräte-Codes benötigt. ``` +## Offline-Queue (Prompt 17-Härtung) + +`lib/offline/queue.dart`: Positionen werden lokal in sqflite zwischengespeichert +(`enqueue`), Status je Eintrag (`wartet`/`wird_uebertragen`/`fehler`), Sync bei +Reconnect (`connectivity_plus`) und periodisch alle 15s - gleiches Muster wie +`frontend/src/offline/queue.ts` (IndexedDB), nur die lokale DB ist anders. +`KontrollScreen` gleicht per 2s-Reconciliation-Loop den Anzeigezustand mit der +Queue ab, Abschluss ist gesperrt, solange Einträge offen sind. + ## Bewusst noch nicht umgesetzt (Unterschied zur React-PWA) -- **Keine Offline-Queue**: anders als die PWA (`frontend/src/offline/queue.ts`, - IndexedDB) sendet diese erste Fassung Positionen direkt; bei Netzwerkfehler - erscheint nur eine "erneut versuchen"-Meldung, keine automatische - Zwischenspeicherung/Sync bei Reconnect. Nachziehen, sobald die App über das - Grundgerüst hinauswächst - gleiches Muster wie in der PWA übertragbar - (z. B. `sqflite` oder `hive` statt IndexedDB). - Keine Tests (Flutter-Widget-/Integrationstests) - Sprintplan sieht Testphase 4 primär für die PWA vor (Playwright); Android-Testabdeckung ist noch offen. diff --git a/flutter_app/lib/offline/queue.dart b/flutter_app/lib/offline/queue.dart new file mode 100644 index 0000000..eb95e6b --- /dev/null +++ b/flutter_app/lib/offline/queue.dart @@ -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 toRow() => { + 'kontrolle_id': kontrolleId, + 'material_id': materialId, + 'istmenge': istmenge, + 'ablaufdatum': ablaufdatum, + 'chargennummer': chargennummer, + 'seriennummer': seriennummer, + 'status': status, + 'fehler_text': fehlerText, + }; + + factory QueueItem.fromRow(Map 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() 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 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> 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 entferneFehlerEintrag(String kontrolleId, int materialId) async { + final db = await _database(); + await db.delete( + 'queue', + where: 'kontrolle_id = ? AND material_id = ?', + whereArgs: [kontrolleId, materialId], + ); + } + + Future _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 _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 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; + } + } +} diff --git a/flutter_app/lib/screens/kontroll_screen.dart b/flutter_app/lib/screens/kontroll_screen.dart index 89976ab..f5b7247 100644 --- a/flutter_app/lib/screens/kontroll_screen.dart +++ b/flutter_app/lib/screens/kontroll_screen.dart @@ -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 position; final Map? 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 { String? _startFehler; String? _abschlussFehler; bool _wirdAbgeschlossen = false; + bool _online = true; + + final _connectivity = Connectivity(); + StreamSubscription>? _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 _ladeObjekt() async { @@ -59,6 +92,7 @@ class _KontrollScreenState extends State { ); setState(() => _kontrolle = kontrolle as Map); await _ladePositionen(); + _starteReconciliationLoop(); } on ApiException catch (error) { if (error.status == 409) { setState(() => _sperre = error.detail as Map?); @@ -88,26 +122,53 @@ class _KontrollScreenState extends State { }); } + /// 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 _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 _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 { 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), }; diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 8bb75c4..a5b62bf 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -12,6 +12,9 @@ dependencies: http: ^1.2.2 shared_preferences: ^2.3.2 mobile_scanner: ^5.2.3 + sqflite: ^2.3.3 + path: ^1.9.0 + connectivity_plus: ^6.0.5 dev_dependencies: flutter_test: