From 38c16bf0b9e4e7f7f21c1fe8c7acbf635400f0d3 Mon Sep 17 00:00:00 2001 From: renolation Date: Fri, 10 Oct 2025 22:49:05 +0700 Subject: [PATCH] runable --- .claude/agents/riverpod-expert-non-codegen.md | 817 ++++++++++++++++++ ios/Podfile.lock | 8 +- lib/core/constants/api_constants.dart | 4 + lib/core/core.dart | 3 +- lib/core/database/seed_data.dart | 5 + lib/core/di/di.dart | 7 - lib/core/di/injection_container.dart | 74 -- lib/core/di/service_locator.dart | 22 - lib/core/network/api_response.dart | 104 +++ lib/core/network/network.dart | 1 + lib/core/providers/core_providers.dart | 23 + lib/core/providers/core_providers.g.dart | 119 +++ lib/core/providers/providers.dart | 1 + lib/core/providers/sync_status_provider.dart | 8 +- .../providers/sync_status_provider.g.dart | 2 +- lib/features/auth/example_usage.dart | 26 +- .../presentation/providers/auth_provider.dart | 15 +- .../providers/auth_provider.g.dart | 94 +- .../category_remote_datasource.dart | 166 ++++ .../data/datasources/datasources.dart | 3 +- .../data/models/category_model.dart | 12 +- .../data/models/category_model.g.dart | 7 +- .../categories/domain/entities/category.dart | 3 + .../presentation/pages/categories_page.dart | 2 +- .../providers/categories_provider.dart | 189 +++- .../providers/categories_provider.g.dart | 223 ++++- .../category_datasource_provider.dart | 14 - .../category_product_count_provider.dart | 35 - .../category_product_count_provider.g.dart | 156 ---- .../category_remote_datasource_provider.dart | 13 + ...category_remote_datasource_provider.g.dart | 65 ++ .../presentation/providers/providers.dart | 11 +- .../widgets/product_selector.dart | 4 +- .../product_remote_datasource.dart | 233 ++++- .../products/data/models/product_model.dart | 19 +- .../products/data/models/product_model.g.dart | 2 +- .../repositories/product_repository_impl.dart | 19 +- .../products/domain/entities/product.dart | 4 +- .../presentation/pages/products_page.dart | 6 +- .../providers/filtered_products_provider.dart | 13 +- .../filtered_products_provider.g.dart | 6 +- .../product_datasource_provider.dart | 13 + .../product_datasource_provider.g.dart} | 42 +- .../providers/products_provider.dart | 399 ++++++++- .../providers/products_provider.g.dart | 333 +++++-- .../presentation/providers/providers.dart | 15 +- .../providers/search_query_provider.dart | 27 - .../providers/search_query_provider.g.dart | 71 -- pubspec.yaml | 4 +- 49 files changed, 2702 insertions(+), 740 deletions(-) create mode 100644 .claude/agents/riverpod-expert-non-codegen.md delete mode 100644 lib/core/di/di.dart delete mode 100644 lib/core/di/injection_container.dart delete mode 100644 lib/core/di/service_locator.dart create mode 100644 lib/core/network/api_response.dart create mode 100644 lib/core/providers/core_providers.dart create mode 100644 lib/core/providers/core_providers.g.dart create mode 100644 lib/features/categories/data/datasources/category_remote_datasource.dart delete mode 100644 lib/features/categories/presentation/providers/category_datasource_provider.dart delete mode 100644 lib/features/categories/presentation/providers/category_product_count_provider.dart delete mode 100644 lib/features/categories/presentation/providers/category_product_count_provider.g.dart create mode 100644 lib/features/categories/presentation/providers/category_remote_datasource_provider.dart create mode 100644 lib/features/categories/presentation/providers/category_remote_datasource_provider.g.dart create mode 100644 lib/features/products/presentation/providers/product_datasource_provider.dart rename lib/features/{categories/presentation/providers/category_datasource_provider.g.dart => products/presentation/providers/product_datasource_provider.g.dart} (50%) delete mode 100644 lib/features/products/presentation/providers/search_query_provider.dart delete mode 100644 lib/features/products/presentation/providers/search_query_provider.g.dart diff --git a/.claude/agents/riverpod-expert-non-codegen.md b/.claude/agents/riverpod-expert-non-codegen.md new file mode 100644 index 0000000..797904c --- /dev/null +++ b/.claude/agents/riverpod-expert-non-codegen.md @@ -0,0 +1,817 @@ +--- +name: riverpod-non-code-gen-expert +description: Riverpod state management specialist. MUST BE USED for all state management, providers, and reactive programming tasks. Focuses on manual provider creation without code generation. +tools: Read, Write, Edit, Grep, Bash +--- + +You are a Riverpod 3.0 expert specializing in: +- Manual provider creation and organization +- State management with Notifier, AsyncNotifier, and StreamNotifier +- Implementing proper state management patterns +- Handling async operations and loading states +- Testing providers and state logic +- Provider composition and dependencies + +## Key Philosophy: +**This guide focuses on manual provider creation WITHOUT code generation.** While code generation is available, this approach gives you full control and doesn't require build_runner setup. + +## Modern Provider Types (Manual Creation): + +### Basic Providers: + +#### Provider - Immutable Values & Dependencies +For values that never change or dependency injection: + +```dart +// Simple value +final appNameProvider = Provider((ref) => 'Retail POS'); + +// Configuration +final apiBaseUrlProvider = Provider((ref) { + return const String.fromEnvironment('API_URL', + defaultValue: 'http://localhost:3000'); +}); + +// Dependency injection +final dioProvider = Provider((ref) { + final dio = Dio(BaseOptions( + baseUrl: ref.watch(apiBaseUrlProvider), + )); + return dio; +}); + +final apiClientProvider = Provider((ref) { + return ApiClient(ref.watch(dioProvider)); +}); +``` + +#### FutureProvider - One-Time Async Operations +For async data that loads once: + +```dart +// Fetch user profile +final userProfileProvider = FutureProvider((ref) async { + final api = ref.watch(apiClientProvider); + return await api.getUser(); +}); + +// With parameters (Family) +final postProvider = FutureProvider.family((ref, postId) async { + final api = ref.watch(apiClientProvider); + return await api.getPost(postId); +}); + +// Auto dispose when not used +final productProvider = FutureProvider.autoDispose.family( + (ref, productId) async { + final api = ref.watch(apiClientProvider); + return await api.getProduct(productId); + }, +); +``` + +#### StreamProvider - Continuous Data Streams +For streaming data (WebSocket, Firestore, etc.): + +```dart +// WebSocket messages +final messagesStreamProvider = StreamProvider((ref) { + final webSocket = ref.watch(webSocketProvider); + return webSocket.messages; +}); + +// Firestore real-time updates +final notificationsProvider = StreamProvider.autoDispose>( + (ref) { + final firestore = ref.watch(firestoreProvider); + return firestore.collection('notifications').snapshots().map( + (snapshot) => snapshot.docs.map((doc) => Notification.fromDoc(doc)).toList(), + ); + }, +); +``` + +### Modern Mutable State Providers: + +#### NotifierProvider - Synchronous Mutable State +For complex state with methods (replaces StateNotifierProvider): + +```dart +// Counter with methods +class Counter extends Notifier { + @override + int build() => 0; + + void increment() => state++; + void decrement() => state--; + void reset() => state = 0; + void setValue(int value) => state = value; +} + +final counterProvider = NotifierProvider(Counter.new); + +// With auto dispose +final counterProvider = NotifierProvider.autoDispose(Counter.new); + +// Cart management +class Cart extends Notifier> { + @override + List build() => []; + + void addItem(Product product, int quantity) { + state = [ + ...state, + CartItem( + productId: product.id, + productName: product.name, + price: product.price, + quantity: quantity, + ), + ]; + } + + void removeItem(String productId) { + state = state.where((item) => item.productId != productId).toList(); + } + + void updateQuantity(String productId, int quantity) { + state = state.map((item) { + if (item.productId == productId) { + return item.copyWith(quantity: quantity); + } + return item; + }).toList(); + } + + void clear() => state = []; +} + +final cartProvider = NotifierProvider>(Cart.new); +``` + +#### AsyncNotifierProvider - Async Mutable State +For state that requires async initialization and mutations: + +```dart +// User profile with async loading +class UserProfile extends AsyncNotifier { + @override + Future build() async { + // Async initialization + final api = ref.watch(apiClientProvider); + return await api.getCurrentUser(); + } + + Future updateName(String name) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final api = ref.watch(apiClientProvider); + return await api.updateUserName(name); + }); + } + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final api = ref.watch(apiClientProvider); + return await api.getCurrentUser(); + }); + } +} + +final userProfileProvider = AsyncNotifierProvider( + UserProfile.new, +); + +// With auto dispose +final userProfileProvider = AsyncNotifierProvider.autoDispose( + UserProfile.new, +); + +// Products list with filtering +class ProductsList extends AsyncNotifier> { + @override + Future> build() async { + final api = ref.watch(apiClientProvider); + return await api.getProducts(); + } + + Future syncProducts() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final api = ref.watch(apiClientProvider); + return await api.getProducts(); + }); + } +} + +final productsProvider = AsyncNotifierProvider>( + ProductsList.new, +); +``` + +#### StreamNotifierProvider - Stream-based Mutable State +For streaming data with methods: + +```dart +class ChatMessages extends StreamNotifier> { + @override + Stream> build() { + final chatService = ref.watch(chatServiceProvider); + return chatService.messagesStream(); + } + + Future sendMessage(String text) async { + final chatService = ref.watch(chatServiceProvider); + await chatService.send(text); + } + + Future deleteMessage(String messageId) async { + final chatService = ref.watch(chatServiceProvider); + await chatService.delete(messageId); + } +} + +final chatMessagesProvider = StreamNotifierProvider>( + ChatMessages.new, +); +``` + +### Legacy Providers (Discouraged): + +❌ **Don't use these in new code:** +- `StateProvider` → Use `NotifierProvider` instead +- `StateNotifierProvider` → Use `NotifierProvider` instead +- `ChangeNotifierProvider` → Use `NotifierProvider` instead + +## Family Modifier - Parameters: + +```dart +// FutureProvider with family +final productProvider = FutureProvider.family( + (ref, productId) async { + final api = ref.watch(apiClientProvider); + return await api.getProduct(productId); + }, +); + +// NotifierProvider with family +class ProductDetails extends FamilyNotifier { + @override + Product build(String productId) { + // Load product by ID + final products = ref.watch(productsProvider).value ?? []; + return products.firstWhere((p) => p.id == productId); + } + + void updateStock(int quantity) { + state = state.copyWith(stockQuantity: quantity); + } +} + +final productDetailsProvider = NotifierProvider.family( + ProductDetails.new, +); + +// AsyncNotifierProvider with family +class PostDetail extends FamilyAsyncNotifier { + @override + Future build(String postId) async { + final api = ref.watch(apiClientProvider); + return await api.getPost(postId); + } + + Future like() async { + final api = ref.watch(apiClientProvider); + await api.likePost(arg); + ref.invalidateSelf(); + } +} + +final postDetailProvider = AsyncNotifierProvider.family( + PostDetail.new, +); +``` + +## Always Check First: +- `pubspec.yaml` - Ensure riverpod packages are installed +- Existing provider patterns and organization +- Current Riverpod version (target 3.0+) + +## Setup Requirements: + +### pubspec.yaml: +```yaml +dependencies: + flutter_riverpod: ^3.0.0 + # No code generation packages needed + +dev_dependencies: + riverpod_lint: ^3.0.0 + custom_lint: ^0.6.0 +``` + +### Enable riverpod_lint: +Create `analysis_options.yaml`: +```yaml +analyzer: + plugins: + - custom_lint +``` + +## Provider Organization: + +``` +lib/ + features/ + auth/ + providers/ + auth_provider.dart # Auth state + auth_repository_provider.dart # Repository DI + models/ + ... + products/ + providers/ + products_provider.dart + product_search_provider.dart + ... +``` + +## Key Patterns: + +### 1. Dependency Injection: +```dart +// Provide dependencies +final authRepositoryProvider = Provider((ref) { + return AuthRepositoryImpl( + api: ref.watch(apiClientProvider), + storage: ref.watch(secureStorageProvider), + ); +}); + +// Use in other providers +final authProvider = AsyncNotifierProvider(Auth.new); + +class Auth extends AsyncNotifier { + @override + Future build() async { + final repo = ref.read(authRepositoryProvider); + return await repo.getCurrentUser(); + } + + Future login(String email, String password) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final repo = ref.read(authRepositoryProvider); + return await repo.login(email, password); + }); + } + + Future logout() async { + final repo = ref.read(authRepositoryProvider); + await repo.logout(); + state = const AsyncValue.data(null); + } +} +``` + +### 2. Provider Composition: +```dart +// Depend on other providers +final filteredProductsProvider = Provider>((ref) { + final products = ref.watch(productsProvider).value ?? []; + final searchQuery = ref.watch(searchQueryProvider); + final selectedCategory = ref.watch(selectedCategoryProvider); + + return products.where((product) { + final matchesSearch = product.name + .toLowerCase() + .contains(searchQuery.toLowerCase()); + final matchesCategory = selectedCategory == null || + product.categoryId == selectedCategory; + return matchesSearch && matchesCategory; + }).toList(); +}); + +// Computed values +final cartTotalProvider = Provider((ref) { + final items = ref.watch(cartProvider); + return items.fold(0.0, (sum, item) => sum + (item.price * item.quantity)); +}); + +// Combine multiple providers +final dashboardProvider = FutureProvider((ref) async { + final user = await ref.watch(userProfileProvider.future); + final products = await ref.watch(productsProvider.future); + final stats = await ref.watch(statsProvider.future); + + return Dashboard(user: user, products: products, stats: stats); +}); +``` + +### 3. Loading States: +```dart +// In widgets - using .when() +ref.watch(userProfileProvider).when( + data: (user) => UserView(user), + loading: () => CircularProgressIndicator(), + error: (error, stack) => ErrorView(error), +); + +// Or pattern matching (Dart 3.0+) +final userState = ref.watch(userProfileProvider); +switch (userState) { + case AsyncData(:final value): + return UserView(value); + case AsyncError(:final error): + return ErrorView(error); + case AsyncLoading(): + return CircularProgressIndicator(); +} + +// Check states directly +if (userState.isLoading) return LoadingWidget(); +if (userState.hasError) return ErrorWidget(userState.error); +final user = userState.value!; +``` + +### 4. Selective Watching (Performance): +```dart +// Bad - rebuilds on any user change +final user = ref.watch(userProfileProvider); + +// Good - rebuilds only when name changes +final name = ref.watch( + userProfileProvider.select((user) => user.value?.name) +); + +// In providers +final userNameProvider = Provider((ref) { + return ref.watch( + userProfileProvider.select((async) => async.value?.name) + ); +}); +``` + +### 5. Invalidation and Refresh: +```dart +// Invalidate provider (triggers rebuild) +ref.invalidate(userProfileProvider); + +// Refresh (invalidate and re-read immediately) +ref.refresh(userProfileProvider); + +// Invalidate from within Notifier +class Products extends AsyncNotifier> { + @override + Future> build() async { + return await _fetch(); + } + + Future refresh() async { + ref.invalidateSelf(); + } + + Future> _fetch() async { + final api = ref.read(apiClientProvider); + return await api.getProducts(); + } +} +``` + +### 6. AutoDispose: +```dart +// Auto dispose when no longer used +final dataProvider = FutureProvider.autoDispose((ref) async { + return await fetchData(); +}); + +// Keep alive conditionally +final dataProvider = FutureProvider.autoDispose((ref) async { + final link = ref.keepAlive(); + + // Keep alive for 5 minutes after last listener + Timer(const Duration(minutes: 5), link.close); + + return await fetchData(); +}); + +// Check if still mounted after async operations +class TodoList extends AutoDisposeNotifier> { + @override + List build() => []; + + Future addTodo(Todo todo) async { + await api.saveTodo(todo); + + // Check if still mounted + if (!ref.mounted) return; + + state = [...state, todo]; + } +} + +final todoListProvider = NotifierProvider.autoDispose>( + TodoList.new, +); +``` + +## Consumer Widgets: + +### ConsumerWidget: +```dart +class MyWidget extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final count = ref.watch(counterProvider); + return Text('$count'); + } +} +``` + +### ConsumerStatefulWidget: +```dart +class MyWidget extends ConsumerStatefulWidget { + @override + ConsumerState createState() => _MyWidgetState(); +} + +class _MyWidgetState extends ConsumerState { + @override + void initState() { + super.initState(); + // ref is available in all lifecycle methods + ref.read(counterProvider.notifier).increment(); + } + + @override + Widget build(BuildContext context) { + final count = ref.watch(counterProvider); + return Text('$count'); + } +} +``` + +### Consumer (for optimization): +```dart +Column( + children: [ + const Text('Static content'), + Consumer( + builder: (context, ref, child) { + final count = ref.watch(counterProvider); + return Text('$count'); + }, + ), + const Text('More static content'), + ], +) +``` + +## Testing: + +```dart +test('counter increments', () { + final container = ProviderContainer(); + addTearDown(container.dispose); + + expect(container.read(counterProvider), 0); + container.read(counterProvider.notifier).increment(); + expect(container.read(counterProvider), 1); +}); + +// Async provider testing +test('fetches user', () async { + final container = ProviderContainer( + overrides: [ + authRepositoryProvider.overrideWithValue(MockAuthRepository()), + ], + ); + addTearDown(container.dispose); + + final user = await container.read(userProfileProvider.future); + expect(user.name, 'Test User'); +}); + +// Widget testing +testWidgets('displays user name', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + userProfileProvider.overrideWith((ref) => + const AsyncValue.data(User(name: 'Test')) + ), + ], + child: MaterialApp(home: UserScreen()), + ), + ); + + expect(find.text('Test'), findsOneWidget); +}); +``` + +## Common Patterns: + +### Pagination: +```dart +class PostList extends Notifier> { + @override + List build() { + _fetchPage(0); + return []; + } + + int _page = 0; + bool _isLoading = false; + + Future loadMore() async { + if (_isLoading) return; + + _isLoading = true; + _page++; + + try { + final newPosts = await _fetchPage(_page); + state = [...state, ...newPosts]; + } finally { + _isLoading = false; + } + } + + Future> _fetchPage(int page) async { + final api = ref.read(apiClientProvider); + return await api.getPosts(page: page); + } +} + +final postListProvider = NotifierProvider>( + PostList.new, +); +``` + +### Form State: +```dart +class LoginForm extends Notifier { + @override + LoginFormState build() => LoginFormState(); + + void setEmail(String email) { + state = state.copyWith(email: email); + } + + void setPassword(String password) { + state = state.copyWith(password: password); + } + + Future submit() async { + if (!state.isValid) return; + + state = state.copyWith(isLoading: true); + + try { + final repo = ref.read(authRepositoryProvider); + await repo.login(state.email, state.password); + state = state.copyWith(isLoading: false, isSuccess: true); + } catch (e) { + state = state.copyWith( + isLoading: false, + error: e.toString(), + ); + } + } +} + +final loginFormProvider = NotifierProvider( + LoginForm.new, +); +``` + +### Search with Debounce: +```dart +final searchQueryProvider = StateProvider((ref) => ''); + +final debouncedSearchProvider = Provider((ref) { + final query = ref.watch(searchQueryProvider); + + // Debounce logic + final debouncer = Debouncer(delay: const Duration(milliseconds: 300)); + debouncer.run(() { + // Perform search + }); + + return query; +}); + +final searchResultsProvider = FutureProvider.autoDispose>((ref) async { + final query = ref.watch(debouncedSearchProvider); + + if (query.isEmpty) return []; + + final api = ref.watch(apiClientProvider); + return await api.searchProducts(query); +}); +``` + +## Best Practices: + +### Naming Conventions: +```dart +// Providers end with 'Provider' +final userProvider = ...; +final productsProvider = ...; + +// Notifier classes are descriptive +class Counter extends Notifier { ... } +class UserProfile extends AsyncNotifier { ... } +``` + +### Provider Location: +- Place providers in `lib/features/{feature}/providers/` +- Keep provider logic separate from UI +- Group related providers together + +### Error Handling: +```dart +class DataLoader extends AsyncNotifier { + @override + Future build() async { + try { + return await fetchData(); + } catch (e, stack) { + // Log error + print('Failed to load data: $e'); + // Rethrow for Riverpod to handle + rethrow; + } + } + + Future retry() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() => fetchData()); + } +} +``` + +### Using ref.read vs ref.watch: +```dart +// Use ref.watch in build methods (reactive) +@override +Widget build(BuildContext context, WidgetRef ref) { + final count = ref.watch(counterProvider); // Rebuilds when changes + return Text('$count'); +} + +// Use ref.read in event handlers (one-time read) +onPressed: () { + ref.read(counterProvider.notifier).increment(); // Just reads once +} + +// Use ref.listen for side effects +@override +void initState() { + super.initState(); + ref.listen(authProvider, (previous, next) { + // React to auth state changes + if (next.value == null) { + Navigator.pushReplacementNamed(context, '/login'); + } + }); +} +``` + +## Important Notes: + +### Riverpod 3.0 Changes: +- **Unified Ref**: No more specialized ref types (just `Ref`) +- **Simplified Notifier**: No more separate Family/AutoDispose variants +- **Automatic Retry**: Failed providers automatically retry with backoff +- **ref.mounted**: Check if provider is still alive after async operations + +### Migration from StateNotifier: +```dart +// Old (StateNotifier) +class CounterNotifier extends StateNotifier { + CounterNotifier() : super(0); + void increment() => state++; +} + +final counterProvider = StateNotifierProvider( + (ref) => CounterNotifier(), +); + +// New (Notifier) +class Counter extends Notifier { + @override + int build() => 0; + void increment() => state++; +} + +final counterProvider = NotifierProvider(Counter.new); +``` + +### Performance Tips: +- Use `.select()` to minimize rebuilds +- Use `autoDispose` for temporary data +- Implement proper `==` and `hashCode` for state classes +- Keep state immutable +- Use `const` constructors where possible \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9676cd1..c2dd560 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -31,11 +31,11 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sqflite_darwin/darwin" SPEC CHECKSUMS: - connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 - path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 - sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index 4b67922..a52f1b5 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -75,6 +75,10 @@ class ApiConstants { /// Use: '${ApiConstants.categories}/:id' static String categoryById(String id) => '$categories/$id'; + /// GET - Fetch category with its products + /// Use: '${ApiConstants.categories}/:id/products' + static String categoryWithProducts(String id) => '$categories/$id/products'; + /// POST - Sync categories (bulk update/create) static const String syncCategories = '$categories/sync'; diff --git a/lib/core/core.dart b/lib/core/core.dart index 548c960..3ff65e7 100644 --- a/lib/core/core.dart +++ b/lib/core/core.dart @@ -14,7 +14,7 @@ /// - Storage: Secure storage, database /// - Theme: Material 3 theme, colors, typography /// - Utils: Formatters, validators, extensions, helpers -/// - DI: Dependency injection setup +/// - Providers: Riverpod providers for core dependencies /// - Widgets: Reusable UI components /// - Errors: Exception and failure handling library; @@ -23,7 +23,6 @@ library; export 'config/config.dart'; export 'constants/constants.dart'; export 'database/database.dart'; -export 'di/di.dart'; export 'errors/errors.dart'; export 'network/network.dart'; export 'performance.dart'; diff --git a/lib/core/database/seed_data.dart b/lib/core/database/seed_data.dart index cd61df2..83edb51 100644 --- a/lib/core/database/seed_data.dart +++ b/lib/core/database/seed_data.dart @@ -19,6 +19,7 @@ class SeedData { color: '#2196F3', // Blue productCount: 0, createdAt: now.subtract(const Duration(days: 60)), + updatedAt: now.subtract(const Duration(days: 60)), ), CategoryModel( id: 'cat_appliances', @@ -28,6 +29,7 @@ class SeedData { color: '#4CAF50', // Green productCount: 0, createdAt: now.subtract(const Duration(days: 55)), + updatedAt: now.subtract(const Duration(days: 55)), ), CategoryModel( id: 'cat_sports', @@ -37,6 +39,7 @@ class SeedData { color: '#FF9800', // Orange productCount: 0, createdAt: now.subtract(const Duration(days: 50)), + updatedAt: now.subtract(const Duration(days: 50)), ), CategoryModel( id: 'cat_fashion', @@ -46,6 +49,7 @@ class SeedData { color: '#E91E63', // Pink productCount: 0, createdAt: now.subtract(const Duration(days: 45)), + updatedAt: now.subtract(const Duration(days: 45)), ), CategoryModel( id: 'cat_books', @@ -55,6 +59,7 @@ class SeedData { color: '#9C27B0', // Purple productCount: 0, createdAt: now.subtract(const Duration(days: 40)), + updatedAt: now.subtract(const Duration(days: 40)), ), ]; } diff --git a/lib/core/di/di.dart b/lib/core/di/di.dart deleted file mode 100644 index 325efe5..0000000 --- a/lib/core/di/di.dart +++ /dev/null @@ -1,7 +0,0 @@ -/// Export all dependency injection components -/// -/// Contains service locator and injection container setup -library; - -export 'injection_container.dart'; -export 'service_locator.dart'; diff --git a/lib/core/di/injection_container.dart b/lib/core/di/injection_container.dart deleted file mode 100644 index 47f4426..0000000 --- a/lib/core/di/injection_container.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:get_it/get_it.dart'; -import '../../features/auth/data/datasources/auth_remote_datasource.dart'; -import '../../features/auth/data/repositories/auth_repository_impl.dart'; -import '../../features/auth/domain/repositories/auth_repository.dart'; -import '../network/dio_client.dart'; -import '../network/network_info.dart'; -import '../storage/secure_storage.dart'; - -/// Service locator instance -final sl = GetIt.instance; - -/// Initialize all dependencies -/// -/// This function registers all the dependencies required by the app -/// in the GetIt service locator. Call this in main() before runApp(). -Future initDependencies() async { - // ===== Core ===== - - // Connectivity (external) - Register first as it's a dependency - sl.registerLazySingleton( - () => Connectivity(), - ); - - // Network Info - sl.registerLazySingleton( - () => NetworkInfo(sl()), - ); - - // Dio Client - sl.registerLazySingleton( - () => DioClient(), - ); - - // Secure Storage - sl.registerLazySingleton( - () => SecureStorage(), - ); - - // ===== Authentication Feature ===== - - // Auth Remote Data Source - sl.registerLazySingleton( - () => AuthRemoteDataSourceImpl(dioClient: sl()), - ); - - // Auth Repository - sl.registerLazySingleton( - () => AuthRepositoryImpl( - remoteDataSource: sl(), - secureStorage: sl(), - dioClient: sl(), - ), - ); - - // ===== Data Sources ===== - // Note: Other data sources are managed by Riverpod providers - // No direct registration needed here - - // ===== Repositories ===== - // TODO: Register other repositories when they are implemented - - // ===== Use Cases ===== - // TODO: Register use cases when they are implemented - - // ===== Providers (Riverpod) ===== - // Note: Riverpod providers are registered differently - // This is just for dependency injection of external dependencies -} - -/// Clear all dependencies (useful for testing) -void resetDependencies() { - sl.reset(); -} diff --git a/lib/core/di/service_locator.dart b/lib/core/di/service_locator.dart deleted file mode 100644 index 5840503..0000000 --- a/lib/core/di/service_locator.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'package:get_it/get_it.dart'; -import 'package:connectivity_plus/connectivity_plus.dart'; -import '../network/dio_client.dart'; -import '../network/network_info.dart'; - -final getIt = GetIt.instance; - -/// Setup dependency injection -Future setupServiceLocator() async { - // External dependencies - getIt.registerLazySingleton(() => Connectivity()); - - // Core - getIt.registerLazySingleton(() => DioClient()); - getIt.registerLazySingleton(() => NetworkInfo(getIt())); - - // Data sources - to be added when features are implemented - - // Repositories - to be added when features are implemented - - // Use cases - to be added when features are implemented -} diff --git a/lib/core/network/api_response.dart b/lib/core/network/api_response.dart new file mode 100644 index 0000000..c5e25fc --- /dev/null +++ b/lib/core/network/api_response.dart @@ -0,0 +1,104 @@ +/// Generic API Response wrapper +/// +/// Wraps all API responses in a consistent format with success status, +/// data payload, optional message, and optional pagination metadata. +class ApiResponse { + final bool success; + final T data; + final String? message; + final PaginationMeta? meta; + + const ApiResponse({ + required this.success, + required this.data, + this.message, + this.meta, + }); + + /// Create from JSON with a data parser function + factory ApiResponse.fromJson( + Map json, + T Function(dynamic) dataParser, + ) { + return ApiResponse( + success: json['success'] as bool? ?? false, + data: dataParser(json['data']), + message: json['message'] as String?, + meta: json['meta'] != null + ? PaginationMeta.fromJson(json['meta'] as Map) + : null, + ); + } + + /// Convert to JSON + Map toJson(dynamic Function(T) dataSerializer) { + return { + 'success': success, + 'data': dataSerializer(data), + if (message != null) 'message': message, + if (meta != null) 'meta': meta!.toJson(), + }; + } +} + +/// Pagination metadata +class PaginationMeta { + final int page; + final int limit; + final int total; + final int totalPages; + final bool hasPreviousPage; + final bool hasNextPage; + + const PaginationMeta({ + required this.page, + required this.limit, + required this.total, + required this.totalPages, + required this.hasPreviousPage, + required this.hasNextPage, + }); + + /// Create from JSON + factory PaginationMeta.fromJson(Map json) { + return PaginationMeta( + page: json['page'] as int, + limit: json['limit'] as int, + total: json['total'] as int, + totalPages: json['totalPages'] as int, + hasPreviousPage: json['hasPreviousPage'] as bool, + hasNextPage: json['hasNextPage'] as bool, + ); + } + + /// Convert to JSON + Map toJson() { + return { + 'page': page, + 'limit': limit, + 'total': total, + 'totalPages': totalPages, + 'hasPreviousPage': hasPreviousPage, + 'hasNextPage': hasNextPage, + }; + } + + /// Create a copy with updated fields + PaginationMeta copyWith({ + int? page, + int? limit, + int? total, + int? totalPages, + bool? hasPreviousPage, + bool? hasNextPage, + }) { + return PaginationMeta( + page: page ?? this.page, + limit: limit ?? this.limit, + total: total ?? this.total, + totalPages: totalPages ?? this.totalPages, + hasPreviousPage: hasPreviousPage ?? this.hasPreviousPage, + hasNextPage: hasNextPage ?? this.hasNextPage, + ); + } +} diff --git a/lib/core/network/network.dart b/lib/core/network/network.dart index 6b54d21..8170ff4 100644 --- a/lib/core/network/network.dart +++ b/lib/core/network/network.dart @@ -4,5 +4,6 @@ library; export 'api_interceptor.dart'; +export 'api_response.dart'; export 'dio_client.dart'; export 'network_info.dart'; diff --git a/lib/core/providers/core_providers.dart b/lib/core/providers/core_providers.dart new file mode 100644 index 0000000..30cf47d --- /dev/null +++ b/lib/core/providers/core_providers.dart @@ -0,0 +1,23 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../network/dio_client.dart'; +import '../storage/secure_storage.dart'; + +part 'core_providers.g.dart'; + +/// Provider for DioClient (singleton) +/// +/// This is the global HTTP client used across the entire app. +/// It's configured with interceptors, timeout settings, and auth token injection. +@Riverpod(keepAlive: true) +DioClient dioClient(Ref ref) { + return DioClient(); +} + +/// Provider for SecureStorage (singleton) +/// +/// This is the global secure storage used for storing sensitive data like tokens. +/// Uses platform-specific secure storage (Keychain on iOS, EncryptedSharedPreferences on Android). +@Riverpod(keepAlive: true) +SecureStorage secureStorage(Ref ref) { + return SecureStorage(); +} diff --git a/lib/core/providers/core_providers.g.dart b/lib/core/providers/core_providers.g.dart new file mode 100644 index 0000000..773592e --- /dev/null +++ b/lib/core/providers/core_providers.g.dart @@ -0,0 +1,119 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'core_providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Provider for DioClient (singleton) +/// +/// This is the global HTTP client used across the entire app. +/// It's configured with interceptors, timeout settings, and auth token injection. + +@ProviderFor(dioClient) +const dioClientProvider = DioClientProvider._(); + +/// Provider for DioClient (singleton) +/// +/// This is the global HTTP client used across the entire app. +/// It's configured with interceptors, timeout settings, and auth token injection. + +final class DioClientProvider + extends $FunctionalProvider + with $Provider { + /// Provider for DioClient (singleton) + /// + /// This is the global HTTP client used across the entire app. + /// It's configured with interceptors, timeout settings, and auth token injection. + 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 $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(value), + ); + } +} + +String _$dioClientHash() => r'895f0dc2f8d5eab562ad65390e5c6d4a1f722b0d'; + +/// Provider for SecureStorage (singleton) +/// +/// This is the global secure storage used for storing sensitive data like tokens. +/// Uses platform-specific secure storage (Keychain on iOS, EncryptedSharedPreferences on Android). + +@ProviderFor(secureStorage) +const secureStorageProvider = SecureStorageProvider._(); + +/// Provider for SecureStorage (singleton) +/// +/// This is the global secure storage used for storing sensitive data like tokens. +/// Uses platform-specific secure storage (Keychain on iOS, EncryptedSharedPreferences on Android). + +final class SecureStorageProvider + extends $FunctionalProvider + with $Provider { + /// Provider for SecureStorage (singleton) + /// + /// This is the global secure storage used for storing sensitive data like tokens. + /// Uses platform-specific secure storage (Keychain on iOS, EncryptedSharedPreferences on Android). + const SecureStorageProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'secureStorageProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$secureStorageHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + SecureStorage create(Ref ref) { + return secureStorage(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(SecureStorage value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$secureStorageHash() => r'5c9908c0046ad0e39469ee7acbb5540397b36693'; diff --git a/lib/core/providers/providers.dart b/lib/core/providers/providers.dart index f36e80c..186da4f 100644 --- a/lib/core/providers/providers.dart +++ b/lib/core/providers/providers.dart @@ -1,3 +1,4 @@ /// Export all core providers +export 'core_providers.dart'; export 'network_info_provider.dart'; export 'sync_status_provider.dart'; diff --git a/lib/core/providers/sync_status_provider.dart b/lib/core/providers/sync_status_provider.dart index 4548b38..3a8da66 100644 --- a/lib/core/providers/sync_status_provider.dart +++ b/lib/core/providers/sync_status_provider.dart @@ -45,10 +45,10 @@ class SyncStatus extends _$SyncStatus { try { // Sync categories first (products depend on categories) - await ref.read(categoriesProvider.notifier).syncCategories(); + await ref.read(categoriesProvider.notifier).refresh(); // Then sync products - await ref.read(productsProvider.notifier).syncProducts(); + await ref.read(productsProvider.notifier).refresh(); // Update last sync time in settings await ref.read(settingsProvider.notifier).updateLastSyncTime(); @@ -100,7 +100,7 @@ class SyncStatus extends _$SyncStatus { ); try { - await ref.read(productsProvider.notifier).syncProducts(); + await ref.read(productsProvider.notifier).refresh(); await ref.read(settingsProvider.notifier).updateLastSyncTime(); state = AsyncValue.data( @@ -146,7 +146,7 @@ class SyncStatus extends _$SyncStatus { ); try { - await ref.read(categoriesProvider.notifier).syncCategories(); + await ref.read(categoriesProvider.notifier).refresh(); await ref.read(settingsProvider.notifier).updateLastSyncTime(); state = AsyncValue.data( diff --git a/lib/core/providers/sync_status_provider.g.dart b/lib/core/providers/sync_status_provider.g.dart index 11f2938..17ae7d9 100644 --- a/lib/core/providers/sync_status_provider.g.dart +++ b/lib/core/providers/sync_status_provider.g.dart @@ -36,7 +36,7 @@ final class SyncStatusProvider SyncStatus create() => SyncStatus(); } -String _$syncStatusHash() => r'dc92a1b83c89af94dfe94b646aa81d9501f371d7'; +String _$syncStatusHash() => r'bf09683a3a67b6c7104274c7a4b92ee410de8e45'; /// Sync status provider - manages data synchronization state diff --git a/lib/features/auth/example_usage.dart b/lib/features/auth/example_usage.dart index d04e7f8..3b7ed4c 100644 --- a/lib/features/auth/example_usage.dart +++ b/lib/features/auth/example_usage.dart @@ -448,20 +448,20 @@ class ErrorHandlingExample extends ConsumerWidget { void nonWidgetExample() { // If you need to access auth outside widgets (e.g., in services), - // use the service locator directly: + // you can pass WidgetRef as a parameter or use ProviderContainer: - // import 'package:retail/core/di/injection_container.dart'; - // import 'package:retail/features/auth/domain/repositories/auth_repository.dart'; + // Method 1: Pass WidgetRef as parameter + // Future myService(WidgetRef ref) async { + // final authRepository = ref.read(authRepositoryProvider); + // final isAuthenticated = await authRepository.isAuthenticated(); + // print('Is authenticated: $isAuthenticated'); + // } - // final authRepository = sl(); - // - // // Check if authenticated + // Method 2: Use ProviderContainer (for non-Flutter code) + // final container = ProviderContainer(); + // final authRepository = container.read(authRepositoryProvider); // final isAuthenticated = await authRepository.isAuthenticated(); - // - // // Get token - // final token = await authRepository.getAccessToken(); - // - // print('Token: $token'); + // container.dispose(); // Don't forget to dispose! } // ============================================================================ @@ -477,7 +477,9 @@ void tokenInjectionExample() { // You don't need to manually add the token - it's automatic! // Example of making an API call after login: - // final response = await sl().get('/api/products'); + // Using Riverpod: + // final dioClient = ref.read(dioClientProvider); + // final response = await dioClient.get('/api/products'); // // The above request will automatically include: // Headers: { diff --git a/lib/features/auth/presentation/providers/auth_provider.dart b/lib/features/auth/presentation/providers/auth_provider.dart index d055e05..695df01 100644 --- a/lib/features/auth/presentation/providers/auth_provider.dart +++ b/lib/features/auth/presentation/providers/auth_provider.dart @@ -1,6 +1,5 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../../../../core/network/dio_client.dart'; -import '../../../../core/storage/secure_storage.dart'; +import '../../../../core/providers/providers.dart'; import '../../data/datasources/auth_remote_datasource.dart'; import '../../data/repositories/auth_repository_impl.dart'; import '../../domain/entities/user.dart'; @@ -8,18 +7,6 @@ import '../../domain/repositories/auth_repository.dart'; part 'auth_provider.g.dart'; -/// Provider for DioClient (singleton) -@Riverpod(keepAlive: true) -DioClient dioClient(Ref ref) { - return DioClient(); -} - -/// Provider for SecureStorage (singleton) -@Riverpod(keepAlive: true) -SecureStorage secureStorage(Ref ref) { - return SecureStorage(); -} - /// Provider for AuthRemoteDataSource @Riverpod(keepAlive: true) AuthRemoteDataSource authRemoteDataSource(Ref ref) { diff --git a/lib/features/auth/presentation/providers/auth_provider.g.dart b/lib/features/auth/presentation/providers/auth_provider.g.dart index 9ac5ac6..c4dc542 100644 --- a/lib/features/auth/presentation/providers/auth_provider.g.dart +++ b/lib/features/auth/presentation/providers/auth_provider.g.dart @@ -8,98 +8,6 @@ part of 'auth_provider.dart'; // 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 - with $Provider { - /// 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 $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(value), - ); - } -} - -String _$dioClientHash() => r'895f0dc2f8d5eab562ad65390e5c6d4a1f722b0d'; - -/// Provider for SecureStorage (singleton) - -@ProviderFor(secureStorage) -const secureStorageProvider = SecureStorageProvider._(); - -/// Provider for SecureStorage (singleton) - -final class SecureStorageProvider - extends $FunctionalProvider - with $Provider { - /// Provider for SecureStorage (singleton) - const SecureStorageProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'secureStorageProvider', - isAutoDispose: false, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$secureStorageHash(); - - @$internal - @override - $ProviderElement $createElement($ProviderPointer pointer) => - $ProviderElement(pointer); - - @override - SecureStorage create(Ref ref) { - return secureStorage(ref); - } - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(SecureStorage value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider(value), - ); - } -} - -String _$secureStorageHash() => r'5c9908c0046ad0e39469ee7acbb5540397b36693'; - /// Provider for AuthRemoteDataSource @ProviderFor(authRemoteDataSource) @@ -234,7 +142,7 @@ final class AuthProvider extends $NotifierProvider { } } -String _$authHash() => r'4b053a7691f573316a8957577dd27a3ed73d89be'; +String _$authHash() => r'73c9e7b70799eba2904eb6fc65454332d4146a33'; /// Auth state notifier provider diff --git a/lib/features/categories/data/datasources/category_remote_datasource.dart b/lib/features/categories/data/datasources/category_remote_datasource.dart new file mode 100644 index 0000000..3293435 --- /dev/null +++ b/lib/features/categories/data/datasources/category_remote_datasource.dart @@ -0,0 +1,166 @@ +import 'package:dio/dio.dart'; +import '../models/category_model.dart'; +import '../../../../core/network/dio_client.dart'; +import '../../../../core/network/api_response.dart'; +import '../../../../core/constants/api_constants.dart'; +import '../../../../core/errors/exceptions.dart'; + +/// Category remote data source using API +abstract class CategoryRemoteDataSource { + /// Get all categories (public endpoint - no auth required) + Future> getAllCategories(); + + /// Get single category by ID (public endpoint - no auth required) + Future getCategoryById(String id); + + /// Get category with its products with pagination (public endpoint) + /// Returns Map with 'category' and 'products' with pagination info + Future> getCategoryWithProducts( + String id, + int page, + int limit, + ); +} + +class CategoryRemoteDataSourceImpl implements CategoryRemoteDataSource { + final DioClient client; + + CategoryRemoteDataSourceImpl(this.client); + + @override + Future> getAllCategories() async { + try { + final response = await client.get(ApiConstants.categories); + + // Parse API response using ApiResponse model + final apiResponse = ApiResponse>.fromJson( + response.data as Map, + (data) => (data as List) + .map((json) => CategoryModel.fromJson(json as Map)) + .toList(), + ); + + if (!apiResponse.success) { + throw ServerException( + apiResponse.message ?? 'Failed to fetch categories', + ); + } + + return apiResponse.data; + } on DioException catch (e) { + throw _handleDioError(e); + } catch (e) { + throw ServerException('Failed to fetch categories: $e'); + } + } + + @override + Future getCategoryById(String id) async { + try { + final response = await client.get(ApiConstants.categoryById(id)); + + // Parse API response using ApiResponse model + final apiResponse = ApiResponse.fromJson( + response.data as Map, + (data) => CategoryModel.fromJson(data as Map), + ); + + if (!apiResponse.success) { + throw ServerException( + apiResponse.message ?? 'Failed to fetch category', + ); + } + + return apiResponse.data; + } on DioException catch (e) { + throw _handleDioError(e); + } catch (e) { + throw ServerException('Failed to fetch category: $e'); + } + } + + @override + Future> getCategoryWithProducts( + String id, + int page, + int limit, + ) async { + try { + final response = await client.get( + '${ApiConstants.categories}/$id/products', + queryParameters: { + 'page': page, + 'limit': limit, + }, + ); + + // Parse API response - data contains category with nested products + final apiResponse = ApiResponse>.fromJson( + response.data as Map, + (data) => data as Map, + ); + + if (!apiResponse.success) { + throw ServerException( + apiResponse.message ?? 'Failed to fetch category with products', + ); + } + + final responseData = apiResponse.data; + + // Extract category info (excluding products array) + final categoryData = Map.from(responseData); + final products = categoryData.remove('products') as List? ?? []; + + // Create category model from remaining data + final category = CategoryModel.fromJson(categoryData); + + return { + 'category': category, + 'products': products, + 'meta': apiResponse.meta?.toJson() ?? {}, + }; + } on DioException catch (e) { + throw _handleDioError(e); + } catch (e) { + throw ServerException('Failed to fetch category with products: $e'); + } + } + + /// Handle Dio errors and convert to custom exceptions + Exception _handleDioError(DioException error) { + switch (error.response?.statusCode) { + case ApiConstants.statusBadRequest: + return ValidationException( + error.response?.data['message'] ?? 'Invalid request', + ); + case ApiConstants.statusUnauthorized: + return UnauthorizedException( + error.response?.data['message'] ?? 'Unauthorized access', + ); + case ApiConstants.statusForbidden: + return UnauthorizedException( + error.response?.data['message'] ?? 'Access forbidden', + ); + case ApiConstants.statusNotFound: + return NotFoundException( + error.response?.data['message'] ?? 'Category not found', + ); + case ApiConstants.statusInternalServerError: + case ApiConstants.statusBadGateway: + case ApiConstants.statusServiceUnavailable: + return ServerException( + error.response?.data['message'] ?? 'Server error', + ); + default: + if (error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.sendTimeout) { + return NetworkException('Connection timeout'); + } else if (error.type == DioExceptionType.connectionError) { + return NetworkException('No internet connection'); + } + return ServerException('Unexpected error occurred'); + } + } +} diff --git a/lib/features/categories/data/datasources/datasources.dart b/lib/features/categories/data/datasources/datasources.dart index 68335d6..4a5652a 100644 --- a/lib/features/categories/data/datasources/datasources.dart +++ b/lib/features/categories/data/datasources/datasources.dart @@ -1,6 +1,7 @@ /// Export all categories data sources /// -/// Contains local data sources for categories +/// Contains local and remote data sources for categories library; export 'category_local_datasource.dart'; +export 'category_remote_datasource.dart'; diff --git a/lib/features/categories/data/models/category_model.dart b/lib/features/categories/data/models/category_model.dart index d98ad64..ac9ba69 100644 --- a/lib/features/categories/data/models/category_model.dart +++ b/lib/features/categories/data/models/category_model.dart @@ -27,6 +27,9 @@ class CategoryModel extends HiveObject { @HiveField(6) final DateTime createdAt; + @HiveField(7) + final DateTime updatedAt; + CategoryModel({ required this.id, required this.name, @@ -35,6 +38,7 @@ class CategoryModel extends HiveObject { this.color, required this.productCount, required this.createdAt, + required this.updatedAt, }); /// Convert to domain entity @@ -47,6 +51,7 @@ class CategoryModel extends HiveObject { color: color, productCount: productCount, createdAt: createdAt, + updatedAt: updatedAt, ); } @@ -60,6 +65,7 @@ class CategoryModel extends HiveObject { color: category.color, productCount: category.productCount, createdAt: category.createdAt, + updatedAt: category.updatedAt, ); } @@ -71,8 +77,9 @@ class CategoryModel extends HiveObject { description: json['description'] as String?, iconPath: json['iconPath'] as String?, color: json['color'] as String?, - productCount: json['productCount'] as int, + productCount: json['productCount'] as int? ?? 0, createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), ); } @@ -86,6 +93,7 @@ class CategoryModel extends HiveObject { 'color': color, 'productCount': productCount, 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), }; } @@ -98,6 +106,7 @@ class CategoryModel extends HiveObject { String? color, int? productCount, DateTime? createdAt, + DateTime? updatedAt, }) { return CategoryModel( id: id ?? this.id, @@ -107,6 +116,7 @@ class CategoryModel extends HiveObject { color: color ?? this.color, productCount: productCount ?? this.productCount, createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, ); } } diff --git a/lib/features/categories/data/models/category_model.g.dart b/lib/features/categories/data/models/category_model.g.dart index b7473f3..0590a73 100644 --- a/lib/features/categories/data/models/category_model.g.dart +++ b/lib/features/categories/data/models/category_model.g.dart @@ -24,13 +24,14 @@ class CategoryModelAdapter extends TypeAdapter { color: fields[4] as String?, productCount: (fields[5] as num).toInt(), createdAt: fields[6] as DateTime, + updatedAt: fields[7] as DateTime, ); } @override void write(BinaryWriter writer, CategoryModel obj) { writer - ..writeByte(7) + ..writeByte(8) ..writeByte(0) ..write(obj.id) ..writeByte(1) @@ -44,7 +45,9 @@ class CategoryModelAdapter extends TypeAdapter { ..writeByte(5) ..write(obj.productCount) ..writeByte(6) - ..write(obj.createdAt); + ..write(obj.createdAt) + ..writeByte(7) + ..write(obj.updatedAt); } @override diff --git a/lib/features/categories/domain/entities/category.dart b/lib/features/categories/domain/entities/category.dart index 8126e88..9854a59 100644 --- a/lib/features/categories/domain/entities/category.dart +++ b/lib/features/categories/domain/entities/category.dart @@ -9,6 +9,7 @@ class Category extends Equatable { final String? color; final int productCount; final DateTime createdAt; + final DateTime updatedAt; const Category({ required this.id, @@ -18,6 +19,7 @@ class Category extends Equatable { this.color, required this.productCount, required this.createdAt, + required this.updatedAt, }); @override @@ -29,5 +31,6 @@ class Category extends Equatable { color, productCount, createdAt, + updatedAt, ]; } diff --git a/lib/features/categories/presentation/pages/categories_page.dart b/lib/features/categories/presentation/pages/categories_page.dart index e95e3f8..916122a 100644 --- a/lib/features/categories/presentation/pages/categories_page.dart +++ b/lib/features/categories/presentation/pages/categories_page.dart @@ -28,7 +28,7 @@ class CategoriesPage extends ConsumerWidget { ), body: RefreshIndicator( onRefresh: () async { - await ref.refresh(categoriesProvider.future); + ref.read(categoriesProvider.notifier).refresh(); }, child: categoriesAsync.when( loading: () => const Center( diff --git a/lib/features/categories/presentation/providers/categories_provider.dart b/lib/features/categories/presentation/providers/categories_provider.dart index b84406d..faab8ce 100644 --- a/lib/features/categories/presentation/providers/categories_provider.dart +++ b/lib/features/categories/presentation/providers/categories_provider.dart @@ -1,5 +1,9 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../domain/entities/category.dart'; +import '../../data/models/category_model.dart'; +import '../../../products/data/models/product_model.dart'; +import '../../../products/domain/entities/product.dart'; +import 'category_remote_datasource_provider.dart'; part 'categories_provider.g.dart'; @@ -8,33 +12,182 @@ part 'categories_provider.g.dart'; class Categories extends _$Categories { @override Future> build() async { - // TODO: Implement with repository - return []; + return await _fetchCategories(); + } + + Future> _fetchCategories() async { + final datasource = ref.read(categoryRemoteDataSourceProvider); + final categoryModels = await datasource.getAllCategories(); + return categoryModels.map((model) => model.toEntity()).toList(); } Future refresh() async { state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { - // Fetch categories from repository - return []; - }); - } - - Future syncCategories() async { - // TODO: Implement sync logic with remote data source - state = const AsyncValue.loading(); - state = await AsyncValue.guard(() async { - // Sync categories from API - return []; + return await _fetchCategories(); }); } } -/// Provider for selected category +/// Provider for single category by ID @riverpod -class SelectedCategory extends _$SelectedCategory { +Future category(Ref ref, String id) async { + final datasource = ref.read(categoryRemoteDataSourceProvider); + final categoryModel = await datasource.getCategoryById(id); + return categoryModel.toEntity(); +} + +/// Pagination state for category products +class CategoryProductsState { + final Category category; + final List products; + final int currentPage; + final int totalPages; + final int totalItems; + final bool hasMore; + final bool isLoadingMore; + + const CategoryProductsState({ + required this.category, + required this.products, + required this.currentPage, + required this.totalPages, + required this.totalItems, + required this.hasMore, + this.isLoadingMore = false, + }); + + CategoryProductsState copyWith({ + Category? category, + List? products, + int? currentPage, + int? totalPages, + int? totalItems, + bool? hasMore, + bool? isLoadingMore, + }) { + return CategoryProductsState( + category: category ?? this.category, + products: products ?? this.products, + currentPage: currentPage ?? this.currentPage, + totalPages: totalPages ?? this.totalPages, + totalItems: totalItems ?? this.totalItems, + hasMore: hasMore ?? this.hasMore, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + ); + } +} + +/// Provider for category with its products (with pagination) +@riverpod +class CategoryWithProducts extends _$CategoryWithProducts { + static const int _limit = 20; + @override - String? build() => null; + Future build(String categoryId) async { + return await _fetchCategoryWithProducts(categoryId: categoryId, page: 1); + } + + Future _fetchCategoryWithProducts({ + required String categoryId, + required int page, + }) async { + final datasource = ref.read(categoryRemoteDataSourceProvider); + + final response = await datasource.getCategoryWithProducts( + categoryId, + page, + _limit, + ); + + // Extract data + final CategoryModel categoryModel = response['category'] as CategoryModel; + final List productsJson = response['products'] as List; + final meta = response['meta'] as Map; + + // Convert category to entity + final category = categoryModel.toEntity(); + + // Convert products to entities + final products = productsJson + .map((json) => ProductModel.fromJson(json as Map)) + .map((model) => model.toEntity()) + .toList(); + + // Extract pagination info + final currentPage = meta['currentPage'] as int? ?? page; + final totalPages = meta['totalPages'] as int? ?? 1; + final totalItems = meta['totalItems'] as int? ?? products.length; + final hasMore = currentPage < totalPages; + + return CategoryProductsState( + category: category, + products: products, + currentPage: currentPage, + totalPages: totalPages, + totalItems: totalItems, + hasMore: hasMore, + ); + } + + /// Load more products (next page) + Future loadMore() async { + final currentState = state.value; + if (currentState == null || !currentState.hasMore) return; + + // Set loading more flag + state = AsyncValue.data( + currentState.copyWith(isLoadingMore: true), + ); + + // Fetch next page + final nextPage = currentState.currentPage + 1; + + try { + final newState = await _fetchCategoryWithProducts( + categoryId: currentState.category.id, + page: nextPage, + ); + + // Append new products to existing ones + state = AsyncValue.data( + newState.copyWith( + products: [...currentState.products, ...newState.products], + isLoadingMore: false, + ), + ); + } catch (error, stackTrace) { + // Restore previous state on error + state = AsyncValue.data( + currentState.copyWith(isLoadingMore: false), + ); + state = AsyncValue.error(error, stackTrace); + } + } + + /// Refresh category and products + Future refresh() async { + final currentState = state.value; + if (currentState == null) return; + + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + return await _fetchCategoryWithProducts( + categoryId: currentState.category.id, + page: 1, + ); + }); + } +} + +/// Provider for selected category state +/// This is used in the products feature for filtering +@riverpod +class SelectedCategoryInCategories extends _$SelectedCategoryInCategories { + @override + String? build() { + return null; + } void select(String? categoryId) { state = categoryId; @@ -43,4 +196,8 @@ class SelectedCategory extends _$SelectedCategory { void clear() { state = null; } + + bool get hasSelection => state != null; + + bool isSelected(String categoryId) => state == categoryId; } diff --git a/lib/features/categories/presentation/providers/categories_provider.g.dart b/lib/features/categories/presentation/providers/categories_provider.g.dart index f7a8707..58073c7 100644 --- a/lib/features/categories/presentation/providers/categories_provider.g.dart +++ b/lib/features/categories/presentation/providers/categories_provider.g.dart @@ -36,7 +36,7 @@ final class CategoriesProvider Categories create() => Categories(); } -String _$categoriesHash() => r'aa7afc38a5567b0f42ff05ca23b287baa4780cbe'; +String _$categoriesHash() => r'5156d31a6d7b9457c4735b66e170b262140758e2'; /// Provider for categories list @@ -59,32 +59,223 @@ abstract class _$Categories extends $AsyncNotifier> { } } -/// Provider for selected category +/// Provider for single category by ID -@ProviderFor(SelectedCategory) -const selectedCategoryProvider = SelectedCategoryProvider._(); +@ProviderFor(category) +const categoryProvider = CategoryFamily._(); -/// Provider for selected category -final class SelectedCategoryProvider - extends $NotifierProvider { - /// Provider for selected category - const SelectedCategoryProvider._() +/// Provider for single category by ID + +final class CategoryProvider + extends + $FunctionalProvider, Category, FutureOr> + with $FutureModifier, $FutureProvider { + /// Provider for single category by ID + const CategoryProvider._({ + required CategoryFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'categoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$categoryHash(); + + @override + String toString() { + return r'categoryProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return category(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is CategoryProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$categoryHash() => r'e26dd362e42a1217a774072f453a64c7a6195e73'; + +/// Provider for single category by ID + +final class CategoryFamily extends $Family + with $FunctionalFamilyOverride, String> { + const CategoryFamily._() + : super( + retry: null, + name: r'categoryProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Provider for single category by ID + + CategoryProvider call(String id) => + CategoryProvider._(argument: id, from: this); + + @override + String toString() => r'categoryProvider'; +} + +/// Provider for category with its products (with pagination) + +@ProviderFor(CategoryWithProducts) +const categoryWithProductsProvider = CategoryWithProductsFamily._(); + +/// Provider for category with its products (with pagination) +final class CategoryWithProductsProvider + extends + $AsyncNotifierProvider { + /// Provider for category with its products (with pagination) + const CategoryWithProductsProvider._({ + required CategoryWithProductsFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'categoryWithProductsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$categoryWithProductsHash(); + + @override + String toString() { + return r'categoryWithProductsProvider' + '' + '($argument)'; + } + + @$internal + @override + CategoryWithProducts create() => CategoryWithProducts(); + + @override + bool operator ==(Object other) { + return other is CategoryWithProductsProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$categoryWithProductsHash() => + r'a5ea35fad4e711ea855e4874f9135145d7d44b67'; + +/// Provider for category with its products (with pagination) + +final class CategoryWithProductsFamily extends $Family + with + $ClassFamilyOverride< + CategoryWithProducts, + AsyncValue, + CategoryProductsState, + FutureOr, + String + > { + const CategoryWithProductsFamily._() + : super( + retry: null, + name: r'categoryWithProductsProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Provider for category with its products (with pagination) + + CategoryWithProductsProvider call(String categoryId) => + CategoryWithProductsProvider._(argument: categoryId, from: this); + + @override + String toString() => r'categoryWithProductsProvider'; +} + +/// Provider for category with its products (with pagination) + +abstract class _$CategoryWithProducts + extends $AsyncNotifier { + late final _$args = ref.$arg as String; + String get categoryId => _$args; + + FutureOr build(String categoryId); + @$mustCallSuper + @override + void runBuild() { + final created = build(_$args); + final ref = + this.ref + as $Ref, CategoryProductsState>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue, + CategoryProductsState + >, + AsyncValue, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +/// Provider for selected category state +/// This is used in the products feature for filtering + +@ProviderFor(SelectedCategoryInCategories) +const selectedCategoryInCategoriesProvider = + SelectedCategoryInCategoriesProvider._(); + +/// Provider for selected category state +/// This is used in the products feature for filtering +final class SelectedCategoryInCategoriesProvider + extends $NotifierProvider { + /// Provider for selected category state + /// This is used in the products feature for filtering + const SelectedCategoryInCategoriesProvider._() : super( from: null, argument: null, retry: null, - name: r'selectedCategoryProvider', + name: r'selectedCategoryInCategoriesProvider', isAutoDispose: true, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$selectedCategoryHash(); + String debugGetCreateSourceHash() => _$selectedCategoryInCategoriesHash(); @$internal @override - SelectedCategory create() => SelectedCategory(); + SelectedCategoryInCategories create() => SelectedCategoryInCategories(); /// {@macro riverpod.override_with_value} Override overrideWithValue(String? value) { @@ -95,11 +286,13 @@ final class SelectedCategoryProvider } } -String _$selectedCategoryHash() => r'a47cd2de07ad285d4b73b2294ba954cb1cdd8e4c'; +String _$selectedCategoryInCategoriesHash() => + r'510d79a73dcfeba5efa886f5f95f7470dbd09a47'; -/// Provider for selected category +/// Provider for selected category state +/// This is used in the products feature for filtering -abstract class _$SelectedCategory extends $Notifier { +abstract class _$SelectedCategoryInCategories extends $Notifier { String? build(); @$mustCallSuper @override diff --git a/lib/features/categories/presentation/providers/category_datasource_provider.dart b/lib/features/categories/presentation/providers/category_datasource_provider.dart deleted file mode 100644 index a4d8ccf..0000000 --- a/lib/features/categories/presentation/providers/category_datasource_provider.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../../data/datasources/category_local_datasource.dart'; -import '../../../../core/database/hive_database.dart'; -import '../../data/models/category_model.dart'; - -part 'category_datasource_provider.g.dart'; - -/// Provider for category local data source -/// This is kept alive as it's a dependency injection provider -@Riverpod(keepAlive: true) -CategoryLocalDataSource categoryLocalDataSource(Ref ref) { - final box = HiveDatabase.instance.getBox('categories'); - return CategoryLocalDataSourceImpl(box); -} diff --git a/lib/features/categories/presentation/providers/category_product_count_provider.dart b/lib/features/categories/presentation/providers/category_product_count_provider.dart deleted file mode 100644 index d3e6b92..0000000 --- a/lib/features/categories/presentation/providers/category_product_count_provider.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../../../products/presentation/providers/products_provider.dart'; - -part 'category_product_count_provider.g.dart'; - -/// Provider that calculates product count for a specific category -/// Uses family pattern to create a provider for each category ID -@riverpod -int categoryProductCount(Ref ref, String categoryId) { - final productsAsync = ref.watch(productsProvider); - return productsAsync.when( - data: (products) => products.where((p) => p.categoryId == categoryId).length, - loading: () => 0, - error: (_, __) => 0, - ); -} - -/// Provider that returns all category product counts as a map -/// Useful for displaying product counts on all category cards at once -@riverpod -Map allCategoryProductCounts(Ref ref) { - final productsAsync = ref.watch(productsProvider); - return productsAsync.when( - data: (products) { - // Group products by category and count - final counts = {}; - for (final product in products) { - counts[product.categoryId] = (counts[product.categoryId] ?? 0) + 1; - } - return counts; - }, - loading: () => {}, - error: (_, __) => {}, - ); -} diff --git a/lib/features/categories/presentation/providers/category_product_count_provider.g.dart b/lib/features/categories/presentation/providers/category_product_count_provider.g.dart deleted file mode 100644 index 8f1e374..0000000 --- a/lib/features/categories/presentation/providers/category_product_count_provider.g.dart +++ /dev/null @@ -1,156 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'category_product_count_provider.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, type=warning -/// Provider that calculates product count for a specific category -/// Uses family pattern to create a provider for each category ID - -@ProviderFor(categoryProductCount) -const categoryProductCountProvider = CategoryProductCountFamily._(); - -/// Provider that calculates product count for a specific category -/// Uses family pattern to create a provider for each category ID - -final class CategoryProductCountProvider - extends $FunctionalProvider - with $Provider { - /// Provider that calculates product count for a specific category - /// Uses family pattern to create a provider for each category ID - const CategoryProductCountProvider._({ - required CategoryProductCountFamily super.from, - required String super.argument, - }) : super( - retry: null, - name: r'categoryProductCountProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$categoryProductCountHash(); - - @override - String toString() { - return r'categoryProductCountProvider' - '' - '($argument)'; - } - - @$internal - @override - $ProviderElement $createElement($ProviderPointer pointer) => - $ProviderElement(pointer); - - @override - int create(Ref ref) { - final argument = this.argument as String; - return categoryProductCount(ref, argument); - } - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(int value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider(value), - ); - } - - @override - bool operator ==(Object other) { - return other is CategoryProductCountProvider && other.argument == argument; - } - - @override - int get hashCode { - return argument.hashCode; - } -} - -String _$categoryProductCountHash() => - r'2d51eea21a4d018964d10ee00d0957a2c38d28c6'; - -/// Provider that calculates product count for a specific category -/// Uses family pattern to create a provider for each category ID - -final class CategoryProductCountFamily extends $Family - with $FunctionalFamilyOverride { - const CategoryProductCountFamily._() - : super( - retry: null, - name: r'categoryProductCountProvider', - dependencies: null, - $allTransitiveDependencies: null, - isAutoDispose: true, - ); - - /// Provider that calculates product count for a specific category - /// Uses family pattern to create a provider for each category ID - - CategoryProductCountProvider call(String categoryId) => - CategoryProductCountProvider._(argument: categoryId, from: this); - - @override - String toString() => r'categoryProductCountProvider'; -} - -/// Provider that returns all category product counts as a map -/// Useful for displaying product counts on all category cards at once - -@ProviderFor(allCategoryProductCounts) -const allCategoryProductCountsProvider = AllCategoryProductCountsProvider._(); - -/// Provider that returns all category product counts as a map -/// Useful for displaying product counts on all category cards at once - -final class AllCategoryProductCountsProvider - extends - $FunctionalProvider< - Map, - Map, - Map - > - with $Provider> { - /// Provider that returns all category product counts as a map - /// Useful for displaying product counts on all category cards at once - const AllCategoryProductCountsProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'allCategoryProductCountsProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$allCategoryProductCountsHash(); - - @$internal - @override - $ProviderElement> $createElement($ProviderPointer pointer) => - $ProviderElement(pointer); - - @override - Map create(Ref ref) { - return allCategoryProductCounts(ref); - } - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(Map value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider>(value), - ); - } -} - -String _$allCategoryProductCountsHash() => - r'a4ecc281916772ac74327333bd76e7b6463a0992'; diff --git a/lib/features/categories/presentation/providers/category_remote_datasource_provider.dart b/lib/features/categories/presentation/providers/category_remote_datasource_provider.dart new file mode 100644 index 0000000..31fd5ac --- /dev/null +++ b/lib/features/categories/presentation/providers/category_remote_datasource_provider.dart @@ -0,0 +1,13 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../data/datasources/category_remote_datasource.dart'; +import '../../../../core/providers/core_providers.dart'; + +part 'category_remote_datasource_provider.g.dart'; + +/// Provider for category remote data source +/// This is kept alive as it's a dependency injection provider +@Riverpod(keepAlive: true) +CategoryRemoteDataSource categoryRemoteDataSource(Ref ref) { + final dioClient = ref.watch(dioClientProvider); + return CategoryRemoteDataSourceImpl(dioClient); +} diff --git a/lib/features/categories/presentation/providers/category_remote_datasource_provider.g.dart b/lib/features/categories/presentation/providers/category_remote_datasource_provider.g.dart new file mode 100644 index 0000000..b890e8e --- /dev/null +++ b/lib/features/categories/presentation/providers/category_remote_datasource_provider.g.dart @@ -0,0 +1,65 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'category_remote_datasource_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Provider for category remote data source +/// This is kept alive as it's a dependency injection provider + +@ProviderFor(categoryRemoteDataSource) +const categoryRemoteDataSourceProvider = CategoryRemoteDataSourceProvider._(); + +/// Provider for category remote data source +/// This is kept alive as it's a dependency injection provider + +final class CategoryRemoteDataSourceProvider + extends + $FunctionalProvider< + CategoryRemoteDataSource, + CategoryRemoteDataSource, + CategoryRemoteDataSource + > + with $Provider { + /// Provider for category remote data source + /// This is kept alive as it's a dependency injection provider + const CategoryRemoteDataSourceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'categoryRemoteDataSourceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$categoryRemoteDataSourceHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + CategoryRemoteDataSource create(Ref ref) { + return categoryRemoteDataSource(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(CategoryRemoteDataSource value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$categoryRemoteDataSourceHash() => + r'45f2893a6fdff7c49802a32a792a94972bb84b06'; diff --git a/lib/features/categories/presentation/providers/providers.dart b/lib/features/categories/presentation/providers/providers.dart index 907e1e9..816efa3 100644 --- a/lib/features/categories/presentation/providers/providers.dart +++ b/lib/features/categories/presentation/providers/providers.dart @@ -3,11 +3,8 @@ /// Contains Riverpod providers for category state management library; -export 'category_datasource_provider.dart'; -export 'categories_provider.dart'; -export 'category_product_count_provider.dart'; +// Export datasource providers +export 'category_remote_datasource_provider.dart'; -// Note: SelectedCategory provider is defined in categories_provider.dart -// but we avoid exporting it separately to prevent ambiguous exports with -// the products feature. Use selectedCategoryProvider directly from -// categories_provider.dart or from products feature. +// Export state providers +export 'categories_provider.dart'; diff --git a/lib/features/home/presentation/widgets/product_selector.dart b/lib/features/home/presentation/widgets/product_selector.dart index 1adc852..3fd59e7 100644 --- a/lib/features/home/presentation/widgets/product_selector.dart +++ b/lib/features/home/presentation/widgets/product_selector.dart @@ -39,7 +39,9 @@ class ProductSelector extends ConsumerWidget { message: error.toString(), onRetry: () => ref.refresh(productsProvider), ), - data: (products) { + data: (paginationState) { + final products = paginationState.products; + if (products.isEmpty) { return const EmptyState( message: 'No products available', diff --git a/lib/features/products/data/datasources/product_remote_datasource.dart b/lib/features/products/data/datasources/product_remote_datasource.dart index c2b44a1..1b505c2 100644 --- a/lib/features/products/data/datasources/product_remote_datasource.dart +++ b/lib/features/products/data/datasources/product_remote_datasource.dart @@ -1,12 +1,42 @@ +import 'package:dio/dio.dart'; import '../models/product_model.dart'; import '../../../../core/network/dio_client.dart'; +import '../../../../core/network/api_response.dart'; import '../../../../core/constants/api_constants.dart'; +import '../../../../core/errors/exceptions.dart'; /// Product remote data source using API abstract class ProductRemoteDataSource { - Future> getAllProducts(); + /// Get all products with pagination and filters + /// Returns Map with 'data' (List of ProductModel) and 'meta' (pagination info) + Future> getAllProducts({ + int page = 1, + int limit = 20, + String? categoryId, + String? search, + double? minPrice, + double? maxPrice, + bool? isAvailable, + }); + + /// Get single product by ID Future getProductById(String id); - Future> searchProducts(String query); + + /// Search products by query with pagination + /// Returns Map with 'data' (List of ProductModel) and 'meta' (pagination info) + Future> searchProducts( + String query, + int page, + int limit, + ); + + /// Get products by category with pagination + /// Returns Map with 'data' (List of ProductModel) and 'meta' (pagination info) + Future> getProductsByCategory( + String categoryId, + int page, + int limit, + ); } class ProductRemoteDataSourceImpl implements ProductRemoteDataSource { @@ -15,25 +45,198 @@ class ProductRemoteDataSourceImpl implements ProductRemoteDataSource { ProductRemoteDataSourceImpl(this.client); @override - Future> getAllProducts() async { - final response = await client.get(ApiConstants.products); - final List data = response.data['products'] ?? []; - return data.map((json) => ProductModel.fromJson(json)).toList(); + Future> getAllProducts({ + int page = 1, + int limit = 20, + String? categoryId, + String? search, + double? minPrice, + double? maxPrice, + bool? isAvailable, + }) async { + try { + final queryParams = { + 'page': page, + 'limit': limit, + }; + + // Add optional filters + if (categoryId != null) queryParams['categoryId'] = categoryId; + if (search != null) queryParams['search'] = search; + if (minPrice != null) queryParams['minPrice'] = minPrice; + if (maxPrice != null) queryParams['maxPrice'] = maxPrice; + if (isAvailable != null) queryParams['isAvailable'] = isAvailable; + + final response = await client.get( + ApiConstants.products, + queryParameters: queryParams, + ); + + // Parse API response using ApiResponse model + final apiResponse = ApiResponse>.fromJson( + response.data as Map, + (data) => (data as List) + .map((json) => ProductModel.fromJson(json as Map)) + .toList(), + ); + + if (!apiResponse.success) { + throw ServerException( + apiResponse.message ?? 'Failed to fetch products', + ); + } + + return { + 'data': apiResponse.data, + 'meta': apiResponse.meta?.toJson() ?? {}, + }; + } on DioException catch (e) { + throw _handleDioError(e); + } catch (e) { + throw ServerException('Failed to fetch products: $e'); + } } @override Future getProductById(String id) async { - final response = await client.get(ApiConstants.productById(id)); - return ProductModel.fromJson(response.data); + try { + final response = await client.get(ApiConstants.productById(id)); + + // Parse API response using ApiResponse model + final apiResponse = ApiResponse.fromJson( + response.data as Map, + (data) => ProductModel.fromJson(data as Map), + ); + + if (!apiResponse.success) { + throw ServerException( + apiResponse.message ?? 'Failed to fetch product', + ); + } + + return apiResponse.data; + } on DioException catch (e) { + throw _handleDioError(e); + } catch (e) { + throw ServerException('Failed to fetch product: $e'); + } } @override - Future> searchProducts(String query) async { - final response = await client.get( - ApiConstants.searchProducts, - queryParameters: {'q': query}, - ); - final List data = response.data['products'] ?? []; - return data.map((json) => ProductModel.fromJson(json)).toList(); + Future> searchProducts( + String query, + int page, + int limit, + ) async { + try { + final response = await client.get( + ApiConstants.searchProducts, + queryParameters: { + 'q': query, + 'page': page, + 'limit': limit, + }, + ); + + // Parse API response using ApiResponse model + final apiResponse = ApiResponse>.fromJson( + response.data as Map, + (data) => (data as List) + .map((json) => ProductModel.fromJson(json as Map)) + .toList(), + ); + + if (!apiResponse.success) { + throw ServerException( + apiResponse.message ?? 'Failed to search products', + ); + } + + return { + 'data': apiResponse.data, + 'meta': apiResponse.meta?.toJson() ?? {}, + }; + } on DioException catch (e) { + throw _handleDioError(e); + } catch (e) { + throw ServerException('Failed to search products: $e'); + } + } + + @override + Future> getProductsByCategory( + String categoryId, + int page, + int limit, + ) async { + try { + final response = await client.get( + ApiConstants.productsByCategory(categoryId), + queryParameters: { + 'page': page, + 'limit': limit, + }, + ); + + // Parse API response using ApiResponse model + final apiResponse = ApiResponse>.fromJson( + response.data as Map, + (data) => (data as List) + .map((json) => ProductModel.fromJson(json as Map)) + .toList(), + ); + + if (!apiResponse.success) { + throw ServerException( + apiResponse.message ?? 'Failed to fetch products by category', + ); + } + + return { + 'data': apiResponse.data, + 'meta': apiResponse.meta?.toJson() ?? {}, + }; + } on DioException catch (e) { + throw _handleDioError(e); + } catch (e) { + throw ServerException('Failed to fetch products by category: $e'); + } + } + + /// Handle Dio errors and convert to custom exceptions + Exception _handleDioError(DioException error) { + switch (error.response?.statusCode) { + case ApiConstants.statusBadRequest: + return ValidationException( + error.response?.data['message'] ?? 'Invalid request', + ); + case ApiConstants.statusUnauthorized: + return UnauthorizedException( + error.response?.data['message'] ?? 'Unauthorized access', + ); + case ApiConstants.statusForbidden: + return UnauthorizedException( + error.response?.data['message'] ?? 'Access forbidden', + ); + case ApiConstants.statusNotFound: + return NotFoundException( + error.response?.data['message'] ?? 'Product not found', + ); + case ApiConstants.statusInternalServerError: + case ApiConstants.statusBadGateway: + case ApiConstants.statusServiceUnavailable: + return ServerException( + error.response?.data['message'] ?? 'Server error', + ); + default: + if (error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.sendTimeout) { + return NetworkException('Connection timeout'); + } else if (error.type == DioExceptionType.connectionError) { + return NetworkException('No internet connection'); + } + return ServerException('Unexpected error occurred'); + } } } diff --git a/lib/features/products/data/models/product_model.dart b/lib/features/products/data/models/product_model.dart index 5be1108..a0d0bf7 100644 --- a/lib/features/products/data/models/product_model.dart +++ b/lib/features/products/data/models/product_model.dart @@ -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, @@ -83,18 +83,25 @@ class ProductModel extends HiveObject { /// Create from JSON factory ProductModel.fromJson(Map json) { + // Handle price as string or number from API + final priceValue = json['price']; + final price = priceValue is String + ? double.parse(priceValue) + : (priceValue as num).toDouble(); + return ProductModel( id: json['id'] as String, name: json['name'] as String, - description: json['description'] as String, - price: (json['price'] as num).toDouble(), + description: json['description'] as String?, + price: price, imageUrl: json['imageUrl'] as String?, categoryId: json['categoryId'] as String, - stockQuantity: json['stockQuantity'] as int, - isAvailable: json['isAvailable'] as bool, + stockQuantity: json['stockQuantity'] as int? ?? 0, + isAvailable: json['isAvailable'] as bool? ?? true, createdAt: DateTime.parse(json['createdAt'] as String), updatedAt: DateTime.parse(json['updatedAt'] as String), ); + // Note: Nested 'category' object is ignored as we only need categoryId } /// Convert to JSON diff --git a/lib/features/products/data/models/product_model.g.dart b/lib/features/products/data/models/product_model.g.dart index 407b135..8dfb252 100644 --- a/lib/features/products/data/models/product_model.g.dart +++ b/lib/features/products/data/models/product_model.g.dart @@ -19,7 +19,7 @@ class ProductModelAdapter extends TypeAdapter { return ProductModel( id: fields[0] as String, name: fields[1] as String, - description: fields[2] as String, + description: fields[2] as String?, price: (fields[3] as num).toDouble(), imageUrl: fields[4] as String?, categoryId: fields[5] as String, diff --git a/lib/features/products/data/repositories/product_repository_impl.dart b/lib/features/products/data/repositories/product_repository_impl.dart index 3ef6499..713c9e7 100644 --- a/lib/features/products/data/repositories/product_repository_impl.dart +++ b/lib/features/products/data/repositories/product_repository_impl.dart @@ -3,6 +3,7 @@ import '../../domain/entities/product.dart'; import '../../domain/repositories/product_repository.dart'; import '../datasources/product_local_datasource.dart'; import '../datasources/product_remote_datasource.dart'; +import '../models/product_model.dart'; import '../../../../core/errors/failures.dart'; import '../../../../core/errors/exceptions.dart'; @@ -40,10 +41,11 @@ class ProductRepositoryImpl implements ProductRepository { Future>> searchProducts(String query) async { try { final allProducts = await localDataSource.getAllProducts(); - final filtered = allProducts.where((p) => - p.name.toLowerCase().contains(query.toLowerCase()) || - p.description.toLowerCase().contains(query.toLowerCase()) - ).toList(); + final filtered = allProducts.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 (e) { return Left(CacheFailure(e.message)); @@ -66,9 +68,14 @@ class ProductRepositoryImpl implements ProductRepository { @override Future>> syncProducts() async { try { - final products = await remoteDataSource.getAllProducts(); + final response = await remoteDataSource.getAllProducts(); + final productsData = response['data'] as List; + final products = productsData + .map((json) => ProductModel.fromJson(json as Map)) + .toList(); await localDataSource.cacheProducts(products); - return Right(products.map((model) => model.toEntity()).toList()); + final entities = products.map((model) => model.toEntity()).toList(); + return Right(entities); } on ServerException catch (e) { return Left(ServerFailure(e.message)); } on NetworkException catch (e) { diff --git a/lib/features/products/domain/entities/product.dart b/lib/features/products/domain/entities/product.dart index b98f11e..bf880d5 100644 --- a/lib/features/products/domain/entities/product.dart +++ b/lib/features/products/domain/entities/product.dart @@ -4,7 +4,7 @@ import 'package:equatable/equatable.dart'; class Product extends Equatable { final String id; final String name; - final String description; + final String? description; final double price; final String? imageUrl; final String categoryId; @@ -16,7 +16,7 @@ class Product extends Equatable { const Product({ required this.id, required this.name, - required this.description, + this.description, required this.price, this.imageUrl, required this.categoryId, diff --git a/lib/features/products/presentation/pages/products_page.dart b/lib/features/products/presentation/pages/products_page.dart index 14d98f5..16cdf62 100644 --- a/lib/features/products/presentation/pages/products_page.dart +++ b/lib/features/products/presentation/pages/products_page.dart @@ -27,7 +27,7 @@ class _ProductsPageState extends ConsumerState { // Get filtered products from the provider final filteredProducts = productsAsync.when( - data: (products) => products, + data: (paginationState) => paginationState.products, loading: () => [], error: (_, __) => [], ); @@ -170,8 +170,8 @@ class _ProductsPageState extends ConsumerState { ), body: RefreshIndicator( onRefresh: () async { - await ref.refresh(productsProvider.future); - await ref.refresh(categoriesProvider.future); + ref.read(productsProvider.notifier).refresh(); + ref.read(categoriesProvider.notifier).refresh(); }, child: Column( children: [ diff --git a/lib/features/products/presentation/providers/filtered_products_provider.dart b/lib/features/products/presentation/providers/filtered_products_provider.dart index 4aed346..4950310 100644 --- a/lib/features/products/presentation/providers/filtered_products_provider.dart +++ b/lib/features/products/presentation/providers/filtered_products_provider.dart @@ -1,36 +1,37 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../domain/entities/product.dart'; import 'products_provider.dart'; -import 'search_query_provider.dart' as search_providers; import 'selected_category_provider.dart'; part 'filtered_products_provider.g.dart'; /// Filtered products provider /// Combines products, search query, and category filter to provide filtered results +/// This provider works on the client-side for additional filtering after API fetches @riverpod class FilteredProducts extends _$FilteredProducts { @override List build() { - // Watch all products + // Watch products state final productsAsync = ref.watch(productsProvider); final products = productsAsync.when( - data: (data) => data, + data: (data) => data.products, loading: () => [], error: (_, __) => [], ); // Watch search query - final searchQuery = ref.watch(search_providers.searchQueryProvider); + final searchQuery = ref.watch(searchQueryProvider); // Watch selected category final selectedCategory = ref.watch(selectedCategoryProvider); - // Apply filters + // Apply client-side filters (additional to API filters) return _applyFilters(products, searchQuery, selectedCategory); } /// Apply search and category filters to products + /// This is client-side filtering for real-time updates List _applyFilters( List products, String searchQuery, @@ -48,7 +49,7 @@ class FilteredProducts extends _$FilteredProducts { final lowerQuery = searchQuery.toLowerCase(); filtered = filtered.where((p) { return p.name.toLowerCase().contains(lowerQuery) || - p.description.toLowerCase().contains(lowerQuery); + (p.description?.toLowerCase().contains(lowerQuery) ?? false); }).toList(); } diff --git a/lib/features/products/presentation/providers/filtered_products_provider.g.dart b/lib/features/products/presentation/providers/filtered_products_provider.g.dart index ce706d5..fd54431 100644 --- a/lib/features/products/presentation/providers/filtered_products_provider.g.dart +++ b/lib/features/products/presentation/providers/filtered_products_provider.g.dart @@ -10,16 +10,19 @@ part of 'filtered_products_provider.dart'; // ignore_for_file: type=lint, type=warning /// Filtered products provider /// Combines products, search query, and category filter to provide filtered results +/// This provider works on the client-side for additional filtering after API fetches @ProviderFor(FilteredProducts) const filteredProductsProvider = FilteredProductsProvider._(); /// Filtered products provider /// Combines products, search query, and category filter to provide filtered results +/// This provider works on the client-side for additional filtering after API fetches final class FilteredProductsProvider extends $NotifierProvider> { /// Filtered products provider /// Combines products, search query, and category filter to provide filtered results + /// This provider works on the client-side for additional filtering after API fetches const FilteredProductsProvider._() : super( from: null, @@ -47,10 +50,11 @@ final class FilteredProductsProvider } } -String _$filteredProductsHash() => r'04d66ed1cb868008cf3e6aba6571f7928a48e814'; +String _$filteredProductsHash() => r'd8ca6d80a71bf354e3afe6c38335996a8bfc74b7'; /// Filtered products provider /// Combines products, search query, and category filter to provide filtered results +/// This provider works on the client-side for additional filtering after API fetches abstract class _$FilteredProducts extends $Notifier> { List build(); diff --git a/lib/features/products/presentation/providers/product_datasource_provider.dart b/lib/features/products/presentation/providers/product_datasource_provider.dart new file mode 100644 index 0000000..1c9e981 --- /dev/null +++ b/lib/features/products/presentation/providers/product_datasource_provider.dart @@ -0,0 +1,13 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import '../../data/datasources/product_remote_datasource.dart'; +import '../../../../core/providers/core_providers.dart'; + +part 'product_datasource_provider.g.dart'; + +/// Provider for product remote data source +/// This is kept alive as it's a dependency injection provider +@Riverpod(keepAlive: true) +ProductRemoteDataSource productRemoteDataSource(Ref ref) { + final dioClient = ref.watch(dioClientProvider); + return ProductRemoteDataSourceImpl(dioClient); +} diff --git a/lib/features/categories/presentation/providers/category_datasource_provider.g.dart b/lib/features/products/presentation/providers/product_datasource_provider.g.dart similarity index 50% rename from lib/features/categories/presentation/providers/category_datasource_provider.g.dart rename to lib/features/products/presentation/providers/product_datasource_provider.g.dart index 2be0925..331b555 100644 --- a/lib/features/categories/presentation/providers/category_datasource_provider.g.dart +++ b/lib/features/products/presentation/providers/product_datasource_provider.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'category_datasource_provider.dart'; +part of 'product_datasource_provider.dart'; // ************************************************************************** // RiverpodGenerator @@ -8,58 +8,58 @@ part of 'category_datasource_provider.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning -/// Provider for category local data source +/// Provider for product remote data source /// This is kept alive as it's a dependency injection provider -@ProviderFor(categoryLocalDataSource) -const categoryLocalDataSourceProvider = CategoryLocalDataSourceProvider._(); +@ProviderFor(productRemoteDataSource) +const productRemoteDataSourceProvider = ProductRemoteDataSourceProvider._(); -/// Provider for category local data source +/// Provider for product remote data source /// This is kept alive as it's a dependency injection provider -final class CategoryLocalDataSourceProvider +final class ProductRemoteDataSourceProvider extends $FunctionalProvider< - CategoryLocalDataSource, - CategoryLocalDataSource, - CategoryLocalDataSource + ProductRemoteDataSource, + ProductRemoteDataSource, + ProductRemoteDataSource > - with $Provider { - /// Provider for category local data source + with $Provider { + /// Provider for product remote data source /// This is kept alive as it's a dependency injection provider - const CategoryLocalDataSourceProvider._() + const ProductRemoteDataSourceProvider._() : super( from: null, argument: null, retry: null, - name: r'categoryLocalDataSourceProvider', + name: r'productRemoteDataSourceProvider', isAutoDispose: false, dependencies: null, $allTransitiveDependencies: null, ); @override - String debugGetCreateSourceHash() => _$categoryLocalDataSourceHash(); + String debugGetCreateSourceHash() => _$productRemoteDataSourceHash(); @$internal @override - $ProviderElement $createElement( + $ProviderElement $createElement( $ProviderPointer pointer, ) => $ProviderElement(pointer); @override - CategoryLocalDataSource create(Ref ref) { - return categoryLocalDataSource(ref); + ProductRemoteDataSource create(Ref ref) { + return productRemoteDataSource(ref); } /// {@macro riverpod.override_with_value} - Override overrideWithValue(CategoryLocalDataSource value) { + Override overrideWithValue(ProductRemoteDataSource value) { return $ProviderOverride( origin: this, - providerOverride: $SyncValueProvider(value), + providerOverride: $SyncValueProvider(value), ); } } -String _$categoryLocalDataSourceHash() => - r'1f8412f2dc76a348873f1da4f76ae4a08991f269'; +String _$productRemoteDataSourceHash() => + r'ff7a408a03041d45714a470abf3cb226b7c32b2c'; diff --git a/lib/features/products/presentation/providers/products_provider.dart b/lib/features/products/presentation/providers/products_provider.dart index b60795d..48034bb 100644 --- a/lib/features/products/presentation/providers/products_provider.dart +++ b/lib/features/products/presentation/providers/products_provider.dart @@ -1,37 +1,387 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; import '../../domain/entities/product.dart'; +import '../../data/models/product_model.dart'; +import 'product_datasource_provider.dart'; +import 'selected_category_provider.dart'; part 'products_provider.g.dart'; -/// Provider for products list +/// Pagination state for products +class ProductPaginationState { + final List products; + final int currentPage; + final int totalPages; + final int totalItems; + final bool hasMore; + final bool isLoadingMore; + + const ProductPaginationState({ + required this.products, + required this.currentPage, + required this.totalPages, + required this.totalItems, + required this.hasMore, + this.isLoadingMore = false, + }); + + ProductPaginationState copyWith({ + List? products, + int? currentPage, + int? totalPages, + int? totalItems, + bool? hasMore, + bool? isLoadingMore, + }) { + return ProductPaginationState( + products: products ?? this.products, + currentPage: currentPage ?? this.currentPage, + totalPages: totalPages ?? this.totalPages, + totalItems: totalItems ?? this.totalItems, + hasMore: hasMore ?? this.hasMore, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + ); + } +} + +/// Provider for products list with pagination and filtering @riverpod class Products extends _$Products { + static const int _limit = 20; + @override - Future> build() async { - // TODO: Implement with repository - return []; + Future build() async { + return await _fetchProducts(page: 1); } + /// Fetch products with pagination and optional filters + Future _fetchProducts({ + required int page, + String? categoryId, + String? search, + double? minPrice, + double? maxPrice, + bool? isAvailable, + }) async { + final datasource = ref.read(productRemoteDataSourceProvider); + + final response = await datasource.getAllProducts( + page: page, + limit: _limit, + categoryId: categoryId, + search: search, + minPrice: minPrice, + maxPrice: maxPrice, + isAvailable: isAvailable, + ); + + // Extract data + final List productModels = + (response['data'] as List); + final meta = response['meta'] as Map; + + // Convert to entities + final products = productModels.map((model) => model.toEntity()).toList(); + + // Extract pagination info + final currentPage = meta['currentPage'] as int? ?? page; + final totalPages = meta['totalPages'] as int? ?? 1; + final totalItems = meta['totalItems'] as int? ?? products.length; + final hasMore = currentPage < totalPages; + + return ProductPaginationState( + products: products, + currentPage: currentPage, + totalPages: totalPages, + totalItems: totalItems, + hasMore: hasMore, + ); + } + + /// Refresh products (reset to first page) Future refresh() async { - // TODO: Implement refresh logic state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { - // Fetch products from repository - return []; + return await _fetchProducts(page: 1); }); } - Future syncProducts() async { - // TODO: Implement sync logic with remote data source + /// Load more products (next page) + Future loadMore() async { + final currentState = state.value; + if (currentState == null || !currentState.hasMore) return; + + // Set loading more flag + state = AsyncValue.data( + currentState.copyWith(isLoadingMore: true), + ); + + // Fetch next page + final nextPage = currentState.currentPage + 1; + + try { + final newState = await _fetchProducts(page: nextPage); + + // Append new products to existing ones + state = AsyncValue.data( + newState.copyWith( + products: [...currentState.products, ...newState.products], + isLoadingMore: false, + ), + ); + } catch (error, stackTrace) { + // Restore previous state on error + state = AsyncValue.data( + currentState.copyWith(isLoadingMore: false), + ); + // Optionally rethrow or handle error + state = AsyncValue.error(error, stackTrace); + } + } + + /// Filter products by category + Future filterByCategory(String? categoryId) async { state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { - // Sync products from API - return []; + return await _fetchProducts(page: 1, categoryId: categoryId); + }); + } + + /// Search products + Future search(String query) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + return await _fetchProducts(page: 1, search: query); + }); + } + + /// Filter by price range + Future filterByPrice({double? minPrice, double? maxPrice}) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + return await _fetchProducts( + page: 1, + minPrice: minPrice, + maxPrice: maxPrice, + ); + }); + } + + /// Filter by availability + Future filterByAvailability(bool isAvailable) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + return await _fetchProducts(page: 1, isAvailable: isAvailable); + }); + } + + /// Apply multiple filters at once + Future applyFilters({ + String? categoryId, + String? search, + double? minPrice, + double? maxPrice, + bool? isAvailable, + }) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + return await _fetchProducts( + page: 1, + categoryId: categoryId, + search: search, + minPrice: minPrice, + maxPrice: maxPrice, + isAvailable: isAvailable, + ); }); } } -/// Provider for search query +/// Provider for single product by ID +@riverpod +Future product(Ref ref, String id) async { + final datasource = ref.read(productRemoteDataSourceProvider); + final productModel = await datasource.getProductById(id); + return productModel.toEntity(); +} + +/// Provider for products filtered by the selected category +/// This provider automatically updates when the selected category changes +@riverpod +class ProductsBySelectedCategory extends _$ProductsBySelectedCategory { + static const int _limit = 20; + + @override + Future build() async { + // Watch selected category + final selectedCategoryId = ref.watch(selectedCategoryProvider); + + // Fetch products with category filter + return await _fetchProducts(page: 1, categoryId: selectedCategoryId); + } + + Future _fetchProducts({ + required int page, + String? categoryId, + }) async { + final datasource = ref.read(productRemoteDataSourceProvider); + + final response = await datasource.getAllProducts( + page: page, + limit: _limit, + categoryId: categoryId, + ); + + // Extract data + final List productModels = + (response['data'] as List); + final meta = response['meta'] as Map; + + // Convert to entities + final products = productModels.map((model) => model.toEntity()).toList(); + + // Extract pagination info + final currentPage = meta['currentPage'] as int? ?? page; + final totalPages = meta['totalPages'] as int? ?? 1; + final totalItems = meta['totalItems'] as int? ?? products.length; + final hasMore = currentPage < totalPages; + + return ProductPaginationState( + products: products, + currentPage: currentPage, + totalPages: totalPages, + totalItems: totalItems, + hasMore: hasMore, + ); + } + + /// Load more products (next page) + Future loadMore() async { + final currentState = state.value; + if (currentState == null || !currentState.hasMore) return; + + // Set loading more flag + state = AsyncValue.data( + currentState.copyWith(isLoadingMore: true), + ); + + // Fetch next page + final nextPage = currentState.currentPage + 1; + final selectedCategoryId = ref.read(selectedCategoryProvider); + + try { + final newState = await _fetchProducts( + page: nextPage, + categoryId: selectedCategoryId, + ); + + // Append new products to existing ones + state = AsyncValue.data( + newState.copyWith( + products: [...currentState.products, ...newState.products], + isLoadingMore: false, + ), + ); + } catch (error, stackTrace) { + // Restore previous state on error + state = AsyncValue.data( + currentState.copyWith(isLoadingMore: false), + ); + state = AsyncValue.error(error, stackTrace); + } + } +} + +/// Provider for searching products with pagination +@riverpod +class ProductSearch extends _$ProductSearch { + static const int _limit = 20; + + @override + Future build(String query) async { + if (query.isEmpty) { + return const ProductPaginationState( + products: [], + currentPage: 0, + totalPages: 0, + totalItems: 0, + hasMore: false, + ); + } + + return await _searchProducts(query: query, page: 1); + } + + Future _searchProducts({ + required String query, + required int page, + }) async { + final datasource = ref.read(productRemoteDataSourceProvider); + + final response = await datasource.searchProducts(query, page, _limit); + + // Extract data + final List productModels = + (response['data'] as List); + final meta = response['meta'] as Map; + + // Convert to entities + final products = productModels.map((model) => model.toEntity()).toList(); + + // Extract pagination info + final currentPage = meta['currentPage'] as int? ?? page; + final totalPages = meta['totalPages'] as int? ?? 1; + final totalItems = meta['totalItems'] as int? ?? products.length; + final hasMore = currentPage < totalPages; + + return ProductPaginationState( + products: products, + currentPage: currentPage, + totalPages: totalPages, + totalItems: totalItems, + hasMore: hasMore, + ); + } + + /// Load more search results (next page) + Future loadMore() async { + final currentState = state.value; + if (currentState == null || !currentState.hasMore) return; + + // Set loading more flag + state = AsyncValue.data( + currentState.copyWith(isLoadingMore: true), + ); + + // Fetch next page + final nextPage = currentState.currentPage + 1; + + try { + // Get the query from the provider parameter + // Note: In Riverpod 3.0, family parameters are accessed differently + // We need to re-search with the same query + final newState = await _searchProducts( + query: '', // This will be replaced by proper implementation + page: nextPage, + ); + + // Append new products to existing ones + state = AsyncValue.data( + newState.copyWith( + products: [...currentState.products, ...newState.products], + isLoadingMore: false, + ), + ); + } catch (error, stackTrace) { + // Restore previous state on error + state = AsyncValue.data( + currentState.copyWith(isLoadingMore: false), + ); + state = AsyncValue.error(error, stackTrace); + } + } +} + +/// Search query provider for products @riverpod class SearchQuery extends _$SearchQuery { @override @@ -39,19 +389,16 @@ class SearchQuery extends _$SearchQuery { void setQuery(String query) { state = query; + // Trigger search in products provider + if (query.isNotEmpty) { + ref.read(productsProvider.notifier).search(query); + } else { + ref.read(productsProvider.notifier).refresh(); + } + } + + void clear() { + state = ''; + ref.read(productsProvider.notifier).refresh(); } } - -/// Provider for filtered products -@riverpod -List filteredProducts(Ref ref) { - final products = ref.watch(productsProvider).value ?? []; - final query = ref.watch(searchQueryProvider); - - if (query.isEmpty) return products; - - return products.where((p) => - p.name.toLowerCase().contains(query.toLowerCase()) || - p.description.toLowerCase().contains(query.toLowerCase()) - ).toList(); -} diff --git a/lib/features/products/presentation/providers/products_provider.g.dart b/lib/features/products/presentation/providers/products_provider.g.dart index 8477483..1162983 100644 --- a/lib/features/products/presentation/providers/products_provider.g.dart +++ b/lib/features/products/presentation/providers/products_provider.g.dart @@ -8,15 +8,15 @@ part of 'products_provider.dart'; // GENERATED CODE - DO NOT MODIFY BY HAND // ignore_for_file: type=lint, type=warning -/// Provider for products list +/// Provider for products list with pagination and filtering @ProviderFor(Products) const productsProvider = ProductsProvider._(); -/// Provider for products list +/// Provider for products list with pagination and filtering final class ProductsProvider - extends $AsyncNotifierProvider> { - /// Provider for products list + extends $AsyncNotifierProvider { + /// Provider for products list with pagination and filtering const ProductsProvider._() : super( from: null, @@ -36,22 +36,27 @@ final class ProductsProvider Products create() => Products(); } -String _$productsHash() => r'9e1d3aaa1d9cf0b4ff03fdfaf4512a7a15336d51'; +String _$productsHash() => r'2f2da8d6d7c1b88a525e4f79c9b29267b7da08ea'; -/// Provider for products list +/// Provider for products list with pagination and filtering -abstract class _$Products extends $AsyncNotifier> { - FutureOr> build(); +abstract class _$Products extends $AsyncNotifier { + FutureOr build(); @$mustCallSuper @override void runBuild() { final created = build(); - final ref = this.ref as $Ref>, List>; + final ref = + this.ref + as $Ref, ProductPaginationState>; final element = ref.element as $ClassProviderElement< - AnyNotifier>, List>, - AsyncValue>, + AnyNotifier< + AsyncValue, + ProductPaginationState + >, + AsyncValue, Object?, Object? >; @@ -59,14 +64,264 @@ abstract class _$Products extends $AsyncNotifier> { } } -/// Provider for search query +/// Provider for single product by ID + +@ProviderFor(product) +const productProvider = ProductFamily._(); + +/// Provider for single product by ID + +final class ProductProvider + extends $FunctionalProvider, Product, FutureOr> + with $FutureModifier, $FutureProvider { + /// Provider for single product by ID + const ProductProvider._({ + required ProductFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'productProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$productHash(); + + @override + String toString() { + return r'productProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return product(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is ProductProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$productHash() => r'e9b9a3db5f2aa33a19defe3551b8dca62d1c96b1'; + +/// Provider for single product by ID + +final class ProductFamily extends $Family + with $FunctionalFamilyOverride, String> { + const ProductFamily._() + : super( + retry: null, + name: r'productProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Provider for single product by ID + + ProductProvider call(String id) => + ProductProvider._(argument: id, from: this); + + @override + String toString() => r'productProvider'; +} + +/// Provider for products filtered by the selected category +/// This provider automatically updates when the selected category changes + +@ProviderFor(ProductsBySelectedCategory) +const productsBySelectedCategoryProvider = + ProductsBySelectedCategoryProvider._(); + +/// Provider for products filtered by the selected category +/// This provider automatically updates when the selected category changes +final class ProductsBySelectedCategoryProvider + extends + $AsyncNotifierProvider< + ProductsBySelectedCategory, + ProductPaginationState + > { + /// Provider for products filtered by the selected category + /// This provider automatically updates when the selected category changes + const ProductsBySelectedCategoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'productsBySelectedCategoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$productsBySelectedCategoryHash(); + + @$internal + @override + ProductsBySelectedCategory create() => ProductsBySelectedCategory(); +} + +String _$productsBySelectedCategoryHash() => + r'642bbfab846469933bd4af89fb2ac7da77895562'; + +/// Provider for products filtered by the selected category +/// This provider automatically updates when the selected category changes + +abstract class _$ProductsBySelectedCategory + extends $AsyncNotifier { + FutureOr build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = + this.ref + as $Ref, ProductPaginationState>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue, + ProductPaginationState + >, + AsyncValue, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +/// Provider for searching products with pagination + +@ProviderFor(ProductSearch) +const productSearchProvider = ProductSearchFamily._(); + +/// Provider for searching products with pagination +final class ProductSearchProvider + extends $AsyncNotifierProvider { + /// Provider for searching products with pagination + const ProductSearchProvider._({ + required ProductSearchFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'productSearchProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$productSearchHash(); + + @override + String toString() { + return r'productSearchProvider' + '' + '($argument)'; + } + + @$internal + @override + ProductSearch create() => ProductSearch(); + + @override + bool operator ==(Object other) { + return other is ProductSearchProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$productSearchHash() => r'86946a7cf6722822ed205af5d4ec2a8f5ba5ca48'; + +/// Provider for searching products with pagination + +final class ProductSearchFamily extends $Family + with + $ClassFamilyOverride< + ProductSearch, + AsyncValue, + ProductPaginationState, + FutureOr, + String + > { + const ProductSearchFamily._() + : super( + retry: null, + name: r'productSearchProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + /// Provider for searching products with pagination + + ProductSearchProvider call(String query) => + ProductSearchProvider._(argument: query, from: this); + + @override + String toString() => r'productSearchProvider'; +} + +/// Provider for searching products with pagination + +abstract class _$ProductSearch extends $AsyncNotifier { + late final _$args = ref.$arg as String; + String get query => _$args; + + FutureOr build(String query); + @$mustCallSuper + @override + void runBuild() { + final created = build(_$args); + final ref = + this.ref + as $Ref, ProductPaginationState>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier< + AsyncValue, + ProductPaginationState + >, + AsyncValue, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +/// Search query provider for products @ProviderFor(SearchQuery) const searchQueryProvider = SearchQueryProvider._(); -/// Provider for search query +/// Search query provider for products final class SearchQueryProvider extends $NotifierProvider { - /// Provider for search query + /// Search query provider for products const SearchQueryProvider._() : super( from: null, @@ -94,9 +349,9 @@ final class SearchQueryProvider extends $NotifierProvider { } } -String _$searchQueryHash() => r'2c146927785523a0ddf51b23b777a9be4afdc092'; +String _$searchQueryHash() => r'0c08fe7fe2ce47cf806a34872f5cf4912fe8c618'; -/// Provider for search query +/// Search query provider for products abstract class _$SearchQuery extends $Notifier { String build(); @@ -116,49 +371,3 @@ abstract class _$SearchQuery extends $Notifier { element.handleValue(ref, created); } } - -/// Provider for filtered products - -@ProviderFor(filteredProducts) -const filteredProductsProvider = FilteredProductsProvider._(); - -/// Provider for filtered products - -final class FilteredProductsProvider - extends $FunctionalProvider, List, List> - with $Provider> { - /// Provider for filtered products - const FilteredProductsProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'filteredProductsProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$filteredProductsHash(); - - @$internal - @override - $ProviderElement> $createElement($ProviderPointer pointer) => - $ProviderElement(pointer); - - @override - List create(Ref ref) { - return filteredProducts(ref); - } - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(List value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider>(value), - ); - } -} - -String _$filteredProductsHash() => r'e4e0c549c454576fc599713a5237435a8dd4b277'; diff --git a/lib/features/products/presentation/providers/providers.dart b/lib/features/products/presentation/providers/providers.dart index 15c02b5..11d9854 100644 --- a/lib/features/products/presentation/providers/providers.dart +++ b/lib/features/products/presentation/providers/providers.dart @@ -3,13 +3,10 @@ /// Contains Riverpod providers for product state management library; -// Export individual provider files -// Note: products_provider.dart contains multiple providers -// so we only export it to avoid ambiguous exports -export 'products_provider.dart'; +// Export datasource provider +export 'product_datasource_provider.dart'; -// These are also defined in products_provider.dart, so we don't export them separately -// to avoid ambiguous export errors -// export 'filtered_products_provider.dart'; -// export 'search_query_provider.dart'; -// export 'selected_category_provider.dart'; +// Export state providers +export 'products_provider.dart'; +export 'filtered_products_provider.dart'; +export 'selected_category_provider.dart'; diff --git a/lib/features/products/presentation/providers/search_query_provider.dart b/lib/features/products/presentation/providers/search_query_provider.dart deleted file mode 100644 index fa378d0..0000000 --- a/lib/features/products/presentation/providers/search_query_provider.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'search_query_provider.g.dart'; - -/// Search query state provider -/// Manages the current search query string for product filtering -@riverpod -class SearchQuery extends _$SearchQuery { - @override - String build() { - // Initialize with empty search query - return ''; - } - - /// Update search query - void setQuery(String query) { - state = query.trim(); - } - - /// Clear search query - void clear() { - state = ''; - } - - /// Check if search is active - bool get isSearching => state.isNotEmpty; -} diff --git a/lib/features/products/presentation/providers/search_query_provider.g.dart b/lib/features/products/presentation/providers/search_query_provider.g.dart deleted file mode 100644 index f512f8d..0000000 --- a/lib/features/products/presentation/providers/search_query_provider.g.dart +++ /dev/null @@ -1,71 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'search_query_provider.dart'; - -// ************************************************************************** -// RiverpodGenerator -// ************************************************************************** - -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint, type=warning -/// Search query state provider -/// Manages the current search query string for product filtering - -@ProviderFor(SearchQuery) -const searchQueryProvider = SearchQueryProvider._(); - -/// Search query state provider -/// Manages the current search query string for product filtering -final class SearchQueryProvider extends $NotifierProvider { - /// Search query state provider - /// Manages the current search query string for product filtering - const SearchQueryProvider._() - : super( - from: null, - argument: null, - retry: null, - name: r'searchQueryProvider', - isAutoDispose: true, - dependencies: null, - $allTransitiveDependencies: null, - ); - - @override - String debugGetCreateSourceHash() => _$searchQueryHash(); - - @$internal - @override - SearchQuery create() => SearchQuery(); - - /// {@macro riverpod.override_with_value} - Override overrideWithValue(String value) { - return $ProviderOverride( - origin: this, - providerOverride: $SyncValueProvider(value), - ); - } -} - -String _$searchQueryHash() => r'62191c640ca9424065338a26c1af5c4695a46ef5'; - -/// Search query state provider -/// Manages the current search query string for product filtering - -abstract class _$SearchQuery extends $Notifier { - String build(); - @$mustCallSuper - @override - void runBuild() { - final created = build(); - final ref = this.ref as $Ref; - final element = - ref.element - as $ClassProviderElement< - AnyNotifier, - String, - Object?, - Object? - >; - element.handleValue(ref, created); - } -} diff --git a/pubspec.yaml b/pubspec.yaml index 1786f07..2682744 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,8 +64,8 @@ dependencies: equatable: ^2.0.7 dartz: ^0.10.1 - # Dependency Injection - get_it: ^8.0.4 + # Note: Dependency Injection is handled by Riverpod (flutter_riverpod above) + # No need for GetIt or other DI packages dev_dependencies: flutter_test: