add promotion/detail

This commit is contained in:
Phuoc Nguyen
2025-10-24 14:42:14 +07:00
parent fbeaa3c9e8
commit 338d26a38a
12 changed files with 1681 additions and 122 deletions

View File

@@ -6,8 +6,10 @@ library;
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/features/home/presentation/pages/home_page.dart';
import 'package:worker/features/home/domain/entities/promotion.dart';
import 'package:worker/features/main/presentation/pages/main_scaffold.dart';
import 'package:worker/features/products/presentation/pages/products_page.dart';
import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart';
/// App Router
///
@@ -25,17 +27,17 @@ class AppRouter {
// Route definitions
routes: [
// Home Route
// Main Route (with bottom navigation)
GoRoute(
path: RouteNames.home,
name: RouteNames.home,
pageBuilder: (context, state) => MaterialPage(
key: state.pageKey,
child: const HomePage(),
child: const MainScaffold(),
),
),
// Products Route
// Products Route (full screen, no bottom nav)
GoRoute(
path: RouteNames.products,
name: RouteNames.products,
@@ -45,6 +47,19 @@ class AppRouter {
),
),
// Promotion Detail Route
GoRoute(
path: RouteNames.promotionDetail,
name: RouteNames.promotionDetail,
pageBuilder: (context, state) {
final promotionId = state.pathParameters['id'];
return MaterialPage(
key: state.pageKey,
child: PromotionDetailPage(promotionId: promotionId),
);
},
),
// TODO: Add more routes as features are implemented
],

View File

@@ -105,14 +105,8 @@ class HomePage extends ConsumerWidget {
? PromotionSlider(
promotions: promotions,
onPromotionTap: (promotion) {
// TODO: Navigate to promotion details
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${l10n.viewDetails}: ${promotion.title}',
),
),
);
// Navigate to promotion details
context.push('/promotions/${promotion.id}');
},
)
: const SizedBox.shrink(),
@@ -225,7 +219,7 @@ class HomePage extends ConsumerWidget {
],
),
// Bottom Padding (for FAB and bottom nav clearance)
// Bottom Padding (for bottom nav clearance)
const SizedBox(height: 100),
],
),
@@ -233,112 +227,6 @@ class HomePage extends ConsumerWidget {
),
],
),
// Floating Action Button (Chat) - positioned like HTML: bottom: 90px
floatingActionButton: Padding(
padding: const EdgeInsets.only(bottom: 20), // 90px - 70px (bottom nav)
child: FloatingActionButton(
onPressed: () => _showComingSoon(context, l10n.chat, l10n),
backgroundColor: AppColors.accentCyan,
elevation: 8,
child: const Icon(Icons.chat_bubble, size: 24, color: Colors.white),
),
),
// Bottom Navigation Bar
bottomNavigationBar: Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: SizedBox(
height: 70,
child: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
backgroundColor: Colors.white,
selectedItemColor: AppColors.primaryBlue,
unselectedItemColor: const Color(0xFF666666),
selectedFontSize: 11,
unselectedFontSize: 11,
iconSize: 24,
currentIndex: 0,
elevation: 0,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.home),
label: 'Trang chủ',
),
BottomNavigationBarItem(
icon: const Icon(Icons.loyalty),
label: 'Hội viên',
),
BottomNavigationBarItem(
icon: const Icon(Icons.local_offer),
label: 'Khuyến mãi',
),
BottomNavigationBarItem(
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.notifications),
Positioned(
top: -4,
right: -4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.danger,
borderRadius: BorderRadius.circular(12),
),
constraints: const BoxConstraints(
minWidth: 20,
minHeight: 20,
),
child: const Text(
'5',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
),
),
],
),
label: 'Thông báo',
),
BottomNavigationBarItem(
icon: const Icon(Icons.account_circle),
label: 'Cài đặt',
),
],
onTap: (index) {
// TODO: Implement navigation
final labels = [
'Trang chủ',
'Hội viên',
'Khuyến mãi',
'Thông báo',
'Cài đặt',
];
_showComingSoon(context, labels[index], l10n);
},
),
),
),
),
);
}

View File

