import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../../core/auth/auth_store.dart';
import '../../../core/storage/local_storage.dart';
import '../../../core/identifiers/uuid.dart';
import '../../../core/sync/sync_queue_store.dart';
import '../../../core/sync/report_sync.dart';
import '../../../core/sync/report_asset_sync.dart';
import '../../jobs/data/job_store.dart';
import 'report_photo_store.dart';
import '../../../core/web/web_office_api.dart';
class MaterialEntry {
const MaterialEntry({
required this.name,
required this.quantity,
required this.unit,
});
final String name;
final String quantity;
final String unit;
Map<String, String> toJson() => {
'name': name,
'quantity': quantity,
'unit': unit,
};
factory MaterialEntry.fromJson(Map<String, dynamic> json) => MaterialEntry(
name: json['name'] as String? ?? '',
quantity: json['quantity'] as String? ?? '',
unit: json['unit'] as String? ?? 'Stk.',
);
}
class DemoReport {
const DemoReport({
required this.id,
required this.customer,
required this.site,
required this.activity,
required this.material,
required this.quantity,
required this.date,
required this.start,
required this.end,
this.isFinalized = false,
this.finalizedAt,
this.signature = const [],
this.participantIds = const [],
this.activityItems = const [],
this.materialItems = const [],
this.additionalOrder = '',
this.additionalOrderApproved = false,
this.breakMinutes = 0,
this.version = 1,
this.photoIds = const [],
this.fileIds = const [],
this.fileNames = const [],
this.customerId,
this.siteId,
this.jobId,
this.signatureAssetId,
});
final String id;
final String customer;
final String site;
final String activity;
final String material;
final String quantity;
final DateTime date;
final TimeOfDay start;
final TimeOfDay end;
final bool isFinalized;
final DateTime? finalizedAt;
final List<List<double>> signature;
final List<String> participantIds;
final List<String> activityItems;
final List<MaterialEntry> materialItems;
final String additionalOrder;
final bool additionalOrderApproved;
final int breakMinutes;
final int version;
final List<String> photoIds;
final List<String> fileIds;
final List<String> fileNames;
final String? customerId;
final String? siteId;
final String? jobId;
final String? signatureAssetId;
int get grossMinutes {
final startValue = start.hour * 60 + start.minute;
final endValue = end.hour * 60 + end.minute;
return endValue - startValue;
}
int get netMinutes => grossMinutes - breakMinutes;
Map<String, Object?> toJson() => {
'id': id,
'customer': customer,
'site': site,
'activity': activity,
'material': material,
'quantity': quantity,
'date': date.toIso8601String(),
'startHour': start.hour,
'startMinute': start.minute,
'endHour': end.hour,
'endMinute': end.minute,
'isFinalized': isFinalized,
'finalizedAt': finalizedAt?.toIso8601String(),
'signature': signature,
'participantIds': participantIds,
'activityItems': activityItems,
'materialItems': materialItems.map((item) => item.toJson()).toList(),
'additionalOrder': additionalOrder,
'additionalOrderApproved': additionalOrderApproved,
'breakMinutes': breakMinutes,
'version': version,
'photoIds': photoIds,
'fileIds': fileIds,
'fileNames': fileNames,
'customerId': customerId,
'siteId': siteId,
'jobId': jobId,
'signatureAssetId': signatureAssetId,
};
factory DemoReport.fromJson(Map<String, dynamic> json) => DemoReport(
id: json['id'] as String,
customer: json['customer'] as String,
site: json['site'] as String,
activity: json['activity'] as String,
material: json['material'] as String? ?? '',
quantity: json['quantity'] as String? ?? '',
date: DateTime.parse(json['date'] as String),
start: TimeOfDay(
hour: json['startHour'] as int,
minute: json['startMinute'] as int,
),
end: TimeOfDay(
hour: json['endHour'] as int,
minute: json['endMinute'] as int,
),
isFinalized: json['isFinalized'] as bool? ?? false,
finalizedAt: json['finalizedAt'] == null
? null
: DateTime.parse(json['finalizedAt'] as String),
signature: (json['signature'] as List<dynamic>? ?? [])
.map(
(point) => (point as List<dynamic>)
.map((value) => (value as num).toDouble())
.toList(),
)
.toList(),
participantIds:
(json['participantIds'] as List<dynamic>? ?? const ['demo-max'])
.cast<String>(),
activityItems:
(json['activityItems'] as List<dynamic>? ?? [json['activity']])
.whereType<String>()
.where((value) => value.isNotEmpty)
.toList(),
materialItems: json['materialItems'] is List<dynamic>
? (json['materialItems'] as List<dynamic>)
.map(
(value) =>
MaterialEntry.fromJson(value as Map<String, dynamic>),
)
.toList()
: (json['material'] as String? ?? '').isEmpty
? const []
: [
MaterialEntry(
name: json['material'] as String,
quantity: json['quantity'] as String? ?? '',
unit: 'Stk.',
),
],
additionalOrder: json['additionalOrder'] as String? ?? '',
additionalOrderApproved: json['additionalOrderApproved'] as bool? ?? false,
breakMinutes: json['breakMinutes'] as int? ?? 0,
version: json['version'] as int? ?? 1,
photoIds: (json['photoIds'] as List<dynamic>? ?? const []).cast<String>(),
fileIds: (json['fileIds'] as List<dynamic>? ?? const []).cast<String>(),
fileNames: (json['fileNames'] as List<dynamic>? ?? const []).cast<String>(),
customerId: json['customerId'] as String?,
siteId: json['siteId'] as String?,
jobId: json['jobId'] as String?,
signatureAssetId: json['signatureAssetId'] as String?,
);
DemoReport withVersion(int nextVersion) => DemoReport(
id: id,
customer: customer,
site: site,
activity: activity,
material: material,
quantity: quantity,
date: date,
start: start,
end: end,
isFinalized: isFinalized,
finalizedAt: finalizedAt,
signature: signature,
participantIds: participantIds,
activityItems: activityItems,
materialItems: materialItems,
additionalOrder: additionalOrder,
additionalOrderApproved: additionalOrderApproved,
breakMinutes: breakMinutes,
version: nextVersion,
photoIds: photoIds,
fileIds: fileIds,
fileNames: fileNames,
customerId: customerId,
siteId: siteId,
jobId: jobId,
signatureAssetId: signatureAssetId,
);
DemoReport finalized(List<List<double>> signatureData) => DemoReport(
id: id,
customer: customer,
site: site,
activity: activity,
material: material,
quantity: quantity,
date: date,
start: start,
end: end,
isFinalized: true,
finalizedAt: DateTime.now(),
signature: signatureData,
participantIds: participantIds,
activityItems: activityItems,
materialItems: materialItems,
additionalOrder: additionalOrder,
additionalOrderApproved: additionalOrderApproved,
breakMinutes: breakMinutes,
version: version + 1,
photoIds: photoIds,
fileIds: fileIds,
fileNames: fileNames,
customerId: customerId,
siteId: siteId,
jobId: jobId,
signatureAssetId: newUuid(),
);
DemoReport withReferences({
String? customerId,
String? siteId,
String? jobId,
}) => DemoReport(
id: id,
customer: customer,
site: site,
activity: activity,
material: material,
quantity: quantity,
date: date,
start: start,
end: end,
isFinalized: isFinalized,
finalizedAt: finalizedAt,
signature: signature,
participantIds: participantIds,
activityItems: activityItems,
materialItems: materialItems,
additionalOrder: additionalOrder,
additionalOrderApproved: additionalOrderApproved,
breakMinutes: breakMinutes,
version: version,
photoIds: photoIds,
fileIds: fileIds,
fileNames: fileNames,
customerId: customerId ?? this.customerId,
siteId: siteId ?? this.siteId,
jobId: jobId ?? this.jobId,
signatureAssetId: signatureAssetId,
);
}
enum ReportAuditAction { created, updated, finalized, deleted }
class ReportAuditEntry {
const ReportAuditEntry({
required this.id,
required this.reportId,
required this.action,
required this.timestamp,
required this.version,
required this.snapshot,
});
final String id;
final String reportId;
final ReportAuditAction action;
final DateTime timestamp;
final int version;
final DemoReport snapshot;
String get label => switch (action) {
ReportAuditAction.created => 'Entwurf erstellt',
ReportAuditAction.updated => 'Entwurf bearbeitet',
ReportAuditAction.finalized => 'Vom Kunden unterschrieben',
ReportAuditAction.deleted => 'Entwurf gelöscht',
};
Map<String, Object?> toJson() => {
'id': id,
'reportId': reportId,
'action': action.name,
'timestamp': timestamp.toIso8601String(),
'version': version,
'snapshot': snapshot.toJson(),
};
factory ReportAuditEntry.fromJson(Map<String, dynamic> json) {
final actionName = json['action'] as String? ?? 'created';
return ReportAuditEntry(
id: json['id'] as String,
reportId: json['reportId'] as String,
action: ReportAuditAction.values.firstWhere(
(value) => value.name == actionName,
orElse: () => ReportAuditAction.created,
),
timestamp: DateTime.parse(json['timestamp'] as String),
version: json['version'] as int? ?? 1,
snapshot: DemoReport.fromJson(json['snapshot'] as Map<String, dynamic>),
);
}
}
class DemoReportStore extends ChangeNotifier {
DemoReportStore._();
static final instance = DemoReportStore._();
static const _storageKey = 'machweis_reports_repository_v2';
static const _legacyStorageKey = 'machweis_demo_reports_v1';
final List<DemoReport> _reports = [];
final List<ReportAuditEntry> _history = [];
int webPage = 1;
int webLimit = 10;
int webTotal = 0;
String webSearch = '';
String? webStatus;
List<DemoReport> get reports => List.unmodifiable(_reports);
List<ReportAuditEntry> historyFor(String reportId) => List.unmodifiable(
_history.where((entry) => entry.reportId == reportId).toList()
..sort((a, b) => b.timestamp.compareTo(a.timestamp)),
);
Future<void> initialize() async {
if (kIsWeb && AuthStore.instance.session != null) {
try {
final result = await WebOfficeApi.instance.listReports();
_reports
..clear()
..addAll(result.items.map(_fromServerRow));
webPage = result.page;
webLimit = result.limit;
webTotal = result.total;
_history.clear();
} on Object {
// Keep the last loaded web snapshot visible on transient errors.
}
notifyListeners();
return;
}
if (LocalStorage.instance.usesRelationalStore) {
final repository = LocalReportRepository();
final loaded = await repository.readAll();
if (loaded.reports.isNotEmpty || loaded.history.isNotEmpty) {
_reports
..clear()
..addAll(loaded.reports);
_history
..clear()
..addAll(loaded.history);
return;
}
if (AuthStore.instance.session != null) {
_reports.clear();
_history.clear();
notifyListeners();
return;
}
await _migrateJsonRepository(repository);
return;
}
final stored = await LocalStorage.instance.getString(_storageKey);
if (stored == null) {
await _migrateLegacyReports();
return;
}
try {
final repository = jsonDecode(stored) as Map<String, dynamic>;
final values = repository['reports'] as List<dynamic>? ?? const [];
final history = repository['history'] as List<dynamic>? ?? const [];
_reports
..clear()
..addAll(
values.map(
(value) => DemoReport.fromJson(value as Map<String, dynamic>),
),
);
_history
..clear()
..addAll(
history.map(
(value) => ReportAuditEntry.fromJson(value as Map<String, dynamic>),
),
);
_linkLegacyReports();
} on Object {
await LocalStorage.instance.remove(_storageKey);
}
}
Future<void> loadWebReports({
String search = '',
String? status,
int page = 1,
}) async {
if (!kIsWeb || AuthStore.instance.session == null) return;
final result = await WebOfficeApi.instance.listReports(
search: search,
status: status,
page: page,
limit: webLimit,
);
_reports
..clear()
..addAll(result.items.map(_fromServerRow));
webSearch = search;
webStatus = status;
webPage = result.page;
webTotal = result.total;
_history.clear();
notifyListeners();
}
DemoReport _fromServerRow(Map<String, dynamic> row) {
final payload = Map<String, dynamic>.from(
row['payload'] as Map? ?? const {},
);
final merged = <String, dynamic>{
...payload,
'id': row['id'] ?? payload['id'],
'version': row['current_version'] ?? payload['version'] ?? 1,
'customer': payload['customer'] ?? '',
'site': payload['site'] ?? 'Nachweis',
'activity': payload['activity'] ?? '',
'date':
payload['date'] ??
row['created_at'] ??
DateTime.now().toIso8601String(),
'startHour': payload['startHour'] ?? 0,
'startMinute': payload['startMinute'] ?? 0,
'endHour': payload['endHour'] ?? 0,
'endMinute': payload['endMinute'] ?? 0,
'isFinalized':
row['status'] == 'finalized' || payload['isFinalized'] == true,
};
return DemoReport.fromJson(merged);
}
Future<void> _migrateLegacyReports() async {
final stored = await LocalStorage.instance.getString(_legacyStorageKey);
if (stored == null) return;
try {
final values = jsonDecode(stored) as List<dynamic>;
_reports
..clear()
..addAll(
values.map(
(value) => DemoReport.fromJson(value as Map<String, dynamic>),
),
);
_linkLegacyReports();
final migratedAt = DateTime.now();
_history
..clear()
..addAll(
_reports.map(
(report) => _auditEntry(
report,
report.isFinalized
? ReportAuditAction.finalized
: ReportAuditAction.created,
timestamp: migratedAt,
),
),
);
await _persist();
await LocalStorage.instance.remove(_legacyStorageKey);
} on Object {
await LocalStorage.instance.remove(_legacyStorageKey);
}
}
Future<void> add(DemoReport report) async {
final initial = report.withVersion(1);
if (kIsWeb && AuthStore.instance.session != null) {
await WebOfficeApi.instance.createReport({
'jobId': initial.jobId,
'status': initial.isFinalized ? 'finalized' : 'draft',
'payload': Map<String, dynamic>.from(initial.toJson()),
});
await initialize();
return;
}
_reports.insert(0, initial);
_history.add(_auditEntry(initial, ReportAuditAction.created));
notifyListeners();
await _persist();
await _enqueue(initial, SyncAction.create);
}
Future<void> remove(String id) async {
final index = _reports.indexWhere((item) => item.id == id);
if (index == -1 || _reports[index].isFinalized) return;
final report = _reports[index];
if (kIsWeb && AuthStore.instance.session != null) {
await WebOfficeApi.instance.deleteReport(report.id);
await initialize();
return;
}
_reports.removeWhere((report) => report.id == id);
_history.add(_auditEntry(report, ReportAuditAction.deleted));
notifyListeners();
await _persist();
final tenantId = AuthStore.instance.session?.tenantId;
await SyncQueueStore.instance.enqueue(
entityType: 'report',
entityId: report.id,
action: SyncAction.delete,
payload: {'id': report.id, 'version': report.version},
tenantId: tenantId,
baseRevision: tenantId == null
? 0
: SyncQueueStore.instance.reportRevision(tenantId, report.id),
);
await ReportSyncController.instance.synchronize();
for (final photoId in report.photoIds) {
await ReportPhotoStore.instance.remove(photoId);
}
for (final fileId in report.fileIds) {
await ReportAssetSyncController.instance.enqueueDelete(
assetId: fileId,
reportId: report.id,
);
}
if (report.fileIds.isNotEmpty) {
await ReportAssetSyncController.instance.synchronize();
}
}
Future<void> update(DemoReport report) async {
final index = _reports.indexWhere((item) => item.id == report.id);
if (index == -1) return;
if (_reports[index].isFinalized) return;
final updated = report.withVersion(_reports[index].version + 1);
if (kIsWeb && AuthStore.instance.session != null) {
await WebOfficeApi.instance.addReportVersion(
updated.id,
Map<String, dynamic>.from(updated.toJson()),
);
await initialize();
return;
}
_reports[index] = updated;
_history.add(_auditEntry(updated, ReportAuditAction.updated));
notifyListeners();
await _persist();
await _enqueue(updated, SyncAction.update);
}
Future<void> finalize(String id, List<List<double>> signature) async {
final index = _reports.indexWhere((item) => item.id == id);
if (index == -1 || _reports[index].isFinalized) return;
final finalized = _reports[index].finalized(signature);
if (kIsWeb && AuthStore.instance.session != null) {
await WebOfficeApi.instance.addReportVersion(
finalized.id,
Map<String, dynamic>.from(finalized.toJson()),
status: 'finalized',
);
await initialize();
return;
}
_reports[index] = finalized;
_history.add(_auditEntry(_reports[index], ReportAuditAction.finalized));
notifyListeners();
await _persist();
await _enqueue(_reports[index], SyncAction.finalize);
final persisted = _reports[index];
if (persisted.signatureAssetId != null) {
await ReportAssetSyncController.instance.enqueueSignature(
assetId: persisted.signatureAssetId!,
reportId: persisted.id,
points: persisted.signature,
);
await ReportAssetSyncController.instance.synchronize();
}
}
Future<void> _persist() async {
if (LocalStorage.instance.usesRelationalStore) {
await LocalReportRepository().replaceAll(_reports, _history);
return;
}
await LocalStorage.instance.setString(
_storageKey,
jsonEncode({
'reports': _reports.map((report) => report.toJson()).toList(),
'history': _history.map((entry) => entry.toJson()).toList(),
}),
);
}
Future<void> _migrateJsonRepository(LocalReportRepository repository) async {
final stored = await LocalStorage.instance.getString(_storageKey);
if (stored != null) {
try {
final value = jsonDecode(stored) as Map<String, dynamic>;
_reports
..clear()
..addAll(
(value['reports'] as List<dynamic>? ?? const []).map(
(item) => DemoReport.fromJson(item as Map<String, dynamic>),
),
);
_history
..clear()
..addAll(
(value['history'] as List<dynamic>? ?? const []).map(
(item) => ReportAuditEntry.fromJson(item as Map<String, dynamic>),
),
);
_linkLegacyReports();
await repository.replaceAll(_reports, _history);
return;
} on Object {
// Fall through to the older report format without deleting either.
}
}
await _migrateLegacyReports();
if (_reports.isNotEmpty || _history.isNotEmpty) {
await repository.replaceAll(_reports, _history);
}
}
void _linkLegacyReports() {
for (var index = 0; index < _reports.length; index++) {
final report = _reports[index];
if (report.customerId != null &&
report.siteId != null &&
report.jobId != null) {
continue;
}
final matches = JobStore.instance.jobs
.where(
(job) =>
job.customer.trim() == report.customer.trim() &&
job.site.trim() == report.site.trim(),
)
.toList();
if (matches.length == 1) {
final job = matches.single;
_reports[index] = report.withReferences(
customerId: job.customerId,
siteId: job.siteId,
jobId: job.id,
);
}
}
}
static ReportAuditEntry _auditEntry(
DemoReport report,
ReportAuditAction action, {
DateTime? timestamp,
}) {
final occurredAt = timestamp ?? DateTime.now();
return ReportAuditEntry(
id: newUuid(),
reportId: report.id,
action: action,
timestamp: occurredAt,
version: report.version,
snapshot: report,
);
}
Future<void> _enqueue(DemoReport report, SyncAction action) async {
final tenantId = AuthStore.instance.session?.tenantId;
await SyncQueueStore.instance.enqueue(
entityType: 'report',
entityId: report.id,
action: action,
payload: report.toJson(),
tenantId: tenantId,
baseRevision: action == SyncAction.create || tenantId == null
? 0
: SyncQueueStore.instance.reportRevision(tenantId, report.id),
);
await ReportSyncController.instance.synchronize();
await ReportAssetSyncController.instance.synchronize();
}
}
class LocalReportData {
const LocalReportData(this.reports, this.history);
final List<DemoReport> reports;
final List<ReportAuditEntry> history;
}
class LocalReportRepository {
Future<LocalReportData> readAll() async {
final tenantId = AuthStore.instance.session?.tenantId;
final reports =
(await LocalStorage.instance.mobileQuery(
'reports',
where: tenantId == null ? 'tenant_id IS NULL' : 'tenant_id = ?',
whereArgs: tenantId == null ? null : [tenantId],
))
.map(
(row) => DemoReport.fromJson(
jsonDecode(row['payload'] as String) as Map<String, dynamic>,
),
)
.toList();
final history =
(await LocalStorage.instance.mobileQuery(
'report_audit',
where: tenantId == null ? 'tenant_id IS NULL' : 'tenant_id = ?',
whereArgs: tenantId == null ? null : [tenantId],
))
.map(
(row) => ReportAuditEntry.fromJson(
jsonDecode(row['payload'] as String) as Map<String, dynamic>,
),
)
.toList();
return LocalReportData(reports, history);
}
Future<void> replaceAll(
List<DemoReport> reports,
List<ReportAuditEntry> history,
) async {
final tenantId = AuthStore.instance.session?.tenantId;
final where = tenantId == null ? 'tenant_id IS NULL' : 'tenant_id = ?';
final whereArgs = tenantId == null ? <Object?>[] : <Object?>[tenantId];
await LocalStorage.instance.deleteRows(
'reports',
where: where,
whereArgs: whereArgs,
);
await LocalStorage.instance.deleteRows(
'report_audit',
where: where,
whereArgs: whereArgs,
);
for (final report in reports) {
await LocalStorage.instance.replaceRow('reports', {
'id': report.id,
'tenant_id': tenantId,
'customer_id': report.customerId,
'site_id': report.siteId,
'job_id': report.jobId,
'payload': jsonEncode(report.toJson()),
});
}
for (final entry in history) {
await LocalStorage.instance.replaceRow('report_audit', {
'id': entry.id,
'tenant_id': tenantId,
'report_id': entry.reportId,
'timestamp': entry.timestamp.toIso8601String(),
'payload': jsonEncode(entry.toJson()),
});
}
}
}
