update
This commit is contained in:
312
lib/features/home/presentation/pages/home_page.dart
Normal file
312
lib/features/home/presentation/pages/home_page.dart
Normal file
@@ -0,0 +1,312 @@
|
||||
/// Page: Home Page
|
||||
///
|
||||
/// Main dashboard screen of the Worker app.
|
||||
/// Displays member card, promotions, and quick action sections.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/home/presentation/providers/member_card_provider.dart';
|
||||
import 'package:worker/features/home/presentation/providers/promotions_provider.dart';
|
||||
import 'package:worker/features/home/presentation/widgets/member_card_widget.dart';
|
||||
import 'package:worker/features/home/presentation/widgets/promotion_slider.dart';
|
||||
import 'package:worker/features/home/presentation/widgets/quick_action_section.dart';
|
||||
|
||||
/// Home Page
|
||||
///
|
||||
/// Main entry point of the app after login.
|
||||
/// Shows:
|
||||
/// - Member card (Diamond/Platinum/Gold)
|
||||
/// - Promotional banners
|
||||
/// - Quick action sections
|
||||
/// - Bottom navigation
|
||||
/// - Floating action button (Chat)
|
||||
class HomePage extends ConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch member card state
|
||||
final memberCardAsync = ref.watch(memberCardProvider);
|
||||
|
||||
// Watch promotions state
|
||||
final promotionsAsync = ref.watch(promotionsProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.grey50,
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
// Refresh both member card and promotions
|
||||
await Future.wait<void>([
|
||||
ref.read(memberCardProvider.notifier).refresh(),
|
||||
ref.read(promotionsProvider.notifier).refresh(),
|
||||
]);
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
// App Bar
|
||||
const SliverAppBar(
|
||||
floating: true,
|
||||
snap: true,
|
||||
backgroundColor: AppColors.primaryBlue,
|
||||
title: Text('Trang ch<63>'),
|
||||
centerTitle: true,
|
||||
),
|
||||
|
||||
// Member Card Section
|
||||
SliverToBoxAdapter(
|
||||
child: memberCardAsync.when(
|
||||
data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
||||
loading: () => Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.grey100,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.danger.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: AppColors.danger,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Kh<EFBFBD>ng th<74> t<>i th<74> th<74>nh vi<76>n',
|
||||
style: TextStyle(
|
||||
color: AppColors.danger,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
error.toString(),
|
||||
style: TextStyle(
|
||||
color: AppColors.grey500,
|
||||
fontSize: 12,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Promotions Section
|
||||
SliverToBoxAdapter(
|
||||
child: promotionsAsync.when(
|
||||
data: (promotions) => promotions.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: PromotionSlider(
|
||||
promotions: promotions,
|
||||
onPromotionTap: (promotion) {
|
||||
// TODO: Navigate to promotion details
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Xem chi ti<74>t: ${promotion.title}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (error, stack) => const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
|
||||
// Quick Action Sections
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
// Products & Cart Section
|
||||
QuickActionSection(
|
||||
title: 'S<EFBFBD>n ph<70>m & Gi<47> h<>ng',
|
||||
actions: [
|
||||
QuickAction(
|
||||
icon: Icons.grid_view,
|
||||
label: 'S<EFBFBD>n ph<70>m',
|
||||
onTap: () => _showComingSoon(context, 'S<EFBFBD>n ph<70>m'),
|
||||
),
|
||||
QuickAction(
|
||||
icon: Icons.shopping_cart,
|
||||
label: 'Gi<EFBFBD> h<>ng',
|
||||
badge: '3',
|
||||
onTap: () => _showComingSoon(context, 'Gi<EFBFBD> h<>ng'),
|
||||
),
|
||||
QuickAction(
|
||||
icon: Icons.favorite,
|
||||
label: 'Y<EFBFBD>u th<74>ch',
|
||||
onTap: () => _showComingSoon(context, 'Y<EFBFBD>u th<74>ch'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Loyalty Section
|
||||
QuickActionSection(
|
||||
title: 'Kh<EFBFBD>ch h<>ng th<74>n thi<68>t',
|
||||
actions: [
|
||||
QuickAction(
|
||||
icon: Icons.card_giftcard,
|
||||
label: '<10>i qu<71>',
|
||||
onTap: () => _showComingSoon(context, '<10>i qu<71>'),
|
||||
),
|
||||
QuickAction(
|
||||
icon: Icons.history,
|
||||
label: 'L<EFBFBD>ch s<> i<>m',
|
||||
onTap: () => _showComingSoon(context, 'L<EFBFBD>ch s<> i<>m'),
|
||||
),
|
||||
QuickAction(
|
||||
icon: Icons.person_add,
|
||||
label: 'Gi<EFBFBD>i thi<68>u b<>n',
|
||||
onTap: () => _showComingSoon(context, 'Gi<EFBFBD>i thi<68>u b<>n'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Quote Requests Section
|
||||
QuickActionSection(
|
||||
title: 'Y<EFBFBD>u c<>u b<>o gi<67> & b<>o gi<67>',
|
||||
actions: [
|
||||
QuickAction(
|
||||
icon: Icons.description,
|
||||
label: 'Y<EFBFBD>u c<>u b<>o gi<67>',
|
||||
onTap: () => _showComingSoon(context, 'Y<EFBFBD>u c<>u b<>o gi<67>'),
|
||||
),
|
||||
QuickAction(
|
||||
icon: Icons.receipt_long,
|
||||
label: 'B<EFBFBD>o gi<67>',
|
||||
onTap: () => _showComingSoon(context, 'B<EFBFBD>o gi<67>'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Orders & Payments Section
|
||||
QuickActionSection(
|
||||
title: '<10>n h<>ng & thanh to<74>n',
|
||||
actions: [
|
||||
QuickAction(
|
||||
icon: Icons.inventory_2,
|
||||
label: '<10>n h<>ng',
|
||||
onTap: () => _showComingSoon(context, '<10>n h<>ng'),
|
||||
),
|
||||
QuickAction(
|
||||
icon: Icons.payment,
|
||||
label: 'Thanh to<74>n',
|
||||
onTap: () => _showComingSoon(context, 'Thanh to<74>n'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Sample Houses & News Section
|
||||
QuickActionSection(
|
||||
title: 'Nh<EFBFBD> m<>u, d<> <20>n & tin t<>c',
|
||||
actions: [
|
||||
QuickAction(
|
||||
icon: Icons.home_work,
|
||||
label: 'Nh<EFBFBD> m<>u',
|
||||
onTap: () => _showComingSoon(context, 'Nh<EFBFBD> m<>u'),
|
||||
),
|
||||
QuickAction(
|
||||
icon: Icons.business,
|
||||
label: 'ng k<> d<> <20>n',
|
||||
onTap: () => _showComingSoon(context, 'ng k<> d<> <20>n'),
|
||||
),
|
||||
QuickAction(
|
||||
icon: Icons.article,
|
||||
label: 'Tin t<>c',
|
||||
onTap: () => _showComingSoon(context, 'Tin t<>c'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Bottom Padding (for FAB clearance)
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Floating Action Button (Chat)
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showComingSoon(context, 'Chat'),
|
||||
backgroundColor: AppColors.accentCyan,
|
||||
child: const Icon(Icons.chat_bubble),
|
||||
),
|
||||
|
||||
// Bottom Navigation Bar
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
currentIndex: 0,
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home),
|
||||
label: 'Trang ch<63>',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.loyalty),
|
||||
label: 'H<EFBFBD>i vi<76>n',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.local_offer),
|
||||
label: 'Khuy<EFBFBD>n m<>i',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Badge(
|
||||
label: Text('5'),
|
||||
child: Icon(Icons.notifications),
|
||||
),
|
||||
label: 'Th<EFBFBD>ng b<>o',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(Icons.account_circle),
|
||||
label: 'C<EFBFBD>i <11>t',
|
||||
),
|
||||
],
|
||||
onTap: (index) {
|
||||
// TODO: Implement navigation
|
||||
final labels = [
|
||||
'Trang ch<63>',
|
||||
'H<EFBFBD>i vi<76>n',
|
||||
'Khuy<EFBFBD>n m<>i',
|
||||
'Th<EFBFBD>ng b<>o',
|
||||
'C<EFBFBD>i <11>t'
|
||||
];
|
||||
_showComingSoon(context, labels[index]);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show coming soon message
|
||||
void _showComingSoon(BuildContext context, String feature) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('$feature - S<>p ra m<>t'),
|
||||
duration: const Duration(seconds: 1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/// Provider: Member Card Provider
|
||||
///
|
||||
/// Manages the state of the member card data using Riverpod.
|
||||
/// Provides access to member card information throughout the app.
|
||||
///
|
||||
/// Uses AsyncNotifierProvider for automatic loading, error, and data states.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/home/data/datasources/home_local_datasource.dart';
|
||||
import 'package:worker/features/home/data/repositories/home_repository_impl.dart';
|
||||
import 'package:worker/features/home/domain/entities/member_card.dart';
|
||||
import 'package:worker/features/home/domain/usecases/get_member_card.dart';
|
||||
|
||||
part 'member_card_provider.g.dart';
|
||||
|
||||
/// Member Card Provider
|
||||
///
|
||||
/// Fetches and caches the current user's member card.
|
||||
/// Automatically handles loading, error, and data states.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // In a ConsumerWidget
|
||||
/// final memberCardAsync = ref.watch(memberCardProvider);
|
||||
///
|
||||
/// memberCardAsync.when(
|
||||
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
@riverpod
|
||||
class MemberCardNotifier extends _$MemberCardNotifier {
|
||||
@override
|
||||
Future<MemberCard> build() async {
|
||||
// Initialize dependencies
|
||||
final localDataSource = const HomeLocalDataSourceImpl();
|
||||
final repository = HomeRepositoryImpl(localDataSource: localDataSource);
|
||||
final useCase = GetMemberCard(repository);
|
||||
|
||||
// Fetch member card
|
||||
return await useCase();
|
||||
}
|
||||
|
||||
/// Refresh member card data
|
||||
///
|
||||
/// Forces a refresh from the server (when API is available).
|
||||
/// Updates the cached state with fresh data.
|
||||
Future<void> refresh() async {
|
||||
// Set loading state
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
// Fetch fresh data
|
||||
state = await AsyncValue.guard(() async {
|
||||
final localDataSource = const HomeLocalDataSourceImpl();
|
||||
final repository = HomeRepositoryImpl(localDataSource: localDataSource);
|
||||
final useCase = GetMemberCard(repository);
|
||||
|
||||
return await useCase.refresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'member_card_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Member Card Provider
|
||||
///
|
||||
/// Fetches and caches the current user's member card.
|
||||
/// Automatically handles loading, error, and data states.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // In a ConsumerWidget
|
||||
/// final memberCardAsync = ref.watch(memberCardProvider);
|
||||
///
|
||||
/// memberCardAsync.when(
|
||||
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
@ProviderFor(MemberCardNotifier)
|
||||
const memberCardProvider = MemberCardNotifierProvider._();
|
||||
|
||||
/// Member Card Provider
|
||||
///
|
||||
/// Fetches and caches the current user's member card.
|
||||
/// Automatically handles loading, error, and data states.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // In a ConsumerWidget
|
||||
/// final memberCardAsync = ref.watch(memberCardProvider);
|
||||
///
|
||||
/// memberCardAsync.when(
|
||||
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
final class MemberCardNotifierProvider
|
||||
extends $AsyncNotifierProvider<MemberCardNotifier, MemberCard> {
|
||||
/// Member Card Provider
|
||||
///
|
||||
/// Fetches and caches the current user's member card.
|
||||
/// Automatically handles loading, error, and data states.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // In a ConsumerWidget
|
||||
/// final memberCardAsync = ref.watch(memberCardProvider);
|
||||
///
|
||||
/// memberCardAsync.when(
|
||||
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
const MemberCardNotifierProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'memberCardProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$memberCardNotifierHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
MemberCardNotifier create() => MemberCardNotifier();
|
||||
}
|
||||
|
||||
String _$memberCardNotifierHash() =>
|
||||
r'6b9447a8b91b85fac3cc7ed58222743a7a829e22';
|
||||
|
||||
/// Member Card Provider
|
||||
///
|
||||
/// Fetches and caches the current user's member card.
|
||||
/// Automatically handles loading, error, and data states.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // In a ConsumerWidget
|
||||
/// final memberCardAsync = ref.watch(memberCardProvider);
|
||||
///
|
||||
/// memberCardAsync.when(
|
||||
/// data: (memberCard) => MemberCardWidget(memberCard: memberCard),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
abstract class _$MemberCardNotifier extends $AsyncNotifier<MemberCard> {
|
||||
FutureOr<MemberCard> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<AsyncValue<MemberCard>, MemberCard>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<MemberCard>, MemberCard>,
|
||||
AsyncValue<MemberCard>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/// Provider: Promotions Provider
|
||||
///
|
||||
/// Manages the state of promotions data using Riverpod.
|
||||
/// Provides access to active promotions throughout the app.
|
||||
///
|
||||
/// Uses AsyncNotifierProvider for automatic loading, error, and data states.
|
||||
library;
|
||||
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:worker/features/home/data/datasources/home_local_datasource.dart';
|
||||
import 'package:worker/features/home/data/repositories/home_repository_impl.dart';
|
||||
import 'package:worker/features/home/domain/entities/promotion.dart';
|
||||
import 'package:worker/features/home/domain/usecases/get_promotions.dart';
|
||||
|
||||
part 'promotions_provider.g.dart';
|
||||
|
||||
/// Promotions Provider
|
||||
///
|
||||
/// Fetches and caches the list of active promotions.
|
||||
/// Automatically handles loading, error, and data states.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // In a ConsumerWidget
|
||||
/// final promotionsAsync = ref.watch(promotionsProvider);
|
||||
///
|
||||
/// promotionsAsync.when(
|
||||
/// data: (promotions) => PromotionSlider(promotions: promotions),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
@riverpod
|
||||
class PromotionsNotifier extends _$PromotionsNotifier {
|
||||
@override
|
||||
Future<List<Promotion>> build() async {
|
||||
// Initialize dependencies
|
||||
final localDataSource = const HomeLocalDataSourceImpl();
|
||||
final repository = HomeRepositoryImpl(localDataSource: localDataSource);
|
||||
final useCase = GetPromotions(repository);
|
||||
|
||||
// Fetch promotions (only active ones)
|
||||
return await useCase();
|
||||
}
|
||||
|
||||
/// Refresh promotions data
|
||||
///
|
||||
/// Forces a refresh from the server (when API is available).
|
||||
/// Updates the cached state with fresh data.
|
||||
Future<void> refresh() async {
|
||||
// Set loading state
|
||||
state = const AsyncValue.loading();
|
||||
|
||||
// Fetch fresh data
|
||||
state = await AsyncValue.guard(() async {
|
||||
final localDataSource = const HomeLocalDataSourceImpl();
|
||||
final repository = HomeRepositoryImpl(localDataSource: localDataSource);
|
||||
final useCase = GetPromotions(repository);
|
||||
|
||||
return await useCase.refresh();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'promotions_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Promotions Provider
|
||||
///
|
||||
/// Fetches and caches the list of active promotions.
|
||||
/// Automatically handles loading, error, and data states.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // In a ConsumerWidget
|
||||
/// final promotionsAsync = ref.watch(promotionsProvider);
|
||||
///
|
||||
/// promotionsAsync.when(
|
||||
/// data: (promotions) => PromotionSlider(promotions: promotions),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
@ProviderFor(PromotionsNotifier)
|
||||
const promotionsProvider = PromotionsNotifierProvider._();
|
||||
|
||||
/// Promotions Provider
|
||||
///
|
||||
/// Fetches and caches the list of active promotions.
|
||||
/// Automatically handles loading, error, and data states.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // In a ConsumerWidget
|
||||
/// final promotionsAsync = ref.watch(promotionsProvider);
|
||||
///
|
||||
/// promotionsAsync.when(
|
||||
/// data: (promotions) => PromotionSlider(promotions: promotions),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
final class PromotionsNotifierProvider
|
||||
extends $AsyncNotifierProvider<PromotionsNotifier, List<Promotion>> {
|
||||
/// Promotions Provider
|
||||
///
|
||||
/// Fetches and caches the list of active promotions.
|
||||
/// Automatically handles loading, error, and data states.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // In a ConsumerWidget
|
||||
/// final promotionsAsync = ref.watch(promotionsProvider);
|
||||
///
|
||||
/// promotionsAsync.when(
|
||||
/// data: (promotions) => PromotionSlider(promotions: promotions),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
const PromotionsNotifierProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'promotionsProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$promotionsNotifierHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
PromotionsNotifier create() => PromotionsNotifier();
|
||||
}
|
||||
|
||||
String _$promotionsNotifierHash() =>
|
||||
r'3cd866c74ba11c6519e9b63521e1757ef117c7a9';
|
||||
|
||||
/// Promotions Provider
|
||||
///
|
||||
/// Fetches and caches the list of active promotions.
|
||||
/// Automatically handles loading, error, and data states.
|
||||
///
|
||||
/// Usage:
|
||||
/// ```dart
|
||||
/// // In a ConsumerWidget
|
||||
/// final promotionsAsync = ref.watch(promotionsProvider);
|
||||
///
|
||||
/// promotionsAsync.when(
|
||||
/// data: (promotions) => PromotionSlider(promotions: promotions),
|
||||
/// loading: () => CircularProgressIndicator(),
|
||||
/// error: (error, stack) => ErrorWidget(error),
|
||||
/// );
|
||||
/// ```
|
||||
|
||||
abstract class _$PromotionsNotifier extends $AsyncNotifier<List<Promotion>> {
|
||||
FutureOr<List<Promotion>> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<AsyncValue<List<Promotion>>, List<Promotion>>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<List<Promotion>>, List<Promotion>>,
|
||||
AsyncValue<List<Promotion>>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
192
lib/features/home/presentation/widgets/member_card_widget.dart
Normal file
192
lib/features/home/presentation/widgets/member_card_widget.dart
Normal file
@@ -0,0 +1,192 @@
|
||||
/// Widget: Member Card Widget
|
||||
///
|
||||
/// Displays a user's membership card with tier-specific styling.
|
||||
/// Shows member information, points, QR code, and tier badge.
|
||||
///
|
||||
/// Supports three tiers: Diamond, Platinum, Gold
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/home/domain/entities/member_card.dart';
|
||||
|
||||
/// Member Card Widget
|
||||
///
|
||||
/// Renders a beautiful gradient card displaying member information.
|
||||
/// The gradient and styling changes based on the membership tier.
|
||||
class MemberCardWidget extends StatelessWidget {
|
||||
/// Member card data
|
||||
final MemberCard memberCard;
|
||||
|
||||
const MemberCardWidget({
|
||||
super.key,
|
||||
required this.memberCard,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
gradient: _getGradientForTier(memberCard.tier),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header Row: Branding and Valid Until
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Branding
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'EUROTILE',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
memberCard.memberType.displayName,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Valid Until
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'Valid through',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_formatDate(memberCard.validUntil),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// Footer Row: Member Info and QR Code
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// Member Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
memberCard.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'CLASS: ${memberCard.tier.displayName}',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Points: ${_formatPoints(memberCard.points)}',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// QR Code
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: memberCard.qrData,
|
||||
version: QrVersions.auto,
|
||||
size: 60,
|
||||
backgroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get gradient for tier
|
||||
LinearGradient _getGradientForTier(MemberTier tier) {
|
||||
switch (tier) {
|
||||
case MemberTier.diamond:
|
||||
return AppColors.diamondGradient;
|
||||
case MemberTier.platinum:
|
||||
return AppColors.platinumGradient;
|
||||
case MemberTier.gold:
|
||||
return AppColors.goldGradient;
|
||||
}
|
||||
}
|
||||
|
||||
/// Format date to DD/MM/YYYY
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
|
||||
}
|
||||
|
||||
/// Format points with thousands separator
|
||||
String _formatPoints(int points) {
|
||||
return points.toString().replaceAllMapped(
|
||||
RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'),
|
||||
(Match m) => '${m[1]},',
|
||||
);
|
||||
}
|
||||
}
|
||||
164
lib/features/home/presentation/widgets/promotion_slider.dart
Normal file
164
lib/features/home/presentation/widgets/promotion_slider.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
/// Widget: Promotion Slider
|
||||
///
|
||||
/// Horizontal scrolling list of promotional banners.
|
||||
/// Displays promotion images, titles, and descriptions.
|
||||
library;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
import 'package:worker/features/home/domain/entities/promotion.dart';
|
||||
|
||||
/// Promotion Slider Widget
|
||||
///
|
||||
/// Displays a horizontal scrollable list of promotion cards.
|
||||
/// Each card shows an image, title, and brief description.
|
||||
class PromotionSlider extends StatelessWidget {
|
||||
/// List of promotions to display
|
||||
final List<Promotion> promotions;
|
||||
|
||||
/// Callback when a promotion is tapped
|
||||
final void Function(Promotion promotion)? onPromotionTap;
|
||||
|
||||
const PromotionSlider({
|
||||
super.key,
|
||||
required this.promotions,
|
||||
this.onPromotionTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (promotions.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'Chương trình ưu đãi',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: promotions.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _PromotionCard(
|
||||
promotion: promotions[index],
|
||||
onTap: onPromotionTap != null
|
||||
? () => onPromotionTap!(promotions[index])
|
||||
: null,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual Promotion Card
|
||||
class _PromotionCard extends StatelessWidget {
|
||||
final Promotion promotion;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _PromotionCard({
|
||||
required this.promotion,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 280,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Promotion Image
|
||||
ClipRRect(
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: promotion.imageUrl,
|
||||
height: 140,
|
||||
width: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
height: 140,
|
||||
color: AppColors.grey100,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
height: 140,
|
||||
color: AppColors.grey100,
|
||||
child: const Icon(
|
||||
Icons.image_not_supported,
|
||||
size: 48,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Promotion Info
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
promotion.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
promotion.description,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.grey500,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
109
lib/features/home/presentation/widgets/quick_action_item.dart
Normal file
109
lib/features/home/presentation/widgets/quick_action_item.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
/// Widget: Quick Action Item
|
||||
///
|
||||
/// Individual action button with icon and label.
|
||||
/// Used in quick action grids on the home screen.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:worker/core/theme/colors.dart';
|
||||
|
||||
/// Quick Action Item Widget
|
||||
///
|
||||
/// Displays an icon button with a label below.
|
||||
/// Supports optional badge for notifications or counts.
|
||||
class QuickActionItem extends StatelessWidget {
|
||||
/// Icon to display
|
||||
final IconData icon;
|
||||
|
||||
/// Label text
|
||||
final String label;
|
||||
|
||||
/// Optional badge text (e.g., "3" for cart items)
|
||||
final String? badge;
|
||||
|
||||
/// Tap callback
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const QuickActionItem({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.badge,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Icon with optional badge
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryBlue.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 28,
|
||||
color: AppColors.primaryBlue,
|
||||
),
|
||||
),
|
||||
// Badge
|
||||
if (badge != null && badge!.isNotEmpty)
|
||||
Positioned(
|
||||
top: -4,
|
||||
right: -4,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.danger,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 20,
|
||||
minHeight: 20,
|
||||
),
|
||||
child: Text(
|
||||
badge!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Label
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/// Widget: Quick Action Section
|
||||
///
|
||||
/// Section container with title and grid of action items.
|
||||
/// Groups related actions together (e.g., Products & Cart, Loyalty, etc.)
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:worker/features/home/presentation/widgets/quick_action_item.dart';
|
||||
|
||||
/// Quick Action Section Data Model
|
||||
class QuickAction {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String? badge;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const QuickAction({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
this.badge,
|
||||
this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
/// Quick Action Section Widget
|
||||
///
|
||||
/// Displays a titled card containing a grid of action buttons.
|
||||
/// Each section groups related functionality.
|
||||
class QuickActionSection extends StatelessWidget {
|
||||
/// Section title
|
||||
final String title;
|
||||
|
||||
/// List of actions in this section
|
||||
final List<QuickAction> actions;
|
||||
|
||||
const QuickActionSection({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.actions,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section Title
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Action Grid
|
||||
_buildActionGrid(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionGrid() {
|
||||
// Determine grid layout based on number of items
|
||||
final int crossAxisCount = actions.length <= 2 ? 2 : 3;
|
||||
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
childAspectRatio: 1.0,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemCount: actions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final action = actions[index];
|
||||
return QuickActionItem(
|
||||
icon: action.icon,
|
||||
label: action.label,
|
||||
badge: action.badge,
|
||||
onTap: action.onTap,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user