Compare commits
2 Commits
dea5de8828
...
6892895021
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6892895021 | ||
|
|
4fdd1e44f8 |
345
lib/application/product/product_loader/product_loader_bloc.dart
Normal file
345
lib/application/product/product_loader/product_loader_bloc.dart
Normal file
@ -0,0 +1,345 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../domain/product/product.dart';
|
||||
|
||||
part 'product_loader_event.dart';
|
||||
part 'product_loader_state.dart';
|
||||
part 'product_loader_bloc.freezed.dart';
|
||||
|
||||
@injectable
|
||||
class ProductLoaderBloc extends Bloc<ProductLoaderEvent, ProductLoaderState> {
|
||||
final IProductRepository _productRepository;
|
||||
|
||||
Timer? _loadMoreDebounce;
|
||||
Timer? _searchDebounce;
|
||||
|
||||
ProductLoaderBloc(this._productRepository)
|
||||
: super(ProductLoaderState.initial()) {
|
||||
on<ProductLoaderEvent>(_onProductLoaderEvent);
|
||||
}
|
||||
|
||||
Future<void> _onProductLoaderEvent(
|
||||
ProductLoaderEvent event,
|
||||
Emitter<ProductLoaderState> emit,
|
||||
) {
|
||||
return event.map(
|
||||
getProduct: (e) async {
|
||||
emit(state.copyWith(isLoadingMore: true));
|
||||
|
||||
log(
|
||||
'📱 Loading local products - categoryId: ${e.categoryId}, search: ${e.search}',
|
||||
);
|
||||
|
||||
// Pastikan database lokal sudah siap
|
||||
final isReady = await _productRepository.isLocalDatabaseReady();
|
||||
if (!isReady) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoadingMore: false,
|
||||
failureOptionProduct: optionOf(
|
||||
ProductFailure.dynamicErrorMessage(
|
||||
'Database lokal belum siap. Silakan lakukan sinkronisasi data terlebih dahulu.',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final result = await _productRepository.getProducts(
|
||||
page: 1,
|
||||
limit: 10,
|
||||
categoryId: e.categoryId,
|
||||
search: e.search,
|
||||
);
|
||||
|
||||
await result.fold(
|
||||
(failure) async {
|
||||
log('❌ Error loading local products: $failure');
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoadingMore: false,
|
||||
failureOptionProduct: optionOf(failure),
|
||||
),
|
||||
);
|
||||
},
|
||||
(response) async {
|
||||
final products = response.products;
|
||||
final totalPages = response.totalPages;
|
||||
final hasReachedMax = products.length < 10 || 1 >= totalPages;
|
||||
|
||||
log(
|
||||
'✅ Local products loaded: ${products.length}, hasReachedMax: $hasReachedMax, totalPages: $totalPages',
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
products: products,
|
||||
page: 1,
|
||||
hasReachedMax: hasReachedMax,
|
||||
isLoadingMore: false,
|
||||
failureOptionProduct: none(),
|
||||
categoryId: e.categoryId,
|
||||
searchQuery: e.search,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loadMore: (e) async {
|
||||
final currentState = state;
|
||||
|
||||
// Cegah double load
|
||||
if (currentState.isLoadingMore || currentState.hasReachedMax) {
|
||||
log(
|
||||
'⏹️ Load more blocked - isLoadingMore: ${currentState.isLoadingMore}, hasReachedMax: ${currentState.hasReachedMax}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
emit(currentState.copyWith(isLoadingMore: true));
|
||||
|
||||
final nextPage = currentState.page + 1;
|
||||
log('📄 Loading more local products - page: $nextPage');
|
||||
|
||||
try {
|
||||
final result = await _productRepository.getProducts(
|
||||
page: nextPage,
|
||||
limit: 10,
|
||||
categoryId: currentState.categoryId,
|
||||
search: currentState.searchQuery,
|
||||
);
|
||||
|
||||
await result.fold(
|
||||
(failure) async {
|
||||
log('❌ Error loading more local products: $failure');
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
isLoadingMore: false,
|
||||
failureOptionProduct: optionOf(failure),
|
||||
),
|
||||
);
|
||||
},
|
||||
(response) async {
|
||||
final newProducts = response.products;
|
||||
final totalPages = response.totalPages;
|
||||
|
||||
// Hindari duplikat produk
|
||||
final currentProductIds = currentState.products
|
||||
.map((p) => p.id)
|
||||
.toSet();
|
||||
final filteredNewProducts = newProducts
|
||||
.where((product) => !currentProductIds.contains(product.id))
|
||||
.toList();
|
||||
|
||||
final allProducts = [
|
||||
...currentState.products,
|
||||
...filteredNewProducts,
|
||||
];
|
||||
|
||||
final hasReachedMax =
|
||||
filteredNewProducts.length < 10 || nextPage >= totalPages;
|
||||
|
||||
log(
|
||||
'✅ More local products loaded: ${filteredNewProducts.length} new, total: ${allProducts.length}, hasReachedMax: $hasReachedMax',
|
||||
);
|
||||
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
products: allProducts,
|
||||
page: nextPage,
|
||||
hasReachedMax: hasReachedMax,
|
||||
isLoadingMore: false,
|
||||
failureOptionProduct: none(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
log('❌ Exception loading more local products: $e');
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
isLoadingMore: false,
|
||||
failureOptionProduct: optionOf(
|
||||
ProductFailure.dynamicErrorMessage(
|
||||
'Gagal memuat produk tambahan: $e',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
refresh: (e) async {
|
||||
final categoryId = state.categoryId;
|
||||
final searchQuery = state.searchQuery;
|
||||
|
||||
_loadMoreDebounce?.cancel();
|
||||
_searchDebounce?.cancel();
|
||||
|
||||
log(
|
||||
'🔄 Refreshing local products - categoryId: $categoryId, search: $searchQuery',
|
||||
);
|
||||
|
||||
emit(state.copyWith(isLoadingMore: true));
|
||||
|
||||
try {
|
||||
_productRepository.clearCache();
|
||||
|
||||
final result = await _productRepository.refreshProducts(
|
||||
categoryId: categoryId,
|
||||
search: searchQuery,
|
||||
);
|
||||
|
||||
await result.fold(
|
||||
(failure) async {
|
||||
log('❌ Failed to refresh local products: $failure');
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoadingMore: false,
|
||||
failureOptionProduct: optionOf(failure),
|
||||
),
|
||||
);
|
||||
},
|
||||
(response) async {
|
||||
final products = response.products;
|
||||
final totalPages = response.totalPages;
|
||||
final hasReachedMax = products.length < 10 || 1 >= totalPages;
|
||||
|
||||
log('✅ Refreshed local products: ${products.length}');
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
products: products,
|
||||
hasReachedMax: hasReachedMax,
|
||||
page: 1,
|
||||
isLoadingMore: false,
|
||||
failureOptionProduct: none(),
|
||||
categoryId: categoryId,
|
||||
searchQuery: searchQuery,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
log('❌ Exception refreshing local products: $e');
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoadingMore: false,
|
||||
failureOptionProduct: optionOf(
|
||||
ProductFailure.dynamicErrorMessage(e.toString()),
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {}
|
||||
},
|
||||
searchProduct: (e) async {
|
||||
_searchDebounce?.cancel();
|
||||
|
||||
// Debounce ringan agar UX lebih halus
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 150), () async {
|
||||
emit(state.copyWith(isLoadingMore: true));
|
||||
|
||||
log('🔍 Local search: "${e.query}"');
|
||||
|
||||
try {
|
||||
final result = await _productRepository.getProducts(
|
||||
page: 1,
|
||||
limit: 20, // lebih banyak hasil untuk pencarian
|
||||
categoryId: e.categoryId,
|
||||
search: e.query,
|
||||
);
|
||||
|
||||
await result.fold(
|
||||
(failure) async {
|
||||
log('❌ Local search error: $failure');
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoadingMore: false,
|
||||
failureOptionProduct: optionOf(failure),
|
||||
),
|
||||
);
|
||||
},
|
||||
(response) async {
|
||||
final products = response.products;
|
||||
final totalPages = response.totalPages;
|
||||
final hasReachedMax = products.length < 20 || 1 >= totalPages;
|
||||
|
||||
log(
|
||||
'✅ Local search results: ${products.length} products found',
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
products: products,
|
||||
hasReachedMax: hasReachedMax,
|
||||
page: 1,
|
||||
isLoadingMore: false,
|
||||
categoryId: e.categoryId,
|
||||
searchQuery: e.query,
|
||||
failureOptionProduct: none(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
log('❌ Exception during local search: $e');
|
||||
emit(
|
||||
state.copyWith(
|
||||
isLoadingMore: false,
|
||||
failureOptionProduct: optionOf(
|
||||
ProductFailure.dynamicErrorMessage(e.toString()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
getDatabaseStats: (e) async {
|
||||
log('📊 Getting local database stats...');
|
||||
|
||||
try {
|
||||
final result = await _productRepository.getDatabaseStats();
|
||||
|
||||
await result.fold(
|
||||
(failure) async {
|
||||
log('❌ Failed to get database stats: $failure');
|
||||
emit(state.copyWith(failureOptionProduct: optionOf(failure)));
|
||||
},
|
||||
(stats) async {
|
||||
log('✅ Local database stats retrieved: $stats');
|
||||
// Jika UI kamu perlu tampilkan, bisa simpan ke state, misalnya:
|
||||
// emit(state.copyWith(databaseStats: some(stats)));
|
||||
// Tapi kalau hanya untuk log/debug, tidak perlu ubah state
|
||||
},
|
||||
);
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'❌ Exception while getting database stats: $e',
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
failureOptionProduct: optionOf(
|
||||
ProductFailure.dynamicErrorMessage(e.toString()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
clearCache: (e) async {
|
||||
log('🧹 Manually clearing local cache');
|
||||
_productRepository.clearCache();
|
||||
|
||||
// Refresh current data after cache clear
|
||||
add(const ProductLoaderEvent.refresh());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,26 @@
|
||||
part of 'product_loader_bloc.dart';
|
||||
|
||||
@freezed
|
||||
class ProductLoaderEvent with _$ProductLoaderEvent {
|
||||
const factory ProductLoaderEvent.getProduct({
|
||||
String? categoryId,
|
||||
String? search, // Added search parameter
|
||||
bool? forceRefresh, // Kept for compatibility but ignored
|
||||
}) = _GetProduct;
|
||||
|
||||
const factory ProductLoaderEvent.loadMore({
|
||||
String? categoryId,
|
||||
String? search,
|
||||
}) = _LoadMore;
|
||||
|
||||
const factory ProductLoaderEvent.refresh() = _Refresh;
|
||||
|
||||
const factory ProductLoaderEvent.searchProduct({
|
||||
String? query,
|
||||
String? categoryId,
|
||||
}) = _SearchProduct;
|
||||
|
||||
const factory ProductLoaderEvent.getDatabaseStats() = _GetDatabaseStats;
|
||||
|
||||
const factory ProductLoaderEvent.clearCache() = _ClearCache;
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
part of 'product_loader_bloc.dart';
|
||||
|
||||
@freezed
|
||||
class ProductLoaderState with _$ProductLoaderState {
|
||||
factory ProductLoaderState({
|
||||
required List<Product> products,
|
||||
required Option<ProductFailure> failureOptionProduct,
|
||||
@Default(false) bool hasReachedMax,
|
||||
@Default(1) int page,
|
||||
@Default(false) bool isLoadingMore,
|
||||
String? searchQuery,
|
||||
String? categoryId,
|
||||
}) = _ProductLoaderState;
|
||||
|
||||
factory ProductLoaderState.initial() =>
|
||||
ProductLoaderState(products: [], failureOptionProduct: none());
|
||||
}
|
||||
323
lib/application/sync/sync_bloc.dart
Normal file
323
lib/application/sync/sync_bloc.dart
Normal file
@ -0,0 +1,323 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
542
lib/application/sync/sync_bloc.freezed.dart
Normal file
542
lib/application/sync/sync_bloc.freezed.dart
Normal file
@ -0,0 +1,542 @@
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'sync_bloc.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
|
||||
);
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SyncEvent {
|
||||
@optionalTypeArgs
|
||||
TResult when<TResult extends Object?>({
|
||||
required TResult Function() startSync,
|
||||
required TResult Function() cancelSync,
|
||||
}) => throw _privateConstructorUsedError;
|
||||
@optionalTypeArgs
|
||||
TResult? whenOrNull<TResult extends Object?>({
|
||||
TResult? Function()? startSync,
|
||||
TResult? Function()? cancelSync,
|
||||
}) => throw _privateConstructorUsedError;
|
||||
@optionalTypeArgs
|
||||
TResult maybeWhen<TResult extends Object?>({
|
||||
TResult Function()? startSync,
|
||||
TResult Function()? cancelSync,
|
||||
required TResult orElse(),
|
||||
}) => throw _privateConstructorUsedError;
|
||||
@optionalTypeArgs
|
||||
TResult map<TResult extends Object?>({
|
||||
required TResult Function(_StartSync value) startSync,
|
||||
required TResult Function(_CancelSync value) cancelSync,
|
||||
}) => throw _privateConstructorUsedError;
|
||||
@optionalTypeArgs
|
||||
TResult? mapOrNull<TResult extends Object?>({
|
||||
TResult? Function(_StartSync value)? startSync,
|
||||
TResult? Function(_CancelSync value)? cancelSync,
|
||||
}) => throw _privateConstructorUsedError;
|
||||
@optionalTypeArgs
|
||||
TResult maybeMap<TResult extends Object?>({
|
||||
TResult Function(_StartSync value)? startSync,
|
||||
TResult Function(_CancelSync value)? cancelSync,
|
||||
required TResult orElse(),
|
||||
}) => throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $SyncEventCopyWith<$Res> {
|
||||
factory $SyncEventCopyWith(SyncEvent value, $Res Function(SyncEvent) then) =
|
||||
_$SyncEventCopyWithImpl<$Res, SyncEvent>;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$SyncEventCopyWithImpl<$Res, $Val extends SyncEvent>
|
||||
implements $SyncEventCopyWith<$Res> {
|
||||
_$SyncEventCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of SyncEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$StartSyncImplCopyWith<$Res> {
|
||||
factory _$$StartSyncImplCopyWith(
|
||||
_$StartSyncImpl value,
|
||||
$Res Function(_$StartSyncImpl) then,
|
||||
) = __$$StartSyncImplCopyWithImpl<$Res>;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$StartSyncImplCopyWithImpl<$Res>
|
||||
extends _$SyncEventCopyWithImpl<$Res, _$StartSyncImpl>
|
||||
implements _$$StartSyncImplCopyWith<$Res> {
|
||||
__$$StartSyncImplCopyWithImpl(
|
||||
_$StartSyncImpl _value,
|
||||
$Res Function(_$StartSyncImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of SyncEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$StartSyncImpl implements _StartSync {
|
||||
const _$StartSyncImpl();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SyncEvent.startSync()';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType && other is _$StartSyncImpl);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult when<TResult extends Object?>({
|
||||
required TResult Function() startSync,
|
||||
required TResult Function() cancelSync,
|
||||
}) {
|
||||
return startSync();
|
||||
}
|
||||
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult? whenOrNull<TResult extends Object?>({
|
||||
TResult? Function()? startSync,
|
||||
TResult? Function()? cancelSync,
|
||||
}) {
|
||||
return startSync?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult maybeWhen<TResult extends Object?>({
|
||||
TResult Function()? startSync,
|
||||
TResult Function()? cancelSync,
|
||||
required TResult orElse(),
|
||||
}) {
|
||||
if (startSync != null) {
|
||||
return startSync();
|
||||
}
|
||||
return orElse();
|
||||
}
|
||||
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult map<TResult extends Object?>({
|
||||
required TResult Function(_StartSync value) startSync,
|
||||
required TResult Function(_CancelSync value) cancelSync,
|
||||
}) {
|
||||
return startSync(this);
|
||||
}
|
||||
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult? mapOrNull<TResult extends Object?>({
|
||||
TResult? Function(_StartSync value)? startSync,
|
||||
TResult? Function(_CancelSync value)? cancelSync,
|
||||
}) {
|
||||
return startSync?.call(this);
|
||||
}
|
||||
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult maybeMap<TResult extends Object?>({
|
||||
TResult Function(_StartSync value)? startSync,
|
||||
TResult Function(_CancelSync value)? cancelSync,
|
||||
required TResult orElse(),
|
||||
}) {
|
||||
if (startSync != null) {
|
||||
return startSync(this);
|
||||
}
|
||||
return orElse();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _StartSync implements SyncEvent {
|
||||
const factory _StartSync() = _$StartSyncImpl;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$CancelSyncImplCopyWith<$Res> {
|
||||
factory _$$CancelSyncImplCopyWith(
|
||||
_$CancelSyncImpl value,
|
||||
$Res Function(_$CancelSyncImpl) then,
|
||||
) = __$$CancelSyncImplCopyWithImpl<$Res>;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$CancelSyncImplCopyWithImpl<$Res>
|
||||
extends _$SyncEventCopyWithImpl<$Res, _$CancelSyncImpl>
|
||||
implements _$$CancelSyncImplCopyWith<$Res> {
|
||||
__$$CancelSyncImplCopyWithImpl(
|
||||
_$CancelSyncImpl _value,
|
||||
$Res Function(_$CancelSyncImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of SyncEvent
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$CancelSyncImpl implements _CancelSync {
|
||||
const _$CancelSyncImpl();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SyncEvent.cancelSync()';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType && other is _$CancelSyncImpl);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => runtimeType.hashCode;
|
||||
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult when<TResult extends Object?>({
|
||||
required TResult Function() startSync,
|
||||
required TResult Function() cancelSync,
|
||||
}) {
|
||||
return cancelSync();
|
||||
}
|
||||
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult? whenOrNull<TResult extends Object?>({
|
||||
TResult? Function()? startSync,
|
||||
TResult? Function()? cancelSync,
|
||||
}) {
|
||||
return cancelSync?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult maybeWhen<TResult extends Object?>({
|
||||
TResult Function()? startSync,
|
||||
TResult Function()? cancelSync,
|
||||
required TResult orElse(),
|
||||
}) {
|
||||
if (cancelSync != null) {
|
||||
return cancelSync();
|
||||
}
|
||||
return orElse();
|
||||
}
|
||||
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult map<TResult extends Object?>({
|
||||
required TResult Function(_StartSync value) startSync,
|
||||
required TResult Function(_CancelSync value) cancelSync,
|
||||
}) {
|
||||
return cancelSync(this);
|
||||
}
|
||||
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult? mapOrNull<TResult extends Object?>({
|
||||
TResult? Function(_StartSync value)? startSync,
|
||||
TResult? Function(_CancelSync value)? cancelSync,
|
||||
}) {
|
||||
return cancelSync?.call(this);
|
||||
}
|
||||
|
||||
@override
|
||||
@optionalTypeArgs
|
||||
TResult maybeMap<TResult extends Object?>({
|
||||
TResult Function(_StartSync value)? startSync,
|
||||
TResult Function(_CancelSync value)? cancelSync,
|
||||
required TResult orElse(),
|
||||
}) {
|
||||
if (cancelSync != null) {
|
||||
return cancelSync(this);
|
||||
}
|
||||
return orElse();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _CancelSync implements SyncEvent {
|
||||
const factory _CancelSync() = _$CancelSyncImpl;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SyncState {
|
||||
bool get isSyncing => throw _privateConstructorUsedError;
|
||||
double get progress => throw _privateConstructorUsedError;
|
||||
SyncStep? get currentStep => throw _privateConstructorUsedError;
|
||||
String? get message => throw _privateConstructorUsedError;
|
||||
SyncStats? get stats => throw _privateConstructorUsedError;
|
||||
String? get errorMessage => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of SyncState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$SyncStateCopyWith<SyncState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $SyncStateCopyWith<$Res> {
|
||||
factory $SyncStateCopyWith(SyncState value, $Res Function(SyncState) then) =
|
||||
_$SyncStateCopyWithImpl<$Res, SyncState>;
|
||||
@useResult
|
||||
$Res call({
|
||||
bool isSyncing,
|
||||
double progress,
|
||||
SyncStep? currentStep,
|
||||
String? message,
|
||||
SyncStats? stats,
|
||||
String? errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$SyncStateCopyWithImpl<$Res, $Val extends SyncState>
|
||||
implements $SyncStateCopyWith<$Res> {
|
||||
_$SyncStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of SyncState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? isSyncing = null,
|
||||
Object? progress = null,
|
||||
Object? currentStep = freezed,
|
||||
Object? message = freezed,
|
||||
Object? stats = freezed,
|
||||
Object? errorMessage = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
isSyncing: null == isSyncing
|
||||
? _value.isSyncing
|
||||
: isSyncing // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
progress: null == progress
|
||||
? _value.progress
|
||||
: progress // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
currentStep: freezed == currentStep
|
||||
? _value.currentStep
|
||||
: currentStep // ignore: cast_nullable_to_non_nullable
|
||||
as SyncStep?,
|
||||
message: freezed == message
|
||||
? _value.message
|
||||
: message // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
stats: freezed == stats
|
||||
? _value.stats
|
||||
: stats // ignore: cast_nullable_to_non_nullable
|
||||
as SyncStats?,
|
||||
errorMessage: freezed == errorMessage
|
||||
? _value.errorMessage
|
||||
: errorMessage // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$SyncStateImplCopyWith<$Res>
|
||||
implements $SyncStateCopyWith<$Res> {
|
||||
factory _$$SyncStateImplCopyWith(
|
||||
_$SyncStateImpl value,
|
||||
$Res Function(_$SyncStateImpl) then,
|
||||
) = __$$SyncStateImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
bool isSyncing,
|
||||
double progress,
|
||||
SyncStep? currentStep,
|
||||
String? message,
|
||||
SyncStats? stats,
|
||||
String? errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$SyncStateImplCopyWithImpl<$Res>
|
||||
extends _$SyncStateCopyWithImpl<$Res, _$SyncStateImpl>
|
||||
implements _$$SyncStateImplCopyWith<$Res> {
|
||||
__$$SyncStateImplCopyWithImpl(
|
||||
_$SyncStateImpl _value,
|
||||
$Res Function(_$SyncStateImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of SyncState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? isSyncing = null,
|
||||
Object? progress = null,
|
||||
Object? currentStep = freezed,
|
||||
Object? message = freezed,
|
||||
Object? stats = freezed,
|
||||
Object? errorMessage = freezed,
|
||||
}) {
|
||||
return _then(
|
||||
_$SyncStateImpl(
|
||||
isSyncing: null == isSyncing
|
||||
? _value.isSyncing
|
||||
: isSyncing // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
progress: null == progress
|
||||
? _value.progress
|
||||
: progress // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
currentStep: freezed == currentStep
|
||||
? _value.currentStep
|
||||
: currentStep // ignore: cast_nullable_to_non_nullable
|
||||
as SyncStep?,
|
||||
message: freezed == message
|
||||
? _value.message
|
||||
: message // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
stats: freezed == stats
|
||||
? _value.stats
|
||||
: stats // ignore: cast_nullable_to_non_nullable
|
||||
as SyncStats?,
|
||||
errorMessage: freezed == errorMessage
|
||||
? _value.errorMessage
|
||||
: errorMessage // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$SyncStateImpl implements _SyncState {
|
||||
const _$SyncStateImpl({
|
||||
this.isSyncing = false,
|
||||
this.progress = 0.0,
|
||||
this.currentStep,
|
||||
this.message,
|
||||
this.stats,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool isSyncing;
|
||||
@override
|
||||
@JsonKey()
|
||||
final double progress;
|
||||
@override
|
||||
final SyncStep? currentStep;
|
||||
@override
|
||||
final String? message;
|
||||
@override
|
||||
final SyncStats? stats;
|
||||
@override
|
||||
final String? errorMessage;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SyncState(isSyncing: $isSyncing, progress: $progress, currentStep: $currentStep, message: $message, stats: $stats, errorMessage: $errorMessage)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$SyncStateImpl &&
|
||||
(identical(other.isSyncing, isSyncing) ||
|
||||
other.isSyncing == isSyncing) &&
|
||||
(identical(other.progress, progress) ||
|
||||
other.progress == progress) &&
|
||||
(identical(other.currentStep, currentStep) ||
|
||||
other.currentStep == currentStep) &&
|
||||
(identical(other.message, message) || other.message == message) &&
|
||||
(identical(other.stats, stats) || other.stats == stats) &&
|
||||
(identical(other.errorMessage, errorMessage) ||
|
||||
other.errorMessage == errorMessage));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
isSyncing,
|
||||
progress,
|
||||
currentStep,
|
||||
message,
|
||||
stats,
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
/// Create a copy of SyncState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$SyncStateImplCopyWith<_$SyncStateImpl> get copyWith =>
|
||||
__$$SyncStateImplCopyWithImpl<_$SyncStateImpl>(this, _$identity);
|
||||
}
|
||||
|
||||
abstract class _SyncState implements SyncState {
|
||||
const factory _SyncState({
|
||||
final bool isSyncing,
|
||||
final double progress,
|
||||
final SyncStep? currentStep,
|
||||
final String? message,
|
||||
final SyncStats? stats,
|
||||
final String? errorMessage,
|
||||
}) = _$SyncStateImpl;
|
||||
|
||||
@override
|
||||
bool get isSyncing;
|
||||
@override
|
||||
double get progress;
|
||||
@override
|
||||
SyncStep? get currentStep;
|
||||
@override
|
||||
String? get message;
|
||||
@override
|
||||
SyncStats? get stats;
|
||||
@override
|
||||
String? get errorMessage;
|
||||
|
||||
/// Create a copy of SyncState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$SyncStateImplCopyWith<_$SyncStateImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
7
lib/application/sync/sync_event.dart
Normal file
7
lib/application/sync/sync_event.dart
Normal file
@ -0,0 +1,7 @@
|
||||
part of 'sync_bloc.dart';
|
||||
|
||||
@freezed
|
||||
class SyncEvent with _$SyncEvent {
|
||||
const factory SyncEvent.startSync() = _StartSync;
|
||||
const factory SyncEvent.cancelSync() = _CancelSync;
|
||||
}
|
||||
15
lib/application/sync/sync_state.dart
Normal file
15
lib/application/sync/sync_state.dart
Normal file
@ -0,0 +1,15 @@
|
||||
part of 'sync_bloc.dart';
|
||||
|
||||
@freezed
|
||||
class SyncState with _$SyncState {
|
||||
const factory SyncState({
|
||||
@Default(false) bool isSyncing,
|
||||
@Default(0.0) double progress,
|
||||
SyncStep? currentStep,
|
||||
String? message,
|
||||
SyncStats? stats,
|
||||
String? errorMessage,
|
||||
}) = _SyncState;
|
||||
|
||||
factory SyncState.initial() => const SyncState();
|
||||
}
|
||||
@ -2,4 +2,5 @@ class ApiPath {
|
||||
static const String login = '/api/v1/auth/login';
|
||||
static const String outlets = '/api/v1/outlets';
|
||||
static const String categories = '/api/v1/categories';
|
||||
static const String products = '/api/v1/products';
|
||||
}
|
||||
|
||||
@ -23,4 +23,6 @@ abstract class ICategoryRepository {
|
||||
Future<Either<CategoryFailure, Map<String, dynamic>>> getDatabaseStats();
|
||||
|
||||
void clearCache();
|
||||
|
||||
Future<void> clearAllCategories();
|
||||
}
|
||||
|
||||
86
lib/domain/product/entities/product_entity.dart
Normal file
86
lib/domain/product/entities/product_entity.dart
Normal file
@ -0,0 +1,86 @@
|
||||
part of '../product.dart';
|
||||
|
||||
@freezed
|
||||
class ListProduct with _$ListProduct {
|
||||
const factory ListProduct({
|
||||
required List<Product> products,
|
||||
required int totalCount,
|
||||
required int page,
|
||||
required int limit,
|
||||
required int totalPages,
|
||||
}) = _ListProduct;
|
||||
|
||||
factory ListProduct.empty() => const ListProduct(
|
||||
products: [],
|
||||
totalCount: 0,
|
||||
page: 0,
|
||||
limit: 0,
|
||||
totalPages: 0,
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class Product with _$Product {
|
||||
const factory Product({
|
||||
required String id,
|
||||
required String organizationId,
|
||||
required String categoryId,
|
||||
required String sku,
|
||||
required String name,
|
||||
required String description,
|
||||
required double price,
|
||||
required double cost,
|
||||
required String businessType,
|
||||
required String imageUrl,
|
||||
required String printerType,
|
||||
required Map<String, dynamic> metadata,
|
||||
required bool isActive,
|
||||
required String createdAt,
|
||||
required String updatedAt,
|
||||
required List<ProductVariant> variants,
|
||||
}) = _Product;
|
||||
|
||||
factory Product.empty() => const Product(
|
||||
id: '',
|
||||
organizationId: '',
|
||||
categoryId: '',
|
||||
sku: '',
|
||||
name: '',
|
||||
description: '',
|
||||
price: 0.0,
|
||||
cost: 0.0,
|
||||
businessType: '',
|
||||
imageUrl: '',
|
||||
printerType: '',
|
||||
metadata: {},
|
||||
isActive: false,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
variants: [],
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ProductVariant with _$ProductVariant {
|
||||
const factory ProductVariant({
|
||||
required String id,
|
||||
required String productId,
|
||||
required String name,
|
||||
required double priceModifier,
|
||||
required double cost,
|
||||
required Map<String, dynamic> metadata,
|
||||
required String createdAt,
|
||||
required String updatedAt,
|
||||
}) = _ProductVariant;
|
||||
|
||||
factory ProductVariant.empty() => const ProductVariant(
|
||||
id: '',
|
||||
productId: '',
|
||||
name: '',
|
||||
priceModifier: 0.0,
|
||||
cost: 0.0,
|
||||
metadata: {},
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
);
|
||||
}
|
||||
12
lib/domain/product/failures/product_failure.dart
Normal file
12
lib/domain/product/failures/product_failure.dart
Normal file
@ -0,0 +1,12 @@
|
||||
part of '../product.dart';
|
||||
|
||||
@freezed
|
||||
sealed class ProductFailure with _$ProductFailure {
|
||||
const factory ProductFailure.serverError(ApiFailure failure) = _ServerError;
|
||||
const factory ProductFailure.unexpectedError() = _UnexpectedError;
|
||||
const factory ProductFailure.empty() = _Empty;
|
||||
const factory ProductFailure.localStorageError(String erroMessage) =
|
||||
_LocalStorageError;
|
||||
const factory ProductFailure.dynamicErrorMessage(String erroMessage) =
|
||||
_DynamicErrorMessage;
|
||||
}
|
||||
11
lib/domain/product/product.dart
Normal file
11
lib/domain/product/product.dart
Normal file
@ -0,0 +1,11 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import '../../common/api/api_failure.dart';
|
||||
|
||||
part 'product.freezed.dart';
|
||||
|
||||
part 'entities/product_entity.dart';
|
||||
part 'failures/product_failure.dart';
|
||||
part 'repositories/i_product_repository.dart';
|
||||
1964
lib/domain/product/product.freezed.dart
Normal file
1964
lib/domain/product/product.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
38
lib/domain/product/repositories/i_product_repository.dart
Normal file
38
lib/domain/product/repositories/i_product_repository.dart
Normal file
@ -0,0 +1,38 @@
|
||||
part of '../product.dart';
|
||||
|
||||
abstract class IProductRepository {
|
||||
Future<Either<ProductFailure, Unit>> saveProductsBatch(
|
||||
List<Product> products,
|
||||
);
|
||||
|
||||
Future<Either<ProductFailure, ListProduct>> getProducts({
|
||||
int page = 1,
|
||||
int limit = 10,
|
||||
String? categoryId,
|
||||
String? search,
|
||||
bool forceRefresh = false,
|
||||
});
|
||||
|
||||
Future<Either<ProductFailure, List<Product>>> searchProductsOptimized(
|
||||
String query,
|
||||
);
|
||||
|
||||
Future<Either<ProductFailure, String>> syncAllProducts();
|
||||
|
||||
Future<Either<ProductFailure, ListProduct>> refreshProducts({
|
||||
String? categoryId,
|
||||
String? search,
|
||||
});
|
||||
|
||||
Future<Either<ProductFailure, Product>> getProductById(String id);
|
||||
|
||||
Future<bool> hasLocalProducts();
|
||||
|
||||
Future<Either<ProductFailure, Map<String, dynamic>>> getDatabaseStats();
|
||||
|
||||
void clearCache();
|
||||
|
||||
Future<bool> isLocalDatabaseReady();
|
||||
|
||||
Future<void> clearAllProducts();
|
||||
}
|
||||
@ -349,4 +349,17 @@ class CategoryRepository implements ICategoryRepository {
|
||||
log('🧹 Clearing category cache', name: _logName);
|
||||
_localDataProvider.clearCache();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearAllCategories() async {
|
||||
try {
|
||||
log('🗑️ Clearing all categories from repository...', name: _logName);
|
||||
await _localDataProvider.clearAllCategories();
|
||||
clearCache();
|
||||
log('✅ All categories cleared successfully', name: _logName);
|
||||
} catch (e) {
|
||||
log('❌ Error clearing all categories: $e', name: _logName);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
486
lib/infrastructure/product/datasources/local_data_provider.dart
Normal file
486
lib/infrastructure/product/datasources/local_data_provider.dart
Normal file
@ -0,0 +1,486 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:data_channel/data_channel.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import '../../../common/constant/app_constant.dart';
|
||||
import '../../../common/database/database_helper.dart';
|
||||
import '../../../domain/product/product.dart';
|
||||
import '../product_dtos.dart';
|
||||
|
||||
@injectable
|
||||
class ProductLocalDataProvider {
|
||||
final DatabaseHelper _databaseHelper;
|
||||
final _logName = 'ProductLocalDataProvider';
|
||||
|
||||
final Map<String, List<ProductDto>> _queryCache = {};
|
||||
final Duration _cacheExpiry = Duration(minutes: AppConstant.cacheExpire);
|
||||
final Map<String, DateTime> _cacheTimestamps = {};
|
||||
|
||||
ProductLocalDataProvider(this._databaseHelper);
|
||||
|
||||
Future<DC<ProductFailure, void>> saveProductsBatch(
|
||||
List<ProductDto> products, {
|
||||
bool clearFirst = false,
|
||||
}) async {
|
||||
final db = await _databaseHelper.database;
|
||||
|
||||
try {
|
||||
await db.transaction((txn) async {
|
||||
if (clearFirst) {
|
||||
log('🗑️ Clearing existing products...', name: _logName);
|
||||
await txn.delete('product_variants');
|
||||
await txn.delete('products');
|
||||
}
|
||||
|
||||
log('💾 Batch saving ${products.length} products...', name: _logName);
|
||||
|
||||
// ✅ Batch insert products
|
||||
final productBatch = txn.batch();
|
||||
for (final product in products) {
|
||||
productBatch.insert(
|
||||
'products',
|
||||
product.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await productBatch.commit(noResult: true);
|
||||
|
||||
// ✅ Batch insert variants
|
||||
final variantBatch = txn.batch();
|
||||
for (final product in products) {
|
||||
if (product.variants?.isNotEmpty == true) {
|
||||
// Delete existing variants
|
||||
variantBatch.delete(
|
||||
'product_variants',
|
||||
where: 'product_id = ?',
|
||||
whereArgs: [product.id],
|
||||
);
|
||||
|
||||
// Insert variants
|
||||
for (final variant in product.variants!) {
|
||||
variantBatch.insert(
|
||||
'product_variants',
|
||||
variant.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
await variantBatch.commit(noResult: true);
|
||||
});
|
||||
|
||||
// ✅ Clear cache
|
||||
clearCache();
|
||||
log(
|
||||
'✅ Successfully batch saved ${products.length} products',
|
||||
name: _logName,
|
||||
);
|
||||
|
||||
return DC.data(null);
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'❌ Error batch saving products',
|
||||
name: _logName,
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
|
||||
return DC.error(ProductFailure.dynamicErrorMessage(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<DC<ProductFailure, List<ProductDto>>> getCachedProducts({
|
||||
int page = 1,
|
||||
int limit = 10,
|
||||
String? categoryId,
|
||||
String? search,
|
||||
}) async {
|
||||
final cacheKey = _generateCacheKey(page, limit, categoryId, search);
|
||||
final now = DateTime.now();
|
||||
|
||||
try {
|
||||
// ✅ CHECK CACHE FIRST
|
||||
if (_queryCache.containsKey(cacheKey) &&
|
||||
_cacheTimestamps.containsKey(cacheKey)) {
|
||||
final cacheTime = _cacheTimestamps[cacheKey]!;
|
||||
if (now.difference(cacheTime) < _cacheExpiry) {
|
||||
final cachedProducts = _queryCache[cacheKey]!;
|
||||
log(
|
||||
'🚀 Cache HIT: $cacheKey (${cachedProducts.length} products)',
|
||||
name: _logName,
|
||||
);
|
||||
return DC.data(cachedProducts);
|
||||
}
|
||||
}
|
||||
|
||||
log('📀 Cache MISS: $cacheKey, querying database...', name: _logName);
|
||||
|
||||
// Cache miss → query database
|
||||
final result = await getProducts(
|
||||
page: page,
|
||||
limit: limit,
|
||||
categoryId: categoryId,
|
||||
search: search,
|
||||
);
|
||||
|
||||
// ✅ Handle data/error dari getProducts()
|
||||
if (result.hasData) {
|
||||
final products = result.data!;
|
||||
|
||||
// Simpan ke cache
|
||||
_queryCache[cacheKey] = products;
|
||||
_cacheTimestamps[cacheKey] = now;
|
||||
|
||||
log(
|
||||
'💾 Cached ${products.length} products for key: $cacheKey',
|
||||
name: _logName,
|
||||
);
|
||||
|
||||
return DC.data(products);
|
||||
} else {
|
||||
// Kalau error dari getProducts
|
||||
return DC.error(result.error!);
|
||||
}
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'❌ Error getting cached products',
|
||||
name: _logName,
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
|
||||
return DC.error(ProductFailure.localStorageError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<DC<ProductFailure, List<ProductDto>>> getProducts({
|
||||
int page = 1,
|
||||
int limit = 10,
|
||||
String? categoryId,
|
||||
String? search,
|
||||
}) async {
|
||||
final db = await _databaseHelper.database;
|
||||
|
||||
try {
|
||||
String query = 'SELECT * FROM products WHERE 1=1';
|
||||
List<dynamic> whereArgs = [];
|
||||
|
||||
if (categoryId != null && categoryId.isNotEmpty) {
|
||||
query += ' AND category_id = ?';
|
||||
whereArgs.add(categoryId);
|
||||
}
|
||||
|
||||
if (search != null && search.isNotEmpty) {
|
||||
query += ' AND (name LIKE ? OR sku LIKE ? OR description LIKE ?)';
|
||||
whereArgs.add('%$search%');
|
||||
whereArgs.add('%$search%');
|
||||
whereArgs.add('%$search%');
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
if (limit > 0) {
|
||||
query += ' LIMIT ?';
|
||||
whereArgs.add(limit);
|
||||
|
||||
if (page > 1) {
|
||||
query += ' OFFSET ?';
|
||||
whereArgs.add((page - 1) * limit);
|
||||
}
|
||||
}
|
||||
|
||||
final List<Map<String, dynamic>> maps = await db.rawQuery(
|
||||
query,
|
||||
whereArgs,
|
||||
);
|
||||
|
||||
final List<ProductDto> products = [];
|
||||
for (final map in maps) {
|
||||
final variants = await _getProductVariants(db, map['id']);
|
||||
final product = ProductDto.fromMap(map, variants);
|
||||
products.add(product);
|
||||
}
|
||||
|
||||
log(
|
||||
'📊 Retrieved ${products.length} products from database',
|
||||
name: _logName,
|
||||
);
|
||||
|
||||
return DC.data(products);
|
||||
} catch (e, s) {
|
||||
log('❌ Error getting products', name: _logName, error: e, stackTrace: s);
|
||||
return DC.error(ProductFailure.localStorageError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<DC<ProductFailure, List<ProductDto>>> searchProductsOptimized(
|
||||
String query,
|
||||
) async {
|
||||
final db = await _databaseHelper.database;
|
||||
|
||||
try {
|
||||
log('🔍 Optimized search for: "$query"', name: _logName);
|
||||
|
||||
// ✅ Smart query with prioritization
|
||||
final List<Map<String, dynamic>> maps = await db.rawQuery(
|
||||
'''
|
||||
SELECT * FROM products
|
||||
WHERE name LIKE ? OR sku LIKE ? OR description LIKE ?
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN name LIKE ? THEN 1 -- Highest priority: name match
|
||||
WHEN sku LIKE ? THEN 2 -- Second priority: SKU match
|
||||
ELSE 3 -- Lowest priority: description
|
||||
END,
|
||||
name ASC
|
||||
LIMIT 50
|
||||
''',
|
||||
[
|
||||
'%$query%', '%$query%', '%$query%',
|
||||
'$query%', '$query%', // Prioritize results that start with query
|
||||
],
|
||||
);
|
||||
|
||||
final List<ProductDto> products = [];
|
||||
for (final map in maps) {
|
||||
final variants = await _getProductVariants(db, map['id']);
|
||||
final product = ProductDto.fromMap(map, variants);
|
||||
products.add(product);
|
||||
}
|
||||
|
||||
log(
|
||||
'🎯 Optimized search found ${products.length} results',
|
||||
name: _logName,
|
||||
);
|
||||
|
||||
return DC.data(products);
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'❌ Error in optimized search',
|
||||
name: _logName,
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
return DC.error(ProductFailure.localStorageError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<DC<ProductFailure, ProductDto>> getProductById(String id) async {
|
||||
final db = await _databaseHelper.database;
|
||||
|
||||
try {
|
||||
final List<Map<String, dynamic>> maps = await db.query(
|
||||
'products',
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
if (maps.isEmpty) {
|
||||
log('❌ Product not found: $id', name: _logName);
|
||||
return DC.error(ProductFailure.empty());
|
||||
}
|
||||
|
||||
final variants = await _getProductVariants(db, id);
|
||||
final product = ProductDto.fromMap(maps.first, variants);
|
||||
|
||||
log('✅ Product found: ${product.name}', name: _logName);
|
||||
return DC.data(product);
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'❌ Error getting product by ID',
|
||||
name: _logName,
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
return DC.error(ProductFailure.localStorageError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<DC<ProductFailure, Map<String, dynamic>>> getDatabaseStats() async {
|
||||
final db = await _databaseHelper.database;
|
||||
|
||||
try {
|
||||
final productCount =
|
||||
Sqflite.firstIntValue(
|
||||
await db.rawQuery('SELECT COUNT(*) FROM products'),
|
||||
) ??
|
||||
0;
|
||||
|
||||
final variantCount =
|
||||
Sqflite.firstIntValue(
|
||||
await db.rawQuery('SELECT COUNT(*) FROM product_variants'),
|
||||
) ??
|
||||
0;
|
||||
|
||||
final categoryCount =
|
||||
Sqflite.firstIntValue(
|
||||
await db.rawQuery(
|
||||
'SELECT COUNT(DISTINCT category_id) FROM products WHERE category_id IS NOT NULL',
|
||||
),
|
||||
) ??
|
||||
0;
|
||||
|
||||
final dbSize = await _getDatabaseSize();
|
||||
|
||||
final stats = {
|
||||
'total_products': productCount,
|
||||
'total_variants': variantCount,
|
||||
'total_categories': categoryCount,
|
||||
'database_size_mb': dbSize,
|
||||
'cache_entries': _queryCache.length,
|
||||
'cache_size_mb': _getCacheSize(),
|
||||
};
|
||||
|
||||
log('📊 Database Stats: $stats', name: _logName);
|
||||
|
||||
return DC.data(stats);
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'❌ Error getting database stats',
|
||||
name: _logName,
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
return DC.error(ProductFailure.localStorageError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> getTotalCount({String? categoryId, String? search}) async {
|
||||
final db = await _databaseHelper.database;
|
||||
|
||||
try {
|
||||
String query = 'SELECT COUNT(*) FROM products WHERE 1=1';
|
||||
List<dynamic> whereArgs = [];
|
||||
|
||||
if (categoryId != null && categoryId.isNotEmpty) {
|
||||
query += ' AND category_id = ?';
|
||||
whereArgs.add(categoryId);
|
||||
}
|
||||
|
||||
if (search != null && search.isNotEmpty) {
|
||||
query += ' AND (name LIKE ? OR sku LIKE ? OR description LIKE ?)';
|
||||
whereArgs.add('%$search%');
|
||||
whereArgs.add('%$search%');
|
||||
whereArgs.add('%$search%');
|
||||
}
|
||||
|
||||
final result = await db.rawQuery(query, whereArgs);
|
||||
final count = Sqflite.firstIntValue(result) ?? 0;
|
||||
log(
|
||||
'📊 Total count: $count (categoryId: $categoryId, search: $search)',
|
||||
name: _logName,
|
||||
);
|
||||
return count;
|
||||
} catch (e) {
|
||||
log('❌ Error getting total count: $e', name: _logName);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> hasProducts() async {
|
||||
final count = await getTotalCount();
|
||||
final hasData = count > 0;
|
||||
log('🔍 Has products: $hasData ($count products)', name: _logName);
|
||||
return hasData;
|
||||
}
|
||||
|
||||
Future<void> clearAllProducts() async {
|
||||
final db = await _databaseHelper.database;
|
||||
|
||||
try {
|
||||
await db.transaction((txn) async {
|
||||
await txn.delete('product_variants');
|
||||
await txn.delete('products');
|
||||
});
|
||||
clearCache();
|
||||
log('🗑️ All products cleared from local DB', name: _logName);
|
||||
} catch (e) {
|
||||
log('❌ Error clearing products: $e', name: _logName);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<double> _getDatabaseSize() async {
|
||||
try {
|
||||
final dbPath = p.join(await getDatabasesPath(), 'db_pos.db');
|
||||
final file = File(dbPath);
|
||||
if (await file.exists()) {
|
||||
final size = await file.length();
|
||||
return size / (1024 * 1024); // Convert to MB
|
||||
}
|
||||
} catch (e) {
|
||||
log('Error getting database size: $e', name: _logName);
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
Future<List<ProductVariantDto>> _getProductVariants(
|
||||
Database db,
|
||||
String productId,
|
||||
) async {
|
||||
try {
|
||||
final List<Map<String, dynamic>> maps = await db.query(
|
||||
'product_variants',
|
||||
where: 'product_id = ?',
|
||||
whereArgs: [productId],
|
||||
orderBy: 'name ASC',
|
||||
);
|
||||
|
||||
return maps.map((map) => ProductVariantDto.fromMap(map)).toList();
|
||||
} catch (e) {
|
||||
log(
|
||||
'❌ Error getting variants for product $productId: $e',
|
||||
name: _logName,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
String _generateCacheKey(
|
||||
int page,
|
||||
int limit,
|
||||
String? categoryId,
|
||||
String? search,
|
||||
) {
|
||||
return 'products_${page}_${limit}_${categoryId ?? 'null'}_${search ?? 'null'}';
|
||||
}
|
||||
|
||||
double _getCacheSize() {
|
||||
double totalSize = 0;
|
||||
_queryCache.forEach((key, products) {
|
||||
totalSize += products.length * 0.001; // Rough estimate in MB
|
||||
});
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
void clearCache() {
|
||||
final count = _queryCache.length;
|
||||
_queryCache.clear();
|
||||
_cacheTimestamps.clear();
|
||||
log('🧹 Cache cleared: $count entries removed', name: _logName);
|
||||
}
|
||||
|
||||
void clearExpiredCache() {
|
||||
final now = DateTime.now();
|
||||
final expiredKeys = <String>[];
|
||||
|
||||
_cacheTimestamps.forEach((key, timestamp) {
|
||||
if (now.difference(timestamp) > _cacheExpiry) {
|
||||
expiredKeys.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
for (final key in expiredKeys) {
|
||||
_queryCache.remove(key);
|
||||
_cacheTimestamps.remove(key);
|
||||
}
|
||||
|
||||
if (expiredKeys.isNotEmpty) {
|
||||
log('⏰ Expired cache cleared: ${expiredKeys.length} entries');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:data_channel/data_channel.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../common/api/api_client.dart';
|
||||
import '../../../common/api/api_failure.dart';
|
||||
import '../../../common/function/app_function.dart';
|
||||
import '../../../common/url/api_path.dart';
|
||||
import '../../../domain/product/product.dart';
|
||||
import '../product_dtos.dart';
|
||||
|
||||
@injectable
|
||||
class ProductRemoteDataProvider {
|
||||
final ApiClient _apiClient;
|
||||
final _logName = 'ProductRemoteDataProvider';
|
||||
|
||||
ProductRemoteDataProvider(this._apiClient);
|
||||
|
||||
Future<DC<ProductFailure, ListProductDto>> fetchProducts({
|
||||
int page = 1,
|
||||
int limit = 10,
|
||||
String? categoryId,
|
||||
String? search,
|
||||
}) async {
|
||||
try {
|
||||
Map<String, dynamic> queryParameters = {'page': page, 'limit': limit};
|
||||
|
||||
if (categoryId != null) {
|
||||
queryParameters['category_id'] = categoryId;
|
||||
}
|
||||
|
||||
if (search != null && search.isNotEmpty) {
|
||||
queryParameters['search'] = search;
|
||||
}
|
||||
|
||||
final response = await _apiClient.get(
|
||||
ApiPath.products,
|
||||
params: queryParameters,
|
||||
headers: getAuthorizationHeader(),
|
||||
);
|
||||
|
||||
if (response.data['data'] == null) {
|
||||
return DC.error(ProductFailure.empty());
|
||||
}
|
||||
|
||||
final categories = ListProductDto.fromJson(
|
||||
response.data['data'] as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
return DC.data(categories);
|
||||
} on ApiFailure catch (e, s) {
|
||||
log('fetchProductError', name: _logName, error: e, stackTrace: s);
|
||||
return DC.error(ProductFailure.serverError(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
206
lib/infrastructure/product/dtos/product_dto.dart
Normal file
206
lib/infrastructure/product/dtos/product_dto.dart
Normal file
@ -0,0 +1,206 @@
|
||||
part of '../product_dtos.dart';
|
||||
|
||||
@freezed
|
||||
class ListProductDto with _$ListProductDto {
|
||||
const ListProductDto._();
|
||||
|
||||
const factory ListProductDto({
|
||||
@JsonKey(name: "products") required List<ProductDto> products,
|
||||
@JsonKey(name: "total_count") required int totalCount,
|
||||
@JsonKey(name: "page") required int page,
|
||||
@JsonKey(name: "limit") required int limit,
|
||||
@JsonKey(name: "total_pages") required int totalPages,
|
||||
}) = _ListProductDto;
|
||||
|
||||
factory ListProductDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$ListProductDtoFromJson(json);
|
||||
|
||||
ListProduct toDomain() => ListProduct(
|
||||
products: products.map((dto) => dto.toDomain()).toList(),
|
||||
totalCount: totalCount,
|
||||
page: page,
|
||||
limit: limit,
|
||||
totalPages: totalPages,
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ProductDto with _$ProductDto {
|
||||
const ProductDto._();
|
||||
|
||||
const factory ProductDto({
|
||||
@JsonKey(name: "id") String? id,
|
||||
@JsonKey(name: "organization_id") String? organizationId,
|
||||
@JsonKey(name: "category_id") String? categoryId,
|
||||
@JsonKey(name: "sku") String? sku,
|
||||
@JsonKey(name: "name") String? name,
|
||||
@JsonKey(name: "description") String? description,
|
||||
@JsonKey(name: "price") double? price,
|
||||
@JsonKey(name: "cost") double? cost,
|
||||
@JsonKey(name: "business_type") String? businessType,
|
||||
@JsonKey(name: "image_url") String? imageUrl,
|
||||
@JsonKey(name: "printer_type") String? printerType,
|
||||
@JsonKey(name: "metadata") Map<String, dynamic>? metadata,
|
||||
@JsonKey(name: "is_active") bool? isActive,
|
||||
@JsonKey(name: "created_at") String? createdAt,
|
||||
@JsonKey(name: "updated_at") String? updatedAt,
|
||||
@JsonKey(name: "variants") List<ProductVariantDto>? variants,
|
||||
}) = _ProductDto;
|
||||
|
||||
factory ProductDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProductDtoFromJson(json);
|
||||
|
||||
/// Mapping ke domain
|
||||
Product toDomain() => Product(
|
||||
id: id ?? '',
|
||||
organizationId: organizationId ?? '',
|
||||
categoryId: categoryId ?? '',
|
||||
sku: sku ?? '',
|
||||
name: name ?? '',
|
||||
description: description ?? '',
|
||||
price: price ?? 0.0,
|
||||
cost: cost ?? 0.0,
|
||||
businessType: businessType ?? '',
|
||||
imageUrl: imageUrl ?? '',
|
||||
printerType: printerType ?? '',
|
||||
metadata: metadata ?? {},
|
||||
isActive: isActive ?? false,
|
||||
createdAt: createdAt ?? '',
|
||||
updatedAt: updatedAt ?? '',
|
||||
variants: variants?.map((v) => v.toDomain()).toList() ?? [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'organization_id': organizationId,
|
||||
'category_id': categoryId,
|
||||
'sku': sku,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'price': price,
|
||||
'cost': cost,
|
||||
'business_type': businessType,
|
||||
'image_url': imageUrl,
|
||||
'printer_type': printerType,
|
||||
'metadata': metadata != null ? jsonEncode(metadata) : null,
|
||||
'is_active': isActive == true ? 1 : 0,
|
||||
'created_at': createdAt,
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
|
||||
factory ProductDto.fromMap(
|
||||
Map<String, dynamic> map,
|
||||
List<ProductVariantDto> variants,
|
||||
) => ProductDto(
|
||||
id: map['id'] as String?,
|
||||
organizationId: map['organization_id'] as String?,
|
||||
categoryId: map['category_id'] as String?,
|
||||
sku: map['sku'] as String?,
|
||||
name: map['name'] as String?,
|
||||
description: map['description'] as String?,
|
||||
price: map['price'] != null ? (map['price'] as num).toDouble() : null,
|
||||
cost: map['cost'] != null ? (map['cost'] as num).toDouble() : null,
|
||||
businessType: map['business_type'] as String?,
|
||||
imageUrl: map['image_url'] as String?,
|
||||
printerType: map['printer_type'] as String?,
|
||||
metadata: map['metadata'] != null
|
||||
? jsonDecode(map['metadata'] as String) as Map<String, dynamic>
|
||||
: null,
|
||||
isActive: map['is_active'] != null ? (map['is_active'] as int) == 1 : null,
|
||||
|
||||
createdAt: map['created_at'] as String?,
|
||||
updatedAt: map['updated_at'] as String?,
|
||||
variants: variants,
|
||||
);
|
||||
|
||||
factory ProductDto.fromDomain(Product product) => ProductDto(
|
||||
id: product.id,
|
||||
organizationId: product.organizationId,
|
||||
categoryId: product.categoryId,
|
||||
sku: product.sku,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
price: product.price,
|
||||
cost: product.cost,
|
||||
businessType: product.businessType,
|
||||
imageUrl: product.imageUrl,
|
||||
printerType: product.printerType,
|
||||
metadata: product.metadata,
|
||||
isActive: product.isActive,
|
||||
createdAt: product.createdAt,
|
||||
updatedAt: product.updatedAt,
|
||||
variants: product.variants
|
||||
.map((v) => ProductVariantDto.fromDomain(v))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class ProductVariantDto with _$ProductVariantDto {
|
||||
const ProductVariantDto._();
|
||||
|
||||
const factory ProductVariantDto({
|
||||
@JsonKey(name: "id") String? id,
|
||||
@JsonKey(name: "product_id") String? productId,
|
||||
@JsonKey(name: "name") String? name,
|
||||
@JsonKey(name: "price_modifier") double? priceModifier,
|
||||
@JsonKey(name: "cost") double? cost,
|
||||
@JsonKey(name: "metadata") Map<String, dynamic>? metadata,
|
||||
@JsonKey(name: "created_at") String? createdAt,
|
||||
@JsonKey(name: "updated_at") String? updatedAt,
|
||||
}) = _ProductVariantDto;
|
||||
|
||||
factory ProductVariantDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$ProductVariantDtoFromJson(json);
|
||||
|
||||
/// Mapping ke domain
|
||||
ProductVariant toDomain() => ProductVariant(
|
||||
id: id ?? '',
|
||||
productId: productId ?? '',
|
||||
name: name ?? '',
|
||||
priceModifier: priceModifier ?? 0.0,
|
||||
cost: cost ?? 0.0,
|
||||
metadata: metadata ?? {},
|
||||
createdAt: createdAt ?? '',
|
||||
updatedAt: updatedAt ?? '',
|
||||
);
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'id': id,
|
||||
'product_id': productId,
|
||||
'name': name,
|
||||
'price_modifier': priceModifier,
|
||||
'cost': cost,
|
||||
'metadata': metadata != null ? jsonEncode(metadata) : null,
|
||||
'created_at': createdAt,
|
||||
'updated_at': updatedAt,
|
||||
};
|
||||
|
||||
factory ProductVariantDto.fromMap(Map<String, dynamic> map) =>
|
||||
ProductVariantDto(
|
||||
id: map['id'] as String?,
|
||||
productId: map['product_id'] as String?,
|
||||
name: map['name'] as String?,
|
||||
priceModifier: map['price_modifier'] != null
|
||||
? (map['price_modifier'] as num).toDouble()
|
||||
: null,
|
||||
cost: map['cost'] != null ? (map['cost'] as num).toDouble() : null,
|
||||
metadata: map['metadata'] != null
|
||||
? jsonDecode(map['metadata'] as String) as Map<String, dynamic>
|
||||
: null,
|
||||
createdAt: map['created_at'] as String?,
|
||||
updatedAt: map['updated_at'] as String?,
|
||||
);
|
||||
|
||||
factory ProductVariantDto.fromDomain(ProductVariant variant) =>
|
||||
ProductVariantDto(
|
||||
id: variant.id,
|
||||
productId: variant.productId,
|
||||
name: variant.name,
|
||||
priceModifier: variant.priceModifier,
|
||||
cost: variant.cost,
|
||||
metadata: variant.metadata,
|
||||
createdAt: variant.createdAt,
|
||||
updatedAt: variant.updatedAt,
|
||||
);
|
||||
}
|
||||
10
lib/infrastructure/product/product_dtos.dart
Normal file
10
lib/infrastructure/product/product_dtos.dart
Normal file
@ -0,0 +1,10 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import '../../domain/product/product.dart';
|
||||
|
||||
part 'product_dtos.freezed.dart';
|
||||
part 'product_dtos.g.dart';
|
||||
|
||||
part 'dtos/product_dto.dart';
|
||||
1204
lib/infrastructure/product/product_dtos.freezed.dart
Normal file
1204
lib/infrastructure/product/product_dtos.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
96
lib/infrastructure/product/product_dtos.g.dart
Normal file
96
lib/infrastructure/product/product_dtos.g.dart
Normal file
@ -0,0 +1,96 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'product_dtos.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$ListProductDtoImpl _$$ListProductDtoImplFromJson(Map<String, dynamic> json) =>
|
||||
_$ListProductDtoImpl(
|
||||
products: (json['products'] as List<dynamic>)
|
||||
.map((e) => ProductDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
totalCount: (json['total_count'] as num).toInt(),
|
||||
page: (json['page'] as num).toInt(),
|
||||
limit: (json['limit'] as num).toInt(),
|
||||
totalPages: (json['total_pages'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$ListProductDtoImplToJson(
|
||||
_$ListProductDtoImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'products': instance.products,
|
||||
'total_count': instance.totalCount,
|
||||
'page': instance.page,
|
||||
'limit': instance.limit,
|
||||
'total_pages': instance.totalPages,
|
||||
};
|
||||
|
||||
_$ProductDtoImpl _$$ProductDtoImplFromJson(Map<String, dynamic> json) =>
|
||||
_$ProductDtoImpl(
|
||||
id: json['id'] as String?,
|
||||
organizationId: json['organization_id'] as String?,
|
||||
categoryId: json['category_id'] as String?,
|
||||
sku: json['sku'] as String?,
|
||||
name: json['name'] as String?,
|
||||
description: json['description'] as String?,
|
||||
price: (json['price'] as num?)?.toDouble(),
|
||||
cost: (json['cost'] as num?)?.toDouble(),
|
||||
businessType: json['business_type'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
printerType: json['printer_type'] as String?,
|
||||
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||
isActive: json['is_active'] as bool?,
|
||||
createdAt: json['created_at'] as String?,
|
||||
updatedAt: json['updated_at'] as String?,
|
||||
variants: (json['variants'] as List<dynamic>?)
|
||||
?.map((e) => ProductVariantDto.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$ProductDtoImplToJson(_$ProductDtoImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'organization_id': instance.organizationId,
|
||||
'category_id': instance.categoryId,
|
||||
'sku': instance.sku,
|
||||
'name': instance.name,
|
||||
'description': instance.description,
|
||||
'price': instance.price,
|
||||
'cost': instance.cost,
|
||||
'business_type': instance.businessType,
|
||||
'image_url': instance.imageUrl,
|
||||
'printer_type': instance.printerType,
|
||||
'metadata': instance.metadata,
|
||||
'is_active': instance.isActive,
|
||||
'created_at': instance.createdAt,
|
||||
'updated_at': instance.updatedAt,
|
||||
'variants': instance.variants,
|
||||
};
|
||||
|
||||
_$ProductVariantDtoImpl _$$ProductVariantDtoImplFromJson(
|
||||
Map<String, dynamic> json,
|
||||
) => _$ProductVariantDtoImpl(
|
||||
id: json['id'] as String?,
|
||||
productId: json['product_id'] as String?,
|
||||
name: json['name'] as String?,
|
||||
priceModifier: (json['price_modifier'] as num?)?.toDouble(),
|
||||
cost: (json['cost'] as num?)?.toDouble(),
|
||||
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||
createdAt: json['created_at'] as String?,
|
||||
updatedAt: json['updated_at'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$ProductVariantDtoImplToJson(
|
||||
_$ProductVariantDtoImpl instance,
|
||||
) => <String, dynamic>{
|
||||
'id': instance.id,
|
||||
'product_id': instance.productId,
|
||||
'name': instance.name,
|
||||
'price_modifier': instance.priceModifier,
|
||||
'cost': instance.cost,
|
||||
'metadata': instance.metadata,
|
||||
'created_at': instance.createdAt,
|
||||
'updated_at': instance.updatedAt,
|
||||
};
|
||||
350
lib/infrastructure/product/repositories/product_repository.dart
Normal file
350
lib/infrastructure/product/repositories/product_repository.dart
Normal file
@ -0,0 +1,350 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dartz/dartz.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
|
||||
import '../../../domain/product/product.dart';
|
||||
import '../datasources/local_data_provider.dart';
|
||||
import '../datasources/remote_data_provider.dart';
|
||||
import '../product_dtos.dart';
|
||||
|
||||
@Injectable(as: IProductRepository)
|
||||
class ProductRepository implements IProductRepository {
|
||||
final ProductRemoteDataProvider _remoteDataProvider;
|
||||
final ProductLocalDataProvider _localDataProvider;
|
||||
|
||||
final _logName = 'ProductRepository';
|
||||
|
||||
ProductRepository(this._remoteDataProvider, this._localDataProvider);
|
||||
|
||||
@override
|
||||
Future<Either<ProductFailure, Product>> getProductById(String id) async {
|
||||
try {
|
||||
log('🔍 Getting product by ID from local: $id', name: _logName);
|
||||
|
||||
final product = await _localDataProvider.getProductById(id);
|
||||
|
||||
if (product.hasData) {
|
||||
log('❌ Product not found: $id', name: _logName);
|
||||
return Left(
|
||||
ProductFailure.dynamicErrorMessage(
|
||||
'Produk dengan ID $id tidak ditemukan',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final productDomain = product.data!.toDomain();
|
||||
|
||||
log('✅ Product loaded: ${productDomain.name}', name: _logName);
|
||||
return Right(productDomain);
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'❌ Error getting product by ID',
|
||||
name: _logName,
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
return Left(ProductFailure.localStorageError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<ProductFailure, ListProduct>> getProducts({
|
||||
int page = 1,
|
||||
int limit = 10,
|
||||
String? categoryId,
|
||||
String? search,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
try {
|
||||
log(
|
||||
'📦 Fetching products from local DB - page: $page, categoryId: $categoryId, search: $search',
|
||||
name: _logName,
|
||||
);
|
||||
|
||||
// 🧹 Bersihkan cache kedaluwarsa
|
||||
_localDataProvider.clearExpiredCache();
|
||||
|
||||
// ⚡ Ambil data dari cache lokal
|
||||
final cachedProducts = await _localDataProvider.getCachedProducts(
|
||||
page: page,
|
||||
limit: limit,
|
||||
categoryId: categoryId,
|
||||
search: search,
|
||||
);
|
||||
|
||||
if (cachedProducts.hasError) {
|
||||
return left(cachedProducts.error!);
|
||||
}
|
||||
|
||||
// 📊 Hitung total item (untuk pagination)
|
||||
final totalCount = await _localDataProvider.getTotalCount(
|
||||
categoryId: categoryId,
|
||||
search: search,
|
||||
);
|
||||
|
||||
// 🧱 Bangun entity domain ListProduct
|
||||
final result = ListProduct(
|
||||
products: cachedProducts.data!.map((p) => p.toDomain()).toList(),
|
||||
totalCount: totalCount,
|
||||
page: page,
|
||||
limit: limit,
|
||||
totalPages: totalCount > 0 ? (totalCount / limit).ceil() : 0,
|
||||
);
|
||||
|
||||
log(
|
||||
'✅ Returned ${cachedProducts.data!.length} local products ($totalCount total)',
|
||||
name: _logName,
|
||||
);
|
||||
|
||||
return Right(result);
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'❌ Error getting local products',
|
||||
name: _logName,
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
return Left(ProductFailure.localStorageError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<ProductFailure, ListProduct>> refreshProducts({
|
||||
String? categoryId,
|
||||
String? search,
|
||||
}) async {
|
||||
try {
|
||||
log('🔄 Refreshing local products...', name: _logName);
|
||||
|
||||
// Bersihkan cache agar hasil baru diambil dari database
|
||||
_localDataProvider.clearCache();
|
||||
|
||||
// Ambil ulang data produk dari lokal database
|
||||
final cachedProducts = await _localDataProvider.getCachedProducts(
|
||||
page: 1,
|
||||
limit: 10,
|
||||
categoryId: categoryId,
|
||||
search: search,
|
||||
);
|
||||
|
||||
if (cachedProducts.hasError) {
|
||||
return left(cachedProducts.error!);
|
||||
}
|
||||
|
||||
final products = cachedProducts.data!.map((p) => p.toDomain()).toList();
|
||||
|
||||
final totalCount = await _localDataProvider.getTotalCount(
|
||||
categoryId: categoryId,
|
||||
search: search,
|
||||
);
|
||||
|
||||
// 🧱 Bangun entity domain ListProduct
|
||||
final result = ListProduct(
|
||||
products: products,
|
||||
totalCount: totalCount,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
totalPages: totalCount > 0 ? (totalCount / 10).ceil() : 0,
|
||||
);
|
||||
|
||||
log(
|
||||
'✅ Refreshed ${cachedProducts.data!.length} local products',
|
||||
name: _logName,
|
||||
);
|
||||
|
||||
return Right(result);
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'❌ Error refreshing local products',
|
||||
name: _logName,
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
return Left(ProductFailure.localStorageError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<ProductFailure, List<Product>>> searchProductsOptimized(
|
||||
String query,
|
||||
) async {
|
||||
try {
|
||||
log('🔍 Local optimized search for: "$query"', name: _logName);
|
||||
|
||||
// 🔎 Cari dari local database
|
||||
final results = await _localDataProvider.searchProductsOptimized(query);
|
||||
|
||||
if (results.hasError) {
|
||||
return left(results.error!);
|
||||
}
|
||||
|
||||
// ✅ Mapping ke domain entity (kalau hasilnya masih berupa DTO)
|
||||
final products = results.data!.map((p) => p.toDomain()).toList();
|
||||
|
||||
log(
|
||||
'✅ Local search completed: ${products.length} results',
|
||||
name: _logName,
|
||||
);
|
||||
|
||||
return Right(products);
|
||||
} catch (e, s) {
|
||||
log('❌ Error in local search', name: _logName, error: e, stackTrace: s);
|
||||
return Left(ProductFailure.localStorageError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<ProductFailure, String>> syncAllProducts() async {
|
||||
try {
|
||||
log('🔄 Starting manual sync of all products...', name: _logName);
|
||||
|
||||
int page = 1;
|
||||
const limit = 50;
|
||||
bool hasMore = true;
|
||||
int totalSynced = 0;
|
||||
|
||||
// Clear local DB before fresh sync
|
||||
await _localDataProvider.clearAllProducts();
|
||||
|
||||
while (hasMore) {
|
||||
log('📄 Syncing page $page...', name: _logName);
|
||||
|
||||
// NOTE: _remoteDatasource.getProducts() returns DC<..., ProductResponseModel>
|
||||
final remoteResult = await _remoteDataProvider.fetchProducts(
|
||||
page: page,
|
||||
limit: limit,
|
||||
);
|
||||
|
||||
// Handle DC result manually (no fold)
|
||||
if (!remoteResult.hasData) {
|
||||
// remote returned an error/failure
|
||||
final remoteFailure = remoteResult.error;
|
||||
log('❌ Sync failed at page $page: $remoteFailure', name: _logName);
|
||||
return Left(
|
||||
ProductFailure.dynamicErrorMessage(remoteFailure.toString()),
|
||||
);
|
||||
}
|
||||
|
||||
final response = remoteResult.data!;
|
||||
final products = response.products;
|
||||
|
||||
if (products.isNotEmpty) {
|
||||
// Save page to local DB
|
||||
await _localDataProvider.saveProductsBatch(
|
||||
products,
|
||||
clearFirst: false, // don't clear on subsequent pages
|
||||
);
|
||||
|
||||
totalSynced += products.length;
|
||||
|
||||
// Determine if more pages exist
|
||||
hasMore = page < (response.totalPages);
|
||||
page++;
|
||||
|
||||
log(
|
||||
'📦 Page ${page - 1} synced: ${products.length} products',
|
||||
name: _logName,
|
||||
);
|
||||
} else {
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
final message = 'Berhasil sinkronisasi $totalSynced produk';
|
||||
log('✅ $message', name: _logName);
|
||||
return Right(message);
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'❌ Gagal sinkronisasi produk',
|
||||
name: _logName,
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
return Left(ProductFailure.localStorageError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void clearCache() {
|
||||
log('🧹 Clearing local cache', name: _logName);
|
||||
_localDataProvider.clearCache();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<ProductFailure, Map<String, dynamic>>>
|
||||
getDatabaseStats() async {
|
||||
try {
|
||||
log('📊 Getting local database stats...', name: _logName);
|
||||
|
||||
final stats = await _localDataProvider.getDatabaseStats();
|
||||
|
||||
log('✅ Database stats loaded successfully: $stats', name: _logName);
|
||||
|
||||
return Right(stats.data!);
|
||||
} catch (e, s) {
|
||||
log(
|
||||
'❌ Error getting database stats',
|
||||
name: _logName,
|
||||
error: e,
|
||||
stackTrace: s,
|
||||
);
|
||||
return Left(ProductFailure.localStorageError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> hasLocalProducts() async {
|
||||
final hasProducts = await _localDataProvider.hasProducts();
|
||||
log('📊 Has local products: $hasProducts', name: _logName);
|
||||
return hasProducts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isLocalDatabaseReady() async {
|
||||
try {
|
||||
final stats = await _localDataProvider.getDatabaseStats();
|
||||
final productCount = stats.data!['total_products'] ?? 0;
|
||||
final isReady = productCount > 0;
|
||||
log(
|
||||
'🔍 Local database ready: $isReady ($productCount products)',
|
||||
name: _logName,
|
||||
);
|
||||
return isReady;
|
||||
} catch (e) {
|
||||
log('❌ Error checking database readiness: $e', name: _logName);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clearAllProducts() async {
|
||||
try {
|
||||
log('🗑️ Clearing all products from repository...', name: _logName);
|
||||
await _localDataProvider.clearAllProducts();
|
||||
clearCache();
|
||||
log('✅ All products cleared successfully', name: _logName);
|
||||
} catch (e) {
|
||||
log('❌ Error clearing all products: $e', name: _logName);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<ProductFailure, Unit>> saveProductsBatch(
|
||||
List<Product> products,
|
||||
) async {
|
||||
try {
|
||||
final productDtos = products.map(ProductDto.fromDomain).toList();
|
||||
|
||||
// Simpan batch ke local DB
|
||||
await _localDataProvider.saveProductsBatch(productDtos);
|
||||
|
||||
log('💾 Saved ${products.length} products to local DB', name: _logName);
|
||||
return right(unit);
|
||||
} catch (e, stack) {
|
||||
log('❌ Failed to save products batch: $e\n$stack', name: _logName);
|
||||
return left(ProductFailure.dynamicErrorMessage(e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,9 @@ import 'package:apskel_pos_flutter_v2/application/auth/login_form/login_form_blo
|
||||
as _i46;
|
||||
import 'package:apskel_pos_flutter_v2/application/outlet/outlet_loader/outlet_loader_bloc.dart'
|
||||
as _i76;
|
||||
import 'package:apskel_pos_flutter_v2/application/product/product_loader/product_loader_bloc.dart'
|
||||
as _i13;
|
||||
import 'package:apskel_pos_flutter_v2/application/sync/sync_bloc.dart' as _i741;
|
||||
import 'package:apskel_pos_flutter_v2/common/api/api_client.dart' as _i457;
|
||||
import 'package:apskel_pos_flutter_v2/common/database/database_helper.dart'
|
||||
as _i487;
|
||||
@ -28,6 +31,7 @@ import 'package:apskel_pos_flutter_v2/common/network/network_client.dart'
|
||||
import 'package:apskel_pos_flutter_v2/domain/auth/auth.dart' as _i776;
|
||||
import 'package:apskel_pos_flutter_v2/domain/category/category.dart' as _i502;
|
||||
import 'package:apskel_pos_flutter_v2/domain/outlet/outlet.dart' as _i552;
|
||||
import 'package:apskel_pos_flutter_v2/domain/product/product.dart' as _i44;
|
||||
import 'package:apskel_pos_flutter_v2/env.dart' as _i923;
|
||||
import 'package:apskel_pos_flutter_v2/infrastructure/auth/datasources/local_data_provider.dart'
|
||||
as _i204;
|
||||
@ -47,6 +51,12 @@ import 'package:apskel_pos_flutter_v2/infrastructure/outlet/datasources/remote_d
|
||||
as _i132;
|
||||
import 'package:apskel_pos_flutter_v2/infrastructure/outlet/repositories/outlet_repository.dart'
|
||||
as _i845;
|
||||
import 'package:apskel_pos_flutter_v2/infrastructure/product/datasources/local_data_provider.dart'
|
||||
as _i464;
|
||||
import 'package:apskel_pos_flutter_v2/infrastructure/product/datasources/remote_data_provider.dart'
|
||||
as _i707;
|
||||
import 'package:apskel_pos_flutter_v2/infrastructure/product/repositories/product_repository.dart'
|
||||
as _i763;
|
||||
import 'package:apskel_pos_flutter_v2/presentation/router/app_router.dart'
|
||||
as _i800;
|
||||
import 'package:connectivity_plus/connectivity_plus.dart' as _i895;
|
||||
@ -85,6 +95,9 @@ extension GetItInjectableX on _i174.GetIt {
|
||||
gh.factory<_i708.CategoryLocalDataProvider>(
|
||||
() => _i708.CategoryLocalDataProvider(gh<_i487.DatabaseHelper>()),
|
||||
);
|
||||
gh.factory<_i464.ProductLocalDataProvider>(
|
||||
() => _i464.ProductLocalDataProvider(gh<_i487.DatabaseHelper>()),
|
||||
);
|
||||
gh.factory<_i204.AuthLocalDataProvider>(
|
||||
() => _i204.AuthLocalDataProvider(gh<_i460.SharedPreferences>()),
|
||||
);
|
||||
@ -104,6 +117,9 @@ extension GetItInjectableX on _i174.GetIt {
|
||||
gh.factory<_i132.OutletRemoteDataProvider>(
|
||||
() => _i132.OutletRemoteDataProvider(gh<_i457.ApiClient>()),
|
||||
);
|
||||
gh.factory<_i707.ProductRemoteDataProvider>(
|
||||
() => _i707.ProductRemoteDataProvider(gh<_i457.ApiClient>()),
|
||||
);
|
||||
gh.factory<_i776.IAuthRepository>(
|
||||
() => _i941.AuthRepository(
|
||||
gh<_i370.AuthRemoteDataProvider>(),
|
||||
@ -116,6 +132,12 @@ extension GetItInjectableX on _i174.GetIt {
|
||||
gh<_i708.CategoryLocalDataProvider>(),
|
||||
),
|
||||
);
|
||||
gh.factory<_i44.IProductRepository>(
|
||||
() => _i763.ProductRepository(
|
||||
gh<_i707.ProductRemoteDataProvider>(),
|
||||
gh<_i464.ProductLocalDataProvider>(),
|
||||
),
|
||||
);
|
||||
gh.factory<_i552.IOutletRepository>(
|
||||
() => _i845.OutletRepository(
|
||||
gh<_i132.OutletRemoteDataProvider>(),
|
||||
@ -134,6 +156,15 @@ extension GetItInjectableX on _i174.GetIt {
|
||||
gh.factory<_i76.OutletLoaderBloc>(
|
||||
() => _i76.OutletLoaderBloc(gh<_i552.IOutletRepository>()),
|
||||
);
|
||||
gh.factory<_i13.ProductLoaderBloc>(
|
||||
() => _i13.ProductLoaderBloc(gh<_i44.IProductRepository>()),
|
||||
);
|
||||
gh.factory<_i741.SyncBloc>(
|
||||
() => _i741.SyncBloc(
|
||||
gh<_i44.IProductRepository>(),
|
||||
gh<_i502.ICategoryRepository>(),
|
||||
),
|
||||
);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user