324 lines
8.6 KiB
Dart
324 lines
8.6 KiB
Dart
|
|
import 'dart:async';
|
|||
|
|
import 'dart:developer';
|
|||
|
|
|
|||
|
|
import 'package:bloc/bloc.dart';
|
|||
|
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
|||
|
|
import 'package:injectable/injectable.dart';
|
|||
|
|
|
|||
|
|
import '../../domain/category/category.dart';
|
|||
|
|
import '../../domain/product/product.dart';
|
|||
|
|
|
|||
|
|
part 'sync_event.dart';
|
|||
|
|
part 'sync_state.dart';
|
|||
|
|
part 'sync_bloc.freezed.dart';
|
|||
|
|
|
|||
|
|
enum SyncStep { categories, products, variants, completed }
|
|||
|
|
|
|||
|
|
class SyncStats {
|
|||
|
|
final int totalProducts;
|
|||
|
|
final int totalCategories;
|
|||
|
|
final int totalVariants;
|
|||
|
|
final double databaseSizeMB;
|
|||
|
|
|
|||
|
|
SyncStats({
|
|||
|
|
required this.totalProducts,
|
|||
|
|
required this.totalCategories,
|
|||
|
|
required this.totalVariants,
|
|||
|
|
required this.databaseSizeMB,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@injectable
|
|||
|
|
class SyncBloc extends Bloc<SyncEvent, SyncState> {
|
|||
|
|
final IProductRepository _productRepository;
|
|||
|
|
final ICategoryRepository _categoryRepository;
|
|||
|
|
|
|||
|
|
Timer? _progressTimer;
|
|||
|
|
bool _isCancelled = false;
|
|||
|
|
|
|||
|
|
SyncBloc(this._productRepository, this._categoryRepository)
|
|||
|
|
: super(SyncState.initial()) {
|
|||
|
|
on<SyncEvent>(_onSyncEvent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _onSyncEvent(SyncEvent event, Emitter<SyncState> emit) {
|
|||
|
|
return event.map(
|
|||
|
|
startSync: (e) async {
|
|||
|
|
log('🔄 Starting full data sync (categories + products)...');
|
|||
|
|
_isCancelled = false;
|
|||
|
|
|
|||
|
|
emit(
|
|||
|
|
state.copyWith(
|
|||
|
|
isSyncing: true,
|
|||
|
|
currentStep: SyncStep.categories,
|
|||
|
|
progress: 0.05,
|
|||
|
|
message: 'Membersihkan data lama...',
|
|||
|
|
errorMessage: null,
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// Step 1: Clear existing local data
|
|||
|
|
await _productRepository.clearAllProducts();
|
|||
|
|
await _categoryRepository.clearAllCategories();
|
|||
|
|
|
|||
|
|
if (_isCancelled) return;
|
|||
|
|
|
|||
|
|
// Step 2: Sync categories first
|
|||
|
|
await _syncCategories(emit);
|
|||
|
|
|
|||
|
|
if (_isCancelled) return;
|
|||
|
|
|
|||
|
|
// Step 3: Sync products
|
|||
|
|
await _syncProducts(emit);
|
|||
|
|
|
|||
|
|
if (_isCancelled) return;
|
|||
|
|
|
|||
|
|
// Step 4: Final stats
|
|||
|
|
emit(
|
|||
|
|
state.copyWith(
|
|||
|
|
currentStep: SyncStep.completed,
|
|||
|
|
progress: 0.95,
|
|||
|
|
message: 'Menyelesaikan sinkronisasi...',
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
final stats = await _generateSyncStats();
|
|||
|
|
|
|||
|
|
emit(
|
|||
|
|
state.copyWith(
|
|||
|
|
isSyncing: false,
|
|||
|
|
stats: stats,
|
|||
|
|
progress: 1.0,
|
|||
|
|
message: 'Sinkronisasi selesai ✅',
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
log('✅ Full sync completed successfully');
|
|||
|
|
} catch (e) {
|
|||
|
|
log('❌ Sync failed: $e');
|
|||
|
|
emit(
|
|||
|
|
state.copyWith(
|
|||
|
|
isSyncing: false,
|
|||
|
|
errorMessage: 'Gagal sinkronisasi: $e',
|
|||
|
|
message: 'Sinkronisasi gagal ❌',
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
cancelSync: (e) async {
|
|||
|
|
log('⏹️ Cancelling sync...');
|
|||
|
|
_isCancelled = true;
|
|||
|
|
_progressTimer?.cancel();
|
|||
|
|
emit(SyncState.initial());
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _syncCategories(Emitter<SyncState> emit) async {
|
|||
|
|
log('📁 Syncing categories...');
|
|||
|
|
|
|||
|
|
emit(
|
|||
|
|
state.copyWith(
|
|||
|
|
isSyncing: true,
|
|||
|
|
currentStep: SyncStep.categories,
|
|||
|
|
progress: 0.1,
|
|||
|
|
message: 'Mengunduh kategori...',
|
|||
|
|
errorMessage: null,
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// Gunakan CategoryRepository sync method
|
|||
|
|
final result = await _categoryRepository.syncAllCategories();
|
|||
|
|
|
|||
|
|
await result.fold(
|
|||
|
|
(failure) async {
|
|||
|
|
throw Exception('Gagal sync kategori: $failure');
|
|||
|
|
},
|
|||
|
|
(successMessage) async {
|
|||
|
|
log('✅ Categories sync completed: $successMessage');
|
|||
|
|
|
|||
|
|
emit(
|
|||
|
|
state.copyWith(
|
|||
|
|
currentStep: SyncStep.categories,
|
|||
|
|
progress: 0.2,
|
|||
|
|
message: 'Kategori berhasil diunduh ✅',
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
} catch (e) {
|
|||
|
|
log('❌ Category sync failed: $e');
|
|||
|
|
emit(
|
|||
|
|
state.copyWith(
|
|||
|
|
isSyncing: false,
|
|||
|
|
errorMessage: 'Gagal sync kategori: $e',
|
|||
|
|
message: 'Sinkronisasi kategori gagal ❌',
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
rethrow; // penting agar _onStartSync tahu kalau gagal
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _syncProducts(Emitter<SyncState> emit) async {
|
|||
|
|
log('📦 Syncing products...');
|
|||
|
|
|
|||
|
|
int page = 1;
|
|||
|
|
int totalSynced = 0;
|
|||
|
|
int? totalCount;
|
|||
|
|
int? totalPages;
|
|||
|
|
bool shouldContinue = true;
|
|||
|
|
|
|||
|
|
while (!_isCancelled && shouldContinue) {
|
|||
|
|
// Hitung progress dinamis (kategori 0.0–0.2, produk 0.2–0.9)
|
|||
|
|
double progress = 0.2;
|
|||
|
|
if (totalCount != null && totalCount! > 0) {
|
|||
|
|
progress = 0.2 + (totalSynced / totalCount!) * 0.7;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
emit(
|
|||
|
|
state.copyWith(
|
|||
|
|
isSyncing: true,
|
|||
|
|
currentStep: SyncStep.products,
|
|||
|
|
progress: progress,
|
|||
|
|
message: totalCount != null
|
|||
|
|
? 'Mengunduh produk... ($totalSynced dari $totalCount)'
|
|||
|
|
: 'Mengunduh produk... ($totalSynced produk)',
|
|||
|
|
errorMessage: null,
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
final result = await _productRepository.getProducts(
|
|||
|
|
page: page,
|
|||
|
|
limit: 50, // ambil batch besar biar cepat
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
await result.fold(
|
|||
|
|
(failure) async {
|
|||
|
|
emit(
|
|||
|
|
state.copyWith(
|
|||
|
|
isSyncing: false,
|
|||
|
|
errorMessage: 'Gagal sync produk: $failure',
|
|||
|
|
message: 'Sinkronisasi produk gagal ❌',
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
throw Exception(failure);
|
|||
|
|
},
|
|||
|
|
(response) async {
|
|||
|
|
final products = response.products;
|
|||
|
|
final responseData = response;
|
|||
|
|
|
|||
|
|
// Ambil total count & total page dari respons pertama
|
|||
|
|
if (page == 1) {
|
|||
|
|
totalCount = responseData.totalCount;
|
|||
|
|
totalPages = responseData.totalPages;
|
|||
|
|
log('📊 Total products to sync: $totalCount ($totalPages pages)');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (products.isEmpty) {
|
|||
|
|
shouldContinue = false;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Simpan batch produk ke local DB
|
|||
|
|
await _productRepository.saveProductsBatch(products);
|
|||
|
|
|
|||
|
|
totalSynced += products.length;
|
|||
|
|
page++;
|
|||
|
|
|
|||
|
|
log(
|
|||
|
|
'📦 Synced page ${page - 1}: ${products.length} products (Total: $totalSynced)',
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Cek apakah sudah selesai sync
|
|||
|
|
if (totalPages != null && page > totalPages!) {
|
|||
|
|
shouldContinue = false;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Fallback jika pagination info tidak lengkap
|
|||
|
|
if (products.length < 50) {
|
|||
|
|
shouldContinue = false;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Tambahkan delay kecil agar tidak overload server
|
|||
|
|
await Future.delayed(const Duration(milliseconds: 100));
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Selesai sync produk
|
|||
|
|
emit(
|
|||
|
|
state.copyWith(
|
|||
|
|
progress: 0.9,
|
|||
|
|
message: 'Sinkronisasi produk selesai ✅ ($totalSynced total)',
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<SyncStats> _generateSyncStats() async {
|
|||
|
|
try {
|
|||
|
|
log('📊 Generating sync statistics via repository...');
|
|||
|
|
|
|||
|
|
// Jalankan kedua query secara paralel untuk efisiensi
|
|||
|
|
final results = await Future.wait([
|
|||
|
|
_productRepository.getDatabaseStats(),
|
|||
|
|
_categoryRepository.getDatabaseStats(),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Default kosong
|
|||
|
|
Map<String, dynamic> productStats = {};
|
|||
|
|
Map<String, dynamic> categoryStats = {};
|
|||
|
|
|
|||
|
|
// Ambil hasil product stats
|
|||
|
|
await results[0].fold(
|
|||
|
|
(failure) async {
|
|||
|
|
log('⚠️ Failed to get product stats: $failure');
|
|||
|
|
},
|
|||
|
|
(data) async {
|
|||
|
|
productStats = data;
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Ambil hasil category stats
|
|||
|
|
await results[1].fold(
|
|||
|
|
(failure) async {
|
|||
|
|
log('⚠️ Failed to get category stats: $failure');
|
|||
|
|
},
|
|||
|
|
(data) async {
|
|||
|
|
categoryStats = data;
|
|||
|
|
},
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// Bangun objek SyncStats akhir
|
|||
|
|
final stats = SyncStats(
|
|||
|
|
totalProducts: productStats['total_products'] ?? 0,
|
|||
|
|
totalCategories: categoryStats['total_categories'] ?? 0,
|
|||
|
|
totalVariants: productStats['total_variants'] ?? 0,
|
|||
|
|
databaseSizeMB:
|
|||
|
|
((productStats['database_size_mb'] ?? 0.0) as num).toDouble() +
|
|||
|
|
((categoryStats['database_size_mb'] ?? 0.0) as num).toDouble(),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
log('✅ Sync stats generated: $stats');
|
|||
|
|
return stats;
|
|||
|
|
} catch (e, stack) {
|
|||
|
|
log('❌ Error generating sync stats: $e\n$stack');
|
|||
|
|
return SyncStats(
|
|||
|
|
totalProducts: 0,
|
|||
|
|
totalCategories: 0,
|
|||
|
|
totalVariants: 0,
|
|||
|
|
databaseSizeMB: 0.0,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Future<void> close() {
|
|||
|
|
_progressTimer?.cancel();
|
|||
|
|
return super.close();
|
|||
|
|
}
|
|||
|
|
}
|