fix(flutter): Sync-Abbruch-Bug in Offline-Queues behoben, API-URL konfigurierbar
CI / backend-tests (push) Successful in 3m16s
CI / frontend-build (push) Successful in 21s

flutter analyze/test/build erstmals verifiziert (Build-Host 192.168.1.236,
Flutter 3.47.3). Dabei Bug gefunden: synchronisiere() brach in beiden
Offline-Queues (Kontrolle + Mangel) bei JEDEM unerwarteten Fehler die
komplette Restqueue ab statt nur bei echtem Netzwerkfehler - ein kaputter
Eintrag hätte alle nachfolgenden dauerhaft blockiert. Jetzt nur noch bei
SocketException. Zusätzlich kApiBaseUrl auf --dart-define umgestellt statt
Code-Änderung pro Build, und Test für die neue Mangel-Queue ergänzt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VDMvneg6wKfXdXvTYNd3dN
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-12 22:10:56 +02:00
1 parent 230237ac94
commit ecca012197
7 files changed
+345 -6

No files matched your search

+144
View File
@@ -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 ++++++++++++
---
+12
View File
@@ -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
+8 -4
View File
@@ -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());
+13 -1
View File
@@ -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<void> alleLoeschen() async {
final db = await _database();
await db.delete('mangel_queue');
}
Future<void> 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
}
}
+19 -1
View File
@@ -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<void> alleLoeschen() async {
final db = await _database();
await db.delete('queue');
}
Future<void> 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
}
}
+143
View File
@@ -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);
});
}
+6
View File
@@ -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();