product repo
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user