Files
patrickandClaude Sonnet 5 ecca012197
CI / backend-tests (push) Successful in 3m16s
CI / frontend-build (push) Successful in 21s
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
2026-09-12 22:10:56 +02:00

235 lines
7.3 KiB
Dart

import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:sqflite/sqflite.dart';
import '../api/api_client.dart';
/// Android-Pendant zu frontend/src/offline/mangelQueue.ts (MOBILE-003-Ausweitung):
/// Mangelmeldung ist neben der Kontroll-Erfassung die häufigste Feld-Aktion,
/// soll also genau wie diese nie an einer kurzen Funklücke scheitern. Das
/// Foto wird - anders als in der PWA (File/Blob per IndexedDB) - als Kopie in
/// ein App-eigenes Verzeichnis abgelegt (sqflite kennt keine Blobs im selben
/// strukturierten Sinn, ein Dateipfad ist hier robuster) und erst NACH
/// erfolgreicher Mangel-Erstellung separat hochgeladen.
class MangelQueueItem {
MangelQueueItem({
required this.key,
required this.objektId,
this.entitaetTyp,
this.entitaetId,
required this.beschreibung,
required this.prioritaet,
this.fotoPfad,
this.status = 'wartet',
this.fehlerText,
});
final String key;
final int objektId;
final String? entitaetTyp;
final String? entitaetId;
final String beschreibung;
final String prioritaet;
final String? fotoPfad;
String status; // wartet | wird_uebertragen | fehler
String? fehlerText;
Map<String, dynamic> toRow() => {
'key': key,
'objekt_id': objektId,
'entitaet_typ': entitaetTyp,
'entitaet_id': entitaetId,
'beschreibung': beschreibung,
'prioritaet': prioritaet,
'foto_pfad': fotoPfad,
'status': status,
'fehler_text': fehlerText,
};
factory MangelQueueItem.fromRow(Map<String, dynamic> row) => MangelQueueItem(
key: row['key'] as String,
objektId: row['objekt_id'] as int,
entitaetTyp: row['entitaet_typ'] as String?,
entitaetId: row['entitaet_id'] as String?,
beschreibung: row['beschreibung'] as String,
prioritaet: row['prioritaet'] as String,
fotoPfad: row['foto_pfad'] as String?,
status: row['status'] as String,
fehlerText: row['fehler_text'] as String?,
);
}
class MangelQueue {
MangelQueue._();
static final MangelQueue instance = MangelQueue._();
Database? _db;
bool _synchronisierungLaeuft = false;
Future<Database> _database() async {
final bestehend = _db;
if (bestehend != null) return bestehend;
final pfad = p.join(await getDatabasesPath(), 'mabea_mangel_queue.db');
final db = await openDatabase(
pfad,
version: 1,
onCreate: (db, version) => db.execute('''
CREATE TABLE mangel_queue (
key TEXT PRIMARY KEY,
objekt_id INTEGER NOT NULL,
entitaet_typ TEXT,
entitaet_id TEXT,
beschreibung TEXT NOT NULL,
prioritaet TEXT NOT NULL,
foto_pfad TEXT,
status TEXT NOT NULL,
fehler_text TEXT
)
'''),
);
_db = db;
return db;
}
Future<void> enqueue({
required int objektId,
String? entitaetTyp,
String? entitaetId,
required String beschreibung,
required String prioritaet,
String? fotoPfad,
}) async {
final db = await _database();
final key = DateTime.now().microsecondsSinceEpoch.toString();
await db.insert(
'mangel_queue',
MangelQueueItem(
key: key,
objektId: objektId,
entitaetTyp: entitaetTyp,
entitaetId: entitaetId,
beschreibung: beschreibung,
prioritaet: prioritaet,
fotoPfad: fotoPfad,
).toRow(),
);
}
Future<List<MangelQueueItem>> alleEintraege() async {
final db = await _database();
final rows = await db.query('mangel_queue');
return rows.map(MangelQueueItem.fromRow).toList();
}
Future<void> entferneFehlerEintrag(String key) async {
MangelQueueItem? item;
for (final e in await alleEintraege()) {
if (e.key == key) item = e;
}
final db = await _database();
await db.delete('mangel_queue', where: 'key = ?', whereArgs: [key]);
final fotoPfad = item?.fotoPfad;
if (fotoPfad != null) {
final datei = File(fotoPfad);
if (await datei.exists()) await datei.delete();
}
}
Future<void> _setzeStatus(String key, String status, {String? fehlerText}) async {
final db = await _database();
await db.update(
'mangel_queue',
{'status': status, 'fehler_text': fehlerText},
where: 'key = ?',
whereArgs: [key],
);
}
Future<void> _sendeEintrag(ApiClient apiClient, MangelQueueItem item) async {
await _setzeStatus(item.key, 'wird_uebertragen');
try {
final mangel = await apiClient.request(
'/maengel',
method: 'POST',
body: {
'objekt_id': item.objektId,
'entitaet_typ': item.entitaetTyp,
'entitaet_id': item.entitaetId,
'beschreibung': item.beschreibung,
'prioritaet': item.prioritaet,
},
) as Map<String, dynamic>;
final fotoPfad = item.fotoPfad;
if (fotoPfad != null && await File(fotoPfad).exists()) {
try {
await apiClient.ladeDokumentHoch(
entitaetTyp: 'mangel',
entitaetId: mangel['id'] as String,
dokumenttyp: 'sonstiges',
istOriginal: true,
datei: File(fotoPfad),
);
} catch (_) {
// Mangel selbst ist bereits erfolgreich angelegt (Kernnutzen erfüllt) -
// ein fehlgeschlagener Foto-Upload darf den Eintrag nicht in der Queue
// hängen lassen, sonst wird derselbe Mangel bei jedem Sync-Versuch neu
// angelegt. Foto-Verlust ist bewusst der kleinere Schaden hier
// (gleiche Abwägung wie in der PWA, mangelQueue.ts).
}
await File(fotoPfad).delete().catchError((_) => File(fotoPfad));
}
final db = await _database();
await db.delete('mangel_queue', where: 'key = ?', whereArgs: [item.key]);
} on ApiException catch (error) {
if (error.status >= 400 && error.status < 500) {
await _setzeStatus(
item.key,
'fehler',
fehlerText: 'Server hat abgelehnt (${error.status}) - bitte erneut versuchen.',
);
} else {
await _setzeStatus(item.key, 'wartet');
}
rethrow;
} catch (error) {
await _setzeStatus(item.key, 'wartet');
rethrow;
}
}
/// 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;
try {
final eintraege = await alleEintraege();
for (final item in eintraege) {
if (item.status == 'fehler') continue; // braucht bewusste Nutzer-Aktion
try {
await _sendeEintrag(apiClient, item);
} on ApiException {
// schon in _sendeEintrag behandelt, weiter mit dem nächsten Eintrag.
} 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
}
}
} finally {
_synchronisierungLaeuft = false;
}
}
}