fix(flutter): Sync-Abbruch-Bug in Offline-Queues behoben, API-URL konfigurierbar
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:
1 parent
230237ac94
commit
ecca012197
7 files changed
+345
-6
No files matched your search
@@ -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 ++++++++++++
|
||||
|
||||
---
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user