diff --git a/lib/core/database/database_handler.dart b/lib/core/database/database_handler.dart new file mode 100644 index 0000000..361db7a --- /dev/null +++ b/lib/core/database/database_handler.dart @@ -0,0 +1,87 @@ +import 'dart:async'; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; + +class DatabaseHelper { + static DatabaseHelper? _instance; + static Database? _database; + + DatabaseHelper._internal(); + + static DatabaseHelper get instance { + _instance ??= DatabaseHelper._internal(); + return _instance!; + } + + Future get database async { + _database ??= await _initDatabase(); + return _database!; + } + + Future _initDatabase() async { + String path = join(await getDatabasesPath(), 'pos_database.db'); + + return await openDatabase( + path, + version: 1, + onCreate: _onCreate, + onUpgrade: _onUpgrade, + ); + } + + Future _onCreate(Database db, int version) async { + // Products table + await db.execute(''' + CREATE TABLE products ( + id TEXT PRIMARY KEY, + organization_id TEXT, + category_id TEXT, + sku TEXT, + name TEXT, + description TEXT, + price INTEGER, + cost INTEGER, + business_type TEXT, + image_url TEXT, + printer_type TEXT, + metadata TEXT, + is_active INTEGER, + created_at TEXT, + updated_at TEXT + ) + '''); + + // Product Variants table + await db.execute(''' + CREATE TABLE product_variants ( + id TEXT PRIMARY KEY, + product_id TEXT, + name TEXT, + price_modifier INTEGER, + cost INTEGER, + metadata TEXT, + created_at TEXT, + updated_at TEXT, + FOREIGN KEY (product_id) REFERENCES products (id) ON DELETE CASCADE + ) + '''); + + // Create indexes for better performance + await db.execute( + 'CREATE INDEX idx_products_category_id ON products(category_id)'); + await db.execute('CREATE INDEX idx_products_name ON products(name)'); + await db.execute('CREATE INDEX idx_products_sku ON products(sku)'); + await db.execute( + 'CREATE INDEX idx_products_description ON products(description)'); + } + + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + // Handle database upgrades here + } + + Future close() async { + final db = await database; + await db.close(); + _database = null; + } +} diff --git a/lib/core/database/migration_handler.dart b/lib/core/database/migration_handler.dart new file mode 100644 index 0000000..7b4e9f8 --- /dev/null +++ b/lib/core/database/migration_handler.dart @@ -0,0 +1,29 @@ +import 'package:sqflite/sqflite.dart'; + +class DatabaseMigrationHandler { + static Future migrate( + Database db, int oldVersion, int newVersion) async { + if (oldVersion < 2) { + // Add indexes for better performance + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_products_name_search ON products(name)'); + await db.execute( + 'CREATE INDEX IF NOT EXISTS idx_products_sku_search ON products(sku)'); + } + + if (oldVersion < 3) { + // Add full text search support + await db.execute( + 'CREATE VIRTUAL TABLE products_fts USING fts5(name, sku, description, content=products, content_rowid=rowid)'); + await db.execute( + 'INSERT INTO products_fts SELECT name, sku, description FROM products'); + } + + if (oldVersion < 4) { + // Add sync tracking + await db.execute('ALTER TABLE products ADD COLUMN last_sync_at TEXT'); + await db.execute( + 'ALTER TABLE products ADD COLUMN sync_version INTEGER DEFAULT 1'); + } + } +} diff --git a/lib/core/error/database_error_handler.dart b/lib/core/error/database_error_handler.dart new file mode 100644 index 0000000..1bbd099 --- /dev/null +++ b/lib/core/error/database_error_handler.dart @@ -0,0 +1,53 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:sqflite/sqflite.dart'; + +class DatabaseErrorHandler { + static Future executeWithRetry( + Future Function() operation, { + int maxRetries = 3, + Duration delay = const Duration(milliseconds: 500), + }) async { + int attempts = 0; + + while (attempts < maxRetries) { + try { + return await operation(); + } catch (e) { + attempts++; + + if (attempts >= maxRetries) { + rethrow; + } + + log('Database operation failed (attempt $attempts/$maxRetries): $e'); + await Future.delayed(delay * attempts); + } + } + + throw Exception('Max retries exceeded'); + } + + static bool isDatabaseCorrupted(dynamic error) { + final errorString = error.toString().toLowerCase(); + return errorString.contains('corrupt') || + errorString.contains('malformed') || + errorString.contains('no such table'); + } + + static Future handleDatabaseCorruption() async { + try { + // Delete corrupted database + final dbPath = await getDatabasesPath(); + final file = File('$dbPath/pos_database.db'); + if (await file.exists()) { + await file.delete(); + } + + log('Corrupted database deleted, will be recreated'); + } catch (e) { + log('Error handling database corruption: $e'); + } + } +} diff --git a/lib/core/performance/database_monitor.dart b/lib/core/performance/database_monitor.dart new file mode 100644 index 0000000..a6759f9 --- /dev/null +++ b/lib/core/performance/database_monitor.dart @@ -0,0 +1,64 @@ +import 'dart:developer'; + +class DatabasePerformanceMonitor { + static final Map> _queryTimes = {}; + + static Future monitorQuery( + String queryName, + Future Function() query, + ) async { + final stopwatch = Stopwatch()..start(); + + try { + final result = await query(); + stopwatch.stop(); + + _recordQueryTime(queryName, stopwatch.elapsedMilliseconds); + + return result; + } catch (e) { + stopwatch.stop(); + log('Query "$queryName" failed after ${stopwatch.elapsedMilliseconds}ms: $e'); + rethrow; + } + } + + static void _recordQueryTime(String queryName, int milliseconds) { + if (!_queryTimes.containsKey(queryName)) { + _queryTimes[queryName] = []; + } + + _queryTimes[queryName]!.add(milliseconds); + + // Keep only last 100 entries + if (_queryTimes[queryName]!.length > 100) { + _queryTimes[queryName]!.removeAt(0); + } + + // Log slow queries + if (milliseconds > 1000) { + log('Slow query detected: "$queryName" took ${milliseconds}ms'); + } + } + + static Map getPerformanceStats() { + final stats = {}; + + _queryTimes.forEach((queryName, times) { + if (times.isNotEmpty) { + final avgTime = times.reduce((a, b) => a + b) / times.length; + final maxTime = times.reduce((a, b) => a > b ? a : b); + final minTime = times.reduce((a, b) => a < b ? a : b); + + stats[queryName] = { + 'average_ms': avgTime.round(), + 'max_ms': maxTime, + 'min_ms': minTime, + 'total_queries': times.length, + }; + } + }); + + return stats; + } +} diff --git a/lib/data/datasources/product/product_local_datasource.dart b/lib/data/datasources/product/product_local_datasource.dart new file mode 100644 index 0000000..ecaefcd --- /dev/null +++ b/lib/data/datasources/product/product_local_datasource.dart @@ -0,0 +1,496 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; +import 'package:enaklo_pos/core/database/database_handler.dart'; +import 'package:enaklo_pos/data/models/response/product_response_model.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart' as p; + +class ProductLocalDatasource { + static ProductLocalDatasource? _instance; + + ProductLocalDatasource._internal(); + + static ProductLocalDatasource get instance { + _instance ??= ProductLocalDatasource._internal(); + return _instance!; + } + + Future get _db async => await DatabaseHelper.instance.database; + + // ======================================== + // CACHING SYSTEM + // ======================================== + final Map> _queryCache = {}; + final Duration _cacheExpiry = Duration(minutes: 5); + final Map _cacheTimestamps = {}; + + // ======================================== + // ENHANCED BATCH SAVE + // ======================================== + Future saveProductsBatch(List products, + {bool clearFirst = false}) async { + final db = await _db; + + try { + await db.transaction((txn) async { + if (clearFirst) { + log('๐Ÿ—‘๏ธ Clearing existing products...'); + await txn.delete('product_variants'); + await txn.delete('products'); + } + + log('๐Ÿ’พ Batch saving ${products.length} products...'); + + // โœ… BATCH INSERT PRODUCTS - Much faster than individual inserts + final batch = txn.batch(); + for (final product in products) { + batch.insert( + 'products', + _productToMap(product), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + + // โœ… BATCH INSERT VARIANTS + final variantBatch = txn.batch(); + for (final product in products) { + if (product.variants?.isNotEmpty == true) { + // Delete existing variants in batch + variantBatch.delete( + 'product_variants', + where: 'product_id = ?', + whereArgs: [product.id], + ); + + // Insert new variants + for (final variant in product.variants!) { + variantBatch.insert( + 'product_variants', + _variantToMap(variant), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + } + } + await variantBatch.commit(noResult: true); + }); + + // Clear cache after update + clearCache(); + log('โœ… Successfully batch saved ${products.length} products'); + } catch (e) { + log('โŒ Error batch saving products: $e'); + rethrow; + } + } + + // ======================================== + // CACHED QUERY - HIGH PERFORMANCE + // ======================================== + Future> getCachedProducts({ + int page = 1, + int limit = 10, + String? categoryId, + String? search, + }) async { + final cacheKey = _generateCacheKey(page, limit, categoryId, search); + final now = DateTime.now(); + + // โœ… CHECK CACHE FIRST + if (_queryCache.containsKey(cacheKey) && + _cacheTimestamps.containsKey(cacheKey)) { + final cacheTime = _cacheTimestamps[cacheKey]!; + if (now.difference(cacheTime) < _cacheExpiry) { + log('๐Ÿš€ Cache HIT: $cacheKey (${_queryCache[cacheKey]!.length} products)'); + return _queryCache[cacheKey]!; // Return from cache - SUPER FAST + } + } + + log('๐Ÿ“€ Cache MISS: $cacheKey, querying database...'); + + // Cache miss, query database + final products = await getProducts( + page: page, + limit: limit, + categoryId: categoryId, + search: search, + ); + + // โœ… STORE IN CACHE for next time + _queryCache[cacheKey] = products; + _cacheTimestamps[cacheKey] = now; + + log('๐Ÿ’พ Cached ${products.length} products for key: $cacheKey'); + return products; + } + + // ======================================== + // REGULAR GET PRODUCTS (No Cache) + // ======================================== + Future> getProducts({ + int page = 1, + int limit = 10, + String? categoryId, + String? search, + }) async { + final db = await _db; + + try { + String query = 'SELECT * FROM products WHERE 1=1'; + List 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> maps = + await db.rawQuery(query, whereArgs); + + List products = []; + for (final map in maps) { + final variants = await _getProductVariants(db, map['id']); + final product = _mapToProduct(map, variants); + products.add(product); + } + + log('๐Ÿ“Š Retrieved ${products.length} products from database'); + return products; + } catch (e) { + log('โŒ Error getting products: $e'); + return []; + } + } + + // ======================================== + // OPTIMIZED SEARCH with RANKING + // ======================================== + Future> searchProductsOptimized(String query) async { + final db = await _db; + + try { + log('๐Ÿ” Optimized search for: "$query"'); + + // โœ… Smart query with prioritization + final List> 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 + ]); + + List products = []; + for (final map in maps) { + final variants = await _getProductVariants(db, map['id']); + products.add(_mapToProduct(map, variants)); + } + + log('๐ŸŽฏ Optimized search found ${products.length} results'); + return products; + } catch (e) { + log('โŒ Error in optimized search: $e'); + return []; + } + } + + // ======================================== + // DATABASE ANALYTICS & MONITORING + // ======================================== + Future> getDatabaseStats() async { + final db = await _db; + + 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'); + return stats; + } catch (e) { + log('โŒ Error getting database stats: $e'); + return {}; + } + } + + Future _getDatabaseSize() async { + try { + final dbPath = p.join(await getDatabasesPath(), 'pos_database.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'); + } + return 0.0; + } + + double _getCacheSize() { + double totalSize = 0; + _queryCache.forEach((key, products) { + totalSize += products.length * 0.001; // Rough estimate in MB + }); + return totalSize; + } + + // ======================================== + // CACHE MANAGEMENT + // ======================================== + String _generateCacheKey( + int page, int limit, String? categoryId, String? search) { + return 'products_${page}_${limit}_${categoryId ?? 'null'}_${search ?? 'null'}'; + } + + void clearCache() { + final count = _queryCache.length; + _queryCache.clear(); + _cacheTimestamps.clear(); + log('๐Ÿงน Cache cleared: $count entries removed'); + } + + void clearExpiredCache() { + final now = DateTime.now(); + final expiredKeys = []; + + _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'); + } + } + + // ======================================== + // OTHER METHODS (Same as basic but with enhanced logging) + // ======================================== + + Future getProductById(String id) async { + final db = await _db; + + try { + final List> maps = await db.query( + 'products', + where: 'id = ?', + whereArgs: [id], + ); + + if (maps.isEmpty) { + log('โŒ Product not found: $id'); + return null; + } + + final variants = await _getProductVariants(db, id); + final product = _mapToProduct(maps.first, variants); + log('โœ… Product found: ${product.name}'); + return product; + } catch (e) { + log('โŒ Error getting product by ID: $e'); + return null; + } + } + + Future getTotalCount({String? categoryId, String? search}) async { + final db = await _db; + + try { + String query = 'SELECT COUNT(*) FROM products WHERE 1=1'; + List 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)'); + return count; + } catch (e) { + log('โŒ Error getting total count: $e'); + return 0; + } + } + + Future hasProducts() async { + final count = await getTotalCount(); + final hasData = count > 0; + log('๐Ÿ” Has products: $hasData ($count products)'); + return hasData; + } + + Future clearAllProducts() async { + final db = await _db; + + try { + await db.transaction((txn) async { + await txn.delete('product_variants'); + await txn.delete('products'); + }); + clearCache(); + log('๐Ÿ—‘๏ธ All products cleared from local DB'); + } catch (e) { + log('โŒ Error clearing products: $e'); + rethrow; + } + } + + // ======================================== + // HELPER METHODS + // ======================================== + + Future> _getProductVariants( + Database db, String productId) async { + try { + final List> maps = await db.query( + 'product_variants', + where: 'product_id = ?', + whereArgs: [productId], + orderBy: 'name ASC', + ); + + return maps.map((map) => _mapToVariant(map)).toList(); + } catch (e) { + log('โŒ Error getting variants for product $productId: $e'); + return []; + } + } + + Map _productToMap(Product product) { + return { + 'id': product.id, + 'organization_id': product.organizationId, + 'category_id': product.categoryId, + 'sku': product.sku, + 'name': product.name, + 'description': product.description, + 'price': product.price, + 'cost': product.cost, + 'business_type': product.businessType, + 'image_url': product.imageUrl, + 'printer_type': product.printerType, + 'metadata': + product.metadata != null ? json.encode(product.metadata) : null, + 'is_active': product.isActive == true ? 1 : 0, + 'created_at': product.createdAt?.toIso8601String(), + 'updated_at': product.updatedAt?.toIso8601String(), + }; + } + + Map _variantToMap(ProductVariant variant) { + return { + 'id': variant.id, + 'product_id': variant.productId, + 'name': variant.name, + 'price_modifier': variant.priceModifier, + 'cost': variant.cost, + 'metadata': + variant.metadata != null ? json.encode(variant.metadata) : null, + 'created_at': variant.createdAt?.toIso8601String(), + 'updated_at': variant.updatedAt?.toIso8601String(), + }; + } + + Product _mapToProduct( + Map map, List variants) { + return Product( + id: map['id'], + organizationId: map['organization_id'], + categoryId: map['category_id'], + sku: map['sku'], + name: map['name'], + description: map['description'], + price: map['price'], + cost: map['cost'], + businessType: map['business_type'], + imageUrl: map['image_url'], + printerType: map['printer_type'], + metadata: map['metadata'] != null ? json.decode(map['metadata']) : null, + isActive: map['is_active'] == 1, + createdAt: + map['created_at'] != null ? DateTime.parse(map['created_at']) : null, + updatedAt: + map['updated_at'] != null ? DateTime.parse(map['updated_at']) : null, + variants: variants, + ); + } + + ProductVariant _mapToVariant(Map map) { + return ProductVariant( + id: map['id'], + productId: map['product_id'], + name: map['name'], + priceModifier: map['price_modifier'], + cost: map['cost'], + metadata: map['metadata'] != null ? json.decode(map['metadata']) : null, + createdAt: + map['created_at'] != null ? DateTime.parse(map['created_at']) : null, + updatedAt: + map['updated_at'] != null ? DateTime.parse(map['updated_at']) : null, + ); + } +} diff --git a/lib/data/repositories/product/product_repository.dart b/lib/data/repositories/product/product_repository.dart new file mode 100644 index 0000000..c611212 --- /dev/null +++ b/lib/data/repositories/product/product_repository.dart @@ -0,0 +1,145 @@ +import 'dart:developer'; +import 'package:dartz/dartz.dart'; +import 'package:enaklo_pos/data/datasources/product/product_local_datasource.dart'; +import 'package:enaklo_pos/data/models/response/product_response_model.dart'; + +class ProductRepository { + static ProductRepository? _instance; + + final ProductLocalDatasource _localDatasource; + + ProductRepository._internal() + : _localDatasource = ProductLocalDatasource.instance; + + static ProductRepository get instance { + _instance ??= ProductRepository._internal(); + return _instance!; + } + + // ======================================== + // PURE LOCAL DATABASE OPERATIONS + // ======================================== + Future> getProducts({ + int page = 1, + int limit = 10, + String? categoryId, + String? search, + bool forceRefresh = false, // Ignored - kept for compatibility + }) async { + try { + log('๐Ÿ“ฑ Getting products from local database - page: $page, categoryId: $categoryId, search: $search'); + + // Clean expired cache for optimal performance + _localDatasource.clearExpiredCache(); + + // Use cached query for maximum performance + final cachedProducts = await _localDatasource.getCachedProducts( + page: page, + limit: limit, + categoryId: categoryId, + search: search, + ); + + final totalCount = await _localDatasource.getTotalCount( + categoryId: categoryId, + search: search, + ); + + final productData = ProductData( + products: cachedProducts, + totalCount: totalCount, + page: page, + limit: limit, + totalPages: totalCount > 0 ? (totalCount / limit).ceil() : 0, + ); + + final response = ProductResponseModel( + success: true, + data: productData, + errors: null, + ); + + log('โœ… Returned ${cachedProducts.length} local products (${totalCount} total)'); + return Right(response); + } catch (e) { + log('โŒ Error getting local products: $e'); + return Left('Gagal memuat produk dari database lokal: $e'); + } + } + + // ======================================== + // OPTIMIZED LOCAL SEARCH + // ======================================== + Future>> searchProductsOptimized( + String query) async { + try { + log('๐Ÿ” Local optimized search for: "$query"'); + + final products = await _localDatasource.searchProductsOptimized(query); + + log('โœ… Local search completed: ${products.length} results'); + return Right(products); + } catch (e) { + log('โŒ Error in local search: $e'); + return Left('Pencarian lokal gagal: $e'); + } + } + + // ======================================== + // LOCAL DATABASE OPERATIONS + // ======================================== + + // Refresh just cleans cache and reloads from local + Future> refreshProducts({ + String? categoryId, + String? search, + }) async { + log('๐Ÿ”„ Refreshing local products...'); + + // Clear cache for fresh local data + clearCache(); + + return await getProducts( + page: 1, + limit: 10, + categoryId: categoryId, + search: search, + ); + } + + Future getProductById(String id) async { + log('๐Ÿ” Getting product by ID from local: $id'); + return await _localDatasource.getProductById(id); + } + + Future hasLocalProducts() async { + final hasProducts = await _localDatasource.hasProducts(); + log('๐Ÿ“Š Has local products: $hasProducts'); + return hasProducts; + } + + Future> getDatabaseStats() async { + final stats = await _localDatasource.getDatabaseStats(); + log('๐Ÿ“Š Database stats: $stats'); + return stats; + } + + void clearCache() { + log('๐Ÿงน Clearing local cache'); + _localDatasource.clearCache(); + } + + // Helper method to check if local database is populated + Future isLocalDatabaseReady() async { + try { + final stats = await getDatabaseStats(); + final productCount = stats['total_products'] ?? 0; + final isReady = productCount > 0; + log('๐Ÿ” Local database ready: $isReady ($productCount products)'); + return isReady; + } catch (e) { + log('โŒ Error checking database readiness: $e'); + return false; + } + } +} diff --git a/lib/data/services/sync_manager.dart b/lib/data/services/sync_manager.dart new file mode 100644 index 0000000..70fd801 --- /dev/null +++ b/lib/data/services/sync_manager.dart @@ -0,0 +1,79 @@ +import 'dart:async'; +import 'dart:developer'; +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:enaklo_pos/data/repositories/product/product_repository.dart'; + +class SyncManager { + final ProductRepository _productRepository; + final Connectivity _connectivity = Connectivity(); + + Timer? _syncTimer; + bool _isSyncing = false; + StreamSubscription>? _connectivitySubscription; + + SyncManager(this._productRepository) { + _startPeriodicSync(); + _listenToConnectivityChanges(); + } + + void _startPeriodicSync() { + // Sync setiap 5 menit jika ada koneksi + _syncTimer = Timer.periodic(Duration(minutes: 5), (timer) { + _performBackgroundSync(); + }); + } + + void _listenToConnectivityChanges() { + _connectivitySubscription = _connectivity.onConnectivityChanged.listen( + (List results) { + // Check if any connection is available + final hasConnection = + results.any((result) => result != ConnectivityResult.none); + + if (hasConnection) { + log('Connection restored, starting background sync'); + _performBackgroundSync(); + } else { + log('Connection lost'); + } + }, + ); + } + + Future _performBackgroundSync() async { + if (_isSyncing) return; + + // Check current connectivity before syncing + final connectivityResults = await _connectivity.checkConnectivity(); + final hasConnection = + connectivityResults.any((result) => result != ConnectivityResult.none); + + if (!hasConnection) { + log('No internet connection, skipping sync'); + return; + } + + try { + _isSyncing = true; + log('Starting background sync'); + + await _productRepository.refreshProducts(); + + log('Background sync completed'); + } catch (e) { + log('Background sync failed: $e'); + } finally { + _isSyncing = false; + } + } + + // Public method untuk manual sync + Future performManualSync() async { + await _performBackgroundSync(); + } + + void dispose() { + _syncTimer?.cancel(); + _connectivitySubscription?.cancel(); + } +} diff --git a/lib/main.dart b/lib/main.dart index 8565816..4c6074b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'package:enaklo_pos/data/datasources/table_remote_datasource.dart'; import 'package:enaklo_pos/data/datasources/user_remote_datasource.dart'; import 'package:enaklo_pos/presentation/customer/bloc/customer_form/customer_form_bloc.dart'; import 'package:enaklo_pos/presentation/customer/bloc/customer_loader/customer_loader_bloc.dart'; +import 'package:enaklo_pos/presentation/data_sync/bloc/data_sync_bloc.dart'; import 'package:enaklo_pos/presentation/home/bloc/category_loader/category_loader_bloc.dart'; import 'package:enaklo_pos/presentation/home/bloc/current_outlet/current_outlet_bloc.dart'; import 'package:enaklo_pos/presentation/home/bloc/order_form/order_form_bloc.dart'; @@ -261,7 +262,7 @@ class _MyAppState extends State { create: (context) => AddOrderItemsBloc(OrderRemoteDatasource()), ), BlocProvider( - create: (context) => ProductLoaderBloc(ProductRemoteDatasource()), + create: (context) => ProductLoaderBloc(), ), BlocProvider( create: (context) => OrderFormBloc(OrderRemoteDatasource()), @@ -314,6 +315,9 @@ class _MyAppState extends State { BlocProvider( create: (context) => CategoryReportBloc(AnalyticRemoteDatasource()), ), + BlocProvider( + create: (context) => DataSyncBloc(), + ), ], child: MaterialApp( navigatorKey: AuthInterceptor.navigatorKey, diff --git a/lib/presentation/auth/login_page.dart b/lib/presentation/auth/login_page.dart index 8c8aa96..67a0581 100644 --- a/lib/presentation/auth/login_page.dart +++ b/lib/presentation/auth/login_page.dart @@ -1,3 +1,4 @@ +import 'package:enaklo_pos/presentation/data_sync/pages/data_sync_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:enaklo_pos/data/datasources/auth_local_datasource.dart'; @@ -9,7 +10,6 @@ import '../../core/components/buttons.dart'; import '../../core/components/custom_text_field.dart'; import '../../core/components/spaces.dart'; import '../../core/constants/colors.dart'; -import '../home/pages/dashboard_page.dart'; import 'bloc/login/login_bloc.dart'; class LoginPage extends StatefulWidget { @@ -104,7 +104,7 @@ class _LoginPageState extends State { Navigator.pushReplacement( context, MaterialPageRoute( - builder: (context) => const DashboardPage(), + builder: (context) => const DataSyncPage(), ), ); }, diff --git a/lib/presentation/data_sync/bloc/data_sync_bloc.dart b/lib/presentation/data_sync/bloc/data_sync_bloc.dart new file mode 100644 index 0000000..b3ab86c --- /dev/null +++ b/lib/presentation/data_sync/bloc/data_sync_bloc.dart @@ -0,0 +1,185 @@ +import 'dart:async'; +import 'dart:developer'; +import 'package:bloc/bloc.dart'; +import 'package:enaklo_pos/data/datasources/product/product_local_datasource.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import '../../../data/datasources/product_remote_datasource.dart'; + +part 'data_sync_event.dart'; +part 'data_sync_state.dart'; +part 'data_sync_bloc.freezed.dart'; + +enum SyncStep { products, categories, 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, + }); +} + +class DataSyncBloc extends Bloc { + final ProductRemoteDatasource _remoteDatasource = ProductRemoteDatasource(); + final ProductLocalDatasource _localDatasource = + ProductLocalDatasource.instance; + + Timer? _progressTimer; + bool _isCancelled = false; + + DataSyncBloc() : super(const DataSyncState.initial()) { + on<_StartSync>(_onStartSync); + on<_CancelSync>(_onCancelSync); + } + + @override + Future close() { + _progressTimer?.cancel(); + return super.close(); + } + + Future _onStartSync( + _StartSync event, + Emitter emit, + ) async { + log('๐Ÿ”„ Starting data sync...'); + _isCancelled = false; + + try { + // Step 1: Clear existing local data + emit(const DataSyncState.syncing( + SyncStep.products, 0.1, 'Membersihkan data lama...')); + await _localDatasource.clearAllProducts(); + + if (_isCancelled) return; + + // Step 2: Sync products + await _syncProducts(emit); + + if (_isCancelled) return; + + // Step 3: Generate final stats + emit(const DataSyncState.syncing( + SyncStep.completed, 0.9, 'Menyelesaikan sinkronisasi...')); + + final stats = await _generateSyncStats(); + + emit(DataSyncState.completed(stats)); + log('โœ… Sync completed successfully'); + } catch (e) { + log('โŒ Sync failed: $e'); + emit(DataSyncState.error('Gagal sinkronisasi: $e')); + } + } + + Future _syncProducts(Emitter emit) async { + log('๐Ÿ“ฆ Syncing products...'); + + int page = 1; + int totalSynced = 0; + int? totalCount; + int? totalPages; + bool shouldContinue = true; + + while (!_isCancelled && shouldContinue) { + // Calculate accurate progress based on total count + double progress = 0.2; + if (totalCount != null && (totalCount ?? 0) > 0) { + progress = 0.2 + (totalSynced / (totalCount ?? 0)) * 0.6; + } + + emit(DataSyncState.syncing( + SyncStep.products, + progress, + totalCount != null + ? 'Mengunduh produk... ($totalSynced dari $totalCount)' + : 'Mengunduh produk... ($totalSynced produk)', + )); + + final result = await _remoteDatasource.getProducts( + page: page, + limit: 50, // Bigger batch for sync + ); + + await result.fold( + (failure) async { + throw Exception(failure); + }, + (response) async { + final products = response.data?.products ?? []; + final responseData = response.data; + + // Get pagination info from first response + if (page == 1 && responseData != null) { + totalCount = responseData.totalCount; + totalPages = responseData.totalPages; + log('๐Ÿ“Š Total products to sync: $totalCount (${totalPages} pages)'); + } + + if (products.isEmpty) { + shouldContinue = false; + return; + } + + // Save to local database in batches + await _localDatasource.saveProductsBatch(products); + + totalSynced += products.length; + page++; + + log('๐Ÿ“ฆ Synced page ${page - 1}: ${products.length} products (Total: $totalSynced)'); + + // Check if we reached the end using pagination info + if (totalPages != null && page > (totalPages ?? 0)) { + shouldContinue = false; + return; + } + + // Fallback check if pagination info not available + if (products.length < 50) { + shouldContinue = false; + return; + } + + // Small delay to prevent overwhelming the server + await Future.delayed(Duration(milliseconds: 100)); + }, + ); + } + + emit(DataSyncState.syncing( + SyncStep.completed, + 0.8, + 'Produk berhasil diunduh ($totalSynced dari ${totalCount ?? totalSynced})', + )); + + log('โœ… Products sync completed: $totalSynced products synced'); + } + + Future _generateSyncStats() async { + final dbStats = await _localDatasource.getDatabaseStats(); + + return SyncStats( + totalProducts: dbStats['total_products'] ?? 0, + totalCategories: dbStats['total_categories'] ?? 0, + totalVariants: dbStats['total_variants'] ?? 0, + databaseSizeMB: dbStats['database_size_mb'] ?? 0.0, + ); + } + + Future _onCancelSync( + _CancelSync event, + Emitter emit, + ) async { + log('โน๏ธ Cancelling sync...'); + _isCancelled = true; + _progressTimer?.cancel(); + emit(const DataSyncState.initial()); + } +} diff --git a/lib/presentation/data_sync/bloc/data_sync_bloc.freezed.dart b/lib/presentation/data_sync/bloc/data_sync_bloc.freezed.dart new file mode 100644 index 0000000..8640084 --- /dev/null +++ b/lib/presentation/data_sync/bloc/data_sync_bloc.freezed.dart @@ -0,0 +1,962 @@ +// 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 'data_sync_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(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 _$DataSyncEvent { + @optionalTypeArgs + TResult when({ + required TResult Function() startSync, + required TResult Function() cancelSync, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? startSync, + TResult? Function()? cancelSync, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? startSync, + TResult Function()? cancelSync, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(_StartSync value) startSync, + required TResult Function(_CancelSync value) cancelSync, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_StartSync value)? startSync, + TResult? Function(_CancelSync value)? cancelSync, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_StartSync value)? startSync, + TResult Function(_CancelSync value)? cancelSync, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $DataSyncEventCopyWith<$Res> { + factory $DataSyncEventCopyWith( + DataSyncEvent value, $Res Function(DataSyncEvent) then) = + _$DataSyncEventCopyWithImpl<$Res, DataSyncEvent>; +} + +/// @nodoc +class _$DataSyncEventCopyWithImpl<$Res, $Val extends DataSyncEvent> + implements $DataSyncEventCopyWith<$Res> { + _$DataSyncEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of DataSyncEvent + /// 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 _$DataSyncEventCopyWithImpl<$Res, _$StartSyncImpl> + implements _$$StartSyncImplCopyWith<$Res> { + __$$StartSyncImplCopyWithImpl( + _$StartSyncImpl _value, $Res Function(_$StartSyncImpl) _then) + : super(_value, _then); + + /// Create a copy of DataSyncEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$StartSyncImpl implements _StartSync { + const _$StartSyncImpl(); + + @override + String toString() { + return 'DataSyncEvent.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({ + required TResult Function() startSync, + required TResult Function() cancelSync, + }) { + return startSync(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? startSync, + TResult? Function()? cancelSync, + }) { + return startSync?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? startSync, + TResult Function()? cancelSync, + required TResult orElse(), + }) { + if (startSync != null) { + return startSync(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_StartSync value) startSync, + required TResult Function(_CancelSync value) cancelSync, + }) { + return startSync(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_StartSync value)? startSync, + TResult? Function(_CancelSync value)? cancelSync, + }) { + return startSync?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + 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 DataSyncEvent { + const factory _StartSync() = _$StartSyncImpl; +} + +/// @nodoc +abstract class _$$CancelSyncImplCopyWith<$Res> { + factory _$$CancelSyncImplCopyWith( + _$CancelSyncImpl value, $Res Function(_$CancelSyncImpl) then) = + __$$CancelSyncImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$CancelSyncImplCopyWithImpl<$Res> + extends _$DataSyncEventCopyWithImpl<$Res, _$CancelSyncImpl> + implements _$$CancelSyncImplCopyWith<$Res> { + __$$CancelSyncImplCopyWithImpl( + _$CancelSyncImpl _value, $Res Function(_$CancelSyncImpl) _then) + : super(_value, _then); + + /// Create a copy of DataSyncEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$CancelSyncImpl implements _CancelSync { + const _$CancelSyncImpl(); + + @override + String toString() { + return 'DataSyncEvent.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({ + required TResult Function() startSync, + required TResult Function() cancelSync, + }) { + return cancelSync(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? startSync, + TResult? Function()? cancelSync, + }) { + return cancelSync?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? startSync, + TResult Function()? cancelSync, + required TResult orElse(), + }) { + if (cancelSync != null) { + return cancelSync(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_StartSync value) startSync, + required TResult Function(_CancelSync value) cancelSync, + }) { + return cancelSync(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_StartSync value)? startSync, + TResult? Function(_CancelSync value)? cancelSync, + }) { + return cancelSync?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + 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 DataSyncEvent { + const factory _CancelSync() = _$CancelSyncImpl; +} + +/// @nodoc +mixin _$DataSyncState { + @optionalTypeArgs + TResult when({ + required TResult Function() initial, + required TResult Function(SyncStep step, double progress, String message) + syncing, + required TResult Function(SyncStats stats) completed, + required TResult Function(String message) error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function(SyncStep step, double progress, String message)? syncing, + TResult? Function(SyncStats stats)? completed, + TResult? Function(String message)? error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function(SyncStep step, double progress, String message)? syncing, + TResult Function(SyncStats stats)? completed, + TResult Function(String message)? error, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(_Initial value) initial, + required TResult Function(_Syncing value) syncing, + required TResult Function(_Completed value) completed, + required TResult Function(_Error value) error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Initial value)? initial, + TResult? Function(_Syncing value)? syncing, + TResult? Function(_Completed value)? completed, + TResult? Function(_Error value)? error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Initial value)? initial, + TResult Function(_Syncing value)? syncing, + TResult Function(_Completed value)? completed, + TResult Function(_Error value)? error, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $DataSyncStateCopyWith<$Res> { + factory $DataSyncStateCopyWith( + DataSyncState value, $Res Function(DataSyncState) then) = + _$DataSyncStateCopyWithImpl<$Res, DataSyncState>; +} + +/// @nodoc +class _$DataSyncStateCopyWithImpl<$Res, $Val extends DataSyncState> + implements $DataSyncStateCopyWith<$Res> { + _$DataSyncStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of DataSyncState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc +abstract class _$$InitialImplCopyWith<$Res> { + factory _$$InitialImplCopyWith( + _$InitialImpl value, $Res Function(_$InitialImpl) then) = + __$$InitialImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$InitialImplCopyWithImpl<$Res> + extends _$DataSyncStateCopyWithImpl<$Res, _$InitialImpl> + implements _$$InitialImplCopyWith<$Res> { + __$$InitialImplCopyWithImpl( + _$InitialImpl _value, $Res Function(_$InitialImpl) _then) + : super(_value, _then); + + /// Create a copy of DataSyncState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$InitialImpl implements _Initial { + const _$InitialImpl(); + + @override + String toString() { + return 'DataSyncState.initial()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _$InitialImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() initial, + required TResult Function(SyncStep step, double progress, String message) + syncing, + required TResult Function(SyncStats stats) completed, + required TResult Function(String message) error, + }) { + return initial(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function(SyncStep step, double progress, String message)? syncing, + TResult? Function(SyncStats stats)? completed, + TResult? Function(String message)? error, + }) { + return initial?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function(SyncStep step, double progress, String message)? syncing, + TResult Function(SyncStats stats)? completed, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (initial != null) { + return initial(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_Initial value) initial, + required TResult Function(_Syncing value) syncing, + required TResult Function(_Completed value) completed, + required TResult Function(_Error value) error, + }) { + return initial(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Initial value)? initial, + TResult? Function(_Syncing value)? syncing, + TResult? Function(_Completed value)? completed, + TResult? Function(_Error value)? error, + }) { + return initial?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Initial value)? initial, + TResult Function(_Syncing value)? syncing, + TResult Function(_Completed value)? completed, + TResult Function(_Error value)? error, + required TResult orElse(), + }) { + if (initial != null) { + return initial(this); + } + return orElse(); + } +} + +abstract class _Initial implements DataSyncState { + const factory _Initial() = _$InitialImpl; +} + +/// @nodoc +abstract class _$$SyncingImplCopyWith<$Res> { + factory _$$SyncingImplCopyWith( + _$SyncingImpl value, $Res Function(_$SyncingImpl) then) = + __$$SyncingImplCopyWithImpl<$Res>; + @useResult + $Res call({SyncStep step, double progress, String message}); +} + +/// @nodoc +class __$$SyncingImplCopyWithImpl<$Res> + extends _$DataSyncStateCopyWithImpl<$Res, _$SyncingImpl> + implements _$$SyncingImplCopyWith<$Res> { + __$$SyncingImplCopyWithImpl( + _$SyncingImpl _value, $Res Function(_$SyncingImpl) _then) + : super(_value, _then); + + /// Create a copy of DataSyncState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? step = null, + Object? progress = null, + Object? message = null, + }) { + return _then(_$SyncingImpl( + null == step + ? _value.step + : step // ignore: cast_nullable_to_non_nullable + as SyncStep, + null == progress + ? _value.progress + : progress // ignore: cast_nullable_to_non_nullable + as double, + null == message + ? _value.message + : message // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$SyncingImpl implements _Syncing { + const _$SyncingImpl(this.step, this.progress, this.message); + + @override + final SyncStep step; + @override + final double progress; + @override + final String message; + + @override + String toString() { + return 'DataSyncState.syncing(step: $step, progress: $progress, message: $message)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SyncingImpl && + (identical(other.step, step) || other.step == step) && + (identical(other.progress, progress) || + other.progress == progress) && + (identical(other.message, message) || other.message == message)); + } + + @override + int get hashCode => Object.hash(runtimeType, step, progress, message); + + /// Create a copy of DataSyncState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SyncingImplCopyWith<_$SyncingImpl> get copyWith => + __$$SyncingImplCopyWithImpl<_$SyncingImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() initial, + required TResult Function(SyncStep step, double progress, String message) + syncing, + required TResult Function(SyncStats stats) completed, + required TResult Function(String message) error, + }) { + return syncing(step, progress, message); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function(SyncStep step, double progress, String message)? syncing, + TResult? Function(SyncStats stats)? completed, + TResult? Function(String message)? error, + }) { + return syncing?.call(step, progress, message); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function(SyncStep step, double progress, String message)? syncing, + TResult Function(SyncStats stats)? completed, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (syncing != null) { + return syncing(step, progress, message); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_Initial value) initial, + required TResult Function(_Syncing value) syncing, + required TResult Function(_Completed value) completed, + required TResult Function(_Error value) error, + }) { + return syncing(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Initial value)? initial, + TResult? Function(_Syncing value)? syncing, + TResult? Function(_Completed value)? completed, + TResult? Function(_Error value)? error, + }) { + return syncing?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Initial value)? initial, + TResult Function(_Syncing value)? syncing, + TResult Function(_Completed value)? completed, + TResult Function(_Error value)? error, + required TResult orElse(), + }) { + if (syncing != null) { + return syncing(this); + } + return orElse(); + } +} + +abstract class _Syncing implements DataSyncState { + const factory _Syncing( + final SyncStep step, final double progress, final String message) = + _$SyncingImpl; + + SyncStep get step; + double get progress; + String get message; + + /// Create a copy of DataSyncState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SyncingImplCopyWith<_$SyncingImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$CompletedImplCopyWith<$Res> { + factory _$$CompletedImplCopyWith( + _$CompletedImpl value, $Res Function(_$CompletedImpl) then) = + __$$CompletedImplCopyWithImpl<$Res>; + @useResult + $Res call({SyncStats stats}); +} + +/// @nodoc +class __$$CompletedImplCopyWithImpl<$Res> + extends _$DataSyncStateCopyWithImpl<$Res, _$CompletedImpl> + implements _$$CompletedImplCopyWith<$Res> { + __$$CompletedImplCopyWithImpl( + _$CompletedImpl _value, $Res Function(_$CompletedImpl) _then) + : super(_value, _then); + + /// Create a copy of DataSyncState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? stats = null, + }) { + return _then(_$CompletedImpl( + null == stats + ? _value.stats + : stats // ignore: cast_nullable_to_non_nullable + as SyncStats, + )); + } +} + +/// @nodoc + +class _$CompletedImpl implements _Completed { + const _$CompletedImpl(this.stats); + + @override + final SyncStats stats; + + @override + String toString() { + return 'DataSyncState.completed(stats: $stats)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$CompletedImpl && + (identical(other.stats, stats) || other.stats == stats)); + } + + @override + int get hashCode => Object.hash(runtimeType, stats); + + /// Create a copy of DataSyncState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$CompletedImplCopyWith<_$CompletedImpl> get copyWith => + __$$CompletedImplCopyWithImpl<_$CompletedImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() initial, + required TResult Function(SyncStep step, double progress, String message) + syncing, + required TResult Function(SyncStats stats) completed, + required TResult Function(String message) error, + }) { + return completed(stats); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function(SyncStep step, double progress, String message)? syncing, + TResult? Function(SyncStats stats)? completed, + TResult? Function(String message)? error, + }) { + return completed?.call(stats); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function(SyncStep step, double progress, String message)? syncing, + TResult Function(SyncStats stats)? completed, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (completed != null) { + return completed(stats); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_Initial value) initial, + required TResult Function(_Syncing value) syncing, + required TResult Function(_Completed value) completed, + required TResult Function(_Error value) error, + }) { + return completed(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Initial value)? initial, + TResult? Function(_Syncing value)? syncing, + TResult? Function(_Completed value)? completed, + TResult? Function(_Error value)? error, + }) { + return completed?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Initial value)? initial, + TResult Function(_Syncing value)? syncing, + TResult Function(_Completed value)? completed, + TResult Function(_Error value)? error, + required TResult orElse(), + }) { + if (completed != null) { + return completed(this); + } + return orElse(); + } +} + +abstract class _Completed implements DataSyncState { + const factory _Completed(final SyncStats stats) = _$CompletedImpl; + + SyncStats get stats; + + /// Create a copy of DataSyncState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$CompletedImplCopyWith<_$CompletedImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$ErrorImplCopyWith<$Res> { + factory _$$ErrorImplCopyWith( + _$ErrorImpl value, $Res Function(_$ErrorImpl) then) = + __$$ErrorImplCopyWithImpl<$Res>; + @useResult + $Res call({String message}); +} + +/// @nodoc +class __$$ErrorImplCopyWithImpl<$Res> + extends _$DataSyncStateCopyWithImpl<$Res, _$ErrorImpl> + implements _$$ErrorImplCopyWith<$Res> { + __$$ErrorImplCopyWithImpl( + _$ErrorImpl _value, $Res Function(_$ErrorImpl) _then) + : super(_value, _then); + + /// Create a copy of DataSyncState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? message = null, + }) { + return _then(_$ErrorImpl( + null == message + ? _value.message + : message // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$ErrorImpl implements _Error { + const _$ErrorImpl(this.message); + + @override + final String message; + + @override + String toString() { + return 'DataSyncState.error(message: $message)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ErrorImpl && + (identical(other.message, message) || other.message == message)); + } + + @override + int get hashCode => Object.hash(runtimeType, message); + + /// Create a copy of DataSyncState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ErrorImplCopyWith<_$ErrorImpl> get copyWith => + __$$ErrorImplCopyWithImpl<_$ErrorImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() initial, + required TResult Function(SyncStep step, double progress, String message) + syncing, + required TResult Function(SyncStats stats) completed, + required TResult Function(String message) error, + }) { + return error(message); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function(SyncStep step, double progress, String message)? syncing, + TResult? Function(SyncStats stats)? completed, + TResult? Function(String message)? error, + }) { + return error?.call(message); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function(SyncStep step, double progress, String message)? syncing, + TResult Function(SyncStats stats)? completed, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (error != null) { + return error(message); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_Initial value) initial, + required TResult Function(_Syncing value) syncing, + required TResult Function(_Completed value) completed, + required TResult Function(_Error value) error, + }) { + return error(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Initial value)? initial, + TResult? Function(_Syncing value)? syncing, + TResult? Function(_Completed value)? completed, + TResult? Function(_Error value)? error, + }) { + return error?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Initial value)? initial, + TResult Function(_Syncing value)? syncing, + TResult Function(_Completed value)? completed, + TResult Function(_Error value)? error, + required TResult orElse(), + }) { + if (error != null) { + return error(this); + } + return orElse(); + } +} + +abstract class _Error implements DataSyncState { + const factory _Error(final String message) = _$ErrorImpl; + + String get message; + + /// Create a copy of DataSyncState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ErrorImplCopyWith<_$ErrorImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/presentation/data_sync/bloc/data_sync_event.dart b/lib/presentation/data_sync/bloc/data_sync_event.dart new file mode 100644 index 0000000..65ce5a3 --- /dev/null +++ b/lib/presentation/data_sync/bloc/data_sync_event.dart @@ -0,0 +1,7 @@ +part of 'data_sync_bloc.dart'; + +@freezed +class DataSyncEvent with _$DataSyncEvent { + const factory DataSyncEvent.startSync() = _StartSync; + const factory DataSyncEvent.cancelSync() = _CancelSync; +} diff --git a/lib/presentation/data_sync/bloc/data_sync_state.dart b/lib/presentation/data_sync/bloc/data_sync_state.dart new file mode 100644 index 0000000..1177761 --- /dev/null +++ b/lib/presentation/data_sync/bloc/data_sync_state.dart @@ -0,0 +1,13 @@ +part of 'data_sync_bloc.dart'; + +@freezed +class DataSyncState with _$DataSyncState { + const factory DataSyncState.initial() = _Initial; + const factory DataSyncState.syncing( + SyncStep step, + double progress, + String message, + ) = _Syncing; + const factory DataSyncState.completed(SyncStats stats) = _Completed; + const factory DataSyncState.error(String message) = _Error; +} diff --git a/lib/presentation/data_sync/pages/data_sync_page.dart b/lib/presentation/data_sync/pages/data_sync_page.dart new file mode 100644 index 0000000..fa397e3 --- /dev/null +++ b/lib/presentation/data_sync/pages/data_sync_page.dart @@ -0,0 +1,635 @@ +// ======================================== +// DATA SYNC PAGE - POST LOGIN SYNC +// lib/presentation/sync/pages/data_sync_page.dart +// ======================================== + +import 'dart:async'; +import 'package:enaklo_pos/core/extensions/build_context_ext.dart'; +import 'package:enaklo_pos/presentation/home/pages/dashboard_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../core/components/buttons.dart'; +import '../../../core/components/spaces.dart'; +import '../../../core/constants/colors.dart'; +import '../bloc/data_sync_bloc.dart'; + +class DataSyncPage extends StatefulWidget { + const DataSyncPage({super.key}); + + @override + State createState() => _DataSyncPageState(); +} + +class _DataSyncPageState extends State + with TickerProviderStateMixin { + late AnimationController _animationController; + late Animation _progressAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: Duration(milliseconds: 500), + vsync: this, + ); + _progressAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + )); + + // Auto start sync + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().add(const DataSyncEvent.startSync()); + }); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.grey.shade50, + body: SafeArea( + child: BlocConsumer( + listener: (context, state) { + state.maybeWhen( + orElse: () {}, + syncing: (step, progress, message) { + _animationController.animateTo(progress); + }, + completed: (stats) { + _animationController.animateTo(1.0); + // Navigate to home after delay + Future.delayed(Duration(seconds: 2), () { + context.pushReplacement(DashboardPage()); + }); + }, + error: (message) { + _animationController.stop(); + }, + ); + }, + builder: (context, state) { + return Padding( + padding: EdgeInsets.all(24), + child: Column( + children: [ + SpaceHeight(60), + + // Header + _buildHeader(), + + SpaceHeight(60), + + // Sync progress + Expanded( + child: state.when( + initial: () => _buildInitialState(), + syncing: (step, progress, message) => + _buildSyncingState(step, progress, message), + completed: (stats) => _buildCompletedState(stats), + error: (message) => _buildErrorState(message), + ), + ), + + SpaceHeight(40), + + // Actions + _buildActions(state), + ], + ), + ); + }, + ), + ), + ); + } + + Widget _buildHeader() { + return Column( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + Icons.sync, + size: 40, + color: AppColors.primary, + ), + ), + SpaceHeight(20), + Text( + 'Sinkronisasi Data', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.grey.shade800, + ), + ), + SpaceHeight(8), + Text( + 'Mengunduh data terbaru ke perangkat', + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade600, + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildInitialState() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.download_rounded, + size: 64, + color: Colors.grey.shade400, + ), + SpaceHeight(20), + Text( + 'Siap untuk sinkronisasi', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + SpaceHeight(8), + Text( + 'Tekan tombol mulai untuk mengunduh data', + style: TextStyle( + color: Colors.grey.shade600, + ), + ), + ], + ); + } + + Widget _buildSyncingState(SyncStep step, double progress, String message) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Progress circle + Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 120, + height: 120, + child: AnimatedBuilder( + animation: _progressAnimation, + builder: (context, child) { + return CircularProgressIndicator( + value: _progressAnimation.value, + strokeWidth: 8, + backgroundColor: Colors.grey.shade200, + valueColor: + AlwaysStoppedAnimation(AppColors.primary), + ); + }, + ), + ), + Column( + children: [ + Icon( + _getSyncIcon(step), + size: 32, + color: AppColors.primary, + ), + SpaceHeight(4), + AnimatedBuilder( + animation: _progressAnimation, + builder: (context, child) { + return Text( + '${(_progressAnimation.value * 100).toInt()}%', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ); + }, + ), + ], + ), + ], + ), + + SpaceHeight(30), + + // Step indicator + _buildStepIndicator(step), + + SpaceHeight(20), + + // Current message + Container( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + message, + style: TextStyle( + color: Colors.blue.shade700, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + ), + + SpaceHeight(20), + + // Sync details + _buildSyncDetails(step, progress), + ], + ); + } + + Widget _buildStepIndicator(SyncStep currentStep) { + final steps = [ + ('Produk', SyncStep.products, Icons.inventory_2), + ('Kategori', SyncStep.categories, Icons.category), + ('Variant', SyncStep.variants, Icons.tune), + ('Selesai', SyncStep.completed, Icons.check_circle), + ]; + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: steps.map((stepData) { + final (label, step, icon) = stepData; + final isActive = step == currentStep; + final isCompleted = step.index < currentStep.index; + + return Container( + margin: EdgeInsets.symmetric(horizontal: 4), + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isActive + ? AppColors.primary.withOpacity(0.1) + : isCompleted + ? Colors.green.shade50 + : Colors.grey.shade100, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isCompleted ? Icons.check : icon, + size: 14, + color: isActive + ? AppColors.primary + : isCompleted + ? Colors.green.shade600 + : Colors.grey.shade500, + ), + SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, + color: isActive + ? AppColors.primary + : isCompleted + ? Colors.green.shade600 + : Colors.grey.shade600, + ), + ), + ], + ), + ); + }).toList(), + ); + } + + Widget _buildSyncDetails(SyncStep step, double progress) { + return Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Status:', + style: TextStyle( + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + Text( + _getStepLabel(step), + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], + ), + SpaceHeight(8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Progress:', + style: TextStyle( + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + Text( + '${(progress * 100).toInt()}%', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildCompletedState(SyncStats stats) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Success icon + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(50), + ), + child: Icon( + Icons.check_circle, + size: 60, + color: Colors.green.shade600, + ), + ), + + SpaceHeight(30), + + Text( + 'Sinkronisasi Berhasil!', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.green.shade700, + ), + ), + + SpaceHeight(16), + + Text( + 'Data berhasil diunduh ke perangkat', + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade600, + ), + ), + + SpaceHeight(30), + + // Stats cards + Container( + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + children: [ + Text( + 'Data yang Diunduh', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + SpaceHeight(16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildStatItem( + 'Produk', + '${stats.totalProducts}', + Icons.inventory_2, + Colors.blue, + ), + _buildStatItem( + 'Kategori', + '${stats.totalCategories}', + Icons.category, + Colors.green, + ), + _buildStatItem( + 'Variant', + '${stats.totalVariants}', + Icons.tune, + Colors.orange, + ), + ], + ), + ], + ), + ), + + SpaceHeight(20), + + Container( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Mengalihkan ke halaman utama...', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + ), + ], + ); + } + + Widget _buildErrorState(String message) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.red.shade400, + ), + SpaceHeight(20), + Text( + 'Sinkronisasi Gagal', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.red.shade600, + ), + ), + SpaceHeight(12), + Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + message, + style: TextStyle( + color: Colors.red.shade700, + ), + textAlign: TextAlign.center, + ), + ), + SpaceHeight(20), + Text( + 'Periksa koneksi internet dan coba lagi', + style: TextStyle( + color: Colors.grey.shade600, + ), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildStatItem( + String label, String value, IconData icon, Color color) { + return Column( + children: [ + Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + icon, + size: 24, + color: color, + ), + ), + SpaceHeight(8), + Text( + value, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ); + } + + Widget _buildActions(DataSyncState state) { + return state.when( + initial: () => Button.filled( + onPressed: () { + context.read().add(const DataSyncEvent.startSync()); + }, + label: 'Mulai Sinkronisasi', + ), + syncing: (step, progress, message) => Button.outlined( + onPressed: () { + context.read().add(const DataSyncEvent.cancelSync()); + }, + label: 'Batalkan', + ), + completed: (stats) => Button.filled( + onPressed: () { + Navigator.of(context).pushReplacementNamed('/home'); + }, + label: 'Lanjutkan ke Aplikasi', + ), + error: (message) => Row( + children: [ + Expanded( + child: Button.outlined( + onPressed: () { + Navigator.of(context).pushReplacementNamed('/home'); + }, + label: 'Lewati', + ), + ), + SizedBox(width: 16), + Expanded( + child: Button.filled( + onPressed: () { + context + .read() + .add(const DataSyncEvent.startSync()); + }, + label: 'Coba Lagi', + ), + ), + ], + ), + ); + } + + IconData _getSyncIcon(SyncStep step) { + switch (step) { + case SyncStep.products: + return Icons.inventory_2; + case SyncStep.categories: + return Icons.category; + case SyncStep.variants: + return Icons.tune; + case SyncStep.completed: + return Icons.check_circle; + } + } + + String _getStepLabel(SyncStep step) { + switch (step) { + case SyncStep.products: + return 'Mengunduh Produk'; + case SyncStep.categories: + return 'Mengunduh Kategori'; + case SyncStep.variants: + return 'Mengunduh Variant'; + case SyncStep.completed: + return 'Selesai'; + } + } +} diff --git a/lib/presentation/home/bloc/product_loader/product_loader_bloc.dart b/lib/presentation/home/bloc/product_loader/product_loader_bloc.dart index 1a18a08..e35cb7f 100644 --- a/lib/presentation/home/bloc/product_loader/product_loader_bloc.dart +++ b/lib/presentation/home/bloc/product_loader/product_loader_bloc.dart @@ -1,8 +1,8 @@ import 'dart:async'; - -import 'package:bloc/bloc.dart'; -import 'package:enaklo_pos/data/datasources/product_remote_datasource.dart'; +import 'dart:developer'; import 'package:enaklo_pos/data/models/response/product_response_model.dart'; +import 'package:enaklo_pos/data/repositories/product/product_repository.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'product_loader_event.dart'; @@ -10,102 +10,114 @@ part 'product_loader_state.dart'; part 'product_loader_bloc.freezed.dart'; class ProductLoaderBloc extends Bloc { - final ProductRemoteDatasource _productRemoteDatasource; + final ProductRepository _productRepository = ProductRepository.instance; - // Debouncing untuk mencegah multiple load more calls Timer? _loadMoreDebounce; + Timer? _searchDebounce; bool _isLoadingMore = false; - ProductLoaderBloc(this._productRemoteDatasource) - : super(ProductLoaderState.initial()) { + ProductLoaderBloc() : super(const ProductLoaderState.initial()) { on<_GetProduct>(_onGetProduct); on<_LoadMore>(_onLoadMore); on<_Refresh>(_onRefresh); + on<_SearchProduct>(_onSearchProduct); + on<_GetDatabaseStats>(_onGetDatabaseStats); + on<_ClearCache>(_onClearCache); } @override Future close() { _loadMoreDebounce?.cancel(); + _searchDebounce?.cancel(); return super.close(); } - // Debounce transformer untuk load more - // EventTransformer _debounceTransformer() { - // return (events, mapper) { - // return events - // .debounceTime(const Duration(milliseconds: 300)) - // .asyncExpand(mapper); - // }; - // } - - // Initial load + // Pure local product loading Future _onGetProduct( _GetProduct event, Emitter emit, ) async { - emit(const _Loading()); - _isLoadingMore = false; // Reset loading state + emit(const ProductLoaderState.loading()); + _isLoadingMore = false; - final result = await _productRemoteDatasource.getProducts( + log('๐Ÿ“ฑ Loading local products - categoryId: ${event.categoryId}'); + + // Check if local database is ready + final isReady = await _productRepository.isLocalDatabaseReady(); + if (!isReady) { + emit(const ProductLoaderState.error( + 'Database lokal belum siap. Silakan lakukan sinkronisasi data terlebih dahulu.')); + return; + } + + final result = await _productRepository.getProducts( page: 1, limit: 10, categoryId: event.categoryId, + search: event.search, ); await result.fold( - (failure) async => emit(_Error(failure)), + (failure) async { + log('โŒ Error loading local products: $failure'); + emit(ProductLoaderState.error(failure)); + }, (response) async { final products = response.data?.products ?? []; - final hasReachedMax = products.length < 10; + final totalPages = response.data?.totalPages ?? 1; + final hasReachedMax = products.length < 10 || 1 >= totalPages; - emit(_Loaded( + log('โœ… Local products loaded: ${products.length}, hasReachedMax: $hasReachedMax, totalPages: $totalPages'); + + emit(ProductLoaderState.loaded( products: products, hasReachedMax: hasReachedMax, currentPage: 1, isLoadingMore: false, + categoryId: event.categoryId, + searchQuery: event.search, )); }, ); } - // Load more with enhanced debouncing + // Pure local load more Future _onLoadMore( _LoadMore event, Emitter emit, ) async { final currentState = state; - // Enhanced validation if (currentState is! _Loaded || currentState.hasReachedMax || _isLoadingMore || currentState.isLoadingMore) { + log('โน๏ธ Load more blocked - state: ${currentState.runtimeType}, isLoadingMore: $_isLoadingMore'); return; } _isLoadingMore = true; - - // Emit loading more state emit(currentState.copyWith(isLoadingMore: true)); final nextPage = currentState.currentPage + 1; + log('๐Ÿ“„ Loading more local products - page: $nextPage'); try { - final result = await _productRemoteDatasource.getProducts( + final result = await _productRepository.getProducts( page: nextPage, limit: 10, - categoryId: event.categoryId, + categoryId: currentState.categoryId, + search: currentState.searchQuery, ); await result.fold( (failure) async { - // On error, revert loading state but don't show error - // Just silently fail and allow retry + log('โŒ Error loading more local products: $failure'); emit(currentState.copyWith(isLoadingMore: false)); - _isLoadingMore = false; }, (response) async { final newProducts = response.data?.products ?? []; + final totalPages = response.data?.totalPages ?? 1; // Prevent duplicate products final currentProductIds = @@ -117,32 +129,130 @@ class ProductLoaderBloc extends Bloc { final allProducts = List.from(currentState.products) ..addAll(filteredNewProducts); - final hasReachedMax = newProducts.length < 10; + final hasReachedMax = + newProducts.length < 10 || nextPage >= totalPages; - emit(_Loaded( + log('โœ… More local products loaded: ${filteredNewProducts.length} new, total: ${allProducts.length}'); + + emit(ProductLoaderState.loaded( products: allProducts, hasReachedMax: hasReachedMax, currentPage: nextPage, isLoadingMore: false, + categoryId: currentState.categoryId, + searchQuery: currentState.searchQuery, )); - - _isLoadingMore = false; }, ); } catch (e) { - // Handle unexpected errors + log('โŒ Exception loading more local products: $e'); emit(currentState.copyWith(isLoadingMore: false)); + } finally { _isLoadingMore = false; } } - // Refresh data + // Pure local refresh Future _onRefresh( _Refresh event, Emitter emit, ) async { + final currentState = state; + String? categoryId; + String? searchQuery; + + if (currentState is _Loaded) { + categoryId = currentState.categoryId; + searchQuery = currentState.searchQuery; + } + _isLoadingMore = false; _loadMoreDebounce?.cancel(); - add(const _GetProduct()); + _searchDebounce?.cancel(); + + log('๐Ÿ”„ Refreshing local products'); + + // Clear local cache + _productRepository.clearCache(); + + add(ProductLoaderEvent.getProduct( + categoryId: categoryId, + search: searchQuery, + )); + } + + // Fast local search (no debouncing needed for local data) + Future _onSearchProduct( + _SearchProduct event, + Emitter emit, + ) async { + // Cancel previous search + _searchDebounce?.cancel(); + + // Minimal debounce for local search (much faster) + _searchDebounce = Timer(Duration(milliseconds: 150), () async { + emit(const ProductLoaderState.loading()); + _isLoadingMore = false; + + log('๐Ÿ” Local search: "${event.query}"'); + + final result = await _productRepository.getProducts( + page: 1, + limit: 20, // More results for search + categoryId: event.categoryId, + search: event.query, + ); + + await result.fold( + (failure) async { + log('โŒ Local search error: $failure'); + emit(ProductLoaderState.error(failure)); + }, + (response) async { + final products = response.data?.products ?? []; + final totalPages = response.data?.totalPages ?? 1; + final hasReachedMax = products.length < 20 || 1 >= totalPages; + + log('โœ… Local search results: ${products.length} products found'); + + emit(ProductLoaderState.loaded( + products: products, + hasReachedMax: hasReachedMax, + currentPage: 1, + isLoadingMore: false, + categoryId: event.categoryId, + searchQuery: event.query, + )); + }, + ); + }); + } + + // Get local database statistics + Future _onGetDatabaseStats( + _GetDatabaseStats event, + Emitter emit, + ) async { + try { + final stats = await _productRepository.getDatabaseStats(); + log('๐Ÿ“Š Local database stats retrieved: $stats'); + + // You can emit a special state here if needed for UI updates + // For now, just log the stats + } catch (e) { + log('โŒ Error getting local database stats: $e'); + } + } + + // Clear local cache + Future _onClearCache( + _ClearCache event, + Emitter emit, + ) async { + log('๐Ÿงน Manually clearing local cache'); + _productRepository.clearCache(); + + // Refresh current data after cache clear + add(const ProductLoaderEvent.refresh()); } } diff --git a/lib/presentation/home/bloc/product_loader/product_loader_bloc.freezed.dart b/lib/presentation/home/bloc/product_loader/product_loader_bloc.freezed.dart index 5e2d0e5..111fa8d 100644 --- a/lib/presentation/home/bloc/product_loader/product_loader_bloc.freezed.dart +++ b/lib/presentation/home/bloc/product_loader/product_loader_bloc.freezed.dart @@ -18,23 +18,36 @@ final _privateConstructorUsedError = UnsupportedError( mixin _$ProductLoaderEvent { @optionalTypeArgs TResult when({ - required TResult Function(String? categoryId, String? search) getProduct, + required TResult Function( + String? categoryId, String? search, bool? forceRefresh) + getProduct, required TResult Function(String? categoryId, String? search) loadMore, required TResult Function() refresh, + required TResult Function(String? query, String? categoryId) searchProduct, + required TResult Function() getDatabaseStats, + required TResult Function() clearCache, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult? whenOrNull({ - TResult? Function(String? categoryId, String? search)? getProduct, + TResult? Function(String? categoryId, String? search, bool? forceRefresh)? + getProduct, TResult? Function(String? categoryId, String? search)? loadMore, TResult? Function()? refresh, + TResult? Function(String? query, String? categoryId)? searchProduct, + TResult? Function()? getDatabaseStats, + TResult? Function()? clearCache, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult maybeWhen({ - TResult Function(String? categoryId, String? search)? getProduct, + TResult Function(String? categoryId, String? search, bool? forceRefresh)? + getProduct, TResult Function(String? categoryId, String? search)? loadMore, TResult Function()? refresh, + TResult Function(String? query, String? categoryId)? searchProduct, + TResult Function()? getDatabaseStats, + TResult Function()? clearCache, required TResult orElse(), }) => throw _privateConstructorUsedError; @@ -43,6 +56,9 @@ mixin _$ProductLoaderEvent { required TResult Function(_GetProduct value) getProduct, required TResult Function(_LoadMore value) loadMore, required TResult Function(_Refresh value) refresh, + required TResult Function(_SearchProduct value) searchProduct, + required TResult Function(_GetDatabaseStats value) getDatabaseStats, + required TResult Function(_ClearCache value) clearCache, }) => throw _privateConstructorUsedError; @optionalTypeArgs @@ -50,6 +66,9 @@ mixin _$ProductLoaderEvent { TResult? Function(_GetProduct value)? getProduct, TResult? Function(_LoadMore value)? loadMore, TResult? Function(_Refresh value)? refresh, + TResult? Function(_SearchProduct value)? searchProduct, + TResult? Function(_GetDatabaseStats value)? getDatabaseStats, + TResult? Function(_ClearCache value)? clearCache, }) => throw _privateConstructorUsedError; @optionalTypeArgs @@ -57,6 +76,9 @@ mixin _$ProductLoaderEvent { TResult Function(_GetProduct value)? getProduct, TResult Function(_LoadMore value)? loadMore, TResult Function(_Refresh value)? refresh, + TResult Function(_SearchProduct value)? searchProduct, + TResult Function(_GetDatabaseStats value)? getDatabaseStats, + TResult Function(_ClearCache value)? clearCache, required TResult orElse(), }) => throw _privateConstructorUsedError; @@ -89,7 +111,7 @@ abstract class _$$GetProductImplCopyWith<$Res> { _$GetProductImpl value, $Res Function(_$GetProductImpl) then) = __$$GetProductImplCopyWithImpl<$Res>; @useResult - $Res call({String? categoryId, String? search}); + $Res call({String? categoryId, String? search, bool? forceRefresh}); } /// @nodoc @@ -107,6 +129,7 @@ class __$$GetProductImplCopyWithImpl<$Res> $Res call({ Object? categoryId = freezed, Object? search = freezed, + Object? forceRefresh = freezed, }) { return _then(_$GetProductImpl( categoryId: freezed == categoryId @@ -117,6 +140,10 @@ class __$$GetProductImplCopyWithImpl<$Res> ? _value.search : search // ignore: cast_nullable_to_non_nullable as String?, + forceRefresh: freezed == forceRefresh + ? _value.forceRefresh + : forceRefresh // ignore: cast_nullable_to_non_nullable + as bool?, )); } } @@ -124,16 +151,19 @@ class __$$GetProductImplCopyWithImpl<$Res> /// @nodoc class _$GetProductImpl implements _GetProduct { - const _$GetProductImpl({this.categoryId, this.search}); + const _$GetProductImpl({this.categoryId, this.search, this.forceRefresh}); @override final String? categoryId; @override final String? search; +// Added search parameter + @override + final bool? forceRefresh; @override String toString() { - return 'ProductLoaderEvent.getProduct(categoryId: $categoryId, search: $search)'; + return 'ProductLoaderEvent.getProduct(categoryId: $categoryId, search: $search, forceRefresh: $forceRefresh)'; } @override @@ -143,11 +173,14 @@ class _$GetProductImpl implements _GetProduct { other is _$GetProductImpl && (identical(other.categoryId, categoryId) || other.categoryId == categoryId) && - (identical(other.search, search) || other.search == search)); + (identical(other.search, search) || other.search == search) && + (identical(other.forceRefresh, forceRefresh) || + other.forceRefresh == forceRefresh)); } @override - int get hashCode => Object.hash(runtimeType, categoryId, search); + int get hashCode => + Object.hash(runtimeType, categoryId, search, forceRefresh); /// Create a copy of ProductLoaderEvent /// with the given fields replaced by the non-null parameter values. @@ -160,33 +193,46 @@ class _$GetProductImpl implements _GetProduct { @override @optionalTypeArgs TResult when({ - required TResult Function(String? categoryId, String? search) getProduct, + required TResult Function( + String? categoryId, String? search, bool? forceRefresh) + getProduct, required TResult Function(String? categoryId, String? search) loadMore, required TResult Function() refresh, + required TResult Function(String? query, String? categoryId) searchProduct, + required TResult Function() getDatabaseStats, + required TResult Function() clearCache, }) { - return getProduct(categoryId, search); + return getProduct(categoryId, search, forceRefresh); } @override @optionalTypeArgs TResult? whenOrNull({ - TResult? Function(String? categoryId, String? search)? getProduct, + TResult? Function(String? categoryId, String? search, bool? forceRefresh)? + getProduct, TResult? Function(String? categoryId, String? search)? loadMore, TResult? Function()? refresh, + TResult? Function(String? query, String? categoryId)? searchProduct, + TResult? Function()? getDatabaseStats, + TResult? Function()? clearCache, }) { - return getProduct?.call(categoryId, search); + return getProduct?.call(categoryId, search, forceRefresh); } @override @optionalTypeArgs TResult maybeWhen({ - TResult Function(String? categoryId, String? search)? getProduct, + TResult Function(String? categoryId, String? search, bool? forceRefresh)? + getProduct, TResult Function(String? categoryId, String? search)? loadMore, TResult Function()? refresh, + TResult Function(String? query, String? categoryId)? searchProduct, + TResult Function()? getDatabaseStats, + TResult Function()? clearCache, required TResult orElse(), }) { if (getProduct != null) { - return getProduct(categoryId, search); + return getProduct(categoryId, search, forceRefresh); } return orElse(); } @@ -197,6 +243,9 @@ class _$GetProductImpl implements _GetProduct { required TResult Function(_GetProduct value) getProduct, required TResult Function(_LoadMore value) loadMore, required TResult Function(_Refresh value) refresh, + required TResult Function(_SearchProduct value) searchProduct, + required TResult Function(_GetDatabaseStats value) getDatabaseStats, + required TResult Function(_ClearCache value) clearCache, }) { return getProduct(this); } @@ -207,6 +256,9 @@ class _$GetProductImpl implements _GetProduct { TResult? Function(_GetProduct value)? getProduct, TResult? Function(_LoadMore value)? loadMore, TResult? Function(_Refresh value)? refresh, + TResult? Function(_SearchProduct value)? searchProduct, + TResult? Function(_GetDatabaseStats value)? getDatabaseStats, + TResult? Function(_ClearCache value)? clearCache, }) { return getProduct?.call(this); } @@ -217,6 +269,9 @@ class _$GetProductImpl implements _GetProduct { TResult Function(_GetProduct value)? getProduct, TResult Function(_LoadMore value)? loadMore, TResult Function(_Refresh value)? refresh, + TResult Function(_SearchProduct value)? searchProduct, + TResult Function(_GetDatabaseStats value)? getDatabaseStats, + TResult Function(_ClearCache value)? clearCache, required TResult orElse(), }) { if (getProduct != null) { @@ -227,11 +282,14 @@ class _$GetProductImpl implements _GetProduct { } abstract class _GetProduct implements ProductLoaderEvent { - const factory _GetProduct({final String? categoryId, final String? search}) = - _$GetProductImpl; + const factory _GetProduct( + {final String? categoryId, + final String? search, + final bool? forceRefresh}) = _$GetProductImpl; String? get categoryId; - String? get search; + String? get search; // Added search parameter + bool? get forceRefresh; /// Create a copy of ProductLoaderEvent /// with the given fields replaced by the non-null parameter values. @@ -317,9 +375,14 @@ class _$LoadMoreImpl implements _LoadMore { @override @optionalTypeArgs TResult when({ - required TResult Function(String? categoryId, String? search) getProduct, + required TResult Function( + String? categoryId, String? search, bool? forceRefresh) + getProduct, required TResult Function(String? categoryId, String? search) loadMore, required TResult Function() refresh, + required TResult Function(String? query, String? categoryId) searchProduct, + required TResult Function() getDatabaseStats, + required TResult Function() clearCache, }) { return loadMore(categoryId, search); } @@ -327,9 +390,13 @@ class _$LoadMoreImpl implements _LoadMore { @override @optionalTypeArgs TResult? whenOrNull({ - TResult? Function(String? categoryId, String? search)? getProduct, + TResult? Function(String? categoryId, String? search, bool? forceRefresh)? + getProduct, TResult? Function(String? categoryId, String? search)? loadMore, TResult? Function()? refresh, + TResult? Function(String? query, String? categoryId)? searchProduct, + TResult? Function()? getDatabaseStats, + TResult? Function()? clearCache, }) { return loadMore?.call(categoryId, search); } @@ -337,9 +404,13 @@ class _$LoadMoreImpl implements _LoadMore { @override @optionalTypeArgs TResult maybeWhen({ - TResult Function(String? categoryId, String? search)? getProduct, + TResult Function(String? categoryId, String? search, bool? forceRefresh)? + getProduct, TResult Function(String? categoryId, String? search)? loadMore, TResult Function()? refresh, + TResult Function(String? query, String? categoryId)? searchProduct, + TResult Function()? getDatabaseStats, + TResult Function()? clearCache, required TResult orElse(), }) { if (loadMore != null) { @@ -354,6 +425,9 @@ class _$LoadMoreImpl implements _LoadMore { required TResult Function(_GetProduct value) getProduct, required TResult Function(_LoadMore value) loadMore, required TResult Function(_Refresh value) refresh, + required TResult Function(_SearchProduct value) searchProduct, + required TResult Function(_GetDatabaseStats value) getDatabaseStats, + required TResult Function(_ClearCache value) clearCache, }) { return loadMore(this); } @@ -364,6 +438,9 @@ class _$LoadMoreImpl implements _LoadMore { TResult? Function(_GetProduct value)? getProduct, TResult? Function(_LoadMore value)? loadMore, TResult? Function(_Refresh value)? refresh, + TResult? Function(_SearchProduct value)? searchProduct, + TResult? Function(_GetDatabaseStats value)? getDatabaseStats, + TResult? Function(_ClearCache value)? clearCache, }) { return loadMore?.call(this); } @@ -374,6 +451,9 @@ class _$LoadMoreImpl implements _LoadMore { TResult Function(_GetProduct value)? getProduct, TResult Function(_LoadMore value)? loadMore, TResult Function(_Refresh value)? refresh, + TResult Function(_SearchProduct value)? searchProduct, + TResult Function(_GetDatabaseStats value)? getDatabaseStats, + TResult Function(_ClearCache value)? clearCache, required TResult orElse(), }) { if (loadMore != null) { @@ -438,9 +518,14 @@ class _$RefreshImpl implements _Refresh { @override @optionalTypeArgs TResult when({ - required TResult Function(String? categoryId, String? search) getProduct, + required TResult Function( + String? categoryId, String? search, bool? forceRefresh) + getProduct, required TResult Function(String? categoryId, String? search) loadMore, required TResult Function() refresh, + required TResult Function(String? query, String? categoryId) searchProduct, + required TResult Function() getDatabaseStats, + required TResult Function() clearCache, }) { return refresh(); } @@ -448,9 +533,13 @@ class _$RefreshImpl implements _Refresh { @override @optionalTypeArgs TResult? whenOrNull({ - TResult? Function(String? categoryId, String? search)? getProduct, + TResult? Function(String? categoryId, String? search, bool? forceRefresh)? + getProduct, TResult? Function(String? categoryId, String? search)? loadMore, TResult? Function()? refresh, + TResult? Function(String? query, String? categoryId)? searchProduct, + TResult? Function()? getDatabaseStats, + TResult? Function()? clearCache, }) { return refresh?.call(); } @@ -458,9 +547,13 @@ class _$RefreshImpl implements _Refresh { @override @optionalTypeArgs TResult maybeWhen({ - TResult Function(String? categoryId, String? search)? getProduct, + TResult Function(String? categoryId, String? search, bool? forceRefresh)? + getProduct, TResult Function(String? categoryId, String? search)? loadMore, TResult Function()? refresh, + TResult Function(String? query, String? categoryId)? searchProduct, + TResult Function()? getDatabaseStats, + TResult Function()? clearCache, required TResult orElse(), }) { if (refresh != null) { @@ -475,6 +568,9 @@ class _$RefreshImpl implements _Refresh { required TResult Function(_GetProduct value) getProduct, required TResult Function(_LoadMore value) loadMore, required TResult Function(_Refresh value) refresh, + required TResult Function(_SearchProduct value) searchProduct, + required TResult Function(_GetDatabaseStats value) getDatabaseStats, + required TResult Function(_ClearCache value) clearCache, }) { return refresh(this); } @@ -485,6 +581,9 @@ class _$RefreshImpl implements _Refresh { TResult? Function(_GetProduct value)? getProduct, TResult? Function(_LoadMore value)? loadMore, TResult? Function(_Refresh value)? refresh, + TResult? Function(_SearchProduct value)? searchProduct, + TResult? Function(_GetDatabaseStats value)? getDatabaseStats, + TResult? Function(_ClearCache value)? clearCache, }) { return refresh?.call(this); } @@ -495,6 +594,9 @@ class _$RefreshImpl implements _Refresh { TResult Function(_GetProduct value)? getProduct, TResult Function(_LoadMore value)? loadMore, TResult Function(_Refresh value)? refresh, + TResult Function(_SearchProduct value)? searchProduct, + TResult Function(_GetDatabaseStats value)? getDatabaseStats, + TResult Function(_ClearCache value)? clearCache, required TResult orElse(), }) { if (refresh != null) { @@ -508,14 +610,464 @@ abstract class _Refresh implements ProductLoaderEvent { const factory _Refresh() = _$RefreshImpl; } +/// @nodoc +abstract class _$$SearchProductImplCopyWith<$Res> { + factory _$$SearchProductImplCopyWith( + _$SearchProductImpl value, $Res Function(_$SearchProductImpl) then) = + __$$SearchProductImplCopyWithImpl<$Res>; + @useResult + $Res call({String? query, String? categoryId}); +} + +/// @nodoc +class __$$SearchProductImplCopyWithImpl<$Res> + extends _$ProductLoaderEventCopyWithImpl<$Res, _$SearchProductImpl> + implements _$$SearchProductImplCopyWith<$Res> { + __$$SearchProductImplCopyWithImpl( + _$SearchProductImpl _value, $Res Function(_$SearchProductImpl) _then) + : super(_value, _then); + + /// Create a copy of ProductLoaderEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? query = freezed, + Object? categoryId = freezed, + }) { + return _then(_$SearchProductImpl( + query: freezed == query + ? _value.query + : query // ignore: cast_nullable_to_non_nullable + as String?, + categoryId: freezed == categoryId + ? _value.categoryId + : categoryId // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc + +class _$SearchProductImpl implements _SearchProduct { + const _$SearchProductImpl({this.query, this.categoryId}); + + @override + final String? query; + @override + final String? categoryId; + + @override + String toString() { + return 'ProductLoaderEvent.searchProduct(query: $query, categoryId: $categoryId)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SearchProductImpl && + (identical(other.query, query) || other.query == query) && + (identical(other.categoryId, categoryId) || + other.categoryId == categoryId)); + } + + @override + int get hashCode => Object.hash(runtimeType, query, categoryId); + + /// Create a copy of ProductLoaderEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SearchProductImplCopyWith<_$SearchProductImpl> get copyWith => + __$$SearchProductImplCopyWithImpl<_$SearchProductImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function( + String? categoryId, String? search, bool? forceRefresh) + getProduct, + required TResult Function(String? categoryId, String? search) loadMore, + required TResult Function() refresh, + required TResult Function(String? query, String? categoryId) searchProduct, + required TResult Function() getDatabaseStats, + required TResult Function() clearCache, + }) { + return searchProduct(query, categoryId); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String? categoryId, String? search, bool? forceRefresh)? + getProduct, + TResult? Function(String? categoryId, String? search)? loadMore, + TResult? Function()? refresh, + TResult? Function(String? query, String? categoryId)? searchProduct, + TResult? Function()? getDatabaseStats, + TResult? Function()? clearCache, + }) { + return searchProduct?.call(query, categoryId); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String? categoryId, String? search, bool? forceRefresh)? + getProduct, + TResult Function(String? categoryId, String? search)? loadMore, + TResult Function()? refresh, + TResult Function(String? query, String? categoryId)? searchProduct, + TResult Function()? getDatabaseStats, + TResult Function()? clearCache, + required TResult orElse(), + }) { + if (searchProduct != null) { + return searchProduct(query, categoryId); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_GetProduct value) getProduct, + required TResult Function(_LoadMore value) loadMore, + required TResult Function(_Refresh value) refresh, + required TResult Function(_SearchProduct value) searchProduct, + required TResult Function(_GetDatabaseStats value) getDatabaseStats, + required TResult Function(_ClearCache value) clearCache, + }) { + return searchProduct(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_GetProduct value)? getProduct, + TResult? Function(_LoadMore value)? loadMore, + TResult? Function(_Refresh value)? refresh, + TResult? Function(_SearchProduct value)? searchProduct, + TResult? Function(_GetDatabaseStats value)? getDatabaseStats, + TResult? Function(_ClearCache value)? clearCache, + }) { + return searchProduct?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_GetProduct value)? getProduct, + TResult Function(_LoadMore value)? loadMore, + TResult Function(_Refresh value)? refresh, + TResult Function(_SearchProduct value)? searchProduct, + TResult Function(_GetDatabaseStats value)? getDatabaseStats, + TResult Function(_ClearCache value)? clearCache, + required TResult orElse(), + }) { + if (searchProduct != null) { + return searchProduct(this); + } + return orElse(); + } +} + +abstract class _SearchProduct implements ProductLoaderEvent { + const factory _SearchProduct( + {final String? query, final String? categoryId}) = _$SearchProductImpl; + + String? get query; + String? get categoryId; + + /// Create a copy of ProductLoaderEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SearchProductImplCopyWith<_$SearchProductImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$GetDatabaseStatsImplCopyWith<$Res> { + factory _$$GetDatabaseStatsImplCopyWith(_$GetDatabaseStatsImpl value, + $Res Function(_$GetDatabaseStatsImpl) then) = + __$$GetDatabaseStatsImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$GetDatabaseStatsImplCopyWithImpl<$Res> + extends _$ProductLoaderEventCopyWithImpl<$Res, _$GetDatabaseStatsImpl> + implements _$$GetDatabaseStatsImplCopyWith<$Res> { + __$$GetDatabaseStatsImplCopyWithImpl(_$GetDatabaseStatsImpl _value, + $Res Function(_$GetDatabaseStatsImpl) _then) + : super(_value, _then); + + /// Create a copy of ProductLoaderEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$GetDatabaseStatsImpl implements _GetDatabaseStats { + const _$GetDatabaseStatsImpl(); + + @override + String toString() { + return 'ProductLoaderEvent.getDatabaseStats()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _$GetDatabaseStatsImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function( + String? categoryId, String? search, bool? forceRefresh) + getProduct, + required TResult Function(String? categoryId, String? search) loadMore, + required TResult Function() refresh, + required TResult Function(String? query, String? categoryId) searchProduct, + required TResult Function() getDatabaseStats, + required TResult Function() clearCache, + }) { + return getDatabaseStats(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String? categoryId, String? search, bool? forceRefresh)? + getProduct, + TResult? Function(String? categoryId, String? search)? loadMore, + TResult? Function()? refresh, + TResult? Function(String? query, String? categoryId)? searchProduct, + TResult? Function()? getDatabaseStats, + TResult? Function()? clearCache, + }) { + return getDatabaseStats?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String? categoryId, String? search, bool? forceRefresh)? + getProduct, + TResult Function(String? categoryId, String? search)? loadMore, + TResult Function()? refresh, + TResult Function(String? query, String? categoryId)? searchProduct, + TResult Function()? getDatabaseStats, + TResult Function()? clearCache, + required TResult orElse(), + }) { + if (getDatabaseStats != null) { + return getDatabaseStats(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_GetProduct value) getProduct, + required TResult Function(_LoadMore value) loadMore, + required TResult Function(_Refresh value) refresh, + required TResult Function(_SearchProduct value) searchProduct, + required TResult Function(_GetDatabaseStats value) getDatabaseStats, + required TResult Function(_ClearCache value) clearCache, + }) { + return getDatabaseStats(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_GetProduct value)? getProduct, + TResult? Function(_LoadMore value)? loadMore, + TResult? Function(_Refresh value)? refresh, + TResult? Function(_SearchProduct value)? searchProduct, + TResult? Function(_GetDatabaseStats value)? getDatabaseStats, + TResult? Function(_ClearCache value)? clearCache, + }) { + return getDatabaseStats?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_GetProduct value)? getProduct, + TResult Function(_LoadMore value)? loadMore, + TResult Function(_Refresh value)? refresh, + TResult Function(_SearchProduct value)? searchProduct, + TResult Function(_GetDatabaseStats value)? getDatabaseStats, + TResult Function(_ClearCache value)? clearCache, + required TResult orElse(), + }) { + if (getDatabaseStats != null) { + return getDatabaseStats(this); + } + return orElse(); + } +} + +abstract class _GetDatabaseStats implements ProductLoaderEvent { + const factory _GetDatabaseStats() = _$GetDatabaseStatsImpl; +} + +/// @nodoc +abstract class _$$ClearCacheImplCopyWith<$Res> { + factory _$$ClearCacheImplCopyWith( + _$ClearCacheImpl value, $Res Function(_$ClearCacheImpl) then) = + __$$ClearCacheImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$ClearCacheImplCopyWithImpl<$Res> + extends _$ProductLoaderEventCopyWithImpl<$Res, _$ClearCacheImpl> + implements _$$ClearCacheImplCopyWith<$Res> { + __$$ClearCacheImplCopyWithImpl( + _$ClearCacheImpl _value, $Res Function(_$ClearCacheImpl) _then) + : super(_value, _then); + + /// Create a copy of ProductLoaderEvent + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$ClearCacheImpl implements _ClearCache { + const _$ClearCacheImpl(); + + @override + String toString() { + return 'ProductLoaderEvent.clearCache()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _$ClearCacheImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function( + String? categoryId, String? search, bool? forceRefresh) + getProduct, + required TResult Function(String? categoryId, String? search) loadMore, + required TResult Function() refresh, + required TResult Function(String? query, String? categoryId) searchProduct, + required TResult Function() getDatabaseStats, + required TResult Function() clearCache, + }) { + return clearCache(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String? categoryId, String? search, bool? forceRefresh)? + getProduct, + TResult? Function(String? categoryId, String? search)? loadMore, + TResult? Function()? refresh, + TResult? Function(String? query, String? categoryId)? searchProduct, + TResult? Function()? getDatabaseStats, + TResult? Function()? clearCache, + }) { + return clearCache?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String? categoryId, String? search, bool? forceRefresh)? + getProduct, + TResult Function(String? categoryId, String? search)? loadMore, + TResult Function()? refresh, + TResult Function(String? query, String? categoryId)? searchProduct, + TResult Function()? getDatabaseStats, + TResult Function()? clearCache, + required TResult orElse(), + }) { + if (clearCache != null) { + return clearCache(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_GetProduct value) getProduct, + required TResult Function(_LoadMore value) loadMore, + required TResult Function(_Refresh value) refresh, + required TResult Function(_SearchProduct value) searchProduct, + required TResult Function(_GetDatabaseStats value) getDatabaseStats, + required TResult Function(_ClearCache value) clearCache, + }) { + return clearCache(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_GetProduct value)? getProduct, + TResult? Function(_LoadMore value)? loadMore, + TResult? Function(_Refresh value)? refresh, + TResult? Function(_SearchProduct value)? searchProduct, + TResult? Function(_GetDatabaseStats value)? getDatabaseStats, + TResult? Function(_ClearCache value)? clearCache, + }) { + return clearCache?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_GetProduct value)? getProduct, + TResult Function(_LoadMore value)? loadMore, + TResult Function(_Refresh value)? refresh, + TResult Function(_SearchProduct value)? searchProduct, + TResult Function(_GetDatabaseStats value)? getDatabaseStats, + TResult Function(_ClearCache value)? clearCache, + required TResult orElse(), + }) { + if (clearCache != null) { + return clearCache(this); + } + return orElse(); + } +} + +abstract class _ClearCache implements ProductLoaderEvent { + const factory _ClearCache() = _$ClearCacheImpl; +} + /// @nodoc mixin _$ProductLoaderState { @optionalTypeArgs TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore) + required TResult Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery) loaded, required TResult Function(String message) error, }) => @@ -524,8 +1076,13 @@ mixin _$ProductLoaderState { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore)? + TResult? Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery)? loaded, TResult? Function(String message)? error, }) => @@ -534,8 +1091,13 @@ mixin _$ProductLoaderState { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore)? + TResult Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery)? loaded, TResult Function(String message)? error, required TResult orElse(), @@ -632,8 +1194,13 @@ class _$InitialImpl implements _Initial { TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore) + required TResult Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery) loaded, required TResult Function(String message) error, }) { @@ -645,8 +1212,13 @@ class _$InitialImpl implements _Initial { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore)? + TResult? Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery)? loaded, TResult? Function(String message)? error, }) { @@ -658,8 +1230,13 @@ class _$InitialImpl implements _Initial { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore)? + TResult Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery)? loaded, TResult Function(String message)? error, required TResult orElse(), @@ -755,8 +1332,13 @@ class _$LoadingImpl implements _Loading { TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore) + required TResult Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery) loaded, required TResult Function(String message) error, }) { @@ -768,8 +1350,13 @@ class _$LoadingImpl implements _Loading { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore)? + TResult? Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery)? loaded, TResult? Function(String message)? error, }) { @@ -781,8 +1368,13 @@ class _$LoadingImpl implements _Loading { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore)? + TResult Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery)? loaded, TResult Function(String message)? error, required TResult orElse(), @@ -845,7 +1437,9 @@ abstract class _$$LoadedImplCopyWith<$Res> { {List products, bool hasReachedMax, int currentPage, - bool isLoadingMore}); + bool isLoadingMore, + String? categoryId, + String? searchQuery}); } /// @nodoc @@ -865,6 +1459,8 @@ class __$$LoadedImplCopyWithImpl<$Res> Object? hasReachedMax = null, Object? currentPage = null, Object? isLoadingMore = null, + Object? categoryId = freezed, + Object? searchQuery = freezed, }) { return _then(_$LoadedImpl( products: null == products @@ -883,6 +1479,14 @@ class __$$LoadedImplCopyWithImpl<$Res> ? _value.isLoadingMore : isLoadingMore // ignore: cast_nullable_to_non_nullable as bool, + categoryId: freezed == categoryId + ? _value.categoryId + : categoryId // ignore: cast_nullable_to_non_nullable + as String?, + searchQuery: freezed == searchQuery + ? _value.searchQuery + : searchQuery // ignore: cast_nullable_to_non_nullable + as String?, )); } } @@ -894,7 +1498,9 @@ class _$LoadedImpl implements _Loaded { {required final List products, required this.hasReachedMax, required this.currentPage, - required this.isLoadingMore}) + required this.isLoadingMore, + this.categoryId, + this.searchQuery}) : _products = products; final List _products; @@ -911,10 +1517,14 @@ class _$LoadedImpl implements _Loaded { final int currentPage; @override final bool isLoadingMore; + @override + final String? categoryId; + @override + final String? searchQuery; @override String toString() { - return 'ProductLoaderState.loaded(products: $products, hasReachedMax: $hasReachedMax, currentPage: $currentPage, isLoadingMore: $isLoadingMore)'; + return 'ProductLoaderState.loaded(products: $products, hasReachedMax: $hasReachedMax, currentPage: $currentPage, isLoadingMore: $isLoadingMore, categoryId: $categoryId, searchQuery: $searchQuery)'; } @override @@ -928,7 +1538,11 @@ class _$LoadedImpl implements _Loaded { (identical(other.currentPage, currentPage) || other.currentPage == currentPage) && (identical(other.isLoadingMore, isLoadingMore) || - other.isLoadingMore == isLoadingMore)); + other.isLoadingMore == isLoadingMore) && + (identical(other.categoryId, categoryId) || + other.categoryId == categoryId) && + (identical(other.searchQuery, searchQuery) || + other.searchQuery == searchQuery)); } @override @@ -937,7 +1551,9 @@ class _$LoadedImpl implements _Loaded { const DeepCollectionEquality().hash(_products), hasReachedMax, currentPage, - isLoadingMore); + isLoadingMore, + categoryId, + searchQuery); /// Create a copy of ProductLoaderState /// with the given fields replaced by the non-null parameter values. @@ -952,12 +1568,18 @@ class _$LoadedImpl implements _Loaded { TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore) + required TResult Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery) loaded, required TResult Function(String message) error, }) { - return loaded(products, hasReachedMax, currentPage, isLoadingMore); + return loaded(products, hasReachedMax, currentPage, isLoadingMore, + categoryId, searchQuery); } @override @@ -965,12 +1587,18 @@ class _$LoadedImpl implements _Loaded { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore)? + TResult? Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery)? loaded, TResult? Function(String message)? error, }) { - return loaded?.call(products, hasReachedMax, currentPage, isLoadingMore); + return loaded?.call(products, hasReachedMax, currentPage, isLoadingMore, + categoryId, searchQuery); } @override @@ -978,14 +1606,20 @@ class _$LoadedImpl implements _Loaded { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore)? + TResult Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery)? loaded, TResult Function(String message)? error, required TResult orElse(), }) { if (loaded != null) { - return loaded(products, hasReachedMax, currentPage, isLoadingMore); + return loaded(products, hasReachedMax, currentPage, isLoadingMore, + categoryId, searchQuery); } return orElse(); } @@ -1033,12 +1667,16 @@ abstract class _Loaded implements ProductLoaderState { {required final List products, required final bool hasReachedMax, required final int currentPage, - required final bool isLoadingMore}) = _$LoadedImpl; + required final bool isLoadingMore, + final String? categoryId, + final String? searchQuery}) = _$LoadedImpl; List get products; bool get hasReachedMax; int get currentPage; bool get isLoadingMore; + String? get categoryId; + String? get searchQuery; /// Create a copy of ProductLoaderState /// with the given fields replaced by the non-null parameter values. @@ -1117,8 +1755,13 @@ class _$ErrorImpl implements _Error { TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore) + required TResult Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery) loaded, required TResult Function(String message) error, }) { @@ -1130,8 +1773,13 @@ class _$ErrorImpl implements _Error { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore)? + TResult? Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery)? loaded, TResult? Function(String message)? error, }) { @@ -1143,8 +1791,13 @@ class _$ErrorImpl implements _Error { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(List products, bool hasReachedMax, - int currentPage, bool isLoadingMore)? + TResult Function( + List products, + bool hasReachedMax, + int currentPage, + bool isLoadingMore, + String? categoryId, + String? searchQuery)? loaded, TResult Function(String message)? error, required TResult orElse(), diff --git a/lib/presentation/home/bloc/product_loader/product_loader_event.dart b/lib/presentation/home/bloc/product_loader/product_loader_event.dart index 3ff5bb6..9545eb1 100644 --- a/lib/presentation/home/bloc/product_loader/product_loader_event.dart +++ b/lib/presentation/home/bloc/product_loader/product_loader_event.dart @@ -2,9 +2,25 @@ part of 'product_loader_bloc.dart'; @freezed class ProductLoaderEvent with _$ProductLoaderEvent { - const factory ProductLoaderEvent.getProduct( - {String? categoryId, String? search}) = _GetProduct; - const factory ProductLoaderEvent.loadMore( - {String? categoryId, String? search}) = _LoadMore; + 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; } diff --git a/lib/presentation/home/bloc/product_loader/product_loader_state.dart b/lib/presentation/home/bloc/product_loader/product_loader_state.dart index 1464f78..709b1e2 100644 --- a/lib/presentation/home/bloc/product_loader/product_loader_state.dart +++ b/lib/presentation/home/bloc/product_loader/product_loader_state.dart @@ -9,6 +9,8 @@ class ProductLoaderState with _$ProductLoaderState { required bool hasReachedMax, required int currentPage, required bool isLoadingMore, + String? categoryId, + String? searchQuery, }) = _Loaded; const factory ProductLoaderState.error(String message) = _Error; } diff --git a/lib/presentation/home/pages/home_page.dart b/lib/presentation/home/pages/home_page.dart index 430b4c0..69ae96f 100644 --- a/lib/presentation/home/pages/home_page.dart +++ b/lib/presentation/home/pages/home_page.dart @@ -1,5 +1,11 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first +// ======================================== +// OFFLINE-ONLY HOMEPAGE - NO API CALLS +// lib/presentation/home/pages/home_page.dart +// ======================================== + +import 'dart:developer'; import 'package:enaklo_pos/core/components/flushbar.dart'; +import 'package:enaklo_pos/data/datasources/product/product_local_datasource.dart'; import 'package:enaklo_pos/presentation/home/bloc/category_loader/category_loader_bloc.dart'; import 'package:enaklo_pos/presentation/home/bloc/current_outlet/current_outlet_bloc.dart'; import 'package:enaklo_pos/presentation/home/bloc/product_loader/product_loader_bloc.dart'; @@ -44,48 +50,70 @@ class _HomePageState extends State { final ScrollController scrollController = ScrollController(); String searchQuery = ''; - test() async { - // await AuthLocalDataSource().removeAuthData(); - } + // Local database only + Map _databaseStats = {}; + final ProductLocalDatasource _localDatasource = + ProductLocalDatasource.instance; + bool _isLoadingStats = true; @override void initState() { - test(); - // First sync products from API, then load local products - _syncAndLoadProducts(); super.initState(); + _initializeLocalData(); + _loadProducts(); } @override void dispose() { - // Properly dispose controllers searchController.dispose(); scrollController.dispose(); super.dispose(); } - void _syncAndLoadProducts() { - // Trigger sync from API first - // context.read().add(const SyncProductEvent.syncProduct()); + // Initialize local data only + void _initializeLocalData() { + _loadDatabaseStats(); + } - // Also load local products initially in case sync fails or takes time - // context - // .read() - // .add(const LocalProductEvent.getLocalProduct()); + // Load database statistics + void _loadDatabaseStats() async { + try { + final stats = await _localDatasource.getDatabaseStats(); + if (mounted) { + setState(() { + _databaseStats = stats; + _isLoadingStats = false; + }); + } + log('๐Ÿ“Š Local database stats: $stats'); + } catch (e) { + log('โŒ Error loading local stats: $e'); + setState(() { + _isLoadingStats = false; + }); + } + } + + void _loadProducts() { + log('๐Ÿ“ฑ Loading products from local database only...'); + + // Load products from local database only context .read() .add(const ProductLoaderEvent.getProduct()); - // Initialize checkout with tax and service charge settings + // Initialize other components context.read().add(CheckoutEvent.started(widget.items)); - - // Get Category context.read().add(CategoryLoaderEvent.get()); - - // Get Outlets context.read().add(CurrentOutletEvent.currentOutlet()); } + void _refreshLocalData() { + log('๐Ÿ”„ Refreshing local data...'); + context.read().add(const ProductLoaderEvent.refresh()); + _loadDatabaseStats(); + } + void onCategoryTap(int index) { searchController.clear(); setState(() { @@ -97,10 +125,10 @@ class _HomePageState extends State { ScrollNotification notification, String? categoryId) { if (notification is ScrollEndNotification && scrollController.position.extentAfter == 0) { + log('๐Ÿ“„ Loading more local products for category: $categoryId'); context.read().add( ProductLoaderEvent.loadMore( categoryId: categoryId, - search: searchQuery, ), ); return true; @@ -130,386 +158,88 @@ class _HomePageState extends State { tag: 'confirmation_screen', child: Scaffold( backgroundColor: AppColors.white, - body: Row( + body: Column( children: [ + // Local database indicator + _buildLocalModeIndicator(), + + // Main content Expanded( - flex: 3, - child: Align( - alignment: AlignmentDirectional.topStart, - child: BlocBuilder( - builder: (context, state) { - return state.maybeWhen( - orElse: () => Center( - child: CircularProgressIndicator(), - ), - loaded: (categories, categoryId) => Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - HomeTitle( - controller: searchController, - onChanged: (value) { - setState(() { - searchQuery = value; - }); - Future.delayed(Duration(milliseconds: 600), () { - context.read().add( - ProductLoaderEvent.getProduct( - categoryId: categoryId, - search: value, - ), - ); - }); - }, - ), - BlocBuilder( - builder: (context, state) { - return Expanded( - child: CategoryTabBar( - categories: categories, - tabViews: categories.map((category) { - return SizedBox( - child: state.maybeWhen(orElse: () { - return const Center( - child: CircularProgressIndicator(), - ); - }, loading: () { - return const Center( - child: CircularProgressIndicator(), - ); - }, loaded: (products, hashasReachedMax, - currentPage, isLoadingMore) { - if (products.isEmpty) { - return Center( - child: Column( - children: [ - Text('No Items Found'), - SpaceHeight(20), - Button.filled( - width: 120, - onPressed: () { - context - .read< - ProductLoaderBloc>() - .add(const ProductLoaderEvent - .getProduct()); - }, - label: 'Retry', - ), - ], + child: Row( + children: [ + // Left panel - Products + Expanded( + flex: 3, + child: Align( + alignment: AlignmentDirectional.topStart, + child: BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + orElse: () => + Center(child: CircularProgressIndicator()), + loaded: (categories, categoryId) => Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + // Enhanced home title with local stats + _buildLocalHomeTitle(categoryId), + + // Products section + Expanded( + child: BlocBuilder( + builder: (context, productState) { + return CategoryTabBar( + categories: categories, + tabViews: categories.map((category) { + return SizedBox( + child: productState.maybeWhen( + orElse: () => + _buildLoadingState(), + loading: () => + _buildLoadingState(), + loaded: (products, + hasReachedMax, + currentPage, + isLoadingMore, + categoryId, + searchQuery) { + if (products.isEmpty) { + return _buildEmptyState( + categoryId); + } + return _buildProductGrid( + products, + hasReachedMax, + isLoadingMore, + categoryId, + currentPage, + ); + }, + error: (message) => + _buildErrorState( + message, categoryId), ), ); - } - return NotificationListener< - ScrollNotification>( - onNotification: (notification) { - return _handleScrollNotification( - notification, categoryId); - }, - child: GridView.builder( - itemCount: products.length, - controller: scrollController, - padding: const EdgeInsets.all(16), - cacheExtent: 80.0, - gridDelegate: - SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 180, - mainAxisSpacing: 30, - crossAxisSpacing: 30, - childAspectRatio: 180 / 240, - ), - itemBuilder: (context, index) => - ProductCard( - data: products[index], - onCartButton: () {}, - ), - ), - ); - }), - ); - }).toList(), - ), - ); - }, - ), - ], - ), - ); - }, - ), - ), - ), - Expanded( - flex: 2, - child: Align( - alignment: Alignment.topCenter, - child: Material( - color: Colors.white, - child: Column( - children: [ - HomeRightTitle( - table: widget.table, - ), - Padding( - padding: const EdgeInsets.all(16.0) - .copyWith(bottom: 0, top: 27), - child: Column( - children: [ - const Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Item', - style: TextStyle( - color: AppColors.primary, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - SizedBox( - width: 130, - ), - SizedBox( - width: 50.0, - child: Text( - 'Qty', - style: TextStyle( - color: AppColors.primary, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - SizedBox( - child: Text( - 'Price', - style: TextStyle( - color: AppColors.primary, - fontSize: 16, - fontWeight: FontWeight.w600, - ), + }).toList(), + ); + }, ), ), ], ), - const SpaceHeight(8), - const Divider(), - ], - ), + ); + }, ), - Expanded( - child: BlocBuilder( - builder: (context, state) { - return state.maybeWhen( - orElse: () => const Center( - child: Text('No Items'), - ), - loaded: ( - products, - discountModel, - discount, - discountAmount, - tax, - serviceCharge, - totalQuantity, - totalPrice, - draftName, - orderType, - deliveryType, - ) { - if (products.isEmpty) { - return const Center( - child: Text('No Items'), - ); - } - return ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric( - horizontal: 16), - itemBuilder: (context, index) => - OrderMenu(data: products[index]), - separatorBuilder: (context, index) => - const SpaceHeight(1.0), - itemCount: products.length, - ); - }, - ); - }, - ), - ), - Padding( - padding: const EdgeInsets.all(16.0).copyWith(top: 0), - child: Column( - children: [ - const Divider(), - const SpaceHeight(16.0), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Pajak', - style: TextStyle( - color: AppColors.black, - fontWeight: FontWeight.bold, - ), - ), - BlocBuilder( - builder: (context, state) { - final tax = state.maybeWhen( - orElse: () => 0, - loaded: ( - products, - discountModel, - discount, - discountAmount, - tax, - serviceCharge, - totalQuantity, - totalPrice, - draftName, - orderType, - deliveryType, - ) { - if (products.isEmpty) { - return 0; - } - return tax; - }); - return Text( - '$tax %', - style: const TextStyle( - color: AppColors.primary, - fontWeight: FontWeight.w600, - ), - ); - }, - ), - ], - ), - const SpaceHeight(16.0), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'Sub total', - style: TextStyle( - color: AppColors.black, - fontWeight: FontWeight.bold, - ), - ), - BlocBuilder( - builder: (context, state) { - final price = state.maybeWhen( - orElse: () => 0, - loaded: (products, - discountModel, - discount, - discountAmount, - tax, - serviceCharge, - totalQuantity, - totalPrice, - draftName, - orderType, - deliveryType) { - if (products.isEmpty) { - return 0; - } - return products - .map((e) => - (e.product.price! * - e.quantity) + - (e.variant?.priceModifier ?? - 0)) - .reduce((value, element) => - value + element); - }); - - return Text( - price.currencyFormatRp, - style: const TextStyle( - color: AppColors.primary, - fontWeight: FontWeight.w900, - ), - ); - }, - ), - ], - ), - SpaceHeight(16.0), - BlocBuilder( - builder: (context, state) { - return state.maybeWhen( - orElse: () => Align( - alignment: Alignment.bottomCenter, - child: Button.filled( - borderRadius: 12, - elevation: 1, - disabled: true, - onPressed: () { - context.push(ConfirmPaymentPage( - isTable: widget.table == null - ? false - : true, - table: widget.table, - )); - }, - label: 'Lanjutkan Pembayaran', - ), - ), - loaded: (items, - discountModel, - discount, - discountAmount, - tax, - serviceCharge, - totalQuantity, - totalPrice, - draftName, - orderType, - deliveryType) => - Align( - alignment: Alignment.bottomCenter, - child: Button.filled( - borderRadius: 12, - elevation: 1, - disabled: items.isEmpty, - onPressed: () { - if (orderType.name == 'dineIn' && - widget.table == null) { - AppFlushbar.showError(context, - 'Mohon pilih meja terlebih dahulu'); - return; - } - - if (orderType.name == 'delivery' && - deliveryType == null) { - AppFlushbar.showError(context, - 'Mohon pilih pengiriman terlebih dahulu'); - return; - } - - context.push(ConfirmPaymentPage( - isTable: widget.table == null - ? false - : true, - table: widget.table, - )); - }, - label: 'Lanjutkan Pembayaran', - ), - ), - ); - }, - ), - ], - ), - ), - ], + ), ), - ), + + // Right panel - Cart (unchanged) + Expanded( + flex: 2, + child: _buildCartSection(), + ), + ], ), ), ], @@ -518,26 +248,702 @@ class _HomePageState extends State { ), ); } -} -// ignore: unused_element -class _IsEmpty extends StatelessWidget { - const _IsEmpty(); + // Local mode indicator + Widget _buildLocalModeIndicator() { + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16), + color: Colors.blue.shade600, + child: Row( + children: [ + Icon(Icons.storage, color: Colors.white, size: 16), + SizedBox(width: 8), + Expanded( + child: Text( + _isLoadingStats + ? 'Mode Lokal - Memuat data...' + : 'Mode Lokal - ${_databaseStats['total_products'] ?? 0} produk tersimpan', + style: TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + if (_databaseStats.isNotEmpty) ...[ + Text( + '${(_databaseStats['database_size_mb'] ?? 0.0).toStringAsFixed(1)} MB', + style: TextStyle( + color: Colors.white.withOpacity(0.8), + fontSize: 11, + ), + ), + SizedBox(width: 8), + ], + InkWell( + onTap: _refreshLocalData, + child: Container( + padding: EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Icon(Icons.refresh, color: Colors.white, size: 14), + ), + ), + ], + ), + ); + } - @override - Widget build(BuildContext context) { + // Enhanced home title with local stats only + Widget _buildLocalHomeTitle(String? categoryId) { + return Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: Colors.grey.shade200)), + ), + child: Column( + children: [ + // Original HomeTitle with faster search + HomeTitle( + controller: searchController, + onChanged: (value) { + setState(() { + searchQuery = value; + }); + + // Fast local search - no debounce needed for local data + Future.delayed(Duration(milliseconds: 200), () { + if (value == searchController.text) { + log('๐Ÿ” Local search: "$value"'); + context.read().add( + ProductLoaderEvent.searchProduct( + categoryId: categoryId, + query: value, + ), + ); + } + }); + }, + ), + + // Local database stats + if (_databaseStats.isNotEmpty) ...[ + SizedBox(height: 8), + Row( + children: [ + // Local storage indicator + Container( + padding: EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.storage, + size: 12, color: Colors.blue.shade600), + SizedBox(width: 3), + Text( + 'Lokal', + style: TextStyle( + fontSize: 10, + color: Colors.blue.shade600, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + + SizedBox(width: 8), + + // Database stats chips + _buildStatChip( + '${_databaseStats['total_products'] ?? 0}', + 'produk', + Icons.inventory_2, + Colors.green, + ), + SizedBox(width: 6), + _buildStatChip( + '${_databaseStats['total_variants'] ?? 0}', + 'varian', + Icons.tune, + Colors.orange, + ), + SizedBox(width: 6), + _buildStatChip( + '${_databaseStats['cache_entries'] ?? 0}', + 'cache', + Icons.memory, + Colors.purple, + ), + + Spacer(), + + // Clear cache button + InkWell( + onTap: () { + _localDatasource.clearExpiredCache(); + _loadDatabaseStats(); + AppFlushbar.showSuccess(context, 'Cache dibersihkan'); + }, + child: Container( + padding: EdgeInsets.all(5), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(5), + ), + child: Icon( + Icons.clear_all, + size: 14, + color: Colors.grey.shade600, + ), + ), + ), + SizedBox(width: 4), + // Refresh button + InkWell( + onTap: _refreshLocalData, + child: Container( + padding: EdgeInsets.all(5), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(5), + ), + child: Icon( + Icons.refresh, + size: 14, + color: AppColors.primary, + ), + ), + ), + ], + ), + ], + ], + ), + ); + } + + Widget _buildStatChip( + String value, String label, IconData icon, Color color) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 10, color: color), + SizedBox(width: 2), + Text( + value, + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: color, + ), + ), + SizedBox(width: 1), + Text( + label, + style: TextStyle( + fontSize: 8, + color: color.withOpacity(0.8), + ), + ), + ], + ), + ); + } + + Widget _buildLoadingState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: AppColors.primary), + SizedBox(height: 16), + Text( + 'Memuat data lokal...', + style: TextStyle(color: Colors.grey.shade600), + ), + ], + ), + ); + } + + Widget _buildEmptyState(String? categoryId) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Assets.icons.noProduct.svg(), + SizedBox(height: 20), + Text( + searchQuery.isNotEmpty + ? 'Produk "$searchQuery" tidak ditemukan' + : 'Belum ada data produk lokal', + textAlign: TextAlign.center, + ), + SizedBox(height: 8), + Text( + 'Tambahkan produk ke database lokal terlebih dahulu', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + SpaceHeight(20), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (searchQuery.isNotEmpty) ...[ + Button.filled( + width: 100, + onPressed: () { + searchController.clear(); + setState(() => searchQuery = ''); + context.read().add( + ProductLoaderEvent.getProduct(categoryId: categoryId), + ); + }, + label: 'Reset', + ), + SizedBox(width: 12), + ], + Button.filled( + width: 120, + onPressed: () { + context.read().add( + ProductLoaderEvent.getProduct(categoryId: categoryId), + ); + }, + label: 'Muat Ulang', + ), + ], + ), + ], + ), + ); + } + + Widget _buildErrorState(String message, String? categoryId) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: Colors.red.shade400, + ), + SizedBox(height: 16), + Text( + 'Error Database Lokal', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(height: 8), + Padding( + padding: EdgeInsets.symmetric(horizontal: 32), + child: Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade600), + ), + ), + SizedBox(height: 16), + Button.filled( + width: 120, + onPressed: () { + context.read().add( + ProductLoaderEvent.getProduct(categoryId: categoryId), + ); + }, + label: 'Coba Lagi', + ), + ], + ), + ); + } + + Widget _buildProductGrid( + List products, + bool hasReachedMax, + bool isLoadingMore, + String? categoryId, + int currentPage, + ) { return Column( - mainAxisAlignment: MainAxisAlignment.center, children: [ - SpaceHeight(40), - Assets.icons.noProduct.svg(), - const SizedBox(height: 40.0), - const Text( - 'Belum Ada Produk', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16), + // Product count with local indicator + if (products.isNotEmpty) + Container( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.storage, + size: 10, color: Colors.blue.shade600), + SizedBox(width: 2), + Text( + '${products.length}', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.blue.shade600, + ), + ), + ], + ), + ), + SizedBox(width: 6), + Text( + 'produk dari database lokal', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 11, + ), + ), + if (currentPage > 1) ...[ + SizedBox(width: 6), + Container( + padding: EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Hal $currentPage', + style: TextStyle( + color: AppColors.primary, + fontSize: 9, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + Spacer(), + if (isLoadingMore) + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primary, + ), + ), + ], + ), + ), + + // Products grid - faster loading from local DB + Expanded( + child: NotificationListener( + onNotification: (notification) => + _handleScrollNotification(notification, categoryId), + child: GridView.builder( + itemCount: products.length, + controller: scrollController, + padding: const EdgeInsets.all(16), + cacheExtent: 200.0, // Bigger cache for smooth scrolling + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 180, + mainAxisSpacing: 30, + crossAxisSpacing: 30, + childAspectRatio: 180 / 240, + ), + itemBuilder: (context, index) => ProductCard( + data: products[index], + onCartButton: () { + // Cart functionality + }, + ), + ), + ), ), + + // End of data indicator + if (hasReachedMax && products.isNotEmpty) + Container( + padding: EdgeInsets.all(8), + child: Text( + 'Semua produk lokal telah dimuat', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 11, + ), + ), + ), ], ); } + + // Cart section (unchanged from original) + Widget _buildCartSection() { + return Align( + alignment: Alignment.topCenter, + child: Material( + color: Colors.white, + child: Column( + children: [ + HomeRightTitle(table: widget.table), + Padding( + padding: const EdgeInsets.all(16.0).copyWith(bottom: 0, top: 27), + child: Column( + children: [ + const Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Item', + style: TextStyle( + color: AppColors.primary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + SizedBox(width: 130), + SizedBox( + width: 50.0, + child: Text( + 'Qty', + style: TextStyle( + color: AppColors.primary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + SizedBox( + child: Text( + 'Price', + style: TextStyle( + color: AppColors.primary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SpaceHeight(8), + const Divider(), + ], + ), + ), + Expanded( + child: BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + orElse: () => const Center(child: Text('No Items')), + loaded: (products, + discountModel, + discount, + discountAmount, + tax, + serviceCharge, + totalQuantity, + totalPrice, + draftName, + orderType, + deliveryType) { + if (products.isEmpty) { + return const Center(child: Text('No Items')); + } + return ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemBuilder: (context, index) => + OrderMenu(data: products[index]), + separatorBuilder: (context, index) => + const SpaceHeight(1.0), + itemCount: products.length, + ); + }, + ); + }, + ), + ), + + // Payment section (unchanged) + Padding( + padding: const EdgeInsets.all(16.0).copyWith(top: 0), + child: Column( + children: [ + const Divider(), + const SpaceHeight(16.0), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Pajak', + style: TextStyle( + color: AppColors.black, + fontWeight: FontWeight.bold, + ), + ), + BlocBuilder( + builder: (context, state) { + final tax = state.maybeWhen( + orElse: () => 0, + loaded: (products, + discountModel, + discount, + discountAmount, + tax, + serviceCharge, + totalQuantity, + totalPrice, + draftName, + orderType, + deliveryType) { + if (products.isEmpty) return 0; + return tax; + }, + ); + return Text( + '$tax %', + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w600, + ), + ); + }, + ), + ], + ), + const SpaceHeight(16.0), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Sub total', + style: TextStyle( + color: AppColors.black, + fontWeight: FontWeight.bold, + ), + ), + BlocBuilder( + builder: (context, state) { + final price = state.maybeWhen( + orElse: () => 0, + loaded: (products, + discountModel, + discount, + discountAmount, + tax, + serviceCharge, + totalQuantity, + totalPrice, + draftName, + orderType, + deliveryType) { + if (products.isEmpty) return 0; + return products + .map((e) => + (e.product.price! * e.quantity) + + (e.variant?.priceModifier ?? 0)) + .reduce((value, element) => value + element); + }, + ); + return Text( + price.currencyFormatRp, + style: const TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.w900, + ), + ); + }, + ), + ], + ), + SpaceHeight(16.0), + BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + orElse: () => Align( + alignment: Alignment.bottomCenter, + child: Button.filled( + borderRadius: 12, + elevation: 1, + disabled: true, + onPressed: () { + context.push(ConfirmPaymentPage( + isTable: widget.table == null ? false : true, + table: widget.table, + )); + }, + label: 'Lanjutkan Pembayaran', + ), + ), + loaded: (items, + discountModel, + discount, + discountAmount, + tax, + serviceCharge, + totalQuantity, + totalPrice, + draftName, + orderType, + deliveryType) => + Align( + alignment: Alignment.bottomCenter, + child: Button.filled( + borderRadius: 12, + elevation: 1, + disabled: items.isEmpty, + onPressed: () { + if (orderType.name == 'dineIn' && + widget.table == null) { + AppFlushbar.showError(context, + 'Mohon pilih meja terlebih dahulu'); + return; + } + if (orderType.name == 'delivery' && + deliveryType == null) { + AppFlushbar.showError(context, + 'Mohon pilih pengiriman terlebih dahulu'); + return; + } + context.push(ConfirmPaymentPage( + isTable: widget.table == null ? false : true, + table: widget.table, + )); + }, + label: 'Lanjutkan Pembayaran', + ), + ), + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } } diff --git a/pubspec.lock b/pubspec.lock index 24c43f5..3d2aa3f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1030,7 +1030,7 @@ packages: source: hosted version: "2.1.1" path: - dependency: transitive + dependency: "direct main" description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" diff --git a/pubspec.yaml b/pubspec.yaml index 1fadce3..1a5c2af 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,7 @@ dependencies: syncfusion_flutter_datepicker: ^30.2.5 firebase_core: ^4.1.0 firebase_crashlytics: ^5.0.1 + path: ^1.9.1 # imin_printer: ^0.6.10 dev_dependencies: