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: