product repo

This commit is contained in:
efrilm
2025-10-24 22:03:35 +07:00
parent dea5de8828
commit 4fdd1e44f8
18 changed files with 6256 additions and 0 deletions
@@ -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));
}
}
}
@@ -0,0 +1,173 @@
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,
);
}
@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?,
);
}
@@ -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
@@ -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,
};
@@ -0,0 +1,331 @@
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';
@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;
}
}
}