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:
co-authored by
Cursor
multica-agent
parent
714794f7fe
commit
9bde926c81
@@ -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,30 +144,35 @@ 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(
|
||||||
callType: callType,
|
key: _hostKey,
|
||||||
initState: initState,
|
child: SingleRoomView(
|
||||||
callEventSubject: callEventSubject,
|
callType: callType,
|
||||||
roomID: roomID,
|
initState: initState,
|
||||||
userID: initState == CallState.call ? inviteeUserIDList.first : inviterUserID,
|
callEventSubject: callEventSubject,
|
||||||
onDial: onDialSingle,
|
roomID: roomID,
|
||||||
onTapCancel: onTapCancel,
|
userID: initState == CallState.call ? inviteeUserIDList.first : inviterUserID,
|
||||||
onTapHangup: onTapHangup,
|
onDial: onDialSingle,
|
||||||
onTapReject: onTapReject,
|
onTapCancel: onTapCancel,
|
||||||
onTapPickup: onTapPickup,
|
onTapHangup: onTapHangup,
|
||||||
onSyncUserInfo: onSyncUserInfo,
|
onTapReject: onTapReject,
|
||||||
autoPickup: autoPickup,
|
onTapPickup: onTapPickup,
|
||||||
onBindRoomID: (roomID) => currentRoomID = roomID,
|
onSyncUserInfo: onSyncUserInfo,
|
||||||
onWaitingAccept: onWaitingAccept,
|
autoPickup: autoPickup,
|
||||||
onBusyLine: onBusyLine,
|
onBindRoomID: (roomID) => currentRoomID = roomID,
|
||||||
onStartCalling: onStartCalling,
|
onWaitingAccept: onWaitingAccept,
|
||||||
onError: onError,
|
onBusyLine: onBusyLine,
|
||||||
onRoomDisconnected: onRoomDisconnected,
|
onStartCalling: onStartCalling,
|
||||||
onClose: () {
|
onError: onError,
|
||||||
onClose?.call();
|
onRoomDisconnected: onRoomDisconnected,
|
||||||
close();
|
onInviteTimeout: onInviteTimeout,
|
||||||
},
|
onClose: () {
|
||||||
|
onClose?.call();
|
||||||
|
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> {
|
|||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
bool get _isEnding =>
|
||||||
Widget build(BuildContext context) => SafeArea(
|
_callState == CallState.hangup ||
|
||||||
child: Stack(
|
_callState == CallState.beHangup ||
|
||||||
children: [
|
_callState == CallState.cancel ||
|
||||||
Positioned(
|
_callState == CallState.beCanceled ||
|
||||||
left: 16.w,
|
_callState == CallState.timeout ||
|
||||||
top: 7.h,
|
_callState == CallState.beRejected ||
|
||||||
child: ImageRes.liveClose.toImage
|
_callState == CallState.reject ||
|
||||||
..width = 30.w
|
_callState == CallState.networkError;
|
||||||
..height = 30.h
|
|
||||||
..onTap = widget.onMinimize,
|
|
||||||
),
|
|
||||||
if (null != _participant)
|
|
||||||
Positioned(
|
|
||||||
right: 16.w,
|
|
||||||
top: 7.h,
|
|
||||||
child: Visibility(
|
|
||||||
visible: isVideo,
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
(_participant!.isCameraEnabled() ? ImageRes.liveCameraOff : ImageRes.liveCameraOn).toImage
|
|
||||||
..width = 30.w
|
|
||||||
..height = 30.h
|
|
||||||
..onTap = (_participant!.isCameraEnabled() ? _disableVideo : _enableVideo),
|
|
||||||
16.horizontalSpace,
|
|
||||||
ImageRes.liveSwitchCamera.toImage
|
|
||||||
..width = 30.w
|
|
||||||
..height = 30.h
|
|
||||||
..onTap = _toggleCamera,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (null != widget.userInfo)
|
|
||||||
Positioned(
|
|
||||||
top: 166.h,
|
|
||||||
width: 1.sw,
|
|
||||||
child: _userInfoView,
|
|
||||||
),
|
|
||||||
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(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
List<Widget> get _buttonGroup {
|
@override
|
||||||
if (_callState == CallState.call || _callState == CallState.connecting && widget.initState == CallState.call) {
|
Widget build(BuildContext context) {
|
||||||
return [
|
final topInset = MediaQuery.of(context).padding.top;
|
||||||
LiveButton.microphone(on: _enabledMicrophone, onTap: _toggleAudio),
|
return Stack(
|
||||||
LiveButton.cancel(onTap: widget.onCancel),
|
children: [
|
||||||
LiveButton.speaker(on: _enabledSpeaker, onTap: _toggleSpeaker),
|
Positioned(
|
||||||
];
|
left: 16,
|
||||||
} else if (_callState == CallState.beCalled || _callState == CallState.connecting && widget.initState == CallState.beCalled) {
|
top: topInset + 8,
|
||||||
return [
|
child: SizedBox(
|
||||||
LiveButton.reject(onTap: widget.onReject),
|
width: Styles.callMinimizeHit,
|
||||||
LiveButton.pickUp(onTap: widget.onPickUp),
|
height: Styles.callMinimizeHit,
|
||||||
];
|
child: IconButton(
|
||||||
} else if (_callState == CallState.calling) {
|
padding: EdgeInsets.zero,
|
||||||
return [
|
icon: Icon(Icons.keyboard_arrow_down, color: Styles.c_FFFFFF, size: Styles.callIconSize),
|
||||||
LiveButton.microphone(on: _enabledMicrophone, onTap: _toggleAudio),
|
onPressed: widget.onMinimize,
|
||||||
LiveButton.hungUp(onTap: () => widget.onHangUp?.call(true)),
|
),
|
||||||
LiveButton.speaker(on: _enabledSpeaker, onTap: _toggleSpeaker),
|
),
|
||||||
];
|
),
|
||||||
|
if (null != _participant && isVideo)
|
||||||
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
top: topInset + 8,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_participant!.isCameraEnabled() ? Icons.videocam_off_outlined : Icons.videocam_outlined,
|
||||||
|
color: Styles.c_FFFFFF,
|
||||||
|
size: Styles.callIconSize,
|
||||||
|
),
|
||||||
|
onPressed: _participant!.isCameraEnabled() ? _disableVideo : _enableVideo,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.cameraswitch_outlined, color: Styles.c_FFFFFF, size: Styles.callIconSize),
|
||||||
|
onPressed: _toggleCamera,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(child: _infoColumn),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 32 + MediaQuery.of(context).padding.bottom,
|
||||||
|
child: _buttonArea,
|
||||||
|
),
|
||||||
|
if (_callState == CallState.connecting) const LiveLoadingView(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
return [];
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user