896 lines
28 KiB
Dart
896 lines
28 KiB
Dart
|
|
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),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|