prodycrts

This commit is contained in:
Phuoc Nguyen
2025-10-20 15:56:34 +07:00
parent e321e9a419
commit f95fa9d0a6
40 changed files with 3123 additions and 447 deletions

View File

@@ -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

3
devtools_options.yaml Normal file
View File

@@ -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:

View File

@@ -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,
),
),
],
);
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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<void>([
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<void>([
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}'),

View File

@@ -54,7 +54,7 @@ class QuickActionItem extends StatelessWidget {
),
child: Icon(
icon,
size: 28,
size: 20,
color: AppColors.primaryBlue,
),
),

View File

@@ -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) {

View File

@@ -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.

View File

@@ -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<List<ProductModel>> getAllProducts();
Future<List<ProductModel>> searchProducts(String query);
Future<List<ProductModel>> getProductsByCategory(String categoryId);
Future<ProductModel> getProductById(String id);
Future<List<CategoryModel>> 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<Map<String, dynamic>> _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<Map<String, dynamic>> _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': '',
'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': '',
'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': '',
'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': '',
'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': '',
'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': '',
'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': '',
'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': '',
'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<List<ProductModel>> getAllProducts() async {
// Simulate network delay
await Future.delayed(const Duration(milliseconds: 500));
return _productsJson
.map((json) => ProductModel.fromJson(json))
.toList();
}
@override
Future<List<ProductModel>> 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<List<ProductModel>> 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<ProductModel> 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<List<CategoryModel>> getCategories() async {
// Simulate network delay
await Future.delayed(const Duration(milliseconds: 300));
return _categoriesJson
.map((json) => CategoryModel.fromJson(json))
.toList();
}
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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);
}
}

View File

@@ -0,0 +1,53 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'category_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class CategoryModelAdapter extends TypeAdapter<CategoryModel> {
@override
final typeId = 12;
@override
CategoryModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
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;
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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,
);
}
}

View File

@@ -0,0 +1,77 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'product_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ProductModelAdapter extends TypeAdapter<ProductModel> {
@override
final typeId = 1;
@override
ProductModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
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;
}

View File

@@ -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<List<Product>> 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<List<Product>> 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<List<Product>> 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<Product> 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<List<Category>> getCategories() async {
try {
final categoryModels = await localDataSource.getCategories();
return categoryModels.map((model) => model.toEntity()).toList();
} catch (e) {
throw Exception('Failed to get categories: $e');
}
}
}

View File

@@ -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);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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<List<Product>> getAllProducts();
/// Search products by query
///
/// [query] - Search term to filter products
/// Returns filtered list of products matching the query.
Future<List<Product>> searchProducts(String query);
/// Get products by category
///
/// [categoryId] - Category ID to filter by
/// Returns list of products in the specified category.
Future<List<Product>> 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<Product> getProductById(String id);
/// Get all categories
///
/// Returns a list of all product categories.
Future<List<Category>> getCategories();
}

View File

@@ -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<List<Category>> call() async {
final categories = await repository.getCategories();
// Sort by display order
categories.sort((a, b) => a.order.compareTo(b.order));
return categories;
}
}

View File

@@ -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<List<Product>> call({String? categoryId}) async {
if (categoryId == null || categoryId == 'all') {
return await repository.getAllProducts();
} else {
return await repository.getProductsByCategory(categoryId);
}
}
}

View File

@@ -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<List<Product>> call(String query) async {
// Return all products if query is empty
if (query.trim().isEmpty) {
return await repository.getAllProducts();
}
return await repository.searchProducts(query);
}
}

View File

@@ -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,
),
),
),
],
),
),
);
}
}

View File

@@ -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<List<Category>> categories(Ref ref) async {
final localDataSource = const ProductsLocalDataSourceImpl();
final repository = ProductsRepositoryImpl(localDataSource: localDataSource);
final useCase = GetCategories(repository);
return await useCase();
}

View File

@@ -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<Category>>,
List<Category>,
FutureOr<List<Category>>
>
with $FutureModifier<List<Category>>, $FutureProvider<List<Category>> {
/// 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<List<Category>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<Category>> create(Ref ref) {
return categories(ref);
}
}
String _$categoriesHash() => r'6de35d3271d6d6572d9cdf5ed68edd26036115fc';

View File

@@ -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<List<Product>> 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<Product> 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<void> 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<List<Product>> allProducts(Ref ref) async {
final localDataSource = const ProductsLocalDataSourceImpl();
final repository = ProductsRepositoryImpl(localDataSource: localDataSource);
final getProductsUseCase = GetProducts(repository);
return await getProductsUseCase();
}

View File

@@ -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, List<Product>> {
/// 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<List<Product>> {
FutureOr<List<Product>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<List<Product>>, List<Product>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<Product>>, List<Product>>,
AsyncValue<List<Product>>,
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<Product>>,
List<Product>,
FutureOr<List<Product>>
>
with $FutureModifier<List<Product>>, $FutureProvider<List<Product>> {
/// 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<List<Product>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<Product>> create(Ref ref) {
return allProducts(ref);
}
}
String _$allProductsHash() => r'a02e989ad36e644d9b62e681b3ced88e10e4d4c3';

View File

@@ -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 = '';
}
}

View File

@@ -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<SearchQuery, String> {
/// 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<String>(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> {
String build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String, String>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String, String>,
String,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -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';
}
}

View File

@@ -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<SelectedCategory, String> {
/// 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<String>(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> {
String build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<String, String>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<String, String>,
String,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -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<Category> 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,
);
},
),
);
}
}

View File

@@ -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,
),
),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -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<Product> 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,
);
},
);
}
}

View File

@@ -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<ProductSearchBar> createState() => _ProductSearchBarState();
}
class _ProductSearchBarState extends ConsumerState<ProductSearchBar> {
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,
),
),
);
}
}

View File

@@ -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:

View File

@@ -87,6 +87,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get search => 'Search';
@override
String get searchProducts => 'Search products...';
@override
String get filter => 'Filter';

View File

@@ -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';

View File

@@ -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());

View File

@@ -65,6 +65,7 @@
"delete": "Delete",
"edit": "Edit",
"search": "Search",
"searchProducts": "Search products...",
"filter": "Filter",
"sort": "Sort",
"confirm": "Confirm",

View File

@@ -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",