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
- Use Flutter 3.x features and Material 3 design
- Implement clean architecture with Riverpod for state management
- Use Hive CE for local database and offline-first functionality
- Follow proper dependency injection with GetIt
- Use Hive CE for local database with **online-first** strategy (API first, cache fallback)
- Follow proper dependency injection with Riverpod providers
- Implement proper error handling and user feedback
- Follow platform-specific design guidelines
- 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
**Data Requirements**:
- Product list from Hive (offline-first)
- Product list from API (online-first with Hive cache fallback)
- Product images (cached with variants)
- Product search indexing
- Category relationships
@@ -969,45 +969,63 @@ GridView.builder(
- Debounce search queries
- Optimize cart calculations
## Offline-First Strategy
## Online-First Strategy
### Data Flow
1. **Read**: Always read from Hive first (instant UI)
2. **Sync**: Background sync with API when online
3. **Update**: Update Hive and UI when sync completes
4. **Conflict**: Handle conflicts with last-write-wins strategy
1. **Check Connection**: Check if device is online
2. **Try API First**: If online, fetch fresh data from API
3. **Update Cache**: Save API response to Hive for offline access
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
@riverpod
class DataSync extends _$DataSync {
class Products extends _$Products {
@override
Future<SyncStatus> build() async {
return await _performSync();
}
Future<List<Product>> build() async {
// Online-first: Try to load from API first
final repository = ref.watch(productRepositoryProvider);
final networkInfo = ref.watch(networkInfoProvider);
Future<SyncStatus> _performSync() async {
if (!await ref.read(networkInfoProvider).isConnected) {
return SyncStatus.offline;
}
// Check if online
final isConnected = await networkInfo.isConnected;
if (isConnected) {
// Try API first
try {
// Sync categories first
await ref.read(categoriesProvider.notifier).syncCategories();
// Then sync products and variants
await ref.read(productsProvider.notifier).syncProducts();
// Sync suppliers
await ref.read(suppliersProvider.notifier).syncSuppliers();
// Update last sync time
await ref.read(settingsProvider.notifier).updateLastSync();
return SyncStatus.success;
final syncResult = await repository.syncProducts();
return syncResult.fold(
(failure) {
// API failed, fallback to cache
print('API failed, falling back to cache: ${failure.message}');
return _loadFromCache();
},
(products) => products,
);
} 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
- [ ] Follows clean architecture principles
- [ ] Proper error handling implemented
- [ ] Offline-first approach maintained
- [ ] **Online-first approach maintained** (API first, cache fallback)
- [ ] Performance optimizations applied
- [ ] Proper state management with Riverpod
- [ ] 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 'network_info_provider.dart';
export 'sync_status_provider.dart';
export 'dio_client_provider.dart';

View File

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

View File

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

View File

@@ -18,8 +18,27 @@ class CategoryRepositoryImpl implements CategoryRepository {
@override
Future<Either<Failure, List<Category>>> getAllCategories() async {
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());
} 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) {
return Left(CacheFailure(e.message));
}
@@ -28,11 +47,33 @@ class CategoryRepositoryImpl implements CategoryRepository {
@override
Future<Either<Failure, Category>> getCategoryById(String id) async {
try {
final category = await localDataSource.getCategoryById(id);
if (category == null) {
return Left(NotFoundFailure('Category not found'));
}
// Try remote first (online-first)
final category = await remoteDataSource.getCategoryById(id);
// Cache the result
await localDataSource.updateCategory(category);
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) {
return Left(CacheFailure(e.message));
}

View File

@@ -5,12 +5,12 @@ import '../../../../core/providers/providers.dart';
part 'categories_provider.g.dart';
/// Provider for categories list with API-first approach
/// Provider for categories list with online-first approach
@riverpod
class Categories extends _$Categories {
@override
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 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);
}
}
/// 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(),
onRetry: () => ref.refresh(productsProvider),
),
data: (paginationState) {
final products = paginationState.products;
data: (products) {
if (products.isEmpty) {
return const EmptyState(

View File

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

View File

@@ -19,8 +19,27 @@ class ProductRepositoryImpl implements ProductRepository {
@override
Future<Either<Failure, List<Product>>> getAllProducts() async {
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());
} 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) {
return Left(CacheFailure(e.message));
}
@@ -29,9 +48,29 @@ class ProductRepositoryImpl implements ProductRepository {
@override
Future<Either<Failure, List<Product>>> getProductsByCategory(String categoryId) async {
try {
final allProducts = await localDataSource.getAllProducts();
final filtered = allProducts.where((p) => p.categoryId == categoryId).toList();
// Try remote first (online-first)
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());
} 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) {
return Left(CacheFailure(e.message));
}
@@ -40,13 +79,37 @@ class ProductRepositoryImpl implements ProductRepository {
@override
Future<Either<Failure, List<Product>>> searchProducts(String query) async {
try {
final allProducts = await localDataSource.getAllProducts();
final filtered = allProducts.where((p) {
// Try remote first (online-first)
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 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(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) {
return Left(CacheFailure(e.message));
}
@@ -55,11 +118,33 @@ class ProductRepositoryImpl implements ProductRepository {
@override
Future<Either<Failure, Product>> getProductById(String id) async {
try {
final product = await localDataSource.getProductById(id);
if (product == null) {
return Left(NotFoundFailure('Product not found'));
}
// Try remote first (online-first)
final product = await remoteDataSource.getProductById(id);
// Cache the result
await localDataSource.updateProduct(product);
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) {
return Left(CacheFailure(e.message));
}
@@ -68,11 +153,7 @@ class ProductRepositoryImpl implements ProductRepository {
@override
Future<Either<Failure, List<Product>>> syncProducts() async {
try {
final response = await remoteDataSource.getAllProducts();
final productsData = response['data'] as List<dynamic>;
final products = productsData
.map((json) => ProductModel.fromJson(json as Map<String, dynamic>))
.toList();
final products = await remoteDataSource.getAllProducts();
await localDataSource.cacheProducts(products);
final entities = products.map((model) => model.toEntity()).toList();
return Right(entities);

View File

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

View File

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

View File

@@ -50,7 +50,7 @@ final class FilteredProductsProvider
}
}
String _$filteredProductsHash() => r'd8ca6d80a71bf354e3afe6c38335996a8bfc74b7';
String _$filteredProductsHash() => r'97fb09ade4bc65f92f3d4844b059bb2b0660d3df';
/// Filtered products provider
/// 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';
/// Provider for products list with API-first approach
/// Provider for products list with online-first approach
@riverpod
class Products extends _$Products {
@override
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 networkInfo = ref.watch(networkInfoProvider);

View File

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