- 新增 VideoRecorder 基于 flutter_webrtc MediaRecorder 录制远程视频为 MP4 - 新增 ControlMessageDecoder 解析被控端上报的 REPORT_* protobuf 消息 - 连接控制器支持分辨率/帧率切换、录制状态管理与远程上报处理 - 远程控制页新增更多菜单(静音/分辨率/帧率/录制),并统一退出时断开连接 - WebRTC 编排器在 DataChannel 打开及媒体就绪后主动请求默认分辨率,解决黑屏问题 - 新增 DisplayModeUtil 在 Android 上启用最高刷新率 - 登出时失效设备相关 Provider,避免切换账号后读到旧缓存
189 lines
5.6 KiB
Dart
189 lines
5.6 KiB
Dart
import 'dart:async';
|
||
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
|
||
import '../../../core/network/api_exception.dart';
|
||
import '../../../core/network/error_message.dart';
|
||
import '../../../core/storage/token_storage.dart';
|
||
import '../../device/data/device_providers.dart';
|
||
import '../data/auth_providers.dart';
|
||
import '../domain/auth_models.dart';
|
||
import '../domain/auth_state.dart';
|
||
|
||
/// 认证控制器。
|
||
///
|
||
/// 承载登录 / 注册 / 发送验证码 / 倒计时等全部业务逻辑,UI 仅触发动作并消费状态。
|
||
/// 使用 keepAlive,避免登录流程中页面切换导致状态丢失。
|
||
class AuthController extends Notifier<AuthState> {
|
||
AuthController();
|
||
|
||
Timer? _countdownTimer;
|
||
|
||
@override
|
||
AuthState build() {
|
||
ref.keepAlive();
|
||
ref.onDispose(() => _countdownTimer?.cancel());
|
||
return const AuthState();
|
||
}
|
||
|
||
String get _deviceId => TokenStorage.deviceId;
|
||
|
||
/// 密码登录。
|
||
Future<bool> loginByPassword({
|
||
required String phone,
|
||
required String password,
|
||
}) async {
|
||
return _run(() => ref.read(authRepositoryProvider).loginByPassword(
|
||
phone: phone,
|
||
password: password,
|
||
deviceId: _deviceId,
|
||
));
|
||
}
|
||
|
||
/// 短信登录。
|
||
Future<bool> loginBySms({
|
||
required String phone,
|
||
required String code,
|
||
}) async {
|
||
return _run(() => ref.read(authRepositoryProvider).loginBySms(
|
||
phone: phone,
|
||
code: code,
|
||
deviceId: _deviceId,
|
||
));
|
||
}
|
||
|
||
/// 注册。
|
||
Future<bool> register({
|
||
required String phone,
|
||
required String code,
|
||
required String password,
|
||
}) async {
|
||
return _run(() => ref.read(authRepositoryProvider).register(
|
||
phone: phone,
|
||
code: code,
|
||
password: password,
|
||
deviceId: _deviceId,
|
||
));
|
||
}
|
||
|
||
/// 发送短信验证码并启动 60s 倒计时。
|
||
///
|
||
/// 发送前先做本地校验:手机号为空或格式不正确时直接提示,不发起请求、
|
||
/// 不启动倒计时,避免出现「空号也能点但弹报错」的体验问题。
|
||
Future<void> sendSmsCode({
|
||
required String phone,
|
||
required SmsScene scene,
|
||
}) async {
|
||
if (state.isSendingCode || state.countdownSeconds > 0) return;
|
||
final trimmed = phone.trim();
|
||
final phoneError = _validatePhone(trimmed);
|
||
if (phoneError != null) {
|
||
state = state.copyWith(alert: phoneError);
|
||
return;
|
||
}
|
||
state = state.copyWith(isSendingCode: true, clearAlert: true);
|
||
try {
|
||
await ref.read(authRepositoryProvider).sendSmsCode(
|
||
phone: trimmed,
|
||
scene: scene,
|
||
);
|
||
_startCountdown();
|
||
} catch (e) {
|
||
state = state.copyWith(
|
||
isSendingCode: false,
|
||
alert: userMessageOf(e),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 校验手机号:空或不符合 11 位中国大陆手机号规则时返回提示文案。
|
||
String? _validatePhone(String phone) {
|
||
if (phone.isEmpty) return '请输入手机号';
|
||
// 1 开头的 11 位手机号。
|
||
if (!RegExp(r'^1\d{10}$').hasMatch(phone)) return '手机号格式不正确';
|
||
return null;
|
||
}
|
||
|
||
/// 重置密码(忘记密码流程)。
|
||
Future<bool> resetPassword({
|
||
required String phone,
|
||
required String code,
|
||
required String password,
|
||
}) async {
|
||
state = state.copyWith(isLoading: true, clearAlert: true);
|
||
try {
|
||
await ref.read(authRepositoryProvider).resetPassword(
|
||
phone: phone,
|
||
code: code,
|
||
password: password,
|
||
);
|
||
state = state.copyWith(isLoading: false);
|
||
return true;
|
||
} catch (e) {
|
||
state = state.copyWith(isLoading: false, alert: userMessageOf(e));
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 消费一次性提示。
|
||
void consumeAlert() {
|
||
if (state.alert != null) {
|
||
state = state.copyWith(clearAlert: true);
|
||
}
|
||
}
|
||
|
||
/// 登出。
|
||
///
|
||
/// 除清除令牌与登录态外,还需失效当前账号的已绑定设备列表及由其推导的
|
||
/// 选中设备 SN 等 Provider,否则切换账号登录后仍会读到上一账号缓存的设备/
|
||
/// SN 数据(`myDevicesProvider` / `selectedDeviceSnProvider` 等持有旧值)。
|
||
void logout() {
|
||
_countdownTimer?.cancel();
|
||
TokenStorage.clear();
|
||
state = const AuthState();
|
||
// 失效当前账号相关的设备与选中 SN 缓存,避免下一账号登录后沿用旧 SN。
|
||
ref.invalidate(myDevicesProvider);
|
||
}
|
||
|
||
Future<bool> _run(Future<LoginResult> Function() action) async {
|
||
state = state.copyWith(isLoading: true, clearAlert: true);
|
||
try {
|
||
await action();
|
||
state = state.copyWith(isLoading: false);
|
||
return true;
|
||
} catch (e) {
|
||
state = state.copyWith(isLoading: false, alert: userMessageOf(e));
|
||
// 鉴权失效:本地令牌可能已作废,主动登出清理。
|
||
if (_isUnauthorized(e)) {
|
||
logout();
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 判断异常是否为鉴权失效(401)。
|
||
bool _isUnauthorized(Object e) {
|
||
if (e is ApiException) return e.code == '401';
|
||
return false;
|
||
}
|
||
|
||
void _startCountdown() {
|
||
state = state.copyWith(isSendingCode: false, countdownSeconds: 60);
|
||
_countdownTimer?.cancel();
|
||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||
final left = state.countdownSeconds - 1;
|
||
if (left <= 0) {
|
||
timer.cancel();
|
||
state = state.copyWith(countdownSeconds: 0);
|
||
} else {
|
||
state = state.copyWith(countdownSeconds: left);
|
||
}
|
||
});
|
||
}
|
||
|
||
}
|
||
|
||
/// 认证控制器 Provider(会话级 keepAlive 在 build 内通过 ref.keepAlive 实现)。
|
||
final authControllerProvider =
|
||
NotifierProvider<AuthController, AuthState>(AuthController.new);
|