prodycrts
This commit is contained in:
@@ -2071,7 +2071,7 @@ end
|
|||||||
2. **Design Data Layer**: Models, repositories, data sources
|
2. **Design Data Layer**: Models, repositories, data sources
|
||||||
3. **Implement Domain Layer**: Entities, use cases
|
3. **Implement Domain Layer**: Entities, use cases
|
||||||
4. **Create Providers**: Riverpod state management
|
4. **Create Providers**: Riverpod state management
|
||||||
5. **Build UI**: Match HTML reference design
|
5. **Build UI**: Match HTML (in 375px width) reference design
|
||||||
6. **Test**: Unit, widget, integration tests
|
6. **Test**: Unit, widget, integration tests
|
||||||
7. **Optimize**: Performance profiling
|
7. **Optimize**: Performance profiling
|
||||||
|
|
||||||
|
|||||||
3
devtools_options.yaml
Normal file
3
devtools_options.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
||||||
224
lib/app.dart
224
lib/app.dart
@@ -117,227 +117,3 @@ class _AppBuilder extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Placeholder home page
|
|
||||||
///
|
|
||||||
/// This is a temporary home screen that will be replaced with the actual
|
|
||||||
/// home page implementation from features/home/presentation/pages/home_page.dart
|
|
||||||
///
|
|
||||||
/// The actual home page will include:
|
|
||||||
/// - Membership card display (Diamond/Platinum/Gold tiers)
|
|
||||||
/// - Quick action grid
|
|
||||||
/// - Bottom navigation bar
|
|
||||||
/// - Floating action button for chat
|
|
||||||
class _PlaceholderHomePage extends ConsumerWidget {
|
|
||||||
const _PlaceholderHomePage();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Worker App'),
|
|
||||||
centerTitle: true,
|
|
||||||
),
|
|
||||||
body: Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24.0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
// App logo placeholder
|
|
||||||
Container(
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.business_center,
|
|
||||||
size: 64,
|
|
||||||
color: theme.colorScheme.onPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
// Welcome text
|
|
||||||
Text(
|
|
||||||
'Chào mừng đến với Worker App',
|
|
||||||
style: theme.textTheme.headlineMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: theme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Description
|
|
||||||
Text(
|
|
||||||
'Ứng dụng dành cho thầu thợ, kiến trúc sư, đại lý và môi giới',
|
|
||||||
style: theme.textTheme.bodyLarge?.copyWith(
|
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 48),
|
|
||||||
|
|
||||||
// Status indicators
|
|
||||||
const _StatusIndicator(
|
|
||||||
icon: Icons.check_circle,
|
|
||||||
color: Colors.green,
|
|
||||||
label: 'Hive Database: Initialized',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const _StatusIndicator(
|
|
||||||
icon: Icons.check_circle,
|
|
||||||
color: Colors.green,
|
|
||||||
label: 'Riverpod: Active',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
const _StatusIndicator(
|
|
||||||
icon: Icons.check_circle,
|
|
||||||
color: Colors.green,
|
|
||||||
label: 'Material 3 Theme: Loaded',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 48),
|
|
||||||
|
|
||||||
// Next steps card
|
|
||||||
Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(20.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Icons.info_outline,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
'Next Steps',
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const _NextStepItem(
|
|
||||||
number: '1',
|
|
||||||
text: 'Implement authentication flow',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const _NextStepItem(
|
|
||||||
number: '2',
|
|
||||||
text: 'Create home page with membership cards',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const _NextStepItem(
|
|
||||||
number: '3',
|
|
||||||
text: 'Set up navigation and routing',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const _NextStepItem(
|
|
||||||
number: '4',
|
|
||||||
text: 'Implement feature modules',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Floating action button (will be used for chat)
|
|
||||||
floatingActionButton: FloatingActionButton(
|
|
||||||
onPressed: () {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('Chat feature coming soon!'),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Icon(Icons.chat_bubble_outline),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Status indicator widget
|
|
||||||
class _StatusIndicator extends StatelessWidget {
|
|
||||||
const _StatusIndicator({
|
|
||||||
required this.icon,
|
|
||||||
required this.color,
|
|
||||||
required this.label,
|
|
||||||
});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final Color color;
|
|
||||||
final String label;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: color, size: 20),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Next step item widget
|
|
||||||
class _NextStepItem extends StatelessWidget {
|
|
||||||
const _NextStepItem({
|
|
||||||
required this.number,
|
|
||||||
required this.text,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String number;
|
|
||||||
final String text;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
number,
|
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
text,
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -394,19 +394,19 @@ class GridSpecs {
|
|||||||
static const int productGridColumns = 2;
|
static const int productGridColumns = 2;
|
||||||
|
|
||||||
/// Product grid cross axis spacing
|
/// Product grid cross axis spacing
|
||||||
static const double productGridCrossSpacing = AppSpacing.md;
|
static const double productGridCrossSpacing = AppSpacing.xs;
|
||||||
|
|
||||||
/// Product grid main axis spacing
|
/// Product grid main axis spacing
|
||||||
static const double productGridMainSpacing = AppSpacing.md;
|
static const double productGridMainSpacing = AppSpacing.xs;
|
||||||
|
|
||||||
/// Quick action grid cross axis count
|
/// Quick action grid cross axis count
|
||||||
static const int quickActionColumns = 3;
|
static const int quickActionColumns = 3;
|
||||||
|
|
||||||
/// Quick action grid cross axis spacing
|
/// Quick action grid cross axis spacing
|
||||||
static const double quickActionCrossSpacing = AppSpacing.md;
|
static const double quickActionCrossSpacing = AppSpacing.sm;
|
||||||
|
|
||||||
/// Quick action grid main axis spacing
|
/// Quick action grid main axis spacing
|
||||||
static const double quickActionMainSpacing = AppSpacing.md;
|
static const double quickActionMainSpacing = AppSpacing.sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List specifications
|
/// List specifications
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ library;
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:worker/features/home/presentation/pages/home_page.dart';
|
import 'package:worker/features/home/presentation/pages/home_page.dart';
|
||||||
|
import 'package:worker/features/products/presentation/pages/products_page.dart';
|
||||||
|
|
||||||
/// App Router
|
/// App Router
|
||||||
///
|
///
|
||||||
@@ -34,16 +35,17 @@ class AppRouter {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Products Route
|
||||||
|
GoRoute(
|
||||||
|
path: RouteNames.products,
|
||||||
|
name: RouteNames.products,
|
||||||
|
pageBuilder: (context, state) => MaterialPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const ProductsPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// TODO: Add more routes as features are implemented
|
// TODO: Add more routes as features are implemented
|
||||||
// Example:
|
|
||||||
// GoRoute(
|
|
||||||
// path: RouteNames.products,
|
|
||||||
// name: RouteNames.products,
|
|
||||||
// pageBuilder: (context, state) => MaterialPage(
|
|
||||||
// key: state.pageKey,
|
|
||||||
// child: const ProductsPage(),
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
],
|
],
|
||||||
|
|
||||||
// Error page for unknown routes
|
// Error page for unknown routes
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ library;
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/core/theme/colors.dart';
|
||||||
import 'package:worker/features/home/presentation/providers/member_card_provider.dart';
|
import 'package:worker/features/home/presentation/providers/member_card_provider.dart';
|
||||||
import 'package:worker/features/home/presentation/providers/promotions_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);
|
final promotionsAsync = ref.watch(promotionsProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
extendBodyBehindAppBar: true, // allow body to render behind status bar
|
|
||||||
|
|
||||||
backgroundColor: AppColors.grey50,
|
backgroundColor: AppColors.grey50,
|
||||||
body: MediaQuery.removePadding(
|
body: RefreshIndicator(
|
||||||
context: context,
|
onRefresh: () async {
|
||||||
removeTop: true,
|
// Refresh both member card and promotions
|
||||||
child: RefreshIndicator(
|
await Future.wait<void>([
|
||||||
onRefresh: () async {
|
ref.read(memberCardProvider.notifier).refresh(),
|
||||||
// Refresh both member card and promotions
|
ref.read(promotionsProvider.notifier).refresh(),
|
||||||
await Future.wait<void>([
|
]);
|
||||||
ref.read(memberCardProvider.notifier).refresh(),
|
},
|
||||||
ref.read(promotionsProvider.notifier).refresh(),
|
child: CustomScrollView(
|
||||||
]);
|
slivers: [
|
||||||
},
|
// Add top padding for status bar
|
||||||
child: CustomScrollView(
|
SliverPadding(
|
||||||
slivers: [
|
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
|
||||||
// App Bar
|
),
|
||||||
// SliverAppBar(
|
|
||||||
// floating: true,
|
|
||||||
// snap: true,
|
|
||||||
// backgroundColor: AppColors.primaryBlue,
|
|
||||||
// title: Text(l10n.home),
|
|
||||||
// centerTitle: true,
|
|
||||||
// ),
|
|
||||||
|
|
||||||
// Member Card Section
|
// Member Card Section
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: memberCardAsync.when(
|
child: memberCardAsync.when(
|
||||||
data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
||||||
loading: () => Container(
|
loading: () => Container(
|
||||||
margin: const EdgeInsets.all(16),
|
margin: const EdgeInsets.all(16),
|
||||||
height: 200,
|
height: 200,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.grey100,
|
color: AppColors.grey100,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
error: (error, stack) => Container(
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
margin: const EdgeInsets.all(16),
|
),
|
||||||
padding: const EdgeInsets.all(16),
|
error: (error, stack) => Container(
|
||||||
decoration: BoxDecoration(
|
margin: const EdgeInsets.all(16),
|
||||||
color: AppColors.danger.withValues(alpha: 0.1),
|
padding: const EdgeInsets.all(16),
|
||||||
borderRadius: BorderRadius.circular(16),
|
decoration: BoxDecoration(
|
||||||
),
|
color: AppColors.danger.withValues(alpha: 0.1),
|
||||||
child: Column(
|
borderRadius: BorderRadius.circular(16),
|
||||||
mainAxisSize: MainAxisSize.min,
|
),
|
||||||
children: [
|
child: Column(
|
||||||
const Icon(
|
mainAxisSize: MainAxisSize.min,
|
||||||
Icons.error_outline,
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
color: AppColors.danger,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
l10n.error,
|
||||||
|
style: const TextStyle(
|
||||||
color: AppColors.danger,
|
color: AppColors.danger,
|
||||||
size: 48,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
Text(
|
const SizedBox(height: 4),
|
||||||
l10n.error,
|
Text(
|
||||||
style: const TextStyle(
|
error.toString(),
|
||||||
color: AppColors.danger,
|
style: const TextStyle(
|
||||||
fontWeight: FontWeight.w600,
|
color: AppColors.grey500,
|
||||||
),
|
fontSize: 12,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
textAlign: TextAlign.center,
|
||||||
Text(
|
),
|
||||||
error.toString(),
|
],
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.grey500,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// Promotions Section
|
// Promotions Section
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: promotionsAsync.when(
|
child: promotionsAsync.when(
|
||||||
data: (promotions) => promotions.isNotEmpty
|
data: (promotions) => promotions.isNotEmpty
|
||||||
? Padding(
|
? Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: PromotionSlider(
|
child: PromotionSlider(
|
||||||
promotions: promotions,
|
promotions: promotions,
|
||||||
onPromotionTap: (promotion) {
|
onPromotionTap: (promotion) {
|
||||||
// TODO: Navigate to promotion details
|
// TODO: Navigate to promotion details
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('${l10n.viewDetails}: ${promotion.title}'),
|
content: Text(
|
||||||
|
'${l10n.viewDetails}: ${promotion.title}',
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
);
|
||||||
),
|
},
|
||||||
)
|
),
|
||||||
: const SizedBox.shrink(),
|
)
|
||||||
loading: () => const Padding(
|
: const SizedBox.shrink(),
|
||||||
padding: EdgeInsets.all(16),
|
loading: () => const Padding(
|
||||||
child: Center(child: CircularProgressIndicator()),
|
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
|
/// Show coming soon message
|
||||||
void _showComingSoon(BuildContext context, String feature, AppLocalizations l10n) {
|
void _showComingSoon(
|
||||||
|
BuildContext context,
|
||||||
|
String feature,
|
||||||
|
AppLocalizations l10n,
|
||||||
|
) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('$feature - ${l10n.comingSoon}'),
|
content: Text('$feature - ${l10n.comingSoon}'),
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class QuickActionItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
icon,
|
icon,
|
||||||
size: 28,
|
size: 20,
|
||||||
color: AppColors.primaryBlue,
|
color: AppColors.primaryBlue,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ class QuickActionSection extends StatelessWidget {
|
|||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: crossAxisCount,
|
crossAxisCount: crossAxisCount,
|
||||||
childAspectRatio: 1.0,
|
childAspectRatio: 1.0,
|
||||||
crossAxisSpacing: 8,
|
crossAxisSpacing: 4,
|
||||||
mainAxisSpacing: 8,
|
mainAxisSpacing: 4,
|
||||||
),
|
),
|
||||||
itemCount: actions.length,
|
itemCount: actions.length,
|
||||||
itemBuilder: (context, index) {
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -248,6 +248,12 @@ abstract class AppLocalizations {
|
|||||||
/// **'Search'**
|
/// **'Search'**
|
||||||
String get search;
|
String get search;
|
||||||
|
|
||||||
|
/// No description provided for @searchProducts.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Search products...'**
|
||||||
|
String get searchProducts;
|
||||||
|
|
||||||
/// No description provided for @filter.
|
/// No description provided for @filter.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get search => 'Search';
|
String get search => 'Search';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchProducts => 'Search products...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get filter => 'Filter';
|
String get filter => 'Filter';
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ class AppLocalizationsVi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get search => 'Tìm kiếm';
|
String get search => 'Tìm kiếm';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get searchProducts => 'Tìm kiếm sản phẩm...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get filter => 'Lọc';
|
String get filter => 'Lọc';
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,13 @@ import 'package:worker/core/database/models/cached_data.dart';
|
|||||||
import 'package:worker/core/database/models/enums.dart';
|
import 'package:worker/core/database/models/enums.dart';
|
||||||
import 'package:worker/features/home/data/models/member_card_model.dart';
|
import 'package:worker/features/home/data/models/member_card_model.dart';
|
||||||
import 'package:worker/features/home/data/models/promotion_model.dart';
|
import 'package:worker/features/home/data/models/promotion_model.dart';
|
||||||
|
import 'package:worker/features/products/data/models/category_model.dart';
|
||||||
|
import 'package:worker/features/products/data/models/product_model.dart';
|
||||||
|
|
||||||
extension HiveRegistrar on HiveInterface {
|
extension HiveRegistrar on HiveInterface {
|
||||||
void registerAdapters() {
|
void registerAdapters() {
|
||||||
registerAdapter(CachedDataAdapter());
|
registerAdapter(CachedDataAdapter());
|
||||||
|
registerAdapter(CategoryModelAdapter());
|
||||||
registerAdapter(GiftStatusAdapter());
|
registerAdapter(GiftStatusAdapter());
|
||||||
registerAdapter(MemberCardModelAdapter());
|
registerAdapter(MemberCardModelAdapter());
|
||||||
registerAdapter(MemberTierAdapter());
|
registerAdapter(MemberTierAdapter());
|
||||||
@@ -18,6 +21,7 @@ extension HiveRegistrar on HiveInterface {
|
|||||||
registerAdapter(OrderStatusAdapter());
|
registerAdapter(OrderStatusAdapter());
|
||||||
registerAdapter(PaymentMethodAdapter());
|
registerAdapter(PaymentMethodAdapter());
|
||||||
registerAdapter(PaymentStatusAdapter());
|
registerAdapter(PaymentStatusAdapter());
|
||||||
|
registerAdapter(ProductModelAdapter());
|
||||||
registerAdapter(ProjectStatusAdapter());
|
registerAdapter(ProjectStatusAdapter());
|
||||||
registerAdapter(ProjectTypeAdapter());
|
registerAdapter(ProjectTypeAdapter());
|
||||||
registerAdapter(PromotionModelAdapter());
|
registerAdapter(PromotionModelAdapter());
|
||||||
@@ -29,6 +33,7 @@ extension HiveRegistrar on HiveInterface {
|
|||||||
extension IsolatedHiveRegistrar on IsolatedHiveInterface {
|
extension IsolatedHiveRegistrar on IsolatedHiveInterface {
|
||||||
void registerAdapters() {
|
void registerAdapters() {
|
||||||
registerAdapter(CachedDataAdapter());
|
registerAdapter(CachedDataAdapter());
|
||||||
|
registerAdapter(CategoryModelAdapter());
|
||||||
registerAdapter(GiftStatusAdapter());
|
registerAdapter(GiftStatusAdapter());
|
||||||
registerAdapter(MemberCardModelAdapter());
|
registerAdapter(MemberCardModelAdapter());
|
||||||
registerAdapter(MemberTierAdapter());
|
registerAdapter(MemberTierAdapter());
|
||||||
@@ -36,6 +41,7 @@ extension IsolatedHiveRegistrar on IsolatedHiveInterface {
|
|||||||
registerAdapter(OrderStatusAdapter());
|
registerAdapter(OrderStatusAdapter());
|
||||||
registerAdapter(PaymentMethodAdapter());
|
registerAdapter(PaymentMethodAdapter());
|
||||||
registerAdapter(PaymentStatusAdapter());
|
registerAdapter(PaymentStatusAdapter());
|
||||||
|
registerAdapter(ProductModelAdapter());
|
||||||
registerAdapter(ProjectStatusAdapter());
|
registerAdapter(ProjectStatusAdapter());
|
||||||
registerAdapter(ProjectTypeAdapter());
|
registerAdapter(ProjectTypeAdapter());
|
||||||
registerAdapter(PromotionModelAdapter());
|
registerAdapter(PromotionModelAdapter());
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
|
"searchProducts": "Search products...",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
"sort": "Sort",
|
"sort": "Sort",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"delete": "Xóa",
|
"delete": "Xóa",
|
||||||
"edit": "Sửa",
|
"edit": "Sửa",
|
||||||
"search": "Tìm kiếm",
|
"search": "Tìm kiếm",
|
||||||
|
"searchProducts": "Tìm kiếm sản phẩm...",
|
||||||
"filter": "Lọc",
|
"filter": "Lọc",
|
||||||
"sort": "Sắp xếp",
|
"sort": "Sắp xếp",
|
||||||
"confirm": "Xác nhận",
|
"confirm": "Xác nhận",
|
||||||
|
|||||||
Reference in New Issue
Block a user