@@ -6,6 +6,8 @@ library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/home/domain/entities/promotion.dart';
@@ -58,9 +60,17 @@ class PromotionSlider extends StatelessWidget {
itemBuilder: (context, index) {
return _PromotionCard(
promotion: promotions[index],
onTap: onPromotionTap != null
? () => onPromotionTap!(promotions[index])
: null,
onTap: () {
if (onPromotionTap != null) {
onPromotionTap!(promotions[index]);
} else {
// Navigate to promotion detail page
context.pushNamed(
RouteNames.promotionDetail,
extra: promotions[index],
);
}
},
);
},
),

View File

@@ -0,0 +1,216 @@
/// Main Scaffold with Bottom Navigation
///
/// Root navigation wrapper that manages the bottom navigation bar
/// and displays different pages based on the selected tab.
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/pages/home_page.dart';
import 'package:worker/features/main/presentation/providers/current_page_provider.dart';
import 'package:worker/features/promotions/presentation/pages/promotions_page.dart';
/// Main Scaffold Page
///
/// Manages bottom navigation and page switching for:
/// - Home (index 0)
/// - Loyalty (index 1) - Coming soon
/// - Promotions (index 2)
/// - Notifications (index 3) - Coming soon
/// - Account (index 4) - Coming soon
class MainScaffold extends ConsumerWidget {
const MainScaffold({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentIndex = ref.watch(currentPageIndexProvider);
// Define pages
final pages = [
const HomePage(),
_buildComingSoonPage('Hội viên'), // Loyalty
const PromotionsPage(),
_buildComingSoonPage('Thông báo'), // Notifications
_buildComingSoonPage('Cài đặt'), // Account
];
return Scaffold(
body: IndexedStack(
index: currentIndex,
children: pages,
),
floatingActionButton: currentIndex == 0
? Padding(
padding: const EdgeInsets.only(bottom: 20),
child: FloatingActionButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Chat - Đang phát triển'),
duration: Duration(seconds: 1),
),
);
},
backgroundColor: AppColors.accentCyan,
elevation: 8,
child: const Icon(Icons.chat_bubble, size: 24, color: Colors.white),
),
)
: null,
bottomNavigationBar: Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: SizedBox(
height: 70,
child: BottomNavigationBar(
type: BottomNavigationBarType.fixed,
backgroundColor: Colors.white,
selectedItemColor: AppColors.primaryBlue,
unselectedItemColor: const Color(0xFF666666),
selectedFontSize: 11,
unselectedFontSize: 11,
iconSize: 24,
currentIndex: currentIndex,
elevation: 0,
items: [
const BottomNavigationBarItem(
icon: Icon(Icons.home),
label: 'Trang chủ',
),
const BottomNavigationBarItem(
icon: Icon(Icons.loyalty),
label: 'Hội viên',
),
const BottomNavigationBarItem(
icon: Icon(Icons.local_offer),
label: 'Khuyến mãi',
),
BottomNavigationBarItem(
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.notifications),
Positioned(
top: -4,
right: -4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.danger,
borderRadius: BorderRadius.circular(12),
),
constraints: const BoxConstraints(
minWidth: 20,
minHeight: 20,
),
child: const Text(
'5',
style: TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
),
),
],
),
label: 'Thông báo',
),
const BottomNavigationBarItem(
icon: Icon(Icons.account_circle),
label: 'Cài đặt',
),
],
onTap: (index) {
ref.read(currentPageIndexProvider.notifier).setIndex(index);
},
),
),
),
),
);
}
/// Build coming soon placeholder page
Widget _buildComingSoonPage(String title) {
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
body: SafeArea(
child: Column(
children: [
// Header
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
),
),
),
// Coming soon content
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.construction,
size: 80,
color: AppColors.grey500.withValues(alpha: 0.5),
),
const SizedBox(height: 16),
const Text(
'Đang phát triển',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.grey500,
),
),
const SizedBox(height: 8),
const Text(
'Tính năng này sẽ sớm ra mắt',
style: TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
),
],
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,30 @@
/// Provider: Current Page Index Provider
///
/// Manages the state of the current bottom navigation page index.
library;
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'current_page_provider.g.dart';
/// Current Page Index Notifier
///
/// Manages which page is currently displayed in the bottom navigation.
/// Pages:
/// - 0: Home
/// - 1: Loyalty
/// - 2: Promotions
/// - 3: Notifications
/// - 4: Account
@riverpod
class CurrentPageIndex extends _$CurrentPageIndex {
@override
int build() => 0;
/// Set the current page index
void setIndex(int index) {
if (index >= 0 && index <= 4) {
state = index;
}
}
}

View File

@@ -0,0 +1,100 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'current_page_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Current Page Index Notifier
///
/// Manages which page is currently displayed in the bottom navigation.
/// Pages:
/// - 0: Home
/// - 1: Loyalty
/// - 2: Promotions
/// - 3: Notifications
/// - 4: Account
@ProviderFor(CurrentPageIndex)
const currentPageIndexProvider = CurrentPageIndexProvider._();
/// Current Page Index Notifier
///
/// Manages which page is currently displayed in the bottom navigation.
/// Pages:
/// - 0: Home
/// - 1: Loyalty
/// - 2: Promotions
/// - 3: Notifications
/// - 4: Account
final class CurrentPageIndexProvider
extends $NotifierProvider<CurrentPageIndex, int> {
/// Current Page Index Notifier
///
/// Manages which page is currently displayed in the bottom navigation.
/// Pages:
/// - 0: Home
/// - 1: Loyalty
/// - 2: Promotions
/// - 3: Notifications
/// - 4: Account
const CurrentPageIndexProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'currentPageIndexProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$currentPageIndexHash();
@$internal
@override
CurrentPageIndex create() => CurrentPageIndex();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
}
String _$currentPageIndexHash() => r'677ac5cabc001e152a7a79cc7fb7d3789ad49545';
/// Current Page Index Notifier
///
/// Manages which page is currently displayed in the bottom navigation.
/// Pages:
/// - 0: Home
/// - 1: Loyalty
/// - 2: Promotions
/// - 3: Notifications
/// - 4: Account
abstract class _$CurrentPageIndex extends $Notifier<int> {
int build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<int, int>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<int, int>,
int,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,501 @@
/// Promotion Detail Page
///
/// Displays full details of a selected promotion including:
/// - Banner image
/// - Title and date range
/// - Program content
/// - Terms and conditions
/// - Contact information
library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/home/domain/entities/promotion.dart';
import 'package:worker/features/home/presentation/providers/promotions_provider.dart';
import 'package:worker/features/promotions/presentation/widgets/highlight_box.dart';
import 'package:worker/features/promotions/presentation/widgets/promotion_section.dart';
/// Promotion Detail Page
///
/// Full-screen detail view of a promotion with scrollable content
/// and fixed bottom action bar.
class PromotionDetailPage extends ConsumerStatefulWidget {
const PromotionDetailPage({
this.promotionId,
super.key,
});
/// Promotion ID
final String? promotionId;
@override
ConsumerState<PromotionDetailPage> createState() =>
_PromotionDetailPageState();
}
class _PromotionDetailPageState extends ConsumerState<PromotionDetailPage> {
bool _isBookmarked = false;
@override
Widget build(BuildContext context) {
// Watch promotions provider
final promotionsAsync = ref.watch(promotionsProvider);
return promotionsAsync.when(
data: (promotions) {
// Find promotion by ID
final promotion = promotions.firstWhere(
(p) => p.id == widget.promotionId,
orElse: () => promotions.first,
);
return _buildDetailContent(promotion);
},
loading: () => Scaffold(
appBar: AppBar(
title: const Text('Chi tiết khuyến mãi'),
backgroundColor: Colors.white,
foregroundColor: AppColors.primaryBlue,
elevation: 1,
),
body: const Center(
child: CircularProgressIndicator(),
),
),
error: (error, stack) => Scaffold(
appBar: AppBar(
title: const Text('Chi tiết khuyến mãi'),
backgroundColor: Colors.white,
foregroundColor: AppColors.primaryBlue,
elevation: 1,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppColors.danger,
),
const SizedBox(height: 16),
const Text(
'Không thể tải thông tin khuyến mãi',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
error.toString(),
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
textAlign: TextAlign.center,
),
],
),
),
),
);
}
Widget _buildDetailContent(Promotion promotion) {
return Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
// Scrollable Content
CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
pinned: true,
// backgroundColor: Colors.white,
foregroundColor: AppColors.primaryBlue,
elevation: 1,
shadowColor: Colors.black.withValues(alpha: 0.1),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
),
title: const Text(
'Chi tiết khuyến mãi',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
actions: [
// Share Button
IconButton(
icon: const Icon(Icons.share),
color: const Color(0xFF64748B),
onPressed: _handleShare,
),
// Bookmark Button
IconButton(
icon: Icon(
_isBookmarked ? Icons.bookmark : Icons.bookmark_border,
),
color: const Color(0xFF64748B),
onPressed: _handleBookmark,
),
],
),
// Content
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Banner Image
_buildBannerImage(promotion),
// Promotion Header
_buildPromotionHeader(promotion),
// Program Content Section
_buildProgramContentSection(),
// Terms & Conditions Section
_buildTermsSection(),
// Contact Info Section
_buildContactSection(),
// Bottom padding for action bar
const SizedBox(height: 100),
],
),
),
],
),
// Fixed Bottom Action Bar
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildActionBar(),
),
],
),
);
}
/// Build banner image section
Widget _buildBannerImage(Promotion promotion) {
return CachedNetworkImage(
imageUrl: promotion.imageUrl,
width: double.infinity,
height: 200,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
height: 200,
color: AppColors.grey100,
child: const Center(
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => Container(
height: 200,
color: AppColors.grey100,
child: const Center(
child: Icon(
Icons.image_not_supported,
size: 64,
color: AppColors.grey500,
),
),
),
);
}
/// Build promotion header with title and date
Widget _buildPromotionHeader(Promotion promotion) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 16),
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(
color: Color(0xFFE2E8F0),
width: 1,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Text(
promotion.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Color(0xFF1E293B),
height: 1.3,
),
),
const SizedBox(height: 12),
// Date Range and Status
Wrap(
spacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
// Clock icon and date
const Icon(
Icons.access_time,
size: 18,
color: Color(0xFFF59E0B), // warning color
),
Text(
_formatDateRange(promotion),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFFF59E0B),
),
),
// Status Badge
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFF10B981), // success color
borderRadius: BorderRadius.circular(16),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.local_fire_department,
size: 14,
color: Colors.white,
),
SizedBox(width: 4),
Text(
'Đang diễn ra',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
],
),
),
],
),
],
),
);
}
/// Build program content section
Widget _buildProgramContentSection() {
return const PromotionSection(
title: 'Nội dung chương trình',
icon: Icons.card_giftcard,
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PromotionContentText(
'Chương trình khuyến mãi đặc biệt dành cho các công trình xây dựng với mức giảm giá hấp dẫn nhất trong năm.',
),
// Highlight Box
HighlightBox(
emoji: '🎉',
text: 'Giảm giá lên đến 30% cho tất cả sản phẩm gạch men cao cấp',
),
// Discount Details
PromotionContentText(
'Ưu đãi chi tiết:',
isBold: true,
),
PromotionBulletList(
items: [
'Gạch men 60x60cm: Giảm 25% - 30%',
'Gạch men 80x80cm: Giảm 20% - 25%',
'Gạch men 120x60cm: Giảm 15% - 20%',
'Gạch granite 60x60cm: Giảm 20% - 25%',
'Gạch ốp tường: Giảm 15% - 20%',
],
),
SizedBox(height: 16),
// Additional Benefits
PromotionContentText(
'Ưu đãi bổ sung:',
isBold: true,
),
PromotionBulletList(
items: [
'Miễn phí vận chuyển cho đơn hàng từ 500m²',
'Tặng keo dán gạch cho đơn hàng từ 200m²',
'Hỗ trợ thiết kế 3D miễn phí',
'Bảo hành sản phẩm lên đến 15 năm',
],
),
],
),
);
}
/// Build terms and conditions section
Widget _buildTermsSection() {
return const PromotionSection(
title: 'Điều kiện áp dụng',
icon: Icons.description,
content: PromotionBulletList(
items: [
'Áp dụng cho tất cả khách hàng là thợ xây dựng đã đăng ký tài khoản',
'Đơn hàng tối thiểu: 50m² sản phẩm gạch men',
'Thanh toán tối thiểu 50% giá trị đơn hàng khi đặt',
'Không áp dụng đồng thời với các chương trình khuyến mãi khác',
'Giá đã bao gồm VAT, chưa bao gồm phí vận chuyển',
'Sản phẩm không áp dụng đổi trả sau khi đã cắt, gia công',
'Thời gian giao hàng: 3-7 ngày làm việc tùy theo khu vực',
'Khuyến mãi có thể kết thúc sớm nếu hết hàng',
],
),
);
}
/// Build contact information section
Widget _buildContactSection() {
return const PromotionSection(
title: 'Thông tin liên hệ',
icon: Icons.phone,
isLast: true,
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ContactInfo(
label: 'Hotline',
value: '1900-xxxx (8:00 - 18:00 hàng ngày)',
),
ContactInfo(
label: 'Email',
value: 'promotion@company.com',
),
ContactInfo(
label: 'Zalo',
value: '0123.456.789',
),
],
),
);
}
/// Build fixed bottom action bar
Widget _buildActionBar() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: const Border(
top: BorderSide(
color: Color(0xFFE2E8F0),
width: 1,
),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, -2),
),
],
),
padding: const EdgeInsets.all(16),
child: SafeArea(
top: false,
child: ElevatedButton(
onPressed: _handleViewProducts,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.visibility, size: 20),
SizedBox(width: 8),
Text(
'Xem sản phẩm áp dụng',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
);
}
/// Format date range for display
String _formatDateRange(Promotion promotion) {
final startDay = promotion.startDate.day.toString().padLeft(2, '0');
final startMonth = promotion.startDate.month.toString().padLeft(2, '0');
final startYear = promotion.startDate.year;
final endDay = promotion.endDate.day.toString().padLeft(2, '0');
final endMonth = promotion.endDate.month.toString().padLeft(2, '0');
final endYear = promotion.endDate.year;
return '$startDay/$startMonth/$startYear - $endDay/$endMonth/$endYear';
}
/// Handle share button press
void _handleShare() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Tính năng chia sẻ đang phát triển'),
duration: Duration(seconds: 2),
),
);
}
/// Handle bookmark button press
void _handleBookmark() {
setState(() {
_isBookmarked = !_isBookmarked;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
_isBookmarked ? 'Đã lưu khuyến mãi' : 'Đã bỏ lưu khuyến mãi',
),
duration: const Duration(seconds: 2),
),
);
}
/// Handle view products button press
void _handleViewProducts() {
// Navigate to products page
context.push(RouteNames.products);
}
}

View File

@@ -0,0 +1,195 @@
/// Promotions Page
///
/// Displays all available promotions with a featured promotion card
/// at the top and a scrollable list of promotion cards below.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/home/domain/entities/promotion.dart';
import 'package:worker/features/home/presentation/providers/promotions_provider.dart';
import 'package:worker/features/promotions/presentation/widgets/featured_promotion_card.dart';
import 'package:worker/features/promotions/presentation/widgets/promotion_card.dart';
/// Promotions Page
///
/// Shows:
/// - Header bar with title
/// - Featured promotion card (gradient background)
/// - List of promotion cards
///
/// Layout designed for 375px width mobile screens.
class PromotionsPage extends ConsumerWidget {
const PromotionsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch promotions provider (same as home page)
final promotionsAsync = ref.watch(promotionsProvider);
return Scaffold(
backgroundColor: const Color(0xFFF4F6F8),
body: SafeArea(
child: Column(
children: [
// Header
_buildHeader(),
// Scrollable content
Expanded(
child: promotionsAsync.when(
data: (promotions) => _buildPromotionsContent(
context,
promotions,
),
loading: () => const Center(
child: CircularProgressIndicator(),
),
error: (error, stack) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: AppColors.danger,
size: 48,
),
const SizedBox(height: 16),
Text(
'Không thể tải khuyến mãi',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
const SizedBox(height: 8),
Text(
error.toString(),
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
],
),
),
);
}
/// Build header bar
Widget _buildHeader() {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: const Text(
'Khuyến mãi',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
),
),
);
}
/// Build promotions content with featured card and list
Widget _buildPromotionsContent(
BuildContext context,
List<Promotion> promotions,
) {
if (promotions.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.local_offer_outlined,
size: 64,
color: AppColors.grey500,
),
const SizedBox(height: 16),
Text(
'Chưa có khuyến mãi',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
),
const SizedBox(height: 8),
Text(
'Hãy quay lại sau để xem các ưu đãi mới',
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
),
],
),
);
}
return CustomScrollView(
slivers: [
// Featured Promotion Card (first promotion)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 16, bottom: 24),
child: FeaturedPromotionCard(
title: promotions.first.title,
subtitle: promotions.first.description,
timerText: 'Còn 2 ngày 15:30:45',
onTap: () {
context.push('/promotions/${promotions.first.id}');
},
),
),
),
// Promotion List (all promotions)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList(
delegate: SliverChildListDelegate(
promotions
.map(
(promotion) => PromotionCard(
promotion: promotion,
onTap: () {
context.push('/promotions/${promotion.id}');
},
),
)
.toList(),
),
),
),
// Bottom padding for navigation clearance
const SliverToBoxAdapter(
child: SizedBox(height: 16),
),
],
);
}
}

View File

@@ -0,0 +1,125 @@
/// Featured Promotion Card Widget
///
/// Displays a prominent featured promotion with gradient background,
/// typically shown at the top of the promotions page.
library;
import 'package:flutter/material.dart';
import 'package:worker/core/theme/colors.dart';
/// Featured Promotion Card
///
/// Shows a special promotion with:
/// - Gradient background (primary blue to light blue)
/// - Large title and subtitle
/// - Countdown timer (optional)
/// - Percentage icon
class FeaturedPromotionCard extends StatelessWidget {
/// Card title
final String title;
/// Card subtitle/description
final String subtitle;
/// Optional timer text (e.g., "Còn 2 ngày 15:30:45")
final String? timerText;
/// Optional tap callback
final VoidCallback? onTap;
const FeaturedPromotionCard({
required this.title,
required this.subtitle,
this.timerText,
this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.primaryBlue, AppColors.lightBlue],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppColors.primaryBlue.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
// Left side - Text content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 8),
// Subtitle
Text(
subtitle,
style: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.9),
),
),
// Timer (if provided)
if (timerText != null) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.access_time,
size: 14,
color: Colors.white.withValues(alpha: 0.8),
),
const SizedBox(width: 4),
Text(
timerText!,
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.8),
),
),
],
),
],
],
),
),
// Right side - Icon
const SizedBox(width: 16),
Icon(
Icons.percent,
size: 48,
color: Colors.white.withValues(alpha: 0.9),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,62 @@
/// Highlight Box Widget
///
/// A highlighted box with gradient background used to emphasize
/// important information in promotion details.
library;
import 'package:flutter/material.dart';
/// Highlight Box Widget
///
/// Displays important promotion information with:
/// - Yellow/orange gradient background
/// - Border styling
/// - Centered text
/// - Optional emoji/icon
class HighlightBox extends StatelessWidget {
const HighlightBox({
required this.text,
this.emoji,
super.key,
});
/// Text to display in the highlight box
final String text;
/// Optional emoji or icon to display before text
final String? emoji;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(vertical: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Color(0xFFFEF3C7), // #fef3c7
Color(0xFFFED7AA), // #fed7aa
],
),
border: Border.all(
color: const Color(0xFFFBBF24), // #fbbf24
width: 1,
),
borderRadius: BorderRadius.circular(12),
),
child: Text(
emoji != null ? '$emoji $text' : text,
textAlign: TextAlign.center,
style: const TextStyle(
color: Color(0xFF92400E), // #92400e - brown color
fontSize: 15,
fontWeight: FontWeight.w600,
height: 1.5,
),
),
);
}
}

