update favorite, chat

This commit is contained in:
Phuoc Nguyen
2025-11-03 17:05:47 +07:00
parent 988216b151
commit b3d7637760
6 changed files with 530 additions and 81 deletions

View File

@@ -5,6 +5,7 @@ import 'package:hive_ce_flutter/hive_flutter.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:worker/core/constants/storage_constants.dart'; import 'package:worker/core/constants/storage_constants.dart';
import 'package:worker/features/favorites/data/models/favorite_model.dart';
// TODO: Re-enable when build_runner generates this file successfully // TODO: Re-enable when build_runner generates this file successfully
// import 'package:worker/hive_registrar.g.dart'; // import 'package:worker/hive_registrar.g.dart';
@@ -109,8 +110,14 @@ class HiveService {
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentMethod) ? "" : ""} PaymentMethod adapter'); debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.paymentMethod) ? "" : ""} PaymentMethod adapter');
debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.cachedData) ? "" : ""} CachedData adapter'); debugPrint('HiveService: ${Hive.isAdapterRegistered(HiveTypeIds.cachedData) ? "" : ""} CachedData adapter');
// TODO: Register actual model type adapters when models are created // Register model type adapters manually
// These will be added to the auto-generated registrar when models are created // FavoriteModel adapter (typeId: 28)
if (!Hive.isAdapterRegistered(HiveTypeIds.favoriteModel)) {
Hive.registerAdapter(FavoriteModelAdapter());
debugPrint('HiveService: ✓ FavoriteModel adapter registered');
}
// TODO: Register other model type adapters when created
// Example: // Example:
// - UserModel (typeId: 0) // - UserModel (typeId: 0)
// - ProductModel (typeId: 1) // - ProductModel (typeId: 1)

View File

@@ -6,28 +6,30 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:worker/features/account/presentation/pages/addresses_page.dart';
import 'package:worker/features/account/presentation/pages/change_password_page.dart';
import 'package:worker/features/account/presentation/pages/profile_edit_page.dart';
import 'package:worker/features/cart/presentation/pages/cart_page.dart'; import 'package:worker/features/cart/presentation/pages/cart_page.dart';
import 'package:worker/features/cart/presentation/pages/checkout_page.dart'; import 'package:worker/features/cart/presentation/pages/checkout_page.dart';
import 'package:worker/features/chat/presentation/pages/chat_list_page.dart';
import 'package:worker/features/favorites/presentation/pages/favorites_page.dart'; import 'package:worker/features/favorites/presentation/pages/favorites_page.dart';
import 'package:worker/features/loyalty/presentation/pages/loyalty_page.dart'; import 'package:worker/features/loyalty/presentation/pages/loyalty_page.dart';
import 'package:worker/features/loyalty/presentation/pages/points_history_page.dart'; import 'package:worker/features/loyalty/presentation/pages/points_history_page.dart';
import 'package:worker/features/loyalty/presentation/pages/rewards_page.dart'; import 'package:worker/features/loyalty/presentation/pages/rewards_page.dart';
import 'package:worker/features/main/presentation/pages/main_scaffold.dart'; import 'package:worker/features/main/presentation/pages/main_scaffold.dart';
import 'package:worker/features/news/presentation/pages/news_detail_page.dart';
import 'package:worker/features/news/presentation/pages/news_list_page.dart';
import 'package:worker/features/orders/presentation/pages/order_detail_page.dart'; import 'package:worker/features/orders/presentation/pages/order_detail_page.dart';
import 'package:worker/features/orders/presentation/pages/orders_page.dart'; import 'package:worker/features/orders/presentation/pages/orders_page.dart';
import 'package:worker/features/orders/presentation/pages/payment_detail_page.dart'; import 'package:worker/features/orders/presentation/pages/payment_detail_page.dart';
import 'package:worker/features/orders/presentation/pages/payment_qr_page.dart'; import 'package:worker/features/orders/presentation/pages/payment_qr_page.dart';
import 'package:worker/features/orders/presentation/pages/payments_page.dart'; import 'package:worker/features/orders/presentation/pages/payments_page.dart';
import 'package:worker/features/price_policy/price_policy.dart';
import 'package:worker/features/products/presentation/pages/product_detail_page.dart'; import 'package:worker/features/products/presentation/pages/product_detail_page.dart';
import 'package:worker/features/products/presentation/pages/products_page.dart'; import 'package:worker/features/products/presentation/pages/products_page.dart';
import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart'; import 'package:worker/features/promotions/presentation/pages/promotion_detail_page.dart';
import 'package:worker/features/quotes/presentation/pages/quotes_page.dart'; import 'package:worker/features/quotes/presentation/pages/quotes_page.dart';
import 'package:worker/features/price_policy/price_policy.dart';
import 'package:worker/features/news/presentation/pages/news_list_page.dart';
import 'package:worker/features/news/presentation/pages/news_detail_page.dart';
import 'package:worker/features/account/presentation/pages/profile_edit_page.dart';
import 'package:worker/features/account/presentation/pages/addresses_page.dart';
import 'package:worker/features/account/presentation/pages/change_password_page.dart';
/// App Router /// App Router
/// ///
@@ -253,6 +255,14 @@ class AppRouter {
MaterialPage(key: state.pageKey, child: const ChangePasswordPage()), MaterialPage(key: state.pageKey, child: const ChangePasswordPage()),
), ),
// Chat List Route
GoRoute(
path: RouteNames.chat,
name: RouteNames.chat,
pageBuilder: (context, state) =>
MaterialPage(key: state.pageKey, child: const ChatListPage()),
),
// TODO: Add more routes as features are implemented // TODO: Add more routes as features are implemented
], ],

