Flutter: Offline-Queue für Kontroll-Erfassung (Prompt 17-Härtung)
CI / backend-tests (push) Successful in 1m5s
CI / frontend-build (push) Successful in 22s

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:
2026-09-04 18:36:10 +02:00
co-authored by Claude Sonnet 5
parent c71df1ac4d
commit c68d39b45f
6 changed files with 421 additions and 77 deletions
+30
View File
@@ -2753,3 +2753,33 @@ Keine Commits in dieser Session.
- frontend/playwright.config.ts | 21 ++++++++ - 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 ++++++
---
+15
View File
@@ -16,3 +16,18 @@ Keine Commits in dieser Session.
- frontend/src/styles/global.css | 24 +++++++++++ - 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 ++++++
---
+11 -7
View File
@@ -2,7 +2,8 @@
Zweiter Client derselben API (Prompt 19 API-first-Prinzip) neben der React-PWA Zweiter Client derselben API (Prompt 19 API-first-Prinzip) neben der React-PWA
(`frontend/`). Deckt Login, Objektliste, Kontroll-Screen (Start/Sperre-Übernahme/ (`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 ## Wichtig: Plattform-Ordner fehlen noch
@@ -44,13 +45,16 @@ Für iOS entsprechend in `ios/Runner/Info.plist`:
<string>Wird zum Scannen von Objekt-/Geräte-Codes benötigt.</string> <string>Wird zum Scannen von Objekt-/Geräte-Codes benötigt.</string>
``` ```
## 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) ## 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 - Keine Tests (Flutter-Widget-/Integrationstests) - Sprintplan sieht Testphase 4
primär für die PWA vor (Playwright); Android-Testabdeckung ist noch offen. primär für die PWA vor (Playwright); Android-Testabdeckung ist noch offen.
+205
View File
@@ -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;
}
}
}
+110 -23
View File
@@ -1,11 +1,14 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../api/api_client.dart'; import '../api/api_client.dart';
import '../offline/queue.dart';
/// Prompt 11 Screens 2-6 (kompakt). Erste Android-Fassung ohne Offline-Queue /// Prompt 11 Screens 2-6 + Prompt 17 Offline-Härtung: Eingaben laufen über
/// (siehe flutter_app/README.md) - Positionen werden direkt gesendet, bei /// OfflineQueue (sqflite), nie direkt blockierend - gleiches Muster wie die
/// Netzwerkfehler erscheint eine Fehlermeldung mit Wiederholen-Button statt /// React-PWA (frontend/src/pages/kontrolle/useKontrolle.ts).
/// automatischer Zwischenspeicherung wie in der React-PWA (Prompt 17).
class KontrollScreen extends StatefulWidget { class KontrollScreen extends StatefulWidget {
const KontrollScreen({super.key, required this.apiClient, required this.objektId}); const KontrollScreen({super.key, required this.apiClient, required this.objektId});
final ApiClient apiClient; final ApiClient apiClient;
@@ -19,7 +22,7 @@ class _PositionZustand {
_PositionZustand({required this.position, required this.material}); _PositionZustand({required this.position, required this.material});
final Map<String, dynamic> position; final Map<String, dynamic> position;
final Map<String, dynamic>? material; final Map<String, dynamic>? material;
String status = 'unbestaetigt'; // unbestaetigt | wird_gesendet | gespeichert | fehler String status = 'unbestaetigt'; // unbestaetigt | wartet | wird_uebertragen | gespeichert | fehler
String? fehlerText; String? fehlerText;
late final TextEditingController eingabeController = late final TextEditingController eingabeController =
TextEditingController(text: position['sollmenge_effektiv'] as String); TextEditingController(text: position['sollmenge_effektiv'] as String);
@@ -33,12 +36,42 @@ class _KontrollScreenState extends State<KontrollScreen> {
String? _startFehler; String? _startFehler;
String? _abschlussFehler; String? _abschlussFehler;
bool _wirdAbgeschlossen = false; bool _wirdAbgeschlossen = false;
bool _online = true;
final _connectivity = Connectivity();
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
Timer? _reconciliationTimer;
Timer? _periodischerSyncTimer;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_ladeObjekt(); _ladeObjekt();
_starteKontrolle(false); _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 { Future<void> _ladeObjekt() async {
@@ -59,6 +92,7 @@ class _KontrollScreenState extends State<KontrollScreen> {
); );
setState(() => _kontrolle = kontrolle as Map<String, dynamic>); setState(() => _kontrolle = kontrolle as Map<String, dynamic>);
await _ladePositionen(); await _ladePositionen();
_starteReconciliationLoop();
} on ApiException catch (error) { } on ApiException catch (error) {
if (error.status == 409) { if (error.status == 409) {
setState(() => _sperre = error.detail as Map<String, dynamic>?); 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 { Future<void> _positionSenden(_PositionZustand zustand) async {
final kontrolle = _kontrolle; final kontrolle = _kontrolle;
if (kontrolle == null) return; if (kontrolle == null) return;
setState(() { await OfflineQueue.instance.enqueue(
zustand.status = 'wird_gesendet'; kontrolleId: kontrolle['id'] as String,
zustand.fehlerText = null; materialId: zustand.position['material_id'] as int,
}); istmenge: zustand.eingabeController.text,
try {
await widget.apiClient.request(
'/kontrollen/${kontrolle['id']}/positionen/${zustand.position['material_id']}',
method: 'PUT',
body: {'istmenge': zustand.eingabeController.text},
); );
setState(() => zustand.status = 'gespeichert'); setState(() => zustand.status = 'wartet');
} catch (_) { unawaited(OfflineQueue.instance.synchronisiere(widget.apiClient));
setState(() {
zustand.status = 'fehler';
zustand.fehlerText = 'Senden fehlgeschlagen - erneut versuchen, sobald wieder Verbindung besteht.';
});
} }
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 => bool get _alleUebertragen =>
@@ -184,7 +245,20 @@ class _KontrollScreenState extends State<KontrollScreen> {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(_objekt?['name'] as String? ?? 'Kontrolle')), appBar: AppBar(title: Text(_objekt?['name'] as String? ?? 'Kontrolle')),
body: ListView.builder( 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, itemCount: _positionen.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final zustand = _positionen[index]; final zustand = _positionen[index];
@@ -226,7 +300,16 @@ class _KontrollScreenState extends State<KontrollScreen> {
if (zustand.status == 'fehler' && zustand.fehlerText != null) if (zustand.status == 'fehler' && zustand.fehlerText != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Text(zustand.fehlerText!, style: const TextStyle(color: Colors.red)), child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(zustand.fehlerText!, style: const TextStyle(color: Colors.red)),
TextButton(
onPressed: () => _erneutVersuchen(zustand),
child: const Text('Erneut versuchen'),
),
],
),
), ),
], ],
), ),
@@ -234,6 +317,9 @@ class _KontrollScreenState extends State<KontrollScreen> {
); );
}, },
), ),
),
],
),
bottomNavigationBar: SafeArea( bottomNavigationBar: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@@ -266,7 +352,8 @@ class _StatusChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final konfiguration = { final konfiguration = {
'unbestaetigt': ('offen', Colors.grey), '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), 'gespeichert': ('gespeichert', Colors.green),
'fehler': ('Fehler', Colors.red), 'fehler': ('Fehler', Colors.red),
}; };
+3
View File
@@ -12,6 +12,9 @@ dependencies:
http: ^1.2.2 http: ^1.2.2
shared_preferences: ^2.3.2 shared_preferences: ^2.3.2
mobile_scanner: ^5.2.3 mobile_scanner: ^5.2.3
sqflite: ^2.3.3
path: ^1.9.0
connectivity_plus: ^6.0.5
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: