prodycrts
This commit is contained in:
@@ -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}'),
|
||||
|
||||
@@ -54,7 +54,7 @@ class QuickActionItem extends StatelessWidget {
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 28,
|
||||
size: 20,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
270
lib/features/products/README.md
Normal file
270
lib/features/products/README.md
Normal 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.
|
||||
@@ -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': '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<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();
|
||||
}
|
||||
}
|
||||
131
lib/features/products/data/models/category_model.dart
Normal file
131
lib/features/products/data/models/category_model.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
53
lib/features/products/data/models/category_model.g.dart
Normal file
53
lib/features/products/data/models/category_model.g.dart
Normal 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;
|
||||
}
|
||||
242
lib/features/products/data/models/product_model.dart
Normal file
242
lib/features/products/data/models/product_model.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
77
lib/features/products/data/models/product_model.g.dart
Normal file
77
lib/features/products/data/models/product_model.g.dart
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
71
lib/features/products/domain/entities/category.dart
Normal file
71
lib/features/products/domain/entities/category.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
156
lib/features/products/domain/entities/product.dart
Normal file
156
lib/features/products/domain/entities/product.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
28
lib/features/products/domain/usecases/get_categories.dart
Normal file
28
lib/features/products/domain/usecases/get_categories.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
28
lib/features/products/domain/usecases/get_products.dart
Normal file
28
lib/features/products/domain/usecases/get_products.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
lib/features/products/domain/usecases/search_products.dart
Normal file
29
lib/features/products/domain/usecases/search_products.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
233
lib/features/products/presentation/pages/products_page.dart
Normal file
233
lib/features/products/presentation/pages/products_page.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
208
lib/features/products/presentation/widgets/product_card.dart
Normal file
208
lib/features/products/presentation/widgets/product_card.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/features/products/presentation/widgets/product_grid.dart
Normal file
48
lib/features/products/presentation/widgets/product_grid.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user