View File

@@ -0,0 +1,197 @@
/// Promotion Card Widget
///
/// Displays an individual promotion with image, title, description,
/// date range, and action button.
library;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/home/domain/entities/promotion.dart';
/// Promotion Card
///
/// Shows:
/// - Promotion image (150px height)
/// - Title and description
/// - Date range with calendar icon
/// - "Chi tiết" button
class PromotionCard extends StatelessWidget {
/// Promotion data
final Promotion promotion;
/// Callback when card or detail button is tapped
final VoidCallback? onTap;
const PromotionCard({
required this.promotion,
this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image
CachedNetworkImage(
imageUrl: promotion.imageUrl,
height: 150,
width: double.infinity,
fit: BoxFit.cover,
placeholder: (context, url) => Container(
height: 150,
color: AppColors.grey100,
child: const Center(
child: CircularProgressIndicator(),
),
),
errorWidget: (context, url, error) => Container(
height: 150,
color: AppColors.grey100,
child: const Center(
child: Icon(
Icons.image_not_supported,
size: 48,
color: AppColors.grey500,
),
),
),
),
// Content
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
Text(
promotion.title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Color(0xFF212121),
),
),
const SizedBox(height: 8),
// Description
Text(
promotion.description,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF666666),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
// Bottom row: Date and button
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Date range
Expanded(
child: Row(
children: [
const Icon(
Icons.calendar_today,
size: 12,
color: AppColors.primaryBlue,
),
const SizedBox(width: 4),
Expanded(
child: Text(
_formatDateRange(),
style: const TextStyle(
fontSize: 12,
color: AppColors.primaryBlue,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
const SizedBox(width: 8),
// Detail button
ElevatedButton(
onPressed: () {
if (onTap != null) {
onTap!();
} else {
// Navigate to promotion detail page
context.pushNamed(
RouteNames.promotionDetail,
extra: promotion,
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 0,
),
child: const Text(
'Chi tiết',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
],
),
),
],
),
),
);
}
/// Format date range for display
String _formatDateRange() {
final startDay = promotion.startDate.day.toString().padLeft(2, '0');
final startMonth = promotion.startDate.month.toString().padLeft(2, '0');
final endDay = promotion.endDate.day.toString().padLeft(2, '0');
final endMonth = promotion.endDate.month.toString().padLeft(2, '0');
final year = promotion.endDate.year;
return '$startDay/$startMonth - $endDay/$endMonth/$year';
}
}

View File

@@ -0,0 +1,220 @@
/// Promotion Section Widget
///
/// A reusable section widget for organizing content in promotion details.
/// Each section has a title with icon and customizable content.
library;
import 'package:flutter/material.dart';
import 'package:worker/core/theme/colors.dart';
/// Promotion Section Widget
///
/// Displays a content section with:
/// - Icon and title
/// - Custom content widget
/// - Bottom border separator
class PromotionSection extends StatelessWidget {
const PromotionSection({
required this.title,
required this.icon,
required this.content,
this.isLast = false,
super.key,
});
/// Section title
final String title;
/// Icon to display next to title
final IconData icon;
/// Content widget to display in the section
final Widget content;
/// Whether this is the last section (no bottom border)
final bool isLast;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
decoration: BoxDecoration(
border: isLast
? null
: const Border(
bottom: BorderSide(
color: Color(0xFFE2E8F0), // --border-color
width: 1,
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Title with Icon
Row(
children: [
Icon(
icon,
size: 20,
color: AppColors.primaryBlue,
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B), // --text-primary
),
),
],
),
const SizedBox(height: 16),
// Section Content
content,
],
),
);
}
}
/// Promotion Content Text Widget
///
/// Standard text styling for section content with proper line height.
class PromotionContentText extends StatelessWidget {
const PromotionContentText(
this.text, {
this.isBold = false,
super.key,
});
final String text;
final bool isBold;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
text,
style: TextStyle(
fontSize: 15,
color: const Color(0xFF64748B), // --text-secondary
height: 1.7,
fontWeight: isBold ? FontWeight.w600 : FontWeight.normal,
),
),
);
}
}
/// Promotion Bullet List Widget
///
/// Displays a list with custom bullet points.
class PromotionBulletList extends StatelessWidget {
const PromotionBulletList({
required this.items,
super.key,
});
final List<String> items;
@override
Widget build(BuildContext context) {
return Column(
children: items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final isLast = index == items.length - 1;
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
border: isLast
? null
: const Border(
bottom: BorderSide(
color: Color(0xFFF1F5F9), // Light border
width: 1,
),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Custom bullet point
const Text(
'',
style: TextStyle(
color: AppColors.primaryBlue,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 12),
// Item text
Expanded(
child: Text(
item,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF64748B), // --text-secondary
height: 1.7,
),
),
),
],
),
);
}).toList(),
);
}
}
/// Contact Info Widget
///
/// Displays contact information with labels and values.
class ContactInfo extends StatelessWidget {
const ContactInfo({
required this.label,
required this.value,
super.key,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: RichText(
text: TextSpan(
children: [
TextSpan(
text: '$label: ',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF64748B),
height: 1.7,
),
),
TextSpan(
text: value,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.normal,
color: Color(0xFF64748B),
height: 1.7,
),
),
],
),
),
);
}
}