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
364 lines
13 KiB
Dart
364 lines
13 KiB
Dart
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));
|
||
}
|
||
}
|