import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../auth/auth_store.dart';
import 'master_data_sync.dart';
import 'sync_queue_store.dart';
abstract interface class ReportSyncApi {
Future<List<Map<String, dynamic>>> push(
String accessToken,
List<SyncOperation> operations,
);
}
class HttpReportSyncApi implements ReportSyncApi {
HttpReportSyncApi({http.Client? client, String? baseUrl})
: _client = client ?? http.Client(),
baseUrl =
(baseUrl ??
const String.fromEnvironment(
'MACHWEIS_API_BASE_URL',
defaultValue: 'https://machweisapi.haselhuhn-media.com',
))
.replaceFirst(RegExp(r'/$'), '');
final http.Client _client;
final String baseUrl;
@override
Future<List<Map<String, dynamic>>> push(
String accessToken,
List<SyncOperation> operations,
) async {
try {
final response = await _client.post(
Uri.parse('$baseUrl/api/v1/sync/push'),
headers: {
'Authorization': 'Bearer $accessToken',
'Content-Type': 'application/json',
'Idempotency-Key': operations
.map((item) => item.idempotencyKey ?? item.id)
.join('.'),
},
body: jsonEncode({
'operations': operations
.map(
(item) => {
'operation_id': item.id,
'idempotency_key': item.idempotencyKey ?? item.id,
'entity': 'reports',
'entity_id': item.entityId,
'action': item.action == SyncAction.delete
? 'delete'
: item.action == SyncAction.create
? 'create'
: 'update',
'base_revision': item.baseRevision,
'payload': item.payload,
},
)
.toList(),
}),
);
if (response.statusCode == 401) {
throw const SyncClientException(
'Anmeldung muss erneuert werden.',
unauthorized: true,
);
}
if (response.statusCode >= 500) {
throw const SyncClientException(
'Der Berichtsserver ist vorübergehend nicht erreichbar.',
);
}
if (![200, 403, 409, 422].contains(response.statusCode)) {
throw const SyncClientException(
'Unerwartete Antwort des Berichtsservers.',
);
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
final data = Map<String, dynamic>.from(
body['data'] as Map<String, dynamic>? ?? const {},
);
return (data['results'] as List<dynamic>? ?? const [])
.map((item) => Map<String, dynamic>.from(item as Map))
.toList();
} on http.ClientException {
throw const SyncClientException(
'Offline – Berichte werden später erneut synchronisiert.',
);
}
}
}
enum ReportSyncStatus {
unavailable,
idle,
syncing,
success,
offline,
conflict,
error,
}
class ReportSyncController extends ChangeNotifier {
ReportSyncController({
SyncQueueStore? queue,
ReportSyncApi? api,
Future<String> Function({bool forceRefresh})? tokenProvider,
String? Function()? tenantProvider,
bool? supported,
}) : _queue = queue ?? SyncQueueStore.instance,
_api = api ?? HttpReportSyncApi(),
_tokenProvider = tokenProvider ?? AuthStore.instance.accessToken,
_tenantProvider =
tenantProvider ?? (() => AuthStore.instance.session?.tenantId),
_supported =
supported ??
(!kIsWeb &&
(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS));
static final instance = ReportSyncController();
final SyncQueueStore _queue;
final ReportSyncApi _api;
final Future<String> Function({bool forceRefresh}) _tokenProvider;
final String? Function() _tenantProvider;
final bool _supported;
ReportSyncStatus status = ReportSyncStatus.unavailable;
int pendingCount = 0;
int conflictCount = 0;
String? message;
String? _tenantId;
Future<void>? _activeSynchronization;
bool get isSyncing => status == ReportSyncStatus.syncing;
Future<void> initialize() async {
final tenant = _tenantProvider();
if (!_supported || tenant == null) {
status = ReportSyncStatus.unavailable;
notifyListeners();
return;
}
_tenantId = tenant;
await _queue.adoptLegacyReports(tenant);
_refreshCounts();
status = conflictCount > 0
? ReportSyncStatus.conflict
: ReportSyncStatus.idle;
notifyListeners();
}
Future<void> synchronize({DateTime? now, bool force = false}) {
final tenant = _tenantId;
if (!_supported || tenant == null) return Future.value();
final active = _activeSynchronization;
if (active != null) return active;
late final Future<void> run;
run = _synchronize(tenant, now: now, force: force).whenComplete(() {
if (identical(_activeSynchronization, run)) {
_activeSynchronization = null;
}
});
_activeSynchronization = run;
return run;
}
Future<void> _synchronize(
String tenant, {
DateTime? now,
required bool force,
}) async {
final startedAt = now ?? DateTime.now();
final operations = _queue.dueReports(tenant, startedAt, force: force);
if (operations.isEmpty) {
_refreshCounts();
status = conflictCount > 0
? ReportSyncStatus.conflict
: ReportSyncStatus.success;
message = conflictCount > 0
? '$conflictCount Berichtskonflikt(e) benötigen eine Prüfung.'
: 'Berichte sind synchronisiert.';
notifyListeners();
return;
}
status = ReportSyncStatus.syncing;
message = 'Berichte werden synchronisiert …';
_queue.beginReportSend(operations);
notifyListeners();
try {
var token = await _tokenProvider();
List<Map<String, dynamic>> results;
try {
results = await _api.push(token, operations);
} on SyncClientException catch (error) {
if (!error.unauthorized) rethrow;
token = await _tokenProvider(forceRefresh: true);
results = await _api.push(token, operations);
}
final byId = {
for (final result in results)
if (result['operation_id'] is String)
result['operation_id'] as String: result,
};
for (final operation in operations) {
final result = byId[operation.id];
if (result == null) {
await _queue.failReport(
operation,
'Der Server hat die Berichtsänderung nicht bestätigt.',
startedAt,
);
} else {
await _queue.applyReportResult(operation, result);
}
}
_refreshCounts();
status = conflictCount > 0
? ReportSyncStatus.conflict
: ReportSyncStatus.success;
message = conflictCount > 0
? '$conflictCount Berichtskonflikt(e) benötigen eine Prüfung.'
: 'Berichte sind synchronisiert.';
} on SyncClientException catch (error) {
for (final operation in operations) {
await _queue.failReport(operation, error.message, startedAt);
}
_refreshCounts();
status = error.unauthorized
? ReportSyncStatus.error
: ReportSyncStatus.offline;
message = error.message;
} on Object {
const error =
'Berichtssynchronisation konnte nicht abgeschlossen werden.';
for (final operation in operations) {
await _queue.failReport(operation, error, startedAt);
}
_refreshCounts();
status = ReportSyncStatus.error;
message = error;
} finally {
_queue.endReportSend(operations);
notifyListeners();
}
}
void _refreshCounts() {
final tenant = _tenantId;
if (tenant == null) return;
pendingCount = _queue.reportPendingCount(tenant);
conflictCount = _queue.reportConflictCount(tenant);
}
}
