fix(mobile-next): 按 B-91 收口 Android 通话视觉打回项

呼出/通话中按钮、计时器、底色、60s 未接与进出场动效对齐视觉规范。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
编码工程师
2026-08-20 09:58:28 +08:00
co-authored by Cursor multica-agent
parent 714794f7fe
commit 9bde926c81
20 changed files with 640 additions and 210 deletions
@@ -24,6 +24,9 @@ class AppColors {
static const Color maskBg = Color(0xFF000000); static const Color maskBg = Color(0xFF000000);
static const Color success = Color(0xFF00B42A); static const Color success = Color(0xFF00B42A);
static const Color warning = Color(0xFFFF7D00); static const Color warning = Color(0xFFFF7D00);
/// 通话页纯色深底。与 [textPrimary] 同值(`#1D2129`),不得改用 [maskBg]。
static const Color callBg = textPrimary;
} }
class AppFont { class AppFont {
@@ -33,6 +36,9 @@ class AppFont {
static const double sub = 14; static const double sub = 14;
static const double body = 16; static const double body = 16;
static const double title = 18; static const double title = 18;
/// 语音通话计时器,B-91 §2 唯一字号例外。
static const double callTimer = 32;
} }
class AppGap { class AppGap {
@@ -45,6 +51,9 @@ class AppGap {
static const double x6 = 24; static const double x6 = 24;
static const double x8 = 32; static const double x8 = 32;
static const double x12 = 48; static const double x12 = 48;
/// 来电拒绝/接听间距,B-91 §4.10。
static const double x24 = 96;
} }
class AppRadius { class AppRadius {
@@ -66,6 +75,18 @@ class AppIconSize {
static const double lg = 24; static const double lg = 24;
} }
/// 安卓语音通话页尺寸,取值只来自 B-91 §4.10。
class AppCallSize {
AppCallSize._();
static const double nameTop = 96;
static const double avatar = 96;
static const double primaryBtn = 64;
static const double secondaryBtn = 48;
static const double incomingGap = AppGap.x24;
static const double minimizeHit = 48;
}
class AppDuration { class AppDuration {
AppDuration._(); AppDuration._();
@@ -77,6 +98,10 @@ class AppDuration {
static const toastStay = Duration(seconds: 2); static const toastStay = Duration(seconds: 2);
static const bannerCollapse = Duration(milliseconds: 300); static const bannerCollapse = Duration(milliseconds: 300);
static const picture = Duration(milliseconds: 200); static const picture = Duration(milliseconds: 200);
static const callPage = Duration(milliseconds: 250);
static const callEndHold = Duration(milliseconds: 1500);
static const callInviteTimeout = Duration(seconds: 60);
static const callVibrate = Duration(seconds: 1);
} }
class AppMotion { class AppMotion {
@@ -21,6 +21,10 @@ void applyOfficialStylesFromTokens() {
Styles.c_F2F8FF = AppColors.primaryActive; Styles.c_F2F8FF = AppColors.primaryActive;
Styles.durationOverlay = AppDuration.overlay; Styles.durationOverlay = AppDuration.overlay;
Styles.durationOverlaySwitch = AppDuration.overlaySwitch; Styles.durationOverlaySwitch = AppDuration.overlaySwitch;
Styles.callPageMotion = AppDuration.callPage;
Styles.callEndHold = AppDuration.callEndHold;
Styles.callInviteTimeout = AppDuration.callInviteTimeout;
Styles.callVibrate = AppDuration.callVibrate;
Styles.reduceMotion = AppMotion.reduce; Styles.reduceMotion = AppMotion.reduce;
Styles.motionOf = AppMotion.of; Styles.motionOf = AppMotion.of;
Styles.c_18E875 = AppColors.success; Styles.c_18E875 = AppColors.success;
@@ -28,6 +32,15 @@ void applyOfficialStylesFromTokens() {
Styles.c_6085B1 = AppColors.avatarGray; Styles.c_6085B1 = AppColors.avatarGray;
Styles.c_000000 = AppColors.maskBg; Styles.c_000000 = AppColors.maskBg;
Styles.c_000000_opacity70 = AppColors.maskBg.withOpacity(.7); Styles.c_000000_opacity70 = AppColors.maskBg.withOpacity(.7);
Styles.c_callBg = AppColors.callBg;
Styles.c_callSecondaryBg = AppColors.pageBg.withOpacity(.12);
Styles.callNameTop = AppCallSize.nameTop;
Styles.callAvatar = AppCallSize.avatar;
Styles.callPrimaryBtn = AppCallSize.primaryBtn;
Styles.callSecondaryBtn = AppCallSize.secondaryBtn;
Styles.callIncomingGap = AppCallSize.incomingGap;
Styles.callMinimizeHit = AppCallSize.minimizeHit;
Styles.callIconSize = AppIconSize.lg;
Styles.ts_FFFFFF_20sp_medium = TextStyle( Styles.ts_FFFFFF_20sp_medium = TextStyle(
color: AppColors.pageBg, color: AppColors.pageBg,
@@ -42,6 +55,20 @@ void applyOfficialStylesFromTokens() {
color: AppColors.pageBg.withOpacity(.7), color: AppColors.pageBg.withOpacity(.7),
fontSize: AppFont.sub, fontSize: AppFont.sub,
); );
Styles.ts_callName = TextStyle(
color: AppColors.pageBg,
fontSize: AppFont.title,
fontWeight: FontWeight.w500,
);
Styles.ts_callStatus = TextStyle(
color: AppColors.pageBg.withOpacity(.6),
fontSize: AppFont.sub,
);
Styles.ts_callTimer = TextStyle(
color: AppColors.pageBg,
fontSize: AppFont.callTimer,
fontFeatures: const [FontFeature.tabularFigures()],
);
Styles.c_0089FF_opacity10 = AppColors.primary.withOpacity(.1); Styles.c_0089FF_opacity10 = AppColors.primary.withOpacity(.1);
Styles.c_0089FF_opacity20 = AppColors.primary.withOpacity(.2); Styles.c_0089FF_opacity20 = AppColors.primary.withOpacity(.2);
@@ -1,11 +1,13 @@
import 'dart:async'; import 'dart:async';
import '../brand/app_tokens.dart';
/// 一对一语音会话防护:占线、超时、重复信令、销毁时释放计时器。 /// 一对一语音会话防护:占线、超时、重复信令、销毁时释放计时器。
/// 不自建第二套信令,只约束官方 OpenIMLive 的进出场。 /// 不自建第二套信令,只约束官方 OpenIMLive 的进出场。
enum VoiceCallPhase { idle, outgoing, incoming, inCall } enum VoiceCallPhase { idle, outgoing, incoming, inCall }
class CallSessionGuard { class CallSessionGuard {
CallSessionGuard({this.inviteTimeout = const Duration(seconds: 30)}); CallSessionGuard({this.inviteTimeout = AppDuration.callInviteTimeout});
final Duration inviteTimeout; final Duration inviteTimeout;
@@ -1,3 +1,4 @@
import '../brand/app_tokens.dart';
import '../config/app_endpoints.dart'; import '../config/app_endpoints.dart';
/// Android 一对一语音通话配置门。入口只在配置齐全且本卡已落地时打开。 /// Android 一对一语音通话配置门。入口只在配置齐全且本卡已落地时打开。
@@ -7,7 +8,8 @@ class LiveKitCallConfig {
/// 公司账号服务换票路径,对应 account-service `POST /api/rtc_token`。 /// 公司账号服务换票路径,对应 account-service `POST /api/rtc_token`。
static const String rtcTokenPath = '/api/rtc_token'; static const String rtcTokenPath = '/api/rtc_token';
static const Duration inviteTimeout = Duration(seconds: 30); /// 未接超时,唯一业务来源;界面层经 `Styles.callInviteTimeout` 对齐同一 Token。
static const Duration inviteTimeout = AppDuration.callInviteTimeout;
/// 薄适配与构建验证通过后为 true;配置不齐时 [enabled] 仍为 false。 /// 薄适配与构建验证通过后为 true;配置不齐时 [enabled] 仍为 false。
static const bool shipped = true; static const bool shipped = true;
@@ -12,6 +12,7 @@ import 'package:openim_live/openim_live.dart';
import '../../company/feature_flags.dart'; import '../../company/feature_flags.dart';
import '../../company/rtc/call_permissions.dart'; import '../../company/rtc/call_permissions.dart';
import '../../company/rtc/call_session_guard.dart'; import '../../company/rtc/call_session_guard.dart';
import '../../company/rtc/livekit_call_config.dart';
import '../../company/rtc/rtc_token_api.dart'; import '../../company/rtc/rtc_token_api.dart';
import '../im_callback.dart'; import '../im_callback.dart';
@@ -19,7 +20,7 @@ class IMController extends GetxController with IMCallback, OpenIMLive {
late Rx<UserFullInfo> userInfo; late Rx<UserFullInfo> userInfo;
late String atAllTag; late String atAllTag;
final _rtcTokenApi = RtcTokenApi(); final _rtcTokenApi = RtcTokenApi();
final callGuard = CallSessionGuard(); final callGuard = CallSessionGuard(inviteTimeout: LiveKitCallConfig.inviteTimeout);
@override @override
void onClose() { void onClose() {
@@ -425,18 +425,23 @@ const Map<String, String> en_US = {
"outgoingCall": "Outgoing", "outgoingCall": "Outgoing",
"microphone": "Microphone", "microphone": "Microphone",
"speaker": "Speaker", "speaker": "Speaker",
"callMute": "Mute",
"callHandsFree": "Speaker",
"callEnded": "Call ended",
"callNetworkInterrupted": "Network error, call interrupted",
"unansweredCall": "Missed call",
"hangUp": "Hang Up", "hangUp": "Hang Up",
"pickUp": "Pick Up", "pickUp": "Pick Up",
"waitingCallHint": "Calling...", "waitingCallHint": "Calling...",
"waitingVoiceCallHint": "Waiting for the other party to pick up...", "waitingVoiceCallHint": "Waiting for the other party to accept the invitation...",
"invitedVoiceCallHint": "Inviting you for a voice call...", "invitedVoiceCallHint": "Inviting you to a voice call",
"waitingVideoCallHint": "Waiting for the other party to accept the invitation", "waitingVideoCallHint": "Waiting for the other party to accept the invitation",
"invitedVideoCallHint": "Inviting you for a video call...", "invitedVideoCallHint": "Inviting you for a video call...",
"waitingToAnswer": "Waiting to answer", "waitingToAnswer": "Waiting to answer",
"invitedYouToCall": "Invited you to a call", "invitedYouToCall": "Invited you to a call",
"calling": "Calling...", "calling": "Calling...",
"nPeopleCalling": "%s people on the call", "nPeopleCalling": "%s people on the call",
'busyVideoCallHint': 'A user is busy and cannot receive your invitation', 'busyVideoCallHint': 'The other party is busy and cannot receive the invitation',
'inviterBusyVideoCallHint': 'You are in another call and cannot send an invitation', 'inviterBusyVideoCallHint': 'You are in another call and cannot send an invitation',
"whoInvitedVoiceCallHint": "%s invited you for a voice call", "whoInvitedVoiceCallHint": "%s invited you for a voice call",
"whoInvitedVideoCallHint": "%s invited you for a video call", "whoInvitedVideoCallHint": "%s invited you for a video call",
@@ -427,18 +427,23 @@ const Map<String, String> zh_CN = {
"outgoingCall": "呼出", "outgoingCall": "呼出",
"microphone": "麦克风", "microphone": "麦克风",
"speaker": "扬声器", "speaker": "扬声器",
"callMute": "静音",
"callHandsFree": "免提",
"callEnded": "通话已结束",
"callNetworkInterrupted": "网络异常,通话中断",
"unansweredCall": "未接通话",
"hangUp": "挂断", "hangUp": "挂断",
"pickUp": "接听", "pickUp": "接听",
"waitingCallHint": "正在呼叫中…", "waitingCallHint": "正在呼叫中…",
"waitingVoiceCallHint": "正在等待对方接", "waitingVoiceCallHint": "正在等待对方接受邀请",
"invitedVoiceCallHint": "邀请你进行语音通话", "invitedVoiceCallHint": "邀请你语音通话",
"waitingVideoCallHint": "正在等待对方接受邀请", "waitingVideoCallHint": "正在等待对方接受邀请",
"invitedVideoCallHint": "邀请你进行视频通话…", "invitedVideoCallHint": "邀请你进行视频通话…",
"waitingToAnswer": "等待接听", "waitingToAnswer": "等待接听",
"invitedYouToCall": "邀请你通话", "invitedYouToCall": "邀请你通话",
"calling": "通话中", "calling": "通话中",
"nPeopleCalling": "%s人正在通话中", "nPeopleCalling": "%s人正在通话中",
'busyVideoCallHint': '有用户正忙,无法接收到你的邀请', 'busyVideoCallHint': '对方正忙,无法接收邀请',
'inviterBusyVideoCallHint': '你在其它通话中,无法发起邀请', 'inviterBusyVideoCallHint': '你在其它通话中,无法发起邀请',
"whoInvitedVoiceCallHint": "%s邀请你进行语音通话", "whoInvitedVoiceCallHint": "%s邀请你进行语音通话",
"whoInvitedVideoCallHint": "%s邀请你进行视频通话", "whoInvitedVideoCallHint": "%s邀请你进行视频通话",
@@ -874,6 +874,16 @@ class StrRes {
static String get speaker => 'speaker'.tr; static String get speaker => 'speaker'.tr;
static String get callMute => 'callMute'.tr;
static String get callHandsFree => 'callHandsFree'.tr;
static String get callEnded => 'callEnded'.tr;
static String get callNetworkInterrupted => 'callNetworkInterrupted'.tr;
static String get unansweredCall => 'unansweredCall'.tr;
static String get hangUp => 'hangUp'.tr; static String get hangUp => 'hangUp'.tr;
static String get pickUp => 'pickUp'.tr; static String get pickUp => 'pickUp'.tr;
@@ -1,3 +1,5 @@
import 'dart:ui' show FontFeature;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
@@ -17,6 +19,35 @@ class Styles {
static Duration durationOverlay = const Duration(milliseconds: 150); static Duration durationOverlay = const Duration(milliseconds: 150);
static Duration durationOverlaySwitch = const Duration(milliseconds: 100); static Duration durationOverlaySwitch = const Duration(milliseconds: 100);
static Duration callPageMotion = const Duration(milliseconds: 250);
static Duration callEndHold = const Duration(milliseconds: 1500);
static Duration callInviteTimeout = const Duration(seconds: 60);
static Duration callVibrate = const Duration(seconds: 1);
static Color c_callBg = const Color(0xFF1D2129);
static Color c_callSecondaryBg = c_FFFFFF.withOpacity(.12);
static double callNameTop = 96;
static double callAvatar = 96;
static double callPrimaryBtn = 64;
static double callSecondaryBtn = 48;
static double callIncomingGap = 96;
static double callMinimizeHit = 48;
static double callIconSize = 24;
static TextStyle ts_callName = TextStyle(
color: c_FFFFFF,
fontSize: 18.sp,
fontWeight: FontWeight.w500,
);
static TextStyle ts_callStatus = TextStyle(
color: c_FFFFFF.withOpacity(.6),
fontSize: 14.sp,
);
static TextStyle ts_callTimer = TextStyle(
color: c_FFFFFF,
fontSize: 32.sp,
fontFeatures: const [FontFeature.tabularFigures()],
);
static bool Function(BuildContext) reduceMotion = static bool Function(BuildContext) reduceMotion =
(BuildContext context) => MediaQuery.maybeOf(context)?.disableAnimations ?? false; (BuildContext context) => MediaQuery.maybeOf(context)?.disableAnimations ?? false;
@@ -939,7 +939,7 @@ class IMUtils {
content = StrRes.rejectedByCaller; content = StrRes.rejectedByCaller;
break; break;
case 'timeout': case 'timeout':
content = StrRes.callTimeout; content = StrRes.unansweredCall;
break; break;
case 'networkError': case 'networkError':
content = StrRes.networkAnomaly; content = StrRes.networkAnomaly;
@@ -5,6 +5,7 @@ import 'package:rxdart/rxdart.dart';
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
import 'pages/single/room.dart'; import 'pages/single/room.dart';
import 'widgets/call_page_host.dart';
enum CallType { audio, video } enum CallType { audio, video }
@@ -61,6 +62,8 @@ class OpenIMLiveClient implements RTCBridge {
} }
static OverlayEntry? _holder; static OverlayEntry? _holder;
static GlobalKey<CallPageHostState>? _hostKey;
bool _closing = false;
bool isBusy = false; bool isBusy = false;
@@ -81,13 +84,25 @@ class OpenIMLiveClient implements RTCBridge {
} }
close() { close() {
if (_closing) return;
_closing = true;
final host = _hostKey?.currentState;
if (host == null) {
_removeHolder();
return;
}
host.dismiss().whenComplete(_removeHolder);
}
void _removeHolder() {
if (_holder != null) { if (_holder != null) {
_holder?.remove(); _holder?.remove();
_holder = null; _holder = null;
} }
_hostKey = null;
isBusy = false; isBusy = false;
currentRoomID = null; currentRoomID = null;
// The next line disables the wakelock again. _closing = false;
WakelockPlus.disable(); WakelockPlus.disable();
} }
@@ -118,9 +133,10 @@ class OpenIMLiveClient implements RTCBridge {
Function(dynamic error, dynamic stack)? onError, Function(dynamic error, dynamic stack)? onError,
Function()? onClose, Function()? onClose,
Function()? onRoomDisconnected, Function()? onRoomDisconnected,
Future Function()? onInviteTimeout,
}) { }) {
if (isBusy) return; if (isBusy) return;
// close(); _closing = false;
isBusy = true; isBusy = true;
currentRoomID = roomID; currentRoomID = roomID;
this.onTapHangup = onTapHangup; this.onTapHangup = onTapHangup;
@@ -128,8 +144,11 @@ class OpenIMLiveClient implements RTCBridge {
FocusScope.of(ctx).requestFocus(FocusNode()); FocusScope.of(ctx).requestFocus(FocusNode());
if (callObj == CallObj.single) { if (callObj == CallObj.single) {
_hostKey = GlobalKey<CallPageHostState>();
_holder = OverlayEntry( _holder = OverlayEntry(
builder: (context) => SingleRoomView( builder: (context) => CallPageHost(
key: _hostKey,
child: SingleRoomView(
callType: callType, callType: callType,
initState: initState, initState: initState,
callEventSubject: callEventSubject, callEventSubject: callEventSubject,
@@ -148,10 +167,12 @@ class OpenIMLiveClient implements RTCBridge {
onStartCalling: onStartCalling, onStartCalling: onStartCalling,
onError: onError, onError: onError,
onRoomDisconnected: onRoomDisconnected, onRoomDisconnected: onRoomDisconnected,
onInviteTimeout: onInviteTimeout,
onClose: () { onClose: () {
onClose?.call(); onClose?.call();
close(); close();
}, },
),
)); ));
} else {} } else {}
@@ -10,6 +10,7 @@ import 'package:just_audio/just_audio.dart';
import 'package:openim_common/openim_common.dart'; import 'package:openim_common/openim_common.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:vibration/vibration.dart';
import '../openim_live.dart'; import '../openim_live.dart';
@@ -53,12 +54,11 @@ mixin OpenIMLive {
final _ring = 'assets/audio/live_ring.wav'; final _ring = 'assets/audio/live_ring.wav';
final _audioPlayer = AudioPlayer( final _audioPlayer = AudioPlayer(
// Handle audio_session events ourselves for the purpose of this demo.
handleInterruptions: false, handleInterruptions: false,
// androidApplyAudioAttributes: false,
// handleAudioSessionActivation: false,
); );
Timer? _incomingVibrateTimer;
bool get isBusy => OpenIMLiveClient().isBusy; bool get isBusy => OpenIMLiveClient().isBusy;
onCloseLive() { onCloseLive() {
@@ -94,7 +94,7 @@ mixin OpenIMLive {
(event) async { (event) async {
_beCalledEvent = null; _beCalledEvent = null;
if (event.state == CallState.beCalled) { if (event.state == CallState.beCalled) {
_playSound(); _playSound(vibrate: true);
final mediaType = event.data.invitation!.mediaType; final mediaType = event.data.invitation!.mediaType;
final sessionType = event.data.invitation!.sessionType; final sessionType = event.data.invitation!.sessionType;
final callType = mediaType == 'audio' ? CallType.audio : CallType.video; final callType = mediaType == 'audio' ? CallType.audio : CallType.video;
@@ -135,6 +135,7 @@ mixin OpenIMLive {
onError: onError, onError: onError,
onRoomDisconnected: () => onRoomDisconnected(event.data), onRoomDisconnected: () => onRoomDisconnected(event.data),
onStartCalling: onLiveConnected, onStartCalling: onLiveConnected,
onInviteTimeout: () => _onInviteTimeout(event.data),
onClose: () { onClose: () {
_stopSound(); _stopSound();
onLiveSessionClosed(); onLiveSessionClosed();
@@ -195,7 +196,7 @@ mixin OpenIMLive {
inviterUserID: inviterUserID, inviterUserID: inviterUserID,
inviteeUserIDList: inviteeUserIDList, inviteeUserIDList: inviteeUserIDList,
roomID: roomID ?? groupID ?? const Uuid().v4(), roomID: roomID ?? groupID ?? const Uuid().v4(),
timeout: 30, timeout: Styles.callInviteTimeout.inSeconds,
mediaType: mediaType, mediaType: mediaType,
sessionType: sessionType, sessionType: sessionType,
platformID: IMUtils.getPlatform(), platformID: IMUtils.getPlatform(),
@@ -233,6 +234,7 @@ mixin OpenIMLive {
}, },
onError: onError, onError: onError,
onRoomDisconnected: () => onRoomDisconnected(signal), onRoomDisconnected: () => onRoomDisconnected(signal),
onInviteTimeout: () => _onInviteTimeout(signal),
onClose: () { onClose: () {
_stopSound(); _stopSound();
onLiveSessionClosed(); onLiveSessionClosed();
@@ -242,7 +244,6 @@ mixin OpenIMLive {
onError(error, stack) { onError(error, stack) {
Logger.print('onError=====> $error $stack'); Logger.print('onError=====> $error $stack');
OpenIMLiveClient().close();
_stopSound(); _stopSound();
if (error is PlatformException) { if (error is PlatformException) {
if (int.parse(error.code) == SDKErrorCode.hasBeenBlocked) { if (int.parse(error.code) == SDKErrorCode.hasBeenBlocked) {
@@ -250,7 +251,7 @@ mixin OpenIMLive {
return; return;
} }
} }
IMViews.showToast(StrRes.networkError); IMViews.showToast(StrRes.callNetworkInterrupted);
} }
onRoomDisconnected(SignalingInfo signalingInfo) {} onRoomDisconnected(SignalingInfo signalingInfo) {}
@@ -332,12 +333,20 @@ mixin OpenIMLive {
OpenIM.iMManager.messageManager.sendMessage( OpenIM.iMManager.messageManager.sendMessage(
message: message, message: message,
offlinePushInfo: OfflinePushInfo(), offlinePushInfo: OfflinePushInfo(),
userID: signaling.invitation!.inviterUserID, userID: signaling.invitation!.inviterUserID == OpenIM.iMManager.userID
? signaling.invitation!.inviteeUserIDList!.first
: signaling.invitation!.inviterUserID,
isOnlineOnly: true); isOnlineOnly: true);
return true; return true;
} }
Future<void> _onInviteTimeout(SignalingInfo signaling) async {
_stopSound();
insertSignalingMessageSubject.add(CallEvent(CallState.timeout, signaling));
await onTimeoutCancelled(signaling);
}
onTapHangup(SignalingInfo signaling, int duration, bool isPositive) async { onTapHangup(SignalingInfo signaling, int duration, bool isPositive) async {
if (isPositive) { if (isPositive) {
final data = {'customType': CustomMessageType.callingHungup, 'data': signaling.invitation!.toJson()}; final data = {'customType': CustomMessageType.callingHungup, 'data': signaling.invitation!.toJson()};
@@ -388,16 +397,44 @@ mixin OpenIMLive {
return list; return list;
} }
void _playSound() async { void _playSound({bool vibrate = false}) async {
if (!_audioPlayer.playerState.playing) { if (!_audioPlayer.playerState.playing) {
_audioPlayer.setAsset(_ring, package: 'openim_common'); _audioPlayer.setAsset(_ring, package: 'openim_common');
_audioPlayer.setLoopMode(LoopMode.one); _audioPlayer.setLoopMode(LoopMode.one);
_audioPlayer.setVolume(1.0); _audioPlayer.setVolume(1.0);
_audioPlayer.play(); _audioPlayer.play();
} }
if (vibrate) {
_startIncomingVibrate();
}
}
void _startIncomingVibrate() {
_stopIncomingVibrate();
_pulseVibrate();
_incomingVibrateTimer = Timer.periodic(Styles.callVibrate, (_) => _pulseVibrate());
}
void _stopIncomingVibrate() {
_incomingVibrateTimer?.cancel();
_incomingVibrateTimer = null;
try {
Vibration.cancel();
} catch (_) {}
}
Future<void> _pulseVibrate() async {
try {
if (await Vibration.hasVibrator() == true) {
await Vibration.vibrate(duration: 200);
return;
}
} catch (_) {}
HapticFeedback.vibrate();
} }
void _stopSound() async { void _stopSound() async {
_stopIncomingVibrate();
if (_audioPlayer.playerState.playing) { if (_audioPlayer.playerState.playing) {
_audioPlayer.stop(); _audioPlayer.stop();
} }
@@ -31,6 +31,7 @@ class SingleRoomView extends SignalView {
super.onSyncUserInfo, super.onSyncUserInfo,
super.onError, super.onError,
super.onRoomDisconnected, super.onRoomDisconnected,
super.onInviteTimeout,
}); });
@override @override
@@ -64,13 +65,13 @@ class _SingleRoomViewState extends SignalState<SingleRoomView> {
final token = certificate.token; final token = certificate.token;
if (url == null || url.isEmpty || token == null || token.isEmpty) { if (url == null || url.isEmpty || token == null || token.isEmpty) {
widget.onError?.call(StateError('missing livekit credential'), StackTrace.current); widget.onError?.call(StateError('missing livekit credential'), StackTrace.current);
widget.onClose?.call(); onNetworkInterrupted();
return; return;
} }
final busyLineUsers = certificate.busyLineUserIDList ?? []; final busyLineUsers = certificate.busyLineUserIDList ?? [];
if (busyLineUsers.isNotEmpty) { if (busyLineUsers.isNotEmpty) {
widget.onBusyLine?.call(); widget.onBusyLine?.call();
widget.onClose?.call(); await beginEnding(CallState.cancel);
return; return;
} }
try { try {
@@ -109,6 +110,7 @@ class _SingleRoomViewState extends SignalState<SingleRoomView> {
}); });
} catch (error, stackTrace) { } catch (error, stackTrace) {
widget.onError?.call(error, stackTrace); widget.onError?.call(error, stackTrace);
onNetworkInterrupted();
} }
} }
@@ -117,7 +119,7 @@ class _SingleRoomViewState extends SignalState<SingleRoomView> {
Logger.print('Room disconnected: reason => ${event.reason}'); Logger.print('Room disconnected: reason => ${event.reason}');
WidgetsBindingCompatible.instance?.addPostFrameCallback((_) { WidgetsBindingCompatible.instance?.addPostFrameCallback((_) {
widget.onRoomDisconnected?.call(); widget.onRoomDisconnected?.call();
widget.onClose?.call(); onNetworkInterrupted();
}); });
}) })
..on<RoomAttemptReconnectEvent>((event) { ..on<RoomAttemptReconnectEvent>((event) {
@@ -36,6 +36,7 @@ abstract class SignalView extends StatefulWidget {
this.onStartCalling, this.onStartCalling,
this.onError, this.onError,
this.onRoomDisconnected, this.onRoomDisconnected,
this.onInviteTimeout,
}) : super(key: key); }) : super(key: key);
final CallType callType; final CallType callType;
final CallState initState; final CallState initState;
@@ -56,6 +57,7 @@ abstract class SignalView extends StatefulWidget {
final Function()? onRoomDisconnected; final Function()? onRoomDisconnected;
final Function(dynamic error, dynamic stack)? onError; final Function(dynamic error, dynamic stack)? onError;
final Future<UserInfo?> Function(String userID)? onSyncUserInfo; final Future<UserInfo?> Function(String userID)? onSyncUserInfo;
final Future Function()? onInviteTimeout;
} }
abstract class SignalState<T extends SignalView> extends State<T> { abstract class SignalState<T extends SignalView> extends State<T> {
@@ -71,6 +73,8 @@ abstract class SignalState<T extends SignalView> extends State<T> {
bool enabledMicrophone = true; bool enabledMicrophone = true;
bool enabledSpeaker = true; bool enabledSpeaker = true;
Timer? _inviteTimeout; Timer? _inviteTimeout;
bool _ending = false;
bool _closed = false;
ParticipantTrack? remoteParticipantTrack; ParticipantTrack? remoteParticipantTrack;
ParticipantTrack? localParticipantTrack; ParticipantTrack? localParticipantTrack;
@@ -109,9 +113,8 @@ abstract class SignalState<T extends SignalView> extends State<T> {
/// 某些信令通过liveKit的监听 /// 某些信令通过liveKit的监听
_onStateDidUpdate(CallEvent event) { _onStateDidUpdate(CallEvent event) {
Logger.print("CallEvent : 当前:$callState 收到:$event"); Logger.print("CallEvent : 当前:$callState 收到:$event");
if (!mounted) return; if (!mounted || _ending) return;
// ui 状态只有 呼叫,被呼叫,通话中,连接中
if (event.state == CallState.call || if (event.state == CallState.call ||
event.state == CallState.beCalled || event.state == CallState.beCalled ||
event.state == CallState.connecting || event.state == CallState.connecting ||
@@ -119,21 +122,23 @@ abstract class SignalState<T extends SignalView> extends State<T> {
callStateSubject.add(event.state); callStateSubject.add(event.state);
} }
if (event.state == CallState.beRejected || event.state == CallState.beCanceled) { if (event.state == CallState.beRejected) {
_cancelInviteTimeout(); beginEnding(CallState.beRejected, toast: StrRes.rejectedByCaller);
widget.onClose?.call(); } else if (event.state == CallState.beCanceled) {
beginEnding(CallState.beCanceled);
} else if (event.state == CallState.otherReject || event.state == CallState.otherAccepted) { } else if (event.state == CallState.otherReject || event.state == CallState.otherAccepted) {
if (existParticipants()) { if (existParticipants()) {
return; return;
} }
_cancelInviteTimeout();
widget.onClose?.call();
IMViews.showToast(sprintf(StrRes.otherCallHandle, [event.state == CallState.otherReject ? StrRes.rejectCall : StrRes.accept])); IMViews.showToast(sprintf(StrRes.otherCallHandle, [event.state == CallState.otherReject ? StrRes.rejectCall : StrRes.accept]));
beginEnding(event.state);
} else if (event.state == CallState.timeout) { } else if (event.state == CallState.timeout) {
_cancelInviteTimeout(); beginEnding(CallState.timeout);
widget.onClose?.call(); } else if (event.state == CallState.beHangup) {
beginEnding(CallState.beHangup);
} else if (event.state == CallState.networkError) {
beginEnding(CallState.networkError, toast: StrRes.callNetworkInterrupted);
} else if (event.state == CallState.beAccepted) { } else if (event.state == CallState.beAccepted) {
// 邀请对象比发起对象提前进入房间
if (null != remoteParticipantTrack) { if (null != remoteParticipantTrack) {
onParticipantConnected(); onParticipantConnected();
} }
@@ -147,6 +152,7 @@ abstract class SignalState<T extends SignalView> extends State<T> {
} }
onParticipantDisconnected() { onParticipantDisconnected() {
if (_ending) return;
onTapHangup(false); onTapHangup(false);
} }
@@ -187,18 +193,25 @@ abstract class SignalState<T extends SignalView> extends State<T> {
/// [isPositive] 人为挂断行为 /// [isPositive] 人为挂断行为
onTapHangup(bool isPositive) async { onTapHangup(bool isPositive) async {
_cancelInviteTimeout(); if (_ending) return;
await widget.onTapHangup?.call(duration, isPositive).whenComplete(() => /*isPositive ? {} : */ widget.onClose?.call()); await widget.onTapHangup?.call(duration, isPositive);
await beginEnding(CallState.hangup);
} }
onTapCancel() async { onTapCancel() async {
_cancelInviteTimeout(); if (_ending) return;
await widget.onTapCancel?.call().whenComplete(() => widget.onClose?.call()); await widget.onTapCancel?.call();
await beginEnding(CallState.cancel);
} }
onTapReject() async { onTapReject() async {
_cancelInviteTimeout(); if (_ending) return;
await widget.onTapReject?.call().whenComplete(() => widget.onClose?.call()); await widget.onTapReject?.call();
await beginEnding(CallState.reject);
}
onNetworkInterrupted() {
beginEnding(CallState.networkError, toast: StrRes.callNetworkInterrupted);
} }
onTapMinimize() { onTapMinimize() {
@@ -251,14 +264,17 @@ abstract class SignalState<T extends SignalView> extends State<T> {
return; return;
} }
_inviteTimeout?.cancel(); _inviteTimeout?.cancel();
_inviteTimeout = Timer(const Duration(seconds: 30), () async { _inviteTimeout = Timer(Styles.callInviteTimeout, () async {
if (!mounted) return; if (!mounted || _ending) return;
if (callState == CallState.calling) return; if (callState == CallState.calling) return;
if (widget.initState == CallState.call) { if (widget.onInviteTimeout != null) {
await onTapCancel(); await widget.onInviteTimeout!.call();
} else if (widget.initState == CallState.call) {
await widget.onTapCancel?.call();
} else { } else {
await onTapReject(); await widget.onTapReject?.call();
} }
await beginEnding(CallState.timeout);
}); });
} }
@@ -267,6 +283,31 @@ abstract class SignalState<T extends SignalView> extends State<T> {
_inviteTimeout = null; _inviteTimeout = null;
} }
Future<void> beginEnding(CallState endState, {String? toast}) async {
if (_ending || _closed) return;
_ending = true;
_cancelInviteTimeout();
if (!mounted) {
_finishClose();
return;
}
callState = endState;
if (!callStateSubject.isClosed) {
callStateSubject.add(endState);
}
if (toast != null && toast.isNotEmpty) {
IMViews.showToast(toast);
}
await Future<void>.delayed(Styles.callEndHold);
_finishClose();
}
void _finishClose() {
if (_closed) return;
_closed = true;
widget.onClose?.call();
}
@override @override
Widget build(BuildContext context) => Stack( Widget build(BuildContext context) => Stack(
children: [ children: [
@@ -276,7 +317,7 @@ abstract class SignalState<T extends SignalView> extends State<T> {
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
onEnd: () {}, onEnd: () {},
child: Container( child: Container(
color: Styles.c_000000, color: Styles.c_callBg,
child: Stack( child: Stack(
children: [ children: [
// ImageRes.liveBg.toImage // ImageRes.liveBg.toImage
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:openim_common/openim_common.dart'; import 'package:openim_common/openim_common.dart';
@@ -153,6 +152,7 @@ class _ControlsViewState extends State<ControlsView> {
} else { } else {
await _disableAudio(); await _disableAudio();
} }
if (mounted) setState(() {});
}); });
// if (null != _participant) { // if (null != _participant) {
@@ -233,127 +233,168 @@ class _ControlsViewState extends State<ControlsView> {
// } // }
} }
bool get _isEnding =>
_callState == CallState.hangup ||
_callState == CallState.beHangup ||
_callState == CallState.cancel ||
_callState == CallState.beCanceled ||
_callState == CallState.timeout ||
_callState == CallState.beRejected ||
_callState == CallState.reject ||
_callState == CallState.networkError;
@override @override
Widget build(BuildContext context) => SafeArea( Widget build(BuildContext context) {
child: Stack( final topInset = MediaQuery.of(context).padding.top;
return Stack(
children: [ children: [
Positioned( Positioned(
left: 16.w, left: 16,
top: 7.h, top: topInset + 8,
child: ImageRes.liveClose.toImage child: SizedBox(
..width = 30.w width: Styles.callMinimizeHit,
..height = 30.h height: Styles.callMinimizeHit,
..onTap = widget.onMinimize, child: IconButton(
padding: EdgeInsets.zero,
icon: Icon(Icons.keyboard_arrow_down, color: Styles.c_FFFFFF, size: Styles.callIconSize),
onPressed: widget.onMinimize,
), ),
if (null != _participant) ),
),
if (null != _participant && isVideo)
Positioned( Positioned(
right: 16.w, right: 16,
top: 7.h, top: topInset + 8,
child: Visibility(
visible: isVideo,
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
(_participant!.isCameraEnabled() ? ImageRes.liveCameraOff : ImageRes.liveCameraOn).toImage IconButton(
..width = 30.w icon: Icon(
..height = 30.h _participant!.isCameraEnabled() ? Icons.videocam_off_outlined : Icons.videocam_outlined,
..onTap = (_participant!.isCameraEnabled() ? _disableVideo : _enableVideo), color: Styles.c_FFFFFF,
16.horizontalSpace, size: Styles.callIconSize,
ImageRes.liveSwitchCamera.toImage ),
..width = 30.w onPressed: _participant!.isCameraEnabled() ? _disableVideo : _enableVideo,
..height = 30.h ),
..onTap = _toggleCamera, IconButton(
icon: Icon(Icons.cameraswitch_outlined, color: Styles.c_FFFFFF, size: Styles.callIconSize),
onPressed: _toggleCamera,
),
], ],
), ),
), ),
), Positioned.fill(child: _infoColumn),
if (null != widget.userInfo)
Positioned( Positioned(
top: 166.h, left: 0,
width: 1.sw, right: 0,
child: _userInfoView, bottom: 32 + MediaQuery.of(context).padding.bottom,
), child: _buttonArea,
Positioned(
bottom: 32.h,
width: 1.sw,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: _buttonGroup,
),
),
Positioned(
bottom: 156.h,
width: 1.sw,
child: Center(child: _videoCallingDurationView),
), ),
if (_callState == CallState.connecting) const LiveLoadingView(), if (_callState == CallState.connecting) const LiveLoadingView(),
], ],
),
); );
List<Widget> get _buttonGroup {
if (_callState == CallState.call || _callState == CallState.connecting && widget.initState == CallState.call) {
return [
LiveButton.microphone(on: _enabledMicrophone, onTap: _toggleAudio),
LiveButton.cancel(onTap: widget.onCancel),
LiveButton.speaker(on: _enabledSpeaker, onTap: _toggleSpeaker),
];
} else if (_callState == CallState.beCalled || _callState == CallState.connecting && widget.initState == CallState.beCalled) {
return [
LiveButton.reject(onTap: widget.onReject),
LiveButton.pickUp(onTap: widget.onPickUp),
];
} else if (_callState == CallState.calling) {
return [
LiveButton.microphone(on: _enabledMicrophone, onTap: _toggleAudio),
LiveButton.hungUp(onTap: () => widget.onHangUp?.call(true)),
LiveButton.speaker(on: _enabledSpeaker, onTap: _toggleSpeaker),
];
} }
return [];
Widget get _infoColumn {
final nickname = widget.userInfo == null
? ''
: (IMUtils.emptyStrToNull(widget.userInfo!.remark) ?? widget.userInfo!.nickname ?? '');
final faceURL = widget.userInfo?.faceURL;
return Column(
children: [
SizedBox(height: Styles.callNameTop),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
nickname,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Styles.ts_callName,
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_statusText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: isCalling && !isVideo ? Styles.ts_callTimer : Styles.ts_callStatus,
),
),
const SizedBox(height: 32),
AvatarView(
width: Styles.callAvatar,
height: Styles.callAvatar,
text: nickname,
url: faceURL,
borderRadius: BorderRadius.circular(Styles.callAvatar * 0.18),
),
],
);
}
String get _statusText {
if (_callState == CallState.call) {
return isVideo ? StrRes.waitingVideoCallHint : StrRes.waitingVoiceCallHint;
}
if (_callState == CallState.beCalled) {
return isVideo ? StrRes.invitedVideoCallHint : StrRes.invitedVoiceCallHint;
}
if (_callState == CallState.connecting) {
return StrRes.connecting;
}
if (_callState == CallState.networkError) {
return StrRes.callNetworkInterrupted;
}
if (_callState == CallState.beRejected || _callState == CallState.reject) {
return StrRes.rejectedByCaller;
}
if (_isEnding) {
return StrRes.callEnded;
}
if (isCalling) {
return _callingDurationStr;
}
return '';
}
Widget get _buttonArea {
if (_callState == CallState.call ||
(_callState == CallState.connecting && widget.initState == CallState.call)) {
return Center(child: LiveButton.cancel(onTap: widget.onCancel));
}
if (_callState == CallState.beCalled) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
LiveButton.reject(onTap: widget.onReject),
SizedBox(width: Styles.callIncomingGap),
LiveButton.pickUp(onTap: widget.onPickUp),
],
);
}
if (_callState == CallState.calling) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
LiveButton.mute(muted: !_enabledMicrophone, onTap: _toggleAudio),
const SizedBox(width: 48),
LiveButton.handsFree(on: _enabledSpeaker, onTap: _toggleSpeaker),
],
),
const SizedBox(height: 32),
LiveButton.hungUp(onTap: () => widget.onHangUp?.call(true)),
],
);
}
return const SizedBox.shrink();
} }
bool get isVideo => widget.callType == CallType.video; bool get isVideo => widget.callType == CallType.video;
bool get isCalling => _callState == CallState.calling; bool get isCalling => _callState == CallState.calling;
Widget get _videoCallingDurationView => Visibility(
visible: isVideo && isCalling,
child: _callingDurationStr.toText..style = Styles.ts_FFFFFF_opacity70_17sp,
);
Widget get _userInfoView {
String text;
if (_callState == CallState.call) {
text = isVideo ? StrRes.waitingVideoCallHint : StrRes.waitingVoiceCallHint;
} else if (_callState == CallState.beCalled) {
text = isVideo ? StrRes.invitedVideoCallHint : StrRes.invitedVoiceCallHint;
} else if (_callState == CallState.connecting) {
text = StrRes.connecting;
} else {
text = isVideo ? '' : _callingDurationStr;
}
String? nickname = IMUtils.emptyStrToNull(widget.userInfo!.remark) ?? widget.userInfo!.nickname;
String? faceURL = widget.userInfo!.faceURL;
return Visibility(
visible: !(isVideo && isCalling),
child: Column(
children: [
AvatarView(width: 70.w, height: 70.h, text: nickname, url: faceURL),
10.verticalSpace,
(nickname ?? '').toText..style = Styles.ts_FFFFFF_20sp_medium,
10.verticalSpace,
Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: text.toText
..style = Styles.ts_FFFFFF_opacity70_17sp
..maxLines = 1
..overflow = TextOverflow.ellipsis,
),
],
),
);
}
} }
@@ -0,0 +1,72 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:openim_common/openim_common.dart';
/// 通话页进出:250ms 自下而上滑入 ease-out,不可打断。
class CallPageHost extends StatefulWidget {
const CallPageHost({
Key? key,
required this.child,
}) : super(key: key);
final Widget child;
@override
CallPageHostState createState() => CallPageHostState();
}
class CallPageHostState extends State<CallPageHost> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
Completer<void>? _dismissing;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Styles.callPageMotion,
);
_controller.forward();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_controller.duration = Styles.motionOf(context, Styles.callPageMotion);
}
Future<void> dismiss() {
if (_dismissing != null) return _dismissing!.future;
_dismissing = Completer<void>();
final future = _controller.status == AnimationStatus.dismissed
? Future<void>.value()
: _controller.reverse();
future.whenComplete(() {
if (_dismissing?.isCompleted == false) {
_dismissing!.complete();
}
});
return _dismissing!.future;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)),
child: Material(
color: Styles.c_callBg,
child: widget.child,
),
);
}
}
@@ -1,67 +1,119 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:openim_common/openim_common.dart'; import 'package:openim_common/openim_common.dart';
/// 通话页圆形按钮:Token 色圆底 + 线性图标,禁止位图 PNG。
class LiveButton extends StatelessWidget { class LiveButton extends StatelessWidget {
const LiveButton({ const LiveButton({
Key? key, Key? key,
required this.text,
required this.icon, required this.icon,
required this.size,
required this.background,
required this.iconColor,
this.text,
this.onTap, this.onTap,
}) : super(key: key); }) : super(key: key);
final String text;
final String icon; final IconData icon;
final double size;
final Color background;
final Color iconColor;
final String? text;
final Function()? onTap; final Function()? onTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final button = Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Ink(
width: size,
height: size,
decoration: BoxDecoration(
color: background,
shape: BoxShape.circle,
),
child: Icon(
icon,
size: Styles.callIconSize,
color: iconColor,
),
),
),
);
if (text == null || text!.isEmpty) return button;
return Column( return Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
icon.toImage button,
..width = 62.w const SizedBox(height: 8),
..height = 62.h Text(text!, style: Styles.ts_callStatus),
..onTap = onTap,
10.verticalSpace,
text.toText..style = Styles.ts_FFFFFF_opacity70_14sp,
], ],
); );
} }
LiveButton.microphone({ static Color get _danger => Styles.c_FF381F;
super.key,
this.onTap,
bool on = true,
}) : text = StrRes.microphone,
icon = on ? ImageRes.liveMicOn : ImageRes.liveMicOff;
LiveButton.speaker({ static Color get _success => Styles.c_18E875;
static Color get _white => Styles.c_FFFFFF;
static Color get _dark => Styles.c_0C1C33;
LiveButton.mute({
super.key, super.key,
this.onTap, this.onTap,
bool on = true, bool muted = false,
}) : text = StrRes.speaker, }) : text = StrRes.callMute,
icon = on ? ImageRes.liveSpeakerOn : ImageRes.liveSpeakerOff; icon = muted ? Icons.mic_off_outlined : Icons.mic_none_outlined,
size = Styles.callSecondaryBtn,
background = muted ? _white : Styles.c_callSecondaryBg,
iconColor = muted ? _dark : _white;
LiveButton.handsFree({
super.key,
this.onTap,
bool on = false,
}) : text = StrRes.callHandsFree,
icon = Icons.volume_up_outlined,
size = Styles.callSecondaryBtn,
background = on ? _white : Styles.c_callSecondaryBg,
iconColor = on ? _dark : _white;
LiveButton.hungUp({ LiveButton.hungUp({
super.key, super.key,
this.onTap, this.onTap,
}) : text = StrRes.hangUp, }) : text = StrRes.hangUp,
icon = ImageRes.liveHangUp; icon = Icons.call_end,
size = Styles.callPrimaryBtn,
background = _danger,
iconColor = _white;
LiveButton.reject({ LiveButton.reject({
super.key, super.key,
this.onTap, this.onTap,
}) : text = StrRes.reject, }) : text = StrRes.reject,
icon = ImageRes.liveHangUp; icon = Icons.call_end,
size = Styles.callPrimaryBtn,
background = _danger,
iconColor = _white;
LiveButton.cancel({ LiveButton.cancel({
super.key, super.key,
this.onTap, this.onTap,
}) : text = StrRes.cancel, }) : text = StrRes.cancel,
icon = ImageRes.liveHangUp; icon = Icons.call_end,
size = Styles.callPrimaryBtn,
background = _danger,
iconColor = _white;
LiveButton.pickUp({ LiveButton.pickUp({
super.key, super.key,
this.onTap, this.onTap,
}) : text = StrRes.pickUp, }) : text = StrRes.pickUp,
icon = ImageRes.livePicUp; icon = Icons.phone_outlined,
size = Styles.callPrimaryBtn,
background = _success,
iconColor = _white;
} }
+1
View File
@@ -28,6 +28,7 @@ dependencies:
openim_common: openim_common:
path: ../openim_common path: ../openim_common
flutter_openim_sdk: 3.8.3+hotfix.12 flutter_openim_sdk: 3.8.3+hotfix.12
vibration: ^1.7.6
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -9,6 +9,11 @@ void main() {
expect(LiveKitCallConfig.enabled, LiveKitCallConfig.shipped); expect(LiveKitCallConfig.enabled, LiveKitCallConfig.shipped);
}); });
test('默认未接超时是 60 秒', () {
expect(LiveKitCallConfig.inviteTimeout, const Duration(seconds: 60));
expect(CallSessionGuard().inviteTimeout, LiveKitCallConfig.inviteTimeout);
});
test('占线时拒绝第二路呼出/呼入', () { test('占线时拒绝第二路呼出/呼入', () {
final guard = CallSessionGuard(); final guard = CallSessionGuard();
expect(guard.startOutgoing('room-a'), isNull); expect(guard.startOutgoing('room-a'), isNull);
@@ -0,0 +1,50 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:openim/company/brand/app_tokens.dart';
import 'package:openim/company/brand/apply_official_styles.dart';
import 'package:openim/company/rtc/call_session_guard.dart';
import 'package:openim/company/rtc/livekit_call_config.dart';
import 'package:openim_common/openim_common.dart';
void main() {
setUpAll(applyOfficialStylesFromTokens);
test('未接超时只有 60s 一个来源,不再出现 30s', () {
expect(AppDuration.callInviteTimeout, const Duration(seconds: 60));
expect(LiveKitCallConfig.inviteTimeout, AppDuration.callInviteTimeout);
expect(Styles.callInviteTimeout, AppDuration.callInviteTimeout);
expect(CallSessionGuard().inviteTimeout, AppDuration.callInviteTimeout);
});
test('通话页底色走 callBg #1D2129,不复用大图遮罩黑', () {
expect(AppColors.callBg, const Color(0xFF1D2129));
expect(AppColors.callBg, AppColors.textPrimary);
expect(Styles.c_callBg, AppColors.callBg);
expect(Styles.c_callBg, isNot(AppColors.maskBg));
expect(Styles.c_000000, AppColors.maskBg);
});
test('计时器 32px 纯白 tabular figures,状态字 14px 白 60%,姓名 18px', () {
expect(AppFont.callTimer, 32);
expect(Styles.ts_callTimer.fontSize, 32);
expect(Styles.ts_callTimer.color, AppColors.pageBg);
expect(Styles.ts_callTimer.fontFeatures, contains(FontFeature.tabularFigures()));
expect(Styles.ts_callStatus.fontSize, AppFont.sub);
expect(Styles.ts_callStatus.color, AppColors.pageBg.withOpacity(.6));
expect(Styles.ts_callName.fontSize, AppFont.title);
});
test('通话页尺寸与进出场动效来自 Token', () {
expect(Styles.callAvatar, 96);
expect(Styles.callPrimaryBtn, 64);
expect(Styles.callSecondaryBtn, 48);
expect(Styles.callIncomingGap, 96);
expect(Styles.callNameTop, 96);
expect(Styles.callMinimizeHit, 48);
expect(Styles.callPageMotion, const Duration(milliseconds: 250));
expect(Styles.callEndHold, const Duration(milliseconds: 1500));
expect(Styles.callVibrate, const Duration(seconds: 1));
expect(AppRadius.avatar(96), closeTo(17.28, 0.001));
});
}