新增 mobile/:畅联手机端 Flutter 工程(B-58)
- 登录:工号+密码走 account-service /api/login,自动登录、被踢下线回登录页 - 消息:会话列表(未读角标/免打扰/时间)、单聊群聊、文字/语音/图片/文件消息、失败重发、历史分页 - 通讯录:新的同事/我的群聊入口、按部门分组(好友 ex 字段)、搜索 - 通话:一对一语音通话(信令走 OpenIM 自定义消息 + LiveKit,token 走 /api/rtc_token) - 界面按确认效果图 m1/m2/m3 实现,主色 #3B87F5,四态视图齐全,无英文残留
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
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 '../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('对方撤回了一条消息');
|
||||
}
|
||||
|
||||
// ---------------- 发送 ----------------
|
||||
|
||||
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 OpenIM.iMManager.messageManager.sendMessage(
|
||||
message: message,
|
||||
userID: _isGroup ? null : _peerID,
|
||||
groupID: _isGroup ? _groupID : null,
|
||||
offlinePushInfo: OfflinePushInfo(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => message.status = sent.status ?? MessageStatus.succeeded);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => message.status = MessageStatus.failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// 点红色感叹号重发
|
||||
Future<void> _resend(Message message) async {
|
||||
setState(() => message.status = MessageStatus.sending);
|
||||
try {
|
||||
final sent = await OpenIM.iMManager.messageManager.sendMessage(
|
||||
message: message,
|
||||
userID: _isGroup ? null : _peerID,
|
||||
groupID: _isGroup ? _groupID : null,
|
||||
offlinePushInfo: OfflinePushInfo(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => message.status = sent.status ?? MessageStatus.succeeded);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => message.status = MessageStatus.failed);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user