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 createState() => _ChatScreenState(); } class _ChatScreenState extends State { /// 消息列表,新消息在前(配合 reverse 列表) final List _messages = []; final ScrollController _scroll = ScrollController(); final AudioPlayer _player = AudioPlayer(); final Dio _dio = Dio(); StreamSubscription? _msgSub; StreamSubscription? _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 _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 _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 _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 _sendText(String text) async { try { final message = await OpenIM.iMManager.messageManager.createTextMessage(text: text); _sendMessage(message); } catch (_) { _toast('消息没发出去,请重试'); } } Future _sendVoice(String path, int duration) async { try { final message = await OpenIM.iMManager.messageManager.createSoundMessageFromFullPath( soundPath: path, duration: duration, ); _sendMessage(message); } catch (_) { _toast('语音没发出去,请重试'); } } Future _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 _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 _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 _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 _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), ), ), ); } }