Files
chat/client/flutter/lib/pages/chat_page.dart

896 lines
28 KiB
Dart
Raw Normal View History

2026-04-25 16:36:34 +08:00
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),
],
),
);
}
}