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

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

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

@ -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,34 @@
part of '../product.dart';
abstract class IProductRepository {
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

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

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

View File

@ -14,6 +14,8 @@ 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/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 +30,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 +50,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 +94,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 +116,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 +131,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 +155,9 @@ 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>()),
);
return this; return this;
} }
} }