460 lines
13 KiB
Dart
460 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
import 'package:dio/dio.dart';
|
|
import 'package:drift/drift.dart';
|
|
import 'package:niogu_app/core/constants/app_url.dart';
|
|
import 'package:niogu_app/core/database/app_database.dart';
|
|
import 'package:niogu_app/core/enums/sync_status.dart';
|
|
import 'package:niogu_app/core/enums/table_type.dart';
|
|
import 'package:niogu_app/core/system/system_setting.dart';
|
|
import 'package:niogu_app/core/utils/log_message.dart';
|
|
import 'package:rxdart/rxdart.dart';
|
|
|
|
class SyncService {
|
|
final Dio _dio;
|
|
final AppDatabase _db;
|
|
StreamSubscription? _syncSubscription;
|
|
bool _isProcessing = false;
|
|
int _retryCount = 0;
|
|
|
|
SyncService(this._dio, this._db);
|
|
|
|
final Map<String, int> _tablePriority = {
|
|
'tenants': 1,
|
|
'tenant_payment_methods': 2,
|
|
'campaigns': 3,
|
|
'outlets': 4,
|
|
'outlet_campaigns': 5,
|
|
'users': 6,
|
|
'units': 7,
|
|
'categories': 8,
|
|
'products': 9,
|
|
'product_variants': 10,
|
|
'raw_materials': 11,
|
|
'outlet_inventories': 12,
|
|
'receipt_items': 13,
|
|
'customers': 14,
|
|
'customer_addresses': 15,
|
|
'product_likes': 16,
|
|
'product_reviews': 17,
|
|
'suppliers': 18,
|
|
'orders': 19,
|
|
'purchases': 20,
|
|
'transactions': 21,
|
|
'order_items': 22,
|
|
'purchase_items': 23,
|
|
'stock_cards': 24,
|
|
};
|
|
|
|
final _imageFields = [
|
|
'server_logo_url',
|
|
'server_photo_url',
|
|
'server_banner_url',
|
|
'server_image_url',
|
|
'payment_proof_url',
|
|
'product_image_url_snapshot',
|
|
'item_image_url_snapshot',
|
|
];
|
|
|
|
Future<void> startWatching() async {
|
|
_syncSubscription =
|
|
(_db.select(_db.syncQueues)..orderBy([
|
|
(t) => OrderingTerm(expression: t.id, mode: OrderingMode.asc),
|
|
]))
|
|
.watch()
|
|
.debounceTime(const Duration(seconds: 3))
|
|
.listen((queuedItems) async {
|
|
if (queuedItems.isNotEmpty && !_isProcessing) {
|
|
await processBatch();
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> restartUpSync() async {
|
|
final connectivityResult = await Connectivity().checkConnectivity();
|
|
|
|
if (connectivityResult.any((con) => con == ConnectivityResult.none)) {
|
|
throw Exception();
|
|
}
|
|
|
|
await (_db.update(
|
|
_db.syncQueues,
|
|
)..where((t) => t.retryCount.isBiggerOrEqualValue(5))).write(
|
|
SyncQueuesCompanion(
|
|
status: Value(SyncStatus.dirty.status),
|
|
retryCount: Value(0),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> processBatch() async {
|
|
LogMessage.log.i("=== Process Batch ===");
|
|
final isLoggedIn = await SystemSetting.isLoggedIn();
|
|
|
|
if (!isLoggedIn) return;
|
|
|
|
final connectivityResult = await Connectivity().checkConnectivity();
|
|
|
|
if (connectivityResult.any((con) => con == ConnectivityResult.none)) return;
|
|
|
|
if (_isProcessing) return;
|
|
|
|
LogMessage.log.i("=== Start Process === ");
|
|
|
|
_isProcessing = true;
|
|
|
|
final String currentBatchId = DateTime.now().millisecondsSinceEpoch
|
|
.toString();
|
|
|
|
try {
|
|
final rawItems =
|
|
await (_db.select(_db.syncQueues)
|
|
..where(
|
|
(t) =>
|
|
(t.status.equals(SyncStatus.dirty.status) |
|
|
t.status.equals(SyncStatus.failed.status)) &
|
|
t.retryCount.isSmallerThanValue(5),
|
|
)
|
|
..orderBy([
|
|
(t) => OrderingTerm(expression: t.id, mode: OrderingMode.asc),
|
|
]))
|
|
.get();
|
|
|
|
if (rawItems.isEmpty) {
|
|
_isProcessing = false;
|
|
return;
|
|
}
|
|
|
|
final sortedItems = List<SyncQueue>.from(rawItems);
|
|
sortedItems.sort((a, b) {
|
|
final int priorityA = _tablePriority[a.tableType] ?? 99;
|
|
final int priorityB = _tablePriority[b.tableType] ?? 99;
|
|
|
|
if (priorityA == priorityB) {
|
|
return a.id.compareTo(b.id);
|
|
}
|
|
return priorityA.compareTo(priorityB);
|
|
});
|
|
|
|
final items = sortedItems.take(100).toList();
|
|
|
|
await _db.batch((batch) {
|
|
for (var item in items) {
|
|
batch.update(
|
|
_db.syncQueues,
|
|
SyncQueuesCompanion(
|
|
status: Value(SyncStatus.syncing.status),
|
|
batchId: Value(currentBatchId),
|
|
),
|
|
where: (t) => t.id.equals(item.id),
|
|
);
|
|
}
|
|
});
|
|
|
|
await Future.delayed(const Duration(seconds: 3));
|
|
|
|
await _handleImageUploads(items);
|
|
|
|
await Future.delayed(const Duration(seconds: 3));
|
|
|
|
final refreshedItems = await (_db.select(
|
|
_db.syncQueues,
|
|
)..where((t) => t.id.isIn(items.map((e) => e.id)))).get();
|
|
|
|
await Future.delayed(const Duration(seconds: 3));
|
|
|
|
final batchPayload = {
|
|
'batch_id': currentBatchId,
|
|
'items': refreshedItems
|
|
.map((item) => jsonEncode(item.payload))
|
|
.toList(),
|
|
};
|
|
|
|
LogMessage.log.i("=== Process To Sync Up, Try $_retryCount ===");
|
|
|
|
final response = await _dio.post(AppUrl.SYNC_UP, data: batchPayload);
|
|
|
|
await Future.delayed(const Duration(seconds: 3));
|
|
|
|
final report = response.data['report'];
|
|
|
|
await _db.transaction(() async {
|
|
for (var item in refreshedItems) {
|
|
final hasError = (report['errors'] as List).any(
|
|
(e) => e['local_id'] == item.tableLocalId,
|
|
);
|
|
|
|
if (!hasError) {
|
|
final payload = Map<String, dynamic>.from(item.payload);
|
|
|
|
await _updateMasterSyncStatus(
|
|
item.tableType,
|
|
item.tableLocalId,
|
|
SyncStatus.synced.status,
|
|
data: payload['data'],
|
|
);
|
|
|
|
await (_db.delete(
|
|
_db.syncQueues,
|
|
)..where((t) => t.id.equals(item.id))).go();
|
|
}
|
|
}
|
|
});
|
|
|
|
LogMessage.log.i("=== Process Sync Up Done ===");
|
|
} catch (e, st) {
|
|
LogMessage.log.e(
|
|
"=== Process Sync Up Failed ===",
|
|
error: e,
|
|
stackTrace: st,
|
|
);
|
|
await _handleBatchFailure(currentBatchId, e.toString());
|
|
rethrow;
|
|
} finally {
|
|
_isProcessing = false;
|
|
_retryCount++;
|
|
}
|
|
}
|
|
|
|
Future<void> _handleImageUploads(List<SyncQueue> items) async {
|
|
for (final item in items) {
|
|
final payload = Map<String, dynamic>.from(item.payload);
|
|
final data = Map<String, dynamic>.from(payload['data']);
|
|
bool isChanged = false;
|
|
|
|
for (final field in _imageFields) {
|
|
if (data.containsKey(field) && data[field] != null) {
|
|
final imageValue = data[field];
|
|
|
|
if (imageValue is List) {
|
|
final List<String> serverPaths = [];
|
|
|
|
for (final path in imageValue) {
|
|
final pathStr = path.toString();
|
|
|
|
if (_isLocalPath(pathStr)) {
|
|
final folderType = _getFolderTypeFromPath(pathStr);
|
|
|
|
final uploaded = await _uploadSingleFile(pathStr, folderType);
|
|
|
|
if (uploaded != null) {
|
|
serverPaths.add(uploaded);
|
|
isChanged = true;
|
|
} else {
|
|
serverPaths.add(pathStr);
|
|
}
|
|
} else {
|
|
serverPaths.add(pathStr);
|
|
}
|
|
}
|
|
data[field] = serverPaths;
|
|
} else if (imageValue is String && _isLocalPath(imageValue)) {
|
|
final folderType = _getFolderTypeFromPath(imageValue);
|
|
|
|
final uploaded = await _uploadSingleFile(imageValue, folderType);
|
|
|
|
if (uploaded != null) {
|
|
data[field] = uploaded;
|
|
isChanged = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isChanged) {
|
|
payload['data'] = data;
|
|
await (_db.update(_db.syncQueues)..where((t) => t.id.equals(item.id)))
|
|
.write(SyncQueuesCompanion(payload: Value(payload)));
|
|
|
|
LogMessage.log.i(
|
|
"Payload updated with server paths for item: ${item.tableLocalId}",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _isLocalPath(String path) {
|
|
return path.startsWith('/') ||
|
|
path.contains('app_flutter') ||
|
|
path.contains('cache');
|
|
}
|
|
|
|
String _getFolderTypeFromPath(String path) {
|
|
if (path.contains('product_category_images')) {
|
|
return 'product_category_images';
|
|
} else if (path.contains('product_images')) {
|
|
return 'product_images';
|
|
} else if (path.contains('product_variant_images')) {
|
|
return 'product_variant_images';
|
|
} else if (path.contains('payment_proof_orders')) {
|
|
return 'payment_proof_orders';
|
|
} else if (path.contains('payment_proof_purchases')) {
|
|
return 'payment_proof_purchases';
|
|
} else if (path.contains('outlet_banner_images')) {
|
|
return 'outlet_banner_images';
|
|
} else if(path.contains('campaign_images')) {
|
|
return 'campaign_images';
|
|
} else if(path.contains('logo_images')) {
|
|
return 'logo_images';
|
|
}
|
|
return 'others';
|
|
}
|
|
|
|
Future<String?> _uploadSingleFile(String localPath, String folderType) async {
|
|
try {
|
|
final file = File(localPath);
|
|
if (!await file.exists()) {
|
|
LogMessage.log.e("File fisik tidak ditemukan: $localPath");
|
|
return null;
|
|
}
|
|
|
|
final fileName = localPath.split('/').last;
|
|
|
|
final formData = FormData.fromMap({
|
|
"type": folderType,
|
|
"image": await MultipartFile.fromFile(localPath, filename: fileName),
|
|
});
|
|
|
|
final response = await _dio.post(AppUrl.UPLOAD_IMAGE, data: formData);
|
|
|
|
if (response.statusCode == 200) {
|
|
return response.data['data']['server_path'];
|
|
}
|
|
return null;
|
|
} catch (e) {
|
|
LogMessage.log.e("Gagal upload ke $folderType: $e");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<void> _updateMasterSyncStatus(
|
|
String tableName,
|
|
String localId,
|
|
String status, {
|
|
Map<String, dynamic>? data,
|
|
}) async {
|
|
String setClause = 'SET sync_status = ?, server_id = ?';
|
|
|
|
List<Variable> variables = [Variable(status), Variable(localId)];
|
|
|
|
if (data != null) {
|
|
for (var field in _imageFields) {
|
|
if (data.containsKey(field) && data[field] != null) {
|
|
setClause += ', $field = ?';
|
|
|
|
final value = data[field] is List
|
|
? jsonEncode(data[field])
|
|
: data[field];
|
|
|
|
variables.add(Variable(value));
|
|
}
|
|
}
|
|
}
|
|
|
|
variables.add(Variable(localId));
|
|
|
|
await _db.customUpdate(
|
|
'UPDATE $tableName $setClause WHERE local_id = ?',
|
|
variables: variables,
|
|
updates: {_getTableSet(TableType.values.byName(tableName))},
|
|
);
|
|
}
|
|
|
|
ResultSetImplementation _getTableSet(TableType type) {
|
|
switch (type) {
|
|
case TableType.campaigns:
|
|
return _db.campaigns;
|
|
case TableType.categories:
|
|
return _db.categories;
|
|
case TableType.customers:
|
|
return _db.customers;
|
|
case TableType.order_items:
|
|
return _db.orderItems;
|
|
case TableType.orders:
|
|
return _db.orders;
|
|
case TableType.outlet_campaigns:
|
|
return _db.outletCampaigns;
|
|
case TableType.outlet_inventories:
|
|
return _db.outletInventories;
|
|
case TableType.outlets:
|
|
return _db.outlets;
|
|
case TableType.product_likes:
|
|
return _db.productLikes;
|
|
case TableType.product_reviews:
|
|
return _db.productReviews;
|
|
case TableType.product_variants:
|
|
return _db.productVariants;
|
|
case TableType.products:
|
|
return _db.products;
|
|
case TableType.purchase_items:
|
|
return _db.purchaseItems;
|
|
case TableType.purchases:
|
|
return _db.purchases;
|
|
case TableType.raw_materials:
|
|
return _db.rawMaterials;
|
|
case TableType.receipt_items:
|
|
return _db.receiptItems;
|
|
case TableType.stock_cards:
|
|
return _db.stockCards;
|
|
case TableType.suppliers:
|
|
return _db.suppliers;
|
|
case TableType.tenant_payment_methods:
|
|
return _db.tenantPaymentMethods;
|
|
case TableType.tenants:
|
|
return _db.tenants;
|
|
case TableType.transactions:
|
|
return _db.transactions;
|
|
case TableType.units:
|
|
return _db.units;
|
|
case TableType.users:
|
|
return _db.users;
|
|
}
|
|
}
|
|
|
|
Future<void> _handleBatchFailure(String batchId, String errorMessage) async {
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
|
|
try {
|
|
await _db.transaction(() async {
|
|
final items = await (_db.select(
|
|
_db.syncQueues,
|
|
)..where((t) => t.batchId.equals(batchId))).get();
|
|
|
|
for (var item in items) {
|
|
final isPermanentFailure = item.retryCount >= 5;
|
|
|
|
await (_db.update(
|
|
_db.syncQueues,
|
|
)..where((t) => t.id.equals(item.id))).write(
|
|
SyncQueuesCompanion(
|
|
status: Value(
|
|
isPermanentFailure
|
|
? SyncStatus.failed.status
|
|
: SyncStatus.dirty.status,
|
|
),
|
|
batchId: const Value(null),
|
|
retryCount: Value(item.retryCount + 1),
|
|
lastError: Value(errorMessage),
|
|
),
|
|
);
|
|
}
|
|
});
|
|
|
|
LogMessage.log.e(
|
|
"Batch $batchId failed. Items reverted to dirty for retry. with error $errorMessage",
|
|
);
|
|
} catch (e, st) {
|
|
LogMessage.log.e(
|
|
"Critical error in _handleBatchFailure: ${e.toString()}",
|
|
error: e,
|
|
stackTrace: st,
|
|
);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
void stopWatching() => _syncSubscription?.cancel();
|
|
}
|