feat(connection): 增加远程视频录制、分辨率/帧率切换及高刷适配
- 新增 VideoRecorder 基于 flutter_webrtc MediaRecorder 录制远程视频为 MP4 - 新增 ControlMessageDecoder 解析被控端上报的 REPORT_* protobuf 消息 - 连接控制器支持分辨率/帧率切换、录制状态管理与远程上报处理 - 远程控制页新增更多菜单(静音/分辨率/帧率/录制),并统一退出时断开连接 - WebRTC 编排器在 DataChannel 打开及媒体就绪后主动请求默认分辨率,解决黑屏问题 - 新增 DisplayModeUtil 在 Android 上启用最高刷新率 - 登出时失效设备相关 Provider,避免切换账号后读到旧缓存
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -46,3 +46,4 @@ app.*.map.json
|
||||
# Widget Preview related
|
||||
.widget_preview/
|
||||
android/.kotlin/
|
||||
.codebuddy/
|
||||
|
||||
35
lib/core/utils/display_mode_util.dart
Normal file
35
lib/core/utils/display_mode_util.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
||||
|
||||
/// 高刷新率(高刷)适配工具。
|
||||
///
|
||||
/// 部分 Android 设备出于省电考虑,默认不会启用屏幕的最高刷新率
|
||||
/// (如 120Hz / 144Hz),导致 Flutter 渲染帧率被系统限制在 60Hz 以下。
|
||||
/// 通过 [FlutterDisplayMode] 主动将显示模式切换到设备支持的最高刷新率,
|
||||
/// 从而提升动画与滚动流畅度。
|
||||
///
|
||||
/// - Android:通过 `flutter_displaymode` 请求最高刷新率模式。
|
||||
/// - iOS:ProMotion 设备由系统 + Flutter 引擎自动匹配(无需额外配置,
|
||||
/// `Info.plist` 中已设置 `CADisableMinimumFrameDurationOnPhone`)。
|
||||
class DisplayModeUtil {
|
||||
const DisplayModeUtil._();
|
||||
|
||||
/// 在应用启动时调用,将 Android 屏幕切换到最高刷新率。
|
||||
///
|
||||
/// 保持当前分辨率不变,仅将刷新率提升到最高可用档位(如 60 → 120Hz)。
|
||||
/// 非关键流程,失败仅打印日志,不阻塞启动。仅在 Android 平台生效。
|
||||
static Future<void> enableBestDisplayMode() async {
|
||||
if (!Platform.isAndroid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 官方便捷方法:在当前分辨率下选择最高刷新率的显示模式。
|
||||
await FlutterDisplayMode.setHighRefreshRate();
|
||||
} catch (e) {
|
||||
// 高刷适配为可选优化,失败不影响主流程。
|
||||
// ignore: avoid_print
|
||||
print('enableBestDisplayMode failed: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -132,10 +133,16 @@ class AuthController extends Notifier<AuthState> {
|
||||
}
|
||||
|
||||
/// 登出。
|
||||
///
|
||||
/// 除清除令牌与登录态外,还需失效当前账号的已绑定设备列表及由其推导的
|
||||
/// 选中设备 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 {
|
||||
|
||||
@@ -137,3 +137,102 @@ class ControlMessageEncoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 被控端上报的远程视频信息(REPORT_RESOLUTION / REPORT_STREAM_MODE)。
|
||||
class ReportInfo {
|
||||
final int action;
|
||||
final int width;
|
||||
final int height;
|
||||
final int fps;
|
||||
final int streamMode;
|
||||
final List<int> supportedFps;
|
||||
|
||||
const ReportInfo({
|
||||
this.action = 0,
|
||||
this.width = 0,
|
||||
this.height = 0,
|
||||
this.fps = 0,
|
||||
this.streamMode = 0,
|
||||
this.supportedFps = const [],
|
||||
});
|
||||
}
|
||||
|
||||
/// protobuf wire format 解码器。
|
||||
///
|
||||
/// 与控制端 `com.ttstd.control.ControlMessage`(protobuf 3)字段号完全对齐,
|
||||
/// 仅解析被控端会回传的字段(action/width/height/fps/stream_mode/supported_fps)。
|
||||
class ControlMessageDecoder {
|
||||
/// 解析被控端上报的 REPORT_* 消息;无法解析返回 null。
|
||||
static ReportInfo? decode(Uint8List data) {
|
||||
int action = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
int fps = 0;
|
||||
int streamMode = 0;
|
||||
final supportedFps = <int>[];
|
||||
|
||||
final reader = ByteData.sublistView(data);
|
||||
var pos = 0;
|
||||
try {
|
||||
while (pos < data.length) {
|
||||
final tag = _readVarint(reader, pos);
|
||||
pos = tag.$2;
|
||||
final field = tag.$1 >> 3;
|
||||
final wireType = tag.$1 & 0x07;
|
||||
switch (wireType) {
|
||||
case 0: // varint
|
||||
final v = _readVarint(reader, pos);
|
||||
pos = v.$2;
|
||||
final val = v.$1;
|
||||
switch (field) {
|
||||
case 1:
|
||||
action = val;
|
||||
break;
|
||||
case 12:
|
||||
width = val;
|
||||
break;
|
||||
case 13:
|
||||
height = val;
|
||||
break;
|
||||
case 14:
|
||||
fps = val;
|
||||
break;
|
||||
case 15:
|
||||
streamMode = val;
|
||||
break;
|
||||
case 16:
|
||||
supportedFps.add(val);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// 未知 wire type:跳过(解析到 known fields 前不应出现)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return ReportInfo(
|
||||
action: action,
|
||||
width: width,
|
||||
height: height,
|
||||
fps: fps,
|
||||
streamMode: streamMode,
|
||||
supportedFps: supportedFps,
|
||||
);
|
||||
}
|
||||
|
||||
/// 读取一个 varint,返回 (值, 新的位置)。越界抛异常。
|
||||
static (int, int) _readVarint(ByteData data, int pos) {
|
||||
var result = 0;
|
||||
var shift = 0;
|
||||
while (true) {
|
||||
final byte = data.getUint8(pos++);
|
||||
result |= (byte & 0x7F) << shift;
|
||||
if ((byte & 0x80) == 0) break;
|
||||
shift += 7;
|
||||
}
|
||||
return (result, pos);
|
||||
}
|
||||
}
|
||||
|
||||
99
lib/features/connection/data/video_recorder.dart
Normal file
99
lib/features/connection/data/video_recorder.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// 远程视频录制器:基于 flutter_webrtc 的 [MediaRecorder],
|
||||
/// 把远端视频轨道(MediaStreamTrack)直接封装为 MP4 保存到本地。
|
||||
///
|
||||
/// 跨平台(Android / iOS)由 flutter_webrtc 内部完成 H.264 编码与封装,
|
||||
/// 无需自行触碰原生 VideoSink。
|
||||
///
|
||||
/// 录制文件保存位置:
|
||||
/// - Android:/Android/data/<包名>/files/WebRTCRecordings/(app 专属外部存储,无需存储权限)
|
||||
/// - iOS:<App>/Documents/WebRTCRecordings/
|
||||
class VideoRecorder {
|
||||
MediaRecorder? _recorder;
|
||||
bool _recording = false;
|
||||
String? _currentPath;
|
||||
|
||||
/// 是否正在录制。
|
||||
bool get isRecording => _recording;
|
||||
|
||||
/// 是否为空(从未真正开始)。
|
||||
String? get currentPath => _currentPath;
|
||||
|
||||
/// 开始录制指定媒体流中的视频轨道。
|
||||
///
|
||||
/// 返回 true 表示成功开始;空流 / 无视频轨道会抛异常。
|
||||
Future<bool> start(MediaStream stream) async {
|
||||
if (_recording) return false;
|
||||
final videoTracks = stream.getVideoTracks();
|
||||
if (videoTracks.isEmpty) {
|
||||
throw Exception('当前没有可用的远端视频轨道,无法录制');
|
||||
}
|
||||
final path = await _buildOutputPath();
|
||||
// ignore: avoid_print
|
||||
print('[VideoRecorder] 录制开始,保存地址:$path');
|
||||
_recorder = MediaRecorder(albumName: 'WebRTCRecordings');
|
||||
await _recorder!.start(path, videoTrack: videoTracks.first);
|
||||
_currentPath = path;
|
||||
_recording = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 停止录制,返回最终保存的文件路径;未开始录制返回 null。
|
||||
Future<String?> stop() async {
|
||||
if (!_recording || _recorder == null) return null;
|
||||
try {
|
||||
await _recorder!.stop();
|
||||
} catch (e) {
|
||||
// 停止失败时仍清理状态,避免界面卡在"录制中"
|
||||
// ignore: avoid_print
|
||||
print('[VideoRecorder] stop error: $e');
|
||||
} finally {
|
||||
_recorder = null;
|
||||
_recording = false;
|
||||
}
|
||||
final p = _currentPath;
|
||||
_currentPath = null;
|
||||
// ignore: avoid_print
|
||||
print('[VideoRecorder] 录制结束,已保存至:$p');
|
||||
return p;
|
||||
}
|
||||
|
||||
/// 释放资源(断开连接 / 页面销毁时调用)。
|
||||
Future<void> dispose() async {
|
||||
if (_recording && _recorder != null) {
|
||||
try {
|
||||
await _recorder!.stop();
|
||||
} catch (_) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
_recorder = null;
|
||||
_recording = false;
|
||||
_currentPath = null;
|
||||
}
|
||||
|
||||
static String _pad(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
static Future<Directory> _getRecordingDir() async {
|
||||
final base = Platform.isAndroid
|
||||
? (await getExternalStorageDirectory())!
|
||||
: await getApplicationDocumentsDirectory();
|
||||
final dir = Directory('${base.path}/WebRTCRecordings');
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
static Future<String> _buildOutputPath() async {
|
||||
final dir = await _getRecordingDir();
|
||||
final now = DateTime.now();
|
||||
final name = 'rec_${now.year}${_pad(now.month)}${_pad(now.day)}_'
|
||||
'${_pad(now.hour)}${_pad(now.minute)}${_pad(now.second)}.mp4';
|
||||
return '${dir.path}/$name';
|
||||
}
|
||||
}
|
||||
@@ -54,8 +54,8 @@ class WebRtcOrchestrator {
|
||||
/// 远程屏幕渲染器(供页面 RTCVideoView 使用)。
|
||||
final RTCVideoRenderer remoteRenderer = RTCVideoRenderer();
|
||||
|
||||
/// 接收到的被控端报告消息(如 REPORT_RESOLUTION)。
|
||||
void Function(Uint8List data)? onControlMessage;
|
||||
/// 被控端上报的 REPORT_* 消息(REPORT_RESOLUTION / REPORT_STREAM_MODE)。
|
||||
void Function(ReportInfo report)? onReport;
|
||||
|
||||
/// 连接状态变化(用于 UI 显示 "已连接")。
|
||||
void Function(bool connected)? onConnectionState;
|
||||
@@ -74,6 +74,7 @@ class WebRtcOrchestrator {
|
||||
|
||||
/// 发起连接:创建 PeerConnection + DataChannel + 发送 OFFER。
|
||||
Future<void> start() async {
|
||||
await remoteRenderer.initialize();
|
||||
await _initPeerConnection();
|
||||
|
||||
// 创建 control_channel(有序、可靠,保证指令不丢)
|
||||
@@ -83,11 +84,18 @@ class WebRtcOrchestrator {
|
||||
_controlChannel = await _pc!.createDataChannel(_dataChannelLabel, dataChannelInit);
|
||||
_controlChannel!.onMessage = (RTCDataChannelMessage message) {
|
||||
if (message.isBinary) {
|
||||
onControlMessage?.call(message.binary);
|
||||
final report = ControlMessageDecoder.decode(message.binary);
|
||||
if (report != null) {
|
||||
onReport?.call(report);
|
||||
}
|
||||
}
|
||||
};
|
||||
_controlChannel!.onDataChannelState = (RTCDataChannelState state) {
|
||||
debugPrint('[webrtc] control_channel state: $state');
|
||||
if (state == RTCDataChannelState.RTCDataChannelOpen) {
|
||||
// DataChannel 开启后发送一次默认分辨率指令,确保被控端开始推流(解决黑屏需操作才显示的 bug)
|
||||
sendSetResolution();
|
||||
}
|
||||
};
|
||||
|
||||
// 创建 Offer
|
||||
@@ -127,22 +135,16 @@ class WebRtcOrchestrator {
|
||||
debugPrint('[webrtc] 收到远程屏幕轨道');
|
||||
final stream = event.streams.isNotEmpty ? event.streams.first : null;
|
||||
if (stream != null) {
|
||||
// RTCVideoRenderer 必须先 initialize() 再设置 srcObject,否则抛
|
||||
// "Call initialize before setting the stream" 异常,导致画面不渲染。
|
||||
remoteRenderer.initialize().then((_) {
|
||||
remoteRenderer.srcObject = stream;
|
||||
}).catchError((e) {
|
||||
debugPrint('[webrtc] renderer initialize error: $e');
|
||||
});
|
||||
remoteRenderer.srcObject = stream;
|
||||
onConnectionState?.call(true);
|
||||
// 媒体就绪后唤醒被控端持续推流,解决静止画面黑屏/需操作才显示。
|
||||
_wakeUpStreaming();
|
||||
}
|
||||
};
|
||||
|
||||
_pc!.onConnectionState = (RTCPeerConnectionState state) {
|
||||
debugPrint('[webrtc] connection state: ${state.name}');
|
||||
if (state == RTCPeerConnectionState.RTCPeerConnectionStateConnected) {
|
||||
onConnectionState?.call(true);
|
||||
} else if (state == RTCPeerConnectionState.RTCPeerConnectionStateDisconnected ||
|
||||
if (state == RTCPeerConnectionState.RTCPeerConnectionStateDisconnected ||
|
||||
state == RTCPeerConnectionState.RTCPeerConnectionStateFailed ||
|
||||
state == RTCPeerConnectionState.RTCPeerConnectionStateClosed) {
|
||||
onConnectionState?.call(false);
|
||||
@@ -264,6 +266,31 @@ class WebRtcOrchestrator {
|
||||
sendCommand(bytes);
|
||||
}
|
||||
|
||||
/// 发送串流模式指令(mode:0=WebRTC 内置媒体流,1=自编码 H.264)。
|
||||
///
|
||||
/// 注意:被控端 `stream_mode` 语义是「编码方式」而非「按需/持续推流」。
|
||||
/// 本控制端未移植自编码解码链路(方案 §7.1 裁剪),因此**不要**发送
|
||||
/// `mode=1`,否则被控端会切到无人消费的 `video_channel` 自编码裸流导致黑屏。
|
||||
void sendSetStreamMode(int mode) {
|
||||
final bytes = ControlMessageEncoder(
|
||||
action: ActionType.setStreamMode,
|
||||
streamMode: mode,
|
||||
).encode();
|
||||
sendCommand(bytes);
|
||||
}
|
||||
|
||||
/// 媒体连接建立后唤醒被控端推帧:延迟发送一次轻量指令,确保被控端已就绪。
|
||||
///
|
||||
/// 静止画面持续推流由被控端自身保障(`ScreenCaptureService` 的 heartbeat
|
||||
/// 每秒刷新通知扰动 MediaProjection 强制产帧),控制端无需也不应发送
|
||||
/// `SET_STREAM_MODE`。这里仅请求一次默认分辨率(width=0=原生)触发关键帧刷新。
|
||||
void _wakeUpStreaming() {
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
// 请求一次默认分辨率(原生长边),触发被控端关键帧刷新远端画面。
|
||||
sendSetResolution();
|
||||
});
|
||||
}
|
||||
|
||||
/// 关闭连接。
|
||||
Future<void> dispose() async {
|
||||
try {
|
||||
|
||||
@@ -3,30 +3,84 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
|
||||
import '../data/connection_providers.dart';
|
||||
import '../data/protobuf_codec.dart';
|
||||
import '../data/video_recorder.dart';
|
||||
import '../data/webrtc_orchestrator.dart';
|
||||
import '../domain/connection_models.dart';
|
||||
|
||||
/// 分辨率预设(与被控端 SET_RESOLUTION 语义一致:width<=0 表示原始)。
|
||||
const List<Map<String, Object>> kResolutionOptions = [
|
||||
{'label': '原始', 'width': 0, 'height': 0, 'fps': 0},
|
||||
{'label': '1080P', 'width': 1920, 'height': 0, 'fps': 0},
|
||||
{'label': '720P', 'width': 1280, 'height': 0, 'fps': 0},
|
||||
{'label': '480P', 'width': 854, 'height': 0, 'fps': 0},
|
||||
];
|
||||
|
||||
/// 默认帧率档位(收到被控端上报的 supported_fps 后以上报列表为准)。
|
||||
const List<int> kDefaultFpsOptions = [15, 24, 30, 60];
|
||||
|
||||
/// 远程控制会话状态(供 UI 渲染)。
|
||||
class ConnectionSessionState {
|
||||
final ConnectionState state;
|
||||
final String? errorMessage;
|
||||
final bool hasVideo;
|
||||
|
||||
/// 是否正在录制远程视频。
|
||||
final bool recording;
|
||||
/// 录制状态提示(已保存路径等)。
|
||||
final String recordStatus;
|
||||
|
||||
/// 当前选中的分辨率预设下标。
|
||||
final int selectedResolution;
|
||||
/// 分辨率预设列表。
|
||||
final List<Map<String, Object>> resolutionOptions;
|
||||
/// 帧率档位(收到被控端上报后以上报列表为准)。
|
||||
final List<int> fpsOptions;
|
||||
/// 被控端当前采集帧率(0 表示尚未收到上报)。
|
||||
final int currentFps;
|
||||
/// 被控端最近上报的实际采集尺寸(切帧率时保持分辨率不变)。
|
||||
final int lastReportedWidth;
|
||||
final int lastReportedHeight;
|
||||
|
||||
const ConnectionSessionState({
|
||||
this.state = ConnectionState.idle,
|
||||
this.errorMessage,
|
||||
this.hasVideo = false,
|
||||
this.recording = false,
|
||||
this.recordStatus = '',
|
||||
this.selectedResolution = 1,
|
||||
this.resolutionOptions = kResolutionOptions,
|
||||
this.fpsOptions = kDefaultFpsOptions,
|
||||
this.currentFps = 0,
|
||||
this.lastReportedWidth = 0,
|
||||
this.lastReportedHeight = 0,
|
||||
});
|
||||
|
||||
ConnectionSessionState copyWith({
|
||||
ConnectionState? state,
|
||||
String? errorMessage,
|
||||
bool? hasVideo,
|
||||
bool? recording,
|
||||
String? recordStatus,
|
||||
int? selectedResolution,
|
||||
List<Map<String, Object>>? resolutionOptions,
|
||||
List<int>? fpsOptions,
|
||||
int? currentFps,
|
||||
int? lastReportedWidth,
|
||||
int? lastReportedHeight,
|
||||
}) {
|
||||
return ConnectionSessionState(
|
||||
state: state ?? this.state,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
hasVideo: hasVideo ?? this.hasVideo,
|
||||
recording: recording ?? this.recording,
|
||||
recordStatus: recordStatus ?? this.recordStatus,
|
||||
selectedResolution: selectedResolution ?? this.selectedResolution,
|
||||
resolutionOptions: resolutionOptions ?? this.resolutionOptions,
|
||||
fpsOptions: fpsOptions ?? this.fpsOptions,
|
||||
currentFps: currentFps ?? this.currentFps,
|
||||
lastReportedWidth: lastReportedWidth ?? this.lastReportedWidth,
|
||||
lastReportedHeight: lastReportedHeight ?? this.lastReportedHeight,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +88,7 @@ class ConnectionSessionState {
|
||||
/// 远程控制控制器:管理连接状态机(idle → connecting → connected → ended)。
|
||||
class ConnectionController extends Notifier<ConnectionSessionState> {
|
||||
WebRtcOrchestrator? _orchestrator;
|
||||
final VideoRecorder _videoRecorder = VideoRecorder();
|
||||
|
||||
@override
|
||||
ConnectionSessionState build() {
|
||||
@@ -71,6 +126,9 @@ class ConnectionController extends Notifier<ConnectionSessionState> {
|
||||
_orchestrator!.onConnectionState = (connected) {
|
||||
_onRtcState(connected);
|
||||
};
|
||||
_orchestrator!.onReport = (report) {
|
||||
_onReport(report);
|
||||
};
|
||||
|
||||
signaling.onMessage = (message) {
|
||||
// 先透传给 WebRTC 编排器处理(ANSWER/ICE 等),确保握手能继续
|
||||
@@ -104,12 +162,33 @@ class ConnectionController extends Notifier<ConnectionSessionState> {
|
||||
|
||||
void _onRtcState(bool connected) {
|
||||
if (connected) {
|
||||
state = const ConnectionSessionState(
|
||||
state = state.copyWith(
|
||||
state: ConnectionState.connected,
|
||||
hasVideo: true,
|
||||
);
|
||||
} else if (state.state == ConnectionState.connected) {
|
||||
state = const ConnectionSessionState(state: ConnectionState.ended);
|
||||
// 连接成功后按默认分辨率(1080P)下发一次,确保默认生效。
|
||||
selectResolution(state.selectedResolution);
|
||||
} else {
|
||||
state = state.copyWith(
|
||||
state: ConnectionState.ended,
|
||||
hasVideo: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理被控端经 DataChannel 上报的 REPORT_RESOLUTION / REPORT_STREAM_MODE。
|
||||
void _onReport(ReportInfo report) {
|
||||
if (report.width > 0 && report.height > 0) {
|
||||
state = state.copyWith(
|
||||
lastReportedWidth: report.width,
|
||||
lastReportedHeight: report.height,
|
||||
);
|
||||
}
|
||||
if (report.fps > 0) {
|
||||
state = state.copyWith(currentFps: report.fps);
|
||||
}
|
||||
if (report.supportedFps.isNotEmpty) {
|
||||
state = state.copyWith(fpsOptions: report.supportedFps);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,8 +222,68 @@ class ConnectionController extends Notifier<ConnectionSessionState> {
|
||||
_orchestrator?.sendSetResolution(width: width, height: height, fps: fps);
|
||||
}
|
||||
|
||||
/// 切换分辨率:写入状态并下发指令。
|
||||
void selectResolution(int index) {
|
||||
final options = state.resolutionOptions;
|
||||
if (index < 0 || index >= options.length) return;
|
||||
state = state.copyWith(selectedResolution: index);
|
||||
final o = options[index];
|
||||
_orchestrator?.sendSetResolution(
|
||||
width: o['width'] as int,
|
||||
height: o['height'] as int,
|
||||
fps: o['fps'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
/// 切换帧率:仅切帧率,分辨率保持不变。
|
||||
void selectFps(int fps) {
|
||||
if (fps <= 0 || fps == state.currentFps) return;
|
||||
_orchestrator?.sendSetResolution(
|
||||
width: state.lastReportedWidth,
|
||||
height: state.lastReportedHeight,
|
||||
fps: fps,
|
||||
);
|
||||
}
|
||||
|
||||
/// 切换远程视频录制:开始 / 停止。
|
||||
Future<void> toggleRecord() async {
|
||||
if (state.recording) {
|
||||
await _stopRecording();
|
||||
return;
|
||||
}
|
||||
await _startRecording();
|
||||
}
|
||||
|
||||
Future<void> _startRecording() async {
|
||||
if (state.recording) return;
|
||||
final renderer = _orchestrator?.remoteRenderer;
|
||||
final stream = renderer?.srcObject;
|
||||
if (stream == null) {
|
||||
state = state.copyWith(recordStatus: '尚未接收到视频画面,无法录制');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final ok = await _videoRecorder.start(stream);
|
||||
if (ok) {
|
||||
state = state.copyWith(recording: true, recordStatus: '录制中...');
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(recordStatus: '');
|
||||
debugPrint('[record] 开始录制失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopRecording() async {
|
||||
final path = await _videoRecorder.stop();
|
||||
state = state.copyWith(
|
||||
recording: false,
|
||||
recordStatus: path != null ? '已保存:$path' : '录制已停止',
|
||||
);
|
||||
}
|
||||
|
||||
/// 关闭并清理。
|
||||
Future<void> disconnect() async {
|
||||
await _videoRecorder.dispose();
|
||||
await _orchestrator?.dispose();
|
||||
_orchestrator = null;
|
||||
ref.read(signalingClientProvider).dispose();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cupertino_ui/cupertino_ui.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -24,6 +26,10 @@ class RemoteControlPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _RemoteControlPageState extends ConsumerState<RemoteControlPage> {
|
||||
/// 录制状态条是否可见。
|
||||
bool _showRecordStatus = false;
|
||||
Timer? _recordStatusTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -35,32 +41,85 @@ class _RemoteControlPageState extends ConsumerState<RemoteControlPage> {
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_recordStatusTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final session = ref.watch(connectionControllerProvider);
|
||||
final renderer = ref.read(connectionControllerProvider.notifier).remoteRenderer;
|
||||
final connected = session.state == connection.ConnectionState.connected;
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: const Text('远程控制'),
|
||||
trailing: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () {
|
||||
ref.read(connectionControllerProvider.notifier).disconnect();
|
||||
Navigator.of(context).maybePop();
|
||||
},
|
||||
child: const Text('退出'),
|
||||
// 监听录制状态文字:录制中一直显示;非录制时更新后显示,3 秒后自动隐藏。
|
||||
ref.listen(
|
||||
connectionControllerProvider.select((s) => s.recordStatus),
|
||||
(previous, next) {
|
||||
if (next.isEmpty) {
|
||||
_recordStatusTimer?.cancel();
|
||||
if (_showRecordStatus) {
|
||||
setState(() => _showRecordStatus = false);
|
||||
}
|
||||
} else {
|
||||
_recordStatusTimer?.cancel();
|
||||
setState(() => _showRecordStatus = true);
|
||||
// 录制中保持常显;非录制(如保存提示)3 秒后自动隐藏。
|
||||
final recording = ref.read(connectionControllerProvider).recording;
|
||||
if (!recording) {
|
||||
_recordStatusTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (mounted) setState(() => _showRecordStatus = false);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 用 PopScope 统一处理所有退出入口(自带返回箭头 / 系统返回键):
|
||||
// 无论从哪个入口离开页面,都在 pop 后断开远程连接。
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (didPop) {
|
||||
ref.read(connectionControllerProvider.notifier).disconnect();
|
||||
}
|
||||
},
|
||||
child: CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
middle: const Text('远程控制'),
|
||||
trailing: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
onPressed: () => _showMoreMenu(session),
|
||||
child: const Icon(CupertinoIcons.ellipsis, size: 22),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildVideo(renderer, connected, session),
|
||||
),
|
||||
_buildControlsBar(session),
|
||||
],
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildVideo(renderer, connected, session),
|
||||
),
|
||||
_buildControlsBar(session),
|
||||
],
|
||||
),
|
||||
// 悬浮录制状态提示:叠加在视频区顶部,自适应宽度居中,不占用布局空间。
|
||||
if (_showRecordStatus && session.recordStatus.isNotEmpty)
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: _buildRecordStatusBar(session),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -189,6 +248,24 @@ class _RemoteControlPageState extends ConsumerState<RemoteControlPage> {
|
||||
_lastMoveSample = null;
|
||||
}
|
||||
|
||||
/// 悬浮录制状态提示条:自适应宽度居中,录制中红色、非录制灰色。
|
||||
Widget _buildRecordStatusBar(ConnectionSessionState session) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: session.recording
|
||||
? CupertinoColors.systemRed.withValues(alpha: 0.9)
|
||||
: CupertinoColors.systemGrey.withValues(alpha: 0.85),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(
|
||||
session.recordStatus,
|
||||
style: const TextStyle(fontSize: 12, color: CupertinoColors.white),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControlsBar(ConnectionSessionState session) {
|
||||
final connected = session.state == connection.ConnectionState.connected;
|
||||
return Container(
|
||||
@@ -197,37 +274,199 @@ class _RemoteControlPageState extends ConsumerState<RemoteControlPage> {
|
||||
color: CupertinoColors.systemGroupedBackground,
|
||||
border: Border(top: BorderSide(color: CupertinoColors.separator, width: 0.5)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_CtrlButton(
|
||||
icon: CupertinoIcons.speaker_slash_fill,
|
||||
label: '静音',
|
||||
enabled: connected,
|
||||
onTap: () {},
|
||||
),
|
||||
_CtrlButton(
|
||||
icon: CupertinoIcons.home,
|
||||
label: '主页',
|
||||
enabled: connected,
|
||||
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(3), // KEYCODE_HOME
|
||||
),
|
||||
_CtrlButton(
|
||||
icon: CupertinoIcons.arrow_left,
|
||||
label: '返回',
|
||||
enabled: connected,
|
||||
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(4), // KEYCODE_BACK
|
||||
),
|
||||
_CtrlButton(
|
||||
icon: CupertinoIcons.rectangle_grid_2x2,
|
||||
label: '最近',
|
||||
enabled: connected,
|
||||
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(187), // KEYCODE_APP_SWITCH
|
||||
// 单行:返回 / 主页 / 最近
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_CtrlButton(
|
||||
icon: CupertinoIcons.arrow_left,
|
||||
label: '返回',
|
||||
enabled: connected,
|
||||
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(4), // KEYCODE_BACK
|
||||
),
|
||||
_CtrlButton(
|
||||
icon: CupertinoIcons.home,
|
||||
label: '主页',
|
||||
enabled: connected,
|
||||
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(3), // KEYCODE_HOME
|
||||
),
|
||||
_CtrlButton(
|
||||
icon: CupertinoIcons.rectangle_grid_2x2,
|
||||
label: '最近',
|
||||
enabled: connected,
|
||||
onTap: () => ref.read(connectionControllerProvider.notifier).sendKey(187), // KEYCODE_APP_SWITCH
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 弹出「更多」菜单(iOS 风格 ActionSheet):静音 / 分辨率 / 帧率 / 录制。
|
||||
void _showMoreMenu(ConnectionSessionState session) {
|
||||
final controller = ref.read(connectionControllerProvider.notifier);
|
||||
final recording = session.recording;
|
||||
showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoActionSheet(
|
||||
title: const Text('更多操作'),
|
||||
actions: [
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
// TODO: 静音切换
|
||||
},
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(CupertinoIcons.speaker_slash_fill,
|
||||
size: 18, color: CupertinoColors.systemGrey),
|
||||
SizedBox(width: 8),
|
||||
Text('静音'),
|
||||
],
|
||||
),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
_showResolutionMenu(session);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.slider_horizontal_3,
|
||||
size: 18, color: CupertinoColors.systemGrey),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
session.resolutionOptions[session.selectedResolution]['label']
|
||||
as String,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
_showFpsMenu(session);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(CupertinoIcons.speedometer,
|
||||
size: 18, color: CupertinoColors.systemGrey),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
session.currentFps > 0 ? '${session.currentFps}fps' : '帧率',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
CupertinoActionSheetAction(
|
||||
isDestructiveAction: recording,
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
controller.toggleRecord();
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
recording ? CupertinoIcons.stop_circle : CupertinoIcons.video_camera,
|
||||
size: 18,
|
||||
color: recording
|
||||
? CupertinoColors.destructiveRed
|
||||
: CupertinoColors.systemGrey,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(recording ? '停止录制' : '录制'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 弹出分辨率选择菜单(iOS 风格 ActionSheet)。
|
||||
void _showResolutionMenu(ConnectionSessionState session) {
|
||||
final controller = ref.read(connectionControllerProvider.notifier);
|
||||
showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoActionSheet(
|
||||
title: const Text('切换分辨率'),
|
||||
actions: [
|
||||
for (int i = 0; i < session.resolutionOptions.length; i++)
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
controller.selectResolution(i);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (i == session.selectedResolution) ...[
|
||||
const Icon(CupertinoIcons.check_mark,
|
||||
size: 18, color: CupertinoColors.activeBlue),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(session.resolutionOptions[i]['label'] as String),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 弹出帧率选择菜单(iOS 风格 ActionSheet);仅切帧率,分辨率保持不变。
|
||||
void _showFpsMenu(ConnectionSessionState session) {
|
||||
final controller = ref.read(connectionControllerProvider.notifier);
|
||||
showCupertinoModalPopup<void>(
|
||||
context: context,
|
||||
builder: (ctx) => CupertinoActionSheet(
|
||||
title: const Text('切换帧率'),
|
||||
actions: [
|
||||
for (final fps in session.fpsOptions)
|
||||
CupertinoActionSheetAction(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
controller.selectFps(fps);
|
||||
},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (fps == session.currentFps) ...[
|
||||
const Icon(CupertinoIcons.check_mark,
|
||||
size: 18, color: CupertinoColors.activeBlue),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text('${fps}fps'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
cancelButton: CupertinoActionSheetAction(
|
||||
isDefaultAction: true,
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CtrlButton extends StatelessWidget {
|
||||
@@ -250,7 +489,11 @@ class _CtrlButton extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 26, color: enabled ? CupertinoColors.activeBlue : CupertinoColors.systemGrey3),
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: enabled ? CupertinoColors.activeBlue : CupertinoColors.systemGrey3,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 12)),
|
||||
],
|
||||
|
||||
@@ -14,21 +14,21 @@ import 'location_map_view.dart';
|
||||
///
|
||||
/// 对应移动端设计稿的「平板信息页」:实时位置地图、最近截图、
|
||||
/// 今日使用时长、设备操作(重启/关机/截屏/刷新/定位)与常用功能网格。
|
||||
///
|
||||
/// 性能优化说明:
|
||||
/// - 首页包含百度地图(原生平台视图)与网络图片等高重绘成本内容。
|
||||
/// - 为提升下拉/滚动流畅度,将数据驱动的卡片(定位卡片、截图卡片)拆分为
|
||||
/// 独立的 `ConsumerWidget`,各自 `watch` 自己的 Provider,实现**局部刷新**,
|
||||
/// 避免任意一个异步数据变化时重建整页(尤其避免重建地图原生视图)。
|
||||
/// - 地图使用 `RepaintBoundary` 隔离重绘;静态卡片尽量 `const` 化。
|
||||
class HomeTab extends ConsumerWidget {
|
||||
const HomeTab({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
// 仅在此 watch 设备 SN;设备切换时才重建整页结构,其余异步数据由各卡片自行 watch。
|
||||
final sn = ref.watch(selectedDeviceSnProvider);
|
||||
final locationAsync = sn == null
|
||||
? const AsyncValue<DeviceLocation?>.data(null)
|
||||
: ref.watch(deviceLocationProvider(sn));
|
||||
final location = locationAsync.valueOrNull;
|
||||
final locating = locationAsync.isLoading;
|
||||
final screenshotsAsync = sn == null
|
||||
? const AsyncValue<List<ScreenshotVO>>.data([])
|
||||
: ref.watch(recentScreenshotsProvider(sn));
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
@@ -84,17 +84,34 @@ class HomeTab extends ConsumerWidget {
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(bottom: 96),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
_locationCard(l10n,
|
||||
location: location,
|
||||
locating: locating,
|
||||
onTap: () => context.push('/map')),
|
||||
_screenshotsCard(context, l10n, screenshotsAsync, ref),
|
||||
_usageCard(l10n),
|
||||
_deviceOpsCard(context, l10n, ref, sn),
|
||||
SectionTitle(l10n.homeCommon),
|
||||
_commonCard(context, ref, l10n, sn),
|
||||
]),
|
||||
// 用 builder 按需懒构建,避免一次性持有所有卡片 Element;
|
||||
// 配合下方各卡片自身的局部刷新,减少无效重建。
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
// 定位卡片、截图卡片为独立 ConsumerWidget,各自 watch 自己的
|
||||
// Provider,实现局部刷新(数据变化时不重建其余静态卡片)。
|
||||
final child = switch (index) {
|
||||
0 => const _LocationCard(),
|
||||
1 => const _ScreenshotsCard(),
|
||||
2 => _usageCard(l10n),
|
||||
3 => _deviceOpsCard(context, l10n, ref, sn),
|
||||
4 => SectionTitle(l10n.homeCommon),
|
||||
5 => _commonCard(context, ref, l10n, sn),
|
||||
_ => const SizedBox.shrink(),
|
||||
};
|
||||
// 给每个 child 加上稳定 key,配合 findChildIndexCallback 实现
|
||||
// 滚动时 Element 复用,避免滚动离屏/回屏时重建高成本子树。
|
||||
return KeyedSubtree(
|
||||
key: ValueKey<int>(index),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
childCount: 6,
|
||||
findChildIndexCallback: (key) {
|
||||
final v = key is ValueKey<int> ? key.value : null;
|
||||
return (v != null && v < 6) ? v : null;
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -144,243 +161,6 @@ class HomeTab extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
|
||||
Widget _locationCard(
|
||||
AppLocalizations l10n, {
|
||||
required DeviceLocation? location,
|
||||
required bool locating,
|
||||
required VoidCallback onTap,
|
||||
}) =>
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: CardBox(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题栏:实时位置 + 定位状态,右上角查看详情入口
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.green,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Center(
|
||||
child: Text('📍', style: const TextStyle(fontSize: 13))),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Text(l10n.homeLocation,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.ink)),
|
||||
const SizedBox(width: 8),
|
||||
// 定位状态:放在"实时位置"标题后面
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: locating
|
||||
? AppColors.surface
|
||||
: AppColors.green.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
locating
|
||||
? l10n.homeLocating
|
||||
: (location?.displayAddress.isNotEmpty == true
|
||||
? l10n.homeLocated
|
||||
: l10n.homeNoLocation),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: locating
|
||||
? AppColors.sub
|
||||
: (location?.displayAddress.isNotEmpty == true
|
||||
? AppColors.green
|
||||
: AppColors.sub),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 右上角查看详情
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.homeViewDetail,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.blue)),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(CupertinoIcons.chevron_right,
|
||||
size: 14, color: AppColors.blue),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
children: [
|
||||
// 百度地图实时位置(须明确宽高,否则地图空白)
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 150,
|
||||
child: LocationMapView(location: location),
|
||||
),
|
||||
),
|
||||
// 地址信息浮层
|
||||
// 右侧留出间隔,避免遮挡地图右侧的放大缩小按钮。
|
||||
Positioned(
|
||||
left: 12,
|
||||
right: 52,
|
||||
bottom: 10,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.92),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
locating
|
||||
? '📍 ${l10n.homeLocating}…'
|
||||
: (location?.displayAddress.isNotEmpty == true
|
||||
? '📍 ${location!.displayAddress}'
|
||||
: '📍 ${l10n.homeAddress}'),
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.ink),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _screenshotsCard(
|
||||
BuildContext context,
|
||||
AppLocalizations l10n,
|
||||
AsyncValue<List<ScreenshotVO>> screenshots,
|
||||
WidgetRef ref,
|
||||
) =>
|
||||
CardBox(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_cardHeader(
|
||||
'🖼️',
|
||||
AppColors.blue,
|
||||
l10n.homeScreenshots,
|
||||
trailing: l10n.homeViewAll,
|
||||
onTrailingTap: (screenshots.valueOrNull?.isNotEmpty ?? false)
|
||||
? () => context.push('/screenshots', extra: 0)
|
||||
: null,
|
||||
),
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: screenshots.when(
|
||||
loading: () => const Center(
|
||||
child: CupertinoActivityIndicator(radius: 12)),
|
||||
error: (e, _) => Center(
|
||||
child: Text('加载失败',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.sub)),
|
||||
),
|
||||
data: (list) {
|
||||
if (list.isEmpty) {
|
||||
return Center(
|
||||
child: Text(l10n.homeNoScreenshot,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.sub)),
|
||||
);
|
||||
}
|
||||
// 卡片内容宽 = 屏幕宽 - 外间距(16×2) - 内边距(16×2)。
|
||||
// 一排恰好 3 张:减去 2 个间距(10×2) 后均分。
|
||||
const gap = 10.0;
|
||||
const cardInsets = 16.0 * 2 + 16.0 * 2;
|
||||
final itemWidth =
|
||||
(MediaQuery.sizeOf(context).width - cardInsets - gap * 2) / 3;
|
||||
return ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: list.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: gap),
|
||||
itemBuilder: (_, i) {
|
||||
final item = list[i];
|
||||
// 统一把原始时间戳格式化为友好显示。
|
||||
final time = formatTimeString(item.uploadTime);
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/screenshots', extra: i),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
width: itemWidth,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.network(
|
||||
item.url ?? '',
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (ctx, child, progress) =>
|
||||
progress == null
|
||||
? child
|
||||
: const Center(
|
||||
child: CupertinoActivityIndicator(
|
||||
radius: 10)),
|
||||
errorBuilder: (ctx, err, _) => Container(
|
||||
color: const Color(0xFFEEF2F7),
|
||||
child: const Center(
|
||||
child: Icon(CupertinoIcons.photo,
|
||||
size: 22, color: AppColors.sub),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.black
|
||||
.withValues(alpha: 0.45),
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(12),
|
||||
bottomRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(time,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: CupertinoColors.white)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _usageCard(AppLocalizations l10n) => CardBox(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -925,3 +705,296 @@ class _MoreOpItem {
|
||||
final String label;
|
||||
final String op;
|
||||
}
|
||||
|
||||
/// 实时位置卡片(独立 ConsumerWidget)。
|
||||
///
|
||||
/// 自己 `watch` 定位 Provider,定位数据变化时**仅重建本卡片**,避免连带
|
||||
/// 重建整页其它卡片(尤其百度地图原生视图与静态卡片),是首页流畅度的关键。
|
||||
class _LocationCard extends ConsumerWidget {
|
||||
const _LocationCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final sn = ref.watch(selectedDeviceSnProvider);
|
||||
final locationAsync = sn == null
|
||||
? const AsyncValue<DeviceLocation?>.data(null)
|
||||
: ref.watch(deviceLocationProvider(sn));
|
||||
final location = locationAsync.valueOrNull;
|
||||
final locating = locationAsync.isLoading;
|
||||
final hasAddress = location?.displayAddress.isNotEmpty == true;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/map'),
|
||||
child: CardBox(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题栏:实时位置 + 定位状态,右上角查看详情入口
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.green,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Center(
|
||||
child: Text('📍', style: const TextStyle(fontSize: 13))),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Text(l10n.homeLocation,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.ink)),
|
||||
const SizedBox(width: 8),
|
||||
// 定位状态:放在"实时位置"标题后面
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: locating
|
||||
? AppColors.surface
|
||||
: AppColors.green.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
locating
|
||||
? l10n.homeLocating
|
||||
: (hasAddress ? l10n.homeLocated : l10n.homeNoLocation),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: locating
|
||||
? AppColors.sub
|
||||
: (hasAddress ? AppColors.green : AppColors.sub),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 右上角查看详情
|
||||
GestureDetector(
|
||||
onTap: () => context.push('/map'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.homeViewDetail,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.blue)),
|
||||
const SizedBox(width: 2),
|
||||
const Icon(CupertinoIcons.chevron_right,
|
||||
size: 14, color: AppColors.blue),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
children: [
|
||||
// 百度地图实时位置(须明确宽高,否则地图空白)。
|
||||
// RepaintBoundary 隔离原生视图重绘,避免地图刷新污染父层布局。
|
||||
RepaintBoundary(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 150,
|
||||
child: LocationMapView(location: location),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 地址信息浮层
|
||||
// 右侧留出间隔,避免遮挡地图右侧的放大缩小按钮。
|
||||
Positioned(
|
||||
left: 12,
|
||||
right: 52,
|
||||
bottom: 10,
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.white.withValues(alpha: 0.92),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
locating
|
||||
? '📍 ${l10n.homeLocating}…'
|
||||
: (hasAddress
|
||||
? '📍 ${location!.displayAddress}'
|
||||
: '📍 ${l10n.homeAddress}'),
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.ink),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 最近截图卡片(独立 ConsumerWidget)。
|
||||
///
|
||||
/// 自己 `watch` 截图 Provider,截图数据变化时仅重建本卡片,避免连带重建
|
||||
/// 整页其它卡片。水平缩略图列表用 `RepaintBoundary` 隔离重绘。
|
||||
class _ScreenshotsCard extends ConsumerWidget {
|
||||
const _ScreenshotsCard();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final sn = ref.watch(selectedDeviceSnProvider);
|
||||
final screenshots = sn == null
|
||||
? const AsyncValue<List<ScreenshotVO>>.data([])
|
||||
: ref.watch(recentScreenshotsProvider(sn));
|
||||
|
||||
return CardBox(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题栏:最近截图 + 查看全部入口
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Center(
|
||||
child: Text('🖼️', style: const TextStyle(fontSize: 13))),
|
||||
),
|
||||
const SizedBox(width: 7),
|
||||
Text(l10n.homeScreenshots,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.ink)),
|
||||
const Spacer(),
|
||||
if (screenshots.valueOrNull?.isNotEmpty ?? false)
|
||||
GestureDetector(
|
||||
onTap: () => context.push('/screenshots', extra: 0),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(l10n.homeViewAll,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.green)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 120,
|
||||
child: screenshots.when(
|
||||
loading: () =>
|
||||
const Center(child: CupertinoActivityIndicator(radius: 12)),
|
||||
error: (e, _) => Center(
|
||||
child: Text('加载失败',
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.sub)),
|
||||
),
|
||||
data: (list) {
|
||||
if (list.isEmpty) {
|
||||
return Center(
|
||||
child: Text(l10n.homeNoScreenshot,
|
||||
style:
|
||||
const TextStyle(fontSize: 12, color: AppColors.sub)),
|
||||
);
|
||||
}
|
||||
// 卡片内容宽 = 屏幕宽 - 外间距(16×2) - 内边距(16×2)。
|
||||
// 一排恰好 3 张:减去 2 个间距(10×2) 后均分。
|
||||
const gap = 10.0;
|
||||
const cardInsets = 16.0 * 2 + 16.0 * 2;
|
||||
final itemWidth =
|
||||
(MediaQuery.sizeOf(context).width - cardInsets - gap * 2) / 3;
|
||||
return RepaintBoundary(
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: list.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: gap),
|
||||
itemBuilder: (_, i) {
|
||||
final item = list[i];
|
||||
// 统一把原始时间戳格式化为友好显示。
|
||||
final time = formatTimeString(item.uploadTime);
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/screenshots', extra: i),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: SizedBox(
|
||||
width: itemWidth,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.network(
|
||||
item.url ?? '',
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (ctx, child, progress) =>
|
||||
progress == null
|
||||
? child
|
||||
: const Center(
|
||||
child: CupertinoActivityIndicator(
|
||||
radius: 10)),
|
||||
errorBuilder: (ctx, err, _) => Container(
|
||||
color: const Color(0xFFEEF2F7),
|
||||
child: const Center(
|
||||
child: Icon(CupertinoIcons.photo,
|
||||
size: 22, color: AppColors.sub),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.black
|
||||
.withValues(alpha: 0.45),
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(12),
|
||||
bottomRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(time,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: CupertinoColors.white)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,21 @@ class LocationMapView extends ConsumerStatefulWidget {
|
||||
class _LocationMapViewState extends ConsumerState<LocationMapView> {
|
||||
BMFMapController? _mapController;
|
||||
bool _markerAdded = false;
|
||||
bool _mapReady = false;
|
||||
|
||||
/// 首次创建时计算并缓存的初始地图中心,之后固定复用。
|
||||
///
|
||||
/// ⚠️ 关键性能点:`BMFMapWidget` 的 `mapOptions` 一旦变化会触发原生地图重建。
|
||||
/// 若每次 build 都新建 `BMFMapOptions`(对象 `==` 未重写,恒为不相等),
|
||||
/// 父级卡片下拉刷新导致的 rebuild 就会反复重建原生地图,造成卡顿。
|
||||
/// 因此这里只在首次 build 创建一次,中心坐标的更新统一走
|
||||
/// [_syncLocation](`setCenterCoordinate`),不再改 mapOptions。
|
||||
late final BMFCoordinate _initialCenter;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initialCenter = _computeCenter(widget.location);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -43,7 +54,7 @@ class _LocationMapViewState extends ConsumerState<LocationMapView> {
|
||||
@override
|
||||
void didUpdateWidget(covariant LocationMapView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 定位数据变化时刷新标记位置。
|
||||
// 定位数据变化时刷新标记位置(仅当地图已就绪且数据确实变化)。
|
||||
if (oldWidget.location != widget.location) {
|
||||
_syncLocation(widget.location);
|
||||
}
|
||||
@@ -55,6 +66,8 @@ class _LocationMapViewState extends ConsumerState<LocationMapView> {
|
||||
// 按官方「显示定位」文档:地图加载完成后再执行图层/覆盖物操作,
|
||||
// 否则 addMarker / showUserLocation 等在地图未就绪时调用会不生效。
|
||||
controller.setMapDidLoadCallback(callback: () {
|
||||
if (_mapReady) return; // 幂等保护,避免重复注册导致重复原生调用。
|
||||
_mapReady = true;
|
||||
debugPrint('[LocationMapView] mapDidLoad, 执行图层与标记初始化');
|
||||
// 1. 初始化定位图层 + 补偿蓝色定位点。
|
||||
_enableUserLocationLayer();
|
||||
@@ -113,7 +126,8 @@ class _LocationMapViewState extends ConsumerState<LocationMapView> {
|
||||
if (coord == null) return;
|
||||
|
||||
final bd = wgs84ToBd09(coord.lat, coord.lon);
|
||||
controller.setCenterCoordinate(bd, true);
|
||||
// 关闭动画:避免滚动/下拉刷新期间触发原生地图的动画平移重绘,降低卡顿。
|
||||
controller.setCenterCoordinate(bd, false);
|
||||
if (!_markerAdded) {
|
||||
// Marker 标题展示设备 SN(缺失时兜底为"设备位置")。
|
||||
final sn = location!.sn;
|
||||
@@ -160,9 +174,11 @@ class _LocationMapViewState extends ConsumerState<LocationMapView> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// watch 本机定位 Provider:地图可见时自动启动手机定位。
|
||||
ref.watch(locationControllerProvider);
|
||||
// 监听本机定位结果:成功后把蓝色定位点更新到地图。
|
||||
// ⚠️ Riverpod 要求 ref.listen 只能在 build 期间调用,故放在此处。
|
||||
// 使用 ref.listen 而非 ref.watch:仅建立监听、**不使 build 依赖该状态**,
|
||||
// 因此本机定位状态变化不会触发地图原生视图(AndroidView / UiKitView)重建,
|
||||
// 这是首页下拉/滚动卡顿的关键优化。ref.listen 同样会触发 Provider 初始化以启动定位。
|
||||
ref.listen<LocationState>(
|
||||
locationControllerProvider,
|
||||
(prev, next) {
|
||||
@@ -181,7 +197,7 @@ class _LocationMapViewState extends ConsumerState<LocationMapView> {
|
||||
child: BMFMapWidget(
|
||||
onBMFMapCreated: _onMapCreated,
|
||||
mapOptions: BMFMapOptions(
|
||||
center: _computeCenter(widget.location),
|
||||
center: _initialCenter, // 复用首次缓存中心,避免 rebuild 触发地图重建。
|
||||
zoomLevel: 16,
|
||||
showMapScaleBar: false,
|
||||
),
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'app/constants/app_constants.dart';
|
||||
import 'app/router/app_router.dart';
|
||||
import 'core/storage/token_storage.dart';
|
||||
import 'core/utils/device_info_util.dart';
|
||||
import 'core/utils/display_mode_util.dart';
|
||||
import 'core/utils/system_ui_util.dart';
|
||||
|
||||
/// 应用入口。
|
||||
@@ -19,6 +20,10 @@ import 'core/utils/system_ui_util.dart';
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Android 高刷适配:将屏幕切换到设备支持的最高刷新率(120Hz / 144Hz),
|
||||
// 提升动画与滚动流畅度。iOS ProMotion 由系统自动匹配,无需处理。
|
||||
await DisplayModeUtil.enableBestDisplayMode();
|
||||
|
||||
// 初始化百度地图 SDK(需申请 AK;坐标类型使用 BD09LL,设备 GPS 坐标在展示前转换)。
|
||||
BMFMapSDK.setAgreePrivacy(true);
|
||||
BMFMapSDK.setApiKeyAndCoordType(
|
||||
|
||||
392
pubspec.lock
392
pubspec.lock
File diff suppressed because it is too large
Load Diff
@@ -97,6 +97,7 @@ dependencies:
|
||||
material_ui: any
|
||||
intl: any
|
||||
lpinyin: ^2.0.3
|
||||
flutter_displaymode: ^0.7.0
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
Reference in New Issue
Block a user