Files
tongxunruanjian/mobile/lib/screens/chat_screen.dart
T
编码工程师andmultica-agent 12b0a1c943 feat(mobile,account-service): 统一发送服务 + 员工目录接口 + 好友申请五态/验证消息(B-52 施工 2/3)
- 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>
2026-08-16 02:16:00 +08:00

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),
),
),
);
}
}