Files
MABEA/flutter_app/lib/screens/kontroll_screen.dart
T
patrickandClaude Sonnet 5 564e98e5c6
CI / backend-tests (push) Successful in 54s
Sprint 7: React-PWA-Frontend (Offline-Härtung) + Flutter-Android-Grundgerüst
React/Vite-PWA (frontend/):
- Login, Objektliste (Prompt 11 Screen 1), Kontroll-Screen (Screens 2-6 kompakt:
  Start/Objekt-Sperre-Übernahme, Positionserfassung, Abschluss/Abbruch)
- Offline-Härtung (Prompt 17): IndexedDB-Queue (src/offline/queue.ts) puffert
  Positions-PUTs lokal, automatische Übertragung bei Reconnect + periodischem
  Sync-Versuch, Statusanzeige je Position (nicht gespeichert/wird übertragen/
  gespeichert/Fehler), Abschluss-Button bleibt gesperrt bis alles gespeichert
- Nutzt aus, dass PUT /kontrollen/{id}/positionen/{material_id} backend-seitig
  idempotent ist (Upsert) - Queue kann beliebig oft retryen ohne Duplikate
- Service Worker via vite-plugin-pwa für App-Shell-Caching (Start im Feld ohne
  Cold-Load); PWA-Icons als TODO vermerkt (noch keine echten Bilddateien)
- Client-Fehler (4xx) werden nicht automatisch wiederholt, nur Netzwerkfehler

Flutter-Android-Grundgerüst (flutter_app/), auf Nutzerwunsch parallel begonnen:
- Gleiche API als zweiter Client (Prompt 19 API-first), Login/Objektliste/
  Kontroll-Screen als Dart-Äquivalent zur PWA
- Bewusst OHNE Offline-Queue in dieser ersten Fassung (siehe flutter_app/README.md)
- Plattform-Ordner (android/, ios/) nicht von Hand erzeugt - müssen auf dem
  Zielsystem per `flutter create .` nachgezogen werden, sonst zu fehleranfällig
  ohne Testlauf

Offen: Playwright-E2E (Testphase 4), Praktiker-Session, PWA-Icons, Flutter-
Offline-Queue - brauchen laufendes Deployment bzw. sind kein reiner Code-Schritt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
2026-09-04 00:10:26 +02:00

277 lines
9.5 KiB
Dart

import 'package:flutter/material.dart';
import '../api/api_client.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).
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 | wird_gesendet | 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;
@override
void initState() {
super.initState();
_ladeObjekt();
_starteKontrolle(false);
}
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();
} 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();
});
}
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.';
});
}
}
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: 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)),
),
],
),
),
);
},
),
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),
'wird_gesendet': ('wird gesendet…', 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));
}
}