diff --git a/AGENTS.md b/AGENTS.md index 0b7fac0..5df64aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -352,3 +352,35 @@ static const String kBaseUrl = 'http://:/api/v1/open/'; | `login` | `/login/sms/code` | 登录页(验证码登录) | | `register` | `/register/sms/code` | 注册页 | | `resetPassword` | `/reset-password/sms/code` | 忘记密码页 | + +### 17 解耦适配 + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; +``` +都变更为 +```dart +import 'package:material_ui/material_ui.dart'; +import 'package:cupertino_ui/cupertino_ui.dart'; +``` + +flutter_localizations也被拆分,Material和Cupertino的本地化委托分别移进了对应的包。 + +迁移前: +```dart +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter/material.dart'; + +localizationsDelegates: const >[ + GlobalCupertinoLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, +], +``` +迁移后: +```dart +import 'package:material_ui/material_ui.dart'; + +localizationsDelegates: GlobalMaterialLocalizations.delegates, +``` \ No newline at end of file diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d927307..a4d8215 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.util.Properties +import java.io.FileInputStream + plugins { id("com.android.application") id("kotlin-android") @@ -5,6 +8,13 @@ plugins { id("dev.flutter.flutter-gradle-plugin") } +// 读取签名配置 +val keystoreProperties = Properties() +val keystorePropertiesFile = rootProject.file("key.properties") +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) +} + android { namespace = "com.ttstd.familycare" compileSdk = flutter.compileSdkVersion @@ -30,11 +40,22 @@ android { versionName = flutter.versionName } + signingConfigs { + create("own") { + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + storeFile = keystoreProperties["storeFile"]?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as String? + } + } + buildTypes { + debug { + signingConfig = signingConfigs.getByName("own") + } release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + // 使用自有签名配置 + signingConfig = signingConfigs.getByName("own") } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fd0353d..42ba259 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,17 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + android:windowSoftInputMode="adjustResize" + android:windowLayoutInDisplayCutoutMode="shortEdges"> + - - - - - - - diff --git a/android/app/src/main/java/com/ttstd/familycare/AppApplication.java b/android/app/src/main/java/com/ttstd/familycare/AppApplication.java new file mode 100644 index 0000000..6d71bf4 --- /dev/null +++ b/android/app/src/main/java/com/ttstd/familycare/AppApplication.java @@ -0,0 +1,15 @@ +package com.ttstd.familycare; + +import com.baidu.mapapi.base.BmfMapApplication; + +/** + * 应用入口 Application。 + * + * 继承插件提供的 {@link BmfMapApplication},其 onCreate 会自动执行 + * {@code SDKInitializer.setAgreePrivacy(...)} 与 {@code SDKInitializer.initialize(this)}。 + * + * 必须完成该原生初始化,否则地图页创建 BMFMapWidget(平台视图)时会抛出 + * "you have not supplyed the global app context info from SDKInitializer.initialize(Context)"。 + */ +public class AppApplication extends BmfMapApplication { +} diff --git a/android/app/src/main/java/com/ttstd/familycare/MainActivity.java b/android/app/src/main/java/com/ttstd/familycare/MainActivity.java index 3503963..80c18ed 100644 --- a/android/app/src/main/java/com/ttstd/familycare/MainActivity.java +++ b/android/app/src/main/java/com/ttstd/familycare/MainActivity.java @@ -1,6 +1,38 @@ package com.ttstd.familycare; +import androidx.annotation.NonNull; import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.plugin.common.MethodChannel; public class MainActivity extends FlutterActivity { + private static final String CHANNEL = "com.ttstd.familycare/app"; + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL) + .setMethodCallHandler( + (call, result) -> { + if (call.method.equals("moveTaskToBack")) { + // 将应用退到后台而不销毁 Engine,实现热启动优化 + moveTaskToBack(false); + result.success(true); + } else { + result.notImplemented(); + } + } + ); + } + + /** + * 注意:不再直接拦截 onBackPressed。 + * 贪婪拦截会导致 Flutter 内部的 PopScope 失效,使得子页面(如 AppsPage)点击返回键时 + * 无法执行 Flutter 侧定义的返回逻辑(如 context.pop()),而是直接退到桌面。 + * + * 优化后的方案: + * 1. 移除这里的贪婪拦截,让返回键事件传递给 Flutter 引擎。 + * 2. 在 Flutter 的首页 Root 节点使用 PopScope 拦截返回,弹出退出确认。 + * 3. 用户确认退出时,通过上面的 MethodChannel 调用 moveTaskToBack。 + */ } diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be..e11bb73 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -5,6 +5,12 @@ @drawable/launch_background + + true + @android:color/transparent + @android:color/transparent + + shortEdges diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cb1ef88..81adbf1 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -5,6 +5,12 @@ @drawable/launch_background + + true + @android:color/transparent + @android:color/transparent + + shortEdges diff --git a/assets/images/ic_device_location.png b/assets/images/ic_device_location.png new file mode 100644 index 0000000..e69de29 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 2f125b5..37465a7 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -49,6 +49,10 @@ UIApplicationSupportsIndirectInputEvents + UIViewControllerBasedStatusBarAppearance + + UIStatusBarStyle + UIStatusBarStyleDarkContent UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -66,5 +70,14 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + + NSLocationWhenInUseUsageDescription + 用于在地图上展示设备位置 + NSLocationAlwaysUsageDescription + 用于在地图上展示设备位置 + NSLocationAlwaysAndWhenInUseUsageDescription + 用于在地图上展示设备位置 + io.flutter.embedded_views_preview + diff --git a/lib/app/app.dart b/lib/app/app.dart index e14159f..445d64d 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,4 +1,5 @@ import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -19,7 +20,15 @@ class App extends StatelessWidget { title: '桐桐家庭关怀', theme: AppTheme.cupertinoTheme, routerConfig: router, - localizationsDelegates: AppLocalizations.localizationsDelegates, + localizationsDelegates: >[ + AppLocalizations.delegate, + // 注意:本工程使用第三方 cupertino_ui 包,其 Cupertino 组件(如 + // CupertinoTabBar)依赖的是 cupertino_ui 自带的 CupertinoLocalizations, + // 而非 Flutter SDK 的 GlobalCupertinoLocalizations。因此这里必须注册 + // cupertino_ui 的 delegate,否则 CupertinoTabBar 等组件会在运行时 + // 抛 "could not find a CupertinoLocalizations ancestor" 异常。 + ...GlobalCupertinoLocalizations.delegates, + ], supportedLocales: AppLocalizations.supportedLocales, debugShowCheckedModeBanner: false, ); diff --git a/lib/app/constants/app_constants.dart b/lib/app/constants/app_constants.dart index a93015c..1a7eae8 100644 --- a/lib/app/constants/app_constants.dart +++ b/lib/app/constants/app_constants.dart @@ -3,7 +3,7 @@ class AppConstants { const AppConstants._(); /// 生产环境基础地址。 - static const String kBaseUrl = 'http://192.168.100.222:8000/api/v1/open/'; + static const String kBaseUrl = 'http://192.168.100.244:8000/api/'; /// 请求超时(毫秒)。 static const int kConnectTimeoutMs = 15000; @@ -18,4 +18,7 @@ class AppConstants { /// 应用名称。 static const String kAppName = '家庭关怀'; + + /// 百度地图开放平台申请的 AK(Android / iOS 共用)。 + static const String kBaiduMapAk = '请替换为百度地图开放平台申请的AK'; } diff --git a/lib/app/router/app_router.dart b/lib/app/router/app_router.dart index 17c6bb5..50601a4 100644 --- a/lib/app/router/app_router.dart +++ b/lib/app/router/app_router.dart @@ -1,3 +1,5 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../features/auth/presentation/login_page.dart'; @@ -7,12 +9,16 @@ import '../../features/auth/presentation/legal_document_page.dart'; import '../../features/auth/presentation/splash_page.dart'; import '../../features/home/presentation/home_page.dart'; import '../../features/home/presentation/home_tab.dart'; +import '../../features/home/presentation/map_page.dart'; +import '../../features/home/presentation/screenshot_page.dart'; import '../../features/messages/presentation/messages_tab.dart'; import '../../features/profile/presentation/profile_tab.dart'; import '../../features/album/presentation/album_page.dart'; import '../../features/alarm/presentation/alarm_page.dart'; import '../../features/contacts/presentation/contacts_page.dart'; import '../../features/apps/presentation/apps_page.dart'; +import '../../features/device/data/device_providers.dart'; +import '../../features/device/domain/device_models.dart'; /// 应用路由定义(GoRouter)。 /// @@ -61,6 +67,16 @@ GoRouter buildAppRouter() { path: '/apps', builder: (context, state) => const AppsPage(), ), + GoRoute( + path: '/map', + builder: (context, state) => _MapRoute(), + ), + GoRoute( + path: '/screenshots', + builder: (context, state) => _ScreenshotsRoute( + initialIndex: (state.extra is int) ? state.extra as int : 0, + ), + ), StatefulShellRoute.indexedStack( builder: (context, state, shell) => HomePage(child: shell), branches: [ @@ -93,3 +109,34 @@ GoRouter buildAppRouter() { ], ); } + +/// /map 路由包装:从全局选中的设备 SN 读取后渲染地图。 +class _MapRoute extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final sn = ref.watch(selectedDeviceSnProvider) ?? ''; + return MapPage(sn: sn); + } +} + +/// /screenshots 路由包装:从全局选中的设备 SN 读取最近截图列表, +/// 定位到传入的初始索引后渲染截图查看页。 +class _ScreenshotsRoute extends ConsumerWidget { + const _ScreenshotsRoute({required this.initialIndex}); + + final int initialIndex; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sn = ref.watch(selectedDeviceSnProvider); + final screenshotsAsync = sn == null + ? const AsyncValue>.data([]) + : ref.watch(recentScreenshotsProvider(sn)); + final list = screenshotsAsync.valueOrNull ?? const []; + + // 越界保护:初始索引落在列表内,否则回退到 0。 + final index = + (list.isNotEmpty && initialIndex < list.length) ? initialIndex : 0; + return ScreenshotPage(screenshots: list, initialIndex: index); + } +} diff --git a/lib/core/network/api_error.dart b/lib/core/network/api_error.dart index a9d10d2..19f40ef 100644 --- a/lib/core/network/api_error.dart +++ b/lib/core/network/api_error.dart @@ -54,6 +54,15 @@ class AppError { ); } if (status != null) { + // 鉴权失效统一映射为 unauthorized 类型。 + if (status == 401) { + return AppError._( + type: AppErrorType.unauthorized, + message: '登录已失效,请重新登录', + statusCode: status, + original: e, + ); + } return AppError._( type: AppErrorType.server, message: '服务暂时不可用(HTTP $status)', diff --git a/lib/core/network/dio_client.dart b/lib/core/network/dio_client.dart index 9649660..d84343d 100644 --- a/lib/core/network/dio_client.dart +++ b/lib/core/network/dio_client.dart @@ -48,6 +48,87 @@ class DioClient { return dio; } + /// 专用于刷新 token 的独立 Dio 实例。 + /// + /// 不带任何业务/鉴权拦截器,避免刷新请求自身递归进入 401 流程, + /// 也避免被 [_AuthInterceptor] 注入已失效的旧 token。 + static Dio get _refreshDio { + final dio = Dio( + BaseOptions( + baseUrl: AppConstants.kBaseUrl, + connectTimeout: + Duration(milliseconds: AppConstants.kConnectTimeoutMs), + receiveTimeout: + Duration(milliseconds: AppConstants.kReceiveTimeoutMs), + headers: {'Content-Type': 'application/json'}, + ), + ); + return dio; + } + + /// 标记当前是否正在刷新,避免并发 401 引发多次刷新。 + static bool _isRefreshing = false; + + /// 触发一次 token 刷新并重放原请求。 + /// + /// 返回重放后的响应体(已解析 data);刷新失败则抛出 [ApiException] + /// (unauthorized),由上层触发登出清理。 + static Future _refreshAndRetry( + Future> Function() call, + ) async { + if (_isRefreshing) { + // 已有刷新在途:直接等待其完成并重放,避免并发刷新。 + // 简单处理为失败,交由上层登出,避免复杂队列。 + throw const ApiException(code: '401', message: '登录已失效,请重新登录'); + } + _isRefreshing = true; + try { + final refreshed = await _refreshToken(); + if (!refreshed) { + TokenStorage.clear(); + throw const ApiException(code: '401', message: '登录已失效,请重新登录'); + } + // 刷新成功:用新 token 重放原请求。 + final response = await call(); + return _ResponseInterceptor.parse(response); + } finally { + _isRefreshing = false; + } + } + + /// 使用 refreshToken 换取新 token,并写回本地存储。 + /// + /// 返回是否刷新成功。刷新接口为匿名接口(带 refreshToken query), + /// 使用独立 [_refreshDio],不触发任何拦截器。 + static Future _refreshToken() async { + final refreshToken = TokenStorage.refreshToken; + debugPrint('[DioClient] _refreshToken: start, hasRefreshToken=${refreshToken?.isNotEmpty}'); + if (refreshToken == null || refreshToken.isEmpty) return false; + try { + // 对接 open 模块刷新接口,路径需带 v1/open/ 前缀。 + final response = await _refreshDio.post( + 'v1/open/refresh-token', + queryParameters: {'refreshToken': refreshToken}, + ); + debugPrint('[DioClient] _refreshToken: response status=${response.statusCode} body=${response.data}'); + final body = response.data as Map?; + if (body == null) return false; + final code = body['code']?.toString() ?? '-1'; + // 成功码约定为 "00000"。 + if (code != '00000') return false; + final data = body['data'] as Map?; + final newToken = data?['accessToken'] as String?; + final newRefresh = data?['refreshToken'] as String?; + if (newToken == null || newToken.isEmpty) return false; + TokenStorage.saveTokens(token: newToken, refreshToken: newRefresh); + debugPrint('[DioClient] _refreshToken: success, newToken=${newToken.substring(0, 5)}...'); + return true; + } catch (e) { + debugPrint('[DioClient] _refreshToken: unexpected error: $e'); + return false; + } + } + /// 统一 GET 请求,返回解析后的 data。 static Future get( String path, { @@ -80,6 +161,9 @@ class DioClient { /// 统一请求入口:发起请求,错误由拦截器链转换为 [ApiException],此处再包成 /// [AppError] 以便调用方按需分类处理。Repository 直接捕获 [ApiException] 即可。 + /// + /// 401 / 令牌失效处理:无论后台以「HTTP 401 + 业务码」还是纯业务码表达令牌失效, + /// 均在此统一捕获并尝试刷新 token 后重试一次;刷新失败抛出 unauthorized 错误。 static Future _request( Future> Function() call, ) async { @@ -92,8 +176,19 @@ class DioClient { 'statusCode=${response.statusCode}', ); debugPrint('[DioClient] response body=${response.data}'); + // 解析成功返回 data;若为业务错误(code != "00000")会在此抛出 ApiException。 return _ResponseInterceptor.parse(response); - } on ApiException { + } on ApiException catch (e) { + // A0300:端标识/权限异常,旧令牌无法通过刷新恢复 client 身份, + // 直接清理本地令牌并转为登录失效,引导重新登录。 + if (e.code == 'A0300') { + TokenStorage.clear(); + throw const ApiException(code: '401', message: '登录已失效,请重新登录'); + } + // 业务层已抛出的异常:判断是否令牌失效,触发刷新重试。 + if (_isTokenExpired(e, null)) { + return _refreshAndRetry(call); + } rethrow; } on DioException catch (e) { debugPrint( @@ -102,6 +197,12 @@ class DioClient { 'type=${e.type} message=${e.message}', ); debugPrint('[DioClient] error response body=${e.response?.data}'); + // 拦截器已将端标识/权限异常包装为 ApiException(如登录失效),直接透传。 + if (e.error is ApiException) { + throw e.error! as ApiException; + } + // 注:HTTP 401 的刷新重试已在 [_ErrorInterceptor.onError] 统一处理, + // 此处不再重复,避免重复刷新。 throw ApiException.fromAppError( AppError.fromDioException(e), ); @@ -110,12 +211,29 @@ class DioClient { throw ApiException.fromAppError(AppError.unknown(e)); } } + + /// 判断业务异常是否为令牌失效,需触发刷新。 + /// + /// 兼容两类表达:HTTP 401 已在调用处单独判断;此处针对响应体业务码 + /// (如后台用 {code: A0230, msg: 访问令牌无效或已过期} 表达令牌过期)。 + static bool _isTokenExpired(ApiException e, int? statusCode) { + if (statusCode == 401) return true; + // 已知令牌失效业务码(与后台约定,按需补充)。 + const expiredCodes = {'A0230', '401', 'A0801'}; + return expiredCodes.contains(e.code); + } } /// 请求拦截器:自动为已登录会话注入 Authorization 头。 class _AuthInterceptor extends Interceptor { @override void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + // [DEBUG] 打印所有接口请求的完整地址(debugPrint 在 release 下为空实现)。 + if (kDebugMode) { + debugPrint( + '[DioClient] -> ${options.method} ${options.uri}', + ); + } final token = TokenStorage.accessToken; if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; @@ -155,73 +273,99 @@ class _ResponseInterceptor extends Interceptor { } } -/// 错误拦截器:将 [DioException] 统一转换为 [ApiException],并处理 401 刷新。 +/// 错误拦截器:统一处理 HTTP 401 自动刷新 token 并重试。 +/// +/// 所有请求(get/post/put/delete/multipart 等)的 401 响应均在此拦截, +/// 触发刷新后自动重放原请求;刷新失败则向下传递 401 由上层登出处理。 +/// 这样无论哪个请求方法都不会遗漏 401 刷新逻辑。 class _ErrorInterceptor extends Interceptor { - /// 标记当前是否正在刷新,避免并发 401 引发多次刷新。 - static bool _isRefreshing = false; - @override - Future onError( - DioException err, - ErrorInterceptorHandler handler, - ) async { - // HTTP 401:尝试刷新 token 并重试原请求一次。 - if (err.response?.statusCode == 401 && !_isRefreshing) { - try { - _isRefreshing = true; - final refreshed = await _refreshToken(); - if (refreshed) { - final clone = await _retry(err.requestOptions); - handler.resolve(clone); - return; - } - } catch (_) { - // 刷新失败:继续向下抛出 unauthorized。 - } finally { - _isRefreshing = false; - } + void onError(DioException err, ErrorInterceptorHandler handler) { + final response = err.response; + final statusCode = response?.statusCode; + final body = response?.data as Map?; + final code = body?['code']?.toString(); + + // 访问权限异常(A0300):通常意味着当前令牌的端标识不符(如旧令牌未携带 + // clientType,或跨端令牌)。此类令牌无法通过刷新恢复客户端身份,直接清理 + // 本地令牌并转为「登录失效」,由上层引导重新登录,避免反复提示权限异常。 + if (code == 'A0300') { + debugPrint( + '[DioClient] _ErrorInterceptor: 端标识/权限异常 ' + '(status=$statusCode, code=$code). Clearing token & require re-login.', + ); + TokenStorage.clear(); + handler.next(_asLoginExpired(err)); + return; + } + + // 统一判断是否为令牌失效(HTTP 401 或业务码 A0230/A0801 等)。 + if (_isTokenExpiredRaw(code, statusCode)) { + debugPrint( + '[DioClient] _ErrorInterceptor: Token expired detected ' + '(status=$statusCode, code=$code). Attempting refresh...', + ); + _handle401(err, handler); + return; } handler.next(err); } - /// 使用 refreshToken 换取新 token,并写回本地存储。 + /// 将权限/端标识异常包装为「登录失效」错误,供上层统一走重新登录流程。 + static DioException _asLoginExpired(DioException err) { + return DioException( + requestOptions: err.requestOptions, + response: err.response, + type: DioExceptionType.unknown, + error: const ApiException(code: '401', message: '登录已失效,请重新登录'), + ); + } + + /// 内部快速判断逻辑,逻辑同 [DioClient._isTokenExpired]。 + bool _isTokenExpiredRaw(String? code, int? statusCode) { + if (statusCode == 401) return true; + const expiredCodes = {'A0230', '401', 'A0801'}; + return code != null && expiredCodes.contains(code); + } + + /// 处理 HTTP 401:刷新 token 后重放原请求。 /// - /// 返回是否刷新成功。具体刷新接口路径以后台约定为准。 - static Future _refreshToken() async { - final refreshToken = TokenStorage.refreshToken; - if (refreshToken == null || refreshToken.isEmpty) return false; + /// 刷新受 [DioClient._isRefreshing] 保护(并发 401 只刷新一次)。重放通过 + /// [DioClient.instance.fetch] 进行,会自动重新注入新 token 并再次经过 + /// 拦截器链。刷新失败则向下传递原始 401。 + static void _handle401( + DioException err, + ErrorInterceptorHandler handler, + ) async { try { - final response = await DioClient.instance.post( - '/refresh-token', - queryParameters: {'refreshToken': refreshToken}, - ); - final data = response as Map?; - final newToken = data?['accessToken'] as String?; - final newRefresh = data?['refreshToken'] as String?; - if (newToken == null || newToken.isEmpty) return false; - TokenStorage.saveTokens( - token: newToken, - refreshToken: newRefresh, - ); - return true; + final refreshed = await _refreshTokenOnce(); + if (!refreshed) { + TokenStorage.clear(); + handler.next(err); + return; + } + final response = await DioClient.instance.fetch(err.requestOptions); + handler.resolve(response); } catch (_) { - return false; + handler.next(err); } } - /// 用原请求参数重放一次请求。 - static Future> _retry(RequestOptions options) { - return DioClient.instance.request( - options.path, - data: options.data, - queryParameters: options.queryParameters, - options: Options( - method: options.method, - headers: { - ...options.headers, - 'Authorization': 'Bearer ${TokenStorage.accessToken}', - }, - ), - ); + /// 刷新 token(带并发保护)。 + /// + /// 返回是否刷新成功。刷新接口为匿名接口,使用独立 [_refreshDio], + /// 不触发任何拦截器,避免递归进入 401 流程。 + static Future _refreshTokenOnce() async { + debugPrint('[DioClient] _refreshTokenOnce: isRefreshing=${DioClient._isRefreshing}'); + if (DioClient._isRefreshing) { + // 已有刷新在途:简单处理为失败,交由上层登出,避免复杂队列。 + return false; + } + DioClient._isRefreshing = true; + try { + return await DioClient._refreshToken(); + } finally { + DioClient._isRefreshing = false; + } } } diff --git a/lib/core/utils/coord_util.dart b/lib/core/utils/coord_util.dart new file mode 100644 index 0000000..16bf26a --- /dev/null +++ b/lib/core/utils/coord_util.dart @@ -0,0 +1,50 @@ +import 'dart:math'; + +import 'package:flutter_baidu_mapapi_base/flutter_baidu_mapapi_base.dart'; + +/// WGS84(GPS) 经纬度转百度 BD09LL 坐标。 +/// +/// 分两步:WGS84 -> GCJ02(国测局火星坐标),再 GCJ02 -> BD09。 +/// 设备上报的定位通常为 GPS(WGS84),而百度地图使用 BD09LL 坐标系, +/// 展示前必须做该转换,否则地图上位置会有偏移。 +BMFCoordinate wgs84ToBd09(double lat, double lon) { + const double a = 6378245.0; + const double ee = 0.00669342162296594323; + + double transformLat(double x, double y) { + double ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + + 0.2 * sqrt(x.abs()); + ret += (20.0 * sin(6.0 * x * pi) + 20.0 * sin(2.0 * x * pi)) * 2.0 / 3.0; + ret += (20.0 * sin(y * pi) + 40.0 * sin(y / 3.0 * pi)) * 2.0 / 3.0; + ret += (160.0 * sin(y / 12.0 * pi) + 320 * sin(y * pi / 30.0)) * 2.0 / 3.0; + return ret; + } + + double transformLon(double x, double y) { + double ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + + 0.1 * sqrt(x.abs()); + ret += (20.0 * sin(6.0 * x * pi) + 20.0 * sin(2.0 * x * pi)) * 2.0 / 3.0; + ret += (20.0 * sin(x * pi) + 40.0 * sin(x / 3.0 * pi)) * 2.0 / 3.0; + ret += (150.0 * sin(x / 12.0 * pi) + 300.0 * sin(x / 30.0 * pi)) * 2.0 / 3.0; + return ret; + } + + // WGS84 -> GCJ02 + final double dLat = transformLat(lon - 105.0, lat - 35.0); + final double dLon = transformLon(lon - 105.0, lat - 35.0); + final double radLat = lat / 180.0 * pi; + final double magic = sin(radLat); + final double sqrtMagic = sqrt(1 - ee * magic * magic); + final double mgLat = + lat + (dLat * 180.0) / ((a * (1 - ee)) / (sqrtMagic * sqrtMagic) * pi); + final double mgLon = + lon + (dLon * 180.0) / (a / sqrtMagic * cos(radLat) * pi); + + // GCJ02 -> BD09 + final double z = sqrt(mgLon * mgLon + mgLat * mgLat) + 0.00002 * sin(mgLat * pi); + final double theta = atan2(mgLat, mgLon) + 0.000003 * cos(mgLon * pi); + final double bdLon = z * cos(theta) + 0.0065; + final double bdLat = z * sin(theta) + 0.006; + + return BMFCoordinate(bdLat, bdLon); +} diff --git a/lib/core/utils/system_ui_util.dart b/lib/core/utils/system_ui_util.dart new file mode 100644 index 0000000..787f1c5 --- /dev/null +++ b/lib/core/utils/system_ui_util.dart @@ -0,0 +1,49 @@ +import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; + +/// 系统 UI(状态栏 / 导航栏)适配工具。 +/// +/// 集中处理沉浸式状态栏、edge-to-edge 布局与刘海屏(Display Cutout)适配, +/// 避免在业务页面中散落系统 UI 相关代码。 +class SystemUiUtil { + const SystemUiUtil._(); + + /// 应用启动时统一配置系统 UI。 + /// + /// - 使用 edge-to-edge 布局:内容延伸到状态栏与导航栏区域。 + /// - 状态栏 / 导航栏采用半透明叠加,图标使用深色(适配浅色背景 UI)。 + static Future configure() async { + // edge-to-edge:App 内容铺满全屏,安全区由 Flutter 的 SafeArea / MediaQuery + // 在布局层兜底,系统栏以半透明覆盖层呈现。 + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + + // 状态栏 / 导航栏图标亮度:本工程为浅色背景,使用深色图标。 + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + // 状态栏图标(时间、电量等)为深色。 + statusBarIconBrightness: Brightness.dark, + statusBarBrightness: Brightness.light, + // 导航栏图标为深色,导航栏背景半透明以配合 edge-to-edge。 + systemNavigationBarIconBrightness: Brightness.dark, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarDividerColor: Colors.transparent, + // 允许刘海屏内容延伸(Android 端会同步在 Manifest 配置 cutout 模式)。 + statusBarColor: Colors.transparent, + ), + ); + } + + /// 将应用移动到后台(实现 Android 热启动优化)。 + /// + /// 调用原生端的 moveTaskToBack(false),避免 Activity 销毁。 + static Future moveToBack() async { + const channel = MethodChannel('com.ttstd.familycare/app'); + try { + await channel.invokeMethod('moveTaskToBack'); + } on PlatformException catch (e) { + debugPrint('Failed to move task to back: ${e.message}'); + // 兜底方案:退回到系统默认行为 + await SystemNavigator.pop(); + } + } +} diff --git a/lib/core/utils/time_format.dart b/lib/core/utils/time_format.dart new file mode 100644 index 0000000..b03f8ec --- /dev/null +++ b/lib/core/utils/time_format.dart @@ -0,0 +1,57 @@ +/// 统一的时间格式化工具。 +/// +/// 后端各时间字段(如截图 `uploadTime`、设备 `lastUpdateTime` 等)常以 +/// 原始字符串返回,格式不一(ISO8601 / 空格分隔 / 纯数字时间戳等)。 +/// 通过 [formatTimeString] 统一解析并格式化为友好的 `MM-dd HH:mm`。 +library; + +/// 将后端返回的时间字符串解析为友好显示(`MM-dd HH:mm`)。 +/// +/// 支持以下常见格式: +/// - ISO8601:`2026-08-18T14:30:00`、`2026-08-18T14:30:00+08:00` +/// - 空格分隔:`2026-08-18 14:30:00` +/// - 纯数字时间戳(秒或毫秒):`1784500200000` +/// +/// 解析失败时返回 [fallback](默认空串);[raw] 为 null/空 时同样返回 +/// [fallback]。若无法识别但仍非空,会原样返回原始字符串。 +String formatTimeString(String? raw, {String fallback = ''}) { + if (raw == null || raw.isEmpty) return fallback; + + final dt = tryParseTimeString(raw); + if (dt == null) return raw; + + return formatDateTime(dt); +} + +/// 将 [DateTime] 格式化为友好的 `MM-dd HH:mm`。 +String formatDateTime(DateTime time) { + final local = time.toLocal(); + final m = _two(local.month); + final d = _two(local.day); + final h = _two(local.hour); + final min = _two(local.minute); + return '$m-$d $h:$min'; +} + +/// 尝试把后端常见的时间字符串解析为 [DateTime],失败返回 null。 +DateTime? tryParseTimeString(String raw) { + final s = raw.trim(); + if (s.isEmpty) return null; + + // 1. 纯数字:可能是秒或毫秒时间戳。 + if (RegExp(r'^\d+$').hasMatch(s)) { + final n = int.tryParse(s); + if (n != null) { + // 13 位以上按毫秒,否则按秒。 + return n >= 1000000000000 + ? DateTime.fromMillisecondsSinceEpoch(n) + : DateTime.fromMillisecondsSinceEpoch(n * 1000); + } + } + + // 2. 空格分隔:`2026-08-18 14:30:00` → 补 `T` 变 ISO8601。 + final iso = s.contains(' ') ? s.replaceFirst(' ', 'T') : s; + return DateTime.tryParse(iso); +} + +String _two(int v) => v.toString().padLeft(2, '0'); diff --git a/lib/core/widgets/feature_ui.dart b/lib/core/widgets/feature_ui.dart index 8cc8bda..ce8c8a6 100644 --- a/lib/core/widgets/feature_ui.dart +++ b/lib/core/widgets/feature_ui.dart @@ -49,6 +49,7 @@ class IconTile extends StatelessWidget { @override Widget build(BuildContext context) { final tile = Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: size, diff --git a/lib/features/alarm/presentation/alarm_page.dart b/lib/features/alarm/presentation/alarm_page.dart index 23e5e54..82c77d4 100644 --- a/lib/features/alarm/presentation/alarm_page.dart +++ b/lib/features/alarm/presentation/alarm_page.dart @@ -26,21 +26,32 @@ class _AlarmPageState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - middle: Text(l10n.alarmTitle), - leading: CupertinoButton( - padding: EdgeInsets.zero, - child: const Icon(CupertinoIcons.back), - onPressed: () => context.go('/messages'), + return PopScope( + // 拦截系统返回键:优先出栈;若无法出栈则跳回「管理」页,避免直接退到桌面。 + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (context.canPop()) { + context.pop(); + } else { + context.go('/messages'); + } + }, + child: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text(l10n.alarmTitle), + leading: CupertinoButton( + padding: EdgeInsets.zero, + child: const Icon(CupertinoIcons.back), + onPressed: () => context.pop(), + ), + trailing: CupertinoButton( + padding: EdgeInsets.zero, + child: const Icon(CupertinoIcons.add, size: 26), + onPressed: () {}, + ), ), - trailing: CupertinoButton( - padding: EdgeInsets.zero, - child: const Icon(CupertinoIcons.add, size: 26), - onPressed: () {}, - ), - ), - child: ListView.separated( + child: ListView.separated( padding: const EdgeInsets.only(bottom: 24), itemCount: _alarms.length + 1, separatorBuilder: (_, _) => const SizedBox(height: 12), @@ -100,6 +111,7 @@ class _AlarmPageState extends State { ); }, ), + ), ); } } diff --git a/lib/features/album/presentation/album_page.dart b/lib/features/album/presentation/album_page.dart index 2e1e74a..25ec18a 100644 --- a/lib/features/album/presentation/album_page.dart +++ b/lib/features/album/presentation/album_page.dart @@ -40,20 +40,31 @@ class _AlbumPageState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - middle: Text(l10n.albumTitle), - leading: CupertinoButton( - padding: EdgeInsets.zero, - child: const Icon(CupertinoIcons.back), - onPressed: () => context.go('/messages'), + return PopScope( + // 拦截系统返回键:优先出栈;若无法出栈(如 go_router 顶级路由)则跳回「管理」页,避免直接退到桌面。 + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (context.canPop()) { + context.pop(); + } else { + context.go('/messages'); + } + }, + child: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text(l10n.albumTitle), + leading: CupertinoButton( + padding: EdgeInsets.zero, + child: const Icon(CupertinoIcons.back), + onPressed: () => context.pop(), + ), + trailing: CupertinoButton( + padding: EdgeInsets.zero, + child: const Text('选择'), + onPressed: () {}, + ), ), - trailing: CupertinoButton( - padding: EdgeInsets.zero, - child: const Text('选择'), - onPressed: () {}, - ), - ), child: Stack( children: [ ListView( @@ -145,6 +156,7 @@ class _AlbumPageState extends State { ), ), ], + ), ), ); } diff --git a/lib/features/apps/presentation/apps_page.dart b/lib/features/apps/presentation/apps_page.dart index cea39d5..24c967a 100644 --- a/lib/features/apps/presentation/apps_page.dart +++ b/lib/features/apps/presentation/apps_page.dart @@ -1,137 +1,321 @@ import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:ttstd_family_care/app/constants/app_constants.dart'; +import 'package:ttstd_family_care/core/widgets/feature_ui.dart'; +import 'package:ttstd_family_care/features/device/data/device_providers.dart'; +import 'package:ttstd_family_care/features/device/domain/device_models.dart'; +import 'package:ttstd_family_care/l10n/app_localizations.dart'; -import '../../../core/widgets/feature_ui.dart'; -import '../../../l10n/app_localizations.dart'; - -/// 应用页(管理 → 应用)。 -/// -/// 对应移动端设计稿:搜索栏 + 常用九宫格 + 系统工具/影音娱乐分类列表。 -class AppsPage extends StatelessWidget { +/// 应用列表页(对接 client「已安装应用」接口)。 +class AppsPage extends ConsumerWidget { const AppsPage({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - middle: Text(l10n.appsTitle), - leading: CupertinoButton( - padding: EdgeInsets.zero, - child: const Icon(CupertinoIcons.back), - onPressed: () => context.go('/messages'), + final sn = ref.watch(selectedDeviceSnProvider); + final appsAsync = sn == null + ? const AsyncValue>.data(const []) + : ref.watch(deviceApksProvider(sn)); + + return PopScope( + // 拦截系统返回键:优先出栈;若无法出栈则跳回「管理」页,避免直接退到桌面。 + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (context.canPop()) { + context.pop(); + } else { + context.go('/messages'); + } + }, + child: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text(l10n.appsTitle), + leading: CupertinoButton( + padding: EdgeInsets.zero, + child: const Icon(CupertinoIcons.back), + onPressed: () => context.pop(), + ), ), - trailing: CupertinoButton( - padding: EdgeInsets.zero, - child: const Text('管理'), - onPressed: () {}, + child: SafeArea( + child: appsAsync.when( + loading: () => const Center(child: CupertinoActivityIndicator()), + error: (e, _) => Center( + child: Text('${l10n.appsLoadFailed}:$e', + style: const TextStyle(color: AppColors.sub)), + ), + data: (apps) { + if (apps.isEmpty) { + return Center( + child: Text(l10n.appsEmpty, + style: const TextStyle(color: AppColors.sub)), + ); + } + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: apps.length, + itemBuilder: (context, index) { + final app = apps[index]; + return _appCell(context, ref, l10n, sn, app); + }, + ); + }, + ), ), ), - child: ListView( - padding: const EdgeInsets.only(bottom: 20), - children: [ - // 搜索栏 - Container( - margin: const EdgeInsets.fromLTRB(16, 10, 16, 4), - height: 40, - decoration: BoxDecoration( - color: CupertinoColors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: const [BoxShadow(color: Color(0x14141E3C), blurRadius: 8, offset: Offset(0, 2))], + ); + } + + Widget _appCell( + BuildContext context, + WidgetRef ref, + AppLocalizations l10n, + String? sn, + DeviceApkInfo app, + ) => + Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: CupertinoColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + color: Color(0x08000000), + blurRadius: 10, + offset: Offset(0, 4), ), - child: const Row( + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - SizedBox(width: 14), - Icon(CupertinoIcons.search, size: 16, color: AppColors.sub), - SizedBox(width: 8), - Text('搜索应用', style: TextStyle(fontSize: 14, color: AppColors.sub)), + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColors.blue.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: _appIcon(app.iconUrl), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(app.appName ?? app.packageName, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: AppColors.ink)), + const SizedBox(height: 4), + Text('${app.packageName}${app.versionName != null ? ' · v${app.versionName}' : ''}', + style: + const TextStyle(fontSize: 12, color: AppColors.sub), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], + ), + ), + if (app.systemApp) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.sub.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: const Text('系统', + style: TextStyle(fontSize: 11, color: AppColors.sub)), + ), ], ), - ), - SectionTitle(l10n.appsCommon), - CardBox( - child: Column( + const SizedBox(height: 12), + Row( children: [ - Row( - children: [ - Expanded(child: IconTile(emoji: '🖼️', color: AppColors.blue, label: l10n.menuAlbum)), - Expanded(child: IconTile(emoji: '⏰', color: AppColors.orange, label: l10n.menuAlarm)), - Expanded(child: IconTile(emoji: '👥', color: AppColors.green, label: l10n.menuContacts)), - Expanded(child: IconTile(emoji: '📁', color: AppColors.purple, label: l10n.menuFiles)), - ], - ), - const SizedBox(height: 14), - Row( - children: [ - Expanded(child: IconTile(emoji: '📷', color: AppColors.red, label: l10n.appsCamera)), - Expanded(child: IconTile(emoji: '📅', color: AppColors.teal, label: '日历')), - Expanded(child: IconTile(emoji: '📝', color: const Color(0xFF22C55E), label: '备忘录')), - Expanded(child: IconTile(emoji: '➕', color: AppColors.gray, label: l10n.appsAdd)), - ], + _actionButton(context, ref, l10n, sn, app, 'launch', + l10n.appOpLaunch), + const SizedBox(width: 8), + _actionButton(context, ref, l10n, sn, app, 'stop', + l10n.appOpStop), + const SizedBox(width: 8), + _actionButton(context, ref, l10n, sn, app, 'clear_data', + l10n.appOpClearData), + const SizedBox(width: 8), + _actionButton( + context, + ref, + l10n, + sn, + app, + 'uninstall', + l10n.appOpUninstall, + destructive: true, ), ], ), + ], + ), + ); + + /// 应用操作按钮:点击后执行对应操作;卸载/清除数据等危险操作先弹确认框。 + Widget _actionButton( + BuildContext context, + WidgetRef ref, + AppLocalizations l10n, + String? sn, + DeviceApkInfo app, + String op, + String label, { + bool destructive = false, + }) { + final Color color = destructive ? AppColors.red : AppColors.blue; + return Expanded( + child: GestureDetector( + onTap: () { + if (op == 'uninstall') { + _confirmAppOp(context, ref, l10n, sn, app, 'uninstall', + l10n.appOpUninstallConfirm); + } else if (op == 'clear_data') { + _confirmAppOp(context, ref, l10n, sn, app, 'clear_data', + l10n.appOpClearDataConfirm); + } else { + _runAppOp(context, ref, l10n, sn, app.packageName, op); + } + }, + child: Container( + height: 34, + alignment: Alignment.center, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), ), - SectionTitle(l10n.appsSystem), - CardBox( - padding: EdgeInsets.zero, - child: Column( - children: [ - _appRow('⚙️', const Color(0xFF64748B), '设置', '系统 · 已安装'), - const AppDivider(), - _appRow('🌐', AppColors.teal, '浏览器', '系统 · 已安装'), - const AppDivider(), - _appRow('🗺️', const Color(0xFF22C55E), '地图', '系统 · 已安装'), - ], + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: color, ), ), - SectionTitle(l10n.appsEntertain), - CardBox( - padding: EdgeInsets.zero, - child: Column( - children: [ - _appRow('🎵', AppColors.red, '音乐', 'v8.2 · 已安装'), - const AppDivider(), - _appRow('🎬', AppColors.orange, '视频', 'v5.1 · 已安装'), - const AppDivider(), - _appRow('📚', AppColors.purple, '阅读', 'v3.4 · 已安装'), - ], - ), + ), + ), + ); + } + + /// 危险应用操作(卸载/清除数据)确认弹窗。 + Future _confirmAppOp( + BuildContext context, + WidgetRef ref, + AppLocalizations l10n, + String? sn, + DeviceApkInfo app, + String op, + String message, + ) async { + final confirmed = await showCupertinoDialog( + context: context, + builder: (dialogContext) => CupertinoAlertDialog( + title: Text(l10n.appOpConfirmTitle), + content: Text(message), + actions: [ + CupertinoDialogAction( + child: Text(l10n.cancel), + onPressed: () => Navigator.pop(dialogContext, false), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: Text(l10n.confirm), + onPressed: () => Navigator.pop(dialogContext, true), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + await _runAppOp(context, ref, l10n, sn, app.packageName, op); + } + + /// 下发应用操作指令并弹出结果提示。 + Future _runAppOp( + BuildContext context, + WidgetRef ref, + AppLocalizations l10n, + String? sn, + String packageName, + String op, + ) async { + if (sn == null || sn.isEmpty) { + _showAppOpResult(context, l10n, l10n.opCmdFailed); + return; + } + final error = + await ref.read(deviceRepositoryProvider).sendAppOp(sn, op, packageName); + if (!context.mounted) return; + if (error == null) { + // 卸载成功后应用已不在列表,刷新应用列表。 + if (op == 'uninstall') { + ref.invalidate(deviceApksProvider(sn)); + } + _showAppOpResult(context, l10n, l10n.appOpSent); + } else { + _showAppOpResult(context, l10n, error); + } + } + + /// 应用操作结果提示弹窗。 + void _showAppOpResult( + BuildContext context, AppLocalizations l10n, String message) { + showCupertinoDialog( + context: context, + builder: (dialogContext) => CupertinoAlertDialog( + title: Text(l10n.alertTitle), + content: Text(message), + actions: [ + CupertinoDialogAction( + child: Text(l10n.confirm), + onPressed: () => Navigator.pop(dialogContext), ), ], ), ); } - Widget _appRow(String emoji, Color color, String name, String sub) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Row( - children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(13)), - child: Center(child: Text(emoji, style: const TextStyle(fontSize: 19))), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(name, style: const TextStyle(fontSize: 14.5, fontWeight: FontWeight.w600, color: AppColors.ink)), - Text(sub, style: const TextStyle(fontSize: 12, color: AppColors.sub)), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), - decoration: BoxDecoration( - color: const Color(0xFFE8F8EE), - borderRadius: BorderRadius.circular(14), - ), - child: const Text('打开', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.green)), - ), - ], - ), + /// 应用图标:后台返回的是相对路径(如 /static/app_icon/xxx.png), + /// 需要拼接接口 Host 才能作为网络图片加载;无图标或加载失败时回退到占位图标。 + Widget _appIcon(String? iconUrl) { + final resolved = _resolveIconUrl(iconUrl); + if (resolved == null) { + return const Center( + child: Icon(CupertinoIcons.app_fill, color: AppColors.blue, size: 22), ); + } + return Image.network( + resolved, + width: 44, + height: 44, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const Center( + child: Icon(CupertinoIcons.app_fill, color: AppColors.blue, size: 22), + ), + ); + } + + /// 将相对或绝对图标地址解析为完整可访问的 URL。 + String? _resolveIconUrl(String? iconUrl) { + if (iconUrl == null || iconUrl.isEmpty) return null; + if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) { + return iconUrl; + } + // 去除 /api 前缀,保留协议与 Host。 + final base = AppConstants.kBaseUrl.replaceFirst(RegExp(r'/api/?$'), ''); + return '$base$iconUrl'; + } } diff --git a/lib/features/auth/data/auth_api.dart b/lib/features/auth/data/auth_api.dart new file mode 100644 index 0000000..e0d077e --- /dev/null +++ b/lib/features/auth/data/auth_api.dart @@ -0,0 +1,95 @@ +import 'package:ttstd_family_care/core/network/dio_client.dart'; +import 'package:ttstd_family_care/features/auth/domain/auth_models.dart'; + +/// 客户端(家属端)认证接口访问层。 +/// +/// 对接后端 C 端(open 模块)认证接口。`AppConstants.kBaseUrl` 仅包含 `/api/` +/// 前缀,各端点在此显式追加 `v1/open/`(即 `/api/v1/open/**`)。open 模块账号 +/// 落地 `app_user`,与后台管理端(`sys_user`)物理分表。 +/// +/// [DioClient.get] / [DioClient.post] 已统一解析为后台返回体中的 `data` 字段; +/// 业务失败时抛出 [ApiException],由上层(Repository)按需处理。 +class AuthApi { + const AuthApi(); + + /// 账号密码登录。 + Future loginByPassword({ + required String username, + required String password, + }) async { + final data = await DioClient.post( + 'v1/open/login', + data: { + 'username': username, + 'password': password, + }, + ); + return LoginResult.fromJson(data as Map); + } + + /// 短信验证码登录。 + Future loginBySms({ + required String phone, + required String code, + }) async { + final data = await DioClient.post( + 'v1/open/auth/login/mobile', + data: { + 'mobile': phone, + 'code': code, + }, + ); + return LoginResult.fromJson(data as Map); + } + + /// 短信注册。 + Future register({ + required String phone, + required String code, + required String password, + }) async { + final data = await DioClient.post( + 'v1/open/register/mobile', + data: { + 'mobile': phone, + 'code': code, + 'password': password, + }, + ); + return LoginResult.fromJson(data as Map); + } + + /// 发送短信验证码。 + /// + /// 后端按场景拆分验证码发送接口(query 参数 mobile)。 + Future sendSmsCode({ + required String phone, + required SmsScene scene, + }) async { + final path = switch (scene) { + SmsScene.login => 'v1/open/auth/login/sms/code', + SmsScene.register => 'v1/open/auth/register/sms/code', + SmsScene.resetPassword => 'v1/open/reset-password/sms/code', + }; + await DioClient.post( + path, + queryParameters: {'mobile': phone}, + ); + } + + /// 重置密码(忘记密码流程:校验验证码后设置新密码)。 + Future resetPassword({ + required String phone, + required String code, + required String password, + }) async { + await DioClient.post( + 'v1/open/auth/reset-password', + data: { + 'mobile': phone, + 'code': code, + 'password': password, + }, + ); + } +} diff --git a/lib/features/auth/data/auth_providers.dart b/lib/features/auth/data/auth_providers.dart index 79fc9e1..5f58655 100644 --- a/lib/features/auth/data/auth_providers.dart +++ b/lib/features/auth/data/auth_providers.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../domain/auth_repository.dart'; +import 'auth_api.dart'; import 'auth_repository_impl.dart'; /// 认证仓库 Provider。 @@ -8,5 +9,6 @@ import 'auth_repository_impl.dart'; /// 会话级依赖,通过 ref.keepAlive() 避免页面切换时被销毁重建。 final authRepositoryProvider = Provider((ref) { ref.keepAlive(); - return const AuthRepositoryImpl(); + const api = AuthApi(); + return AuthRepositoryImpl(api); }); diff --git a/lib/features/auth/data/auth_repository_impl.dart b/lib/features/auth/data/auth_repository_impl.dart index 634eca0..f84301b 100644 --- a/lib/features/auth/data/auth_repository_impl.dart +++ b/lib/features/auth/data/auth_repository_impl.dart @@ -1,15 +1,16 @@ -import '../../../core/network/dio_client.dart'; import '../../../core/storage/token_storage.dart'; import '../domain/auth_models.dart'; import '../domain/auth_repository.dart'; +import 'auth_api.dart'; -/// 认证仓库实现(数据层)。 +/// [AuthRepository] 的默认实现,委托给 [AuthApi]。 /// -/// 对接后端 C 端(open 模块)认证接口,全部端点以 `/api/v1/open/auth/**` 为前缀 -/// (`AppConstants.kBaseUrl` 已包含该前缀)。open 模块账号落地 `app_user`, -/// 与后台管理端(`sys_user`)物理分表,令牌经 `TokenManager.generateToken` 本地签发。 +/// 负责网络层([DioClient])解耦之外的业务编排:登录/注册成功后将令牌落库 +/// ([TokenStorage]),UI 仅依赖领域层抽象。 class AuthRepositoryImpl implements AuthRepository { - const AuthRepositoryImpl(); + const AuthRepositoryImpl(this._api); + + final AuthApi _api; @override Future loginByPassword({ @@ -17,14 +18,10 @@ class AuthRepositoryImpl implements AuthRepository { required String password, required String deviceId, }) async { - final data = await DioClient.post( - '/login', - data: { - 'username': phone, - 'password': password, - }, + final result = await _api.loginByPassword( + username: phone, + password: password, ); - final result = LoginResult.fromJson(data as Map); TokenStorage.saveTokens( token: result.token, refreshToken: result.refreshToken, @@ -38,14 +35,10 @@ class AuthRepositoryImpl implements AuthRepository { required String code, required String deviceId, }) async { - final data = await DioClient.post( - 'auth/login/mobile', - data: { - 'mobile': phone, - 'code': code, - }, + final result = await _api.loginBySms( + phone: phone, + code: code, ); - final result = LoginResult.fromJson(data as Map); TokenStorage.saveTokens( token: result.token, refreshToken: result.refreshToken, @@ -60,15 +53,11 @@ class AuthRepositoryImpl implements AuthRepository { required String password, required String deviceId, }) async { - final data = await DioClient.post( - '/register/mobile', - data: { - 'mobile': phone, - 'code': code, - 'password': password, - }, + final result = await _api.register( + phone: phone, + code: code, + password: password, ); - final result = LoginResult.fromJson(data as Map); TokenStorage.saveTokens( token: result.token, refreshToken: result.refreshToken, @@ -80,32 +69,14 @@ class AuthRepositoryImpl implements AuthRepository { Future sendSmsCode({ required String phone, required SmsScene scene, - }) async { - // 后端按场景拆分验证码发送接口(query 参数 mobile) - final path = switch (scene) { - SmsScene.login => 'auth/login/sms/code', - SmsScene.register => 'auth/register/sms/code', - SmsScene.resetPassword => '/reset-password/sms/code', - }; - await DioClient.post( - path, - queryParameters: {'mobile': phone}, - ); - } + }) => + _api.sendSmsCode(phone: phone, scene: scene); @override Future resetPassword({ required String phone, required String code, required String password, - }) async { - await DioClient.post( - '/reset-password', - data: { - 'mobile': phone, - 'code': code, - 'password': password, - }, - ); - } + }) => + _api.resetPassword(phone: phone, code: code, password: password); } diff --git a/lib/features/auth/presentation/forgot_password_page.dart b/lib/features/auth/presentation/forgot_password_page.dart index ed67900..c691273 100644 --- a/lib/features/auth/presentation/forgot_password_page.dart +++ b/lib/features/auth/presentation/forgot_password_page.dart @@ -77,9 +77,15 @@ class _ForgotPasswordPageState extends ConsumerState { final l10n = AppLocalizations.of(context); return PopScope( - canPop: true, + // 拦截系统返回键:优先出栈;若无法出栈则跳回登录页。 + canPop: false, onPopInvokedWithResult: (didPop, _) { - if (!didPop) context.pop(); + if (didPop) return; + if (context.canPop()) { + context.pop(); + } else { + context.go('/login'); + } }, child: CupertinoPageScaffold( backgroundColor: CupertinoColors.systemGroupedBackground, @@ -168,13 +174,13 @@ class _ForgotPasswordPageState extends ConsumerState { ' title=${l10n.alertTitle} message=$message'); showCupertinoDialog( context: context, - builder: (_) => CupertinoAlertDialog( + builder: (dialogContext) => CupertinoAlertDialog( title: Text(l10n.alertTitle), content: Text(message), actions: [ CupertinoDialogAction( child: Text(l10n.confirm), - onPressed: () => Navigator.of(context).pop(), + onPressed: () => Navigator.of(dialogContext).pop(), ), ], ), diff --git a/lib/features/auth/presentation/login_page.dart b/lib/features/auth/presentation/login_page.dart index 9d9b127..243bdd7 100644 --- a/lib/features/auth/presentation/login_page.dart +++ b/lib/features/auth/presentation/login_page.dart @@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart'; import 'package:go_router/go_router.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/utils/system_ui_util.dart'; import '../../../app/theme/app_theme.dart'; import '../../../l10n/app_localizations.dart'; import '../domain/auth_models.dart'; @@ -50,18 +51,18 @@ class _LoginPageState extends ConsumerState { ' title=${l10n.exitConfirmTitle} content=${l10n.exitConfirmContent}'); return showCupertinoDialog( context: context, - builder: (_) => CupertinoAlertDialog( + builder: (dialogContext) => CupertinoAlertDialog( title: Text(l10n.exitConfirmTitle), content: Text(l10n.exitConfirmContent), actions: [ CupertinoDialogAction( child: Text(l10n.cancel), - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => Navigator.of(dialogContext).pop(false), ), CupertinoDialogAction( isDestructiveAction: true, child: Text(l10n.exitApp), - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => Navigator.of(dialogContext).pop(true), ), ], ), @@ -88,13 +89,13 @@ class _LoginPageState extends ConsumerState { ' title=${l10n.alertTitle} message=$message'); showCupertinoDialog( context: context, - builder: (_) => CupertinoAlertDialog( + builder: (dialogContext) => CupertinoAlertDialog( title: Text(l10n.alertTitle), content: Text(message), actions: [ CupertinoDialogAction( child: Text(l10n.confirm), - onPressed: () => Navigator.of(context).pop(), + onPressed: () => Navigator.of(dialogContext).pop(), ), ], ), @@ -122,7 +123,10 @@ class _LoginPageState extends ConsumerState { canPop: false, onPopInvokedWithResult: (didPop, _) async { if (didPop) return; - await _onWillPop(); + final shouldExit = await _showExitConfirm(); + if (shouldExit == true) { + await SystemUiUtil.moveToBack(); + } }, child: CupertinoPageScaffold( backgroundColor: CupertinoColors.white, diff --git a/lib/features/auth/presentation/register_page.dart b/lib/features/auth/presentation/register_page.dart index 1f58fbb..120ece5 100644 --- a/lib/features/auth/presentation/register_page.dart +++ b/lib/features/auth/presentation/register_page.dart @@ -52,9 +52,15 @@ class _RegisterPageState extends ConsumerState { final l10n = AppLocalizations.of(context); return PopScope( - canPop: true, + // 拦截系统返回键:优先出栈;若无法出栈则跳回登录页。 + canPop: false, onPopInvokedWithResult: (didPop, _) { - if (!didPop) context.pop(); + if (didPop) return; + if (context.canPop()) { + context.pop(); + } else { + context.go('/login'); + } }, child: CupertinoPageScaffold( backgroundColor: CupertinoColors.systemGroupedBackground, @@ -144,13 +150,13 @@ class _RegisterPageState extends ConsumerState { ' title=${l10n.alertTitle} message=$message'); showCupertinoDialog( context: context, - builder: (_) => CupertinoAlertDialog( + builder: (dialogContext) => CupertinoAlertDialog( title: Text(l10n.alertTitle), content: Text(message), actions: [ CupertinoDialogAction( child: Text(l10n.confirm), - onPressed: () => Navigator.of(context).pop(), + onPressed: () => Navigator.of(dialogContext).pop(), ), ], ), diff --git a/lib/features/contacts/presentation/contacts_page.dart b/lib/features/contacts/presentation/contacts_page.dart index 4477c97..af23cf9 100644 --- a/lib/features/contacts/presentation/contacts_page.dart +++ b/lib/features/contacts/presentation/contacts_page.dart @@ -13,21 +13,32 @@ class ContactsPage extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); - return CupertinoPageScaffold( - navigationBar: CupertinoNavigationBar( - middle: Text(l10n.contactsTitle), - leading: CupertinoButton( - padding: EdgeInsets.zero, - child: const Icon(CupertinoIcons.back), - onPressed: () => context.go('/messages'), + return PopScope( + // 拦截系统返回键:优先出栈;若无法出栈则跳回「管理」页,避免直接退到桌面。 + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (context.canPop()) { + context.pop(); + } else { + context.go('/messages'); + } + }, + child: CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text(l10n.contactsTitle), + leading: CupertinoButton( + padding: EdgeInsets.zero, + child: const Icon(CupertinoIcons.back), + onPressed: () => context.pop(), + ), + trailing: CupertinoButton( + padding: EdgeInsets.zero, + child: const Icon(CupertinoIcons.add, size: 26), + onPressed: () {}, + ), ), - trailing: CupertinoButton( - padding: EdgeInsets.zero, - child: const Icon(CupertinoIcons.add, size: 26), - onPressed: () {}, - ), - ), - child: Stack( + child: Stack( children: [ ListView( padding: const EdgeInsets.only(bottom: 20), @@ -87,6 +98,7 @@ class ContactsPage extends StatelessWidget { ), ), ], + ), ), ); } diff --git a/lib/features/device/data/device_api.dart b/lib/features/device/data/device_api.dart new file mode 100644 index 0000000..d16c9fd --- /dev/null +++ b/lib/features/device/data/device_api.dart @@ -0,0 +1,163 @@ +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:ttstd_family_care/app/constants/app_constants.dart'; +import 'package:ttstd_family_care/core/network/api_exception.dart'; +import 'package:ttstd_family_care/core/network/dio_client.dart'; +import 'package:ttstd_family_care/features/device/domain/device_models.dart'; + +/// 客户端(家属端)设备接口访问层。 +/// +/// 后端接口前缀为 `/api/v1/client/sn`(与登录接口所在的 `/api/v1/open` 不同), +/// 由于 Dio 在 path 以 `/` 开头时会替换 baseUrl 的完整 path 段(保留 host), +/// 这里直接使用绝对路径即可正确命中 client 接口。 +/// [DioClient.get] 已统一解析为后台返回体中的 `data` 字段;业务失败时抛出 +/// [ApiException],本层将其降级为「空数据」以适配家属端展示。 +class DeviceApi { + const DeviceApi(); + + /// 获取当前用户已绑定的设备列表。 + Future> getMyDevices() async { + try { + final data = await DioClient.get( + 'v1/client/sn/my-devices', + ); + return _parseList(data, DeviceBrief.fromJson); + } on ApiException { + return const []; + } + } + + /// 获取已绑定设备基本信息(无上报数据时返回 null)。 + Future getDeviceInfo(String sn) async { + try { + final data = await DioClient.get( + 'v1/client/sn/device-info', + queryParameters: {'sn': sn}, + ); + return data is Map ? DeviceSystemInfo.fromJson(data) : null; + } on ApiException { + return null; + } + } + + /// 获取已绑定设备最新定位信息(无上报数据时返回 null)。 + Future getLocation(String sn) async { + try { + final data = await DioClient.get( + 'v1/client/sn/location', + queryParameters: {'sn': sn}, + ); + return data is Map ? DeviceLocation.fromJson(data) : null; + } on ApiException { + return null; + } + } + + /// 获取已绑定设备已安装应用列表。 + Future> getApks(String sn) async { + try { + final data = await DioClient.get( + 'v1/client/sn/apks', + queryParameters: {'sn': sn}, + ); + return _parseList(data, DeviceApkInfo.fromJson); + } on ApiException { + return const []; + } + } + + /// 向设备下发操作指令(重启/关机/截屏/刷新/定位等)。 + /// + /// [op] 为后端操作标识,如 `reboot` / `shutdown` / `screenshot` / `refresh` / `locate`, + /// 对应接口路径 `/api/v1/client/sn/ops/{op}?sn=xxx`。 + /// 成功返回 null;失败返回后台错误提示。 + Future sendDeviceOp(String sn, String op) async { + try { + final data = await DioClient.post( + 'v1/client/sn/ops/$op', + queryParameters: {'sn': sn}, + ); + debugPrint('[DeviceApi] sendDeviceOp op=$op sn=$sn data=$data'); + return null; + } on ApiException catch (e) { + debugPrint('[DeviceApi] sendDeviceOp op=$op sn=$sn failed: ${e.message}'); + return e.message; + } + } + + /// 对指定应用执行操作(打开/停止/卸载/清除数据)。 + /// + /// [op] 为后端操作标识:`launch` / `stop` / `uninstall` / `clear_data`, + /// 对应接口路径 `/api/v1/client/sn/ops/app/{op}?sn=xxx`,请求体携带应用包名。 + /// 成功返回 null;失败返回后台错误提示。 + Future sendAppOp(String sn, String op, String packageName) async { + try { + final data = await DioClient.post( + 'v1/client/sn/ops/app/$op', + queryParameters: {'sn': sn}, + data: {'packageName': packageName}, + ); + debugPrint('[DeviceApi] sendAppOp op=$op sn=$sn pkg=$packageName data=$data'); + return null; + } on ApiException catch (e) { + debugPrint('[DeviceApi] sendAppOp op=$op sn=$sn pkg=$packageName failed: ${e.message}'); + return e.message; + } + } + + // --------------------------------------------------------------------------- + + static List _parseList( + dynamic data, + T Function(Map) fromJson, + ) { + if (data is! List) return const []; + return data + .whereType>() + .map(fromJson) + .toList(growable: false); + } + + // --------------------------------------------------------------------------- + + /// 获取设备最近截图列表(家属端)。 + /// + /// [sn] 为设备序列号(可空,由后台按绑定关系补全)。 + /// 返回 [ScreenshotVO] 列表,并自动补全图片可访问的完整 URL(拼接 baseUrl)。 + Future> getRecentScreenshots({String? sn}) async { + final query = sn != null && sn.isNotEmpty ? {'sn': sn} : {}; + // DioClient.get 已统一解析出响应体中的 data 字段, + // 后台返回结构为 { code, msg, data: [...] },故此处 payload 即为截图数组。 + final payload = await DioClient.get( + 'v1/client/sn/upload-screenshot', + queryParameters: query, + ); + // 防御性提取:兼容后台直接返回 List,或返回 { data: [...] } 的包裹结构。 + final List listData; + if (payload is List) { + listData = payload; + } else if (payload is Map && payload['data'] is List) { + listData = payload['data'] as List; + } else { + debugPrint('[DeviceApi] getRecentScreenshots 返回非预期结构: sn=$sn payload=$payload'); + return const []; + } + debugPrint('[DeviceApi] getRecentScreenshots sn=$sn count=${listData.length}'); + final origin = Uri.parse(AppConstants.kBaseUrl).origin; + return listData.whereType>().map((e) { + final vo = ScreenshotVO.fromJson(e); + // 后台返回的 url 为相对路径(如 /static/screenshot/xxx),补全为完整地址。 + final raw = vo.url; + final fullUrl = (raw != null && raw.startsWith('/')) + ? '$origin$raw' + : (raw ?? ''); + return ScreenshotVO( + id: vo.id, + sn: vo.sn, + fileName: vo.fileName, + fileSize: vo.fileSize, + url: fullUrl, + uploadTime: vo.uploadTime, + ); + }).toList(growable: false); + } +} diff --git a/lib/features/device/data/device_providers.dart b/lib/features/device/data/device_providers.dart new file mode 100644 index 0000000..fe075ff --- /dev/null +++ b/lib/features/device/data/device_providers.dart @@ -0,0 +1,61 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:ttstd_family_care/features/device/data/device_api.dart'; +import 'package:ttstd_family_care/features/device/data/device_repository_impl.dart'; +import 'package:ttstd_family_care/features/device/domain/device_models.dart'; +import 'package:ttstd_family_care/features/device/domain/device_repository.dart'; + +/// 设备仓储 Provider。 +final deviceRepositoryProvider = Provider((ref) { + const api = DeviceApi(); + return DeviceRepositoryImpl(api); +}); + +/// 当前用户已绑定设备列表。 +final myDevicesProvider = FutureProvider>((ref) async { + final repo = ref.watch(deviceRepositoryProvider); + return repo.getMyDevices(); +}); + +/// 当前选中的设备序列号(默认取绑定列表首个设备)。 +/// +/// 使用普通 Provider(而非 StateProvider)以便随 [myDevicesProvider] 的加载完成 +/// 自动推导出首个已绑定设备的 SN。若后续需要手动切换设备,可改为 StateProvider。 +final selectedDeviceSnProvider = Provider((ref) { + final devices = ref.watch(myDevicesProvider); + return devices.maybeWhen( + data: (list) => list.isEmpty ? null : list.first.serialno, + orElse: () => null, + ); +}); + +/// 按 SN 获取设备基本信息。 +final deviceInfoProvider = + FutureProvider.family((ref, sn) async { + if (sn.isEmpty) return null; + final repo = ref.watch(deviceRepositoryProvider); + return repo.getDeviceInfo(sn); +}); + +/// 按 SN 获取设备最新定位信息。 +final deviceLocationProvider = + FutureProvider.family((ref, sn) async { + if (sn.isEmpty) return null; + final repo = ref.watch(deviceRepositoryProvider); + return repo.getLocation(sn); +}); + +/// 按 SN 获取设备已安装应用列表。 +final deviceApksProvider = + FutureProvider.family, String>((ref, sn) async { + if (sn.isEmpty) return const []; + final repo = ref.watch(deviceRepositoryProvider); + return repo.getApks(sn); +}); + +/// 按 SN 获取设备最近截图列表。 +final recentScreenshotsProvider = + FutureProvider.family, String>((ref, sn) async { + if (sn.isEmpty) return const []; + final repo = ref.watch(deviceRepositoryProvider); + return repo.getRecentScreenshots(sn); +}); diff --git a/lib/features/device/data/device_repository_impl.dart b/lib/features/device/data/device_repository_impl.dart new file mode 100644 index 0000000..f73e87c --- /dev/null +++ b/lib/features/device/data/device_repository_impl.dart @@ -0,0 +1,35 @@ +import 'package:ttstd_family_care/features/device/data/device_api.dart'; +import 'package:ttstd_family_care/features/device/domain/device_models.dart'; +import 'package:ttstd_family_care/features/device/domain/device_repository.dart'; + +/// [DeviceRepository] 的默认实现,委托给 [DeviceApi]。 +class DeviceRepositoryImpl implements DeviceRepository { + const DeviceRepositoryImpl(this._api); + + final DeviceApi _api; + + + @override + Future> getMyDevices() => _api.getMyDevices(); + + @override + Future getDeviceInfo(String sn) => _api.getDeviceInfo(sn); + + @override + Future getLocation(String sn) => _api.getLocation(sn); + + @override + Future> getApks(String sn) => _api.getApks(sn); + + @override + Future> getRecentScreenshots(String sn) => + _api.getRecentScreenshots(sn: sn); + + @override + Future sendDeviceOp(String sn, String op) => + _api.sendDeviceOp(sn, op); + + @override + Future sendAppOp(String sn, String op, String packageName) => + _api.sendAppOp(sn, op, packageName); +} diff --git a/lib/features/device/domain/device_models.dart b/lib/features/device/domain/device_models.dart new file mode 100644 index 0000000..f6007a4 --- /dev/null +++ b/lib/features/device/domain/device_models.dart @@ -0,0 +1,256 @@ +/// 设备相关领域实体(家属端对接 client 接口)。 +/// +/// 以不可变类表达,避免 UI / 仓库直接依赖后台 DTO 结构。 +/// 因未运行 build_runner(避免改动依赖与生成产物),此处手写等价实现: +/// 所有字段 final、提供 const 构造与 copyWith,并手动实现 JSON 转换。 + +/// 设备简要信息(「我的设备」列表)。 +class DeviceBrief { + const DeviceBrief({ + required this.serialno, + this.snName, + this.snModel, + this.snMobile, + this.status, + this.activateTime, + }); + + final String serialno; + final String? snName; + final String? snModel; + final String? snMobile; + final int? status; + final String? activateTime; + + factory DeviceBrief.fromJson(Map json) => DeviceBrief( + serialno: json['serialno'] as String, + snName: json['snName'] as String?, + snModel: json['snModel'] as String?, + snMobile: json['snMobile'] as String?, + status: _toInt(json['status']), + activateTime: json['activateTime']?.toString(), + ); +} + +/// 设备基本信息(系统信息面板)。 +class DeviceSystemInfo { + const DeviceSystemInfo({ + this.serialno, + this.deviceName, + this.deviceBrand, + this.deviceModel, + this.androidVersion, + this.releaseVersion, + this.screenResolution, + this.osType, + this.osVersion, + this.firmwareVersion, + }); + + final String? serialno; + final String? deviceName; + final String? deviceBrand; + final String? deviceModel; + final String? androidVersion; + final String? releaseVersion; + final String? screenResolution; + final String? osType; + final String? osVersion; + final String? firmwareVersion; + + factory DeviceSystemInfo.fromJson(Map json) => DeviceSystemInfo( + serialno: json['serialno'] as String?, + deviceName: json['deviceName'] as String?, + deviceBrand: json['deviceBrand'] as String?, + deviceModel: json['deviceModel'] as String?, + androidVersion: json['androidVersion'] as String?, + releaseVersion: json['releaseVersion'] as String?, + screenResolution: json['screenResolution'] as String?, + osType: json['osType'] as String?, + osVersion: json['osVersion'] as String?, + firmwareVersion: json['firmwareVersion'] as String?, + ); +} + +/// 设备定位信息。 +class DeviceLocation { + const DeviceLocation({ + this.sn, + this.country, + this.province, + this.city, + this.district, + this.street, + this.address, + this.locationDescribe, + this.longitude, + this.latitude, + this.lastSuccessfulTime, + }); + + final String? sn; + final String? country; + final String? province; + final String? city; + final String? district; + final String? street; + final String? address; + final String? locationDescribe; + final String? longitude; + final String? latitude; + final String? lastSuccessfulTime; + + factory DeviceLocation.fromJson(Map json) => DeviceLocation( + sn: json['sn'] as String?, + country: json['country'] as String?, + province: json['province'] as String?, + city: json['city'] as String?, + district: json['district'] as String?, + street: json['street'] as String?, + address: json['address'] as String?, + locationDescribe: json['locationDescribe'] as String?, + longitude: _toDoubleString(json['longitude']), + latitude: _toDoubleString(json['latitude']), + lastSuccessfulTime: json['lastSuccessfulTime']?.toString(), + ); + + /// 拼接展示用地址:省+市+区+街道+详细地址。 + String get displayAddress { + final parts = [ + province, + city, + district, + street, + address, + ].where((e) => e != null && e.isNotEmpty); + return parts.join(''); + } +} + +/// 设备已安装应用信息。 +class DeviceApkInfo { + const DeviceApkInfo({ + required this.packageName, + this.appName, + this.versionName, + this.versionCode, + this.installTime, + this.lastUpdateTime, + this.apkSize, + this.systemApp = false, + this.iconUrl, + }); + + final String packageName; + final String? appName; + final String? versionName; + final int? versionCode; + final String? installTime; + final String? lastUpdateTime; + final int? apkSize; + final bool systemApp; + final String? iconUrl; + + factory DeviceApkInfo.fromJson(Map json) => DeviceApkInfo( + packageName: json['packageName'] as String, + appName: json['appName'] as String?, + versionName: json['versionName'] as String?, + versionCode: _toInt(json['versionCode']), + installTime: json['installTime']?.toString(), + lastUpdateTime: json['lastUpdateTime']?.toString(), + apkSize: _toInt(json['apkSize']), + systemApp: json['systemApp'] as bool? ?? false, + iconUrl: json['iconUrl'] as String?, + ); +} + +/// 设备截图实体(与后台 SnScreenshot 对齐)。 +class SnScreenshot { + const SnScreenshot({ + this.id, + this.sn, + this.fileName, + this.filePath, + this.fileSize, + this.fileMd5, + this.fileSha1, + this.fileSha256, + this.uploadTime, + }); + + final int? id; + final String? sn; + final String? fileName; + final String? filePath; + final int? fileSize; + final String? fileMd5; + final String? fileSha1; + final String? fileSha256; + final String? uploadTime; + + factory SnScreenshot.fromJson(Map json) => SnScreenshot( + id: _toInt(json['id']), + sn: json['sn'] as String?, + fileName: json['fileName'] as String?, + filePath: json['filePath'] as String?, + fileSize: _toInt(json['fileSize']), + fileMd5: json['fileMd5'] as String?, + fileSha1: json['fileSha1'] as String?, + fileSha256: json['fileSha256'] as String?, + uploadTime: json['uploadTime']?.toString(), + ); +} + +/// 设备截图视图对象(与后台 ScreenshotVO 对齐)。 +class ScreenshotVO { + const ScreenshotVO({ + this.id, + this.sn, + this.fileName, + this.fileSize, + this.url, + this.uploadTime, + }); + + final int? id; + final String? sn; + final String? fileName; + final int? fileSize; + final String? url; + final String? uploadTime; + + factory ScreenshotVO.fromJson(Map json) => ScreenshotVO( + id: _toInt(json['id']), + sn: json['sn'] as String?, + fileName: json['fileName'] as String?, + fileSize: _toInt(json['fileSize']), + url: json['url'] as String?, + uploadTime: json['uploadTime']?.toString(), + ); +} + +/// 容错整型解析:后台可能以字符串(如 "123")或数字返回整型字段, +/// 直接用 `as int?` 在字符串场景下会抛出 +/// `type 'String' is not a subtype of type 'int'`,故统一在此转换。 +int? _toInt(dynamic value) { + if (value == null) return null; + if (value is int) return value; + if (value is num) return value.toInt(); + if (value is String) return int.tryParse(value); + return null; +} + +/// 容错浮点解析(用于经纬度等带小数的字段),返回字符串形式便于上层 `double.tryParse`。 +/// +/// 经纬度必须保留小数精度: +/// - 后台若以数字(如 `116.404`)返回,直接截断成整数会偏移数公里; +/// - 后台若以字符串(如 `"39.915"`)返回,`int.tryParse` 会解析失败变成 null。 +String? _toDoubleString(dynamic value) { + if (value == null) return null; + if (value is num) return value.toString(); + if (value is String) { + final parsed = double.tryParse(value); + return parsed?.toString(); + } + return null; +} diff --git a/lib/features/device/domain/device_repository.dart b/lib/features/device/domain/device_repository.dart new file mode 100644 index 0000000..871c969 --- /dev/null +++ b/lib/features/device/domain/device_repository.dart @@ -0,0 +1,31 @@ +import 'package:ttstd_family_care/features/device/domain/device_models.dart'; + +/// 设备仓储,封装设备信息/定位/应用列表/绑定设备列表的获取。 +abstract class DeviceRepository { + /// 当前用户已绑定的设备列表。 + Future> getMyDevices(); + + /// 已绑定设备基本信息(无上报数据时返回 null)。 + Future getDeviceInfo(String sn); + + /// 已绑定设备最新定位信息(无上报数据时返回 null)。 + Future getLocation(String sn); + + /// 已绑定设备已安装应用列表。 + Future> getApks(String sn); + + /// 已绑定设备最近截图列表。 + Future> getRecentScreenshots(String sn); + + /// 向设备下发操作指令(重启/关机/截屏/刷新/定位等)。 + /// + /// [op] 为后端操作标识,如 `reboot` / `shutdown` / `screenshot` / `refresh` / `locate`。 + /// 成功返回 null;失败返回错误提示。 + Future sendDeviceOp(String sn, String op); + + /// 对指定应用执行操作(打开/停止/卸载/清除数据)。 + /// + /// [op] 为后端操作标识:`launch` / `stop` / `uninstall` / `clear_data`。 + /// 成功返回 null;失败返回错误提示。 + Future sendAppOp(String sn, String op, String packageName); +} diff --git a/lib/features/home/data/location_controller.dart b/lib/features/home/data/location_controller.dart new file mode 100644 index 0000000..bf6509e --- /dev/null +++ b/lib/features/home/data/location_controller.dart @@ -0,0 +1,225 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_bmflocation/flutter_bmflocation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../../app/constants/app_constants.dart'; + +/// 家属端手机本机定位结果。 +/// +/// 来自百度定位插件 `flutter_bmflocation`,坐标为百度 BD09LL +/// (与地图 SDK 坐标系一致,可直接用于 `BMFMapController.updateLocationData`)。 +class DeviceCurrentLocation { + const DeviceCurrentLocation({ + required this.latitude, + required this.longitude, + this.radius, + this.address, + this.altitude, + this.speed, + this.course, + }); + + final double latitude; + final double longitude; + + /// 定位精度(米,Android 返回)。 + final double? radius; + + /// 反地理编码地址。 + final String? address; + + /// 海拔(米)。 + final double? altitude; + + /// 速度(m/s)。 + final double? speed; + + /// 方向角(度,正北为 0,顺时针)。 + final double? course; + + factory DeviceCurrentLocation.fromBaidu(BaiduLocation src) => + DeviceCurrentLocation( + latitude: src.latitude ?? 0, + longitude: src.longitude ?? 0, + radius: src.radius, + address: src.address, + altitude: src.altitude, + speed: src.speed, + course: src.course, + ); +} + +/// 本机定位状态。 +sealed class LocationState { + const LocationState(); +} + +/// 定位初始化中 / 尚未定位成功。 +class LocationIdle extends LocationState { + const LocationIdle(); +} + +/// 定位成功。 +class LocationSuccess extends LocationState { + const LocationSuccess(this.location); + final DeviceCurrentLocation location; +} + +/// 定位失败。 +class LocationFailed extends LocationState { + const LocationFailed(this.message); + final String message; +} + +/// 家属端手机本机定位控制器。 +/// +/// 封装百度定位插件 `LocationFlutterPlugin`: +/// - 构建时先请求运行时定位权限(Android 6.0+ / iOS 动态授权), +/// 授权成功后才初始化并启动连续定位。 +/// - 坐标类型设为 BD09LL(与地图 SDK 一致),无需二次坐标转换。 +/// - 全流程输出 [debugPrint] 日志,便于排查定位失败原因。 +/// - Provider 被 dispose(如地图页退出)时自动停止定位并释放插件。 +class LocationController extends Notifier { + LocationFlutterPlugin? _plugin; + bool _started = false; + + @override + LocationState build() { + _started = false; + _plugin = null; + // Provider 被首次 watch 时自动启动本机定位。 + _init(); + // Provider 被 dispose(如地图页退出)时自动停止定位并释放插件。 + ref.onDispose(_teardown); + return const LocationIdle(); + } + + /// 初始化:请求定位权限 -> 初始化定位插件 -> 启动连续定位。 + Future _init() async { + if (_started) return; + _started = true; + + // 1. 请求运行时定位权限。 + final granted = await _ensureLocationPermission(); + if (!granted) { + state = const LocationFailed('定位权限被拒绝,请到系统设置开启'); + return; + } + + // 2. 初始化定位插件。 + final plugin = _plugin ??= LocationFlutterPlugin(); + + // 同意隐私(百度定位 SDK 要求)。 + // ⚠️ 该插件 Android 原生端对 setAgreePrivacy 从不返回 MethodChannel 结果 + // (onMethodCall 里仅调用 LocationClient.setAgreePrivacy,未调用 result.success, + // 且 dispatchMethodHandler 的 switch 对该 method 落入 default,永不回调)。 + // 直接 await 会永久挂起,导致后续 prepareLoc/startLocation 不执行、定位无输出。 + // 故加超时保护:超时视为已同意,继续走定位流程。 + try { + await plugin.setAgreePrivacy(true).timeout( + const Duration(seconds: 2), + onTimeout: () => false, + ); + } catch (_) {} + debugPrint('[Location] setAgreePrivacy done'); + + // authAK 仅对 iOS/鸿蒙生效,Android 复用 Manifest 中的 AK,不调用。 + if (!kIsWeb && (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS)) { + final authed = await plugin.authAK(AppConstants.kBaiduMapAk); + debugPrint('[Location] authAK(${AppConstants.kBaiduMapAk}) => $authed'); + if (!_started) return; + } else { + debugPrint('[Location] 跳过 authAK(Android 使用 Manifest AK)'); + } + + // 3. 注册连续定位回调,把最新结果写入 state。 + plugin.seriesLocationCallback(callback: (result) { + _handleLocationResult(result); + }); + + // 4. 设置 Android / iOS 定位参数:BD09LL、高精度、需要地址信息。 + final androidMap = BaiduLocationAndroidOption( + coordType: BMFLocationCoordType.bd09ll, + locationMode: BMFLocationMode.hightAccuracy, + isNeedAddress: true, + isNeedLocationDescribe: true, + scanspan: 1000, + ).getMap(); + final iosMap = BaiduLocationIOSOption( + coordType: BMFLocationCoordType.bd09ll, + desiredAccuracy: BMFDesiredAccuracy.best, + isNeedNewVersionRgc: true, + ).getMap(); + final prepared = await plugin.prepareLoc(androidMap, iosMap, const {}); + debugPrint('[Location] prepareLoc => $prepared'); + if (!_started) return; + + final started = await plugin.startLocation(); + debugPrint('[Location] startLocation => $started'); + } + + /// 请求运行时定位权限(WhenInUse 足够前台展示地图定位点)。 + Future _ensureLocationPermission() async { + PermissionStatus status = await Permission.locationWhenInUse.status; + debugPrint('[Location] permission.status => ${status.name}'); + if (status.isGranted) { + return true; + } + if (status.isPermanentlyDenied || status.isRestricted) { + debugPrint('[Location] permission permanentlyDenied/restricted,需到系统设置开启'); + // 打开应用设置引导用户手动开启。 + await openAppSettings(); + return false; + } + status = await Permission.locationWhenInUse.request(); + debugPrint('[Location] permission.request => ${status.name}'); + return status.isGranted; + } + + /// 处理定位回调结果。 + void _handleLocationResult(BaiduLocation result) { + final lat = result.latitude; + final lon = result.longitude; + debugPrint('[Location] onResult latitude=$lat longitude=$lon ' + 'radius=${result.radius} address=${result.address} ' + 'detail=${result.locationDetail} errorInfo=${result.errorInfo} ' + 'errorCode=${result.errorCode} locType=${result.locType}'); + if (lat == null || lon == null) { + state = LocationFailed(result.errorInfo ?? '定位失败'); + return; + } + state = LocationSuccess(DeviceCurrentLocation.fromBaidu(result)); + // 定位已有有效结果且非错误,主动停止连续定位,避免常驻耗电。 + stop(); + } + + /// 停止连续定位(保留插件实例,可再次 [startLocation])。 + /// + /// 本机定位拿到首个有效结果后即停止,符合「有结果且非定位错误就停止定位」的约束。 + void stop() { + if (!_started) return; + debugPrint('[Location] stop: stopLocation'); + _started = false; + _plugin?.stopLocation(); + } + + /// 停止定位并释放插件。 + void _teardown() { + if (!_started) return; + debugPrint('[Location] teardown: stopLocation'); + _started = false; + _plugin?.stopLocation(); + _plugin = null; + } +} + +/// 家属端手机本机定位 Provider。 +/// +/// 页面级使用:地图页 watch 时自动启动定位,页面销毁时自动停止, +/// 避免常驻定位导致电量与资源浪费(符合项目 keepAlive 约束,不设 keepAlive)。 +final locationControllerProvider = + NotifierProvider( + LocationController.new, +); diff --git a/lib/features/home/presentation/home_page.dart b/lib/features/home/presentation/home_page.dart index c7a3e18..3108abc 100644 --- a/lib/features/home/presentation/home_page.dart +++ b/lib/features/home/presentation/home_page.dart @@ -2,6 +2,7 @@ import 'package:cupertino_ui/cupertino_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import '../../../core/utils/system_ui_util.dart'; import '../../../l10n/app_localizations.dart'; /// 首页 Shell(含底部 Tab:首页 / 管理 / 我的)。 @@ -24,32 +25,51 @@ class HomePage extends ConsumerWidget { _ => 0, }; - return CupertinoPageScaffold( - child: Stack( - children: [ - child, - Align( - alignment: Alignment.bottomCenter, - child: CupertinoTabBar( - currentIndex: index, - onTap: (i) => _onTabTap(context, i), - items: [ - BottomNavigationBarItem( - icon: const Icon(CupertinoIcons.home), - label: l10n.tabHome, - ), - BottomNavigationBarItem( - icon: const Icon(CupertinoIcons.square_grid_2x2), - label: l10n.tabManage, - ), - BottomNavigationBarItem( - icon: const Icon(CupertinoIcons.person), - label: l10n.tabProfile, - ), - ], + return PopScope( + // 拦截系统返回键:若在非「首页」Tab 则跳回首页;若在首页则弹出退出确认。 + canPop: false, + onPopInvokedWithResult: (didPop, _) async { + if (didPop) return; + + final currentLocation = GoRouterState.of(context).uri.path; + if (currentLocation == '/home') { + final shouldExit = await _showExitConfirm(context); + if (shouldExit == true) { + await SystemUiUtil.moveToBack(); + } + } else { + context.go('/home'); + } + }, + child: CupertinoPageScaffold( + child: Stack( + children: [ + child, + Align( + alignment: Alignment.bottomCenter, + child: CupertinoTabBar( + currentIndex: index, + onTap: (i) => _onTabTap(context, i), + height: 64, + iconSize: 26, + items: [ + BottomNavigationBarItem( + icon: const Icon(CupertinoIcons.home), + label: l10n.tabHome, + ), + BottomNavigationBarItem( + icon: const Icon(CupertinoIcons.square_grid_2x2), + label: l10n.tabManage, + ), + BottomNavigationBarItem( + icon: const Icon(CupertinoIcons.person), + label: l10n.tabProfile, + ), + ], + ), ), - ), - ], + ], + ), ), ); } @@ -64,4 +84,28 @@ class HomePage extends ConsumerWidget { context.go('/profile'); } } + + /// 弹出「确认退出应用」对话框。 + Future _showExitConfirm(BuildContext context) { + final l10n = AppLocalizations.of(context); + return showCupertinoDialog( + context: context, + builder: (_) => CupertinoAlertDialog( + title: Text(l10n.exitConfirmTitle), + content: Text(l10n.exitConfirmContent), + actions: [ + CupertinoDialogAction( + child: Text(l10n.cancel), + onPressed: () => Navigator.of(context).pop(false), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: Text(l10n.exitApp), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ), + ); + } } + diff --git a/lib/features/home/presentation/home_tab.dart b/lib/features/home/presentation/home_tab.dart index 3c31f19..e4c44e9 100644 --- a/lib/features/home/presentation/home_tab.dart +++ b/lib/features/home/presentation/home_tab.dart @@ -1,8 +1,13 @@ import 'package:cupertino_ui/cupertino_ui.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/utils/time_format.dart'; import '../../../core/widgets/feature_ui.dart'; +import '../../../features/device/data/device_providers.dart'; +import '../../../features/device/domain/device_models.dart'; import '../../../l10n/app_localizations.dart'; +import 'location_map_view.dart'; /// 首页 Tab 内容(特性模块:home / presentation)。 /// @@ -14,6 +19,16 @@ class HomeTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); + final sn = ref.watch(selectedDeviceSnProvider); + final locationAsync = sn == null + ? const AsyncValue.data(null) + : ref.watch(deviceLocationProvider(sn)); + final location = locationAsync.valueOrNull; + final locating = locationAsync.isLoading; + final screenshotsAsync = sn == null + ? const AsyncValue>.data([]) + : ref.watch(recentScreenshotsProvider(sn)); + return CupertinoPageScaffold( navigationBar: CupertinoNavigationBar( middle: Text(l10n.appTitle, @@ -50,15 +65,37 @@ class HomeTab extends ConsumerWidget { ), ), child: SafeArea( - child: ListView( - padding: const EdgeInsets.only(bottom: 96), - children: [ - _locationCard(l10n), - _screenshotsCard(l10n), - _usageCard(l10n), - _deviceOpsCard(l10n), - SectionTitle(l10n.homeCommon), - _commonCard(l10n), + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics( + parent: BouncingScrollPhysics(), + ), + slivers: [ + CupertinoSliverRefreshControl( + onRefresh: () async { + // 下拉刷新:重新拉取设备列表、当前设备最新定位与最近截图列表。 + await Future.wait([ + ref.refresh(myDevicesProvider.future), + if (sn != null) ref.refresh(deviceLocationProvider(sn).future), + if (sn != null) ref.refresh(recentScreenshotsProvider(sn).future), + ]); + }, + ), + 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(l10n), + ]), + ), + ), ], ), ), @@ -70,6 +107,7 @@ class HomeTab extends ConsumerWidget { Color color, String title, { String? trailing, + VoidCallback? onTrailingTap, }) => Padding( padding: const EdgeInsets.only(bottom: 12), @@ -90,108 +128,252 @@ class HomeTab extends ConsumerWidget { fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.ink)), const Spacer(), if (trailing != null) - Text(trailing, - style: const TextStyle( - fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.green)), + GestureDetector( + onTap: onTrailingTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text(trailing, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.green)), + ), + ), ], ), ); - Widget _locationCard(AppLocalizations l10n) => CardBox( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _cardHeader('📍', AppColors.green, l10n.homeLocation, trailing: l10n.homeLocating), - ClipRRect( - borderRadius: BorderRadius.circular(14), - child: Container( - height: 150, - color: const Color(0xFFF3F7FF), - child: Stack( + 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: [ - // 简易路网 - Positioned(top: 38, left: 0, right: 0, height: 10, child: Container(color: CupertinoColors.white)), - Positioned(top: 96, left: 0, right: 0, height: 10, child: Container(color: CupertinoColors.white)), - Positioned(top: 0, bottom: 0, left: 120, width: 10, child: Container(color: CupertinoColors.white)), - Positioned(top: 0, bottom: 0, left: 240, width: 10, child: Container(color: CupertinoColors.white)), - // 定位标记 - Positioned( - left: 0, - right: 0, - top: 46, + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: AppColors.green, + borderRadius: BorderRadius.circular(7), + ), child: Center( - child: Container( - width: 30, - height: 30, - decoration: const BoxDecoration( - color: AppColors.green, - shape: BoxShape.circle, - ), - child: const Center( - child: Icon(CupertinoIcons.location_fill, - color: CupertinoColors.white, size: 16), - ), + 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), ), ), ), - Positioned( - left: 12, - 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('📍 ${l10n.homeAddress}', - style: const TextStyle(fontSize: 12, color: AppColors.ink)), + 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), + ], ), ), ], ), ), - ), - ], - ), - ); - - Widget _screenshotsCard(AppLocalizations l10n) => CardBox( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _cardHeader('🖼️', AppColors.blue, l10n.homeScreenshots, trailing: l10n.homeViewAll), - SizedBox( - height: 120, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: 4, - separatorBuilder: (_, _) => const SizedBox(width: 10), - itemBuilder: (_, i) => Container( - width: 88, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - gradient: const LinearGradient( - colors: [AppColors.blue, AppColors.purple], - begin: Alignment.topLeft, - end: Alignment.bottomRight, + Stack( + children: [ + // 百度地图实时位置(须明确宽高,否则地图空白) + ClipRRect( + borderRadius: BorderRadius.circular(14), + child: SizedBox( + width: double.infinity, + height: 150, + child: LocationMapView(location: location), ), ), - child: Align( - alignment: Alignment.bottomLeft, - child: Padding( - padding: const EdgeInsets.all(6), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: CupertinoColors.black.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(6), - ), - child: Text(['14:02', '13:20', '11:48', '09:15'][i], - style: const TextStyle(fontSize: 10, color: CupertinoColors.white)), + // 地址信息浮层 + // 右侧留出间隔,避免遮挡地图右侧的放大缩小按钮。 + 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> 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)), + ), + ), + ], + ), + ), + ), + ); + }, + ); + }, ), ), ], @@ -290,25 +472,181 @@ class HomeTab extends ConsumerWidget { ), ); - Widget _deviceOpsCard(AppLocalizations l10n) => CardBox( + Widget _deviceOpsCard( + BuildContext context, + AppLocalizations l10n, + WidgetRef ref, + String? sn, + ) => + CardBox( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _cardHeader('⚡', AppColors.purple, l10n.homeDeviceOps), Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - IconTile(emoji: '🔄', color: AppColors.blue, label: l10n.opRestart, size: 50, iconSize: 20), - IconTile(emoji: '🔌', color: AppColors.red, label: l10n.opShutdown, size: 50, iconSize: 20), - IconTile(emoji: '📸', color: AppColors.green, label: l10n.opScreenshot, size: 50, iconSize: 20), - IconTile(emoji: '🔃', color: AppColors.orange, label: l10n.opRefresh, size: 50, iconSize: 20), - IconTile(emoji: '📍', color: AppColors.purple, label: l10n.opLocate, size: 50, iconSize: 20), + Expanded( + child: IconTile( + emoji: '🔄', + color: AppColors.blue, + label: l10n.opRestart, + size: 50, + iconSize: 20, + onTap: () => _confirmDeviceOp( + context, l10n, ref, sn, 'reboot', l10n.opRestartConfirm), + ), + ), + Expanded( + child: IconTile( + emoji: '🔌', + color: AppColors.red, + label: l10n.opShutdown, + size: 50, + iconSize: 20, + onTap: () => _confirmDeviceOp( + context, l10n, ref, sn, 'shutdown', l10n.opShutdownConfirm), + ), + ), + Expanded( + child: IconTile( + emoji: '📸', + color: AppColors.green, + label: l10n.opScreenshot, + size: 50, + iconSize: 20, + onTap: () => _runDeviceOp(context, ref, l10n, sn, 'screenshot', + refreshScreenshots: true), + ), + ), + Expanded( + child: IconTile( + emoji: '🔃', + color: AppColors.orange, + label: l10n.opRefresh, + size: 50, + iconSize: 20, + onTap: () => _runDeviceOp(context, ref, l10n, sn, 'refresh'), + ), + ), + Expanded( + child: IconTile( + emoji: '📍', + color: AppColors.purple, + label: l10n.opLocate, + size: 50, + iconSize: 20, + onTap: () => _runDeviceOp(context, ref, l10n, sn, 'locate', + refreshLocation: true), + ), + ), ], ), ], ), ); + /// 危险操作(重启/关机)确认弹窗,确认后下发指令。 + Future _confirmDeviceOp( + BuildContext context, + AppLocalizations l10n, + WidgetRef ref, + String? sn, + String op, + String message, + ) async { + final confirmed = await showCupertinoDialog( + context: context, + builder: (dialogContext) => CupertinoAlertDialog( + title: Text(l10n.opConfirmTitle), + content: Text(message), + actions: [ + CupertinoDialogAction( + child: Text(l10n.cancel), + onPressed: () => Navigator.pop(dialogContext, false), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: Text(l10n.confirm), + onPressed: () => Navigator.pop(dialogContext, true), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + await _runDeviceOp(context, ref, l10n, sn, op); + } + + /// 下发设备操作指令并弹出结果提示。 + Future _runDeviceOp( + BuildContext context, + WidgetRef ref, + AppLocalizations l10n, + String? sn, + String op, { + bool refreshScreenshots = false, + bool refreshLocation = false, + }) async { + if (sn == null || sn.isEmpty) { + _showOpResult(context, l10n, l10n.opCmdFailed); + return; + } + final error = await ref.read(deviceRepositoryProvider).sendDeviceOp(sn, op); + if (!context.mounted) return; + if (error == null) { + if (refreshScreenshots) ref.invalidate(recentScreenshotsProvider(sn)); + if (refreshLocation) ref.invalidate(deviceLocationProvider(sn)); + // 截屏操作成功后,额外提示锁屏状态下截图可能为黑色。 + _showOpResult( + context, + l10n, + l10n.opCmdSent, + subtitle: refreshScreenshots ? l10n.opScreenshotHint : null, + ); + } else { + _showOpResult(context, l10n, error); + } + } + + /// 操作结果提示弹窗。 + /// + /// [subtitle] 为可选的补充说明(如截屏操作的成功提示)。 + void _showOpResult( + BuildContext context, + AppLocalizations l10n, + String message, { + String? subtitle, + }) { + showCupertinoDialog( + context: context, + builder: (dialogContext) => CupertinoAlertDialog( + title: Text(l10n.alertTitle), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(message, textAlign: TextAlign.center), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text( + subtitle, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + color: AppColors.sub, + ), + ), + ], + ], + ), + actions: [ + CupertinoDialogAction( + child: Text(l10n.confirm), + onPressed: () => Navigator.pop(dialogContext), + ), + ], + ), + ); + } + Widget _commonCard(AppLocalizations l10n) => CardBox( child: Column( children: [ diff --git a/lib/features/home/presentation/location_map_view.dart b/lib/features/home/presentation/location_map_view.dart new file mode 100644 index 0000000..2e36942 --- /dev/null +++ b/lib/features/home/presentation/location_map_view.dart @@ -0,0 +1,318 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter_baidu_mapapi_base/flutter_baidu_mapapi_base.dart'; +import 'package:flutter_baidu_mapapi_map/flutter_baidu_mapapi_map.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/utils/coord_util.dart'; +import '../../device/domain/device_models.dart'; +import '../data/location_controller.dart'; + +/// 可复用的嵌入式百度地图视图(用于首页定位卡片等小尺寸地图)。 +/// +/// 根据传入的 [location](WGS84 坐标)将地图中心与标记定位到设备位置; +/// 无有效坐标时保持默认中心(北京)不添加标记。尺寸由父级约束决定。 +class LocationMapView extends ConsumerStatefulWidget { + const LocationMapView({super.key, required this.location}); + + final DeviceLocation? location; + + @override + ConsumerState createState() => _LocationMapViewState(); +} + +class _LocationMapViewState extends ConsumerState { + BMFMapController? _mapController; + bool _markerAdded = false; + + @override + void initState() { + super.initState(); + } + + @override + void dispose() { + // 地图控制器由底层平台视图生命周期管理,销毁时主动清空本地引用, + // 避免 teardown 阶段对已释放的原生视图发起调用(降低百度 SDK 原生崩溃风险)。 + _mapController = null; + super.dispose(); + } + + @override + void didUpdateWidget(covariant LocationMapView oldWidget) { + super.didUpdateWidget(oldWidget); + // 定位数据变化时刷新标记位置。 + if (oldWidget.location != widget.location) { + _syncLocation(widget.location); + } + } + + void _onMapCreated(BMFMapController controller) { + _mapController = controller; + debugPrint('[LocationMapView] onMapCreated, mapController 已就绪'); + // 按官方「显示定位」文档:地图加载完成后再执行图层/覆盖物操作, + // 否则 addMarker / showUserLocation 等在地图未就绪时调用会不生效。 + controller.setMapDidLoadCallback(callback: () { + debugPrint('[LocationMapView] mapDidLoad, 执行图层与标记初始化'); + // 1. 初始化定位图层 + 补偿蓝色定位点。 + _enableUserLocationLayer(); + // 2. 补偿添加设备位置 Marker(地图加载完成后 addMarker 才可靠)。 + _syncLocation(widget.location); + }); + } + + /// 开启定位图层并设置定位点样式(地图加载完成后调用)。 + void _enableUserLocationLayer() { + final controller = _mapController; + if (controller == null) return; + // 1. 开启定位图层。 + controller.showUserLocation(true); + // 2. 设置定位点样式(使用 SDK 默认图标;不设置无效图片路径,避免图标加载失败)。 + final param = BMFUserLocationDisplayParam.userlocationOptions() + ..isAccuracyCircleShow = true + ..accuracyCircleFillColor = const Color(0x3300B0FF) + ..accuracyCircleStrokeColor = const Color(0x6600B0FF) + ..locationViewHierarchy = + BMFLocationViewHierarchy.LOCATION_VIEW_HIERARCHY_TOP; + controller.updateLocationViewWithParam(param); + // 3. 若本机定位已就绪,同步蓝色定位点。 + final current = ref.read(locationControllerProvider); + debugPrint('[LocationMapView] _enableUserLocationLayer: 当前定位状态=${current.runtimeType}'); + if (current is LocationSuccess) _updateUserLocation(current.location); + } + + /// 把手机本机定位结果更新到地图定位图层(蓝色定位点)。 + void _updateUserLocation(DeviceCurrentLocation location) { + final controller = _mapController; + if (controller == null) { + debugPrint('[LocationMapView] _updateUserLocation 被丢弃:mapController 尚未就绪'); + return; + } + debugPrint('[LocationMapView] _updateUserLocation lat=${location.latitude} ' + 'lon=${location.longitude}'); + // 按百度地图「显示定位」文档构造完整位置对象。 + final userLocation = BMFUserLocation( + location: BMFLocation( + coordinate: BMFCoordinate(location.latitude, location.longitude), + altitude: location.altitude ?? 0, + horizontalAccuracy: location.radius ?? 0, + verticalAccuracy: -1.0, + speed: location.speed ?? -1.0, + course: location.course ?? -1.0, + ), + ); + controller.updateLocationData(userLocation); + } + + void _syncLocation(DeviceLocation? location) { + final controller = _mapController; + if (controller == null) return; + final coord = _toWgsCoordinate(location); + if (coord == null) return; + + final bd = wgs84ToBd09(coord.lat, coord.lon); + controller.setCenterCoordinate(bd, true); + if (!_markerAdded) { + // Marker 标题展示设备 SN(缺失时兜底为"设备位置")。 + final sn = location!.sn; + final title = (sn != null && sn.isNotEmpty) ? sn : '设备位置'; + _addDeviceMarker(controller, bd, title); + } + } + + Future _addDeviceMarker( + BMFMapController controller, + BMFCoordinate position, + String title, + ) async { + final icon = await deviceMarkerIconData(title); + if (icon == null || _markerAdded) return; // 并发保护,避免重复添加。 + final marker = BMFMarker.iconData( + position: position, + iconData: icon, + identifier: 'device_location', + ); + await controller.addMarker(marker); + _markerAdded = true; + } + + BMFCoordinate _computeCenter(DeviceLocation? location) { + final coord = _toWgsCoordinate(location); + if (coord != null) return wgs84ToBd09(coord.lat, coord.lon); + // 默认中心:北京 + return BMFCoordinate(39.915, 116.404); + } + + ({double lat, double lon})? _toWgsCoordinate(DeviceLocation? location) { + if (location == null) return null; + final latStr = location.latitude; + final lonStr = location.longitude; + if (latStr == null || latStr.isEmpty || lonStr == null || lonStr.isEmpty) { + return null; + } + final lat = double.tryParse(latStr); + final lon = double.tryParse(lonStr); + if (lat == null || lon == null) return null; + return (lat: lat, lon: lon); + } + + @override + Widget build(BuildContext context) { + // watch 本机定位 Provider:地图可见时自动启动手机定位。 + ref.watch(locationControllerProvider); + // 监听本机定位结果:成功后把蓝色定位点更新到地图。 + ref.listen( + locationControllerProvider, + (prev, next) { + debugPrint('[LocationMapView] listen 定位状态: ${next.runtimeType}'); + if (next is LocationSuccess) { + _updateUserLocation(next.location); + } else if (next is LocationFailed) { + debugPrint('[Location] 首页卡片:定位失败 -> ${next.message}'); + } + }, + ); + + // 重要:BMFMapWidget 底层为 AndroidView / UiKitView,必须被包裹在 + // 明确宽高的容器中(SizedBox.expand 强制铺满父级),否则地图会显示空白。 + return SizedBox.expand( + child: BMFMapWidget( + onBMFMapCreated: _onMapCreated, + mapOptions: BMFMapOptions( + center: _computeCenter(widget.location), + zoomLevel: 16, + showMapScaleBar: false, + ), + ), + ); + } +} + +/// 设备位置 Marker 的 PNG 图标字节缓存(key 为 SN 标题)。 +final Map _deviceMarkerIconCache = {}; + +/// 生成设备位置 Marker 的 PNG 图标字节。 +/// +/// 图标为一个「带圆角边框的气泡标签」:上方是圆角矩形气泡(白底 + 深色描边边框, +/// 内含设备 SN 文字),下方是红色定位针,中间小三角衔接,整体形似带边框的气泡。 +/// 用 `dart:ui` 动态绘制,宽度随 SN 文字长度自适应,超长 SN 自动降字号避免图标过宽变扁; +/// 高度固定偏高,保证竖长比例。避免依赖外部图片资源。 +/// 绘制失败返回 null(调用方不做渲染)。 +Future deviceMarkerIconData(String title) async { + final cached = _deviceMarkerIconCache[title]; + if (cached != null) return cached; + try { + // ---- 尺寸常量(整体放大:解决文字偏小 / 图标偏窄)---- + const bubbleH = 80.0; // 气泡高度 + const padX = 30.0; // 气泡左右内边距 + const pinH = 72.0; // 下方定位针高度 + const pinW = 60.0; // 定位针宽度 + const tailH = 16.0; // 气泡与定位针之间的小三角高度 + const totalH = bubbleH + tailH + pinH; // 图标总高(约 168) + const maxFontSize = 32.0; // 放大后字号上限 + const minFontSize = 20.0; // 长 SN 时的字号下限 + const maxBubbleW = 240.0; // 气泡最大宽度,防止图标过宽(但不裁文字、不换行) + + // ---- 文字始终单行测量(宽约束取极大值,避免换行)---- + double measureLine(String text, double fs) { + final pb = ui.ParagraphBuilder(ui.ParagraphStyle(fontSize: fs)); + pb.addText(text); + final p = pb.build(); + p.layout(const ui.ParagraphConstraints(width: 10000)); + return p.longestLine; + } + + // 优先大号;若单行仍超最大宽,则降字号直到放得下(下限 minFontSize)。 + var fontSize = maxFontSize; + var textW = measureLine(title, fontSize); + while (textW + padX * 2 > maxBubbleW && fontSize > minFontSize) { + fontSize -= 2.0; + textW = measureLine(title, fontSize); + } + + // 气泡宽度 = 单行文字宽 + 左右内边距,保证文字不换行、不被裁。 + final finalBubbleW = textW + padX * 2; + final width = finalBubbleW.ceilToDouble().toInt(); + final height = totalH.ceilToDouble().toInt(); + + final recorder = ui.PictureRecorder(); + final canvas = ui.Canvas(recorder); + + final cx = finalBubbleW / 2; + // ---- 气泡:白底 + 深色描边(圆角矩形)---- + final bubbleRect = ui.RRect.fromRectAndRadius( + ui.Rect.fromLTWH(0, 0, finalBubbleW, bubbleH), + const ui.Radius.circular(10), + ); + final bubbleFill = ui.Paint()..color = const ui.Color(0xFFFFFFFF); + canvas.drawRRect(bubbleRect, bubbleFill); + final bubbleStroke = ui.Paint() + ..color = const ui.Color(0xFF1F2937) + ..style = ui.PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawRRect(bubbleRect, bubbleStroke); + + // ---- 小三角:衔接气泡与定位针,同边框色 ---- + final triangle = ui.Path() + ..moveTo(cx - 9, bubbleH) + ..lineTo(cx + 9, bubbleH) + ..lineTo(cx, bubbleH + tailH) + ..close(); + canvas.drawPath(triangle, bubbleStroke); + + // ---- SN 文字(深色,水平/垂直居中于气泡内)---- + final paintPb = ui.ParagraphBuilder(ui.ParagraphStyle( + fontSize: fontSize, + textAlign: ui.TextAlign.center, + )); + paintPb.pushStyle(ui.TextStyle(color: const ui.Color(0xFF1F2937))); + paintPb.addText(title); + final paintParagraph = paintPb.build(); + paintParagraph.layout(ui.ParagraphConstraints(width: finalBubbleW)); + final textLeft = + (finalBubbleW - paintParagraph.width) / 2; // 文字超宽时以气泡宽度为中心 + canvas.drawParagraph( + paintParagraph, + ui.Offset(textLeft < 0 ? 0 : textLeft, (bubbleH - paintParagraph.height) / 2), + ); + + // ---- 红色定位针(更宽更高,比例竖长)---- + final pinTop = bubbleH + tailH; + final halfPin = pinW / 2; + final pin = ui.Path() + ..moveTo(cx, pinTop) + ..cubicTo(cx - halfPin * 0.7, pinTop, cx - halfPin, pinTop + halfPin * 0.4, + cx - halfPin, pinTop + halfPin * 0.8) + ..cubicTo(cx - halfPin, pinTop + pinH * 0.7, cx, pinTop + pinH, cx, + pinTop + pinH) + ..cubicTo(cx, pinTop + pinH, cx + halfPin, pinTop + pinH * 0.7, + cx + halfPin, pinTop + halfPin * 0.8) + ..cubicTo(cx + halfPin, pinTop + halfPin * 0.4, cx + halfPin * 0.7, pinTop, + cx, pinTop); + final pinFill = ui.Paint()..color = const ui.Color(0xFFE5484D); + canvas.drawPath(pin, pinFill); + final pinStroke = ui.Paint() + ..color = const ui.Color(0xFFC0392B) + ..style = ui.PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawPath(pin, pinStroke); + // 针内白点。 + final dot = ui.Paint()..color = const ui.Color(0xFFFFFFFF); + canvas.drawCircle(ui.Offset(cx, pinTop + pinH * 0.45), 8, dot); + + final picture = recorder.endRecording(); + final image = await picture.toImage(width, height); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + picture.dispose(); + image.dispose(); + if (byteData == null) return null; + final bytes = byteData.buffer.asUint8List(); + _deviceMarkerIconCache[title] = bytes; + return bytes; + } catch (e) { + debugPrint('[DeviceMarker] 生成定位图标失败: $e'); + return null; + } +} diff --git a/lib/features/home/presentation/map_page.dart b/lib/features/home/presentation/map_page.dart new file mode 100644 index 0000000..b1b53e2 --- /dev/null +++ b/lib/features/home/presentation/map_page.dart @@ -0,0 +1,238 @@ +import 'package:cupertino_ui/cupertino_ui.dart'; +import 'package:flutter_baidu_mapapi_base/flutter_baidu_mapapi_base.dart'; +import 'package:flutter_baidu_mapapi_map/flutter_baidu_mapapi_map.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/utils/coord_util.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../device/data/device_providers.dart'; +import '../../device/domain/device_models.dart'; +import '../data/location_controller.dart'; +import 'location_map_view.dart'; + +/// 设备实时位置地图页。 +/// +/// 基于百度地图展示设备上报的实时位置。定位数据来自 +/// `GET /api/v1/client/sn/device-info`(经 [deviceLocationProvider] 解析), +/// 后端返回 GPS(WGS84) 坐标,展示前转换为百度 BD09LL 坐标。 +class MapPage extends ConsumerStatefulWidget { + const MapPage({required this.sn, super.key}); + + final String sn; + + @override + ConsumerState createState() => _MapPageState(); +} + +class _MapPageState extends ConsumerState { + BMFMapController? _mapController; + bool _markerAdded = false; + + @override + void dispose() { + // 地图控制器由底层平台视图生命周期管理,销毁时主动清空本地引用, + // 避免 teardown 阶段对已释放的原生视图发起调用(降低百度 SDK 原生崩溃风险)。 + _mapController = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final locationAsync = ref.watch(deviceLocationProvider(widget.sn)); + + // watch 本机定位 Provider:首次进入自动启动手机定位,页面销毁时自动停止。 + ref.watch(locationControllerProvider); + + // 监听定位数据(ref.listen 只能在 build 方法内调用):无论地图创建时序如何 + // (地图后建/数据后到),只要数据就绪就主动把地图中心与标记同步到设备位置, + // 避免依赖 build 帧时序导致地图停留在默认中心。 + ref.listen>( + deviceLocationProvider(widget.sn), + (prev, next) { + if (next.hasValue) _syncLocation(next.valueOrNull); + }, + ); + + // 监听本机定位结果:定位成功后把蓝色定位点更新到地图(BMFUserLocation)。 + ref.listen( + locationControllerProvider, + (prev, next) { + if (next is LocationSuccess) { + _updateUserLocation(next.location); + } else if (next is LocationFailed) { + debugPrint('[Location] 全屏地图:定位失败 -> ${next.message}'); + } + }, + ); + + // 设备坐标(WGS84)转百度坐标,作为地图初始中心;无数据时回退到北京。 + final BMFCoordinate center = _computeCenter(locationAsync.valueOrNull); + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text(l10n.mapTitle), + previousPageTitle: l10n.back, + ), + child: SafeArea( + child: Stack( + children: [ + BMFMapWidget( + onBMFMapCreated: _onMapCreated, + mapOptions: BMFMapOptions( + center: center, + zoomLevel: 16, + showMapScaleBar: true, + ), + ), + _buildOverlay(locationAsync, l10n), + ], + ), + ), + ); + } + + /// 地图创建完成回调。 + void _onMapCreated(BMFMapController controller) { + _mapController = controller; + // 按官方「显示定位」文档:地图加载完成后再执行图层/覆盖物操作, + // 否则 showUserLocation / addMarker 等在地图未就绪时调用会不生效。 + controller.setMapDidLoadCallback(callback: () { + _enableUserLocationLayer(); + // 补偿添加设备位置 Marker(地图加载完成后 addMarker 才可靠)。 + _syncLocation(ref.read(deviceLocationProvider(widget.sn)).valueOrNull); + }); + } + + /// 开启定位图层并设置定位点样式(地图加载完成后调用)。 + void _enableUserLocationLayer() { + final controller = _mapController; + if (controller == null) return; + // 1. 开启定位图层。 + controller.showUserLocation(true); + // 2. 设置定位点样式(使用 SDK 默认图标;不设置无效图片路径,避免图标加载失败)。 + final param = BMFUserLocationDisplayParam.userlocationOptions() + ..isAccuracyCircleShow = true + ..accuracyCircleFillColor = const Color(0x3300B0FF) + ..accuracyCircleStrokeColor = const Color(0x6600B0FF) + ..locationViewHierarchy = + BMFLocationViewHierarchy.LOCATION_VIEW_HIERARCHY_TOP; + controller.updateLocationViewWithParam(param); + // 3. 若本机定位已就绪,同步蓝色定位点。 + final current = ref.read(locationControllerProvider); + if (current is LocationSuccess) _updateUserLocation(current.location); + } + + /// 把手机本机定位结果更新到地图定位图层(蓝色定位点)。 + void _updateUserLocation(DeviceCurrentLocation location) { + final controller = _mapController; + if (controller == null) return; + // 按百度地图「显示定位」文档构造完整位置对象。 + final userLocation = BMFUserLocation( + location: BMFLocation( + coordinate: BMFCoordinate(location.latitude, location.longitude), + altitude: location.altitude ?? 0, + horizontalAccuracy: location.radius ?? 0, + verticalAccuracy: -1.0, + speed: location.speed ?? -1.0, + course: location.course ?? -1.0, + ), + ); + controller.updateLocationData(userLocation); + } + + /// 根据最新定位刷新地图标记与中心。 + void _syncLocation(DeviceLocation? location) { + final controller = _mapController; + if (controller == null) return; + final coord = _toWgsCoordinate(location); + if (coord == null) return; + + final bd = wgs84ToBd09(coord.lat, coord.lon); + controller.setCenterCoordinate(bd, true); + if (!_markerAdded) { + // Marker 标题展示设备 SN(缺失时兜底为"设备位置")。 + final sn = location!.sn; + final title = (sn != null && sn.isNotEmpty) ? sn : '设备位置'; + // 使用 iconData 指定图标:基础 BMFMarker(position:, title:) 构造不带 icon, + // 在 Android 端不渲染(SDK 3.1.0 起已废弃),故传入红色定位点 PNG 字节。 + _addDeviceMarker(controller, bd, title); + } + } + + /// 添加设备位置红点 Marker。 + /// + /// 图标由 [deviceMarkerIconData] 动态绘制为「带圆角边框的气泡 + 定位针」, + /// 内含设备 SN 文字与边框,不依赖 `titleOptions`(其无边框能力)。 + Future _addDeviceMarker( + BMFMapController controller, + BMFCoordinate position, + String title, + ) async { + final icon = await deviceMarkerIconData(title); + if (icon == null || _markerAdded) return; // 并发保护,避免重复添加。 + final marker = BMFMarker.iconData( + position: position, + iconData: icon, + identifier: 'device_location', + ); + await controller.addMarker(marker); + _markerAdded = true; + } + + /// 计算初始地图中心。 + BMFCoordinate _computeCenter(DeviceLocation? location) { + final coord = _toWgsCoordinate(location); + if (coord != null) return wgs84ToBd09(coord.lat, coord.lon); + // 默认中心:北京 + return BMFCoordinate(39.915, 116.404); + } + + /// 从 [DeviceLocation] 提取合法的 WGS84 坐标,缺失或非数字时返回 null。 + ({double lat, double lon})? _toWgsCoordinate(DeviceLocation? location) { + if (location == null) return null; + final latStr = location.latitude; + final lonStr = location.longitude; + if (latStr == null || latStr.isEmpty || lonStr == null || lonStr.isEmpty) { + return null; + } + final lat = double.tryParse(latStr); + final lon = double.tryParse(lonStr); + if (lat == null || lon == null) return null; + return (lat: lat, lon: lon); + } + + Widget _buildOverlay( + AsyncValue locationAsync, AppLocalizations l10n) { + return locationAsync.when( + loading: () => const Center(child: CupertinoActivityIndicator()), + error: (e, _) => Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + l10n.snapshotLocationFailed, + style: const TextStyle(color: CupertinoColors.systemGrey), + textAlign: TextAlign.center, + ), + ), + ), + data: (location) { + if (_toWgsCoordinate(location) == null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + l10n.noSnapshotLocation, + style: const TextStyle(color: CupertinoColors.systemGrey), + textAlign: TextAlign.center, + ), + ), + ); + } + // 定位数据已就绪且有效:地图中心与标记的同步由 ref.listen + _onMapCreated 负责, + // 这里无需重复调度。 + return const SizedBox.shrink(); + }, + ); + } +} diff --git a/lib/features/home/presentation/screenshot_page.dart b/lib/features/home/presentation/screenshot_page.dart new file mode 100644 index 0000000..fb064d7 --- /dev/null +++ b/lib/features/home/presentation/screenshot_page.dart @@ -0,0 +1,112 @@ +import 'package:cupertino_ui/cupertino_ui.dart'; + +import '../../../core/utils/time_format.dart'; +import '../../../core/widgets/feature_ui.dart'; +import '../../../l10n/app_localizations.dart'; +import '../../device/domain/device_models.dart'; + +/// 截图查看页。 +/// +/// 单张居中展示设备截图,可通过左右滑动(PageView)切换上一张 / 下一张, +/// 顶部展示当前序号与总数,底部展示截图时间。从首页截图卡片的"查看全部" +/// 或点击某张缩略图进入,并定位到对应索引。 +class ScreenshotPage extends StatefulWidget { + const ScreenshotPage({ + super.key, + required this.screenshots, + required this.initialIndex, + }); + + /// 全部截图列表。 + final List screenshots; + + /// 初始展示的截图索引。 + final int initialIndex; + + @override + State createState() => _ScreenshotPageState(); +} + +class _ScreenshotPageState extends State { + late final PageController _pageController; + late int _currentIndex; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + _pageController = PageController(initialPage: widget.initialIndex); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final list = widget.screenshots; + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text( + '${l10n.homeScreenshots} · ${_currentIndex + 1}/${list.length}', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), + ), + previousPageTitle: l10n.back, + ), + child: SafeArea( + child: Column( + children: [ + Expanded( + child: PageView.builder( + controller: _pageController, + itemCount: list.length, + onPageChanged: (i) => setState(() => _currentIndex = i), + itemBuilder: (_, i) { + final item = list[i]; + return Center( + child: InteractiveViewer( + maxScale: 4, + child: Image.network( + item.url ?? '', + fit: BoxFit.contain, + loadingBuilder: (ctx, child, progress) => progress == null + ? child + : const Center( + child: CupertinoActivityIndicator(radius: 14)), + errorBuilder: (ctx, err, _) => const Center( + child: Icon(CupertinoIcons.photo, + size: 48, color: AppColors.sub), + ), + ), + ), + ); + }, + ), + ), + // 底部:截图时间信息(统一格式化时间戳) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Builder(builder: (context) { + final formatted = + formatTimeString(list[_currentIndex].uploadTime); + return Text( + formatted.isNotEmpty + ? '🕐 $formatted' + : (list[_currentIndex].fileName ?? ''), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + const TextStyle(fontSize: 13, color: AppColors.sub), + ); + }), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/messages/presentation/messages_tab.dart b/lib/features/messages/presentation/messages_tab.dart index a8f54cb..3f90fa8 100644 --- a/lib/features/messages/presentation/messages_tab.dart +++ b/lib/features/messages/presentation/messages_tab.dart @@ -108,7 +108,7 @@ class MessagesTab extends ConsumerWidget { label: m.label, size: 44, iconSize: 22, - onTap: m.route == null ? null : () => context.go(m.route!), + onTap: m.route == null ? null : () => context.push(m.route!), ), ); }, @@ -141,6 +141,7 @@ class MessagesTab extends ConsumerWidget { child: Row( children: [ Stack( + clipBehavior: Clip.none, children: [ Container( width: 44, diff --git a/lib/features/profile/presentation/profile_tab.dart b/lib/features/profile/presentation/profile_tab.dart index f0d4129..82d66d8 100644 --- a/lib/features/profile/presentation/profile_tab.dart +++ b/lib/features/profile/presentation/profile_tab.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/widgets/feature_ui.dart'; +import '../../../features/device/data/device_providers.dart'; +import '../../../features/device/domain/device_models.dart'; import '../../../l10n/app_localizations.dart'; import '../../auth/presentation/auth_controller.dart'; @@ -76,21 +78,7 @@ class ProfileTab extends ConsumerWidget { ), SectionTitle(l10n.deviceBound), - CardBox( - padding: EdgeInsets.zero, - gradient: const LinearGradient( - colors: [Color(0xFFEEF6FF), Color(0xFFF3FBF6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - child: Column( - children: [ - _deviceRow('📱', AppColors.green, l10n.devTablet, '在线 · 电量 82%', '管理'), - const AppDivider(), - _deviceRow('⌚', AppColors.blue, l10n.devWatch, '已连接 · 心率监测中', null), - ], - ), - ), + _deviceListCard(ref, l10n), SectionTitle(l10n.profileInfo), CardBox( @@ -151,6 +139,55 @@ class ProfileTab extends ConsumerWidget { ); } + Widget _deviceListCard(WidgetRef ref, AppLocalizations l10n) { + final devicesAsync = ref.watch(myDevicesProvider); + return CardBox( + padding: EdgeInsets.zero, + gradient: const LinearGradient( + colors: [Color(0xFFEEF6FF), Color(0xFFF3FBF6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + child: devicesAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CupertinoActivityIndicator()), + ), + error: (e, _) => Padding( + padding: const EdgeInsets.all(16), + child: Text('${l10n.deviceLoadFailed}:$e', + style: const TextStyle(fontSize: 12, color: AppColors.sub)), + ), + data: (devices) { + if (devices.isEmpty) { + return Padding( + padding: const EdgeInsets.all(16), + child: Text(l10n.deviceNone, + style: const TextStyle(fontSize: 13, color: AppColors.sub)), + ); + } + return Column( + children: [ + for (var i = 0; i < devices.length; i++) ...[ + if (i > 0) const AppDivider(), + _deviceCard(devices[i], l10n), + ], + ], + ); + }, + ), + ); + } + + Widget _deviceCard(DeviceBrief device, AppLocalizations l10n) { + final sub = device.snModel?.isNotEmpty == true + ? '${l10n.deviceOnline} · ${device.snModel}' + : l10n.deviceOnline; + return _deviceRow('📱', AppColors.green, + device.snName?.isNotEmpty == true ? device.snName! : device.serialno, sub, + l10n.deviceManage); + } + Widget _deviceRow(String emoji, Color color, String title, String sub, String? action) => Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7ea1611..ef588d9 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -404,6 +404,66 @@ abstract class AppLocalizations { /// **'应用'** String get appsTitle; + /// 应用列表加载失败提示 + /// + /// In zh, this message translates to: + /// **'应用列表加载失败'** + String get appsLoadFailed; + + /// 应用列表为空提示 + /// + /// In zh, this message translates to: + /// **'暂无已安装应用'** + String get appsEmpty; + + /// 应用操作:打开应用 + /// + /// In zh, this message translates to: + /// **'打开'** + String get appOpLaunch; + + /// 应用操作:停止应用 + /// + /// In zh, this message translates to: + /// **'停止'** + String get appOpStop; + + /// 应用操作:卸载应用 + /// + /// In zh, this message translates to: + /// **'卸载'** + String get appOpUninstall; + + /// 应用操作:清除应用数据 + /// + /// In zh, this message translates to: + /// **'清除数据'** + String get appOpClearData; + + /// 应用操作:确认弹窗标题 + /// + /// In zh, this message translates to: + /// **'应用操作确认'** + String get appOpConfirmTitle; + + /// 应用操作:卸载确认文案 + /// + /// In zh, this message translates to: + /// **'确定要卸载该应用吗?'** + String get appOpUninstallConfirm; + + /// 应用操作:清除数据确认文案 + /// + /// In zh, this message translates to: + /// **'确定要清除该应用的数据吗?'** + String get appOpClearDataConfirm; + + /// 应用操作:指令下发成功 + /// + /// In zh, this message translates to: + /// **'应用操作指令已发送'** + String get appOpSent; + /// 常用功能分组 /// /// In zh, this message translates to: @@ -542,12 +602,78 @@ abstract class AppLocalizations { /// **'定位中'** String get homeLocating; + /// 已定位提示 + /// + /// In zh, this message translates to: + /// **'已定位'** + String get homeLocated; + + /// 暂无定位数据提示 + /// + /// In zh, this message translates to: + /// **'暂无位置'** + String get homeNoLocation; + + /// 通用返回按钮文案 + /// + /// In zh, this message translates to: + /// **'返回'** + String get back; + + /// 设备实时位置获取失败提示 + /// + /// In zh, this message translates to: + /// **'定位信息获取失败,请稍后重试'** + String get snapshotLocationFailed; + + /// 设备暂无定位数据提示 + /// + /// In zh, this message translates to: + /// **'暂未获取到设备定位信息'** + String get noSnapshotLocation; + /// 示例设备地址 /// /// In zh, this message translates to: /// **'望京 SOHO · T3 座'** String get homeAddress; + /// 地图页导航标题 + /// + /// In zh, this message translates to: + /// **'实时位置'** + String get mapTitle; + + /// 地图页当前定位标题 + /// + /// In zh, this message translates to: + /// **'当前定位'** + String get mapCurrentLocation; + + /// 地图页设备详细地址 + /// + /// In zh, this message translates to: + /// **'北京市朝阳区望京 SOHO · T3 座 12 层'** + String get mapAddress; + + /// 地图页经纬度坐标 + /// + /// In zh, this message translates to: + /// **'纬度 39.9965,经度 116.4821'** + String get mapCoordinates; + + /// 地图页定位更新时间 + /// + /// In zh, this message translates to: + /// **'更新于 14:02'** + String get mapUpdatedAt; + + /// 地图页定位精度 + /// + /// In zh, this message translates to: + /// **'定位精度约 10 米'** + String get mapAccuracy; + /// 首页最近截图卡片 /// /// In zh, this message translates to: @@ -560,6 +686,18 @@ abstract class AppLocalizations { /// **'查看全部'** String get homeViewAll; + /// 地图卡片右上角查看详情入口 + /// + /// In zh, this message translates to: + /// **'查看详情'** + String get homeViewDetail; + + /// 首页最近截图为空时的占位文案 + /// + /// In zh, this message translates to: + /// **'暂无截图'** + String get homeNoScreenshot; + /// 首页使用时长卡片 /// /// In zh, this message translates to: @@ -614,6 +752,42 @@ abstract class AppLocalizations { /// **'定位'** String get opLocate; + /// 设备操作:确认弹窗标题 + /// + /// In zh, this message translates to: + /// **'操作确认'** + String get opConfirmTitle; + + /// 设备操作:重启确认文案 + /// + /// In zh, this message translates to: + /// **'确定要重启设备吗?'** + String get opRestartConfirm; + + /// 设备操作:关机确认文案 + /// + /// In zh, this message translates to: + /// **'确定要关机吗?'** + String get opShutdownConfirm; + + /// 设备操作:指令下发成功 + /// + /// In zh, this message translates to: + /// **'操作指令已发送'** + String get opCmdSent; + + /// 设备操作:指令下发失败 + /// + /// In zh, this message translates to: + /// **'操作失败,请稍后重试'** + String get opCmdFailed; + + /// 截屏操作下发成功后,提醒用户设备锁屏时截图可能为黑色 + /// + /// In zh, this message translates to: + /// **'设备锁屏状态下可能显示为黑色'** + String get opScreenshotHint; + /// 常用功能:文件传输 /// /// In zh, this message translates to: @@ -710,6 +884,36 @@ abstract class AppLocalizations { /// **'已绑定设备'** String get deviceBound; + /// 设备信息加载失败提示 + /// + /// In zh, this message translates to: + /// **'设备信息加载失败'** + String get deviceLoadFailed; + + /// 暂无已绑定设备提示 + /// + /// In zh, this message translates to: + /// **'暂无已绑定设备'** + String get deviceNone; + + /// 设备在线状态 + /// + /// In zh, this message translates to: + /// **'在线'** + String get deviceOnline; + + /// 设备暂无详细信息 + /// + /// In zh, this message translates to: + /// **'暂无详情'** + String get deviceNoInfo; + + /// 设备「管理」操作按钮 + /// + /// In zh, this message translates to: + /// **'管理'** + String get deviceManage; + /// 绑定设备:平板 /// /// In zh, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 1e710eb..008bdc0 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -164,6 +164,36 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appsTitle => 'Apps'; + @override + String get appsLoadFailed => 'Failed to load apps'; + + @override + String get appsEmpty => 'No installed apps'; + + @override + String get appOpLaunch => 'Open'; + + @override + String get appOpStop => 'Stop'; + + @override + String get appOpUninstall => 'Uninstall'; + + @override + String get appOpClearData => 'Clear Data'; + + @override + String get appOpConfirmTitle => 'App Operation'; + + @override + String get appOpUninstallConfirm => 'Uninstall this app?'; + + @override + String get appOpClearDataConfirm => 'Clear this app\'s data?'; + + @override + String get appOpSent => 'App operation command sent'; + @override String get appsCommon => 'Common Apps'; @@ -233,15 +263,56 @@ class AppLocalizationsEn extends AppLocalizations { @override String get homeLocating => 'Locating…'; + @override + String get homeLocated => 'Located'; + + @override + String get homeNoLocation => 'No location'; + + @override + String get back => 'Back'; + + @override + String get snapshotLocationFailed => + 'Failed to get location, please try again later'; + + @override + String get noSnapshotLocation => 'No device location available yet'; + @override String get homeAddress => 'Wangjing SOHO · T3'; + @override + String get mapTitle => 'Live Location'; + + @override + String get mapCurrentLocation => 'Current Location'; + + @override + String get mapAddress => + 'Wangjing SOHO · T3, Chaoyang District, Beijing · Floor 12'; + + @override + String get mapCoordinates => 'Lat 39.9965, Lng 116.4821'; + + @override + String get mapUpdatedAt => 'Updated at 14:02'; + + @override + String get mapAccuracy => 'Location accuracy approx. 10 m'; + @override String get homeScreenshots => 'Recent Screenshots'; @override String get homeViewAll => 'View All'; + @override + String get homeViewDetail => 'View Detail'; + + @override + String get homeNoScreenshot => 'No screenshots yet'; + @override String get homeUsage => 'Today\'s Usage'; @@ -269,6 +340,25 @@ class AppLocalizationsEn extends AppLocalizations { @override String get opLocate => 'Locate'; + @override + String get opConfirmTitle => 'Confirm Operation'; + + @override + String get opRestartConfirm => 'Restart this device?'; + + @override + String get opShutdownConfirm => 'Shut down this device?'; + + @override + String get opCmdSent => 'Command sent'; + + @override + String get opCmdFailed => 'Operation failed, please try again later'; + + @override + String get opScreenshotHint => + 'The screenshot may appear black when the device screen is locked'; + @override String get cfFileTransfer => 'File Transfer'; @@ -317,6 +407,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get deviceBound => 'Bound Devices'; + @override + String get deviceLoadFailed => 'Failed to load devices'; + + @override + String get deviceNone => 'No bound devices'; + + @override + String get deviceOnline => 'Online'; + + @override + String get deviceNoInfo => 'No details'; + + @override + String get deviceManage => 'Manage'; + @override String get devTablet => 'My Tablet Pro'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 027ee57..7d55d1a 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -163,6 +163,36 @@ class AppLocalizationsZh extends AppLocalizations { @override String get appsTitle => '应用'; + @override + String get appsLoadFailed => '应用列表加载失败'; + + @override + String get appsEmpty => '暂无已安装应用'; + + @override + String get appOpLaunch => '打开'; + + @override + String get appOpStop => '停止'; + + @override + String get appOpUninstall => '卸载'; + + @override + String get appOpClearData => '清除数据'; + + @override + String get appOpConfirmTitle => '应用操作确认'; + + @override + String get appOpUninstallConfirm => '确定要卸载该应用吗?'; + + @override + String get appOpClearDataConfirm => '确定要清除该应用的数据吗?'; + + @override + String get appOpSent => '应用操作指令已发送'; + @override String get appsCommon => '常用功能'; @@ -232,15 +262,54 @@ class AppLocalizationsZh extends AppLocalizations { @override String get homeLocating => '定位中'; + @override + String get homeLocated => '已定位'; + + @override + String get homeNoLocation => '暂无位置'; + + @override + String get back => '返回'; + + @override + String get snapshotLocationFailed => '定位信息获取失败,请稍后重试'; + + @override + String get noSnapshotLocation => '暂未获取到设备定位信息'; + @override String get homeAddress => '望京 SOHO · T3 座'; + @override + String get mapTitle => '实时位置'; + + @override + String get mapCurrentLocation => '当前定位'; + + @override + String get mapAddress => '北京市朝阳区望京 SOHO · T3 座 12 层'; + + @override + String get mapCoordinates => '纬度 39.9965,经度 116.4821'; + + @override + String get mapUpdatedAt => '更新于 14:02'; + + @override + String get mapAccuracy => '定位精度约 10 米'; + @override String get homeScreenshots => '最近截图'; @override String get homeViewAll => '查看全部'; + @override + String get homeViewDetail => '查看详情'; + + @override + String get homeNoScreenshot => '暂无截图'; + @override String get homeUsage => '今日使用时长'; @@ -268,6 +337,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get opLocate => '定位'; + @override + String get opConfirmTitle => '操作确认'; + + @override + String get opRestartConfirm => '确定要重启设备吗?'; + + @override + String get opShutdownConfirm => '确定要关机吗?'; + + @override + String get opCmdSent => '操作指令已发送'; + + @override + String get opCmdFailed => '操作失败,请稍后重试'; + + @override + String get opScreenshotHint => '设备锁屏状态下可能显示为黑色'; + @override String get cfFileTransfer => '文件传输'; @@ -316,6 +403,21 @@ class AppLocalizationsZh extends AppLocalizations { @override String get deviceBound => '已绑定设备'; + @override + String get deviceLoadFailed => '设备信息加载失败'; + + @override + String get deviceNone => '暂无已绑定设备'; + + @override + String get deviceOnline => '在线'; + + @override + String get deviceNoInfo => '暂无详情'; + + @override + String get deviceManage => '管理'; + @override String get devTablet => '我的平板 Pro'; diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index f4ae97a..55b5ac7 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -51,6 +51,16 @@ "addAlarm": "Add Alarm", "albumTitle": "Album", "appsTitle": "Apps", + "appsLoadFailed": "Failed to load apps", + "appsEmpty": "No installed apps", + "appOpLaunch": "Open", + "appOpStop": "Stop", + "appOpUninstall": "Uninstall", + "appOpClearData": "Clear Data", + "appOpConfirmTitle": "App Operation", + "appOpUninstallConfirm": "Uninstall this app?", + "appOpClearDataConfirm": "Clear this app's data?", + "appOpSent": "App operation command sent", "appsCommon": "Common Apps", "appsSystem": "System Tools", "appsEntertain": "Entertainment", @@ -74,9 +84,22 @@ "homeCommon": "Common Apps", "homeLocation": "Live Location", "homeLocating": "Locating…", + "homeLocated": "Located", + "homeNoLocation": "No location", + "back": "Back", + "snapshotLocationFailed": "Failed to get location, please try again later", + "noSnapshotLocation": "No device location available yet", "homeAddress": "Wangjing SOHO · T3", + "mapTitle": "Live Location", + "mapCurrentLocation": "Current Location", + "mapAddress": "Wangjing SOHO · T3, Chaoyang District, Beijing · Floor 12", + "mapCoordinates": "Lat 39.9965, Lng 116.4821", + "mapUpdatedAt": "Updated at 14:02", + "mapAccuracy": "Location accuracy approx. 10 m", "homeScreenshots": "Recent Screenshots", "homeViewAll": "View All", + "homeViewDetail": "View Detail", + "homeNoScreenshot": "No screenshots yet", "homeUsage": "Today's Usage", "homeUsageTrend": "↓12% from yesterday", "homeUsageToday": "Today", @@ -86,6 +109,12 @@ "opScreenshot": "Screenshot", "opRefresh": "Refresh", "opLocate": "Locate", + "opConfirmTitle": "Confirm Operation", + "opRestartConfirm": "Restart this device?", + "opShutdownConfirm": "Shut down this device?", + "opCmdSent": "Command sent", + "opCmdFailed": "Operation failed, please try again later", + "opScreenshotHint": "The screenshot may appear black when the device screen is locked", "cfFileTransfer": "File Transfer", "cfMsgSync": "Message Sync", "cfClean": "Clean & Boost", @@ -102,6 +131,11 @@ "msgAllRead": "Mark All Read", "profileEdit": "Edit Profile", "deviceBound": "Bound Devices", + "deviceLoadFailed": "Failed to load devices", + "deviceNone": "No bound devices", + "deviceOnline": "Online", + "deviceNoInfo": "No details", + "deviceManage": "Manage", "devTablet": "My Tablet Pro", "devWatch": "Smart Watch", "profileInfo": "Profile Info", diff --git a/lib/l10n/intl_zh.arb b/lib/l10n/intl_zh.arb index dfb3913..ea1a642 100644 --- a/lib/l10n/intl_zh.arb +++ b/lib/l10n/intl_zh.arb @@ -204,6 +204,46 @@ "@appsTitle": { "description": "应用页标题" }, + "appsLoadFailed": "应用列表加载失败", + "@appsLoadFailed": { + "description": "应用列表加载失败提示" + }, + "appsEmpty": "暂无已安装应用", + "@appsEmpty": { + "description": "应用列表为空提示" + }, + "appOpLaunch": "打开", + "@appOpLaunch": { + "description": "应用操作:打开应用" + }, + "appOpStop": "停止", + "@appOpStop": { + "description": "应用操作:停止应用" + }, + "appOpUninstall": "卸载", + "@appOpUninstall": { + "description": "应用操作:卸载应用" + }, + "appOpClearData": "清除数据", + "@appOpClearData": { + "description": "应用操作:清除应用数据" + }, + "appOpConfirmTitle": "应用操作确认", + "@appOpConfirmTitle": { + "description": "应用操作:确认弹窗标题" + }, + "appOpUninstallConfirm": "确定要卸载该应用吗?", + "@appOpUninstallConfirm": { + "description": "应用操作:卸载确认文案" + }, + "appOpClearDataConfirm": "确定要清除该应用的数据吗?", + "@appOpClearDataConfirm": { + "description": "应用操作:清除数据确认文案" + }, + "appOpSent": "应用操作指令已发送", + "@appOpSent": { + "description": "应用操作:指令下发成功" + }, "appsCommon": "常用功能", "@appsCommon": { "description": "常用功能分组" @@ -296,10 +336,54 @@ "@homeLocating": { "description": "定位中提示" }, + "homeLocated": "已定位", + "@homeLocated": { + "description": "已定位提示" + }, + "homeNoLocation": "暂无位置", + "@homeNoLocation": { + "description": "暂无定位数据提示" + }, + "back": "返回", + "@back": { + "description": "通用返回按钮文案" + }, + "snapshotLocationFailed": "定位信息获取失败,请稍后重试", + "@snapshotLocationFailed": { + "description": "设备实时位置获取失败提示" + }, + "noSnapshotLocation": "暂未获取到设备定位信息", + "@noSnapshotLocation": { + "description": "设备暂无定位数据提示" + }, "homeAddress": "望京 SOHO · T3 座", "@homeAddress": { "description": "示例设备地址" }, + "mapTitle": "实时位置", + "@mapTitle": { + "description": "地图页导航标题" + }, + "mapCurrentLocation": "当前定位", + "@mapCurrentLocation": { + "description": "地图页当前定位标题" + }, + "mapAddress": "北京市朝阳区望京 SOHO · T3 座 12 层", + "@mapAddress": { + "description": "地图页设备详细地址" + }, + "mapCoordinates": "纬度 39.9965,经度 116.4821", + "@mapCoordinates": { + "description": "地图页经纬度坐标" + }, + "mapUpdatedAt": "更新于 14:02", + "@mapUpdatedAt": { + "description": "地图页定位更新时间" + }, + "mapAccuracy": "定位精度约 10 米", + "@mapAccuracy": { + "description": "地图页定位精度" + }, "homeScreenshots": "最近截图", "@homeScreenshots": { "description": "首页最近截图卡片" @@ -308,6 +392,14 @@ "@homeViewAll": { "description": "查看全部入口" }, + "homeViewDetail": "查看详情", + "@homeViewDetail": { + "description": "地图卡片右上角查看详情入口" + }, + "homeNoScreenshot": "暂无截图", + "@homeNoScreenshot": { + "description": "首页最近截图为空时的占位文案" + }, "homeUsage": "今日使用时长", "@homeUsage": { "description": "首页使用时长卡片" @@ -344,6 +436,30 @@ "@opLocate": { "description": "设备操作:定位" }, + "opConfirmTitle": "操作确认", + "@opConfirmTitle": { + "description": "设备操作:确认弹窗标题" + }, + "opRestartConfirm": "确定要重启设备吗?", + "@opRestartConfirm": { + "description": "设备操作:重启确认文案" + }, + "opShutdownConfirm": "确定要关机吗?", + "@opShutdownConfirm": { + "description": "设备操作:关机确认文案" + }, + "opCmdSent": "操作指令已发送", + "@opCmdSent": { + "description": "设备操作:指令下发成功" + }, + "opCmdFailed": "操作失败,请稍后重试", + "@opCmdFailed": { + "description": "设备操作:指令下发失败" + }, + "opScreenshotHint": "设备锁屏状态下可能显示为黑色", + "@opScreenshotHint": { + "description": "截屏操作下发成功后,提醒用户设备锁屏时截图可能为黑色" + }, "cfFileTransfer": "文件传输", "@cfFileTransfer": { "description": "常用功能:文件传输" @@ -408,6 +524,26 @@ "@deviceBound": { "description": "我的页已绑定设备分组" }, + "deviceLoadFailed": "设备信息加载失败", + "@deviceLoadFailed": { + "description": "设备信息加载失败提示" + }, + "deviceNone": "暂无已绑定设备", + "@deviceNone": { + "description": "暂无已绑定设备提示" + }, + "deviceOnline": "在线", + "@deviceOnline": { + "description": "设备在线状态" + }, + "deviceNoInfo": "暂无详情", + "@deviceNoInfo": { + "description": "设备暂无详细信息" + }, + "deviceManage": "管理", + "@deviceManage": { + "description": "设备「管理」操作按钮" + }, "devTablet": "我的平板 Pro", "@devTablet": { "description": "绑定设备:平板" diff --git a/lib/main.dart b/lib/main.dart index ad30025..42f6846 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,17 +1,35 @@ import 'package:material_ui/material_ui.dart' show WidgetsFlutterBinding; import 'package:flutter/widgets.dart' show runApp; +import 'package:flutter_baidu_mapapi_base/flutter_baidu_mapapi_base.dart' + show BMFMapSDK, BMF_COORD_TYPE; +import 'package:flutter_baidu_mapapi_map/flutter_baidu_mapapi_map.dart' + show BMFAndroidVersion; + import 'app/app.dart'; +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/system_ui_util.dart'; /// 应用入口。 /// -/// 职责:初始化存储、打印设备信息、构建路由与根组件。不包含任何业务或 UI 逻辑。 +/// 职责:初始化存储、配置系统 UI、打印设备信息、构建路由与根组件。不包含任何业务或 UI 逻辑。 void main() async { WidgetsFlutterBinding.ensureInitialized(); + // 初始化百度地图 SDK(需申请 AK;坐标类型使用 BD09LL,设备 GPS 坐标在展示前转换)。 + BMFMapSDK.setAgreePrivacy(true); + BMFMapSDK.setApiKeyAndCoordType( + AppConstants.kBaiduMapAk, BMF_COORD_TYPE.BD09LL); + // 初始化 Android 版本适配:Android 10+ 使用 surfaceMapView(比默认 textureMapView 更稳定), + // 避免百度 SDK 在模拟器 / 部分机型上因 textureView + EGL 渲染冲突触发原生崩溃(SIGSEGV / EGL_BAD_MATCH)。 + await BMFAndroidVersion.initAndroidVersion(); + + // 沉浸式状态栏 / edge-to-edge / 刘海屏适配(不影响启动主流程)。 + await SystemUiUtil.configure(); + // 初始化本地存储(含超时保护,避免阻塞启动导致卡在首屏)。 await TokenStorage.initialize() .timeout(const Duration(seconds: 5), onTimeout: () {}); diff --git a/pubspec.lock b/pubspec.lock index ffb0c75..f090894 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -6,7 +6,7 @@ packages: description: name: _fe_analyzer_shared sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "85.0.0" analyzer: @@ -14,7 +14,7 @@ packages: description: name: analyzer sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "7.6.0" analyzer_plugin: @@ -22,7 +22,7 @@ packages: description: name: analyzer_plugin sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.13.4" android_id: @@ -30,7 +30,7 @@ packages: description: name: android_id sha256: "543bbfcf316de69d3ac36601d74eeaacd0248178a2671b00ad30d09f35bd3581" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.5.2+1" args: @@ -38,7 +38,7 @@ packages: description: name: args sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.7.0" async: @@ -46,7 +46,7 @@ packages: description: name: async sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.13.1" boolean_selector: @@ -54,7 +54,7 @@ packages: description: name: boolean_selector sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.2" build: @@ -62,7 +62,7 @@ packages: description: name: build sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.5.4" build_config: @@ -70,7 +70,7 @@ packages: description: name: build_config sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.2" build_daemon: @@ -78,7 +78,7 @@ packages: description: name: build_daemon sha256: "79e05eaf15a48d7230b053a4363b8eaac0cc234bbd0134c3229455481f55cbc6" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.1.5" build_resolvers: @@ -86,7 +86,7 @@ packages: description: name: build_resolvers sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.5.4" build_runner: @@ -94,7 +94,7 @@ packages: description: name: build_runner sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.5.4" build_runner_core: @@ -102,7 +102,7 @@ packages: description: name: build_runner_core sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "9.1.2" built_collection: @@ -110,7 +110,7 @@ packages: description: name: built_collection sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "5.1.1" built_value: @@ -118,7 +118,7 @@ packages: description: name: built_value sha256: "31b24be6615ec7fcf70b3aa5a7469fe35826485e639a16dd7eb83ba30e4cc6a8" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "8.12.7" characters: @@ -126,7 +126,7 @@ packages: description: name: characters sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.1" checked_yaml: @@ -134,7 +134,7 @@ packages: description: name: checked_yaml sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.4" clock: @@ -142,23 +142,23 @@ packages: description: name: clock sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.2" code_assets: dependency: transitive description: name: code_assets - sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" - url: "https://pub.flutter-io.cn" + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.2.1" code_builder: dependency: transitive description: name: code_builder sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.11.1" collection: @@ -166,7 +166,7 @@ packages: description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.19.1" convert: @@ -174,7 +174,7 @@ packages: description: name: convert sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.2" crypto: @@ -182,7 +182,7 @@ packages: description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.7" cupertino_icons: @@ -190,7 +190,7 @@ packages: description: name: cupertino_icons sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.9" cupertino_ui: @@ -198,7 +198,7 @@ packages: description: name: cupertino_ui sha256: "7ed8ce4159d342eec4c65f4ea6eec57adaf9365404378541f38efc1da20a5b3d" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.0" custom_lint_core: @@ -206,7 +206,7 @@ packages: description: name: custom_lint_core sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.7.5" custom_lint_visitor: @@ -214,7 +214,7 @@ packages: description: name: custom_lint_visitor sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.0+7.7.0" dart_style: @@ -222,7 +222,7 @@ packages: description: name: dart_style sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.1" dart_webrtc: @@ -230,47 +230,47 @@ packages: description: name: dart_webrtc sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.8.1" device_info_plus: dependency: "direct main" description: name: device_info_plus - sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65" - url: "https://pub.flutter-io.cn" + sha256: "0891702f96b2e465fe567b7ec448380e6b1c14f60af552a8536d9f583b6b8442" + url: "https://pub.dev" source: hosted - version: "13.1.0" + version: "13.2.0" device_info_plus_platform_interface: dependency: transitive description: name: device_info_plus_platform_interface sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "8.1.0" dio: dependency: "direct main" description: name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c - url: "https://pub.flutter-io.cn" + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.dev" source: hosted - version: "5.9.2" + version: "5.11.0" dio_web_adapter: dependency: transitive description: name: dio_web_adapter - sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" - url: "https://pub.flutter-io.cn" + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.2.1" fake_async: dependency: transitive description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.3" ffi: @@ -278,7 +278,7 @@ packages: description: name: ffi sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.0" ffi_leak_tracker: @@ -286,7 +286,7 @@ packages: description: name: ffi_leak_tracker sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.1.2" file: @@ -294,7 +294,7 @@ packages: description: name: file sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "7.0.1" fixnum: @@ -302,7 +302,7 @@ packages: description: name: fixnum sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.1" flutter: @@ -311,27 +311,35 @@ packages: source: sdk version: "0.0.0" flutter_baidu_mapapi_base: - dependency: transitive + dependency: "direct main" description: name: flutter_baidu_mapapi_base - sha256: "5a85ad1df38d2614dd556d7fd9de2e28ffc4890a89fc9e3a4b3a311304b5bad8" - url: "https://pub.flutter-io.cn" + sha256: bafde09cb9c623fede29a0abd5bea2367e2dd7227784670af64cec5d4d81366d + url: "https://pub.dev" source: hosted - version: "3.9.9+1" + version: "3.9.9" flutter_baidu_mapapi_map: dependency: "direct main" description: name: flutter_baidu_mapapi_map - sha256: "74f72140ed92368559434fcaf423eb5d8a466c1261229633fab50d4f01da4b01" - url: "https://pub.flutter-io.cn" + sha256: "670e48687e1ec6a09df48e55800dcf6c08193d977265a6a6da12b7a6471af956" + url: "https://pub.dev" source: hosted - version: "3.9.9+1" + version: "3.9.9" + flutter_bmflocation: + dependency: "direct main" + description: + name: flutter_bmflocation + sha256: c7af05b856f058603fd648bfe3786a6395f2ae5a6efaabaab8b3f9c96cdb8f3f + url: "https://pub.dev" + source: hosted + version: "3.8.4+1" flutter_lints: dependency: "direct dev" description: name: flutter_lints sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.0.0" flutter_localizations: @@ -343,16 +351,16 @@ packages: dependency: transitive description: name: flutter_plugin_android_lifecycle - sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" - url: "https://pub.flutter-io.cn" + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" source: hosted - version: "2.0.34" + version: "2.0.35" flutter_riverpod: dependency: "direct main" description: name: flutter_riverpod sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.6.1" flutter_secure_storage: @@ -360,7 +368,7 @@ packages: description: name: flutter_secure_storage sha256: "15e8c8fe269fdf7d469b23008ab3df521c8b826ed345820532364c31bdebace6" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "11.0.0" flutter_secure_storage_darwin: @@ -368,7 +376,7 @@ packages: description: name: flutter_secure_storage_darwin sha256: ac6d76a752de0cd738334eb4b21743fc4943f449f5b6e308f18838b048c02ac0 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.4.0" flutter_secure_storage_linux: @@ -376,7 +384,7 @@ packages: description: name: flutter_secure_storage_linux sha256: "76fa9c841b3b1619fc5b5bc36efc7d158fa2356f223b6caeb1d0c80a54168546" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.2" flutter_secure_storage_platform_interface: @@ -384,7 +392,7 @@ packages: description: name: flutter_secure_storage_platform_interface sha256: "788060052712555182aba55ecb5f8b6e5cb9cfe8f776c83249a61fe3ce877db4" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.3" flutter_secure_storage_web: @@ -392,7 +400,7 @@ packages: description: name: flutter_secure_storage_web sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.1" flutter_secure_storage_windows: @@ -400,7 +408,7 @@ packages: description: name: flutter_secure_storage_windows sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.2.2" flutter_test: @@ -418,7 +426,7 @@ packages: description: name: flutter_webrtc sha256: e997161d7da3adedd3d430691b20931b0b4d96fa48bb60938d9ba0bf6fca98be - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.6.0" freezed: @@ -426,7 +434,7 @@ packages: description: name: freezed sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.5.8" freezed_annotation: @@ -434,7 +442,7 @@ packages: description: name: freezed_annotation sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.4" frontend_server_client: @@ -442,7 +450,7 @@ packages: description: name: frontend_server_client sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.0.0" glob: @@ -450,7 +458,7 @@ packages: description: name: glob sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.3" go_router: @@ -458,7 +466,7 @@ packages: description: name: go_router sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "14.8.1" graphs: @@ -466,23 +474,23 @@ packages: description: name: graphs sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.2" hooks: dependency: transitive description: name: hooks - sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" - url: "https://pub.flutter-io.cn" + sha256: eaac480a35ec0814146c2c48d96aaa829e0e44a7662c88ae84c9edf4bc35651f + url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "2.2.0" http: dependency: transitive description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.6.0" http_multi_server: @@ -490,7 +498,7 @@ packages: description: name: http_multi_server sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.2.2" http_parser: @@ -498,47 +506,55 @@ packages: description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.1.2" intl: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" - url: "https://pub.flutter-io.cn" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" + url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" io: dependency: transitive description: name: io sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.5" jni: dependency: transitive description: name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f - url: "https://pub.flutter-io.cn" + sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.3" jni_flutter: dependency: transitive description: name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" - url: "https://pub.flutter-io.cn" + sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" + url: "https://pub.dev" source: hosted - version: "1.0.1" + version: "1.0.2" + jni_util: + dependency: transitive + description: + name: jni_util + sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" + url: "https://pub.dev" + source: hosted + version: "1.0.0" js: dependency: transitive description: name: js sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.7.2" json_annotation: @@ -546,7 +562,7 @@ packages: description: name: json_annotation sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.9.0" json_serializable: @@ -554,7 +570,7 @@ packages: description: name: json_serializable sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.9.5" leak_tracker: @@ -562,7 +578,7 @@ packages: description: name: leak_tracker sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "11.0.2" leak_tracker_flutter_testing: @@ -570,7 +586,7 @@ packages: description: name: leak_tracker_flutter_testing sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.10" leak_tracker_testing: @@ -578,7 +594,7 @@ packages: description: name: leak_tracker_testing sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.2" lints: @@ -586,7 +602,7 @@ packages: description: name: lints sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.1.0" logger: @@ -594,7 +610,7 @@ packages: description: name: logger sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.7.0" logging: @@ -602,23 +618,23 @@ packages: description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.0" matcher: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.flutter-io.cn" + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: name: material_color_utilities sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.13.0" material_ui: @@ -626,103 +642,95 @@ packages: description: name: material_ui sha256: d9b4f6c69b80bc83d0a14357c86e4c14c8076e807ae73cf2960c8560f623995f - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.0" meta: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.flutter-io.cn" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.0" mime: dependency: transitive description: name: mime sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.0" mmkv: dependency: "direct main" description: name: mmkv - sha256: "42fb1a6be1b00051612b4060e343eb28ade355ef6b845b07351696b2c6d2b256" - url: "https://pub.flutter-io.cn" + sha256: af1b7a0f1ebf6dd26c0bbcf8a214eb0ac4d456d6087363719d7bccfa7615b95b + url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" mmkv_android: dependency: transitive description: name: mmkv_android - sha256: "1cf400ea4527306fcbe9db962441d8fe347cfad1dfd8034b4f465c5d501dc40d" - url: "https://pub.flutter-io.cn" + sha256: "0ba77fdfa74c42c06ada6dae7bef5634482e983cf41adecf79441a3d52b4ef12" + url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" mmkv_ios: dependency: transitive description: name: mmkv_ios - sha256: ee97fd5c1d7941fc4a5a60f68c08b505d9257a22b10081a90aa3325fd782da18 - url: "https://pub.flutter-io.cn" + sha256: "2db1adfcb54bdcbe53270ae558d6f328c98fc93431dc6951aafe37c2ec1b2cb1" + url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" mmkv_linux: dependency: transitive description: name: mmkv_linux - sha256: a8f66b18c673d56c62abf7e7097d2d5b760f8e10afc5ff01f02f4e91683b3dcf - url: "https://pub.flutter-io.cn" + sha256: ca193279250054089736ae4aba36b31ed615bdae1f85f2f7235f668299ccedca + url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" mmkv_ohos: dependency: transitive description: name: mmkv_ohos - sha256: "3477f5f9233ed6e5929e75bee9273f7835de576d637fedd6a72572bb230b72b5" - url: "https://pub.flutter-io.cn" + sha256: "6d53f04e556acd265fa8e0841b17696009c76c4ec3d6bef0d81e4cc16de2d683" + url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" mmkv_platform_interface: dependency: transitive description: name: mmkv_platform_interface sha256: bef7422b14f84297fe637adc82b9fe018155a429fcbaef53efb06ecf005b0288 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.0" mmkv_win32: dependency: transitive description: name: mmkv_win32 - sha256: "34b8b8a4596a23bc375f4ea811fc7d81674a15a4961d58dd9b126b50168d41a8" - url: "https://pub.flutter-io.cn" + sha256: a8f97c1d9c92073b0a92237093d54a6d4bf2203cd1420cb4bdacb8c70d62e340 + url: "https://pub.dev" source: hosted - version: "2.4.0" - native_toolchain_c: - dependency: transitive - description: - name: native_toolchain_c - sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.17.6" + version: "2.4.1" objective_c: dependency: transitive description: name: objective_c - sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" - url: "https://pub.flutter-io.cn" + sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e + url: "https://pub.dev" source: hosted - version: "9.3.0" + version: "9.5.0" package_config: dependency: transitive description: name: package_config sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.0" path: @@ -730,23 +738,23 @@ packages: description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.9.1" path_provider: dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.flutter-io.cn" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: name: path_provider_android sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.1" path_provider_foundation: @@ -754,39 +762,87 @@ packages: description: name: path_provider_foundation sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.6.0" path_provider_linux: dependency: transitive description: name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.flutter-io.cn" + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.2.2" path_provider_platform_interface: dependency: transitive description: name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.flutter-io.cn" + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" path_provider_windows: dependency: transitive description: name: path_provider_windows sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: fe54465bcc62a4564c6e4db337bbaded6c0c0fa6e10487414436d163114784f6 + url: "https://pub.dev" + source: hosted + version: "12.0.3" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f49cb15a064ea9d974fc7fbb302099353b7b170d07284e86e264561579e5bcf8 + url: "https://pub.dev" + source: hosted + version: "9.6.1" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "6ea98b3f17f60d3b527f2647ed2ab4dc0f6bfe25b22cb1c363f5d8f62252f6ac" + url: "https://pub.dev" + source: hosted + version: "0.1.4+1" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: a5c8a97ecf5616112a5b16d4b8e9ec0e5ae90ef63ac69c0d7b8ae240be760b23 + url: "https://pub.dev" + source: hosted + version: "4.4.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: caeae01858a0a7d2df67a445ac98e1ad95e55a0e77c73044f4e9b1c8c2289cbd + url: "https://pub.dev" + source: hosted + version: "0.2.2" platform: dependency: transitive description: name: platform sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.6" plugin_platform_interface: @@ -794,7 +850,7 @@ packages: description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.8" pool: @@ -802,7 +858,7 @@ packages: description: name: pool sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.5.2" protobuf: @@ -810,7 +866,7 @@ packages: description: name: protobuf sha256: "75ec242d22e950bdcc79ee38dd520ce4ee0bc491d7fadc4ea47694604d22bf06" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "6.0.0" pub_semver: @@ -818,7 +874,7 @@ packages: description: name: pub_semver sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.2.0" pubspec_parse: @@ -826,23 +882,23 @@ packages: description: name: pubspec_parse sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.5.0" record_use: dependency: transitive description: name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.flutter-io.cn" + sha256: "1cb8564af8d43b464294411db9217f5ec04891c6f22ee2c32d73ae05e88a6bd2" + url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "1.1.1" riverpod: dependency: transitive description: name: riverpod sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.6.1" riverpod_analyzer_utils: @@ -850,7 +906,7 @@ packages: description: name: riverpod_analyzer_utils sha256: "837a6dc33f490706c7f4632c516bcd10804ee4d9ccc8046124ca56388715fdf3" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "0.5.9" riverpod_annotation: @@ -858,7 +914,7 @@ packages: description: name: riverpod_annotation sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.6.1" riverpod_generator: @@ -866,7 +922,7 @@ packages: description: name: riverpod_generator sha256: "120d3310f687f43e7011bb213b90a436f1bbc300f0e4b251a72c39bccb017a4f" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.6.4" shared_preferences: @@ -874,7 +930,7 @@ packages: description: name: shared_preferences sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.5.5" shared_preferences_android: @@ -882,7 +938,7 @@ packages: description: name: shared_preferences_android sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.27" shared_preferences_foundation: @@ -890,7 +946,7 @@ packages: description: name: shared_preferences_foundation sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.5.6" shared_preferences_linux: @@ -898,7 +954,7 @@ packages: description: name: shared_preferences_linux sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.1" shared_preferences_platform_interface: @@ -906,7 +962,7 @@ packages: description: name: shared_preferences_platform_interface sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.2" shared_preferences_web: @@ -914,7 +970,7 @@ packages: description: name: shared_preferences_web sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.3" shared_preferences_windows: @@ -922,7 +978,7 @@ packages: description: name: shared_preferences_windows sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.4.1" shelf: @@ -930,7 +986,7 @@ packages: description: name: shelf sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.2" shelf_web_socket: @@ -938,7 +994,7 @@ packages: description: name: shelf_web_socket sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.0" sky_engine: @@ -951,7 +1007,7 @@ packages: description: name: source_gen sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.0.0" source_helper: @@ -959,7 +1015,7 @@ packages: description: name: source_helper sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.3.7" source_span: @@ -967,7 +1023,7 @@ packages: description: name: source_span sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.10.2" stack_trace: @@ -975,7 +1031,7 @@ packages: description: name: stack_trace sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.12.1" state_notifier: @@ -983,7 +1039,7 @@ packages: description: name: state_notifier sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.0" stream_channel: @@ -991,7 +1047,7 @@ packages: description: name: stream_channel sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.4" stream_transform: @@ -999,7 +1055,7 @@ packages: description: name: stream_transform sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "2.1.1" string_scanner: @@ -1007,39 +1063,39 @@ packages: description: name: string_scanner sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.1" synchronized: dependency: transitive description: name: synchronized - sha256: "61894a1956de6b4fc1aefd0892e109514a1a706cbece3ac59decd90ff5a7a423" - url: "https://pub.flutter-io.cn" + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.dev" source: hosted - version: "3.4.1+1" + version: "3.4.1+2" term_glyph: dependency: transitive description: name: term_glyph sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.flutter-io.cn" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" timing: dependency: transitive description: name: timing sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.2" typed_data: @@ -1047,7 +1103,7 @@ packages: description: name: typed_data sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.4.0" uuid: @@ -1055,23 +1111,23 @@ packages: description: name: uuid sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "4.6.0" vector_math: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.flutter-io.cn" + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vm_service: dependency: transitive description: name: vm_service sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "15.2.0" watcher: @@ -1079,7 +1135,7 @@ packages: description: name: watcher sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.2.1" web: @@ -1087,7 +1143,7 @@ packages: description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.1" web_socket: @@ -1095,7 +1151,7 @@ packages: description: name: web_socket sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.0.1" web_socket_channel: @@ -1103,7 +1159,7 @@ packages: description: name: web_socket_channel sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.3" webrtc_interface: @@ -1111,23 +1167,23 @@ packages: description: name: webrtc_interface sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.5.1" win32: dependency: transitive description: name: win32 - sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9 - url: "https://pub.flutter-io.cn" + sha256: a0b93865d5644f11cf6a8c3f6db909f1ec168958b5805f6cc684adea957cd63d + url: "https://pub.dev" source: hosted - version: "6.2.0" + version: "6.4.0" win32_registry: dependency: transitive description: name: win32_registry sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.0.3" xdg_directories: @@ -1135,7 +1191,7 @@ packages: description: name: xdg_directories sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "1.1.0" yaml: @@ -1143,7 +1199,7 @@ packages: description: name: yaml sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.flutter-io.cn" + url: "https://pub.dev" source: hosted version: "3.1.3" sdks: diff --git a/pubspec.yaml b/pubspec.yaml index 2297a00..ac3662c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,7 +35,13 @@ dependencies: sdk: flutter mmkv: ^2.4.0 - flutter_baidu_mapapi_map: ^3.9.9 + flutter_baidu_mapapi_base: 3.9.9 + flutter_baidu_mapapi_map: 3.9.9 + # 百度地图定位插件:获取家属端手机真实经纬度,用于在地图上显示本机蓝色定位点 + flutter_bmflocation: ^3.8.4+1 + + # 运行时定位权限请求(Android 6.0+ / iOS 需动态申请定位权限) + permission_handler: ^12.0.1 # 跨平台 WebRTC(同时支持 Android 与 iOS) flutter_webrtc: ^1.5.2 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 6c59d9f..30d83a3 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( @@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); MmkvWin32PluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("MmkvWin32Plugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 3bdf77e..fa4ea08 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows flutter_webrtc mmkv_win32 + permission_handler_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST