lib/offline/queue.dart (sqflite) analog zur React-PWA-Queue: enqueue statt Direkt-Senden, Status wartet/wird_uebertragen/fehler, Idempotenz, Sync bei Reconnect (connectivity_plus) + periodisch alle 15s. KontrollScreen gleicht per 2s-Reconciliation-Loop den Anzeigezustand ab, "Kein Netz"-Banner. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
206 lines
6.4 KiB
Dart
206 lines
6.4 KiB
Dart
import 'package:path/path.dart' as p;
|
|
import 'package:sqflite/sqflite.dart';
|
|
|
|
import '../api/api_client.dart';
|
|
|
|
/// Gleiche Idee wie in der React-PWA (frontend/src/offline/queue.ts):
|
|
/// Eingaben werden lokal zwischengespeichert, sobald eingegeben, nicht erst
|
|
/// bei Übertragung verworfen, wenn die Verbindung kurz fehlt. sqflite statt
|
|
/// IndexedDB, weil das die etablierte lokale-DB-Lösung für Flutter ist -
|
|
/// gleiche Rolle, andere Plattform (Prompt 17-Härtung für den Android-Client).
|
|
class QueueItem {
|
|
QueueItem({
|
|
required this.kontrolleId,
|
|
required this.materialId,
|
|
required this.istmenge,
|
|
this.ablaufdatum,
|
|
this.chargennummer,
|
|
this.seriennummer,
|
|
this.status = 'wartet',
|
|
this.fehlerText,
|
|
});
|
|
|
|
final String kontrolleId;
|
|
final int materialId;
|
|
final String istmenge;
|
|
final String? ablaufdatum;
|
|
final String? chargennummer;
|
|
final String? seriennummer;
|
|
String status; // wartet | wird_uebertragen | fehler
|
|
String? fehlerText;
|
|
|
|
Map<String, dynamic> toRow() => {
|
|
'kontrolle_id': kontrolleId,
|
|
'material_id': materialId,
|
|
'istmenge': istmenge,
|
|
'ablaufdatum': ablaufdatum,
|
|
'chargennummer': chargennummer,
|
|
'seriennummer': seriennummer,
|
|
'status': status,
|
|
'fehler_text': fehlerText,
|
|
};
|
|
|
|
factory QueueItem.fromRow(Map<String, dynamic> row) => QueueItem(
|
|
kontrolleId: row['kontrolle_id'] as String,
|
|
materialId: row['material_id'] as int,
|
|
istmenge: row['istmenge'] as String,
|
|
ablaufdatum: row['ablaufdatum'] as String?,
|
|
chargennummer: row['chargennummer'] as String?,
|
|
seriennummer: row['seriennummer'] as String?,
|
|
status: row['status'] as String,
|
|
fehlerText: row['fehler_text'] as String?,
|
|
);
|
|
}
|
|
|
|
class OfflineQueue {
|
|
OfflineQueue._();
|
|
static final OfflineQueue instance = OfflineQueue._();
|
|
|
|
Database? _db;
|
|
bool _synchronisierungLaeuft = false;
|
|
|
|
Future<Database> _database() async {
|
|
final bestehend = _db;
|
|
if (bestehend != null) return bestehend;
|
|
final pfad = p.join(await getDatabasesPath(), 'mabea_offline_queue.db');
|
|
final db = await openDatabase(
|
|
pfad,
|
|
version: 1,
|
|
onCreate: (db, version) => db.execute('''
|
|
CREATE TABLE queue (
|
|
kontrolle_id TEXT NOT NULL,
|
|
material_id INTEGER NOT NULL,
|
|
istmenge TEXT NOT NULL,
|
|
ablaufdatum TEXT,
|
|
chargennummer TEXT,
|
|
seriennummer TEXT,
|
|
status TEXT NOT NULL,
|
|
fehler_text TEXT,
|
|
PRIMARY KEY (kontrolle_id, material_id)
|
|
)
|
|
'''),
|
|
);
|
|
_db = db;
|
|
return db;
|
|
}
|
|
|
|
Future<void> enqueue({
|
|
required String kontrolleId,
|
|
required int materialId,
|
|
required String istmenge,
|
|
String? ablaufdatum,
|
|
String? chargennummer,
|
|
String? seriennummer,
|
|
}) async {
|
|
final db = await _database();
|
|
await db.insert(
|
|
'queue',
|
|
QueueItem(
|
|
kontrolleId: kontrolleId,
|
|
materialId: materialId,
|
|
istmenge: istmenge,
|
|
ablaufdatum: ablaufdatum,
|
|
chargennummer: chargennummer,
|
|
seriennummer: seriennummer,
|
|
).toRow(),
|
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
);
|
|
}
|
|
|
|
Future<List<QueueItem>> eintraegeFuerKontrolle(String kontrolleId) async {
|
|
final db = await _database();
|
|
final rows = await db.query('queue', where: 'kontrolle_id = ?', whereArgs: [kontrolleId]);
|
|
return rows.map(QueueItem.fromRow).toList();
|
|
}
|
|
|
|
Future<void> entferneFehlerEintrag(String kontrolleId, int materialId) async {
|
|
final db = await _database();
|
|
await db.delete(
|
|
'queue',
|
|
where: 'kontrolle_id = ? AND material_id = ?',
|
|
whereArgs: [kontrolleId, materialId],
|
|
);
|
|
}
|
|
|
|
Future<void> _setzeStatus(
|
|
String kontrolleId,
|
|
int materialId,
|
|
String status, {
|
|
String? fehlerText,
|
|
}) async {
|
|
final db = await _database();
|
|
await db.update(
|
|
'queue',
|
|
{'status': status, 'fehler_text': fehlerText},
|
|
where: 'kontrolle_id = ? AND material_id = ?',
|
|
whereArgs: [kontrolleId, materialId],
|
|
);
|
|
}
|
|
|
|
Future<void> _sendeEintrag(ApiClient apiClient, QueueItem item) async {
|
|
await _setzeStatus(item.kontrolleId, item.materialId, 'wird_uebertragen');
|
|
try {
|
|
await apiClient.request(
|
|
'/kontrollen/${item.kontrolleId}/positionen/${item.materialId}',
|
|
method: 'PUT',
|
|
body: {
|
|
'istmenge': item.istmenge,
|
|
'ablaufdatum': item.ablaufdatum,
|
|
'chargennummer': item.chargennummer,
|
|
'seriennummer': item.seriennummer,
|
|
},
|
|
);
|
|
// Idempotent (Backend-Upsert, siehe frontend/src/offline/queue.ts) - aus
|
|
// Queue entfernen ist sicher, auch bei mehrfachem Sendeversuch vor Erfolg.
|
|
final db = await _database();
|
|
await db.delete(
|
|
'queue',
|
|
where: 'kontrolle_id = ? AND material_id = ?',
|
|
whereArgs: [item.kontrolleId, item.materialId],
|
|
);
|
|
} on ApiException catch (error) {
|
|
if (error.status >= 400 && error.status < 500) {
|
|
await _setzeStatus(
|
|
item.kontrolleId,
|
|
item.materialId,
|
|
'fehler',
|
|
fehlerText: 'Server hat abgelehnt (${error.status}) - bitte Kontrolle neu prüfen.',
|
|
);
|
|
} else {
|
|
await _setzeStatus(item.kontrolleId, item.materialId, 'wartet');
|
|
}
|
|
rethrow;
|
|
} catch (error) {
|
|
// Netzwerkfehler (kein ApiException) - bleibt "wartet", nächster Sync
|
|
// versucht es erneut.
|
|
await _setzeStatus(item.kontrolleId, item.materialId, 'wartet');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Wird bei Reconnect und periodisch aufgerufen. Bricht bei Netzwerkfehlern
|
|
/// früh ab (weitere Versuche würden ohnehin scheitern, solange offline).
|
|
Future<void> synchronisiere(ApiClient apiClient) async {
|
|
if (_synchronisierungLaeuft) return;
|
|
_synchronisierungLaeuft = true;
|
|
try {
|
|
final db = await _database();
|
|
final rows = await db.query('queue');
|
|
for (final row in rows) {
|
|
final item = QueueItem.fromRow(row);
|
|
if (item.status == 'fehler') continue; // braucht bewusste Nutzer-Aktion
|
|
try {
|
|
await _sendeEintrag(apiClient, item);
|
|
} on ApiException {
|
|
// Client-/Server-Fehler: schon in _sendeEintrag behandelt, weiter mit
|
|
// dem nächsten Eintrag.
|
|
} catch (_) {
|
|
return; // vermutlich offline - restliche Queue in Ruhe lassen
|
|
}
|
|
}
|
|
} finally {
|
|
_synchronisierungLaeuft = false;
|
|
}
|
|
}
|
|
}
|