This commit is contained in:
2025-10-16 17:22:27 +07:00
parent 3b1f198f2a
commit 7dc66d80fc
17 changed files with 222 additions and 217 deletions

View File

@@ -64,8 +64,8 @@ You have access to these expert subagents - USE THEM PROACTIVELY:
## Flutter Best Practices ## Flutter Best Practices
- Use Flutter 3.x features and Material 3 design - Use Flutter 3.x features and Material 3 design
- Implement clean architecture with Riverpod for state management - Implement clean architecture with Riverpod for state management
- Use Hive CE for local database and offline-first functionality - Use Hive CE for local database with **online-first** strategy (API first, cache fallback)
- Follow proper dependency injection with GetIt - Follow proper dependency injection with Riverpod providers
- Implement proper error handling and user feedback - Implement proper error handling and user feedback
- Follow platform-specific design guidelines - Follow platform-specific design guidelines
- Use proper localization for multi-language support - Use proper localization for multi-language support
@@ -453,7 +453,7 @@ A comprehensive Flutter-based Point of Sale (POS) application designed for retai
- Supplier state - Supplier state
**Data Requirements**: **Data Requirements**:
- Product list from Hive (offline-first) - Product list from API (online-first with Hive cache fallback)
- Product images (cached with variants) - Product images (cached with variants)
- Product search indexing - Product search indexing
- Category relationships - Category relationships
@@ -969,45 +969,63 @@ GridView.builder(
- Debounce search queries - Debounce search queries
- Optimize cart calculations - Optimize cart calculations
## Offline-First Strategy ## Online-First Strategy
### Data Flow ### Data Flow
1. **Read**: Always read from Hive first (instant UI) 1. **Check Connection**: Check if device is online
2. **Sync**: Background sync with API when online 2. **Try API First**: If online, fetch fresh data from API
3. **Update**: Update Hive and UI when sync completes 3. **Update Cache**: Save API response to Hive for offline access
4. **Conflict**: Handle conflicts with last-write-wins strategy 4. **Fallback to Cache**: If API fails or offline, load from Hive
5. **Show Data**: Display data to user (from API or cache)
### Sync Logic ### Implementation Pattern
```dart ```dart
@riverpod @riverpod
class DataSync extends _$DataSync { class Products extends _$Products {
@override @override
Future<SyncStatus> build() async { Future<List<Product>> build() async {
return await _performSync(); // Online-first: Try to load from API first
} final repository = ref.watch(productRepositoryProvider);
final networkInfo = ref.watch(networkInfoProvider);
Future<SyncStatus> _performSync() async { // Check if online
if (!await ref.read(networkInfoProvider).isConnected) { final isConnected = await networkInfo.isConnected;
return SyncStatus.offline;
}
if (isConnected) {
// Try API first
try { try {
// Sync categories first final syncResult = await repository.syncProducts();
await ref.read(categoriesProvider.notifier).syncCategories(); return syncResult.fold(
(failure) {
// Then sync products and variants // API failed, fallback to cache
await ref.read(productsProvider.notifier).syncProducts(); print('API failed, falling back to cache: ${failure.message}');
return _loadFromCache();
// Sync suppliers },
await ref.read(suppliersProvider.notifier).syncSuppliers(); (products) => products,
);
// Update last sync time
await ref.read(settingsProvider.notifier).updateLastSync();
return SyncStatus.success;
} catch (e) { } catch (e) {
return SyncStatus.failed; // API error, fallback to cache
print('API error, falling back to cache: $e');
return _loadFromCache();
} }
} else {
// Offline, load from cache
print('Offline, loading from cache');
return _loadFromCache();
}
}
Future<List<Product>> _loadFromCache() async {
final repository = ref.read(productRepositoryProvider);
final result = await repository.getAllProducts();
return result.fold(
(failure) {
print('Cache load failed: ${failure.message}');
return <Product>[];
},
(products) => products,
);
} }
} }
``` ```
@@ -1134,7 +1152,7 @@ class DataSync extends _$DataSync {
### Code Review Checklist ### Code Review Checklist
- [ ] Follows clean architecture principles - [ ] Follows clean architecture principles
- [ ] Proper error handling implemented - [ ] Proper error handling implemented
- [ ] Offline-first approach maintained - [ ] **Online-first approach maintained** (API first, cache fallback)
- [ ] Performance optimizations applied - [ ] Performance optimizations applied
- [ ] Proper state management with Riverpod - [ ] Proper state management with Riverpod
- [ ] Hive models and adapters properly defined - [ ] Hive models and adapters properly defined

View File

@@ -1,10 +0,0 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../network/dio_client.dart';
part 'dio_client_provider.g.dart';
/// Provider for DioClient singleton
@Riverpod(keepAlive: true)
DioClient dioClient(Ref ref) {
return DioClient();
}

View File

@@ -1,55 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'dio_client_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for DioClient singleton
@ProviderFor(dioClient)
const dioClientProvider = DioClientProvider._();
/// Provider for DioClient singleton
final class DioClientProvider
extends $FunctionalProvider<DioClient, DioClient, DioClient>
with $Provider<DioClient> {
/// Provider for DioClient singleton
const DioClientProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'dioClientProvider',
isAutoDispose: false,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$dioClientHash();
@$internal
@override
$ProviderElement<DioClient> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
DioClient create(Ref ref) {
return dioClient(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(DioClient value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<DioClient>(value),
);
}
}
String _$dioClientHash() => r'895f0dc2f8d5eab562ad65390e5c6d4a1f722b0d';

View File

@@ -2,4 +2,3 @@
export 'core_providers.dart'; export 'core_providers.dart';
export 'network_info_provider.dart'; export 'network_info_provider.dart';
export 'sync_status_provider.dart'; export 'sync_status_provider.dart';
export 'dio_client_provider.dart';

View File

@@ -27,7 +27,7 @@ class EmptyState extends StatelessWidget {
children: [ children: [
Icon( Icon(
icon ?? Icons.inbox_outlined, icon ?? Icons.inbox_outlined,
size: 50, size: 48,
color: Theme.of(context).colorScheme.outline, color: Theme.of(context).colorScheme.outline,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),

View File

@@ -6,6 +6,7 @@ abstract class CategoryLocalDataSource {
Future<List<CategoryModel>> getAllCategories(); Future<List<CategoryModel>> getAllCategories();
Future<CategoryModel?> getCategoryById(String id); Future<CategoryModel?> getCategoryById(String id);
Future<void> cacheCategories(List<CategoryModel> categories); Future<void> cacheCategories(List<CategoryModel> categories);
Future<void> updateCategory(CategoryModel category);
Future<void> clearCategories(); Future<void> clearCategories();
} }
@@ -30,6 +31,11 @@ class CategoryLocalDataSourceImpl implements CategoryLocalDataSource {
await box.putAll(categoryMap); await box.putAll(categoryMap);
} }
@override
Future<void> updateCategory(CategoryModel category) async {
await box.put(category.id, category);
}
@override @override
Future<void> clearCategories() async { Future<void> clearCategories() async {
await box.clear(); await box.clear();

View File

@@ -18,8 +18,27 @@ class CategoryRepositoryImpl implements CategoryRepository {
@override @override
Future<Either<Failure, List<Category>>> getAllCategories() async { Future<Either<Failure, List<Category>>> getAllCategories() async {
try { try {
final categories = await localDataSource.getAllCategories(); // Try remote first (online-first)
final categories = await remoteDataSource.getAllCategories();
// Cache the results
await localDataSource.cacheCategories(categories);
return Right(categories.map((model) => model.toEntity()).toList()); return Right(categories.map((model) => model.toEntity()).toList());
} on ServerException catch (e) {
// Remote failed, try local cache
try {
final cachedCategories = await localDataSource.getAllCategories();
return Right(cachedCategories.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(ServerFailure('${e.message}. Cache also unavailable.'));
}
} on NetworkException catch (e) {
// Network failed, try local cache
try {
final cachedCategories = await localDataSource.getAllCategories();
return Right(cachedCategories.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(NetworkFailure('${e.message}. Cache also unavailable.'));
}
} on CacheException catch (e) { } on CacheException catch (e) {
return Left(CacheFailure(e.message)); return Left(CacheFailure(e.message));
} }
@@ -28,11 +47,33 @@ class CategoryRepositoryImpl implements CategoryRepository {
@override @override
Future<Either<Failure, Category>> getCategoryById(String id) async { Future<Either<Failure, Category>> getCategoryById(String id) async {
try { try {
final category = await localDataSource.getCategoryById(id); // Try remote first (online-first)
if (category == null) { final category = await remoteDataSource.getCategoryById(id);
return Left(NotFoundFailure('Category not found')); // Cache the result
} await localDataSource.updateCategory(category);
return Right(category.toEntity()); return Right(category.toEntity());
} on ServerException catch (e) {
// Remote failed, try local cache
try {
final cachedCategory = await localDataSource.getCategoryById(id);
if (cachedCategory == null) {
return Left(NotFoundFailure('Category not found in cache'));
}
return Right(cachedCategory.toEntity());
} on CacheException catch (cacheError) {
return Left(ServerFailure('${e.message}. Cache also unavailable.'));
}
} on NetworkException catch (e) {
// Network failed, try local cache
try {
final cachedCategory = await localDataSource.getCategoryById(id);
if (cachedCategory == null) {
return Left(NotFoundFailure('Category not found in cache'));
}
return Right(cachedCategory.toEntity());
} on CacheException catch (cacheError) {
return Left(NetworkFailure('${e.message}. Cache also unavailable.'));
}
} on CacheException catch (e) { } on CacheException catch (e) {
return Left(CacheFailure(e.message)); return Left(CacheFailure(e.message));
} }

View File

@@ -5,12 +5,12 @@ import '../../../../core/providers/providers.dart';
part 'categories_provider.g.dart'; part 'categories_provider.g.dart';
/// Provider for categories list with API-first approach /// Provider for categories list with online-first approach
@riverpod @riverpod
class Categories extends _$Categories { class Categories extends _$Categories {
@override @override
Future<List<Category>> build() async { Future<List<Category>> build() async {
// API-first: Try to load from API first // Online-first: Try to load from API first
final repository = ref.watch(categoryRepositoryProvider); final repository = ref.watch(categoryRepositoryProvider);
final networkInfo = ref.watch(networkInfoProvider); final networkInfo = ref.watch(networkInfoProvider);
@@ -90,18 +90,3 @@ class Categories extends _$Categories {
}); });
} }
} }
/// Provider for selected category
@riverpod
class SelectedCategory extends _$SelectedCategory {
@override
String? build() => null;
void select(String? categoryId) {
state = categoryId;
}
void clear() {
state = null;
}
}

View File

@@ -58,62 +58,3 @@ abstract class _$Categories extends $AsyncNotifier<List<Category>> {
element.handleValue(ref, created); element.handleValue(ref, created);
} }
} }
/// Provider for selected category
@ProviderFor(SelectedCategory)
const selectedCategoryProvider = SelectedCategoryProvider._();
/// Provider for selected category
final class SelectedCategoryProvider
extends $NotifierProvider<SelectedCategory, String?> {
/// Provider for selected category
const SelectedCategoryProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'selectedCategoryProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$selectedCategoryHash();
@$internal
@override
SelectedCategory create() => SelectedCategory();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(String? value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<String?>(value),
);
}
}
String _$selectedCategoryHash() => r'a47cd2de07ad285d4b73b2294ba954cb1cdd8e4c';
/// Provider for selected category
abstract class _$SelectedCategory extends $Notifier<String?> {
String? build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String?, String?>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String?, String?>,
String?,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -39,8 +39,7 @@ class ProductSelector extends ConsumerWidget {
message: error.toString(), message: error.toString(),
onRetry: () => ref.refresh(productsProvider), onRetry: () => ref.refresh(productsProvider),
), ),
data: (paginationState) { data: (products) {
final products = paginationState.products;
if (products.isEmpty) { if (products.isEmpty) {
return const EmptyState( return const EmptyState(

View File

@@ -13,7 +13,7 @@ class ProductModel extends HiveObject {
final String name; final String name;
@HiveField(2) @HiveField(2)
final String description; final String? description;
@HiveField(3) @HiveField(3)
final double price; final double price;
@@ -39,7 +39,7 @@ class ProductModel extends HiveObject {
ProductModel({ ProductModel({
required this.id, required this.id,
required this.name, required this.name,
required this.description, this.description,
required this.price, required this.price,
this.imageUrl, this.imageUrl,
required this.categoryId, required this.categoryId,
@@ -86,7 +86,7 @@ class ProductModel extends HiveObject {
return ProductModel( return ProductModel(
id: json['id'] as String, id: json['id'] as String,
name: json['name'] as String, name: json['name'] as String,
description: json['description'] as String? ?? '', description: json['description'] as String?,
price: (json['price'] as num).toDouble(), price: (json['price'] as num).toDouble(),
imageUrl: json['imageUrl'] as String?, imageUrl: json['imageUrl'] as String?,
categoryId: json['categoryId'] as String, categoryId: json['categoryId'] as String,

View File

@@ -19,8 +19,27 @@ class ProductRepositoryImpl implements ProductRepository {
@override @override
Future<Either<Failure, List<Product>>> getAllProducts() async { Future<Either<Failure, List<Product>>> getAllProducts() async {
try { try {
final products = await localDataSource.getAllProducts(); // Try remote first (online-first)
final products = await remoteDataSource.getAllProducts();
// Cache the results
await localDataSource.cacheProducts(products);
return Right(products.map((model) => model.toEntity()).toList()); return Right(products.map((model) => model.toEntity()).toList());
} on ServerException catch (e) {
// Remote failed, try local cache
try {
final cachedProducts = await localDataSource.getAllProducts();
return Right(cachedProducts.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(ServerFailure('${e.message}. Cache also unavailable.'));
}
} on NetworkException catch (e) {
// Network failed, try local cache
try {
final cachedProducts = await localDataSource.getAllProducts();
return Right(cachedProducts.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(NetworkFailure('${e.message}. Cache also unavailable.'));
}
} on CacheException catch (e) { } on CacheException catch (e) {
return Left(CacheFailure(e.message)); return Left(CacheFailure(e.message));
} }
@@ -29,9 +48,29 @@ class ProductRepositoryImpl implements ProductRepository {
@override @override
Future<Either<Failure, List<Product>>> getProductsByCategory(String categoryId) async { Future<Either<Failure, List<Product>>> getProductsByCategory(String categoryId) async {
try { try {
final allProducts = await localDataSource.getAllProducts(); // Try remote first (online-first)
final filtered = allProducts.where((p) => p.categoryId == categoryId).toList(); final allProducts = await remoteDataSource.getAllProducts(categoryId: categoryId);
// Cache the results
await localDataSource.cacheProducts(allProducts);
return Right(allProducts.map((model) => model.toEntity()).toList());
} on ServerException catch (e) {
// Remote failed, try local cache
try {
final cachedProducts = await localDataSource.getAllProducts();
final filtered = cachedProducts.where((p) => p.categoryId == categoryId).toList();
return Right(filtered.map((model) => model.toEntity()).toList()); return Right(filtered.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(ServerFailure('${e.message}. Cache also unavailable.'));
}
} on NetworkException catch (e) {
// Network failed, try local cache
try {
final cachedProducts = await localDataSource.getAllProducts();
final filtered = cachedProducts.where((p) => p.categoryId == categoryId).toList();
return Right(filtered.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(NetworkFailure('${e.message}. Cache also unavailable.'));
}
} on CacheException catch (e) { } on CacheException catch (e) {
return Left(CacheFailure(e.message)); return Left(CacheFailure(e.message));
} }
@@ -40,13 +79,37 @@ class ProductRepositoryImpl implements ProductRepository {
@override @override
Future<Either<Failure, List<Product>>> searchProducts(String query) async { Future<Either<Failure, List<Product>>> searchProducts(String query) async {
try { try {
final allProducts = await localDataSource.getAllProducts(); // Try remote first (online-first)
final filtered = allProducts.where((p) { final searchResults = await remoteDataSource.getAllProducts(search: query);
// Cache the results
await localDataSource.cacheProducts(searchResults);
return Right(searchResults.map((model) => model.toEntity()).toList());
} on ServerException catch (e) {
// Remote failed, search in local cache
try {
final cachedProducts = await localDataSource.getAllProducts();
final filtered = cachedProducts.where((p) {
final nameMatch = p.name.toLowerCase().contains(query.toLowerCase()); final nameMatch = p.name.toLowerCase().contains(query.toLowerCase());
final descMatch = p.description?.toLowerCase().contains(query.toLowerCase()) ?? false; final descMatch = p.description?.toLowerCase().contains(query.toLowerCase()) ?? false;
return nameMatch || descMatch; return nameMatch || descMatch;
}).toList(); }).toList();
return Right(filtered.map((model) => model.toEntity()).toList()); return Right(filtered.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(ServerFailure('${e.message}. Cache also unavailable.'));
}
} on NetworkException catch (e) {
// Network failed, search in local cache
try {
final cachedProducts = await localDataSource.getAllProducts();
final filtered = cachedProducts.where((p) {
final nameMatch = p.name.toLowerCase().contains(query.toLowerCase());
final descMatch = p.description?.toLowerCase().contains(query.toLowerCase()) ?? false;
return nameMatch || descMatch;
}).toList();
return Right(filtered.map((model) => model.toEntity()).toList());
} on CacheException catch (cacheError) {
return Left(NetworkFailure('${e.message}. Cache also unavailable.'));
}
} on CacheException catch (e) { } on CacheException catch (e) {
return Left(CacheFailure(e.message)); return Left(CacheFailure(e.message));
} }
@@ -55,11 +118,33 @@ class ProductRepositoryImpl implements ProductRepository {
@override @override
Future<Either<Failure, Product>> getProductById(String id) async { Future<Either<Failure, Product>> getProductById(String id) async {
try { try {
final product = await localDataSource.getProductById(id); // Try remote first (online-first)
if (product == null) { final product = await remoteDataSource.getProductById(id);
return Left(NotFoundFailure('Product not found')); // Cache the result
} await localDataSource.updateProduct(product);
return Right(product.toEntity()); return Right(product.toEntity());
} on ServerException catch (e) {
// Remote failed, try local cache
try {
final cachedProduct = await localDataSource.getProductById(id);
if (cachedProduct == null) {
return Left(NotFoundFailure('Product not found in cache'));
}
return Right(cachedProduct.toEntity());
} on CacheException catch (cacheError) {
return Left(ServerFailure('${e.message}. Cache also unavailable.'));
}
} on NetworkException catch (e) {
// Network failed, try local cache
try {
final cachedProduct = await localDataSource.getProductById(id);
if (cachedProduct == null) {
return Left(NotFoundFailure('Product not found in cache'));
}
return Right(cachedProduct.toEntity());
} on CacheException catch (cacheError) {
return Left(NetworkFailure('${e.message}. Cache also unavailable.'));
}
} on CacheException catch (e) { } on CacheException catch (e) {
return Left(CacheFailure(e.message)); return Left(CacheFailure(e.message));
} }
@@ -68,11 +153,7 @@ class ProductRepositoryImpl implements ProductRepository {
@override @override
Future<Either<Failure, List<Product>>> syncProducts() async { Future<Either<Failure, List<Product>>> syncProducts() async {
try { try {
final response = await remoteDataSource.getAllProducts(); final products = await remoteDataSource.getAllProducts();
final productsData = response['data'] as List<dynamic>;
final products = productsData
.map((json) => ProductModel.fromJson(json as Map<String, dynamic>))
.toList();
await localDataSource.cacheProducts(products); await localDataSource.cacheProducts(products);
final entities = products.map((model) => model.toEntity()).toList(); final entities = products.map((model) => model.toEntity()).toList();
return Right(entities); return Right(entities);

View File

@@ -246,7 +246,7 @@ class ProductDetailPage extends ConsumerWidget {
/// Build description section /// Build description section
Widget _buildDescriptionSection(BuildContext context) { Widget _buildDescriptionSection(BuildContext context) {
if (product.description.isEmpty) { if (product.description == null || product.description!.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@@ -261,7 +261,7 @@ class ProductDetailPage extends ConsumerWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
product.description, product.description!,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
], ],

View File

@@ -15,7 +15,7 @@ class FilteredProducts extends _$FilteredProducts {
// Watch products state // Watch products state
final productsAsync = ref.watch(productsProvider); final productsAsync = ref.watch(productsProvider);
final products = productsAsync.when( final products = productsAsync.when(
data: (data) => data.products, data: (data) => data,
loading: () => <Product>[], loading: () => <Product>[],
error: (_, __) => <Product>[], error: (_, __) => <Product>[],
); );

View File

@@ -50,7 +50,7 @@ final class FilteredProductsProvider
} }
} }
String _$filteredProductsHash() => r'd8ca6d80a71bf354e3afe6c38335996a8bfc74b7'; String _$filteredProductsHash() => r'97fb09ade4bc65f92f3d4844b059bb2b0660d3df';
/// Filtered products provider /// Filtered products provider
/// Combines products, search query, and category filter to provide filtered results /// Combines products, search query, and category filter to provide filtered results

View File

@@ -5,12 +5,12 @@ import '../../../../core/providers/providers.dart';
part 'products_provider.g.dart'; part 'products_provider.g.dart';
/// Provider for products list with API-first approach /// Provider for products list with online-first approach
@riverpod @riverpod
class Products extends _$Products { class Products extends _$Products {
@override @override
Future<List<Product>> build() async { Future<List<Product>> build() async {
// API-first: Try to load from API first // Online-first: Try to load from API first
final repository = ref.watch(productRepositoryProvider); final repository = ref.watch(productRepositoryProvider);
final networkInfo = ref.watch(networkInfoProvider); final networkInfo = ref.watch(networkInfoProvider);

View File

@@ -85,9 +85,9 @@ class ProductListItem extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
if (product.description.isNotEmpty) if (product.description != null && product.description!.isNotEmpty)
Text( Text(
product.description, product.description!,
style: Theme.of(context).textTheme.bodySmall?.copyWith( style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),