- message_send_service:所有消息走统一门禁(登录/连接/空接收方/好友校验), 失败打印 SDK 原始 code 并映射中文提示,不再只吞异常 - chat_screen 接入统一发送服务,失败提示真实原因 - account-service 新增 GET /api/directory(登录用户可读启用员工,用于添加同事) - add_friend_screen 增加可编辑验证消息(视觉规范 4.2) - new_friends_screen 五态 + 窄屏(<360)按钮组换行不挤压姓名(视觉规范 5.3) Co-authored-by: multica-agent <github@multica.ai>
418 lines
13 KiB
Dart
418 lines
13 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'package:just_audio/just_audio.dart';
|
|
import 'package:open_filex/open_filex.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import '../services/call_service.dart';
|
|
import '../services/im_service.dart';
|
|
import '../services/message_send_service.dart';
|
|
import '../theme.dart';
|
|
import '../utils/format.dart';
|
|
import '../widgets/chat_input_bar.dart';
|
|
import '../widgets/message_bubble.dart';
|
|
import 'call_screen.dart';
|
|
|
|
/// 单聊 / 群聊聊天页(效果图 m2)
|
|
class ChatScreen extends StatefulWidget {
|
|
final ConversationInfo conversation;
|
|
|
|
const ChatScreen({super.key, required this.conversation});
|
|
|
|
@override
|
|
State<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends State<ChatScreen> {
|
|
/// 消息列表,新消息在前(配合 reverse 列表)
|
|
final List<Message> _messages = [];
|
|
final ScrollController _scroll = ScrollController();
|
|
final AudioPlayer _player = AudioPlayer();
|
|
final Dio _dio = Dio();
|
|
|
|
StreamSubscription<Message>? _msgSub;
|
|
StreamSubscription<String>? _revokeSub;
|
|
|
|
bool _loadingHistory = false;
|
|
bool _hasMore = true;
|
|
String? _playingVoiceId;
|
|
|
|
static const int _pageSize = 30;
|
|
|
|
/// 相邻消息间隔超过 5 分钟显示时间分隔条
|
|
static const int _dividerGapMillis = 5 * 60 * 1000;
|
|
|
|
ConversationInfo get _conv => widget.conversation;
|
|
bool get _isGroup =>
|
|
_conv.conversationType == ConversationType.superGroup || _conv.conversationType == ConversationType.group;
|
|
String get _peerID => _conv.userID ?? '';
|
|
String get _groupID => _conv.groupID ?? '';
|
|
String get _title => _conv.showName ?? '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadHistory(first: true);
|
|
IMService.instance.markConversationRead(_conv.conversationID);
|
|
_msgSub = IMService.instance.onNewMessage.listen(_onNewMessage);
|
|
_revokeSub = IMService.instance.onMessageRevoked.listen(_onRevoked);
|
|
_scroll.addListener(() {
|
|
// 滚动到顶部加载更早的历史消息
|
|
if (_scroll.position.pixels >= _scroll.position.maxScrollExtent - 40) {
|
|
_loadHistory();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_msgSub?.cancel();
|
|
_revokeSub?.cancel();
|
|
_scroll.dispose();
|
|
_player.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// ---------------- 历史消息 ----------------
|
|
|
|
Future<void> _loadHistory({bool first = false}) async {
|
|
if (_loadingHistory || (!_hasMore && !first)) return;
|
|
_loadingHistory = true;
|
|
try {
|
|
final result = await OpenIM.iMManager.messageManager.getAdvancedHistoryMessageList(
|
|
conversationID: _conv.conversationID,
|
|
count: _pageSize,
|
|
startMsg: first ? null : (_messages.isEmpty ? null : _messages.last),
|
|
);
|
|
final list = result.messageList ?? [];
|
|
if (!mounted) return;
|
|
setState(() {
|
|
if (first) _messages.clear();
|
|
_messages.addAll(list);
|
|
if (list.length < _pageSize || result.isEnd == true) _hasMore = false;
|
|
});
|
|
} catch (_) {
|
|
if (first && mounted) _toast('消息没加载出来,请检查网络');
|
|
} finally {
|
|
_loadingHistory = false;
|
|
}
|
|
}
|
|
|
|
// ---------------- 新消息 / 撤回 ----------------
|
|
|
|
bool _belongsHere(Message msg) {
|
|
if (_isGroup) return msg.groupID == _groupID;
|
|
final myID = IMService.instance.currentUserID;
|
|
// 对方发来的,或自己在别的设备发的
|
|
return (msg.sendID == _peerID && msg.recvID == myID) || (msg.sendID == myID && msg.recvID == _peerID);
|
|
}
|
|
|
|
void _onNewMessage(Message msg) {
|
|
if (!_belongsHere(msg)) return;
|
|
if (!mounted) return;
|
|
setState(() {
|
|
final idx = _messages.indexWhere((m) => m.clientMsgID == msg.clientMsgID);
|
|
if (idx >= 0) {
|
|
_messages[idx] = msg;
|
|
} else {
|
|
_messages.insert(0, msg);
|
|
}
|
|
});
|
|
IMService.instance.markConversationRead(_conv.conversationID);
|
|
}
|
|
|
|
void _onRevoked(String clientMsgID) {
|
|
final idx = _messages.indexWhere((m) => m.clientMsgID == clientMsgID);
|
|
if (idx < 0 || !mounted) return;
|
|
setState(() => _messages.removeAt(idx));
|
|
_toast('对方撤回了一条消息');
|
|
}
|
|
|
|
// ---------------- 发送 ----------------
|
|
|
|
/// 统一发送入口:文字/表情/语音/图片/文件都经过这里。
|
|
/// 失败时用 [MessageSendService] 的中文提示,不再只标红感叹号。
|
|
Future<void> _sendMessage(Message message) async {
|
|
setState(() {
|
|
message.status = MessageStatus.sending;
|
|
_messages.insert(0, message);
|
|
});
|
|
if (_scroll.hasClients) _scroll.jumpTo(0);
|
|
try {
|
|
final sent = await MessageSendService.instance.send(
|
|
message: message,
|
|
userID: _isGroup ? null : _peerID,
|
|
groupID: _isGroup ? _groupID : null,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() => message.status = sent.status ?? MessageStatus.succeeded);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => message.status = MessageStatus.failed);
|
|
_toast(e is Exception ? e.toString() : '消息没发出去,请重试');
|
|
}
|
|
}
|
|
|
|
/// 点红色感叹号重发
|
|
Future<void> _resend(Message message) async {
|
|
setState(() => message.status = MessageStatus.sending);
|
|
try {
|
|
final sent = await MessageSendService.instance.send(
|
|
message: message,
|
|
userID: _isGroup ? null : _peerID,
|
|
groupID: _isGroup ? _groupID : null,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() => message.status = sent.status ?? MessageStatus.succeeded);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => message.status = MessageStatus.failed);
|
|
_toast(e is Exception ? e.toString() : '消息没发出去,请重试');
|
|
}
|
|
}
|
|
|
|
Future<void> _sendText(String text) async {
|
|
try {
|
|
final message = await OpenIM.iMManager.messageManager.createTextMessage(text: text);
|
|
_sendMessage(message);
|
|
} catch (_) {
|
|
_toast('消息没发出去,请重试');
|
|
}
|
|
}
|
|
|
|
Future<void> _sendVoice(String path, int duration) async {
|
|
try {
|
|
final message = await OpenIM.iMManager.messageManager.createSoundMessageFromFullPath(
|
|
soundPath: path,
|
|
duration: duration,
|
|
);
|
|
_sendMessage(message);
|
|
} catch (_) {
|
|
_toast('语音没发出去,请重试');
|
|
}
|
|
}
|
|
|
|
Future<void> _pickAndSendImage() async {
|
|
try {
|
|
final picked = await ImagePicker().pickImage(source: ImageSource.gallery, imageQuality: 80);
|
|
if (picked == null) return;
|
|
final message = await OpenIM.iMManager.messageManager.createImageMessageFromFullPath(imagePath: picked.path);
|
|
_sendMessage(message);
|
|
} catch (_) {
|
|
_toast('图片没发出去,请重试');
|
|
}
|
|
}
|
|
|
|
Future<void> _pickAndSendFile() async {
|
|
try {
|
|
final result = await FilePicker.platform.pickFiles();
|
|
final file = result?.files.single;
|
|
if (file == null || file.path == null) return;
|
|
final message = await OpenIM.iMManager.messageManager.createFileMessageFromFullPath(
|
|
filePath: file.path!,
|
|
fileName: file.name,
|
|
);
|
|
_sendMessage(message);
|
|
} catch (_) {
|
|
_toast('文件没发出去,请重试');
|
|
}
|
|
}
|
|
|
|
// ---------------- 语音播放 / 文件打开 ----------------
|
|
|
|
Future<void> _playVoice(Message message) async {
|
|
try {
|
|
// 再点一次同一条语音 = 停止
|
|
if (_playingVoiceId == message.clientMsgID && _player.playing) {
|
|
await _player.stop();
|
|
if (mounted) setState(() => _playingVoiceId = null);
|
|
return;
|
|
}
|
|
final localPath = message.soundElem?.soundPath;
|
|
final url = message.soundElem?.sourceUrl;
|
|
if (localPath != null && localPath.isNotEmpty && File(localPath).existsSync()) {
|
|
await _player.setFilePath(localPath);
|
|
} else if (url != null && url.isNotEmpty) {
|
|
await _player.setUrl(url);
|
|
} else {
|
|
_toast('语音文件找不到了');
|
|
return;
|
|
}
|
|
if (mounted) setState(() => _playingVoiceId = message.clientMsgID);
|
|
// 播放后去掉未读红点
|
|
setState(() => message.isRead = true);
|
|
await _player.play();
|
|
if (mounted) setState(() => _playingVoiceId = null);
|
|
} catch (_) {
|
|
if (mounted) setState(() => _playingVoiceId = null);
|
|
_toast('语音播放失败');
|
|
}
|
|
}
|
|
|
|
Future<void> _openFile(Message message) async {
|
|
final localPath = message.fileElem?.filePath;
|
|
final url = message.fileElem?.sourceUrl;
|
|
final fileName = message.fileElem?.fileName ?? 'file';
|
|
try {
|
|
String path;
|
|
if (localPath != null && localPath.isNotEmpty && File(localPath).existsSync()) {
|
|
path = localPath;
|
|
} else if (url != null && url.isNotEmpty) {
|
|
_toast('正在下载,请稍候');
|
|
final dir = await getTemporaryDirectory();
|
|
path = '${dir.path}/$fileName';
|
|
await _dio.download(url, path);
|
|
} else {
|
|
_toast('文件找不到了');
|
|
return;
|
|
}
|
|
final result = await OpenFilex.open(path);
|
|
if (result.type != ResultType.done) {
|
|
_toast('没有能打开这个文件的应用');
|
|
}
|
|
} catch (_) {
|
|
_toast('文件打不开,请稍后再试');
|
|
}
|
|
}
|
|
|
|
// ---------------- 语音通话 ----------------
|
|
|
|
Future<void> _startVoiceCall() async {
|
|
final error = await CallService.instance.startCall(peerUserID: _peerID, peerName: _title);
|
|
if (!mounted) return;
|
|
if (error != null) {
|
|
_toast(error);
|
|
return;
|
|
}
|
|
Navigator.of(context).push(MaterialPageRoute(builder: (_) => const CallScreen(), fullscreenDialog: true));
|
|
}
|
|
|
|
void _toast(String text) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text), duration: const Duration(seconds: 2)));
|
|
}
|
|
|
|
// ---------------- 界面 ----------------
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final self = IMService.instance.selfInfo;
|
|
return Scaffold(
|
|
backgroundColor: AppColors.chatBg,
|
|
appBar: AppBar(
|
|
title: Text(_title),
|
|
actions: [
|
|
if (!_isGroup)
|
|
IconButton(
|
|
icon: const Icon(Icons.more_horiz),
|
|
onPressed: () {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
builder: (ctx) => SafeArea(
|
|
child: ListTile(
|
|
leading: const Icon(Icons.phone_outlined),
|
|
title: const Text('语音通话'),
|
|
onTap: () {
|
|
Navigator.of(ctx).pop();
|
|
_startVoiceCall();
|
|
},
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: () => FocusScope.of(context).unfocus(),
|
|
child: ListView.builder(
|
|
controller: _scroll,
|
|
reverse: true,
|
|
padding: const EdgeInsets.symmetric(vertical: AppGap.x2),
|
|
itemCount: _messages.length,
|
|
itemBuilder: (context, i) {
|
|
final msg = _messages[i];
|
|
return Column(
|
|
children: [
|
|
_buildMessageRow(msg, self),
|
|
if (_showDividerAt(i)) _timeDivider(msg),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
SafeArea(
|
|
top: false,
|
|
child: ChatInputBar(
|
|
onSendText: _sendText,
|
|
onSendVoice: _sendVoice,
|
|
onPickImage: _pickAndSendImage,
|
|
onPickFile: _pickAndSendFile,
|
|
onVoiceCall: _isGroup ? null : _startVoiceCall,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMessageRow(Message msg, UserInfo? self) {
|
|
final isMine = msg.sendID == IMService.instance.currentUserID;
|
|
// 通知类消息(如入群提示):居中灰字
|
|
if ((msg.contentType ?? 0) >= MessageType.notificationBegin) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: AppGap.x2),
|
|
child: Center(
|
|
child: Text(
|
|
FormatUtils.messagePreview(msg, isGroup: _isGroup),
|
|
style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return MessageBubble(
|
|
message: msg,
|
|
isMine: isMine,
|
|
selfName: self?.nickname ?? '',
|
|
selfFaceURL: self?.faceURL,
|
|
peerName: _title,
|
|
peerFaceURL: _isGroup ? null : _conv.faceURL,
|
|
showSenderName: _isGroup,
|
|
playingVoiceId: _playingVoiceId,
|
|
onTapVoice: _playVoice,
|
|
onTapFile: _openFile,
|
|
onResend: _resend,
|
|
);
|
|
}
|
|
|
|
/// 与上一条(更旧的)消息间隔超过 5 分钟时显示时间分隔条
|
|
bool _showDividerAt(int i) {
|
|
final current = _messages[i].sendTime ?? 0;
|
|
if (current <= 0) return false;
|
|
if (i == _messages.length - 1) return true; // 最早一条也显示
|
|
final older = _messages[i + 1].sendTime ?? 0;
|
|
return current - older > _dividerGapMillis;
|
|
}
|
|
|
|
Widget _timeDivider(Message msg) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: AppGap.x2),
|
|
child: Center(
|
|
child: Text(
|
|
FormatUtils.chatDividerTime(msg.sendTime ?? 0),
|
|
style: const TextStyle(fontSize: AppFont.small, color: AppColors.textSecondary),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|