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 createState() => _KontrollScreenState(); } class _PositionZustand { _PositionZustand({required this.position, required this.material}); final Map position; final Map? 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 { Map? _objekt; Map? _kontrolle; Map? _sperre; List<_PositionZustand> _positionen = []; 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 { final objekt = await widget.apiClient.request('/objekte/${widget.objektId}'); if (mounted) setState(() => _objekt = objekt as Map); } Future _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); await _ladePositionen(); _starteReconciliationLoop(); } on ApiException catch (error) { if (error.status == 409) { setState(() => _sperre = error.detail as Map?); } else { setState(() => _startFehler = 'Kontrolle konnte nicht gestartet werden.'); } } catch (_) { setState(() => _startFehler = 'Keine Verbindung.'); } } Future _ladePositionen() async { final kontrolle = _kontrolle; if (kontrolle == null) return; final positionen = await widget.apiClient .request('/objekte/${kontrolle['objekt_id']}/positionen') as List; final materialien = await widget.apiClient.request('/materialien') as List; final materialById = {for (final m in materialien) m['id'] as int: m as Map}; setState(() { _positionen = positionen .cast>() .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 _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 _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 _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 _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)); } }