Compare commits

...

2 Commits

Author SHA1 Message Date
efrilm
6892895021 sync bloc 2025-10-24 22:25:01 +07:00
efrilm
4fdd1e44f8 product repo 2025-10-24 22:03:35 +07:00
24 changed files with 7221 additions and 0 deletions

View 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

View File

@ -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;
}

View File

@ -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());
}

View 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.00.2, produk 0.20.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();
}
}

View 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;
}

View 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;
}

View 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();
}

View File

@ -2,4 +2,5 @@ class ApiPath {
static const String login = '/api/v1/auth/login'; static const String login = '/api/v1/auth/login';
static const String outlets = '/api/v1/outlets'; static const String outlets = '/api/v1/outlets';
static const String categories = '/api/v1/categories'; static const String categories = '/api/v1/categories';
static const String products = '/api/v1/products';
} }

View File

@ -23,4 +23,6 @@ abstract class ICategoryRepository {
Future<Either<CategoryFailure, Map<String, dynamic>>> getDatabaseStats(); Future<Either<CategoryFailure, Map<String, dynamic>>> getDatabaseStats();
void clearCache(); void clearCache();
Future<void> clearAllCategories();
} }

View 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: '',
);
}

View 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;
}

View 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';

File diff suppressed because it is too large Load Diff

View 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();
}

View File

@ -349,4 +349,17 @@ class CategoryRepository implements ICategoryRepository {
log('🧹 Clearing category cache', name: _logName); log('🧹 Clearing category cache', name: _logName);
_localDataProvider.clearCache(); _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;
}
}
} }

View 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');
}
}
}

View File

@ -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));
}
}
}

View 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,
);
}

View 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';

File diff suppressed because it is too large Load Diff

View 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,
};

View 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()));
}
}
}

View File

@ -14,6 +14,9 @@ import 'package:apskel_pos_flutter_v2/application/auth/login_form/login_form_blo
as _i46; as _i46;
import 'package:apskel_pos_flutter_v2/application/outlet/outlet_loader/outlet_loader_bloc.dart' import 'package:apskel_pos_flutter_v2/application/outlet/outlet_loader/outlet_loader_bloc.dart'
as _i76; 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/api/api_client.dart' as _i457;
import 'package:apskel_pos_flutter_v2/common/database/database_helper.dart' import 'package:apskel_pos_flutter_v2/common/database/database_helper.dart'
as _i487; 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/auth/auth.dart' as _i776;
import 'package:apskel_pos_flutter_v2/domain/category/category.dart' as _i502; 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/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/env.dart' as _i923;
import 'package:apskel_pos_flutter_v2/infrastructure/auth/datasources/local_data_provider.dart' import 'package:apskel_pos_flutter_v2/infrastructure/auth/datasources/local_data_provider.dart'
as _i204; as _i204;
@ -47,6 +51,12 @@ import 'package:apskel_pos_flutter_v2/infrastructure/outlet/datasources/remote_d
as _i132; as _i132;
import 'package:apskel_pos_flutter_v2/infrastructure/outlet/repositories/outlet_repository.dart' import 'package:apskel_pos_flutter_v2/infrastructure/outlet/repositories/outlet_repository.dart'
as _i845; 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' import 'package:apskel_pos_flutter_v2/presentation/router/app_router.dart'
as _i800; as _i800;
import 'package:connectivity_plus/connectivity_plus.dart' as _i895; import 'package:connectivity_plus/connectivity_plus.dart' as _i895;
@ -85,6 +95,9 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i708.CategoryLocalDataProvider>( gh.factory<_i708.CategoryLocalDataProvider>(
() => _i708.CategoryLocalDataProvider(gh<_i487.DatabaseHelper>()), () => _i708.CategoryLocalDataProvider(gh<_i487.DatabaseHelper>()),
); );
gh.factory<_i464.ProductLocalDataProvider>(
() => _i464.ProductLocalDataProvider(gh<_i487.DatabaseHelper>()),
);
gh.factory<_i204.AuthLocalDataProvider>( gh.factory<_i204.AuthLocalDataProvider>(
() => _i204.AuthLocalDataProvider(gh<_i460.SharedPreferences>()), () => _i204.AuthLocalDataProvider(gh<_i460.SharedPreferences>()),
); );
@ -104,6 +117,9 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i132.OutletRemoteDataProvider>( gh.factory<_i132.OutletRemoteDataProvider>(
() => _i132.OutletRemoteDataProvider(gh<_i457.ApiClient>()), () => _i132.OutletRemoteDataProvider(gh<_i457.ApiClient>()),
); );
gh.factory<_i707.ProductRemoteDataProvider>(
() => _i707.ProductRemoteDataProvider(gh<_i457.ApiClient>()),
);
gh.factory<_i776.IAuthRepository>( gh.factory<_i776.IAuthRepository>(
() => _i941.AuthRepository( () => _i941.AuthRepository(
gh<_i370.AuthRemoteDataProvider>(), gh<_i370.AuthRemoteDataProvider>(),
@ -116,6 +132,12 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i708.CategoryLocalDataProvider>(), gh<_i708.CategoryLocalDataProvider>(),
), ),
); );
gh.factory<_i44.IProductRepository>(
() => _i763.ProductRepository(
gh<_i707.ProductRemoteDataProvider>(),
gh<_i464.ProductLocalDataProvider>(),
),
);
gh.factory<_i552.IOutletRepository>( gh.factory<_i552.IOutletRepository>(
() => _i845.OutletRepository( () => _i845.OutletRepository(
gh<_i132.OutletRemoteDataProvider>(), gh<_i132.OutletRemoteDataProvider>(),
@ -134,6 +156,15 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i76.OutletLoaderBloc>( gh.factory<_i76.OutletLoaderBloc>(
() => _i76.OutletLoaderBloc(gh<_i552.IOutletRepository>()), () => _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; return this;
} }
} }