Sprint 7: React-PWA-Frontend (Offline-Härtung) + Flutter-Android-Grundgerüst
CI / backend-tests (push) Successful in 54s
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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user