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
+157 -70
View File
@@ -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),
};