import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:sales_chat/providers/chat_provider.dart'; import 'package:sales_chat/providers/auth_provider.dart'; import 'package:sales_chat/models/group.dart'; import 'package:sales_chat/models/message.dart'; import 'package:sales_chat/widgets/user_avatar.dart' as custom; import 'package:sales_chat/theme/app_theme.dart'; /// 聊天页面 —— 微信风格 class ChatPage extends StatefulWidget { const ChatPage({super.key}); @override State createState() => _ChatPageState(); } class _ChatPageState extends State { @override Widget build(BuildContext context) { return Column( children: [ AppBar( title: const Text('聊天'), ), Expanded( child: Consumer( builder: (context, chatProvider, _) { if (chatProvider.isLoading && chatProvider.groups.isEmpty) { return const Center(child: CircularProgressIndicator()); } if (chatProvider.groups.isEmpty) { return _buildEmptyState(); } if (chatProvider.currentGroup != null) { return _buildChatView(chatProvider); } return _buildGroupList(chatProvider); }, ), ), ], ); } /// 空状态占位 Widget _buildEmptyState() { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.chat_bubble_outline, size: 64, color: AppTheme.textHint, ), const SizedBox(height: 16), Text( '暂无群聊', style: TextStyle( fontSize: 16, color: AppTheme.textSecondary, ), ), const SizedBox(height: 8), Text( '请联系管理员将您加入群聊', style: TextStyle( fontSize: 14, color: AppTheme.textHint, ), ), ], ), ); } /// 群组列表 —— 微信会话列表风格 /// 白底行 + 细分割线,无卡片阴影 Widget _buildGroupList(ChatProvider chatProvider) { return Container( color: AppTheme.cardBackground, child: ListView.separated( // 去掉顶部和底部多余间距 padding: EdgeInsets.zero, itemCount: chatProvider.groups.length, // 每行之间用细分割线 separatorBuilder: (_, __) => const Divider( height: 0.5, thickness: 0.5, indent: 76, // 头像右侧开始 color: AppTheme.dividerColor, ), itemBuilder: (context, index) { final group = chatProvider.groups[index]; return _WeChatGroupRow( group: group, onTap: () => chatProvider.selectGroup(group), ); }, ), ); } /// 聊天视图 —— 聊天界面 Widget _buildChatView(ChatProvider chatProvider) { final group = chatProvider.currentGroup!; final authProvider = context.read(); return Column( children: [ // 聊天顶栏 Container( color: AppTheme.cardBackground, padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Row( children: [ IconButton( icon: const Icon(Icons.arrow_back_ios, size: 20), onPressed: () => chatProvider.deselectGroup(), ), Expanded( child: GestureDetector( onTap: () => _showGroupInfo(context, group), child: Column( children: [ Text( group.name, style: const TextStyle( fontSize: 17, fontWeight: FontWeight.w600, color: AppTheme.textPrimary, ), ), Text( '${group.memberCount}位成员', style: const TextStyle( fontSize: 11, color: AppTheme.textHint, ), ), ], ), ), ), IconButton( icon: const Icon(Icons.more_horiz, size: 22), onPressed: () => _showGroupInfo(context, group), ), ], ), ), const Divider(height: 0.5, thickness: 0.5), Expanded( child: _ChatMessagesList( messages: chatProvider.currentMessages, currentUserId: authProvider.user?.id ?? '', onLoadMore: () { // 加载更多历史消息 }, ), ), _ChatInput( onSend: (text) { chatProvider.sendMessage(text); }, ), ], ); } /// 显示群信息底部弹窗 void _showGroupInfo(BuildContext context, Group group) { showModalBottomSheet( context: context, backgroundColor: AppTheme.cardBackground, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(12)), ), builder: (context) => _GroupInfoSheet(group: group), ); } } // ================================================================ // 群组列表行 —— 微信会话列表风格 // ================================================================ class _WeChatGroupRow extends StatelessWidget { final Group group; final VoidCallback onTap; const _WeChatGroupRow({ required this.group, required this.onTap, }); @override Widget build(BuildContext context) { return InkWell( onTap: onTap, // 点击水波纹颜色 splashColor: AppTheme.dividerColor, highlightColor: AppTheme.timestampBg, child: Container( color: AppTheme.cardBackground, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ // 左侧群头像 custom.GroupAvatar( groupName: group.name, radius: 24, ), const SizedBox(width: 12), // 右侧群名 + 成员数 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( group.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w500, color: AppTheme.textPrimary, ), ), const SizedBox(height: 4), Text( '${group.memberCount}位成员', style: const TextStyle( fontSize: 13, color: AppTheme.textHint, ), ), ], ), ), // 右侧箭头图标 Icon( Icons.chevron_right, color: AppTheme.textHint, size: 20, ), ], ), ), ); } } // ================================================================ // 消息列表 // ================================================================ class _ChatMessagesList extends StatelessWidget { final List messages; final String currentUserId; final VoidCallback? onLoadMore; const _ChatMessagesList({ required this.messages, required this.currentUserId, this.onLoadMore, }); @override Widget build(BuildContext context) { if (messages.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.chat_bubble_outline, size: 48, color: AppTheme.textHint, ), const SizedBox(height: 12), Text( '暂无消息', style: TextStyle(color: AppTheme.textSecondary), ), const SizedBox(height: 8), Text( '发送第一条消息开始聊天吧', style: TextStyle(color: AppTheme.textHint, fontSize: 12), ), ], ), ); } return Container( color: AppTheme.scaffoldBackground, child: ListView.builder( reverse: true, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: messages.length, itemBuilder: (context, index) { final message = messages[index]; final isMe = message.senderId == currentUserId; // 滚动到顶部时加载更多 if (index == messages.length - 1 && onLoadMore != null) { onLoadMore!(); } // 判断是否需要显示时间戳(与下一条消息间隔超过5分钟) final bool showTimestamp; if (index == messages.length - 1) { // 最旧的消息,始终显示时间 showTimestamp = true; } else { final nextMessage = messages[index + 1]; if (message.createdAt != null && nextMessage.createdAt != null) { final diff = message.createdAt! .difference(nextMessage.createdAt!) .inMinutes; showTimestamp = diff.abs() >= 5; } else { showTimestamp = false; } } return _MessageBubble( message: message, isMe: isMe, showTimestamp: showTimestamp, ); }, ), ); } } // ================================================================ // 消息气泡 —— 微信风格 // ================================================================ class _MessageBubble extends StatelessWidget { final Message message; final bool isMe; final bool showTimestamp; const _MessageBubble({ required this.message, required this.isMe, this.showTimestamp = false, }); @override Widget build(BuildContext context) { // 撤回消息:居中灰色斜体 if (message.hasRecall) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Column( children: [ if (showTimestamp && message.createdAt != null) _buildTimestamp(), const SizedBox(height: 4), Center( child: Text( '消息已撤回', style: TextStyle( color: AppTheme.textHint, fontSize: 12, fontStyle: FontStyle.italic, ), ), ), ], ), ); } return Padding( padding: const EdgeInsets.only(bottom: 10), child: Column( children: [ // 时间戳居中显示在消息上方 if (showTimestamp && message.createdAt != null) _buildTimestamp(), const SizedBox(height: 4), // 消息行 GestureDetector( onLongPress: () => _showMessageOptions(context), child: Row( mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ // 对方消息:左侧头像 if (!isMe) ...[ custom.UserAvatar( displayName: message.senderName.isNotEmpty ? message.senderName : 'U', radius: 16, ), const SizedBox(width: 8), ], // 气泡内容 Flexible( child: Column( crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ // 对方消息:发送者名称(群聊显示) if (!isMe && message.senderName.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 2, left: 2), child: Text( message.senderName, style: const TextStyle( fontSize: 11, color: AppTheme.textHint, fontWeight: FontWeight.w400, ), ), ), // 气泡 Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 9), decoration: BoxDecoration( // 自己:微信绿气泡;对方:白色气泡 color: isMe ? AppTheme.bubbleSelf : AppTheme.bubbleOther, borderRadius: BorderRadius.only( topLeft: const Radius.circular(12), topRight: const Radius.circular(12), // 微信风格:外角小圆角,内角大圆角 bottomLeft: Radius.circular(isMe ? 12 : 4), bottomRight: Radius.circular(isMe ? 4 : 12), ), // 无阴影,干净清爽 ), child: Text( message.content, style: TextStyle( // 己方蓝色气泡用白字,对方灰色气泡用深色字 color: isMe ? Colors.white : AppTheme.textPrimary, fontSize: 15, height: 1.4, ), ), ), ], ), ), // 自己消息:右侧留出间距(对称对方头像) if (isMe) const SizedBox(width: 8), ], ), ), ], ), ); } /// 居中时间戳 Widget _buildTimestamp() { return Padding( padding: const EdgeInsets.only(top: 4, bottom: 2), child: Center( child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: AppTheme.timestampBg, borderRadius: BorderRadius.circular(4), ), child: Text( _formatTime(message.createdAt!), style: const TextStyle( fontSize: 11, color: AppTheme.timestampText, ), ), ), ), ); } /// 长按消息菜单 void _showMessageOptions(BuildContext context) { final authProvider = context.read(); final chatProvider = context.read(); final isAdmin = authProvider.user?.isAdmin ?? false; final canRecall = isMe && _isRecallable(message.createdAt); showModalBottomSheet( context: context, backgroundColor: AppTheme.cardBackground, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(12)), ), builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ // 顶部拖拽指示条 Container( margin: const EdgeInsets.only(top: 8, bottom: 4), width: 36, height: 4, decoration: BoxDecoration( color: AppTheme.dragHandleColor, borderRadius: BorderRadius.circular(2), ), ), if (canRecall) ListTile( leading: Icon(Icons.undo, color: AppTheme.primaryColor), title: const Text('撤回消息'), subtitle: const Text('其他人将不再看到此消息'), onTap: () { Navigator.pop(context); _handleRecall(context, chatProvider); }, ), if (isAdmin && !canRecall) ListTile( leading: const Icon(Icons.delete, color: Colors.red), title: const Text('删除消息', style: TextStyle(color: Colors.red)), subtitle: const Text('管理员删除消息'), onTap: () { Navigator.pop(context); _handleDelete(context, chatProvider); }, ), ListTile( leading: Icon(Icons.copy, color: AppTheme.textSecondary), title: const Text('复制'), onTap: () { Clipboard.setData(ClipboardData(text: message.content)); Navigator.pop(context); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('已复制到剪贴板')), ); }, ), ListTile( leading: Icon(Icons.cancel_outlined, color: AppTheme.textHint), title: const Text('取消'), onTap: () => Navigator.pop(context), ), ], ), ), ); } /// 判断消息是否可撤回(2分钟内) bool _isRecallable(DateTime? createdAt) { if (createdAt == null) return false; final now = DateTime.now(); final diff = now.difference(createdAt); return diff.inMinutes < 2; } /// 撤回消息 Future _handleRecall( BuildContext context, ChatProvider chatProvider) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('撤回消息'), content: const Text('确定要撤回此消息吗?'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('取消'), ), TextButton( onPressed: () => Navigator.pop(context, true), child: const Text('撤回'), ), ], ), ); if (confirmed == true) { final success = await chatProvider.recallMessage(message.id); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(success ? '消息已撤回' : (chatProvider.error ?? '撤回失败')), ), ); } } } /// 删除消息(管理员) Future _handleDelete( BuildContext context, ChatProvider chatProvider) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text('删除消息'), content: const Text('确定吗?此操作无法撤销。'), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('取消'), ), TextButton( onPressed: () => Navigator.pop(context, true), style: TextButton.styleFrom(foregroundColor: Colors.red), child: const Text('删除'), ), ], ), ); if (confirmed == true) { final success = await chatProvider.deleteMessage(message.id); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(success ? '消息已删除' : (chatProvider.error ?? '删除失败')), ), ); } } } /// 格式化时间 String _formatTime(DateTime time) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final yesterday = today.subtract(const Duration(days: 1)); final messageDate = DateTime(time.year, time.month, time.day); final hour = time.hour.toString().padLeft(2, '0'); final minute = time.minute.toString().padLeft(2, '0'); if (messageDate == today) { return '$hour:$minute'; } else if (messageDate == yesterday) { return '昨天 $hour:$minute'; } else if (now.year == time.year) { return '${time.month}月${time.day}日 $hour:$minute'; } else { return '${time.year}年${time.month}月${time.day}日 $hour:$minute'; } } } // ================================================================ // 聊天输入栏 —— 微信风格 // ================================================================ class _ChatInput extends StatefulWidget { final Function(String) onSend; const _ChatInput({required this.onSend}); @override State<_ChatInput> createState() => _ChatInputState(); } class _ChatInputState extends State<_ChatInput> { final _controller = TextEditingController(); @override void dispose() { _controller.dispose(); super.dispose(); } void _handleSend() { final text = _controller.text.trim(); if (text.isNotEmpty) { widget.onSend(text); _controller.clear(); } } @override Widget build(BuildContext context) { return Container( decoration: const BoxDecoration( color: AppTheme.chatInputBg, border: Border( top: BorderSide( color: AppTheme.dividerColor, width: 0.5, ), ), ), padding: EdgeInsets.only( left: 12, right: 12, top: 8, bottom: MediaQuery.of(context).padding.bottom + 8, ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ // 输入框 Expanded( child: Container( constraints: const BoxConstraints( minHeight: 36, maxHeight: 120, ), child: TextField( controller: _controller, maxLines: null, style: const TextStyle( fontSize: 15, color: AppTheme.textPrimary, ), decoration: InputDecoration( hintText: '输入消息...', hintStyle: const TextStyle( color: AppTheme.textHint, fontSize: 15, ), filled: true, fillColor: AppTheme.cardBackground, border: OutlineInputBorder( borderRadius: BorderRadius.circular(6), borderSide: BorderSide.none, ), contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 8, ), isDense: true, ), onSubmitted: (_) => _handleSend(), ), ), ), const SizedBox(width: 8), // 发送按钮:微信绿圆形 GestureDetector( onTap: _handleSend, child: Container( width: 36, height: 36, decoration: const BoxDecoration( color: AppTheme.primaryColor, shape: BoxShape.circle, ), child: const Icon( Icons.send_rounded, color: Colors.white, size: 18, ), ), ), ], ), ); } } // ================================================================ // 群信息底部弹窗 —— 微信风格,干净白底 // ================================================================ class _GroupInfoSheet extends StatelessWidget { final Group group; const _GroupInfoSheet({required this.group}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20), decoration: const BoxDecoration( color: AppTheme.cardBackground, borderRadius: BorderRadius.vertical(top: Radius.circular(12)), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 顶部拖拽指示条 Container( width: 36, height: 4, margin: const EdgeInsets.only(bottom: 20), decoration: BoxDecoration( color: AppTheme.dragHandleColor, borderRadius: BorderRadius.circular(2), ), ), // 群头像 custom.GroupAvatar( groupName: group.name, radius: 40, ), const SizedBox(height: 12), // 群名称 Text( group.name, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600, color: AppTheme.textPrimary, ), ), const SizedBox(height: 4), // 成员数 Text( '${group.memberCount}位成员', style: const TextStyle( fontSize: 14, color: AppTheme.textHint, ), ), // 群描述 if (group.description != null && group.description!.isNotEmpty) ...[ const SizedBox(height: 12), Container( width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppTheme.chatInputBg, borderRadius: BorderRadius.circular(8), ), child: Text( group.description!, textAlign: TextAlign.center, style: const TextStyle( fontSize: 14, color: AppTheme.textSecondary, ), ), ), ], // 频道列表 if (group.panels.isNotEmpty) ...[ const SizedBox(height: 16), const Align( alignment: Alignment.centerLeft, child: Text( '频道', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppTheme.textPrimary, ), ), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: group.panels .where((p) => p.type == 0) .map((p) => Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6), decoration: BoxDecoration( color: AppTheme.chatInputBg, borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.tag, size: 14, color: AppTheme.textHint), const SizedBox(width: 4), Text( p.name, style: const TextStyle( fontSize: 13, color: AppTheme.textPrimary, ), ), ], ), )) .toList(), ), ], const SizedBox(height: 24), ], ), ); } }