From 338d26a38ac7c804569fd293094ef0465c8c366a Mon Sep 17 00:00:00 2001 From: Phuoc Nguyen Date: Fri, 24 Oct 2025 14:42:14 +0700 Subject: [PATCH] add promotion/detail --- lib/core/router/app_router.dart | 23 +- .../home/presentation/pages/home_page.dart | 118 +---- .../widgets/promotion_slider.dart | 16 +- .../presentation/pages/main_scaffold.dart | 216 ++++++++ .../providers/current_page_provider.dart | 30 ++ .../providers/current_page_provider.g.dart | 100 ++++ .../pages/promotion_detail_page.dart | 501 ++++++++++++++++++ .../presentation/pages/promotions_page.dart | 195 +++++++ .../widgets/featured_promotion_card.dart | 125 +++++ .../presentation/widgets/highlight_box.dart | 62 +++ .../presentation/widgets/promotion_card.dart | 197 +++++++ .../widgets/promotion_section.dart | 220 ++++++++ 12 files changed, 1681 insertions(+), 122 deletions(-) create mode 100644 lib/features/main/presentation/pages/main_scaffold.dart create mode 100644 lib/features/main/presentation/providers/current_page_provider.dart create mode 100644 lib/features/main/presentation/providers/current_page_provider.g.dart create mode 100644 lib/features/promotions/presentation/pages/promotion_detail_page.dart create mode 100644 lib/features/promotions/presentation/pages/promotions_page.dart create mode 100644 lib/features/promotions/presentation/widgets/featured_promotion_card.dart create mode 100644 lib/features/promotions/presentation/widgets/highlight_box.dart create mode 100644 lib/features/promotions/presentation/widgets/promotion_card.dart create mode 100644 lib/features/promotions/presentation/widgets/promotion_section.dart diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 4b450c3..a26bf25 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -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 ], diff --git a/lib/features/home/presentation/pages/home_page.dart b/lib/features/home/presentation/pages/home_page.dart index 4c76de7..f8f62e2 100644 --- a/lib/features/home/presentation/pages/home_page.dart +++ b/lib/features/home/presentation/pages/home_page.dart @@ -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); - }, - ), - ), - ), - ), ); } diff --git a/lib/features/home/presentation/widgets/promotion_slider.dart b/lib/features/home/presentation/widgets/promotion_slider.dart index 3755554..d61cc9b 100644 --- a/lib/features/home/presentation/widgets/promotion_slider.dart +++ b/lib/features/home/presentation/widgets/promotion_slider.dart @@ -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], + ); + } + }, ); }, ), diff --git a/lib/features/main/presentation/pages/main_scaffold.dart b/lib/features/main/presentation/pages/main_scaffold.dart new file mode 100644 index 0000000..c5695dd --- /dev/null +++ b/lib/features/main/presentation/pages/main_scaffold.dart @@ -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, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/main/presentation/providers/current_page_provider.dart b/lib/features/main/presentation/providers/current_page_provider.dart new file mode 100644 index 0000000..da8921d --- /dev/null +++ b/lib/features/main/presentation/providers/current_page_provider.dart @@ -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; + } + } +} diff --git a/lib/features/main/presentation/providers/current_page_provider.g.dart b/lib/features/main/presentation/providers/current_page_provider.g.dart new file mode 100644 index 0000000..23f88d6 --- /dev/null +++ b/lib/features/main/presentation/providers/current_page_provider.g.dart @@ -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 { + /// 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(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 build(); + @$mustCallSuper + @override + void runBuild() { + final created = build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + int, + Object?, + Object? + >; + element.handleValue(ref, created); + } +} diff --git a/lib/features/promotions/presentation/pages/promotion_detail_page.dart b/lib/features/promotions/presentation/pages/promotion_detail_page.dart new file mode 100644 index 0000000..c97ebf4 --- /dev/null +++ b/lib/features/promotions/presentation/pages/promotion_detail_page.dart @@ -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 createState() => + _PromotionDetailPageState(); +} + +class _PromotionDetailPageState extends ConsumerState { + 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); + } +} diff --git a/lib/features/promotions/presentation/pages/promotions_page.dart b/lib/features/promotions/presentation/pages/promotions_page.dart new file mode 100644 index 0000000..8c74195 --- /dev/null +++ b/lib/features/promotions/presentation/pages/promotions_page.dart @@ -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 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), + ), + ], + ); + } + +} diff --git a/lib/features/promotions/presentation/widgets/featured_promotion_card.dart b/lib/features/promotions/presentation/widgets/featured_promotion_card.dart new file mode 100644 index 0000000..63cfba9 --- /dev/null +++ b/lib/features/promotions/presentation/widgets/featured_promotion_card.dart @@ -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), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/promotions/presentation/widgets/highlight_box.dart b/lib/features/promotions/presentation/widgets/highlight_box.dart new file mode 100644 index 0000000..f179967 --- /dev/null +++ b/lib/features/promotions/presentation/widgets/highlight_box.dart @@ -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, + ), + ), + ); + } +} diff --git a/lib/features/promotions/presentation/widgets/promotion_card.dart b/lib/features/promotions/presentation/widgets/promotion_card.dart new file mode 100644 index 0000000..76999e5 --- /dev/null +++ b/lib/features/promotions/presentation/widgets/promotion_card.dart @@ -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'; + } +} diff --git a/lib/features/promotions/presentation/widgets/promotion_section.dart b/lib/features/promotions/presentation/widgets/promotion_section.dart new file mode 100644 index 0000000..5d0d92a --- /dev/null +++ b/lib/features/promotions/presentation/widgets/promotion_section.dart @@ -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 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, + ), + ), + ], + ), + ), + ); + } +}