diff --git a/DEVLOG.md b/DEVLOG.md index 7583e2f..949cee9 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -14651,3 +14651,147 @@ Keine Commits in dieser Session. - frontend/src/pages/AdminPage.tsx | 2 +- --- +## 2026-09-12 21:42 – 21:42 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +- 230237a feat(flutter): Mangelmeldung-Offline-Queue + Threat-Model-Fund als Kachel + +### Geänderte Dateien +- DEVLOG.md | 2233 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- arbeitskacheln/14_documents.md | 33 + +- arbeitskacheln/15_mobile.md | 13 + +- backend/DEVLOG.md | 151 +++++ +- backend/THREAT_MODEL_BACKEND.md | 69 +++ +- flutter_app/DEVLOG.md | 44 ++ +- flutter_app/README.md | 9 +- +- flutter_app/lib/api/api_client.dart | 30 + +- flutter_app/lib/offline/mangel_queue.dart | 222 +++++++ +- flutter_app/lib/screens/kontroll_screen.dart | 16 +- +- flutter_app/lib/screens/mangel_melden_screen.dart | 171 ++++++ +- flutter_app/lib/screens/objekt_list_screen.dart | 13 + +- flutter_app/pubspec.yaml | 2 + +- frontend/DEVLOG.md | 387 ++++++++++++ + +--- +## 2026-09-12 21:47 – 21:47 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 2233 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- arbeitskacheln/14_documents.md | 33 + +- arbeitskacheln/15_mobile.md | 13 + +- backend/DEVLOG.md | 151 +++++ +- backend/THREAT_MODEL_BACKEND.md | 69 +++ +- flutter_app/DEVLOG.md | 44 ++ +- flutter_app/README.md | 9 +- +- flutter_app/lib/api/api_client.dart | 30 + +- flutter_app/lib/offline/mangel_queue.dart | 222 +++++++ +- flutter_app/lib/screens/kontroll_screen.dart | 16 +- +- flutter_app/lib/screens/mangel_melden_screen.dart | 171 ++++++ +- flutter_app/lib/screens/objekt_list_screen.dart | 13 + +- flutter_app/pubspec.yaml | 2 + +- frontend/DEVLOG.md | 387 ++++++++++++ + +--- +## 2026-09-12 21:53 – 21:53 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 2233 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- arbeitskacheln/14_documents.md | 33 + +- arbeitskacheln/15_mobile.md | 13 + +- backend/DEVLOG.md | 151 +++++ +- backend/THREAT_MODEL_BACKEND.md | 69 +++ +- flutter_app/DEVLOG.md | 44 ++ +- flutter_app/README.md | 9 +- +- flutter_app/lib/api/api_client.dart | 30 + +- flutter_app/lib/offline/mangel_queue.dart | 222 +++++++ +- flutter_app/lib/screens/kontroll_screen.dart | 16 +- +- flutter_app/lib/screens/mangel_melden_screen.dart | 171 ++++++ +- flutter_app/lib/screens/objekt_list_screen.dart | 13 + +- flutter_app/pubspec.yaml | 2 + +- frontend/DEVLOG.md | 387 ++++++++++++ + +--- +## 2026-09-12 21:53 – 21:53 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 2233 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- arbeitskacheln/14_documents.md | 33 + +- arbeitskacheln/15_mobile.md | 13 + +- backend/DEVLOG.md | 151 +++++ +- backend/THREAT_MODEL_BACKEND.md | 69 +++ +- flutter_app/DEVLOG.md | 44 ++ +- flutter_app/README.md | 9 +- +- flutter_app/lib/api/api_client.dart | 30 + +- flutter_app/lib/offline/mangel_queue.dart | 222 +++++++ +- flutter_app/lib/screens/kontroll_screen.dart | 16 +- +- flutter_app/lib/screens/mangel_melden_screen.dart | 171 ++++++ +- flutter_app/lib/screens/objekt_list_screen.dart | 13 + +- flutter_app/pubspec.yaml | 2 + +- frontend/DEVLOG.md | 387 ++++++++++++ + +--- +## 2026-09-12 21:54 – 21:54 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 2233 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- arbeitskacheln/14_documents.md | 33 + +- arbeitskacheln/15_mobile.md | 13 + +- backend/DEVLOG.md | 151 +++++ +- backend/THREAT_MODEL_BACKEND.md | 69 +++ +- flutter_app/DEVLOG.md | 44 ++ +- flutter_app/README.md | 9 +- +- flutter_app/lib/api/api_client.dart | 30 + +- flutter_app/lib/offline/mangel_queue.dart | 222 +++++++ +- flutter_app/lib/screens/kontroll_screen.dart | 16 +- +- flutter_app/lib/screens/mangel_melden_screen.dart | 171 ++++++ +- flutter_app/lib/screens/objekt_list_screen.dart | 13 + +- flutter_app/pubspec.yaml | 2 + +- frontend/DEVLOG.md | 387 ++++++++++++ + +--- +## 2026-09-12 21:54 – 22:00 (6m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 2233 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- arbeitskacheln/14_documents.md | 33 + +- arbeitskacheln/15_mobile.md | 13 + +- backend/DEVLOG.md | 151 +++++ +- backend/THREAT_MODEL_BACKEND.md | 69 +++ +- flutter_app/DEVLOG.md | 44 ++ +- flutter_app/README.md | 9 +- +- flutter_app/lib/api/api_client.dart | 30 + +- flutter_app/lib/offline/mangel_queue.dart | 222 +++++++ +- flutter_app/lib/screens/kontroll_screen.dart | 16 +- +- flutter_app/lib/screens/mangel_melden_screen.dart | 171 ++++++ +- flutter_app/lib/screens/objekt_list_screen.dart | 13 + +- flutter_app/pubspec.yaml | 2 + +- frontend/DEVLOG.md | 387 ++++++++++++ + +--- diff --git a/arbeitskacheln/15_mobile.md b/arbeitskacheln/15_mobile.md index 64c7c03..7e1ecba 100644 --- a/arbeitskacheln/15_mobile.md +++ b/arbeitskacheln/15_mobile.md @@ -149,6 +149,18 @@ Details zu MOBILE-001 … MOBILE-005 (vollständig). (kein Flutter-SDK lokal verfügbar) — vor Produktivnutzung `flutter analyze` + `flutter build apk` und Kamera-Berechtigung in `android/app/src/main/ AndroidManifest.xml` (siehe `flutter_app/README.md`) prüfen. + **Update 2026-09-12 (verifiziert + Bug gefunden/gefixt, Build auf + 192.168.1.236 mit Flutter 3.47.3):** `flutter analyze` (0 Findings) und + `flutter test` (14/14 grün) laufen jetzt sauber, `flutter build apk --debug` + erfolgreich. Dabei echten Bug in `offline/queue.dart` UND `offline/ + mangel_queue.dart` gefunden: `synchronisiere()` brach bei JEDEM + unerwarteten Fehler (`catch(_)`) die komplette Restqueue ab, nicht nur bei + echtem Netzwerkfehler — ein einzelner kaputter/unerwarteter Fehler bei + einem Eintrag hätte alle nachfolgenden Einträge dauerhaft blockiert. Fix: + nur noch `on SocketException` abbrechen. Zusätzlich `kApiBaseUrl` in + `main.dart` auf `String.fromEnvironment` umgestellt (`--dart-define= + API_BASE_URL=...` statt Code-Änderung pro Build). Neuer Test `test/ + mangel_queue_test.dart` ergänzt (Erfolg/Netzwerkfehler/4xx/Foto-Upload). ## MOBILE-005 — Mobile Beladungskontrolle diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart index 4701304..9a7ae2b 100644 --- a/flutter_app/lib/main.dart +++ b/flutter_app/lib/main.dart @@ -4,10 +4,14 @@ 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'; +// Optimierung: fester String durch --dart-define ersetzt, damit derselbe +// Code für Dev/Prod (Android UND iOS) ohne Neuschreiben gebaut werden kann, +// z.B. `flutter build apk --dart-define=API_BASE_URL=https://mabea.example/api/v1` +// (siehe README). Ohne --dart-define bleibt der bisherige Server als Default. +const String kApiBaseUrl = String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'http://192.168.1.238/api/v1', +); void main() { runApp(const MabeaApp()); diff --git a/flutter_app/lib/offline/mangel_queue.dart b/flutter_app/lib/offline/mangel_queue.dart index 1d7aeb6..9daaa65 100644 --- a/flutter_app/lib/offline/mangel_queue.dart +++ b/flutter_app/lib/offline/mangel_queue.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:sqflite/sqflite.dart'; @@ -200,6 +201,14 @@ class MangelQueue { } } + /// Nur für Tests, gleicher Grund wie OfflineQueue.alleLoeschen (Singleton-DB + /// über den gesamten Testlauf geteilt). + @visibleForTesting + Future alleLoeschen() async { + final db = await _database(); + await db.delete('mangel_queue'); + } + Future synchronisiere(ApiClient apiClient) async { if (_synchronisierungLaeuft) return; _synchronisierungLaeuft = true; @@ -211,7 +220,10 @@ class MangelQueue { await _sendeEintrag(apiClient, item); } on ApiException { // schon in _sendeEintrag behandelt, weiter mit dem nächsten Eintrag. - } catch (_) { + } on SocketException { + // Gleicher Bug-Fund wie in offline/queue.dart: NUR bei echtem + // Netzwerkfehler abbrechen, sonst blockiert ein einzelner + // unerwarteter Fehler dauerhaft den Rest der Queue. return; // vermutlich offline - restliche Queue in Ruhe lassen } } diff --git a/flutter_app/lib/offline/queue.dart b/flutter_app/lib/offline/queue.dart index eb95e6b..b70ff1a 100644 --- a/flutter_app/lib/offline/queue.dart +++ b/flutter_app/lib/offline/queue.dart @@ -1,3 +1,6 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:sqflite/sqflite.dart'; @@ -180,6 +183,16 @@ class OfflineQueue { /// Wird bei Reconnect und periodisch aufgerufen. Bricht bei Netzwerkfehlern /// früh ab (weitere Versuche würden ohnehin scheitern, solange offline). + /// Nur für Tests: Singleton-DB wird über den gesamten Testlauf geteilt, + /// ohne Aufräumen bleiben "wartet"-Einträge aus einem Test für den + /// nächsten Test aktiv und werden mit dessen (neuem, nicht gestubbtem) + /// Mock erneut angefragt. + @visibleForTesting + Future alleLoeschen() async { + final db = await _database(); + await db.delete('queue'); + } + Future synchronisiere(ApiClient apiClient) async { if (_synchronisierungLaeuft) return; _synchronisierungLaeuft = true; @@ -194,7 +207,12 @@ class OfflineQueue { } on ApiException { // Client-/Server-Fehler: schon in _sendeEintrag behandelt, weiter mit // dem nächsten Eintrag. - } catch (_) { + } on SocketException { + // Bug-Fund: NUR bei echtem Netzwerkfehler abbrechen - ein `catch(_)` + // hier hätte auch einen unerwarteten Fehler eines EINZELNEN + // Eintrags (z.B. korrupter Eintrag) den kompletten Rest der Queue + // dauerhaft blockiert, weil jeder folgende Sync-Versuch am selben + // Eintrag erneut abgebrochen wäre, bevor spätere Einträge drankommen. return; // vermutlich offline - restliche Queue in Ruhe lassen } } diff --git a/flutter_app/test/mangel_queue_test.dart b/flutter_app/test/mangel_queue_test.dart new file mode 100644 index 0000000..de5afa4 --- /dev/null +++ b/flutter_app/test/mangel_queue_test.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mocktail/mocktail.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:mabea_app/api/api_client.dart'; +import 'package:mabea_app/offline/mangel_queue.dart'; + +import 'fakes/fake_secure_storage.dart'; + +class _MockClient extends Mock implements http.Client {} + +class _FakeBaseRequest extends Fake implements http.BaseRequest {} + +/// Gleiches Test-Muster wie offline_queue_test.dart (Kontroll-Queue) - eigene +/// Objekt-/Mangel-Kombinationen je Test, damit sich Einträge nicht +/// überschneiden. +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + registerFallbackValue(_FakeBaseRequest()); + }); + + late _MockClient httpClient; + late ApiClient apiClient; + + // Gleicher Grund wie in offline_queue_test.dart: Singleton-DB überlebt den + // gesamten Testlauf, ohne Aufräumen stören sich Tests gegenseitig. + tearDown(() => MangelQueue.instance.alleLoeschen()); + + setUp(() { + FakeSecureStorage.zuruecksetzen(); + httpClient = _MockClient(); + apiClient = ApiClient( + baseUrl: 'http://test.local/api/v1', + httpClient: httpClient, + storage: const FakeSecureStorage(), + ); + }); + + test('enqueue ohne Foto verschwindet nach erfolgreicher Übertragung', () async { + when(() => httpClient.post( + Uri.parse('http://test.local/api/v1/maengel'), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response(jsonEncode({'id': 'mangel-1'}), 200)); + + await MangelQueue.instance.enqueue(objektId: 101, beschreibung: 'Reifen platt', prioritaet: 'hoch'); + var eintraege = await MangelQueue.instance.alleEintraege(); + expect(eintraege.where((e) => e.objektId == 101), hasLength(1)); + + await MangelQueue.instance.synchronisiere(apiClient); + + eintraege = await MangelQueue.instance.alleEintraege(); + expect(eintraege.where((e) => e.objektId == 101), isEmpty); + }); + + test('Netzwerkfehler lässt Eintrag auf "wartet" stehen, kein Datenverlust', () async { + when(() => httpClient.post( + Uri.parse('http://test.local/api/v1/maengel'), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenThrow(const SocketException('Keine Verbindung')); + + await MangelQueue.instance.enqueue(objektId: 102, beschreibung: 'Licht defekt', prioritaet: 'normal'); + await MangelQueue.instance.synchronisiere(apiClient); + + final eintraege = await MangelQueue.instance.alleEintraege().then( + (liste) => liste.where((e) => e.objektId == 102).toList(), + ); + expect(eintraege, hasLength(1)); + expect(eintraege.single.status, 'wartet'); + }); + + test('4xx-Antwort setzt Eintrag auf "fehler", kein Auto-Retry', () async { + when(() => httpClient.post( + Uri.parse('http://test.local/api/v1/maengel'), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response(jsonEncode({'detail': 'ungültig'}), 422)); + + await MangelQueue.instance.enqueue(objektId: 103, beschreibung: 'x', prioritaet: 'niedrig'); + await MangelQueue.instance.synchronisiere(apiClient); + + var eintraege = await MangelQueue.instance.alleEintraege().then( + (liste) => liste.where((e) => e.objektId == 103).toList(), + ); + expect(eintraege.single.status, 'fehler'); + expect(eintraege.single.fehlerText, contains('422')); + + // Erneuter Sync-Lauf darf den Fehler-Eintrag NICHT automatisch erneut senden. + await MangelQueue.instance.synchronisiere(apiClient); + verify(() => httpClient.post( + Uri.parse('http://test.local/api/v1/maengel'), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).called(1); + + final key = eintraege.single.key; + await MangelQueue.instance.entferneFehlerEintrag(key); + eintraege = await MangelQueue.instance.alleEintraege().then( + (liste) => liste.where((e) => e.objektId == 103).toList(), + ); + expect(eintraege, isEmpty); + }); + + test('Mangel mit Foto lädt Dokument nach Erfolg hoch und löscht die lokale Datei', () async { + final tempFoto = File('${Directory.systemTemp.path}/mabea_test_foto.jpg'); + await tempFoto.writeAsBytes([0, 1, 2, 3]); + + when(() => httpClient.post( + Uri.parse('http://test.local/api/v1/maengel'), + headers: any(named: 'headers'), + body: any(named: 'body'), + )).thenAnswer((_) async => http.Response(jsonEncode({'id': 'mangel-mit-foto'}), 200)); + when(() => httpClient.send(any())).thenAnswer( + (_) async => http.StreamedResponse( + Stream.value(utf8.encode(jsonEncode({'id': 'dok-1'}))), + 201, + ), + ); + + await MangelQueue.instance.enqueue( + objektId: 104, + beschreibung: 'mit Foto', + prioritaet: 'normal', + fotoPfad: tempFoto.path, + ); + await MangelQueue.instance.synchronisiere(apiClient); + + verify(() => httpClient.send(any())).called(1); + expect(await tempFoto.exists(), isFalse); + + final eintraege = await MangelQueue.instance.alleEintraege().then( + (liste) => liste.where((e) => e.objektId == 104).toList(), + ); + expect(eintraege, isEmpty); + }); +} diff --git a/flutter_app/test/offline_queue_test.dart b/flutter_app/test/offline_queue_test.dart index 9574484..5091534 100644 --- a/flutter_app/test/offline_queue_test.dart +++ b/flutter_app/test/offline_queue_test.dart @@ -27,6 +27,12 @@ void main() { late _MockClient httpClient; late ApiClient apiClient; + // Bug-Fund: OfflineQueue ist ein Singleton, die sqflite-DB überlebt den + // gesamten Testlauf. Ohne Aufräumen versucht ein späterer Test, einen + // liegengebliebenen "wartet"-Eintrag aus einem FRÜHEREN Test mit seinem + // eigenen (neuen, nicht gestubbten) Mock erneut zu senden. + tearDown(() => OfflineQueue.instance.alleLoeschen()); + setUp(() { FakeSecureStorage.zuruecksetzen(); httpClient = _MockClient();