Files
patrickandClaude Sonnet 5 c68d39b45f
CI / backend-tests (push) Successful in 1m5s
CI / frontend-build (push) Successful in 22s
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
2026-09-04 18:36:10 +02:00

364 lines
13 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 + 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;
final int objektId;
@override
State<KontrollScreen> createState() => _KontrollScreenState();
}
class _PositionZustand {
_PositionZustand({required this.position, required this.material});
final Map<String, dynamic> position;
final Map<String, dynamic>? material;
String status = 'unbestaetigt'; // unbestaetigt | wartet | wird_uebertragen | gespeichert | fehler
String? fehlerText;
late final TextEditingController eingabeController =
TextEditingController(text: position['sollmenge_effektiv'] as String);
}
class _KontrollScreenState extends State<KontrollScreen> {
Map<String, dynamic>? _objekt;
Map<String, dynamic>? _kontrolle;
Map<String, dynamic>? _sperre;
List<_PositionZustand> _positionen = [];
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 {
final objekt = await widget.apiClient.request('/objekte/${widget.objektId}');
if (mounted) setState(() => _objekt = objekt as Map<String, dynamic>);
}
Future<void> _starteKontrolle(bool uebernehmen) async {
setState(() {
_startFehler = null;
_sperre = null;
});
try {
final kontrolle = await widget.apiClient.request(
'/objekte/${widget.objektId}/kontrollen',
method: 'POST',
body: {'uebernehmen': uebernehmen},
);
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>?);
} else {
setState(() => _startFehler = 'Kontrolle konnte nicht gestartet werden.');
}
} catch (_) {
setState(() => _startFehler = 'Keine Verbindung.');
}
}
Future<void> _ladePositionen() async {
final kontrolle = _kontrolle;
if (kontrolle == null) return;
final positionen = await widget.apiClient
.request('/objekte/${kontrolle['objekt_id']}/positionen') as List<dynamic>;
final materialien =
await widget.apiClient.request('/materialien') as List<dynamic>;
final materialById = {for (final m in materialien) m['id'] as int: m as Map<String, dynamic>};
setState(() {
_positionen = positionen
.cast<Map<String, dynamic>>()
.where((p) => p['ist_status'] == 'aktiv')
.map((p) => _PositionZustand(position: p, material: materialById[p['material_id']]))
.toList();
});
}
/// 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;
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 =>
_positionen.isNotEmpty && _positionen.every((p) => p.status == 'gespeichert');
Future<void> _abschliessen() async {
final kontrolle = _kontrolle;
if (kontrolle == null) return;
setState(() {
_wirdAbgeschlossen = true;
_abschlussFehler = null;
});
try {
await widget.apiClient.request('/kontrollen/${kontrolle['id']}/abschliessen', method: 'POST');
if (mounted) Navigator.of(context).pop();
} on ApiException catch (error) {
setState(() {
_abschlussFehler = error.status == 409
? 'Nicht alle Positionen wurden erfasst.'
: 'Abschluss fehlgeschlagen.';
});
} finally {
if (mounted) setState(() => _wirdAbgeschlossen = false);
}
}
Future<void> _abbrechen() async {
final kontrolle = _kontrolle;
if (kontrolle == null) return;
await widget.apiClient
.request('/kontrollen/${kontrolle['id']}/abbrechen', method: 'POST', body: {});
if (mounted) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
if (_sperre != null) {
return Scaffold(
appBar: AppBar(title: const Text('Objekt gesperrt')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Wird gerade kontrolliert von ${_sperre!['benutzer'] ?? 'unbekannt'}, '
'seit ${_sperre!['seit'] ?? ''}.',
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => _starteKontrolle(true),
child: const Text('Trotzdem übernehmen'),
),
],
),
),
);
}
if (_startFehler != null) {
return Scaffold(
appBar: AppBar(title: const Text('Fehler')),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_startFehler!, style: const TextStyle(color: Colors.red)),
TextButton(onPressed: () => _starteKontrolle(false), child: const Text('Erneut versuchen')),
],
),
),
);
}
return Scaffold(
appBar: AppBar(title: Text(_objekt?['name'] as String? ?? 'Kontrolle')),
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(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_abschlussFehler != null)
Text(_abschlussFehler!, style: const TextStyle(color: Colors.red)),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _alleUebertragen && !_wirdAbgeschlossen ? _abschliessen : null,
child: Text(_alleUebertragen ? 'Kontrolle abschließen' : 'Noch nicht alle Positionen gespeichert…'),
),
),
TextButton(onPressed: _abbrechen, child: const Text('Kontrolle abbrechen')),
],
),
),
),
);
}
}
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.status});
final String status;
@override
Widget build(BuildContext context) {
final konfiguration = {
'unbestaetigt': ('offen', Colors.grey),
'wartet': ('nicht gespeichert', Colors.orange),
'wird_uebertragen': ('wird übertragen…', Colors.orange),
'gespeichert': ('gespeichert', Colors.green),
'fehler': ('Fehler', Colors.red),
};
final (text, farbe) = konfiguration[status] ?? ('?', Colors.grey);
return Text(text, style: TextStyle(color: farbe, fontWeight: FontWeight.bold));
}
}