新增 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,120 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../config.dart';
|
||||
|
||||
/// 公司账号登录接口返回的数据
|
||||
class AuthResult {
|
||||
final String userID;
|
||||
final String token;
|
||||
final String nickname;
|
||||
|
||||
AuthResult({required this.userID, required this.token, required this.nickname});
|
||||
|
||||
factory AuthResult.fromJson(Map<String, dynamic> json) => AuthResult(
|
||||
userID: json['userID']?.toString() ?? '',
|
||||
token: json['imToken']?.toString() ?? '',
|
||||
nickname: json['nickname']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// 登录失败,message 直接给用户看(简体中文)
|
||||
class AuthException implements Exception {
|
||||
final String message;
|
||||
AuthException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// 公司账号登录 HTTP 客户端。
|
||||
///
|
||||
/// 接口契约(account-service,见仓库 account-service/README.md):
|
||||
/// - POST {authApiBase}/api/login
|
||||
/// body: {"staffNo": "工号", "password": "...", "platformID": 1 iOS / 2 Android}
|
||||
/// 响应统一包络 {"code": 0, "msg": "", "data": {...}},失败也是 HTTP 200 + code != 0
|
||||
/// 成功 data: {"userID", "nickname", "imToken", "expireTimeSeconds"}(userID/imToken 直接用于 OpenIM SDK 登录)
|
||||
/// - POST {authApiBase}/api/rtc_token(语音通话换 LiveKit 进房 token)
|
||||
/// 请求头: Authorization: Bearer {imToken}
|
||||
/// body: {"room": "房间号", "identity": "当前用户 userID"}
|
||||
/// 成功 data: {"token": "LiveKit 访问 token"}
|
||||
class AuthApi {
|
||||
final Dio _dio = Dio(BaseOptions(
|
||||
baseUrl: authApiBase,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
));
|
||||
|
||||
/// 拆统一响应包络:code == 0 返回 data,否则用 msg 抛 [AuthException]
|
||||
Map<String, dynamic> _unwrap(dynamic body) {
|
||||
if (body is Map<String, dynamic>) {
|
||||
if (body['code'] == 0 && body['data'] is Map<String, dynamic>) {
|
||||
return Map<String, dynamic>.from(body['data'] as Map);
|
||||
}
|
||||
final msg = body['msg']?.toString();
|
||||
throw AuthException((msg != null && msg.isNotEmpty) ? msg : '服务器返回的数据不对,请联系管理员');
|
||||
}
|
||||
throw AuthException('服务器返回的数据不对,请联系管理员');
|
||||
}
|
||||
|
||||
/// 工号 + 密码登录,成功后返回 OpenIM 登录所需的 userID/imToken
|
||||
Future<AuthResult> login({required String staffNo, required String password, required int platformID}) async {
|
||||
try {
|
||||
final resp = await _dio.post('/api/login', data: {
|
||||
'staffNo': staffNo,
|
||||
'password': password,
|
||||
'platformID': platformID,
|
||||
});
|
||||
final result = AuthResult.fromJson(_unwrap(resp.data));
|
||||
if (result.userID.isNotEmpty && result.token.isNotEmpty) return result;
|
||||
throw AuthException('服务器返回的数据不对,请联系管理员');
|
||||
} on DioException catch (e) {
|
||||
// 401 等 HTTP 层失败也带 {code, msg} 包络
|
||||
final data = e.response?.data;
|
||||
if (data is Map && data['msg'] != null && data['msg'].toString().isNotEmpty) {
|
||||
throw AuthException(data['msg'].toString());
|
||||
}
|
||||
throw AuthException('连不上服务器,请检查网络');
|
||||
} catch (e) {
|
||||
if (e is AuthException) rethrow;
|
||||
throw AuthException('登录出错了,请稍后再试');
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取 LiveKit 房间 token(语音通话用)。authToken 即登录返回的 imToken。
|
||||
Future<LiveKitCredential> getRtcToken({required String room, required String identity, required String authToken}) async {
|
||||
try {
|
||||
final resp = await _dio.post(
|
||||
'/api/rtc_token',
|
||||
data: {'room': room, 'identity': identity},
|
||||
options: Options(headers: {'Authorization': 'Bearer $authToken'}),
|
||||
);
|
||||
final data = _unwrap(resp.data);
|
||||
final token = data['token']?.toString();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
final live = data['liveURL']?.toString();
|
||||
return LiveKitCredential(
|
||||
token: token,
|
||||
liveURL: live != null && live.isNotEmpty ? live : livekitUrl,
|
||||
);
|
||||
}
|
||||
throw AuthException('服务器返回的数据不对,请联系管理员');
|
||||
} on DioException catch (e) {
|
||||
final data = e.response?.data;
|
||||
if (data is Map && data['msg'] != null && data['msg'].toString().isNotEmpty) {
|
||||
throw AuthException(data['msg'].toString());
|
||||
}
|
||||
throw AuthException('连不上服务器,请检查网络');
|
||||
} catch (e) {
|
||||
if (e is AuthException) rethrow;
|
||||
throw AuthException('发起通话失败,请稍后再试');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LiveKit 进房凭证
|
||||
class LiveKitCredential {
|
||||
final String token;
|
||||
final String liveURL;
|
||||
|
||||
LiveKitCredential({required this.token, required this.liveURL});
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/signaling.dart';
|
||||
import 'auth_api.dart';
|
||||
import 'im_service.dart';
|
||||
|
||||
/// 通话阶段
|
||||
enum CallPhase {
|
||||
idle, // 空闲
|
||||
outgoing, // 呼出中(等待对方接听)
|
||||
incoming, // 来电中(等待本机接听)
|
||||
incall, // 通话中
|
||||
}
|
||||
|
||||
/// 一对一语音通话:OpenIM 自定义消息做信令 + LiveKit 传声音。
|
||||
/// 信令协议与官方样板工程一致(customType 200-204 的仅在线自定义消息)。
|
||||
class CallService extends ChangeNotifier {
|
||||
CallService._();
|
||||
|
||||
static final CallService instance = CallService._();
|
||||
|
||||
final AuthApi _authApi = AuthApi();
|
||||
|
||||
CallPhase phase = CallPhase.idle;
|
||||
|
||||
/// 对方 userID 与显示名
|
||||
String? peerUserID;
|
||||
String peerName = '';
|
||||
|
||||
/// 通话时长(秒)
|
||||
int callSeconds = 0;
|
||||
|
||||
/// 麦克风是否开启
|
||||
bool micOn = true;
|
||||
|
||||
/// 是否免提
|
||||
bool speakerOn = false;
|
||||
|
||||
/// 有来电时的回调(由 main.dart 设置,用于弹出来电界面)
|
||||
void Function()? onIncomingCall;
|
||||
|
||||
/// 通话结束时要提示给用户的一句话(可为空)
|
||||
String? endHint;
|
||||
|
||||
InvitationInfo? _invitation;
|
||||
Room? _room;
|
||||
EventsListener<RoomEvent>? _roomListener;
|
||||
Timer? _timeoutTimer;
|
||||
Timer? _durationTimer;
|
||||
StreamSubscription<Message>? _signalingSub;
|
||||
bool _started = false;
|
||||
|
||||
/// 来电铃声等待 / 呼叫超时时间(秒),与信令里的 timeout 一致
|
||||
static const int _inviteTimeoutSec = 30;
|
||||
|
||||
/// 启动信令监听(登录成功后调用一次)
|
||||
void start() {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
_signalingSub = IMService.instance.onSignaling.listen(_onSignaling);
|
||||
}
|
||||
|
||||
bool get isBusy => phase != CallPhase.idle;
|
||||
|
||||
/// 发起呼叫(单聊)。返回错误提示,null 表示成功进入呼叫流程。
|
||||
Future<String?> startCall({required String peerUserID, required String peerName}) async {
|
||||
if (isBusy) return '正在通话中,请稍后再试';
|
||||
final im = IMService.instance;
|
||||
final myUserID = im.currentUserID;
|
||||
final authToken = im.currentToken;
|
||||
if (myUserID == null || authToken == null) return '登录状态已失效,请重新登录';
|
||||
|
||||
_invitation = InvitationInfo(
|
||||
inviterUserID: myUserID,
|
||||
inviteeUserIDList: [peerUserID],
|
||||
roomID: const Uuid().v4(),
|
||||
timeout: _inviteTimeoutSec,
|
||||
initiateTime: DateTime.now().millisecondsSinceEpoch,
|
||||
mediaType: 'audio',
|
||||
sessionType: ConversationType.single,
|
||||
platformID: im.platformID,
|
||||
);
|
||||
this.peerUserID = peerUserID;
|
||||
this.peerName = peerName;
|
||||
|
||||
try {
|
||||
// 1. 发呼叫信令(仅在线,不落地)
|
||||
await _sendSignaling(SignalingType.callingInvite, peerUserID);
|
||||
// 2. 取 LiveKit token 并进房(先进房等对方,对方接听信令到达后开始计时)
|
||||
await _joinRoom(myUserID, authToken);
|
||||
_setPhase(CallPhase.outgoing);
|
||||
_startTimeoutTimer(() {
|
||||
endHint = '对方暂时无人接听';
|
||||
_sendSignalingQuietly(SignalingType.callingCancel, peerUserID);
|
||||
_teardown();
|
||||
});
|
||||
return null;
|
||||
} on AuthException catch (e) {
|
||||
_teardown();
|
||||
return e.message;
|
||||
} catch (_) {
|
||||
_teardown();
|
||||
return '发起通话失败,请检查网络后重试';
|
||||
}
|
||||
}
|
||||
|
||||
/// 接听来电
|
||||
Future<void> accept() async {
|
||||
if (phase != CallPhase.incoming) return;
|
||||
final im = IMService.instance;
|
||||
final myUserID = im.currentUserID;
|
||||
final authToken = im.currentToken;
|
||||
final inviter = _invitation?.inviterUserID;
|
||||
if (myUserID == null || authToken == null || inviter == null) {
|
||||
_teardown();
|
||||
return;
|
||||
}
|
||||
_timeoutTimer?.cancel();
|
||||
try {
|
||||
await _sendSignaling(SignalingType.callingAccept, inviter);
|
||||
await _joinRoom(myUserID, authToken);
|
||||
_beginIncall();
|
||||
} catch (_) {
|
||||
endHint = '接听失败,请检查网络';
|
||||
_sendSignalingQuietly(SignalingType.callingReject, inviter);
|
||||
_teardown();
|
||||
}
|
||||
}
|
||||
|
||||
/// 拒接来电
|
||||
Future<void> reject() async {
|
||||
if (phase != CallPhase.incoming) return;
|
||||
final inviter = _invitation?.inviterUserID;
|
||||
if (inviter != null) await _sendSignalingQuietly(SignalingType.callingReject, inviter);
|
||||
_teardown();
|
||||
}
|
||||
|
||||
/// 取消呼叫(呼出方主动取消)
|
||||
Future<void> cancel() async {
|
||||
if (phase != CallPhase.outgoing) return;
|
||||
final peer = peerUserID;
|
||||
if (peer != null) await _sendSignalingQuietly(SignalingType.callingCancel, peer);
|
||||
_teardown();
|
||||
}
|
||||
|
||||
/// 挂断(通话中)
|
||||
Future<void> hangup() async {
|
||||
if (phase != CallPhase.incall) return;
|
||||
final peer = peerUserID;
|
||||
if (peer != null) await _sendSignalingQuietly(SignalingType.callingHungup, peer);
|
||||
_teardown();
|
||||
}
|
||||
|
||||
/// 通话界面上的统一返回键:按当前阶段取消/挂断
|
||||
Future<void> leave() async {
|
||||
switch (phase) {
|
||||
case CallPhase.outgoing:
|
||||
await cancel();
|
||||
break;
|
||||
case CallPhase.incoming:
|
||||
await reject();
|
||||
break;
|
||||
case CallPhase.incall:
|
||||
await hangup();
|
||||
break;
|
||||
case CallPhase.idle:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleMic() async {
|
||||
micOn = !micOn;
|
||||
notifyListeners();
|
||||
try {
|
||||
await _room?.localParticipant?.setMicrophoneEnabled(micOn);
|
||||
} catch (_) {
|
||||
// 切换失败时回退状态
|
||||
micOn = !micOn;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleSpeaker() async {
|
||||
speakerOn = !speakerOn;
|
||||
notifyListeners();
|
||||
try {
|
||||
await Hardware.instance.setSpeakerphoneOn(speakerOn);
|
||||
} catch (_) {
|
||||
speakerOn = !speakerOn;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 信令处理 ----------------
|
||||
|
||||
void _onSignaling(Message msg) {
|
||||
final payload = IMService.parseSignaling(msg);
|
||||
if (payload == null) return;
|
||||
final myUserID = IMService.instance.currentUserID;
|
||||
|
||||
switch (payload.type) {
|
||||
case SignalingType.callingInvite:
|
||||
// 只处理发给自己的单聊音频呼叫
|
||||
final invitation = payload.invitation;
|
||||
final invitees = invitation.inviteeUserIDList ?? [];
|
||||
if (!invitees.contains(myUserID)) return;
|
||||
if (invitation.mediaType != 'audio') return;
|
||||
if (isBusy) {
|
||||
// 占线:直接回拒接(UI 从简,无排队等待)
|
||||
_sendSignalingQuietly(SignalingType.callingReject, invitation.inviterUserID, invitation: invitation);
|
||||
return;
|
||||
}
|
||||
_invitation = invitation;
|
||||
peerUserID = invitation.inviterUserID;
|
||||
peerName = invitation.inviterUserID ?? '';
|
||||
_loadPeerName();
|
||||
_setPhase(CallPhase.incoming);
|
||||
_startTimeoutTimer(() {
|
||||
// 来电超时未接
|
||||
_teardown();
|
||||
});
|
||||
onIncomingCall?.call();
|
||||
break;
|
||||
case SignalingType.callingAccept:
|
||||
if (phase == CallPhase.outgoing && _sameRoom(payload)) {
|
||||
_timeoutTimer?.cancel();
|
||||
_beginIncall();
|
||||
}
|
||||
break;
|
||||
case SignalingType.callingReject:
|
||||
if ((phase == CallPhase.outgoing || phase == CallPhase.incall) && _sameRoom(payload)) {
|
||||
endHint = '对方拒绝了通话';
|
||||
_teardown();
|
||||
}
|
||||
break;
|
||||
case SignalingType.callingCancel:
|
||||
if (phase == CallPhase.incoming && _sameRoom(payload)) {
|
||||
endHint = '对方已取消';
|
||||
_teardown();
|
||||
}
|
||||
break;
|
||||
case SignalingType.callingHungup:
|
||||
if (phase == CallPhase.incall && _sameRoom(payload)) {
|
||||
endHint = '通话已结束';
|
||||
_teardown();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool _sameRoom(SignalingPayload payload) {
|
||||
return payload.roomID != null && payload.roomID == _invitation?.roomID;
|
||||
}
|
||||
|
||||
Future<void> _loadPeerName() async {
|
||||
final id = peerUserID;
|
||||
if (id == null) return;
|
||||
try {
|
||||
final list = await OpenIM.iMManager.userManager.getUsersInfo(userIDList: [id]);
|
||||
if (list.isNotEmpty) {
|
||||
final name = list.first.nickname ?? '';
|
||||
if (name.isNotEmpty && peerUserID == id) {
|
||||
peerName = name;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// 拉不到名字就显示工号
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- LiveKit ----------------
|
||||
|
||||
Future<void> _joinRoom(String myUserID, String authToken) async {
|
||||
final roomID = _invitation?.roomID;
|
||||
if (roomID == null) throw AuthException('通话数据不完整');
|
||||
final credential = await _authApi.getRtcToken(room: roomID, identity: myUserID, authToken: authToken);
|
||||
|
||||
_room = Room();
|
||||
_roomListener = _room!.createListener();
|
||||
_roomListener!
|
||||
..on<RoomDisconnectedEvent>((event) {
|
||||
// 掉线或房间被销毁:直接结束
|
||||
if (phase == CallPhase.incall) endHint = '通话已结束';
|
||||
_teardown();
|
||||
})
|
||||
..on<ParticipantDisconnectedEvent>((event) {
|
||||
// 对方离开房间
|
||||
if (phase == CallPhase.incall) {
|
||||
endHint = '通话已结束';
|
||||
_teardown();
|
||||
}
|
||||
});
|
||||
|
||||
await _room!.connect(credential.liveURL, credential.token);
|
||||
await _room!.localParticipant?.setMicrophoneEnabled(micOn);
|
||||
}
|
||||
|
||||
// ---------------- 内部工具 ----------------
|
||||
|
||||
void _beginIncall() {
|
||||
callSeconds = 0;
|
||||
_durationTimer?.cancel();
|
||||
_durationTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
callSeconds += 1;
|
||||
notifyListeners();
|
||||
});
|
||||
_setPhase(CallPhase.incall);
|
||||
}
|
||||
|
||||
void _setPhase(CallPhase p) {
|
||||
phase = p;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _startTimeoutTimer(void Function() onTimeout) {
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = Timer(const Duration(seconds: _inviteTimeoutSec), onTimeout);
|
||||
}
|
||||
|
||||
Future<void> _sendSignaling(int type, String recvUserID, {InvitationInfo? invitation}) async {
|
||||
final inv = invitation ?? _invitation;
|
||||
if (inv == null) return;
|
||||
final data = jsonEncode({'customType': type, 'data': inv.toJson()});
|
||||
final message = await OpenIM.iMManager.messageManager.createCustomMessage(
|
||||
data: data,
|
||||
extension: '',
|
||||
description: '',
|
||||
);
|
||||
await OpenIM.iMManager.messageManager.sendMessage(
|
||||
message: message,
|
||||
offlinePushInfo: OfflinePushInfo(),
|
||||
userID: recvUserID,
|
||||
isOnlineOnly: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 发信令失败时静默处理(挂断/取消类消息失败不影响本地收尾)
|
||||
Future<void> _sendSignalingQuietly(int type, String? recvUserID, {InvitationInfo? invitation}) async {
|
||||
if (recvUserID == null) return;
|
||||
try {
|
||||
await _sendSignaling(type, recvUserID, invitation: invitation);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 结束通话:释放房间与计时器,回到空闲态
|
||||
void _teardown() {
|
||||
_timeoutTimer?.cancel();
|
||||
_timeoutTimer = null;
|
||||
_durationTimer?.cancel();
|
||||
_durationTimer = null;
|
||||
final room = _room;
|
||||
_room = null;
|
||||
_roomListener?.dispose();
|
||||
_roomListener = null;
|
||||
if (room != null) {
|
||||
() async {
|
||||
try {
|
||||
await room.disconnect();
|
||||
await room.dispose();
|
||||
} catch (_) {}
|
||||
}();
|
||||
}
|
||||
// 复位免提
|
||||
if (speakerOn) {
|
||||
Hardware.instance.setSpeakerphoneOn(false).catchError((_) {});
|
||||
speakerOn = false;
|
||||
}
|
||||
micOn = true;
|
||||
callSeconds = 0;
|
||||
_invitation = null;
|
||||
_setPhase(CallPhase.idle);
|
||||
}
|
||||
|
||||
/// 消费结束提示(界面弹出提示后清空)
|
||||
String? takeEndHint() {
|
||||
final hint = endHint;
|
||||
endHint = null;
|
||||
return hint;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_signalingSub?.cancel();
|
||||
_teardown();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -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