View File

@@ -0,0 +1,462 @@
/// Page: Chat List Page
///
/// List of chat conversations following html/chat-list.html design.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:worker/core/constants/ui_constants.dart';
import 'package:worker/core/theme/colors.dart';
/// Chat List Page
///
/// Shows list of chat conversations with:
/// - Search functionality
/// - Conversation items with avatars
/// - Unread indicators
/// - Online status
/// - Load more button
class ChatListPage extends ConsumerStatefulWidget {
const ChatListPage({super.key});
@override
ConsumerState<ChatListPage> createState() => _ChatListPageState();
}
class _ChatListPageState extends ConsumerState<ChatListPage> {
bool _isSearchVisible = false;
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _toggleSearch() {
setState(() {
_isSearchVisible = !_isSearchVisible;
if (!_isSearchVisible) {
_searchController.clear();
}
});
}
void _newConversation() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Chức năng tạo cuộc trò chuyện mới sẽ được triển khai trong phiên bản tiếp theo.',
),
),
);
}
void _openChat(String conversationId) {
// TODO: Navigate to chat detail page
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Mở cuộc trò chuyện: $conversationId')),
);
}
void _loadMore() {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Đang tải thêm cuộc trò chuyện...')),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.grey50,
appBar: AppBar(
backgroundColor: AppColors.white,
elevation: AppBarSpecs.elevation,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.black),
onPressed: () => Navigator.of(context).pop(),
),
centerTitle: false,
title: const Text(
'Tin nhắn',
style: TextStyle(
color: Colors.black,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
actions: [
IconButton(
icon: Icon(
_isSearchVisible ? Icons.search : Icons.search,
color: Colors.black,
),
onPressed: _toggleSearch,
),
IconButton(
icon: const Icon(Icons.add, color: Colors.black),
onPressed: _newConversation,
),
const SizedBox(width: AppSpacing.sm),
],
),
body: Column(
children: [
// Search Bar (conditional)
if (_isSearchVisible)
Container(
padding: const EdgeInsets.all(12),
color: AppColors.white,
child: TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: 'Tìm kiếm cuộc trò chuyện...',
prefixIcon: const Icon(Icons.search, color: AppColors.grey500),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, color: AppColors.grey500),
onPressed: () {
setState(() {
_searchController.clear();
});
},
)
: null,
filled: true,
fillColor: AppColors.grey50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onChanged: (value) {
setState(() {});
},
),
),
// Conversations List
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
// Conversation 1 - Order Reference
_ConversationItem(
avatarIcon: Icons.inventory_2,
avatarGradient: const [AppColors.primaryBlue, AppColors.lightBlue],
contactName: 'Đơn hàng #SO001234',
messageTime: '14:30',
lastMessage: 'Đơn hàng đang được giao - Dự kiến đến 16:00',
lastMessageIcon: Icons.local_shipping,
contactType: 'Về: Đơn hàng #SO001234',
lastSeen: 'Cập nhật mới',
isUnread: true,
unreadCount: 2,
isOnline: true,
onTap: () => _openChat('order001'),
),
// Conversation 2 - Product Reference
_ConversationItem(
avatarIcon: Icons.inventory_outlined,
avatarGradient: null,
avatarBackgroundColor: AppColors.grey50,
iconColor: AppColors.primaryBlue,
contactName: 'Sản phẩm PR0123',
messageTime: '12:20',
lastMessage: 'Thông tin bổ sung về gạch Granite 60x60',
lastMessageIcon: Icons.info_outline,
contactType: 'Đơn hàng #DH001233',
lastSeen: '2 giờ trước',
isUnread: true,
unreadCount: 1,
isOnline: false,
isAway: true,
onTap: () => _openChat('product001'),
),
// Conversation 3 - Support Team
_ConversationItem(
avatarIcon: Icons.headset_mic,
avatarGradient: const [AppColors.primaryBlue, AppColors.lightBlue],
contactName: 'Tổng đài hỗ trợ',
messageTime: '13:45',
lastMessage: 'Thông tin về quy trình đổi trả sản phẩm',
contactType: 'Bộ phận hỗ trợ',
lastSeen: 'Đang hoạt động',
isUnread: false,
isOnline: true,
onTap: () => _openChat('support001'),
),
// Load More Button
Container(
padding: const EdgeInsets.all(20),
color: AppColors.white,
child: Center(
child: OutlinedButton.icon(
onPressed: _loadMore,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.grey900,
side: const BorderSide(color: AppColors.grey100),
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(Icons.expand_more, size: 20),
label: const Text(
'Tải thêm cuộc trò chuyện',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
),
],
),
),
],
),
);
}
}
/// Conversation Item Widget
class _ConversationItem extends StatelessWidget {
const _ConversationItem({
required this.avatarIcon,
this.avatarGradient,
this.avatarBackgroundColor,
this.iconColor,
required this.contactName,
required this.messageTime,
required this.lastMessage,
this.lastMessageIcon,
required this.contactType,
required this.lastSeen,
this.isUnread = false,
this.unreadCount,
this.isOnline = false,
this.isAway = false,
required this.onTap,
});
final IconData avatarIcon;
final List<Color>? avatarGradient;
final Color? avatarBackgroundColor;
final Color? iconColor;
final String contactName;
final String messageTime;
final String lastMessage;
final IconData? lastMessageIcon;
final String contactType;
final String lastSeen;
final bool isUnread;
final int? unreadCount;
final bool isOnline;
final bool isAway;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isUnread
? const Color(0xFFF0F9FF) // Light blue background for unread
: AppColors.white,
border: const Border(
bottom: BorderSide(color: AppColors.grey100, width: 1),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar with online indicator
Stack(
children: [
Container(
width: 50,
height: 50,
decoration: BoxDecoration(
gradient: avatarGradient != null
? LinearGradient(
colors: avatarGradient!,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: null,
color: avatarBackgroundColor,
shape: BoxShape.circle,
),
child: Icon(
avatarIcon,
color: iconColor ?? AppColors.white,
size: 20,
),
),
// Online indicator
Positioned(
bottom: 2,
right: 2,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: isOnline
? AppColors.success
: isAway
? AppColors.warning
: AppColors.grey500,
shape: BoxShape.circle,
border: Border.all(color: AppColors.white, width: 2),
),
),
),
],
),
const SizedBox(width: 12),
// Conversation content
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header: Contact name and time
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
contactName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.grey900,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
messageTime,
style: const TextStyle(
fontSize: 12,
color: AppColors.grey500,
),
),
],
),
const SizedBox(height: 4),
// Preview: Last message and unread count
Row(
children: [
Expanded(
child: Row(
children: [
if (lastMessageIcon != null) ...[
Icon(
lastMessageIcon,
size: 14,
color: AppColors.grey500,
),
const SizedBox(width: 6),
],
Expanded(
child: Text(
lastMessage,
style: const TextStyle(
fontSize: 14,
color: AppColors.grey500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (unreadCount != null && unreadCount! > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.danger,
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(minWidth: 18),
child: Text(
'$unreadCount',
style: const TextStyle(
color: AppColors.white,
fontSize: 11,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
),
],
],
),
const SizedBox(height: 4),
// Meta: Contact type and last seen
Row(
children: [
Text(
contactType,
style: const TextStyle(
fontSize: 12,
color: AppColors.grey500,
fontWeight: FontWeight.w500,
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: Text(
'',
style: TextStyle(
color: AppColors.grey100,
fontSize: 12,
),
),
),
Text(
lastSeen,
style: const TextStyle(
fontSize: 12,
color: AppColors.grey500,
),
),
],
),
],
),
),
],
),
),
);
}
}

