Files
chat/client/flutter/lib/pages/chat_page.dart
2026-04-25 16:36:34 +08:00

896 lines
28 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
@override
Widget build(BuildContext context) {
return Column(
children: [
AppBar(
title: const Text('聊天'),
),
Expanded(
child: Consumer<ChatProvider>(
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<AuthProvider>();
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<Message> 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<AuthProvider>();
final chatProvider = context.read<ChatProvider>();
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<void> _handleRecall(
BuildContext context, ChatProvider chatProvider) async {
final confirmed = await showDialog<bool>(
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<void> _handleDelete(
BuildContext context, ChatProvider chatProvider) async {
final confirmed = await showDialog<bool>(
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),
],
),
);
}
}