新增 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,289 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../config.dart';
|
||||
import '../models/signaling.dart';
|
||||
|
||||
/// SDK 与服务器的连接状态
|
||||
enum ConnectStatus { idle, connecting, success, failed }
|
||||
|
||||
/// 通话信令的自定义消息类型(与官方样板工程保持一致,保证互通)
|
||||
class SignalingType {
|
||||
SignalingType._();
|
||||
|
||||
static const int callingInvite = 200; // 发起呼叫
|
||||
static const int callingAccept = 201; // 接听
|
||||
static const int callingReject = 202; // 拒接
|
||||
static const int callingCancel = 203; // 取消呼叫
|
||||
static const int callingHungup = 204; // 挂断
|
||||
}
|
||||
|
||||
/// OpenIM SDK 的统一封装:初始化、登录、监听、会话列表。
|
||||
/// 用 ChangeNotifier 做状态分发,不引入额外状态管理框架。
|
||||
class IMService extends ChangeNotifier {
|
||||
IMService._();
|
||||
|
||||
static final IMService instance = IMService._();
|
||||
|
||||
/// SDK 是否已初始化(App 启动时做一次)
|
||||
bool sdkReady = false;
|
||||
|
||||
/// 与服务器的连接状态(断网横幅用)
|
||||
ConnectStatus connectStatus = ConnectStatus.idle;
|
||||
|
||||
/// 是否已登录
|
||||
bool loggedIn = false;
|
||||
|
||||
/// 会话首次同步中(消息列表的「正在加载」态)
|
||||
bool syncing = true;
|
||||
|
||||
/// 会话同步失败(消息列表的「加载失败」态)
|
||||
bool syncFailed = false;
|
||||
|
||||
/// 会话同步是否完成过至少一次(区分「正在加载」和「还没有消息」)
|
||||
bool conversationsLoaded = false;
|
||||
|
||||
/// 会话列表(置顶在前,其余按最新消息时间倒序)
|
||||
List<ConversationInfo> conversations = [];
|
||||
|
||||
/// 当前登录用户信息
|
||||
UserInfo? selfInfo;
|
||||
|
||||
/// 当前登录凭证(发起通话取 LiveKit token 时要用)
|
||||
String? currentUserID;
|
||||
String? currentToken;
|
||||
|
||||
/// 被踢下线 / token 失效时的回调(由 main.dart 设置:清缓存、回登录页)
|
||||
void Function()? onForceLogout;
|
||||
|
||||
/// 收到新消息(聊天页订阅)
|
||||
final StreamController<Message> _newMsgController = StreamController<Message>.broadcast();
|
||||
Stream<Message> get onNewMessage => _newMsgController.stream;
|
||||
|
||||
/// 消息被撤回(撤回方的 clientMsgID,聊天页订阅)
|
||||
final StreamController<String> _revokeController = StreamController<String>.broadcast();
|
||||
Stream<String> get onMessageRevoked => _revokeController.stream;
|
||||
|
||||
/// 收到通话信令(仅在线自定义消息,通话模块订阅)
|
||||
final StreamController<Message> _signalingController = StreamController<Message>.broadcast();
|
||||
Stream<Message> get onSignaling => _signalingController.stream;
|
||||
|
||||
int get platformID => Platform.isIOS ? IMPlatform.ios : IMPlatform.android;
|
||||
|
||||
/// App 启动时初始化 SDK(只做一次)
|
||||
Future<void> init() async {
|
||||
if (sdkReady) return;
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
final dataDir = '${dir.path}/openim';
|
||||
await Directory(dataDir).create(recursive: true);
|
||||
final ok = await OpenIM.iMManager.initSDK(
|
||||
platformID: platformID,
|
||||
apiAddr: apiAddr,
|
||||
wsAddr: wsAddr,
|
||||
dataDir: dataDir,
|
||||
logFilePath: dataDir,
|
||||
logLevel: 6,
|
||||
listener: OnConnectListener(
|
||||
onConnecting: () {
|
||||
connectStatus = ConnectStatus.connecting;
|
||||
notifyListeners();
|
||||
},
|
||||
onConnectSuccess: () {
|
||||
connectStatus = ConnectStatus.success;
|
||||
notifyListeners();
|
||||
},
|
||||
onConnectFailed: (code, error) {
|
||||
connectStatus = ConnectStatus.failed;
|
||||
notifyListeners();
|
||||
},
|
||||
onKickedOffline: _forceLogout,
|
||||
onUserTokenExpired: _forceLogout,
|
||||
onUserTokenInvalid: _forceLogout,
|
||||
),
|
||||
);
|
||||
sdkReady = ok == true;
|
||||
_setBusinessListeners();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setBusinessListeners() {
|
||||
OpenIM.iMManager.conversationManager.setConversationListener(
|
||||
OnConversationListener(
|
||||
onSyncServerStart: (reInstall) {
|
||||
syncing = true;
|
||||
syncFailed = false;
|
||||
notifyListeners();
|
||||
},
|
||||
onSyncServerFinish: (reInstall) {
|
||||
syncing = false;
|
||||
conversationsLoaded = true;
|
||||
refreshConversations();
|
||||
},
|
||||
onSyncServerFailed: (reInstall) {
|
||||
syncing = false;
|
||||
syncFailed = true;
|
||||
notifyListeners();
|
||||
},
|
||||
onConversationChanged: (list) => refreshConversations(),
|
||||
onNewConversation: (list) => refreshConversations(),
|
||||
onTotalUnreadMessageCountChanged: (count) => notifyListeners(),
|
||||
),
|
||||
);
|
||||
OpenIM.iMManager.messageManager.setAdvancedMsgListener(
|
||||
OnAdvancedMsgListener(
|
||||
onRecvNewMessage: (msg) {
|
||||
_newMsgController.add(msg);
|
||||
},
|
||||
onRecvOfflineNewMessage: (msg) {
|
||||
_newMsgController.add(msg);
|
||||
},
|
||||
onNewRecvMessageRevoked: (info) {
|
||||
if (info.clientMsgID != null) _revokeController.add(info.clientMsgID!);
|
||||
},
|
||||
onRecvOnlineOnlyMessage: (msg) {
|
||||
// 通话信令走「仅在线」的自定义消息,转发给通话模块
|
||||
if (msg.contentType == MessageType.custom) {
|
||||
_signalingController.add(msg);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
// 好友与群变更只负责刷新界面(通讯录页直接监听 IMService)
|
||||
OpenIM.iMManager.friendshipManager.setFriendshipListener(
|
||||
OnFriendshipListener(
|
||||
onFriendAdded: (info) => notifyListeners(),
|
||||
onFriendDeleted: (info) => notifyListeners(),
|
||||
onFriendInfoChanged: (info) => notifyListeners(),
|
||||
onFriendApplicationAdded: (info) => notifyListeners(),
|
||||
onFriendApplicationAccepted: (info) => notifyListeners(),
|
||||
onFriendApplicationRejected: (info) => notifyListeners(),
|
||||
),
|
||||
);
|
||||
OpenIM.iMManager.groupManager.setGroupListener(
|
||||
OnGroupListener(
|
||||
onJoinedGroupAdded: (info) => notifyListeners(),
|
||||
onJoinedGroupDeleted: (info) => notifyListeners(),
|
||||
onGroupInfoChanged: (info) => notifyListeners(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 登录(init 之后调用)。userID/token 来自公司账号登录接口。
|
||||
Future<void> login({required String userID, required String token}) async {
|
||||
await init();
|
||||
final user = await OpenIM.iMManager.login(
|
||||
userID: userID,
|
||||
token: token,
|
||||
defaultValue: () async => UserInfo(userID: userID),
|
||||
);
|
||||
currentUserID = userID;
|
||||
currentToken = token;
|
||||
selfInfo = user;
|
||||
loggedIn = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 刷新自己的资料(我的页面展示用)
|
||||
Future<void> refreshSelfInfo() async {
|
||||
if (!loggedIn) return;
|
||||
try {
|
||||
selfInfo = await OpenIM.iMManager.userManager.getSelfUserInfo();
|
||||
notifyListeners();
|
||||
} catch (_) {
|
||||
// 拉取失败沿用内存里的旧数据
|
||||
}
|
||||
}
|
||||
|
||||
/// 退出登录
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await OpenIM.iMManager.logout();
|
||||
} catch (_) {
|
||||
// 本地照常清理
|
||||
}
|
||||
_resetLoginState();
|
||||
}
|
||||
|
||||
void _forceLogout() {
|
||||
_resetLoginState();
|
||||
onForceLogout?.call();
|
||||
}
|
||||
|
||||
void _resetLoginState() {
|
||||
loggedIn = false;
|
||||
currentUserID = null;
|
||||
currentToken = null;
|
||||
selfInfo = null;
|
||||
conversations = [];
|
||||
conversationsLoaded = false;
|
||||
syncing = true;
|
||||
syncFailed = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 重新拉取全部会话并排序(置顶在前,其余按最新消息时间倒序)
|
||||
Future<void> refreshConversations() async {
|
||||
if (!loggedIn) return;
|
||||
try {
|
||||
final list = await OpenIM.iMManager.conversationManager.getAllConversationList();
|
||||
list.sort((a, b) {
|
||||
final ap = a.isPinned == true ? 0 : 1;
|
||||
final bp = b.isPinned == true ? 0 : 1;
|
||||
if (ap != bp) return ap - bp;
|
||||
return (b.latestMsgSendTime ?? 0).compareTo(a.latestMsgSendTime ?? 0);
|
||||
});
|
||||
conversations = list;
|
||||
conversationsLoaded = true;
|
||||
notifyListeners();
|
||||
} catch (_) {
|
||||
// 拉取失败保留现有列表
|
||||
}
|
||||
}
|
||||
|
||||
/// 进入聊天页后清除该会话未读
|
||||
Future<void> markConversationRead(String conversationID) async {
|
||||
try {
|
||||
await OpenIM.iMManager.conversationManager.markConversationMessageAsRead(conversationID: conversationID);
|
||||
} catch (_) {
|
||||
// 标记失败不影响聊天
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析「仅在线」自定义消息里的通话信令,不是通话信令返回 null
|
||||
static SignalingPayload? parseSignaling(Message msg) {
|
||||
try {
|
||||
final data = msg.customElem?.data;
|
||||
if (data == null || data.isEmpty) return null;
|
||||
final map = jsonDecode(data) as Map<String, dynamic>;
|
||||
final customType = map['customType'];
|
||||
if (customType is! int || customType < 200 || customType > 204) return null;
|
||||
final payload = SignalingPayload(
|
||||
type: customType,
|
||||
invitation: InvitationInfo.fromJson(Map<String, dynamic>.from(map['data'] ?? {})),
|
||||
);
|
||||
return payload;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条通话信令
|
||||
class SignalingPayload {
|
||||
/// 见 [SignalingType]
|
||||
final int type;
|
||||
final InvitationInfo invitation;
|
||||
|
||||
SignalingPayload({required this.type, required this.invitation});
|
||||
|
||||
/// 房间号
|
||||
String? get roomID => invitation.roomID;
|
||||
|
||||
/// 呼叫发起人
|
||||
String? get inviterUserID => invitation.inviterUserID;
|
||||
}
|
||||
Reference in New Issue
Block a user