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
235 lines
7.3 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|