Sprint 7: React-PWA-Frontend (Offline-Härtung) + Flutter-Android-Grundgerüst
CI / backend-tests (push) Successful in 54s

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
This commit is contained in:
2026-09-04 00:10:26 +02:00
co-authored by Claude Sonnet 5
parent aba991bbe2
commit 564e98e5c6
28 changed files with 1624 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
.dart_tool/
.packages
build/
.flutter-plugins
.flutter-plugins-dependencies
android/
ios/
*.iml
+40
View File
@@ -0,0 +1,40 @@
# MABEA Android-App (Flutter) erstes Grundgerüst
Zweiter Client derselben API (Prompt 19 API-first-Prinzip) neben der React-PWA
(`frontend/`). Deckt Login, Objektliste, Kontroll-Screen (Start/Sperre-Übernahme/
Positionen/Abschluss/Abbruch) ab.
## Wichtig: Plattform-Ordner fehlen noch
Dieses Verzeichnis enthält nur `lib/`, `pubspec.yaml`, `analysis_options.yaml` -
die von `flutter create` generierten Plattform-Ordner (`android/`, `ios/`, Gradle-
Konfiguration etc.) wurden bewusst NICHT von Hand nachgebaut, da das ohne
Testlauf zu fehleranfällig wäre. Erstinbetriebnahme auf dem Zielsystem (mit
Flutter-SDK installiert):
```bash
cd flutter_app
flutter create . --platforms android --org de.perlbach24 --project-name mabea_app
# überschreibt pubspec.yaml NICHT automatisch mit *nix cp -n, ggf. lib/-Inhalt
# und pubspec.yaml-Dependencies danach gegen diese Version abgleichen
flutter pub get
flutter build apk
```
## API-Adresse
`lib/main.dart`: `kApiBaseUrl` ist aktuell fest auf den Deployment-Server
(`192.168.1.238`) gesetzt. Für Produktivbetrieb auf `--dart-define` umstellen
oder zumindest vor jedem Build prüfen/anpassen.
## Bewusst noch nicht umgesetzt (Unterschied zur React-PWA)
- **Keine Offline-Queue**: anders als die PWA (`frontend/src/offline/queue.ts`,
IndexedDB) sendet diese erste Fassung Positionen direkt; bei Netzwerkfehler
erscheint nur eine "erneut versuchen"-Meldung, keine automatische
Zwischenspeicherung/Sync bei Reconnect. Nachziehen, sobald die App über das
Grundgerüst hinauswächst - gleiches Muster wie in der PWA übertragbar
(z. B. `sqflite` oder `hive` statt IndexedDB).
- Kein QR-/Barcode-Kamera-Scan (Karte 10, Roadmap).
- Keine Tests (Flutter-Widget-/Integrationstests) - Sprintplan sieht Testphase 4
primär für die PWA vor (Playwright); Android-Testabdeckung ist noch offen.
+1
View File
@@ -0,0 +1 @@
include: package:flutter_lints/flutter.yaml
+101
View File
@@ -0,0 +1,101 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
/// Gleiche Basis-Idee wie im React-Client (frontend/src/api/client.ts):
/// gemeinsamer JSON-Wrapper, Token aus persistentem Speicher, ApiException
/// mit HTTP-Status, damit aufrufender Code gezielt reagieren kann (z.B. 409
/// bei Objekt-Sperre).
class ApiException implements Exception {
final int status;
final dynamic detail;
ApiException(this.status, this.detail);
@override
String toString() => 'ApiException($status, $detail)';
}
class ApiClient {
ApiClient({required this.baseUrl});
final String baseUrl;
String? _token;
Future<void> ladeToken() async {
final prefs = await SharedPreferences.getInstance();
_token = prefs.getString('mabea_token');
}
Future<void> setzeToken(String? token) async {
_token = token;
final prefs = await SharedPreferences.getInstance();
if (token == null) {
await prefs.remove('mabea_token');
} else {
await prefs.setString('mabea_token', token);
}
}
bool get istEingeloggt => _token != null;
Future<String> login(String username, String password) async {
final response = await http.post(
Uri.parse('$baseUrl/auth/login'),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {'username': username, 'password': password},
);
if (response.statusCode != 200) {
throw ApiException(response.statusCode, _tryDecode(response.body));
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final token = data['access_token'] as String;
await setzeToken(token);
return token;
}
Future<dynamic> request(
String path, {
String method = 'GET',
Object? body,
}) async {
final uri = Uri.parse('$baseUrl$path');
final headers = {
'Content-Type': 'application/json',
if (_token != null) 'Authorization': 'Bearer $_token',
};
final encodedBody = body != null ? jsonEncode(body) : null;
late http.Response response;
switch (method) {
case 'POST':
response = await http.post(uri, headers: headers, body: encodedBody);
break;
case 'PUT':
response = await http.put(uri, headers: headers, body: encodedBody);
break;
case 'PATCH':
response = await http.patch(uri, headers: headers, body: encodedBody);
break;
case 'DELETE':
response = await http.delete(uri, headers: headers);
break;
default:
response = await http.get(uri, headers: headers);
}
if (response.statusCode >= 400) {
throw ApiException(response.statusCode, _tryDecode(response.body));
}
if (response.body.isEmpty) return null;
return jsonDecode(response.body);
}
dynamic _tryDecode(String body) {
try {
return jsonDecode(body);
} catch (_) {
return null;
}
}
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'api/api_client.dart';
import 'screens/login_screen.dart';
import 'screens/objekt_list_screen.dart';
// API-Basis-URL fest im Code statt .env: Flutter-Umgebungsvariablen brauchen
// --dart-define beim Build, unpraktisch für den ersten Wurf. Vor Produktivbetrieb
// hier auf die echte Backend-Adresse setzen bzw. auf --dart-define umstellen.
const String kApiBaseUrl = 'http://192.168.1.238/api/v1';
void main() {
runApp(const MabeaApp());
}
class MabeaApp extends StatefulWidget {
const MabeaApp({super.key});
@override
State<MabeaApp> createState() => _MabeaAppState();
}
class _MabeaAppState extends State<MabeaApp> {
final _apiClient = ApiClient(baseUrl: kApiBaseUrl);
bool _bereit = false;
@override
void initState() {
super.initState();
_apiClient.ladeToken().then((_) => setState(() => _bereit = true));
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'MABEA',
theme: ThemeData(colorSchemeSeed: const Color(0xFF0B5394), useMaterial3: true),
home: !_bereit
? const Scaffold(body: Center(child: CircularProgressIndicator()))
: _apiClient.istEingeloggt
? ObjektListScreen(apiClient: _apiClient)
: LoginScreen(apiClient: _apiClient),
);
}
}
@@ -0,0 +1,276 @@
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));
}
}
+75
View File
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../api/api_client.dart';
import 'objekt_list_screen.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key, required this.apiClient});
final ApiClient apiClient;
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _loginController = TextEditingController();
final _passwortController = TextEditingController();
String? _fehler;
bool _ladeVorgang = false;
Future<void> _anmelden() async {
setState(() {
_fehler = null;
_ladeVorgang = true;
});
try {
await widget.apiClient.login(_loginController.text, _passwortController.text);
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (_) => ObjektListScreen(apiClient: widget.apiClient)),
);
} catch (_) {
setState(() => _fehler = 'Login oder Passwort falsch');
} finally {
if (mounted) setState(() => _ladeVorgang = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Text('MABEA', style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
TextField(
controller: _loginController,
decoration: const InputDecoration(labelText: 'Login'),
),
const SizedBox(height: 12),
TextField(
controller: _passwortController,
decoration: const InputDecoration(labelText: 'Passwort'),
obscureText: true,
),
if (_fehler != null) ...[
const SizedBox(height: 12),
Text(_fehler!, style: const TextStyle(color: Colors.red)),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _ladeVorgang ? null : _anmelden,
child: Text(_ladeVorgang ? 'Wird geprüft…' : 'Anmelden'),
),
],
),
),
),
);
}
}
@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import '../api/api_client.dart';
import 'kontroll_screen.dart';
/// Prompt 11 Screen 1: Standort/Objekt wählen. Kamera-Scan (Karte 10) ist Roadmap -
/// das Suchfeld nimmt schon jetzt einen gescannten Code entgegen, sobald ein
/// Scanner-Plugin ergänzt wird.
class ObjektListScreen extends StatefulWidget {
const ObjektListScreen({super.key, required this.apiClient});
final ApiClient apiClient;
@override
State<ObjektListScreen> createState() => _ObjektListScreenState();
}
class _ObjektListScreenState extends State<ObjektListScreen> {
List<dynamic> _objekte = [];
String _suche = '';
String? _fehler;
@override
void initState() {
super.initState();
_laden();
}
Future<void> _laden() async {
try {
final ergebnis = await widget.apiClient.request('/objekte');
setState(() => _objekte = ergebnis as List<dynamic>);
} catch (_) {
setState(() => _fehler = 'Objektliste konnte nicht geladen werden.');
}
}
@override
Widget build(BuildContext context) {
final gefiltert = _objekte.where((o) {
final name = (o['name'] as String).toLowerCase();
final code = (o['code'] as String).toLowerCase();
final suche = _suche.toLowerCase();
return name.contains(suche) || code.contains(suche);
}).toList();
return Scaffold(
appBar: AppBar(title: const Text('Objekte')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
decoration: const InputDecoration(labelText: 'Suche oder Code scannen…'),
onChanged: (wert) => setState(() => _suche = wert),
),
),
if (_fehler != null) Text(_fehler!, style: const TextStyle(color: Colors.red)),
Expanded(
child: ListView.builder(
itemCount: gefiltert.length,
itemBuilder: (context, index) {
final objekt = gefiltert[index];
return ListTile(
title: Text(objekt['name'] as String),
subtitle: Text(objekt['code'] as String),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => KontrollScreen(
apiClient: widget.apiClient,
objektId: objekt['id'] as int,
),
),
),
);
},
),
),
],
),
);
}
}
+21
View File
@@ -0,0 +1,21 @@
name: mabea_app
description: MABEA - Digitales Materialmanagement Rettungsdienst/KatS (Android-Client)
publish_to: "none"
version: 0.1.0+1
environment:
sdk: ">=3.3.0 <4.0.0"
dependencies:
flutter:
sdk: flutter
http: ^1.2.2
shared_preferences: ^2.3.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
flutter:
uses-material-design: true