diff --git a/CLAUDE.md b/CLAUDE.md index a6b98c7..41906f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2071,7 +2071,7 @@ end 2. **Design Data Layer**: Models, repositories, data sources 3. **Implement Domain Layer**: Entities, use cases 4. **Create Providers**: Riverpod state management -5. **Build UI**: Match HTML reference design +5. **Build UI**: Match HTML (in 375px width) reference design 6. **Test**: Unit, widget, integration tests 7. **Optimize**: Performance profiling diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/app.dart b/lib/app.dart index 3b12f3b..385a3a8 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -117,227 +117,3 @@ class _AppBuilder extends ConsumerWidget { } } -/// Placeholder home page -/// -/// This is a temporary home screen that will be replaced with the actual -/// home page implementation from features/home/presentation/pages/home_page.dart -/// -/// The actual home page will include: -/// - Membership card display (Diamond/Platinum/Gold tiers) -/// - Quick action grid -/// - Bottom navigation bar -/// - Floating action button for chat -class _PlaceholderHomePage extends ConsumerWidget { - const _PlaceholderHomePage(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - - return Scaffold( - appBar: AppBar( - title: const Text('Worker App'), - centerTitle: true, - ), - body: Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // App logo placeholder - Container( - width: 120, - height: 120, - decoration: BoxDecoration( - color: theme.colorScheme.primary, - borderRadius: BorderRadius.circular(24), - ), - child: Icon( - Icons.business_center, - size: 64, - color: theme.colorScheme.onPrimary, - ), - ), - const SizedBox(height: 32), - - // Welcome text - Text( - 'Chào mừng đến với Worker App', - style: theme.textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.onSurface, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - - // Description - Text( - 'Ứng dụng dành cho thầu thợ, kiến trúc sư, đại lý và môi giới', - style: theme.textTheme.bodyLarge?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.7), - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 48), - - // Status indicators - const _StatusIndicator( - icon: Icons.check_circle, - color: Colors.green, - label: 'Hive Database: Initialized', - ), - const SizedBox(height: 12), - const _StatusIndicator( - icon: Icons.check_circle, - color: Colors.green, - label: 'Riverpod: Active', - ), - const SizedBox(height: 12), - const _StatusIndicator( - icon: Icons.check_circle, - color: Colors.green, - label: 'Material 3 Theme: Loaded', - ), - const SizedBox(height: 48), - - // Next steps card - Card( - child: Padding( - padding: const EdgeInsets.all(20.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon( - Icons.info_outline, - color: theme.colorScheme.primary, - ), - const SizedBox(width: 12), - Text( - 'Next Steps', - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 16), - const _NextStepItem( - number: '1', - text: 'Implement authentication flow', - ), - const SizedBox(height: 8), - const _NextStepItem( - number: '2', - text: 'Create home page with membership cards', - ), - const SizedBox(height: 8), - const _NextStepItem( - number: '3', - text: 'Set up navigation and routing', - ), - const SizedBox(height: 8), - const _NextStepItem( - number: '4', - text: 'Implement feature modules', - ), - ], - ), - ), - ), - ], - ), - ), - ), - - // Floating action button (will be used for chat) - floatingActionButton: FloatingActionButton( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Chat feature coming soon!'), - behavior: SnackBarBehavior.floating, - ), - ); - }, - child: const Icon(Icons.chat_bubble_outline), - ), - ); - } -} - -/// Status indicator widget -class _StatusIndicator extends StatelessWidget { - const _StatusIndicator({ - required this.icon, - required this.color, - required this.label, - }); - - final IconData icon; - final Color color; - final String label; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(icon, color: color, size: 20), - const SizedBox(width: 8), - Text( - label, - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ); - } -} - -/// Next step item widget -class _NextStepItem extends StatelessWidget { - const _NextStepItem({ - required this.number, - required this.text, - }); - - final String number; - final String text; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Row( - children: [ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Center( - child: Text( - number, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - text, - style: theme.textTheme.bodyMedium, - ), - ), - ], - ); - } -} diff --git a/lib/core/constants/ui_constants.dart b/lib/core/constants/ui_constants.dart index 4e0d935..10c71a8 100644 --- a/lib/core/constants/ui_constants.dart +++ b/lib/core/constants/ui_constants.dart @@ -394,19 +394,19 @@ class GridSpecs { static const int productGridColumns = 2; /// Product grid cross axis spacing - static const double productGridCrossSpacing = AppSpacing.md; + static const double productGridCrossSpacing = AppSpacing.xs; /// Product grid main axis spacing - static const double productGridMainSpacing = AppSpacing.md; + static const double productGridMainSpacing = AppSpacing.xs; /// Quick action grid cross axis count static const int quickActionColumns = 3; /// Quick action grid cross axis spacing - static const double quickActionCrossSpacing = AppSpacing.md; + static const double quickActionCrossSpacing = AppSpacing.sm; /// Quick action grid main axis spacing - static const double quickActionMainSpacing = AppSpacing.md; + static const double quickActionMainSpacing = AppSpacing.sm; } /// List specifications diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 0b9200b..4b450c3 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -7,6 +7,7 @@ library; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:worker/features/home/presentation/pages/home_page.dart'; +import 'package:worker/features/products/presentation/pages/products_page.dart'; /// App Router /// @@ -34,16 +35,17 @@ class AppRouter { ), ), + // Products Route + GoRoute( + path: RouteNames.products, + name: RouteNames.products, + pageBuilder: (context, state) => MaterialPage( + key: state.pageKey, + child: const ProductsPage(), + ), + ), + // TODO: Add more routes as features are implemented - // Example: - // GoRoute( - // path: RouteNames.products, - // name: RouteNames.products, - // pageBuilder: (context, state) => MaterialPage( - // key: state.pageKey, - // child: const ProductsPage(), - // ), - // ), ], // Error page for unknown routes diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index b649744..d00fef8 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -6,6 +6,8 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:worker/core/router/app_router.dart'; import 'package:worker/core/theme/colors.dart'; import 'package:worker/features/home/presentation/providers/member_card_provider.dart'; import 'package:worker/features/home/presentation/providers/promotions_provider.dart'; @@ -37,224 +39,223 @@ class HomePage extends ConsumerWidget { final promotionsAsync = ref.watch(promotionsProvider); return Scaffold( - extendBodyBehindAppBar: true, // allow body to render behind status bar - backgroundColor: AppColors.grey50, - body: MediaQuery.removePadding( - context: context, - removeTop: true, - child: RefreshIndicator( - onRefresh: () async { - // Refresh both member card and promotions - await Future.wait([ - ref.read(memberCardProvider.notifier).refresh(), - ref.read(promotionsProvider.notifier).refresh(), - ]); - }, - child: CustomScrollView( - slivers: [ - // App Bar - // SliverAppBar( - // floating: true, - // snap: true, - // backgroundColor: AppColors.primaryBlue, - // title: Text(l10n.home), - // centerTitle: true, - // ), + body: RefreshIndicator( + onRefresh: () async { + // Refresh both member card and promotions + await Future.wait([ + ref.read(memberCardProvider.notifier).refresh(), + ref.read(promotionsProvider.notifier).refresh(), + ]); + }, + child: CustomScrollView( + slivers: [ + // Add top padding for status bar + SliverPadding( + padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), + ), - // Member Card Section - SliverToBoxAdapter( - child: memberCardAsync.when( - data: (memberCard) => MemberCardWidget(memberCard: memberCard), - loading: () => Container( - margin: const EdgeInsets.all(16), - height: 200, - decoration: BoxDecoration( - color: AppColors.grey100, - borderRadius: BorderRadius.circular(16), - ), - child: const Center( - child: CircularProgressIndicator(), - ), + // Member Card Section + SliverToBoxAdapter( + child: memberCardAsync.when( + data: (memberCard) => MemberCardWidget(memberCard: memberCard), + loading: () => Container( + margin: const EdgeInsets.all(16), + height: 200, + decoration: BoxDecoration( + color: AppColors.grey100, + borderRadius: BorderRadius.circular(16), ), - error: (error, stack) => Container( - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.danger.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(16), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.error_outline, + child: const Center(child: CircularProgressIndicator()), + ), + error: (error, stack) => Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.danger.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.error_outline, + color: AppColors.danger, + size: 48, + ), + const SizedBox(height: 8), + Text( + l10n.error, + style: const TextStyle( color: AppColors.danger, - size: 48, + fontWeight: FontWeight.w600, ), - const SizedBox(height: 8), - Text( - l10n.error, - style: const TextStyle( - color: AppColors.danger, - fontWeight: FontWeight.w600, - ), + ), + const SizedBox(height: 4), + Text( + error.toString(), + style: const TextStyle( + color: AppColors.grey500, + fontSize: 12, ), - const SizedBox(height: 4), - Text( - error.toString(), - style: const TextStyle( - color: AppColors.grey500, - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - ], - ), + textAlign: TextAlign.center, + ), + ], ), ), ), + ), - // Promotions Section - SliverToBoxAdapter( - child: promotionsAsync.when( - data: (promotions) => promotions.isNotEmpty - ? Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: PromotionSlider( - promotions: promotions, - onPromotionTap: (promotion) { - // TODO: Navigate to promotion details - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('${l10n.viewDetails}: ${promotion.title}'), + // Promotions Section + SliverToBoxAdapter( + child: promotionsAsync.when( + data: (promotions) => promotions.isNotEmpty + ? Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: PromotionSlider( + promotions: promotions, + onPromotionTap: (promotion) { + // TODO: Navigate to promotion details + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${l10n.viewDetails}: ${promotion.title}', ), - ); - }, - ), - ) - : const SizedBox.shrink(), - loading: () => const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), + ), + ); + }, + ), + ) + : const SizedBox.shrink(), + loading: () => const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + error: (error, stack) => const SizedBox.shrink(), + ), + ), + + // Quick Action Sections + SliverToBoxAdapter( + child: Column( + children: [ + const SizedBox(height: 8), + // Products & Cart Section + QuickActionSection( + title: '${l10n.products} & ${l10n.cart}', + actions: [ + QuickAction( + icon: Icons.grid_view, + label: l10n.products, + onTap: () => context.go(RouteNames.products), + ), + QuickAction( + icon: Icons.shopping_cart, + label: l10n.cart, + badge: '3', + onTap: () => _showComingSoon(context, l10n.cart, l10n), + ), + QuickAction( + icon: Icons.favorite, + label: 'Yêu thích', + onTap: () => + _showComingSoon(context, 'Yêu thích', l10n), + ), + ], ), - error: (error, stack) => const SizedBox.shrink(), - ), + + // Loyalty Section + QuickActionSection( + title: l10n.loyalty, + actions: [ + QuickAction( + icon: Icons.card_giftcard, + label: l10n.redeemReward, + onTap: () => + _showComingSoon(context, l10n.redeemReward, l10n), + ), + QuickAction( + icon: Icons.history, + label: l10n.pointsHistory, + onTap: () => + _showComingSoon(context, l10n.pointsHistory, l10n), + ), + QuickAction( + icon: Icons.person_add, + label: l10n.referral, + onTap: () => + _showComingSoon(context, l10n.referral, l10n), + ), + ], + ), + + // Quote Requests Section + QuickActionSection( + title: l10n.quotes, + actions: [ + QuickAction( + icon: Icons.description, + label: l10n.quotes, + onTap: () => + _showComingSoon(context, l10n.quotes, l10n), + ), + QuickAction( + icon: Icons.receipt_long, + label: l10n.quotes, + onTap: () => + _showComingSoon(context, l10n.quotes, l10n), + ), + ], + ), + + // Orders & Payments Section + QuickActionSection( + title: '${l10n.orders} & ${l10n.payments}', + actions: [ + QuickAction( + icon: Icons.inventory_2, + label: l10n.orders, + onTap: () => + _showComingSoon(context, l10n.orders, l10n), + ), + QuickAction( + icon: Icons.payment, + label: l10n.payments, + onTap: () => + _showComingSoon(context, l10n.payments, l10n), + ), + ], + ), + + // Sample Houses & News Section + QuickActionSection( + title: l10n.projects, + actions: [ + QuickAction( + icon: Icons.home_work, + label: 'Nhà mẫu', + onTap: () => _showComingSoon(context, 'Nhà mẫu', l10n), + ), + QuickAction( + icon: Icons.business, + label: l10n.projects, + onTap: () => + _showComingSoon(context, l10n.projects, l10n), + ), + QuickAction( + icon: Icons.article, + label: 'Tin tức', + onTap: () => _showComingSoon(context, 'Tin tức', l10n), + ), + ], + ), + + // Bottom Padding (for FAB clearance) + const SizedBox(height: 80), + ], ), - - // Quick Action Sections - SliverToBoxAdapter( - child: Column( - children: [ - const SizedBox(height: 8), - // Products & Cart Section - QuickActionSection( - title: '${l10n.products} & ${l10n.cart}', - actions: [ - QuickAction( - icon: Icons.grid_view, - label: l10n.products, - onTap: () => _showComingSoon(context, l10n.products, l10n), - ), - QuickAction( - icon: Icons.shopping_cart, - label: l10n.cart, - badge: '3', - onTap: () => _showComingSoon(context, l10n.cart, l10n), - ), - QuickAction( - icon: Icons.favorite, - label: 'Yêu thích', - onTap: () => _showComingSoon(context, 'Yêu thích', l10n), - ), - ], - ), - - // Loyalty Section - QuickActionSection( - title: l10n.loyalty, - actions: [ - QuickAction( - icon: Icons.card_giftcard, - label: l10n.redeemReward, - onTap: () => _showComingSoon(context, l10n.redeemReward, l10n), - ), - QuickAction( - icon: Icons.history, - label: l10n.pointsHistory, - onTap: () => _showComingSoon(context, l10n.pointsHistory, l10n), - ), - QuickAction( - icon: Icons.person_add, - label: l10n.referral, - onTap: () => _showComingSoon(context, l10n.referral, l10n), - ), - ], - ), - - // Quote Requests Section - QuickActionSection( - title: l10n.quotes, - actions: [ - QuickAction( - icon: Icons.description, - label: l10n.quotes, - onTap: () => _showComingSoon(context, l10n.quotes, l10n), - ), - QuickAction( - icon: Icons.receipt_long, - label: l10n.quotes, - onTap: () => _showComingSoon(context, l10n.quotes, l10n), - ), - ], - ), - - // Orders & Payments Section - QuickActionSection( - title: '${l10n.orders} & ${l10n.payments}', - actions: [ - QuickAction( - icon: Icons.inventory_2, - label: l10n.orders, - onTap: () => _showComingSoon(context, l10n.orders, l10n), - ), - QuickAction( - icon: Icons.payment, - label: l10n.payments, - onTap: () => _showComingSoon(context, l10n.payments, l10n), - ), - ], - ), - - // Sample Houses & News Section - QuickActionSection( - title: l10n.projects, - actions: [ - QuickAction( - icon: Icons.home_work, - label: 'Nhà mẫu', - onTap: () => _showComingSoon(context, 'Nhà mẫu', l10n), - ), - QuickAction( - icon: Icons.business, - label: l10n.projects, - onTap: () => _showComingSoon(context, l10n.projects, l10n), - ), - QuickAction( - icon: Icons.article, - label: 'Tin tức', - onTap: () => _showComingSoon(context, 'Tin tức', l10n), - ), - ], - ), - - // Bottom Padding (for FAB clearance) - const SizedBox(height: 80), - ], - ), - ), - ], - ), + ), + ], ), ), @@ -310,7 +311,11 @@ class HomePage extends ConsumerWidget { } /// Show coming soon message - void _showComingSoon(BuildContext context, String feature, AppLocalizations l10n) { + void _showComingSoon( + BuildContext context, + String feature, + AppLocalizations l10n, + ) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('$feature - ${l10n.comingSoon}'), diff --git a/lib/features/home/presentation/widgets/quick_action_item.dart b/lib/features/home/presentation/widgets/quick_action_item.dart index 66131d2..624ff59 100644 --- a/lib/features/home/presentation/widgets/quick_action_item.dart +++ b/lib/features/home/presentation/widgets/quick_action_item.dart @@ -54,7 +54,7 @@ class QuickActionItem extends StatelessWidget { ), child: Icon( icon, - size: 28, + size: 20, color: AppColors.primaryBlue, ), ), diff --git a/lib/features/home/presentation/widgets/quick_action_section.dart b/lib/features/home/presentation/widgets/quick_action_section.dart index 303fb7b..9141908 100644 --- a/lib/features/home/presentation/widgets/quick_action_section.dart +++ b/lib/features/home/presentation/widgets/quick_action_section.dart @@ -75,8 +75,8 @@ class QuickActionSection extends StatelessWidget { gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, childAspectRatio: 1.0, - crossAxisSpacing: 8, - mainAxisSpacing: 8, + crossAxisSpacing: 4, + mainAxisSpacing: 4, ), itemCount: actions.length, itemBuilder: (context, index) { diff --git a/lib/features/products/README.md b/lib/features/products/README.md new file mode 100644 index 0000000..f23c932 --- /dev/null +++ b/lib/features/products/README.md @@ -0,0 +1,270 @@ +# Products Feature + +This feature implements the complete products catalog browsing functionality for the Worker mobile app using clean architecture principles. + +## Architecture + +The feature follows a clean architecture pattern with three distinct layers: + +### Domain Layer (`domain/`) + +Pure business logic with no dependencies on Flutter or data sources. + +- **Entities** (`entities/`): + - `Product`: Product business entity with price calculations, stock status, and discount logic + - `Category`: Product category for filtering + +- **Repositories** (`repositories/`): + - `ProductsRepository`: Abstract interface defining data operations + +- **Use Cases** (`usecases/`): + - `GetProducts`: Retrieve products with optional category filtering + - `SearchProducts`: Search products by query string + - `GetCategories`: Retrieve all product categories + +### Data Layer (`data/`) + +Handles data persistence and retrieval. + +- **Models** (`models/`): + - `ProductModel`: Hive-compatible product data model (Type ID: 20) + - `CategoryModel`: Hive-compatible category data model (Type ID: 21) + +- **Data Sources** (`datasources/`): + - `ProductsLocalDataSource`: Mock data provider with 10 sample products across 6 categories + +- **Repositories** (`repositories/`): + - `ProductsRepositoryImpl`: Concrete implementation of ProductsRepository + +### Presentation Layer (`presentation/`) + +UI components and state management. + +- **Providers** (`providers/`): + - `ProductsProvider`: Main provider for filtered products (category + search) + - `CategoriesProvider`: Categories list provider + - `SelectedCategoryProvider`: Current selected category state + - `SearchQueryProvider`: Current search query state + - `AllProductsProvider`: Unfiltered products provider + +- **Pages** (`pages/`): + - `ProductsPage`: Main products browsing page with search, filters, and grid + +- **Widgets** (`widgets/`): + - `ProductSearchBar`: Search input with clear button + - `CategoryFilterChips`: Horizontal scrolling category chips + - `ProductCard`: Product display card with image, price, stock status, and add to cart + - `ProductGrid`: 2-column responsive grid layout + +## Features + +### Product Display +- 2-column responsive grid layout +- Product image with caching (cached_network_image) +- Product name, SKU, and price +- Price per unit display (e.g., "450.000đ/m²") +- Sale price with discount badge +- Stock status indicator +- Low stock warning badge +- Add to cart button + +### Filtering & Search +- Real-time search across product name, SKU, and description +- Category filtering with 6 categories: + - Tất cả (All) + - Gạch lát nền (Floor tiles) + - Gạch ốp tường (Wall tiles) + - Gạch trang trí (Decorative tiles) + - Gạch ngoài trời (Outdoor tiles) + - Phụ kiện (Accessories) +- Combined search + category filtering + +### UI/UX Features +- Pull-to-refresh support +- Loading states with CircularProgressIndicator +- Error states with retry button +- Empty states with helpful messages +- Vietnamese localization +- Material 3 design system +- Responsive layout +- Optimized image caching +- Smooth scrolling performance + +## Mock Data + +The feature includes 10 sample products: + +1. **Gạch men cao cấp 60x60** - Premium glazed tiles (450.000đ/m²) +2. **Gạch granite nhập khẩu** - Imported granite (680.000đ/m², sale: 620.000đ) +3. **Gạch mosaic trang trí** - Decorative mosaic (320.000đ/m²) +4. **Gạch 3D họa tiết** - 3D patterned tiles (750.000đ/m², sale: 680.000đ) +5. **Gạch ceramic chống trượt** - Anti-slip ceramic (380.000đ/m²) +6. **Gạch terrazzo đá mài** - Terrazzo tiles (890.000đ/m², sale: 820.000đ) +7. **Gạch ốp tường bếp** - Kitchen wall tiles (280.000đ/m²) +8. **Gạch sân vườn chống rêu** - Anti-mold garden tiles (420.000đ/m², sale: 380.000đ) +9. **Keo dán gạch chuyên dụng** - Tile adhesive (180.000đ/bao) +10. **Keo chà ron màu** - Colored grout (120.000đ/bao, sale: 99.000đ) + +All products include: +- High-quality Unsplash images +- Detailed Vietnamese descriptions +- Brand attribution (Eurotile or Vasta Stone) +- Stock quantities +- Created timestamps + +## State Management + +The feature uses Riverpod 3.0 with code generation: + +```dart +// Watch products (filtered by category and search) +final productsAsync = ref.watch(productsProvider); + +// Update search query +ref.read(searchQueryProvider.notifier).updateQuery('gạch men'); + +// Update selected category +ref.read(selectedCategoryProvider.notifier).updateCategory('floor_tiles'); + +// Refresh products +await ref.read(productsProvider.notifier).refresh(); +``` + +## Navigation + +The products page is registered in the app router: + +```dart +context.goProducts(); // Navigate to products page +context.go(RouteNames.products); // Using route name +``` + +## Dependencies + +- `flutter_riverpod`: ^3.0.0 - State management +- `riverpod_annotation`: ^3.0.0 - Code generation +- `hive_ce`: ^2.6.0 - Local database +- `cached_network_image`: ^3.3.1 - Image caching +- `shimmer`: ^3.0.0 - Loading placeholders +- `intl`: ^0.20.0 - Vietnamese number formatting +- `go_router`: ^14.6.2 - Navigation + +## Future Enhancements + +- [ ] Product detail page +- [ ] Add to cart functionality +- [ ] Cart state management +- [ ] Product favorites/wishlist +- [ ] Advanced filters (price range, brand, etc.) +- [ ] Sort options (price, name, popularity) +- [ ] Load more / pagination +- [ ] Product comparison +- [ ] Recent viewed products +- [ ] Related products recommendations +- [ ] API integration (replace mock data) + +## Testing + +To run tests for this feature: + +```bash +# Unit tests +flutter test test/features/products/domain/ +flutter test test/features/products/data/ + +# Widget tests +flutter test test/features/products/presentation/widgets/ + +# Integration tests +flutter test integration_test/products_test.dart +``` + +## Code Generation + +After making changes to providers or models, run: + +```bash +dart run build_runner build --delete-conflicting-outputs +``` + +## File Structure + +``` +lib/features/products/ +├── domain/ +│ ├── entities/ +│ │ ├── product.dart +│ │ └── category.dart +│ ├── repositories/ +│ │ └── products_repository.dart +│ └── usecases/ +│ ├── get_products.dart +│ ├── search_products.dart +│ └── get_categories.dart +├── data/ +│ ├── models/ +│ │ ├── product_model.dart +│ │ ├── product_model.g.dart +│ │ ├── category_model.dart +│ │ └── category_model.g.dart +│ ├── datasources/ +│ │ └── products_local_datasource.dart +│ └── repositories/ +│ └── products_repository_impl.dart +├── presentation/ +│ ├── providers/ +│ │ ├── products_provider.dart +│ │ ├── products_provider.g.dart +│ │ ├── categories_provider.dart +│ │ ├── categories_provider.g.dart +│ │ ├── selected_category_provider.dart +│ │ ├── selected_category_provider.g.dart +│ │ ├── search_query_provider.dart +│ │ └── search_query_provider.g.dart +│ ├── pages/ +│ │ └── products_page.dart +│ └── widgets/ +│ ├── product_search_bar.dart +│ ├── category_filter_chips.dart +│ ├── product_card.dart +│ └── product_grid.dart +└── README.md +``` + +## Usage Example + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worker/features/products/presentation/pages/products_page.dart'; + +// Navigate to products page +void navigateToProducts(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ProductsPage()), + ); +} + +// Or using go_router +context.goProducts(); +``` + +## Contributing + +When adding new features to this module: + +1. Follow the clean architecture pattern +2. Add domain entities first +3. Implement repository interfaces +4. Create use cases for business logic +5. Add data models with Hive annotations +6. Implement data sources +7. Create Riverpod providers +8. Build UI widgets +9. Update this README +10. Add tests + +## License + +This feature is part of the Worker Mobile App project. diff --git a/lib/features/products/data/datasources/products_local_datasource.dart b/lib/features/products/data/datasources/products_local_datasource.dart new file mode 100644 index 0000000..8841ff0 --- /dev/null +++ b/lib/features/products/data/datasources/products_local_datasource.dart @@ -0,0 +1,294 @@ +/// Data Source: Products Local Data Source +/// +/// Provides mock product and category data for development. +/// In production, this would be replaced with actual API calls. +library; + +import 'package:worker/features/products/data/models/category_model.dart'; +import 'package:worker/features/products/data/models/product_model.dart'; + +/// Products Local Data Source Interface +abstract class ProductsLocalDataSource { + Future> getAllProducts(); + Future> searchProducts(String query); + Future> getProductsByCategory(String categoryId); + Future getProductById(String id); + Future> getCategories(); +} + +/// Products Local Data Source Implementation +/// +/// Provides mock data for products and categories. +/// Simulates async operations with delays. +class ProductsLocalDataSourceImpl implements ProductsLocalDataSource { + const ProductsLocalDataSourceImpl(); + + /// Mock categories data + static final List> _categoriesJson = [ + { + 'id': 'all', + 'name': 'Tất cả', + 'description': 'Tất cả sản phẩm', + 'icon': '📦', + 'order': 0, + }, + { + 'id': 'floor_tiles', + 'name': 'Gạch lát nền', + 'description': 'Gạch lát nền cao cấp', + 'icon': '🏠', + 'order': 1, + }, + { + 'id': 'wall_tiles', + 'name': 'Gạch ốp tường', + 'description': 'Gạch ốp tường chất lượng', + 'icon': '🧱', + 'order': 2, + }, + { + 'id': 'decorative_tiles', + 'name': 'Gạch trang trí', + 'description': 'Gạch trang trí nghệ thuật', + 'icon': '✨', + 'order': 3, + }, + { + 'id': 'outdoor_tiles', + 'name': 'Gạch ngoài trời', + 'description': 'Gạch chống trượt ngoài trời', + 'icon': '🌳', + 'order': 4, + }, + { + 'id': 'accessories', + 'name': 'Phụ kiện', + 'description': 'Phụ kiện xây dựng', + 'icon': '🔧', + 'order': 5, + }, + ]; + + /// Mock products data + static final List> _productsJson = [ + { + 'id': 'prod_001', + 'name': 'Gạch men cao cấp 60x60', + 'sku': 'GM-60-001', + 'description': 'Gạch men bóng kiếng cao cấp, chống trượt, độ bền cao. Phù hợp cho phòng khách, phòng ngủ.', + 'price': 450000.0, + 'unit': 'm²', + 'imageUrl': 'https://images.unsplash.com/photo-1615971677499-5467cbfe1f10?w=400', + 'categoryId': 'floor_tiles', + 'inStock': true, + 'stockQuantity': 150, + 'createdAt': '2024-01-15T08:00:00Z', + 'salePrice': null, + 'brand': 'Eurotile', + }, + { + 'id': 'prod_002', + 'name': 'Gạch granite nhập khẩu', + 'sku': 'GR-80-002', + 'description': 'Gạch granite nhập khẩu Tây Ban Nha, vân đá tự nhiên, sang trọng. Kích thước 80x80cm.', + 'price': 680000.0, + 'unit': 'm²', + 'imageUrl': 'https://images.unsplash.com/photo-1565183928294-7d22e855a326?w=400', + 'categoryId': 'floor_tiles', + 'inStock': true, + 'stockQuantity': 80, + 'createdAt': '2024-01-20T10:30:00Z', + 'salePrice': 620000.0, + 'brand': 'Vasta Stone', + }, + { + 'id': 'prod_003', + 'name': 'Gạch mosaic trang trí', + 'sku': 'MS-30-003', + 'description': 'Gạch mosaic thủy tinh màu sắc đa dạng, tạo điểm nhấn cho không gian. Kích thước 30x30cm.', + 'price': 320000.0, + 'unit': 'm²', + 'imageUrl': 'https://images.unsplash.com/photo-1604709177225-055f99402ea3?w=400', + 'categoryId': 'decorative_tiles', + 'inStock': true, + 'stockQuantity': 45, + 'createdAt': '2024-02-01T14:15:00Z', + 'salePrice': null, + 'brand': 'Eurotile', + }, + { + 'id': 'prod_004', + 'name': 'Gạch 3D họa tiết', + 'sku': '3D-60-004', + 'description': 'Gạch 3D với họa tiết nổi độc đáo, tạo hiệu ứng thị giác ấn tượng cho tường phòng khách.', + 'price': 750000.0, + 'unit': 'm²', + 'imageUrl': 'https://images.unsplash.com/photo-1600585152220-90363fe7e115?w=400', + 'categoryId': 'wall_tiles', + 'inStock': true, + 'stockQuantity': 30, + 'createdAt': '2024-02-10T09:00:00Z', + 'salePrice': 680000.0, + 'brand': 'Vasta Stone', + }, + { + 'id': 'prod_005', + 'name': 'Gạch ceramic chống trượt', + 'sku': 'CR-40-005', + 'description': 'Gạch ceramic chống trượt cấp độ R11, an toàn cho phòng tắm và ban công. Kích thước 40x40cm.', + 'price': 380000.0, + 'unit': 'm²', + 'imageUrl': 'https://images.unsplash.com/photo-1600607687939-ce8a6c25118c?w=400', + 'categoryId': 'outdoor_tiles', + 'inStock': true, + 'stockQuantity': 8, + 'createdAt': '2024-02-15T11:20:00Z', + 'salePrice': null, + 'brand': 'Eurotile', + }, + { + 'id': 'prod_006', + 'name': 'Gạch terrazzo đá mài', + 'sku': 'TZ-60-006', + 'description': 'Gạch terrazzo phong cách retro, đá mài hạt màu, độc đáo và bền đẹp theo thời gian.', + 'price': 890000.0, + 'unit': 'm²', + 'imageUrl': 'https://images.unsplash.com/photo-1600566753190-17f0baa2a6c3?w=400', + 'categoryId': 'decorative_tiles', + 'inStock': true, + 'stockQuantity': 25, + 'createdAt': '2024-02-20T15:45:00Z', + 'salePrice': 820000.0, + 'brand': 'Vasta Stone', + }, + { + 'id': 'prod_007', + 'name': 'Gạch ốp tường bếp', + 'sku': 'OT-30-007', + 'description': 'Gạch ốp tường nhà bếp, dễ lau chùi, chống thấm tốt. Kích thước 30x60cm.', + 'price': 280000.0, + 'unit': 'm²', + 'imageUrl': 'https://images.unsplash.com/photo-1600047509807-ba8f99d2cdde?w=400', + 'categoryId': 'wall_tiles', + 'inStock': true, + 'stockQuantity': 120, + 'createdAt': '2024-03-01T08:30:00Z', + 'salePrice': null, + 'brand': 'Eurotile', + }, + { + 'id': 'prod_008', + 'name': 'Gạch sân vườn chống rêu', + 'sku': 'SV-50-008', + 'description': 'Gạch lát sân vườn chống rêu mốc, bền với thời tiết. Kích thước 50x50cm.', + 'price': 420000.0, + 'unit': 'm²', + 'imageUrl': 'https://images.unsplash.com/photo-1600566752355-35792bedcfea?w=400', + 'categoryId': 'outdoor_tiles', + 'inStock': true, + 'stockQuantity': 65, + 'createdAt': '2024-03-05T10:00:00Z', + 'salePrice': 380000.0, + 'brand': 'Vasta Stone', + }, + { + 'id': 'prod_009', + 'name': 'Keo dán gạch chuyên dụng', + 'sku': 'ACC-KD-009', + 'description': 'Keo dán gạch chất lượng cao, độ bám dính mạnh, chống thấm. Bao 25kg.', + 'price': 180000.0, + 'unit': 'bao', + 'imageUrl': 'https://images.unsplash.com/photo-1581094794329-c8112a89af12?w=400', + 'categoryId': 'accessories', + 'inStock': true, + 'stockQuantity': 200, + 'createdAt': '2024-03-10T13:15:00Z', + 'salePrice': null, + 'brand': 'Eurotile', + }, + { + 'id': 'prod_010', + 'name': 'Keo chà ron màu', + 'sku': 'ACC-KCR-010', + 'description': 'Keo chà ron gạch nhiều màu sắc, chống thấm, chống nấm mốc. Bao 5kg.', + 'price': 120000.0, + 'unit': 'bao', + 'imageUrl': 'https://images.unsplash.com/photo-1621905251918-48416bd8575a?w=400', + 'categoryId': 'accessories', + 'inStock': true, + 'stockQuantity': 150, + 'createdAt': '2024-03-15T09:45:00Z', + 'salePrice': 99000.0, + 'brand': 'Vasta Stone', + }, + ]; + + @override + Future> getAllProducts() async { + // Simulate network delay + await Future.delayed(const Duration(milliseconds: 500)); + + return _productsJson + .map((json) => ProductModel.fromJson(json)) + .toList(); + } + + @override + Future> searchProducts(String query) async { + // Simulate network delay + await Future.delayed(const Duration(milliseconds: 300)); + + final lowercaseQuery = query.toLowerCase(); + + final filtered = _productsJson.where((product) { + final name = (product['name'] as String).toLowerCase(); + final sku = (product['sku'] as String).toLowerCase(); + final description = (product['description'] as String).toLowerCase(); + + return name.contains(lowercaseQuery) || + sku.contains(lowercaseQuery) || + description.contains(lowercaseQuery); + }).toList(); + + return filtered.map((json) => ProductModel.fromJson(json)).toList(); + } + + @override + Future> getProductsByCategory(String categoryId) async { + // Simulate network delay + await Future.delayed(const Duration(milliseconds: 400)); + + if (categoryId == 'all') { + return getAllProducts(); + } + + final filtered = _productsJson + .where((product) => product['categoryId'] == categoryId) + .toList(); + + return filtered.map((json) => ProductModel.fromJson(json)).toList(); + } + + @override + Future getProductById(String id) async { + // Simulate network delay + await Future.delayed(const Duration(milliseconds: 200)); + + final productJson = _productsJson.firstWhere( + (product) => product['id'] == id, + orElse: () => throw Exception('Product not found: $id'), + ); + + return ProductModel.fromJson(productJson); + } + + @override + Future> getCategories() async { + // Simulate network delay + await Future.delayed(const Duration(milliseconds: 300)); + + return _categoriesJson + .map((json) => CategoryModel.fromJson(json)) + .toList(); + } +} diff --git a/lib/features/products/data/models/category_model.dart b/lib/features/products/data/models/category_model.dart new file mode 100644 index 0000000..6a038bf --- /dev/null +++ b/lib/features/products/data/models/category_model.dart @@ -0,0 +1,131 @@ +/// Data Model: Category +/// +/// Data Transfer Object for category information. +library; + +import 'package:hive_ce/hive.dart'; +import 'package:worker/features/products/domain/entities/category.dart'; + +part 'category_model.g.dart'; + +/// Category Model +/// +/// Used for: +/// - JSON serialization/deserialization +/// - Hive local database storage +/// - Converting to/from domain entity +/// +/// Hive Type ID: 12 +@HiveType(typeId: 12) +class CategoryModel extends HiveObject { + /// Unique identifier + @HiveField(0) + final String id; + + /// Category name + @HiveField(1) + final String name; + + /// Category description + @HiveField(2) + final String description; + + /// Icon name or emoji + @HiveField(3) + final String icon; + + /// Display order + @HiveField(4) + final int order; + + CategoryModel({ + required this.id, + required this.name, + required this.description, + required this.icon, + required this.order, + }); + + /// From JSON constructor + factory CategoryModel.fromJson(Map json) { + return CategoryModel( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String, + icon: json['icon'] as String, + order: json['order'] as int, + ); + } + + /// To JSON method + Map toJson() { + return { + 'id': id, + 'name': name, + 'description': description, + 'icon': icon, + 'order': order, + }; + } + + /// Convert to domain entity + Category toEntity() { + return Category( + id: id, + name: name, + description: description, + icon: icon, + order: order, + ); + } + + /// Create from domain entity + factory CategoryModel.fromEntity(Category entity) { + return CategoryModel( + id: entity.id, + name: entity.name, + description: entity.description, + icon: entity.icon, + order: entity.order, + ); + } + + /// Copy with method + CategoryModel copyWith({ + String? id, + String? name, + String? description, + String? icon, + int? order, + }) { + return CategoryModel( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + icon: icon ?? this.icon, + order: order ?? this.order, + ); + } + + @override + String toString() { + return 'CategoryModel(id: $id, name: $name, order: $order)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is CategoryModel && + other.id == id && + other.name == name && + other.description == description && + other.icon == icon && + other.order == order; + } + + @override + int get hashCode { + return Object.hash(id, name, description, icon, order); + } +} diff --git a/lib/features/products/data/models/category_model.g.dart b/lib/features/products/data/models/category_model.g.dart new file mode 100644 index 0000000..c5226df --- /dev/null +++ b/lib/features/products/data/models/category_model.g.dart @@ -0,0 +1,53 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'category_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class CategoryModelAdapter extends TypeAdapter { + @override + final typeId = 12; + + @override + CategoryModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return CategoryModel( + id: fields[0] as String, + name: fields[1] as String, + description: fields[2] as String, + icon: fields[3] as String, + order: (fields[4] as num).toInt(), + ); + } + + @override + void write(BinaryWriter writer, CategoryModel obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.name) + ..writeByte(2) + ..write(obj.description) + ..writeByte(3) + ..write(obj.icon) + ..writeByte(4) + ..write(obj.order); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CategoryModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/features/products/data/models/product_model.dart b/lib/features/products/data/models/product_model.dart new file mode 100644 index 0000000..dd62e02 --- /dev/null +++ b/lib/features/products/data/models/product_model.dart @@ -0,0 +1,242 @@ +/// Data Model: Product +/// +/// Data Transfer Object for product information. +/// Handles JSON and Hive serialization/deserialization. +library; + +import 'package:hive_ce/hive.dart'; +import 'package:worker/features/products/domain/entities/product.dart'; + +part 'product_model.g.dart'; + +/// Product Model +/// +/// Used for: +/// - JSON serialization/deserialization +/// - Hive local database storage +/// - Converting to/from domain entity +/// +/// Hive Type ID: 1 +@HiveType(typeId: 1) +class ProductModel extends HiveObject { + /// Unique identifier + @HiveField(0) + final String id; + + /// Product name + @HiveField(1) + final String name; + + /// Product SKU + @HiveField(2) + final String sku; + + /// Product description + @HiveField(3) + final String description; + + /// Price per unit (VND) + @HiveField(4) + final double price; + + /// Unit of measurement + @HiveField(5) + final String unit; + + /// Product image URL + @HiveField(6) + final String imageUrl; + + /// Category ID + @HiveField(7) + final String categoryId; + + /// Stock availability + @HiveField(8) + final bool inStock; + + /// Stock quantity + @HiveField(9) + final int stockQuantity; + + /// Created date (ISO8601 string) + @HiveField(10) + final String createdAt; + + /// Sale price (optional) + @HiveField(11) + final double? salePrice; + + /// Brand name (optional) + @HiveField(12) + final String? brand; + + ProductModel({ + required this.id, + required this.name, + required this.sku, + required this.description, + required this.price, + required this.unit, + required this.imageUrl, + required this.categoryId, + required this.inStock, + required this.stockQuantity, + required this.createdAt, + this.salePrice, + this.brand, + }); + + /// From JSON constructor + factory ProductModel.fromJson(Map json) { + return ProductModel( + id: json['id'] as String, + name: json['name'] as String, + sku: json['sku'] as String, + description: json['description'] as String, + price: (json['price'] as num).toDouble(), + unit: json['unit'] as String, + imageUrl: json['imageUrl'] as String, + categoryId: json['categoryId'] as String, + inStock: json['inStock'] as bool, + stockQuantity: json['stockQuantity'] as int, + createdAt: json['createdAt'] as String, + salePrice: json['salePrice'] != null ? (json['salePrice'] as num).toDouble() : null, + brand: json['brand'] as String?, + ); + } + + /// To JSON method + Map toJson() { + return { + 'id': id, + 'name': name, + 'sku': sku, + 'description': description, + 'price': price, + 'unit': unit, + 'imageUrl': imageUrl, + 'categoryId': categoryId, + 'inStock': inStock, + 'stockQuantity': stockQuantity, + 'createdAt': createdAt, + 'salePrice': salePrice, + 'brand': brand, + }; + } + + /// Convert to domain entity + Product toEntity() { + return Product( + id: id, + name: name, + sku: sku, + description: description, + price: price, + unit: unit, + imageUrl: imageUrl, + categoryId: categoryId, + inStock: inStock, + stockQuantity: stockQuantity, + createdAt: DateTime.parse(createdAt), + salePrice: salePrice, + brand: brand, + ); + } + + /// Create from domain entity + factory ProductModel.fromEntity(Product entity) { + return ProductModel( + id: entity.id, + name: entity.name, + sku: entity.sku, + description: entity.description, + price: entity.price, + unit: entity.unit, + imageUrl: entity.imageUrl, + categoryId: entity.categoryId, + inStock: entity.inStock, + stockQuantity: entity.stockQuantity, + createdAt: entity.createdAt.toIso8601String(), + salePrice: entity.salePrice, + brand: entity.brand, + ); + } + + /// Copy with method + ProductModel copyWith({ + String? id, + String? name, + String? sku, + String? description, + double? price, + String? unit, + String? imageUrl, + String? categoryId, + bool? inStock, + int? stockQuantity, + String? createdAt, + double? salePrice, + String? brand, + }) { + return ProductModel( + id: id ?? this.id, + name: name ?? this.name, + sku: sku ?? this.sku, + description: description ?? this.description, + price: price ?? this.price, + unit: unit ?? this.unit, + imageUrl: imageUrl ?? this.imageUrl, + categoryId: categoryId ?? this.categoryId, + inStock: inStock ?? this.inStock, + stockQuantity: stockQuantity ?? this.stockQuantity, + createdAt: createdAt ?? this.createdAt, + salePrice: salePrice ?? this.salePrice, + brand: brand ?? this.brand, + ); + } + + @override + String toString() { + return 'ProductModel(id: $id, name: $name, sku: $sku, price: $price, unit: $unit)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ProductModel && + other.id == id && + other.name == name && + other.sku == sku && + other.description == description && + other.price == price && + other.unit == unit && + other.imageUrl == imageUrl && + other.categoryId == categoryId && + other.inStock == inStock && + other.stockQuantity == stockQuantity && + other.createdAt == createdAt && + other.salePrice == salePrice && + other.brand == brand; + } + + @override + int get hashCode { + return Object.hash( + id, + name, + sku, + description, + price, + unit, + imageUrl, + categoryId, + inStock, + stockQuantity, + createdAt, + salePrice, + brand, + ); + } +} diff --git a/lib/features/products/data/models/product_model.g.dart b/lib/features/products/data/models/product_model.g.dart new file mode 100644 index 0000000..43f6cf9 --- /dev/null +++ b/lib/features/products/data/models/product_model.g.dart @@ -0,0 +1,77 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'product_model.dart'; + +// ************************************************************************** +// TypeAdapterGenerator +// ************************************************************************** + +class ProductModelAdapter extends TypeAdapter { + @override + final typeId = 1; + + @override + ProductModel read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return ProductModel( + id: fields[0] as String, + name: fields[1] as String, + sku: fields[2] as String, + description: fields[3] as String, + price: (fields[4] as num).toDouble(), + unit: fields[5] as String, + imageUrl: fields[6] as String, + categoryId: fields[7] as String, + inStock: fields[8] as bool, + stockQuantity: (fields[9] as num).toInt(), + createdAt: fields[10] as String, + salePrice: (fields[11] as num?)?.toDouble(), + brand: fields[12] as String?, + ); + } + + @override + void write(BinaryWriter writer, ProductModel obj) { + writer + ..writeByte(13) + ..writeByte(0) + ..write(obj.id) + ..writeByte(1) + ..write(obj.name) + ..writeByte(2) + ..write(obj.sku) + ..writeByte(3) + ..write(obj.description) + ..writeByte(4) + ..write(obj.price) + ..writeByte(5) + ..write(obj.unit) + ..writeByte(6) + ..write(obj.imageUrl) + ..writeByte(7) + ..write(obj.categoryId) + ..writeByte(8) + ..write(obj.inStock) + ..writeByte(9) + ..write(obj.stockQuantity) + ..writeByte(10) + ..write(obj.createdAt) + ..writeByte(11) + ..write(obj.salePrice) + ..writeByte(12) + ..write(obj.brand); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProductModelAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/features/products/data/repositories/products_repository_impl.dart b/lib/features/products/data/repositories/products_repository_impl.dart new file mode 100644 index 0000000..ed5bf77 --- /dev/null +++ b/lib/features/products/data/repositories/products_repository_impl.dart @@ -0,0 +1,72 @@ +/// Repository Implementation: Products Repository +/// +/// Concrete implementation of the products repository interface. +/// Handles data from local datasource and converts to domain entities. +library; + +import 'package:worker/features/products/data/datasources/products_local_datasource.dart'; +import 'package:worker/features/products/domain/entities/category.dart'; +import 'package:worker/features/products/domain/entities/product.dart'; +import 'package:worker/features/products/domain/repositories/products_repository.dart'; + +/// Products Repository Implementation +/// +/// Implements the repository interface defined in the domain layer. +/// Coordinates data from local datasource and converts models to entities. +class ProductsRepositoryImpl implements ProductsRepository { + final ProductsLocalDataSource localDataSource; + + const ProductsRepositoryImpl({ + required this.localDataSource, + }); + + @override + Future> getAllProducts() async { + try { + final productModels = await localDataSource.getAllProducts(); + return productModels.map((model) => model.toEntity()).toList(); + } catch (e) { + throw Exception('Failed to get products: $e'); + } + } + + @override + Future> searchProducts(String query) async { + try { + final productModels = await localDataSource.searchProducts(query); + return productModels.map((model) => model.toEntity()).toList(); + } catch (e) { + throw Exception('Failed to search products: $e'); + } + } + + @override + Future> getProductsByCategory(String categoryId) async { + try { + final productModels = await localDataSource.getProductsByCategory(categoryId); + return productModels.map((model) => model.toEntity()).toList(); + } catch (e) { + throw Exception('Failed to get products by category: $e'); + } + } + + @override + Future getProductById(String id) async { + try { + final productModel = await localDataSource.getProductById(id); + return productModel.toEntity(); + } catch (e) { + throw Exception('Failed to get product: $e'); + } + } + + @override + Future> getCategories() async { + try { + final categoryModels = await localDataSource.getCategories(); + return categoryModels.map((model) => model.toEntity()).toList(); + } catch (e) { + throw Exception('Failed to get categories: $e'); + } + } +} diff --git a/lib/features/products/domain/entities/category.dart b/lib/features/products/domain/entities/category.dart new file mode 100644 index 0000000..be7b109 --- /dev/null +++ b/lib/features/products/domain/entities/category.dart @@ -0,0 +1,71 @@ +/// Domain Entity: Category +/// +/// Pure business entity representing a product category. +library; + +/// Category Entity +/// +/// Represents a product category for filtering and organization. +class Category { + /// Unique identifier + final String id; + + /// Category name (Vietnamese) + final String name; + + /// Category description + final String description; + + /// Icon name or emoji + final String icon; + + /// Display order + final int order; + + const Category({ + required this.id, + required this.name, + required this.description, + required this.icon, + required this.order, + }); + + /// Copy with method + Category copyWith({ + String? id, + String? name, + String? description, + String? icon, + int? order, + }) { + return Category( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + icon: icon ?? this.icon, + order: order ?? this.order, + ); + } + + @override + String toString() { + return 'Category(id: $id, name: $name, order: $order)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is Category && + other.id == id && + other.name == name && + other.description == description && + other.icon == icon && + other.order == order; + } + + @override + int get hashCode { + return Object.hash(id, name, description, icon, order); + } +} diff --git a/lib/features/products/domain/entities/product.dart b/lib/features/products/domain/entities/product.dart new file mode 100644 index 0000000..a3eafb8 --- /dev/null +++ b/lib/features/products/domain/entities/product.dart @@ -0,0 +1,156 @@ +/// Domain Entity: Product +/// +/// Pure business entity representing a product in the catalog. +/// This entity is framework-independent and contains only business logic. +library; + +/// Product Entity +/// +/// Represents a tile/construction product in the application. +/// Used across all layers but originates in the domain layer. +class Product { + /// Unique identifier + final String id; + + /// Product name (Vietnamese) + final String name; + + /// Product SKU (Stock Keeping Unit) + final String sku; + + /// Product description + final String description; + + /// Price per unit (VND) + final double price; + + /// Unit of measurement (e.g., "m²", "viên", "hộp") + final String unit; + + /// Product image URL + final String imageUrl; + + /// Category ID + final String categoryId; + + /// Stock availability + final bool inStock; + + /// Stock quantity + final int stockQuantity; + + /// Created date + final DateTime createdAt; + + /// Optional sale price + final double? salePrice; + + /// Optional brand name + final String? brand; + + const Product({ + required this.id, + required this.name, + required this.sku, + required this.description, + required this.price, + required this.unit, + required this.imageUrl, + required this.categoryId, + required this.inStock, + required this.stockQuantity, + required this.createdAt, + this.salePrice, + this.brand, + }); + + /// Get effective price (sale price if available, otherwise regular price) + double get effectivePrice => salePrice ?? price; + + /// Check if product is on sale + bool get isOnSale => salePrice != null && salePrice! < price; + + /// Get discount percentage + int get discountPercentage { + if (!isOnSale) return 0; + return (((price - salePrice!) / price) * 100).round(); + } + + /// Check if stock is low (less than 10 items) + bool get isLowStock => inStock && stockQuantity < 10; + + /// Copy with method for creating modified copies + Product copyWith({ + String? id, + String? name, + String? sku, + String? description, + double? price, + String? unit, + String? imageUrl, + String? categoryId, + bool? inStock, + int? stockQuantity, + DateTime? createdAt, + double? salePrice, + String? brand, + }) { + return Product( + id: id ?? this.id, + name: name ?? this.name, + sku: sku ?? this.sku, + description: description ?? this.description, + price: price ?? this.price, + unit: unit ?? this.unit, + imageUrl: imageUrl ?? this.imageUrl, + categoryId: categoryId ?? this.categoryId, + inStock: inStock ?? this.inStock, + stockQuantity: stockQuantity ?? this.stockQuantity, + createdAt: createdAt ?? this.createdAt, + salePrice: salePrice ?? this.salePrice, + brand: brand ?? this.brand, + ); + } + + @override + String toString() { + return 'Product(id: $id, name: $name, sku: $sku, price: $price, unit: $unit, inStock: $inStock)'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is Product && + other.id == id && + other.name == name && + other.sku == sku && + other.description == description && + other.price == price && + other.unit == unit && + other.imageUrl == imageUrl && + other.categoryId == categoryId && + other.inStock == inStock && + other.stockQuantity == stockQuantity && + other.salePrice == salePrice && + other.brand == brand; + } + + @override + int get hashCode { + return Object.hash( + id, + name, + sku, + description, + price, + unit, + imageUrl, + categoryId, + inStock, + stockQuantity, + salePrice, + brand, + ); + } +} diff --git a/lib/features/products/domain/repositories/products_repository.dart b/lib/features/products/domain/repositories/products_repository.dart new file mode 100644 index 0000000..2b02076 --- /dev/null +++ b/lib/features/products/domain/repositories/products_repository.dart @@ -0,0 +1,44 @@ +/// Domain Repository Interface: Products Repository +/// +/// Defines the contract for product data operations. +/// The data layer will implement this interface. +library; + +import 'package:worker/features/products/domain/entities/category.dart'; +import 'package:worker/features/products/domain/entities/product.dart'; + +/// Products Repository Interface +/// +/// Abstract repository defining product data operations. +/// Implemented by the data layer to provide actual data access logic. +abstract class ProductsRepository { + /// Get all products + /// + /// Returns a list of all available products. + /// Throws an exception if the operation fails. + Future> getAllProducts(); + + /// Search products by query + /// + /// [query] - Search term to filter products + /// Returns filtered list of products matching the query. + Future> searchProducts(String query); + + /// Get products by category + /// + /// [categoryId] - Category ID to filter by + /// Returns list of products in the specified category. + Future> getProductsByCategory(String categoryId); + + /// Get product by ID + /// + /// [id] - Product ID + /// Returns the product with the specified ID. + /// Throws an exception if product not found. + Future getProductById(String id); + + /// Get all categories + /// + /// Returns a list of all product categories. + Future> getCategories(); +} diff --git a/lib/features/products/domain/usecases/get_categories.dart b/lib/features/products/domain/usecases/get_categories.dart new file mode 100644 index 0000000..4e76b1e --- /dev/null +++ b/lib/features/products/domain/usecases/get_categories.dart @@ -0,0 +1,28 @@ +/// Use Case: Get Categories +/// +/// Business logic for retrieving product categories. +library; + +import 'package:worker/features/products/domain/entities/category.dart'; +import 'package:worker/features/products/domain/repositories/products_repository.dart'; + +/// Get Categories Use Case +/// +/// Retrieves all product categories from the repository. +class GetCategories { + final ProductsRepository repository; + + const GetCategories(this.repository); + + /// Execute the use case + /// + /// Returns list of all categories sorted by order + Future> call() async { + final categories = await repository.getCategories(); + + // Sort by display order + categories.sort((a, b) => a.order.compareTo(b.order)); + + return categories; + } +} diff --git a/lib/features/products/domain/usecases/get_products.dart b/lib/features/products/domain/usecases/get_products.dart new file mode 100644 index 0000000..5cee06b --- /dev/null +++ b/lib/features/products/domain/usecases/get_products.dart @@ -0,0 +1,28 @@ +/// Use Case: Get Products +/// +/// Business logic for retrieving products with optional filtering. +library; + +import 'package:worker/features/products/domain/entities/product.dart'; +import 'package:worker/features/products/domain/repositories/products_repository.dart'; + +/// Get Products Use Case +/// +/// Retrieves products from the repository with optional category filtering. +class GetProducts { + final ProductsRepository repository; + + const GetProducts(this.repository); + + /// Execute the use case + /// + /// [categoryId] - Optional category ID to filter products + /// Returns list of products (all or filtered by category) + Future> call({String? categoryId}) async { + if (categoryId == null || categoryId == 'all') { + return await repository.getAllProducts(); + } else { + return await repository.getProductsByCategory(categoryId); + } + } +} diff --git a/lib/features/products/domain/usecases/search_products.dart b/lib/features/products/domain/usecases/search_products.dart new file mode 100644 index 0000000..007dceb --- /dev/null +++ b/lib/features/products/domain/usecases/search_products.dart @@ -0,0 +1,29 @@ +/// Use Case: Search Products +/// +/// Business logic for searching products by query string. +library; + +import 'package:worker/features/products/domain/entities/product.dart'; +import 'package:worker/features/products/domain/repositories/products_repository.dart'; + +/// Search Products Use Case +/// +/// Searches for products matching the given query string. +class SearchProducts { + final ProductsRepository repository; + + const SearchProducts(this.repository); + + /// Execute the use case + /// + /// [query] - Search query string + /// Returns list of products matching the query + Future> call(String query) async { + // Return all products if query is empty + if (query.trim().isEmpty) { + return await repository.getAllProducts(); + } + + return await repository.searchProducts(query); + } +} diff --git a/lib/features/products/presentation/pages/products_page.dart b/lib/features/products/presentation/pages/products_page.dart new file mode 100644 index 0000000..8cc2799 --- /dev/null +++ b/lib/features/products/presentation/pages/products_page.dart @@ -0,0 +1,233 @@ +/// Page: Products Page +/// +/// Main products browsing page with search, category filters, and product grid. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/products/presentation/providers/categories_provider.dart'; +import 'package:worker/features/products/presentation/providers/products_provider.dart'; +import 'package:worker/features/products/presentation/widgets/category_filter_chips.dart'; +import 'package:worker/features/products/presentation/widgets/product_grid.dart'; +import 'package:worker/features/products/presentation/widgets/product_search_bar.dart'; +import 'package:worker/generated/l10n/app_localizations.dart'; + +/// Products Page +/// +/// Displays the products catalog with: +/// - Search bar +/// - Category filter chips +/// - Product grid +/// - Pull-to-refresh +/// - Loading and error states +class ProductsPage extends ConsumerWidget { + const ProductsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final categoriesAsync = ref.watch(categoriesProvider); + final productsAsync = ref.watch(productsProvider); + + return Scaffold( + backgroundColor: AppColors.white, + appBar: AppBar( + title: const Text('Sản phẩm', style: TextStyle(color: Colors.black)), + elevation: AppBarSpecs.elevation, + backgroundColor: AppColors.white, + foregroundColor: AppColors.grey900, + centerTitle: false, + actions: [ + // Cart Icon with Badge + IconButton( + icon: const Badge( + label: Text('3'), + backgroundColor: AppColors.danger, + textColor: AppColors.white, + child: Icon(Icons.shopping_cart_outlined, color: Colors.black,), + ), + onPressed: () { + // TODO: Navigate to cart page + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.cart), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + const SizedBox(width: AppSpacing.sm), + ], + ), + body: Column( + children: [ + // Search Bar + const ProductSearchBar(), + const SizedBox(height: AppSpacing.sm), + + // Category Filter Chips + categoriesAsync.when( + data: (categories) => CategoryFilterChips(categories: categories), + loading: () => const SizedBox( + height: 48.0, + child: Center( + child: CircularProgressIndicator(strokeWidth: 2.0), + ), + ), + error: (error, stack) => const SizedBox.shrink(), + ), + + const SizedBox(height: AppSpacing.sm), + + // Products Grid + Expanded( + child: productsAsync.when( + data: (products) { + if (products.isEmpty) { + return _buildEmptyState(context, l10n); + } + + return RefreshIndicator( + onRefresh: () async { + await ref.read(productsProvider.notifier).refresh(); + }, + child: ProductGrid( + products: products, + onProductTap: (product) { + // TODO: Navigate to product detail page + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(product.name), + duration: const Duration(seconds: 1), + ), + ); + }, + onAddToCart: (product) { + // TODO: Add to cart logic + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${product.name} ${l10n.addedToCart}'), + duration: const Duration(seconds: 2), + action: SnackBarAction( + label: l10n.viewDetails, + onPressed: () { + // Navigate to cart + }, + ), + ), + ); + }, + ), + ); + }, + loading: () => _buildLoadingState(), + error: (error, stack) => _buildErrorState(context, l10n, error, ref), + ), + ), + ], + ), + ); + } + + /// Build empty state + Widget _buildEmptyState(BuildContext context, AppLocalizations l10n) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.inventory_2_outlined, + size: 80.0, + color: AppColors.grey500.withAlpha(128), + ), + const SizedBox(height: AppSpacing.lg), + Text( + l10n.noProductsFound, + style: const TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.w500, + color: AppColors.grey900, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + l10n.noResults, + style: const TextStyle( + fontSize: 14.0, + color: AppColors.grey500, + ), + ), + ], + ), + ); + } + + /// Build loading state + Widget _buildLoadingState() { + return const Center( + child: CircularProgressIndicator( + color: AppColors.primaryBlue, + ), + ); + } + + /// Build error state + Widget _buildErrorState( + BuildContext context, + AppLocalizations l10n, + Object error, + WidgetRef ref, + ) { + return Center( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 80.0, + color: AppColors.danger.withAlpha(128), + ), + const SizedBox(height: AppSpacing.lg), + Text( + l10n.error, + style: const TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.w600, + color: AppColors.grey900, + ), + ), + const SizedBox(height: AppSpacing.sm), + Text( + error.toString(), + style: const TextStyle( + fontSize: 14.0, + color: AppColors.grey500, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + ElevatedButton.icon( + onPressed: () async { + await ref.read(productsProvider.notifier).refresh(); + }, + icon: const Icon(Icons.refresh), + label: Text(l10n.tryAgain), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: AppColors.white, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/products/presentation/providers/categories_provider.dart b/lib/features/products/presentation/providers/categories_provider.dart new file mode 100644 index 0000000..62fc41a --- /dev/null +++ b/lib/features/products/presentation/providers/categories_provider.dart @@ -0,0 +1,36 @@ +/// Provider: Categories Provider +/// +/// Manages the state of product categories using Riverpod. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/features/products/data/datasources/products_local_datasource.dart'; +import 'package:worker/features/products/data/repositories/products_repository_impl.dart'; +import 'package:worker/features/products/domain/entities/category.dart'; +import 'package:worker/features/products/domain/usecases/get_categories.dart'; + +part 'categories_provider.g.dart'; + +/// Categories Provider +/// +/// Fetches and caches product categories. +/// Automatically handles loading, error, and data states. +/// +/// Usage: +/// ```dart +/// final categoriesAsync = ref.watch(categoriesProvider); +/// +/// categoriesAsync.when( +/// data: (categories) => CategoryFilterChips(categories: categories), +/// loading: () => ShimmerLoader(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` +@riverpod +Future> categories(Ref ref) async { + final localDataSource = const ProductsLocalDataSourceImpl(); + final repository = ProductsRepositoryImpl(localDataSource: localDataSource); + final useCase = GetCategories(repository); + + return await useCase(); +} diff --git a/lib/features/products/presentation/providers/categories_provider.g.dart b/lib/features/products/presentation/providers/categories_provider.g.dart new file mode 100644 index 0000000..7cbb97c --- /dev/null +++ b/lib/features/products/presentation/providers/categories_provider.g.dart @@ -0,0 +1,95 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'categories_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Categories Provider +/// +/// Fetches and caches product categories. +/// Automatically handles loading, error, and data states. +/// +/// Usage: +/// ```dart +/// final categoriesAsync = ref.watch(categoriesProvider); +/// +/// categoriesAsync.when( +/// data: (categories) => CategoryFilterChips(categories: categories), +/// loading: () => ShimmerLoader(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` + +@ProviderFor(categories) +const categoriesProvider = CategoriesProvider._(); + +/// Categories Provider +/// +/// Fetches and caches product categories. +/// Automatically handles loading, error, and data states. +/// +/// Usage: +/// ```dart +/// final categoriesAsync = ref.watch(categoriesProvider); +/// +/// categoriesAsync.when( +/// data: (categories) => CategoryFilterChips(categories: categories), +/// loading: () => ShimmerLoader(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` + +final class CategoriesProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with $FutureModifier>, $FutureProvider> { + /// Categories Provider + /// + /// Fetches and caches product categories. + /// Automatically handles loading, error, and data states. + /// + /// Usage: + /// ```dart + /// final categoriesAsync = ref.watch(categoriesProvider); + /// + /// categoriesAsync.when( + /// data: (categories) => CategoryFilterChips(categories: categories), + /// loading: () => ShimmerLoader(), + /// error: (error, stack) => ErrorWidget(error), + /// ); + /// ``` + const CategoriesProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'categoriesProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$categoriesHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return categories(ref); + } +} + +String _$categoriesHash() => r'6de35d3271d6d6572d9cdf5ed68edd26036115fc'; diff --git a/lib/features/products/presentation/providers/products_provider.dart b/lib/features/products/presentation/providers/products_provider.dart new file mode 100644 index 0000000..1d83719 --- /dev/null +++ b/lib/features/products/presentation/providers/products_provider.dart @@ -0,0 +1,88 @@ +/// Provider: Products Provider +/// +/// Manages the state of products data using Riverpod. +/// Provides filtered products based on category and search query. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:worker/features/products/data/datasources/products_local_datasource.dart'; +import 'package:worker/features/products/data/repositories/products_repository_impl.dart'; +import 'package:worker/features/products/domain/entities/product.dart'; +import 'package:worker/features/products/domain/usecases/get_products.dart'; +import 'package:worker/features/products/domain/usecases/search_products.dart'; +import 'package:worker/features/products/presentation/providers/selected_category_provider.dart'; +import 'package:worker/features/products/presentation/providers/search_query_provider.dart'; + +part 'products_provider.g.dart'; + +/// Products Provider +/// +/// Fetches and filters products based on selected category and search query. +/// Automatically updates when category or search query changes. +/// +/// Usage: +/// ```dart +/// final productsAsync = ref.watch(productsProvider); +/// +/// productsAsync.when( +/// data: (products) => ProductGrid(products: products), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` +@riverpod +class Products extends _$Products { + @override + Future> build() async { + // Watch dependencies + final selectedCategory = ref.watch(selectedCategoryProvider); + final searchQuery = ref.watch(searchQueryProvider); + + // Initialize dependencies + final localDataSource = const ProductsLocalDataSourceImpl(); + final repository = ProductsRepositoryImpl(localDataSource: localDataSource); + + // Apply filters + List products; + + if (searchQuery.isNotEmpty) { + // Search takes precedence over category filter + final searchUseCase = SearchProducts(repository); + products = await searchUseCase(searchQuery); + + // If a category is selected, filter search results by category + if (selectedCategory != 'all') { + products = products + .where((product) => product.categoryId == selectedCategory) + .toList(); + } + } else { + // No search query, use category filter + final getProductsUseCase = GetProducts(repository); + products = await getProductsUseCase(categoryId: selectedCategory); + } + + return products; + } + + /// Refresh products data + /// + /// Forces a refresh from the datasource. + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() => build()); + } +} + +/// All Products Provider (no filters) +/// +/// Provides all products without any filtering. +/// Useful for product selection dialogs, etc. +@riverpod +Future> allProducts(Ref ref) async { + final localDataSource = const ProductsLocalDataSourceImpl(); + final repository = ProductsRepositoryImpl(localDataSource: localDataSource); + final getProductsUseCase = GetProducts(repository); + + return await getProductsUseCase(); +} diff --git a/lib/features/products/presentation/providers/products_provider.g.dart b/lib/features/products/presentation/providers/products_provider.g.dart new file mode 100644 index 0000000..4db09b1 --- /dev/null +++ b/lib/features/products/presentation/providers/products_provider.g.dart @@ -0,0 +1,169 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'products_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Products Provider +/// +/// Fetches and filters products based on selected category and search query. +/// Automatically updates when category or search query changes. +/// +/// Usage: +/// ```dart +/// final productsAsync = ref.watch(productsProvider); +/// +/// productsAsync.when( +/// data: (products) => ProductGrid(products: products), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` + +@ProviderFor(Products) +const productsProvider = ProductsProvider._(); + +/// Products Provider +/// +/// Fetches and filters products based on selected category and search query. +/// Automatically updates when category or search query changes. +/// +/// Usage: +/// ```dart +/// final productsAsync = ref.watch(productsProvider); +/// +/// productsAsync.when( +/// data: (products) => ProductGrid(products: products), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` +final class ProductsProvider + extends $AsyncNotifierProvider> { + /// Products Provider + /// + /// Fetches and filters products based on selected category and search query. + /// Automatically updates when category or search query changes. + /// + /// Usage: + /// ```dart + /// final productsAsync = ref.watch(productsProvider); + /// + /// productsAsync.when( + /// data: (products) => ProductGrid(products: products), + /// loading: () => CircularProgressIndicator(), + /// error: (error, stack) => ErrorWidget(error), + /// ); + /// ``` + const ProductsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'productsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$productsHash(); + + @$internal + @override + Products create() => Products(); +} + +String _$productsHash() => r'0f1b32d2c14b9d8d600ffb0270f54d32af753e1f'; + +/// Products Provider +/// +/// Fetches and filters products based on selected category and search query. +/// Automatically updates when category or search query changes. +/// +/// Usage: +/// ```dart +/// final productsAsync = ref.watch(productsProvider); +/// +/// productsAsync.when( +/// data: (products) => ProductGrid(products: products), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stack) => ErrorWidget(error), +/// ); +/// ``` + +abstract class _$Products extends $AsyncNotifier> { + FutureOr> build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref>, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier>, List>, + AsyncValue>, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} + +/// All Products Provider (no filters) +/// +/// Provides all products without any filtering. +/// Useful for product selection dialogs, etc. + +@ProviderFor(allProducts) +const allProductsProvider = AllProductsProvider._(); + +/// All Products Provider (no filters) +/// +/// Provides all products without any filtering. +/// Useful for product selection dialogs, etc. + +final class AllProductsProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with $FutureModifier>, $FutureProvider> { + /// All Products Provider (no filters) + /// + /// Provides all products without any filtering. + /// Useful for product selection dialogs, etc. + const AllProductsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'allProductsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$allProductsHash(); + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return allProducts(ref); + } +} + +String _$allProductsHash() => r'a02e989ad36e644d9b62e681b3ced88e10e4d4c3'; diff --git a/lib/features/products/presentation/providers/search_query_provider.dart b/lib/features/products/presentation/providers/search_query_provider.dart new file mode 100644 index 0000000..867b782 --- /dev/null +++ b/lib/features/products/presentation/providers/search_query_provider.dart @@ -0,0 +1,39 @@ +/// Provider: Search Query Provider +/// +/// Manages the current search query state for product filtering. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'search_query_provider.g.dart'; + +/// Search Query Provider +/// +/// Holds the current search query string for filtering products. +/// Default is empty string which shows all products. +/// +/// Usage: +/// ```dart +/// // Read the current value +/// final searchQuery = ref.watch(searchQueryProvider); +/// +/// // Update the value +/// ref.read(searchQueryProvider.notifier).state = 'gạch men'; +/// ``` +@riverpod +class SearchQuery extends _$SearchQuery { + @override + String build() { + return ''; // Default: no search filter + } + + /// Update search query + void updateQuery(String query) { + state = query; + } + + /// Clear search query + void clear() { + state = ''; + } +} diff --git a/lib/features/products/presentation/providers/search_query_provider.g.dart b/lib/features/products/presentation/providers/search_query_provider.g.dart new file mode 100644 index 0000000..239ad65 --- /dev/null +++ b/lib/features/products/presentation/providers/search_query_provider.g.dart @@ -0,0 +1,115 @@ +// 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 Provider +/// +/// Holds the current search query string for filtering products. +/// Default is empty string which shows all products. +/// +/// Usage: +/// ```dart +/// // Read the current value +/// final searchQuery = ref.watch(searchQueryProvider); +/// +/// // Update the value +/// ref.read(searchQueryProvider.notifier).state = 'gạch men'; +/// ``` + +@ProviderFor(SearchQuery) +const searchQueryProvider = SearchQueryProvider._(); + +/// Search Query Provider +/// +/// Holds the current search query string for filtering products. +/// Default is empty string which shows all products. +/// +/// Usage: +/// ```dart +/// // Read the current value +/// final searchQuery = ref.watch(searchQueryProvider); +/// +/// // Update the value +/// ref.read(searchQueryProvider.notifier).state = 'gạch men'; +/// ``` +final class SearchQueryProvider extends $NotifierProvider { + /// Search Query Provider + /// + /// Holds the current search query string for filtering products. + /// Default is empty string which shows all products. + /// + /// Usage: + /// ```dart + /// // Read the current value + /// final searchQuery = ref.watch(searchQueryProvider); + /// + /// // Update the value + /// ref.read(searchQueryProvider.notifier).state = 'gạch men'; + /// ``` + 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'41ea2fa57593abc0cafe16598d8817584ba99ddc'; + +/// Search Query Provider +/// +/// Holds the current search query string for filtering products. +/// Default is empty string which shows all products. +/// +/// Usage: +/// ```dart +/// // Read the current value +/// final searchQuery = ref.watch(searchQueryProvider); +/// +/// // Update the value +/// ref.read(searchQueryProvider.notifier).state = 'gạch men'; +/// ``` + +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/lib/features/products/presentation/providers/selected_category_provider.dart b/lib/features/products/presentation/providers/selected_category_provider.dart new file mode 100644 index 0000000..776e226 --- /dev/null +++ b/lib/features/products/presentation/providers/selected_category_provider.dart @@ -0,0 +1,39 @@ +/// Provider: Selected Category Provider +/// +/// Manages the currently selected category filter state. +library; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'selected_category_provider.g.dart'; + +/// Selected Category Provider +/// +/// Holds the currently selected category ID for filtering products. +/// Default is 'all' which shows all products. +/// +/// Usage: +/// ```dart +/// // Read the current value +/// final selectedCategory = ref.watch(selectedCategoryProvider); +/// +/// // Update the value +/// ref.read(selectedCategoryProvider.notifier).state = 'floor_tiles'; +/// ``` +@riverpod +class SelectedCategory extends _$SelectedCategory { + @override + String build() { + return 'all'; // Default: show all products + } + + /// Update selected category + void updateCategory(String categoryId) { + state = categoryId; + } + + /// Reset to show all products + void reset() { + state = 'all'; + } +} diff --git a/lib/features/products/presentation/providers/selected_category_provider.g.dart b/lib/features/products/presentation/providers/selected_category_provider.g.dart new file mode 100644 index 0000000..33c0f1a --- /dev/null +++ b/lib/features/products/presentation/providers/selected_category_provider.g.dart @@ -0,0 +1,116 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'selected_category_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning +/// Selected Category Provider +/// +/// Holds the currently selected category ID for filtering products. +/// Default is 'all' which shows all products. +/// +/// Usage: +/// ```dart +/// // Read the current value +/// final selectedCategory = ref.watch(selectedCategoryProvider); +/// +/// // Update the value +/// ref.read(selectedCategoryProvider.notifier).state = 'floor_tiles'; +/// ``` + +@ProviderFor(SelectedCategory) +const selectedCategoryProvider = SelectedCategoryProvider._(); + +/// Selected Category Provider +/// +/// Holds the currently selected category ID for filtering products. +/// Default is 'all' which shows all products. +/// +/// Usage: +/// ```dart +/// // Read the current value +/// final selectedCategory = ref.watch(selectedCategoryProvider); +/// +/// // Update the value +/// ref.read(selectedCategoryProvider.notifier).state = 'floor_tiles'; +/// ``` +final class SelectedCategoryProvider + extends $NotifierProvider { + /// Selected Category Provider + /// + /// Holds the currently selected category ID for filtering products. + /// Default is 'all' which shows all products. + /// + /// Usage: + /// ```dart + /// // Read the current value + /// final selectedCategory = ref.watch(selectedCategoryProvider); + /// + /// // Update the value + /// ref.read(selectedCategoryProvider.notifier).state = 'floor_tiles'; + /// ``` + const SelectedCategoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'selectedCategoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$selectedCategoryHash(); + + @$internal + @override + SelectedCategory create() => SelectedCategory(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(String value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$selectedCategoryHash() => r'269171acff2e04353101596c8d65f46fa54dc839'; + +/// Selected Category Provider +/// +/// Holds the currently selected category ID for filtering products. +/// Default is 'all' which shows all products. +/// +/// Usage: +/// ```dart +/// // Read the current value +/// final selectedCategory = ref.watch(selectedCategoryProvider); +/// +/// // Update the value +/// ref.read(selectedCategoryProvider.notifier).state = 'floor_tiles'; +/// ``` + +abstract class _$SelectedCategory 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/lib/features/products/presentation/widgets/category_filter_chips.dart b/lib/features/products/presentation/widgets/category_filter_chips.dart new file mode 100644 index 0000000..0fa9cea --- /dev/null +++ b/lib/features/products/presentation/widgets/category_filter_chips.dart @@ -0,0 +1,76 @@ +/// Widget: Category Filter Chips +/// +/// Horizontal scrolling filter chips for product categories. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/products/domain/entities/category.dart'; +import 'package:worker/features/products/presentation/providers/selected_category_provider.dart'; + +/// Category Filter Chips Widget +/// +/// Displays categories as horizontally scrolling chips. +/// Updates selected category when tapped. +class CategoryFilterChips extends ConsumerWidget { + final List categories; + + const CategoryFilterChips({ + super.key, + required this.categories, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selectedCategory = ref.watch(selectedCategoryProvider); + + return SizedBox( + height: 48.0, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + itemCount: categories.length, + separatorBuilder: (context, index) => const SizedBox(width: AppSpacing.sm), + itemBuilder: (context, index) { + final category = categories[index]; + final isSelected = selectedCategory == category.id; + + return FilterChip( + label: Text( + category.name, + style: TextStyle( + fontSize: 14.0, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected ? AppColors.white : AppColors.grey900, + ), + ), + selected: isSelected, + onSelected: (selected) { + if (selected) { + ref.read(selectedCategoryProvider.notifier).updateCategory(category.id); + } + }, + backgroundColor: AppColors.white, + selectedColor: AppColors.primaryBlue, + checkmarkColor: AppColors.white, + side: BorderSide( + color: isSelected ? AppColors.primaryBlue : AppColors.grey100, + width: isSelected ? 2.0 : 1.0, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + elevation: isSelected ? AppElevation.low : 0, + showCheckmark: false, + ); + }, + ), + ); + } +} diff --git a/lib/features/products/presentation/widgets/product_card.dart b/lib/features/products/presentation/widgets/product_card.dart new file mode 100644 index 0000000..619d370 --- /dev/null +++ b/lib/features/products/presentation/widgets/product_card.dart @@ -0,0 +1,208 @@ +/// Widget: Product Card +/// +/// Displays a product in a card format with image, name, price, and add to cart button. +library; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/products/domain/entities/product.dart'; +import 'package:worker/generated/l10n/app_localizations.dart'; + +/// Product Card Widget +/// +/// Displays product information in a card format. +/// Includes image, name, price, stock status, and add to cart button. +class ProductCard extends StatelessWidget { + final Product product; + final VoidCallback? onTap; + final VoidCallback? onAddToCart; + + const ProductCard({ + super.key, + required this.product, + this.onTap, + this.onAddToCart, + }); + + String _formatPrice(double price) { + final formatter = NumberFormat('#,###', 'vi_VN'); + return '${formatter.format(price)}đ'; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Card( + elevation: ProductCardSpecs.elevation, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ProductCardSpecs.borderRadius), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(ProductCardSpecs.borderRadius), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Product Image + Expanded( + child: Stack( + children: [ + // Image + ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(ProductCardSpecs.borderRadius), + ), + child: CachedNetworkImage( + imageUrl: product.imageUrl, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + memCacheWidth: ImageSpecs.productImageCacheWidth, + memCacheHeight: ImageSpecs.productImageCacheHeight, + placeholder: (context, url) => Shimmer.fromColors( + baseColor: AppColors.grey100, + highlightColor: AppColors.grey50, + child: Container( + color: AppColors.grey100, + ), + ), + errorWidget: (context, url, error) => Container( + color: AppColors.grey100, + child: const Icon( + Icons.image_not_supported, + size: 48.0, + color: AppColors.grey500, + ), + ), + ), + ), + + // Sale Badge + if (product.isOnSale) + Positioned( + top: AppSpacing.sm, + right: AppSpacing.sm, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 4.0, + ), + decoration: BoxDecoration( + color: AppColors.danger, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text( + '-${product.discountPercentage}%', + style: const TextStyle( + color: AppColors.white, + fontSize: 11.0, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + + // Low Stock Badge + if (product.isLowStock) + Positioned( + top: AppSpacing.sm, + left: AppSpacing.sm, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: 4.0, + ), + decoration: BoxDecoration( + color: AppColors.warning, + borderRadius: BorderRadius.circular(AppRadius.sm), + ), + child: Text( + l10n.lowStock, + style: const TextStyle( + color: AppColors.white, + fontSize: 11.0, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + + // Product Info + Padding( + padding: const EdgeInsets.all(AppSpacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Product Name + Text( + product.name, + style: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w600, + height: 1.3, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: AppSpacing.xs), + + // Price + Text( + '${_formatPrice(product.effectivePrice)}/${product.unit}', + style: const TextStyle( + fontSize: 16.0, + fontWeight: FontWeight.bold, + color: AppColors.primaryBlue, + ), + ), + + const SizedBox(height: AppSpacing.sm), + + // Add to Cart Button - Full Width + SizedBox( + width: double.infinity, + height: 36.0, + child: ElevatedButton.icon( + onPressed: product.inStock ? onAddToCart : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryBlue, + foregroundColor: AppColors.white, + disabledBackgroundColor: AppColors.grey100, + disabledForegroundColor: AppColors.grey500, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.button), + ), + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + ), + ), + icon: const Icon(Icons.shopping_cart, size: 18.0), + label: Text( + product.inStock ? l10n.addToCart : l10n.outOfStock, + style: const TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/products/presentation/widgets/product_grid.dart b/lib/features/products/presentation/widgets/product_grid.dart new file mode 100644 index 0000000..b3b9baf --- /dev/null +++ b/lib/features/products/presentation/widgets/product_grid.dart @@ -0,0 +1,48 @@ +/// Widget: Product Grid +/// +/// Grid view displaying product cards. +library; + +import 'package:flutter/material.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/features/products/domain/entities/product.dart'; +import 'package:worker/features/products/presentation/widgets/product_card.dart'; + +/// Product Grid Widget +/// +/// Displays products in a 2-column grid layout. +class ProductGrid extends StatelessWidget { + final List products; + final void Function(Product)? onProductTap; + final void Function(Product)? onAddToCart; + + const ProductGrid({ + super.key, + required this.products, + this.onProductTap, + this.onAddToCart, + }); + + @override + Widget build(BuildContext context) { + return GridView.builder( + padding: const EdgeInsets.all(AppSpacing.xs), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: GridSpecs.productGridColumns, + crossAxisSpacing: AppSpacing.xs, + mainAxisSpacing: AppSpacing.xs, + childAspectRatio: 0.7, // Width / Height ratio + ), + itemCount: products.length, + itemBuilder: (context, index) { + final product = products[index]; + + return ProductCard( + product: product, + onTap: onProductTap != null ? () => onProductTap!(product) : null, + onAddToCart: onAddToCart != null ? () => onAddToCart!(product) : null, + ); + }, + ); + } +} diff --git a/lib/features/products/presentation/widgets/product_search_bar.dart b/lib/features/products/presentation/widgets/product_search_bar.dart new file mode 100644 index 0000000..fb9d2d9 --- /dev/null +++ b/lib/features/products/presentation/widgets/product_search_bar.dart @@ -0,0 +1,113 @@ +/// Widget: Product Search Bar +/// +/// Custom search bar for filtering products. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worker/core/constants/ui_constants.dart'; +import 'package:worker/core/theme/colors.dart'; +import 'package:worker/features/products/presentation/providers/search_query_provider.dart'; +import 'package:worker/generated/l10n/app_localizations.dart'; + +/// Product Search Bar Widget +/// +/// A search input field that updates the search query provider. +/// Includes search icon and clear button. +class ProductSearchBar extends ConsumerStatefulWidget { + const ProductSearchBar({super.key}); + + @override + ConsumerState createState() => _ProductSearchBarState(); +} + +class _ProductSearchBarState extends ConsumerState { + late final TextEditingController _controller; + late final FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + _focusNode = FocusNode(); + } + + @override + void dispose() { + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _onSearchChanged(String value) { + // Update search query provider + ref.read(searchQueryProvider.notifier).updateQuery(value); + } + + void _onClearSearch() { + _controller.clear(); + ref.read(searchQueryProvider.notifier).clear(); + _focusNode.unfocus(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Container( + height: InputFieldSpecs.height, + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + child: TextField( + controller: _controller, + focusNode: _focusNode, + onChanged: _onSearchChanged, + decoration: InputDecoration( + hintText: l10n.searchProducts, + hintStyle: const TextStyle( + fontSize: InputFieldSpecs.hintFontSize, + color: AppColors.grey500, + ), + prefixIcon: const Icon( + Icons.search, + color: AppColors.grey500, + size: AppIconSize.md, + ), + suffixIcon: _controller.text.isNotEmpty + ? IconButton( + icon: const Icon( + Icons.clear, + color: AppColors.grey500, + size: AppIconSize.md, + ), + onPressed: _onClearSearch, + ) + : null, + filled: true, + fillColor: const Color(0xFFF5F5F5), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(InputFieldSpecs.borderRadius), + borderSide: const BorderSide( + color: AppColors.primaryBlue, + width: 2.0, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.md, + ), + ), + style: const TextStyle( + fontSize: InputFieldSpecs.fontSize, + ), + ), + ); + } +} diff --git a/lib/generated/l10n/app_localizations.dart b/lib/generated/l10n/app_localizations.dart index 782d391..a7cd70c 100644 --- a/lib/generated/l10n/app_localizations.dart +++ b/lib/generated/l10n/app_localizations.dart @@ -248,6 +248,12 @@ abstract class AppLocalizations { /// **'Search'** String get search; + /// No description provided for @searchProducts. + /// + /// In en, this message translates to: + /// **'Search products...'** + String get searchProducts; + /// No description provided for @filter. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/app_localizations_en.dart b/lib/generated/l10n/app_localizations_en.dart index 44a8848..6ea826a 100644 --- a/lib/generated/l10n/app_localizations_en.dart +++ b/lib/generated/l10n/app_localizations_en.dart @@ -87,6 +87,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get search => 'Search'; + @override + String get searchProducts => 'Search products...'; + @override String get filter => 'Filter'; diff --git a/lib/generated/l10n/app_localizations_vi.dart b/lib/generated/l10n/app_localizations_vi.dart index 4bd4432..d5d1083 100644 --- a/lib/generated/l10n/app_localizations_vi.dart +++ b/lib/generated/l10n/app_localizations_vi.dart @@ -87,6 +87,9 @@ class AppLocalizationsVi extends AppLocalizations { @override String get search => 'Tìm kiếm'; + @override + String get searchProducts => 'Tìm kiếm sản phẩm...'; + @override String get filter => 'Lọc'; diff --git a/lib/hive_registrar.g.dart b/lib/hive_registrar.g.dart index eafd34d..d618d7a 100644 --- a/lib/hive_registrar.g.dart +++ b/lib/hive_registrar.g.dart @@ -7,10 +7,13 @@ import 'package:worker/core/database/models/cached_data.dart'; import 'package:worker/core/database/models/enums.dart'; import 'package:worker/features/home/data/models/member_card_model.dart'; import 'package:worker/features/home/data/models/promotion_model.dart'; +import 'package:worker/features/products/data/models/category_model.dart'; +import 'package:worker/features/products/data/models/product_model.dart'; extension HiveRegistrar on HiveInterface { void registerAdapters() { registerAdapter(CachedDataAdapter()); + registerAdapter(CategoryModelAdapter()); registerAdapter(GiftStatusAdapter()); registerAdapter(MemberCardModelAdapter()); registerAdapter(MemberTierAdapter()); @@ -18,6 +21,7 @@ extension HiveRegistrar on HiveInterface { registerAdapter(OrderStatusAdapter()); registerAdapter(PaymentMethodAdapter()); registerAdapter(PaymentStatusAdapter()); + registerAdapter(ProductModelAdapter()); registerAdapter(ProjectStatusAdapter()); registerAdapter(ProjectTypeAdapter()); registerAdapter(PromotionModelAdapter()); @@ -29,6 +33,7 @@ extension HiveRegistrar on HiveInterface { extension IsolatedHiveRegistrar on IsolatedHiveInterface { void registerAdapters() { registerAdapter(CachedDataAdapter()); + registerAdapter(CategoryModelAdapter()); registerAdapter(GiftStatusAdapter()); registerAdapter(MemberCardModelAdapter()); registerAdapter(MemberTierAdapter()); @@ -36,6 +41,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface { registerAdapter(OrderStatusAdapter()); registerAdapter(PaymentMethodAdapter()); registerAdapter(PaymentStatusAdapter()); + registerAdapter(ProductModelAdapter()); registerAdapter(ProjectStatusAdapter()); registerAdapter(ProjectTypeAdapter()); registerAdapter(PromotionModelAdapter()); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4192fcc..6f9d2e3 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -65,6 +65,7 @@ "delete": "Delete", "edit": "Edit", "search": "Search", + "searchProducts": "Search products...", "filter": "Filter", "sort": "Sort", "confirm": "Confirm", diff --git a/lib/l10n/app_vi.arb b/lib/l10n/app_vi.arb index 3005be9..9fef90b 100644 --- a/lib/l10n/app_vi.arb +++ b/lib/l10n/app_vi.arb @@ -65,6 +65,7 @@ "delete": "Xóa", "edit": "Sửa", "search": "Tìm kiếm", + "searchProducts": "Tìm kiếm sản phẩm...", "filter": "Lọc", "sort": "Sắp xếp", "confirm": "Xác nhận",