View File

@@ -227,6 +227,7 @@ class HomePage extends ConsumerWidget {
), ),
], ],
), ),
); );
} }

View File

@@ -6,6 +6,8 @@ library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:worker/core/router/app_router.dart';
import 'package:worker/core/theme/colors.dart'; import 'package:worker/core/theme/colors.dart';
import 'package:worker/features/account/presentation/pages/account_page.dart'; import 'package:worker/features/account/presentation/pages/account_page.dart';
import 'package:worker/features/home/presentation/pages/home_page.dart'; import 'package:worker/features/home/presentation/pages/home_page.dart';
@@ -40,25 +42,15 @@ class MainScaffold extends ConsumerWidget {
]; ];
return Scaffold( return Scaffold(
body: IndexedStack( body: IndexedStack(index: currentIndex, children: pages),
index: currentIndex, floatingActionButton: currentIndex < 4
children: pages,
),
floatingActionButton: currentIndex == 0
? Padding( ? Padding(
padding: const EdgeInsets.only(bottom: 20), padding: const EdgeInsets.only(bottom: 20),
child: FloatingActionButton( child: FloatingActionButton(
onPressed: () { onPressed: () => context.push(RouteNames.chat),
ScaffoldMessenger.of(context).showSnackBar( backgroundColor: const Color(0xFF35C6F4), // Accent cyan color
const SnackBar( elevation: 4,
content: Text('Chat - Đang phát triển'), child: const Icon(Icons.chat_bubble, color: AppColors.white, size: 28),
duration: Duration(seconds: 1),
),
);
},
backgroundColor: AppColors.accentCyan,
elevation: 8,
child: const Icon(Icons.chat_bubble, size: 24, color: Colors.white),
), ),
) )
: null, : null,
@@ -66,11 +58,7 @@ class MainScaffold extends ConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, -2)),
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, -2),
),
], ],
), ),
child: SafeArea( child: SafeArea(
@@ -87,18 +75,9 @@ class MainScaffold extends ConsumerWidget {
currentIndex: currentIndex, currentIndex: currentIndex,
elevation: 0, elevation: 0,
items: [ items: [
const BottomNavigationBarItem( const BottomNavigationBarItem(icon: Icon(Icons.home), label: 'Trang chủ'),
icon: Icon(Icons.home), const BottomNavigationBarItem(icon: Icon(Icons.loyalty), label: 'Hội viên'),
label: 'Trang chủ', const BottomNavigationBarItem(icon: Icon(Icons.local_offer), label: 'Tin tức'),
),
const BottomNavigationBarItem(
icon: Icon(Icons.loyalty),
label: 'Hội viên',
),
const BottomNavigationBarItem(
icon: Icon(Icons.local_offer),
label: 'Tin tức',
),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Stack( icon: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
@@ -108,25 +87,12 @@ class MainScaffold extends ConsumerWidget {
top: -4, top: -4,
right: -4, right: -4,
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
horizontal: 6, decoration: BoxDecoration(color: AppColors.danger, borderRadius: BorderRadius.circular(12)),
vertical: 2, constraints: const BoxConstraints(minWidth: 20, minHeight: 20),
),
decoration: BoxDecoration(
color: AppColors.danger,
borderRadius: BorderRadius.circular(12),
),
constraints: const BoxConstraints(
minWidth: 20,
minHeight: 20,
),
child: const Text( child: const Text(
'5', '5',
style: TextStyle( style: TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700),
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
@@ -135,10 +101,7 @@ class MainScaffold extends ConsumerWidget {
), ),
label: 'Thông báo', label: 'Thông báo',
), ),
const BottomNavigationBarItem( const BottomNavigationBarItem(icon: Icon(Icons.account_circle), label: 'Cài đặt'),
icon: Icon(Icons.account_circle),
label: 'Cài đặt',
),
], ],
onTap: (index) { onTap: (index) {
ref.read(currentPageIndexProvider.notifier).setIndex(index); ref.read(currentPageIndexProvider.notifier).setIndex(index);

View File

@@ -19,10 +19,6 @@ import 'package:worker/generated/l10n/app_localizations.dart';
/// Displays product information in a card format. /// Displays product information in a card format.
/// Includes image, name, price, stock status, favorite toggle, and add to cart button. /// Includes image, name, price, stock status, favorite toggle, and add to cart button.
class ProductCard extends ConsumerWidget { class ProductCard extends ConsumerWidget {
final Product product;
final VoidCallback? onTap;
final VoidCallback? onAddToCart;
const ProductCard({ const ProductCard({
super.key, super.key,
required this.product, required this.product,
@@ -30,6 +26,10 @@ class ProductCard extends ConsumerWidget {
this.onAddToCart, this.onAddToCart,
}); });
final Product product;
final VoidCallback? onTap;
final VoidCallback? onAddToCart;
String _formatPrice(double price) { String _formatPrice(double price) {
final formatter = NumberFormat('#,###', 'vi_VN'); final formatter = NumberFormat('#,###', 'vi_VN');
return '${formatter.format(price)}đ'; return '${formatter.format(price)}đ';
@@ -37,7 +37,7 @@ class ProductCard extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context);
final isFavorited = ref.watch(isFavoriteProvider(product.productId)); final isFavorited = ref.watch(isFavoriteProvider(product.productId));
return Card( return Card(
@@ -142,16 +142,21 @@ class ProductCard extends ConsumerWidget {
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: () { onTap: () async {
ref // Capture current state before toggle
final wasfavorited = isFavorited;
// Toggle favorite
await ref
.read(favoritesProvider.notifier) .read(favoritesProvider.notifier)
.toggleFavorite(product.productId); .toggleFavorite(product.productId);
// Show feedback // Show feedback with correct message
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
isFavorited wasfavorited
? 'Đã xóa khỏi yêu thích' ? 'Đã xóa khỏi yêu thích'
: 'Đã thêm vào yêu thích', : 'Đã thêm vào yêu thích',
), ),
@@ -159,6 +164,7 @@ class ProductCard extends ConsumerWidget {
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
); );
}
}, },
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: Container( child: Container(
@@ -169,7 +175,7 @@ class ProductCard extends ConsumerWidget {
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.15), color: Colors.black.withValues(alpha: 0.15),
blurRadius: 6, blurRadius: 6,
offset: const Offset(0, 2), offset: const Offset(0, 2),
), ),