feat(android): 支持沉浸式状态栏、地图定位与自定义签名

- 配置 edge-to-edge 沉浸式状态栏与刘海屏适配
- 新增百度地图权限、AK 配置及定位权限说明
- 接入 MethodChannel 实现返回键退后台热启动优化
- 配置自定义签名并更新构建逻辑
- 完善 token 刷新与鉴权失效统一处理
- 新增地图页与截图页路由
- 修复返回键拦截导致的 PopScope 失效问题
This commit is contained in:
TongTongStudio
2026-08-19 03:43:12 +08:00
parent 41d815336a
commit 06cdc77172
51 changed files with 3988 additions and 634 deletions

View File

@@ -352,3 +352,35 @@ static const String kBaseUrl = 'http://<host>:<port>/api/v1/open/';
| `login` | `/login/sms/code` | 登录页(验证码登录) | | `login` | `/login/sms/code` | 登录页(验证码登录) |
| `register` | `/register/sms/code` | 注册页 | | `register` | `/register/sms/code` | 注册页 |
| `resetPassword` | `/reset-password/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 <LocalizationsDelegate<dynamic>>[
GlobalCupertinoLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
```
迁移后:
```dart
import 'package:material_ui/material_ui.dart';
localizationsDelegates: GlobalMaterialLocalizations.delegates,
```

View File

@@ -1,3 +1,6 @@
import java.util.Properties
import java.io.FileInputStream
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@@ -5,6 +8,13 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
// 读取签名配置
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android { android {
namespace = "com.ttstd.familycare" namespace = "com.ttstd.familycare"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@@ -30,11 +40,22 @@ android {
versionName = flutter.versionName 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 { buildTypes {
debug {
signingConfig = signingConfigs.getByName("own")
}
release { 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("own")
signingConfig = signingConfigs.getByName("debug")
} }
} }
} }

View File

@@ -1,17 +1,43 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
<!-- 百度地图所需权限 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application <application
android:label="ttstd_family_care" android:label="ttstd_family_care"
android:name="${applicationName}" android:name=".AppApplication"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTask"
android:taskAffinity="" android:taskAffinity=""
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize"
android:windowLayoutInDisplayCutoutMode="shortEdges">
<!-- Specifies an Android theme to apply to this Activity as soon as <!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues while the Flutter UI initializes. After that, this theme continues
@@ -30,16 +56,10 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<!-- 百度地图 AK -->
<meta-data
android:name="com.baidu.lbsapi.API_KEY"
android:value="bTdW9YqOykriIUvXe4RpZ2SEgVciR33B" />
</application> </application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest> </manifest>

View File

@@ -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 {
}

View File

@@ -1,6 +1,38 @@
package com.ttstd.familycare; package com.ttstd.familycare;
import androidx.annotation.NonNull;
import io.flutter.embedding.android.FlutterActivity; import io.flutter.embedding.android.FlutterActivity;
import io.flutter.embedding.engine.FlutterEngine;
import io.flutter.plugin.common.MethodChannel;
public class MainActivity extends FlutterActivity { 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。
*/
} }

View File

@@ -5,6 +5,12 @@
<!-- Show a splash screen on the activity. Automatically removed when <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
<!-- 沉浸式:状态栏与导航栏透明,内容延伸到安全区(edge-to-edge) -->
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- 刘海屏:允许内容绘制到刘海区域 -->
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your
@@ -14,5 +20,11 @@
This Theme is only used starting with V2 of Flutter's Android embedding. --> This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">?android:colorBackground</item>
<!-- 沉浸式:状态栏与导航栏透明,内容延伸到安全区(edge-to-edge) -->
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- 刘海屏:允许内容绘制到刘海区域 -->
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style> </style>
</resources> </resources>

View File

@@ -5,6 +5,12 @@
<!-- Show a splash screen on the activity. Automatically removed when <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
<!-- 沉浸式:状态栏与导航栏透明,内容延伸到安全区(edge-to-edge) -->
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- 刘海屏:允许内容绘制到刘海区域 -->
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style> </style>
<!-- Theme applied to the Android Window as soon as the process has started. <!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your This theme determines the color of the Android Window while your
@@ -14,5 +20,11 @@
This Theme is only used starting with V2 of Flutter's Android embedding. --> This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">?android:colorBackground</item>
<!-- 沉浸式:状态栏与导航栏透明,内容延伸到安全区(edge-to-edge) -->
<item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<!-- 刘海屏:允许内容绘制到刘海区域 -->
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style> </style>
</resources> </resources>

View File

View File

@@ -49,6 +49,10 @@
</dict> </dict>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UIStatusBarStyle</key>
<string>UIStatusBarStyleDarkContent</string>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
@@ -66,5 +70,14 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<!-- 定位权限说明(百度地图展示设备位置需获取位置) -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>用于在地图上展示设备位置</string>
<key>NSLocationAlwaysUsageDescription</key>
<string>用于在地图上展示设备位置</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>用于在地图上展示设备位置</string>
<key>io.flutter.embedded_views_preview</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@@ -1,4 +1,5 @@
import 'package:cupertino_ui/cupertino_ui.dart'; import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -19,7 +20,15 @@ class App extends StatelessWidget {
title: '桐桐家庭关怀', title: '桐桐家庭关怀',
theme: AppTheme.cupertinoTheme, theme: AppTheme.cupertinoTheme,
routerConfig: router, routerConfig: router,
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates: <LocalizationsDelegate<dynamic>>[
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, supportedLocales: AppLocalizations.supportedLocales,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
); );

View File

@@ -3,7 +3,7 @@ class AppConstants {
const 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; static const int kConnectTimeoutMs = 15000;
@@ -18,4 +18,7 @@ class AppConstants {
/// 应用名称。 /// 应用名称。
static const String kAppName = '家庭关怀'; static const String kAppName = '家庭关怀';
/// 百度地图开放平台申请的 AK(Android / iOS 共用)。
static const String kBaiduMapAk = '请替换为百度地图开放平台申请的AK';
} }

View File

@@ -1,3 +1,5 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../features/auth/presentation/login_page.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/auth/presentation/splash_page.dart';
import '../../features/home/presentation/home_page.dart'; import '../../features/home/presentation/home_page.dart';
import '../../features/home/presentation/home_tab.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/messages/presentation/messages_tab.dart';
import '../../features/profile/presentation/profile_tab.dart'; import '../../features/profile/presentation/profile_tab.dart';
import '../../features/album/presentation/album_page.dart'; import '../../features/album/presentation/album_page.dart';
import '../../features/alarm/presentation/alarm_page.dart'; import '../../features/alarm/presentation/alarm_page.dart';
import '../../features/contacts/presentation/contacts_page.dart'; import '../../features/contacts/presentation/contacts_page.dart';
import '../../features/apps/presentation/apps_page.dart'; import '../../features/apps/presentation/apps_page.dart';
import '../../features/device/data/device_providers.dart';
import '../../features/device/domain/device_models.dart';
/// 应用路由定义(GoRouter)。 /// 应用路由定义(GoRouter)。
/// ///
@@ -61,6 +67,16 @@ GoRouter buildAppRouter() {
path: '/apps', path: '/apps',
builder: (context, state) => const AppsPage(), 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( StatefulShellRoute.indexedStack(
builder: (context, state, shell) => HomePage(child: shell), builder: (context, state, shell) => HomePage(child: shell),
branches: [ 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<List<ScreenshotVO>>.data([])
: ref.watch(recentScreenshotsProvider(sn));
final list = screenshotsAsync.valueOrNull ?? const <ScreenshotVO>[];
// 越界保护:初始索引落在列表内,否则回退到 0。
final index =
(list.isNotEmpty && initialIndex < list.length) ? initialIndex : 0;
return ScreenshotPage(screenshots: list, initialIndex: index);
}
}

View File

@@ -54,6 +54,15 @@ class AppError {
); );
} }
if (status != null) { if (status != null) {
// 鉴权失效统一映射为 unauthorized 类型。
if (status == 401) {
return AppError._(
type: AppErrorType.unauthorized,
message: '登录已失效,请重新登录',
statusCode: status,
original: e,
);
}
return AppError._( return AppError._(
type: AppErrorType.server, type: AppErrorType.server,
message: '服务暂时不可用(HTTP $status)', message: '服务暂时不可用(HTTP $status)',

View File

@@ -48,6 +48,87 @@ class DioClient {
return dio; 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<dynamic> _refreshAndRetry(
Future<Response<dynamic>> 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<bool> _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<dynamic>(
'v1/open/refresh-token',
queryParameters: {'refreshToken': refreshToken},
);
debugPrint('[DioClient] _refreshToken: response status=${response.statusCode} body=${response.data}');
final body = response.data as Map<String, dynamic>?;
if (body == null) return false;
final code = body['code']?.toString() ?? '-1';
// 成功码约定为 "00000"。
if (code != '00000') return false;
final data = body['data'] as Map<String, dynamic>?;
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。 /// 统一 GET 请求,返回解析后的 data。
static Future<dynamic> get( static Future<dynamic> get(
String path, { String path, {
@@ -80,6 +161,9 @@ class DioClient {
/// 统一请求入口:发起请求,错误由拦截器链转换为 [ApiException],此处再包成 /// 统一请求入口:发起请求,错误由拦截器链转换为 [ApiException],此处再包成
/// [AppError] 以便调用方按需分类处理。Repository 直接捕获 [ApiException] 即可。 /// [AppError] 以便调用方按需分类处理。Repository 直接捕获 [ApiException] 即可。
///
/// 401 / 令牌失效处理:无论后台以「HTTP 401 + 业务码」还是纯业务码表达令牌失效,
/// 均在此统一捕获并尝试刷新 token 后重试一次;刷新失败抛出 unauthorized 错误。
static Future<dynamic> _request( static Future<dynamic> _request(
Future<Response<dynamic>> Function() call, Future<Response<dynamic>> Function() call,
) async { ) async {
@@ -92,8 +176,19 @@ class DioClient {
'statusCode=${response.statusCode}', 'statusCode=${response.statusCode}',
); );
debugPrint('[DioClient] response body=${response.data}'); debugPrint('[DioClient] response body=${response.data}');
// 解析成功返回 data;若为业务错误(code != "00000")会在此抛出 ApiException。
return _ResponseInterceptor.parse(response); 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; rethrow;
} on DioException catch (e) { } on DioException catch (e) {
debugPrint( debugPrint(
@@ -102,6 +197,12 @@ class DioClient {
'type=${e.type} message=${e.message}', 'type=${e.type} message=${e.message}',
); );
debugPrint('[DioClient] error response body=${e.response?.data}'); 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( throw ApiException.fromAppError(
AppError.fromDioException(e), AppError.fromDioException(e),
); );
@@ -110,12 +211,29 @@ class DioClient {
throw ApiException.fromAppError(AppError.unknown(e)); 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 头。 /// 请求拦截器:自动为已登录会话注入 Authorization 头。
class _AuthInterceptor extends Interceptor { class _AuthInterceptor extends Interceptor {
@override @override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) { void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
// [DEBUG] 打印所有接口请求的完整地址(debugPrint 在 release 下为空实现)。
if (kDebugMode) {
debugPrint(
'[DioClient] -> ${options.method} ${options.uri}',
);
}
final token = TokenStorage.accessToken; final token = TokenStorage.accessToken;
if (token != null && token.isNotEmpty) { if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token'; 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 { class _ErrorInterceptor extends Interceptor {
/// 标记当前是否正在刷新,避免并发 401 引发多次刷新。
static bool _isRefreshing = false;
@override @override
Future<void> onError( void onError(DioException err, ErrorInterceptorHandler handler) {
DioException err, final response = err.response;
ErrorInterceptorHandler handler, final statusCode = response?.statusCode;
) async { final body = response?.data as Map<String, dynamic>?;
// HTTP 401:尝试刷新 token 并重试原请求一次。 final code = body?['code']?.toString();
if (err.response?.statusCode == 401 && !_isRefreshing) {
try { // 访问权限异常(A0300):通常意味着当前令牌的端标识不符(如旧令牌未携带
_isRefreshing = true; // clientType,或跨端令牌)。此类令牌无法通过刷新恢复客户端身份,直接清理
final refreshed = await _refreshToken(); // 本地令牌并转为「登录失效」,由上层引导重新登录,避免反复提示权限异常。
if (refreshed) { if (code == 'A0300') {
final clone = await _retry(err.requestOptions); debugPrint(
handler.resolve(clone); '[DioClient] _ErrorInterceptor: 端标识/权限异常 '
return; '(status=$statusCode, code=$code). Clearing token & require re-login.',
} );
} catch (_) { TokenStorage.clear();
// 刷新失败:继续向下抛出 unauthorized。 handler.next(_asLoginExpired(err));
} finally { return;
_isRefreshing = false; }
}
// 统一判断是否为令牌失效(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); 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 后重放原请求。
/// ///
/// 返回是否刷新成功。具体刷新接口路径以后台约定为准。 /// 刷新受 [DioClient._isRefreshing] 保护(并发 401 只刷新一次)。重放通过
static Future<bool> _refreshToken() async { /// [DioClient.instance.fetch] 进行,会自动重新注入新 token 并再次经过
final refreshToken = TokenStorage.refreshToken; /// 拦截器链。刷新失败则向下传递原始 401。
if (refreshToken == null || refreshToken.isEmpty) return false; static void _handle401(
DioException err,
ErrorInterceptorHandler handler,
) async {
try { try {
final response = await DioClient.instance.post<dynamic>( final refreshed = await _refreshTokenOnce();
'/refresh-token', if (!refreshed) {
queryParameters: {'refreshToken': refreshToken}, TokenStorage.clear();
); handler.next(err);
final data = response as Map<String, dynamic>?; return;
final newToken = data?['accessToken'] as String?; }
final newRefresh = data?['refreshToken'] as String?; final response = await DioClient.instance.fetch(err.requestOptions);
if (newToken == null || newToken.isEmpty) return false; handler.resolve(response);
TokenStorage.saveTokens(
token: newToken,
refreshToken: newRefresh,
);
return true;
} catch (_) { } catch (_) {
return false; handler.next(err);
} }
} }
/// 用原请求参数重放一次请求。 /// 刷新 token(带并发保护)。
static Future<Response<dynamic>> _retry(RequestOptions options) { ///
return DioClient.instance.request<dynamic>( /// 返回是否刷新成功。刷新接口为匿名接口,使用独立 [_refreshDio],
options.path, /// 不触发任何拦截器,避免递归进入 401 流程。
data: options.data, static Future<bool> _refreshTokenOnce() async {
queryParameters: options.queryParameters, debugPrint('[DioClient] _refreshTokenOnce: isRefreshing=${DioClient._isRefreshing}');
options: Options( if (DioClient._isRefreshing) {
method: options.method, // 已有刷新在途:简单处理为失败,交由上层登出,避免复杂队列。
headers: { return false;
...options.headers, }
'Authorization': 'Bearer ${TokenStorage.accessToken}', DioClient._isRefreshing = true;
}, try {
), return await DioClient._refreshToken();
); } finally {
DioClient._isRefreshing = false;
}
} }
} }

View File

@@ -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);
}

View File

@@ -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<void> 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<void> 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();
}
}
}

View File

@@ -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');

View File

@@ -49,6 +49,7 @@ class IconTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tile = Column( final tile = Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Container( Container(
width: size, width: size,

View File

@@ -26,21 +26,32 @@ class _AlarmPageState extends State<AlarmPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return CupertinoPageScaffold( return PopScope(
navigationBar: CupertinoNavigationBar( // 拦截系统返回键:优先出栈;若无法出栈则跳回「管理」页,避免直接退到桌面。
middle: Text(l10n.alarmTitle), canPop: false,
leading: CupertinoButton( onPopInvokedWithResult: (didPop, _) {
padding: EdgeInsets.zero, if (didPop) return;
child: const Icon(CupertinoIcons.back), if (context.canPop()) {
onPressed: () => context.go('/messages'), 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( child: ListView.separated(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.add, size: 26),
onPressed: () {},
),
),
child: ListView.separated(
padding: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.only(bottom: 24),
itemCount: _alarms.length + 1, itemCount: _alarms.length + 1,
separatorBuilder: (_, _) => const SizedBox(height: 12), separatorBuilder: (_, _) => const SizedBox(height: 12),
@@ -100,6 +111,7 @@ class _AlarmPageState extends State<AlarmPage> {
); );
}, },
), ),
),
); );
} }
} }

View File

@@ -40,20 +40,31 @@ class _AlbumPageState extends State<AlbumPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return CupertinoPageScaffold( return PopScope(
navigationBar: CupertinoNavigationBar( // 拦截系统返回键:优先出栈;若无法出栈(如 go_router 顶级路由)则跳回「管理」页,避免直接退到桌面。
middle: Text(l10n.albumTitle), canPop: false,
leading: CupertinoButton( onPopInvokedWithResult: (didPop, _) {
padding: EdgeInsets.zero, if (didPop) return;
child: const Icon(CupertinoIcons.back), if (context.canPop()) {
onPressed: () => context.go('/messages'), 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( child: Stack(
children: [ children: [
ListView( ListView(
@@ -145,6 +156,7 @@ class _AlbumPageState extends State<AlbumPage> {
), ),
), ),
], ],
),
), ),
); );
} }

View File

@@ -1,137 +1,321 @@
import 'package:cupertino_ui/cupertino_ui.dart'; import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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'; /// 应用列表页(对接 client「已安装应用」接口)。
import '../../../l10n/app_localizations.dart'; class AppsPage extends ConsumerWidget {
/// 应用页(管理 → 应用)。
///
/// 对应移动端设计稿:搜索栏 + 常用九宫格 + 系统工具/影音娱乐分类列表。
class AppsPage extends StatelessWidget {
const AppsPage({super.key}); const AppsPage({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return CupertinoPageScaffold( final sn = ref.watch(selectedDeviceSnProvider);
navigationBar: CupertinoNavigationBar( final appsAsync = sn == null
middle: Text(l10n.appsTitle), ? const AsyncValue<List<DeviceApkInfo>>.data(const [])
leading: CupertinoButton( : ref.watch(deviceApksProvider(sn));
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.back), return PopScope(
onPressed: () => context.go('/messages'), // 拦截系统返回键:优先出栈;若无法出栈则跳回「管理」页,避免直接退到桌面。
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( child: SafeArea(
padding: EdgeInsets.zero, child: appsAsync.when(
child: const Text('管理'), loading: () => const Center(child: CupertinoActivityIndicator()),
onPressed: () {}, 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: [
// 搜索栏 Widget _appCell(
Container( BuildContext context,
margin: const EdgeInsets.fromLTRB(16, 10, 16, 4), WidgetRef ref,
height: 40, AppLocalizations l10n,
decoration: BoxDecoration( String? sn,
color: CupertinoColors.white, DeviceApkInfo app,
borderRadius: BorderRadius.circular(12), ) =>
boxShadow: const [BoxShadow(color: Color(0x14141E3C), blurRadius: 8, offset: Offset(0, 2))], 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: [ children: [
SizedBox(width: 14), Container(
Icon(CupertinoIcons.search, size: 16, color: AppColors.sub), width: 44,
SizedBox(width: 8), height: 44,
Text('搜索应用', style: TextStyle(fontSize: 14, color: AppColors.sub)), 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)),
),
], ],
), ),
), const SizedBox(height: 12),
SectionTitle(l10n.appsCommon), Row(
CardBox(
child: Column(
children: [ children: [
Row( _actionButton(context, ref, l10n, sn, app, 'launch',
children: [ l10n.appOpLaunch),
Expanded(child: IconTile(emoji: '🖼️', color: AppColors.blue, label: l10n.menuAlbum)), const SizedBox(width: 8),
Expanded(child: IconTile(emoji: '⏰', color: AppColors.orange, label: l10n.menuAlarm)), _actionButton(context, ref, l10n, sn, app, 'stop',
Expanded(child: IconTile(emoji: '👥', color: AppColors.green, label: l10n.menuContacts)), l10n.appOpStop),
Expanded(child: IconTile(emoji: '📁', color: AppColors.purple, label: l10n.menuFiles)), const SizedBox(width: 8),
], _actionButton(context, ref, l10n, sn, app, 'clear_data',
), l10n.appOpClearData),
const SizedBox(height: 14), const SizedBox(width: 8),
Row( _actionButton(
children: [ context,
Expanded(child: IconTile(emoji: '📷', color: AppColors.red, label: l10n.appsCamera)), ref,
Expanded(child: IconTile(emoji: '📅', color: AppColors.teal, label: '日历')), l10n,
Expanded(child: IconTile(emoji: '📝', color: const Color(0xFF22C55E), label: '备忘录')), sn,
Expanded(child: IconTile(emoji: '➕', color: AppColors.gray, label: l10n.appsAdd)), 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), child: Text(
CardBox( label,
padding: EdgeInsets.zero, style: TextStyle(
child: Column( fontSize: 12,
children: [ fontWeight: FontWeight.w600,
_appRow('⚙️', const Color(0xFF64748B), '设置', '系统 · 已安装'), color: color,
const AppDivider(),
_appRow('🌐', AppColors.teal, '浏览器', '系统 · 已安装'),
const AppDivider(),
_appRow('🗺️', const Color(0xFF22C55E), '地图', '系统 · 已安装'),
],
), ),
), ),
SectionTitle(l10n.appsEntertain), ),
CardBox( ),
padding: EdgeInsets.zero, );
child: Column( }
children: [
_appRow('🎵', AppColors.red, '音乐', 'v8.2 · 已安装'), /// 危险应用操作(卸载/清除数据)确认弹窗。
const AppDivider(), Future<void> _confirmAppOp(
_appRow('🎬', AppColors.orange, '视频', 'v5.1 · 已安装'), BuildContext context,
const AppDivider(), WidgetRef ref,
_appRow('📚', AppColors.purple, '阅读', 'v3.4 · 已安装'), AppLocalizations l10n,
], String? sn,
), DeviceApkInfo app,
String op,
String message,
) async {
final confirmed = await showCupertinoDialog<bool>(
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<void> _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( /// 应用图标:后台返回的是相对路径(如 /static/app_icon/xxx.png),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), /// 需要拼接接口 Host 才能作为网络图片加载;无图标或加载失败时回退到占位图标。
child: Row( Widget _appIcon(String? iconUrl) {
children: [ final resolved = _resolveIconUrl(iconUrl);
Container( if (resolved == null) {
width: 42, return const Center(
height: 42, child: Icon(CupertinoIcons.app_fill, color: AppColors.blue, size: 22),
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)),
),
],
),
); );
}
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';
}
} }

View File

@@ -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<LoginResult> 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<String, dynamic>);
}
/// 短信验证码登录。
Future<LoginResult> 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<String, dynamic>);
}
/// 短信注册。
Future<LoginResult> 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<String, dynamic>);
}
/// 发送短信验证码。
///
/// 后端按场景拆分验证码发送接口(query 参数 mobile)。
Future<void> 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<void> 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,
},
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../domain/auth_repository.dart'; import '../domain/auth_repository.dart';
import 'auth_api.dart';
import 'auth_repository_impl.dart'; import 'auth_repository_impl.dart';
/// 认证仓库 Provider。 /// 认证仓库 Provider。
@@ -8,5 +9,6 @@ import 'auth_repository_impl.dart';
/// 会话级依赖,通过 ref.keepAlive() 避免页面切换时被销毁重建。 /// 会话级依赖,通过 ref.keepAlive() 避免页面切换时被销毁重建。
final authRepositoryProvider = Provider<AuthRepository>((ref) { final authRepositoryProvider = Provider<AuthRepository>((ref) {
ref.keepAlive(); ref.keepAlive();
return const AuthRepositoryImpl(); const api = AuthApi();
return AuthRepositoryImpl(api);
}); });

View File

@@ -1,15 +1,16 @@
import '../../../core/network/dio_client.dart';
import '../../../core/storage/token_storage.dart'; import '../../../core/storage/token_storage.dart';
import '../domain/auth_models.dart'; import '../domain/auth_models.dart';
import '../domain/auth_repository.dart'; import '../domain/auth_repository.dart';
import 'auth_api.dart';
/// 认证仓库实现(数据层)。 /// [AuthRepository] 的默认实现,委托给 [AuthApi]。
/// ///
/// 对接后端 C 端(open 模块)认证接口,全部端点以 `/api/v1/open/auth/**` 为前缀 /// 负责网络层([DioClient])解耦之外的业务编排:登录/注册成功后将令牌落库
/// (`AppConstants.kBaseUrl` 已包含该前缀)。open 模块账号落地 `app_user`, /// ([TokenStorage]),UI 仅依赖领域层抽象。
/// 与后台管理端(`sys_user`)物理分表,令牌经 `TokenManager.generateToken` 本地签发。
class AuthRepositoryImpl implements AuthRepository { class AuthRepositoryImpl implements AuthRepository {
const AuthRepositoryImpl(); const AuthRepositoryImpl(this._api);
final AuthApi _api;
@override @override
Future<LoginResult> loginByPassword({ Future<LoginResult> loginByPassword({
@@ -17,14 +18,10 @@ class AuthRepositoryImpl implements AuthRepository {
required String password, required String password,
required String deviceId, required String deviceId,
}) async { }) async {
final data = await DioClient.post( final result = await _api.loginByPassword(
'/login', username: phone,
data: { password: password,
'username': phone,
'password': password,
},
); );
final result = LoginResult.fromJson(data as Map<String, dynamic>);
TokenStorage.saveTokens( TokenStorage.saveTokens(
token: result.token, token: result.token,
refreshToken: result.refreshToken, refreshToken: result.refreshToken,
@@ -38,14 +35,10 @@ class AuthRepositoryImpl implements AuthRepository {
required String code, required String code,
required String deviceId, required String deviceId,
}) async { }) async {
final data = await DioClient.post( final result = await _api.loginBySms(
'auth/login/mobile', phone: phone,
data: { code: code,
'mobile': phone,
'code': code,
},
); );
final result = LoginResult.fromJson(data as Map<String, dynamic>);
TokenStorage.saveTokens( TokenStorage.saveTokens(
token: result.token, token: result.token,
refreshToken: result.refreshToken, refreshToken: result.refreshToken,
@@ -60,15 +53,11 @@ class AuthRepositoryImpl implements AuthRepository {
required String password, required String password,
required String deviceId, required String deviceId,
}) async { }) async {
final data = await DioClient.post( final result = await _api.register(
'/register/mobile', phone: phone,
data: { code: code,
'mobile': phone, password: password,
'code': code,
'password': password,
},
); );
final result = LoginResult.fromJson(data as Map<String, dynamic>);
TokenStorage.saveTokens( TokenStorage.saveTokens(
token: result.token, token: result.token,
refreshToken: result.refreshToken, refreshToken: result.refreshToken,
@@ -80,32 +69,14 @@ class AuthRepositoryImpl implements AuthRepository {
Future<void> sendSmsCode({ Future<void> sendSmsCode({
required String phone, required String phone,
required SmsScene scene, required SmsScene scene,
}) async { }) =>
// 后端按场景拆分验证码发送接口(query 参数 mobile) _api.sendSmsCode(phone: phone, scene: scene);
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},
);
}
@override @override
Future<void> resetPassword({ Future<void> resetPassword({
required String phone, required String phone,
required String code, required String code,
required String password, required String password,
}) async { }) =>
await DioClient.post( _api.resetPassword(phone: phone, code: code, password: password);
'/reset-password',
data: {
'mobile': phone,
'code': code,
'password': password,
},
);
}
} }

View File

@@ -77,9 +77,15 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return PopScope( return PopScope(
canPop: true, // 拦截系统返回键:优先出栈;若无法出栈则跳回登录页。
canPop: false,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (!didPop) context.pop(); if (didPop) return;
if (context.canPop()) {
context.pop();
} else {
context.go('/login');
}
}, },
child: CupertinoPageScaffold( child: CupertinoPageScaffold(
backgroundColor: CupertinoColors.systemGroupedBackground, backgroundColor: CupertinoColors.systemGroupedBackground,
@@ -168,13 +174,13 @@ class _ForgotPasswordPageState extends ConsumerState<ForgotPasswordPage> {
' title=${l10n.alertTitle} message=$message'); ' title=${l10n.alertTitle} message=$message');
showCupertinoDialog<void>( showCupertinoDialog<void>(
context: context, context: context,
builder: (_) => CupertinoAlertDialog( builder: (dialogContext) => CupertinoAlertDialog(
title: Text(l10n.alertTitle), title: Text(l10n.alertTitle),
content: Text(message), content: Text(message),
actions: [ actions: [
CupertinoDialogAction( CupertinoDialogAction(
child: Text(l10n.confirm), child: Text(l10n.confirm),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(dialogContext).pop(),
), ),
], ],
), ),

View File

@@ -3,6 +3,7 @@ import 'package:flutter/gestures.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/utils/system_ui_util.dart';
import '../../../app/theme/app_theme.dart'; import '../../../app/theme/app_theme.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../domain/auth_models.dart'; import '../domain/auth_models.dart';
@@ -50,18 +51,18 @@ class _LoginPageState extends ConsumerState<LoginPage> {
' title=${l10n.exitConfirmTitle} content=${l10n.exitConfirmContent}'); ' title=${l10n.exitConfirmTitle} content=${l10n.exitConfirmContent}');
return showCupertinoDialog<bool>( return showCupertinoDialog<bool>(
context: context, context: context,
builder: (_) => CupertinoAlertDialog( builder: (dialogContext) => CupertinoAlertDialog(
title: Text(l10n.exitConfirmTitle), title: Text(l10n.exitConfirmTitle),
content: Text(l10n.exitConfirmContent), content: Text(l10n.exitConfirmContent),
actions: [ actions: [
CupertinoDialogAction( CupertinoDialogAction(
child: Text(l10n.cancel), child: Text(l10n.cancel),
onPressed: () => Navigator.of(context).pop(false), onPressed: () => Navigator.of(dialogContext).pop(false),
), ),
CupertinoDialogAction( CupertinoDialogAction(
isDestructiveAction: true, isDestructiveAction: true,
child: Text(l10n.exitApp), child: Text(l10n.exitApp),
onPressed: () => Navigator.of(context).pop(true), onPressed: () => Navigator.of(dialogContext).pop(true),
), ),
], ],
), ),
@@ -88,13 +89,13 @@ class _LoginPageState extends ConsumerState<LoginPage> {
' title=${l10n.alertTitle} message=$message'); ' title=${l10n.alertTitle} message=$message');
showCupertinoDialog<void>( showCupertinoDialog<void>(
context: context, context: context,
builder: (_) => CupertinoAlertDialog( builder: (dialogContext) => CupertinoAlertDialog(
title: Text(l10n.alertTitle), title: Text(l10n.alertTitle),
content: Text(message), content: Text(message),
actions: [ actions: [
CupertinoDialogAction( CupertinoDialogAction(
child: Text(l10n.confirm), child: Text(l10n.confirm),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(dialogContext).pop(),
), ),
], ],
), ),
@@ -122,7 +123,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, _) async { onPopInvokedWithResult: (didPop, _) async {
if (didPop) return; if (didPop) return;
await _onWillPop(); final shouldExit = await _showExitConfirm();
if (shouldExit == true) {
await SystemUiUtil.moveToBack();
}
}, },
child: CupertinoPageScaffold( child: CupertinoPageScaffold(
backgroundColor: CupertinoColors.white, backgroundColor: CupertinoColors.white,

View File

@@ -52,9 +52,15 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return PopScope( return PopScope(
canPop: true, // 拦截系统返回键:优先出栈;若无法出栈则跳回登录页。
canPop: false,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (!didPop) context.pop(); if (didPop) return;
if (context.canPop()) {
context.pop();
} else {
context.go('/login');
}
}, },
child: CupertinoPageScaffold( child: CupertinoPageScaffold(
backgroundColor: CupertinoColors.systemGroupedBackground, backgroundColor: CupertinoColors.systemGroupedBackground,
@@ -144,13 +150,13 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
' title=${l10n.alertTitle} message=$message'); ' title=${l10n.alertTitle} message=$message');
showCupertinoDialog<void>( showCupertinoDialog<void>(
context: context, context: context,
builder: (_) => CupertinoAlertDialog( builder: (dialogContext) => CupertinoAlertDialog(
title: Text(l10n.alertTitle), title: Text(l10n.alertTitle),
content: Text(message), content: Text(message),
actions: [ actions: [
CupertinoDialogAction( CupertinoDialogAction(
child: Text(l10n.confirm), child: Text(l10n.confirm),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(dialogContext).pop(),
), ),
], ],
), ),

View File

@@ -13,21 +13,32 @@ class ContactsPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return CupertinoPageScaffold( return PopScope(
navigationBar: CupertinoNavigationBar( // 拦截系统返回键:优先出栈;若无法出栈则跳回「管理」页,避免直接退到桌面。
middle: Text(l10n.contactsTitle), canPop: false,
leading: CupertinoButton( onPopInvokedWithResult: (didPop, _) {
padding: EdgeInsets.zero, if (didPop) return;
child: const Icon(CupertinoIcons.back), if (context.canPop()) {
onPressed: () => context.go('/messages'), 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( child: Stack(
padding: EdgeInsets.zero,
child: const Icon(CupertinoIcons.add, size: 26),
onPressed: () {},
),
),
child: Stack(
children: [ children: [
ListView( ListView(
padding: const EdgeInsets.only(bottom: 20), padding: const EdgeInsets.only(bottom: 20),
@@ -87,6 +98,7 @@ class ContactsPage extends StatelessWidget {
), ),
), ),
], ],
),
), ),
); );
} }

View File

@@ -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<List<DeviceBrief>> getMyDevices() async {
try {
final data = await DioClient.get(
'v1/client/sn/my-devices',
);
return _parseList(data, DeviceBrief.fromJson);
} on ApiException {
return const [];
}
}
/// 获取已绑定设备基本信息(无上报数据时返回 null)。
Future<DeviceSystemInfo?> getDeviceInfo(String sn) async {
try {
final data = await DioClient.get(
'v1/client/sn/device-info',
queryParameters: {'sn': sn},
);
return data is Map<String, dynamic> ? DeviceSystemInfo.fromJson(data) : null;
} on ApiException {
return null;
}
}
/// 获取已绑定设备最新定位信息(无上报数据时返回 null)。
Future<DeviceLocation?> getLocation(String sn) async {
try {
final data = await DioClient.get(
'v1/client/sn/location',
queryParameters: {'sn': sn},
);
return data is Map<String, dynamic> ? DeviceLocation.fromJson(data) : null;
} on ApiException {
return null;
}
}
/// 获取已绑定设备已安装应用列表。
Future<List<DeviceApkInfo>> 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<String?> 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<String?> 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<T> _parseList<T>(
dynamic data,
T Function(Map<String, dynamic>) fromJson,
) {
if (data is! List) return const [];
return data
.whereType<Map<String, dynamic>>()
.map(fromJson)
.toList(growable: false);
}
// ---------------------------------------------------------------------------
/// 获取设备最近截图列表(家属端)。
///
/// [sn] 为设备序列号(可空,由后台按绑定关系补全)。
/// 返回 [ScreenshotVO] 列表,并自动补全图片可访问的完整 URL(拼接 baseUrl)。
Future<List<ScreenshotVO>> getRecentScreenshots({String? sn}) async {
final query = sn != null && sn.isNotEmpty ? {'sn': sn} : <String, dynamic>{};
// DioClient.get 已统一解析出响应体中的 data 字段,
// 后台返回结构为 { code, msg, data: [...] },故此处 payload 即为截图数组。
final payload = await DioClient.get(
'v1/client/sn/upload-screenshot',
queryParameters: query,
);
// 防御性提取:兼容后台直接返回 List,或返回 { data: [...] } 的包裹结构。
final List<dynamic> 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<String, dynamic>>().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);
}
}

View File

@@ -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<DeviceRepository>((ref) {
const api = DeviceApi();
return DeviceRepositoryImpl(api);
});
/// 当前用户已绑定设备列表。
final myDevicesProvider = FutureProvider<List<DeviceBrief>>((ref) async {
final repo = ref.watch(deviceRepositoryProvider);
return repo.getMyDevices();
});
/// 当前选中的设备序列号(默认取绑定列表首个设备)。
///
/// 使用普通 Provider(而非 StateProvider)以便随 [myDevicesProvider] 的加载完成
/// 自动推导出首个已绑定设备的 SN。若后续需要手动切换设备,可改为 StateProvider。
final selectedDeviceSnProvider = Provider<String?>((ref) {
final devices = ref.watch(myDevicesProvider);
return devices.maybeWhen(
data: (list) => list.isEmpty ? null : list.first.serialno,
orElse: () => null,
);
});
/// 按 SN 获取设备基本信息。
final deviceInfoProvider =
FutureProvider.family<DeviceSystemInfo?, String>((ref, sn) async {
if (sn.isEmpty) return null;
final repo = ref.watch(deviceRepositoryProvider);
return repo.getDeviceInfo(sn);
});
/// 按 SN 获取设备最新定位信息。
final deviceLocationProvider =
FutureProvider.family<DeviceLocation?, String>((ref, sn) async {
if (sn.isEmpty) return null;
final repo = ref.watch(deviceRepositoryProvider);
return repo.getLocation(sn);
});
/// 按 SN 获取设备已安装应用列表。
final deviceApksProvider =
FutureProvider.family<List<DeviceApkInfo>, String>((ref, sn) async {
if (sn.isEmpty) return const [];
final repo = ref.watch(deviceRepositoryProvider);
return repo.getApks(sn);
});
/// 按 SN 获取设备最近截图列表。
final recentScreenshotsProvider =
FutureProvider.family<List<ScreenshotVO>, String>((ref, sn) async {
if (sn.isEmpty) return const [];
final repo = ref.watch(deviceRepositoryProvider);
return repo.getRecentScreenshots(sn);
});

View File

@@ -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<List<DeviceBrief>> getMyDevices() => _api.getMyDevices();
@override
Future<DeviceSystemInfo?> getDeviceInfo(String sn) => _api.getDeviceInfo(sn);
@override
Future<DeviceLocation?> getLocation(String sn) => _api.getLocation(sn);
@override
Future<List<DeviceApkInfo>> getApks(String sn) => _api.getApks(sn);
@override
Future<List<ScreenshotVO>> getRecentScreenshots(String sn) =>
_api.getRecentScreenshots(sn: sn);
@override
Future<String?> sendDeviceOp(String sn, String op) =>
_api.sendDeviceOp(sn, op);
@override
Future<String?> sendAppOp(String sn, String op, String packageName) =>
_api.sendAppOp(sn, op, packageName);
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;
}

View File

@@ -0,0 +1,31 @@
import 'package:ttstd_family_care/features/device/domain/device_models.dart';
/// 设备仓储,封装设备信息/定位/应用列表/绑定设备列表的获取。
abstract class DeviceRepository {
/// 当前用户已绑定的设备列表。
Future<List<DeviceBrief>> getMyDevices();
/// 已绑定设备基本信息(无上报数据时返回 null)。
Future<DeviceSystemInfo?> getDeviceInfo(String sn);
/// 已绑定设备最新定位信息(无上报数据时返回 null)。
Future<DeviceLocation?> getLocation(String sn);
/// 已绑定设备已安装应用列表。
Future<List<DeviceApkInfo>> getApks(String sn);
/// 已绑定设备最近截图列表。
Future<List<ScreenshotVO>> getRecentScreenshots(String sn);
/// 向设备下发操作指令(重启/关机/截屏/刷新/定位等)。
///
/// [op] 为后端操作标识,如 `reboot` / `shutdown` / `screenshot` / `refresh` / `locate`。
/// 成功返回 null;失败返回错误提示。
Future<String?> sendDeviceOp(String sn, String op);
/// 对指定应用执行操作(打开/停止/卸载/清除数据)。
///
/// [op] 为后端操作标识:`launch` / `stop` / `uninstall` / `clear_data`。
/// 成功返回 null;失败返回错误提示。
Future<String?> sendAppOp(String sn, String op, String packageName);
}

View File

@@ -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<LocationState> {
LocationFlutterPlugin? _plugin;
bool _started = false;
@override
LocationState build() {
_started = false;
_plugin = null;
// Provider 被首次 watch 时自动启动本机定位。
_init();
// Provider 被 dispose(如地图页退出)时自动停止定位并释放插件。
ref.onDispose(_teardown);
return const LocationIdle();
}
/// 初始化:请求定位权限 -> 初始化定位插件 -> 启动连续定位。
Future<void> _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 <String, Object?>{});
debugPrint('[Location] prepareLoc => $prepared');
if (!_started) return;
final started = await plugin.startLocation();
debugPrint('[Location] startLocation => $started');
}
/// 请求运行时定位权限(WhenInUse 足够前台展示地图定位点)。
Future<bool> _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, LocationState>(
LocationController.new,
);

View File

@@ -2,6 +2,7 @@ import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/utils/system_ui_util.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
/// 首页 Shell(含底部 Tab:首页 / 管理 / 我的)。 /// 首页 Shell(含底部 Tab:首页 / 管理 / 我的)。
@@ -24,32 +25,51 @@ class HomePage extends ConsumerWidget {
_ => 0, _ => 0,
}; };
return CupertinoPageScaffold( return PopScope(
child: Stack( // 拦截系统返回键:若在非「首页」Tab 则跳回首页;若在首页则弹出退出确认。
children: [ canPop: false,
child, onPopInvokedWithResult: (didPop, _) async {
Align( if (didPop) return;
alignment: Alignment.bottomCenter,
child: CupertinoTabBar( final currentLocation = GoRouterState.of(context).uri.path;
currentIndex: index, if (currentLocation == '/home') {
onTap: (i) => _onTabTap(context, i), final shouldExit = await _showExitConfirm(context);
items: [ if (shouldExit == true) {
BottomNavigationBarItem( await SystemUiUtil.moveToBack();
icon: const Icon(CupertinoIcons.home), }
label: l10n.tabHome, } else {
), context.go('/home');
BottomNavigationBarItem( }
icon: const Icon(CupertinoIcons.square_grid_2x2), },
label: l10n.tabManage, child: CupertinoPageScaffold(
), child: Stack(
BottomNavigationBarItem( children: [
icon: const Icon(CupertinoIcons.person), child,
label: l10n.tabProfile, 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'); context.go('/profile');
} }
} }
/// 弹出「确认退出应用」对话框。
Future<bool?> _showExitConfirm(BuildContext context) {
final l10n = AppLocalizations.of(context);
return showCupertinoDialog<bool>(
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),
),
],
),
);
}
} }

View File

@@ -1,8 +1,13 @@
import 'package:cupertino_ui/cupertino_ui.dart'; import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.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 '../../../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 '../../../l10n/app_localizations.dart';
import 'location_map_view.dart';
/// 首页 Tab 内容(特性模块:home / presentation)。 /// 首页 Tab 内容(特性模块:home / presentation)。
/// ///
@@ -14,6 +19,16 @@ class HomeTab extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final sn = ref.watch(selectedDeviceSnProvider);
final locationAsync = sn == null
? const AsyncValue<DeviceLocation?>.data(null)
: ref.watch(deviceLocationProvider(sn));
final location = locationAsync.valueOrNull;
final locating = locationAsync.isLoading;
final screenshotsAsync = sn == null
? const AsyncValue<List<ScreenshotVO>>.data([])
: ref.watch(recentScreenshotsProvider(sn));
return CupertinoPageScaffold( return CupertinoPageScaffold(
navigationBar: CupertinoNavigationBar( navigationBar: CupertinoNavigationBar(
middle: Text(l10n.appTitle, middle: Text(l10n.appTitle,
@@ -50,15 +65,37 @@ class HomeTab extends ConsumerWidget {
), ),
), ),
child: SafeArea( child: SafeArea(
child: ListView( child: CustomScrollView(
padding: const EdgeInsets.only(bottom: 96), physics: const AlwaysScrollableScrollPhysics(
children: [ parent: BouncingScrollPhysics(),
_locationCard(l10n), ),
_screenshotsCard(l10n), slivers: [
_usageCard(l10n), CupertinoSliverRefreshControl(
_deviceOpsCard(l10n), onRefresh: () async {
SectionTitle(l10n.homeCommon), // 下拉刷新:重新拉取设备列表、当前设备最新定位与最近截图列表。
_commonCard(l10n), 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, Color color,
String title, { String title, {
String? trailing, String? trailing,
VoidCallback? onTrailingTap,
}) => }) =>
Padding( Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
@@ -90,108 +128,252 @@ class HomeTab extends ConsumerWidget {
fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.ink)), fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.ink)),
const Spacer(), const Spacer(),
if (trailing != null) if (trailing != null)
Text(trailing, GestureDetector(
style: const TextStyle( onTap: onTrailingTap,
fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.green)), 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( Widget _locationCard(
child: Column( AppLocalizations l10n, {
crossAxisAlignment: CrossAxisAlignment.start, required DeviceLocation? location,
children: [ required bool locating,
_cardHeader('📍', AppColors.green, l10n.homeLocation, trailing: l10n.homeLocating), required VoidCallback onTap,
ClipRRect( }) =>
borderRadius: BorderRadius.circular(14), GestureDetector(
child: Container( onTap: onTap,
height: 150, child: CardBox(
color: const Color(0xFFF3F7FF), child: Column(
child: Stack( crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题栏:实时位置 + 定位状态,右上角查看详情入口
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [ children: [
// 简易路网 Container(
Positioned(top: 38, left: 0, right: 0, height: 10, child: Container(color: CupertinoColors.white)), width: 22,
Positioned(top: 96, left: 0, right: 0, height: 10, child: Container(color: CupertinoColors.white)), height: 22,
Positioned(top: 0, bottom: 0, left: 120, width: 10, child: Container(color: CupertinoColors.white)), decoration: BoxDecoration(
Positioned(top: 0, bottom: 0, left: 240, width: 10, child: Container(color: CupertinoColors.white)), color: AppColors.green,
// 定位标记 borderRadius: BorderRadius.circular(7),
Positioned( ),
left: 0,
right: 0,
top: 46,
child: Center( child: Center(
child: Container( child: Text('📍', style: const TextStyle(fontSize: 13))),
width: 30, ),
height: 30, const SizedBox(width: 7),
decoration: const BoxDecoration( Text(l10n.homeLocation,
color: AppColors.green, style: const TextStyle(
shape: BoxShape.circle, fontSize: 15,
), fontWeight: FontWeight.w700,
child: const Center( color: AppColors.ink)),
child: Icon(CupertinoIcons.location_fill, const SizedBox(width: 8),
color: CupertinoColors.white, size: 16), // 定位状态:放在"实时位置"标题后面
), 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( const Spacer(),
left: 12, // 右上角查看详情
bottom: 10, GestureDetector(
child: Container( onTap: onTap,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), behavior: HitTestBehavior.opaque,
decoration: BoxDecoration( child: Row(
color: CupertinoColors.white.withValues(alpha: 0.92), mainAxisSize: MainAxisSize.min,
borderRadius: BorderRadius.circular(10), children: [
), Text(l10n.homeViewDetail,
child: Text('📍 ${l10n.homeAddress}', style: const TextStyle(
style: const TextStyle(fontSize: 12, color: AppColors.ink)), fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.blue)),
const SizedBox(width: 2),
const Icon(CupertinoIcons.chevron_right,
size: 14, color: AppColors.blue),
],
), ),
), ),
], ],
), ),
), ),
), Stack(
], children: [
), // 百度地图实时位置(须明确宽高,否则地图空白)
); ClipRRect(
borderRadius: BorderRadius.circular(14),
Widget _screenshotsCard(AppLocalizations l10n) => CardBox( child: SizedBox(
child: Column( width: double.infinity,
crossAxisAlignment: CrossAxisAlignment.start, height: 150,
children: [ child: LocationMapView(location: location),
_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,
), ),
), ),
child: Align( // 地址信息浮层
alignment: Alignment.bottomLeft, // 右侧留出间隔,避免遮挡地图右侧的放大缩小按钮。
child: Padding( Positioned(
padding: const EdgeInsets.all(6), left: 12,
child: Container( right: 52,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), bottom: 10,
decoration: BoxDecoration( child: Container(
color: CupertinoColors.black.withValues(alpha: 0.45), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
borderRadius: BorderRadius.circular(6), decoration: BoxDecoration(
), color: CupertinoColors.white.withValues(alpha: 0.92),
child: Text(['14:02', '13:20', '11:48', '09:15'][i], borderRadius: BorderRadius.circular(10),
style: const TextStyle(fontSize: 10, color: CupertinoColors.white)), ),
child: Text(
locating
? '📍 ${l10n.homeLocating}…'
: (location?.displayAddress.isNotEmpty == true
? '📍 ${location!.displayAddress}'
: '📍 ${l10n.homeAddress}'),
style: const TextStyle(fontSize: 12, color: AppColors.ink),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
), ),
), ),
],
),
],
),
),
);
Widget _screenshotsCard(
BuildContext context,
AppLocalizations l10n,
AsyncValue<List<ScreenshotVO>> screenshots,
WidgetRef ref,
) =>
CardBox(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_cardHeader(
'🖼️',
AppColors.blue,
l10n.homeScreenshots,
trailing: l10n.homeViewAll,
onTrailingTap: (screenshots.valueOrNull?.isNotEmpty ?? false)
? () => context.push('/screenshots', extra: 0)
: null,
),
SizedBox(
height: 120,
child: screenshots.when(
loading: () => const Center(
child: CupertinoActivityIndicator(radius: 12)),
error: (e, _) => Center(
child: Text('加载失败',
style: const TextStyle(fontSize: 12, color: AppColors.sub)),
), ),
data: (list) {
if (list.isEmpty) {
return Center(
child: Text(l10n.homeNoScreenshot,
style: const TextStyle(fontSize: 12, color: AppColors.sub)),
);
}
// 卡片内容宽 = 屏幕宽 - 外间距(16×2) - 内边距(16×2)。
// 一排恰好 3 张:减去 2 个间距(10×2) 后均分。
const gap = 10.0;
const cardInsets = 16.0 * 2 + 16.0 * 2;
final itemWidth =
(MediaQuery.sizeOf(context).width - cardInsets - gap * 2) / 3;
return ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: list.length,
separatorBuilder: (_, _) => const SizedBox(width: gap),
itemBuilder: (_, i) {
final item = list[i];
// 统一把原始时间戳格式化为友好显示。
final time = formatTimeString(item.uploadTime);
return GestureDetector(
onTap: () => context.push('/screenshots', extra: i),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: itemWidth,
child: Stack(
fit: StackFit.expand,
children: [
Image.network(
item.url ?? '',
fit: BoxFit.cover,
loadingBuilder: (ctx, child, progress) =>
progress == null
? child
: const Center(
child: CupertinoActivityIndicator(
radius: 10)),
errorBuilder: (ctx, err, _) => Container(
color: const Color(0xFFEEF2F7),
child: const Center(
child: Icon(CupertinoIcons.photo,
size: 22, color: AppColors.sub),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: CupertinoColors.black
.withValues(alpha: 0.45),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
),
child: Text(time,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 10,
color: CupertinoColors.white)),
),
),
],
),
),
),
);
},
);
},
), ),
), ),
], ],
@@ -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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_cardHeader('⚡', AppColors.purple, l10n.homeDeviceOps), _cardHeader('⚡', AppColors.purple, l10n.homeDeviceOps),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
IconTile(emoji: '🔄', color: AppColors.blue, label: l10n.opRestart, size: 50, iconSize: 20), Expanded(
IconTile(emoji: '🔌', color: AppColors.red, label: l10n.opShutdown, size: 50, iconSize: 20), child: IconTile(
IconTile(emoji: '📸', color: AppColors.green, label: l10n.opScreenshot, size: 50, iconSize: 20), emoji: '🔄',
IconTile(emoji: '🔃', color: AppColors.orange, label: l10n.opRefresh, size: 50, iconSize: 20), color: AppColors.blue,
IconTile(emoji: '📍', color: AppColors.purple, label: l10n.opLocate, size: 50, iconSize: 20), 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<void> _confirmDeviceOp(
BuildContext context,
AppLocalizations l10n,
WidgetRef ref,
String? sn,
String op,
String message,
) async {
final confirmed = await showCupertinoDialog<bool>(
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<void> _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( Widget _commonCard(AppLocalizations l10n) => CardBox(
child: Column( child: Column(
children: [ children: [

View File

@@ -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<LocationMapView> createState() => _LocationMapViewState();
}
class _LocationMapViewState extends ConsumerState<LocationMapView> {
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<void> _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<LocationState>(
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<String, Uint8List> _deviceMarkerIconCache = {};
/// 生成设备位置 Marker 的 PNG 图标字节。
///
/// 图标为一个「带圆角边框的气泡标签」:上方是圆角矩形气泡(白底 + 深色描边边框,
/// 内含设备 SN 文字),下方是红色定位针,中间小三角衔接,整体形似带边框的气泡。
/// 用 `dart:ui` 动态绘制,宽度随 SN 文字长度自适应,超长 SN 自动降字号避免图标过宽变扁;
/// 高度固定偏高,保证竖长比例。避免依赖外部图片资源。
/// 绘制失败返回 null(调用方不做渲染)。
Future<Uint8List?> 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;
}
}

View File

@@ -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<MapPage> createState() => _MapPageState();
}
class _MapPageState extends ConsumerState<MapPage> {
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<AsyncValue<DeviceLocation?>>(
deviceLocationProvider(widget.sn),
(prev, next) {
if (next.hasValue) _syncLocation(next.valueOrNull);
},
);
// 监听本机定位结果:定位成功后把蓝色定位点更新到地图(BMFUserLocation)。
ref.listen<LocationState>(
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<void> _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<DeviceLocation?> 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();
},
);
}
}

View File

@@ -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<ScreenshotVO> screenshots;
/// 初始展示的截图索引。
final int initialIndex;
@override
State<ScreenshotPage> createState() => _ScreenshotPageState();
}
class _ScreenshotPageState extends State<ScreenshotPage> {
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),
);
}),
),
],
),
),
);
}
}

View File

@@ -108,7 +108,7 @@ class MessagesTab extends ConsumerWidget {
label: m.label, label: m.label,
size: 44, size: 44,
iconSize: 22, 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( child: Row(
children: [ children: [
Stack( Stack(
clipBehavior: Clip.none,
children: [ children: [
Container( Container(
width: 44, width: 44,

View File

@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../../core/widgets/feature_ui.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 '../../../l10n/app_localizations.dart';
import '../../auth/presentation/auth_controller.dart'; import '../../auth/presentation/auth_controller.dart';
@@ -76,21 +78,7 @@ class ProfileTab extends ConsumerWidget {
), ),
SectionTitle(l10n.deviceBound), SectionTitle(l10n.deviceBound),
CardBox( _deviceListCard(ref, l10n),
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),
],
),
),
SectionTitle(l10n.profileInfo), SectionTitle(l10n.profileInfo),
CardBox( 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( Widget _deviceRow(String emoji, Color color, String title, String sub, String? action) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row( child: Row(

View File

@@ -404,6 +404,66 @@ abstract class AppLocalizations {
/// **'应用'** /// **'应用'**
String get appsTitle; 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: /// In zh, this message translates to:
@@ -542,12 +602,78 @@ abstract class AppLocalizations {
/// **'定位中'** /// **'定位中'**
String get homeLocating; 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: /// In zh, this message translates to:
/// **'望京 SOHO · T3 座'** /// **'望京 SOHO · T3 座'**
String get homeAddress; 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: /// In zh, this message translates to:
@@ -560,6 +686,18 @@ abstract class AppLocalizations {
/// **'查看全部'** /// **'查看全部'**
String get homeViewAll; 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: /// In zh, this message translates to:
@@ -614,6 +752,42 @@ abstract class AppLocalizations {
/// **'定位'** /// **'定位'**
String get opLocate; 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: /// In zh, this message translates to:
@@ -710,6 +884,36 @@ abstract class AppLocalizations {
/// **'已绑定设备'** /// **'已绑定设备'**
String get deviceBound; 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: /// In zh, this message translates to:

View File

@@ -164,6 +164,36 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get appsTitle => 'Apps'; 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 @override
String get appsCommon => 'Common Apps'; String get appsCommon => 'Common Apps';
@@ -233,15 +263,56 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get homeLocating => 'Locating…'; 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 @override
String get homeAddress => 'Wangjing SOHO · T3'; 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 @override
String get homeScreenshots => 'Recent Screenshots'; String get homeScreenshots => 'Recent Screenshots';
@override @override
String get homeViewAll => 'View All'; String get homeViewAll => 'View All';
@override
String get homeViewDetail => 'View Detail';
@override
String get homeNoScreenshot => 'No screenshots yet';
@override @override
String get homeUsage => 'Today\'s Usage'; String get homeUsage => 'Today\'s Usage';
@@ -269,6 +340,25 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get opLocate => 'Locate'; 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 @override
String get cfFileTransfer => 'File Transfer'; String get cfFileTransfer => 'File Transfer';
@@ -317,6 +407,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get deviceBound => 'Bound Devices'; 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 @override
String get devTablet => 'My Tablet Pro'; String get devTablet => 'My Tablet Pro';

View File

@@ -163,6 +163,36 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get appsTitle => '应用'; 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 @override
String get appsCommon => '常用功能'; String get appsCommon => '常用功能';
@@ -232,15 +262,54 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get homeLocating => '定位中'; String get homeLocating => '定位中';
@override
String get homeLocated => '已定位';
@override
String get homeNoLocation => '暂无位置';
@override
String get back => '返回';
@override
String get snapshotLocationFailed => '定位信息获取失败,请稍后重试';
@override
String get noSnapshotLocation => '暂未获取到设备定位信息';
@override @override
String get homeAddress => '望京 SOHO · T3 座'; 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 @override
String get homeScreenshots => '最近截图'; String get homeScreenshots => '最近截图';
@override @override
String get homeViewAll => '查看全部'; String get homeViewAll => '查看全部';
@override
String get homeViewDetail => '查看详情';
@override
String get homeNoScreenshot => '暂无截图';
@override @override
String get homeUsage => '今日使用时长'; String get homeUsage => '今日使用时长';
@@ -268,6 +337,24 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get opLocate => '定位'; 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 @override
String get cfFileTransfer => '文件传输'; String get cfFileTransfer => '文件传输';
@@ -316,6 +403,21 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get deviceBound => '已绑定设备'; String get deviceBound => '已绑定设备';
@override
String get deviceLoadFailed => '设备信息加载失败';
@override
String get deviceNone => '暂无已绑定设备';
@override
String get deviceOnline => '在线';
@override
String get deviceNoInfo => '暂无详情';
@override
String get deviceManage => '管理';
@override @override
String get devTablet => '我的平板 Pro'; String get devTablet => '我的平板 Pro';

View File

@@ -51,6 +51,16 @@
"addAlarm": "Add Alarm", "addAlarm": "Add Alarm",
"albumTitle": "Album", "albumTitle": "Album",
"appsTitle": "Apps", "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", "appsCommon": "Common Apps",
"appsSystem": "System Tools", "appsSystem": "System Tools",
"appsEntertain": "Entertainment", "appsEntertain": "Entertainment",
@@ -74,9 +84,22 @@
"homeCommon": "Common Apps", "homeCommon": "Common Apps",
"homeLocation": "Live Location", "homeLocation": "Live Location",
"homeLocating": "Locating…", "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", "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", "homeScreenshots": "Recent Screenshots",
"homeViewAll": "View All", "homeViewAll": "View All",
"homeViewDetail": "View Detail",
"homeNoScreenshot": "No screenshots yet",
"homeUsage": "Today's Usage", "homeUsage": "Today's Usage",
"homeUsageTrend": "↓12% from yesterday", "homeUsageTrend": "↓12% from yesterday",
"homeUsageToday": "Today", "homeUsageToday": "Today",
@@ -86,6 +109,12 @@
"opScreenshot": "Screenshot", "opScreenshot": "Screenshot",
"opRefresh": "Refresh", "opRefresh": "Refresh",
"opLocate": "Locate", "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", "cfFileTransfer": "File Transfer",
"cfMsgSync": "Message Sync", "cfMsgSync": "Message Sync",
"cfClean": "Clean & Boost", "cfClean": "Clean & Boost",
@@ -102,6 +131,11 @@
"msgAllRead": "Mark All Read", "msgAllRead": "Mark All Read",
"profileEdit": "Edit Profile", "profileEdit": "Edit Profile",
"deviceBound": "Bound Devices", "deviceBound": "Bound Devices",
"deviceLoadFailed": "Failed to load devices",
"deviceNone": "No bound devices",
"deviceOnline": "Online",
"deviceNoInfo": "No details",
"deviceManage": "Manage",
"devTablet": "My Tablet Pro", "devTablet": "My Tablet Pro",
"devWatch": "Smart Watch", "devWatch": "Smart Watch",
"profileInfo": "Profile Info", "profileInfo": "Profile Info",

View File

@@ -204,6 +204,46 @@
"@appsTitle": { "@appsTitle": {
"description": "应用页标题" "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": "常用功能",
"@appsCommon": { "@appsCommon": {
"description": "常用功能分组" "description": "常用功能分组"
@@ -296,10 +336,54 @@
"@homeLocating": { "@homeLocating": {
"description": "定位中提示" "description": "定位中提示"
}, },
"homeLocated": "已定位",
"@homeLocated": {
"description": "已定位提示"
},
"homeNoLocation": "暂无位置",
"@homeNoLocation": {
"description": "暂无定位数据提示"
},
"back": "返回",
"@back": {
"description": "通用返回按钮文案"
},
"snapshotLocationFailed": "定位信息获取失败,请稍后重试",
"@snapshotLocationFailed": {
"description": "设备实时位置获取失败提示"
},
"noSnapshotLocation": "暂未获取到设备定位信息",
"@noSnapshotLocation": {
"description": "设备暂无定位数据提示"
},
"homeAddress": "望京 SOHO · T3 座", "homeAddress": "望京 SOHO · T3 座",
"@homeAddress": { "@homeAddress": {
"description": "示例设备地址" "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": "最近截图",
"@homeScreenshots": { "@homeScreenshots": {
"description": "首页最近截图卡片" "description": "首页最近截图卡片"
@@ -308,6 +392,14 @@
"@homeViewAll": { "@homeViewAll": {
"description": "查看全部入口" "description": "查看全部入口"
}, },
"homeViewDetail": "查看详情",
"@homeViewDetail": {
"description": "地图卡片右上角查看详情入口"
},
"homeNoScreenshot": "暂无截图",
"@homeNoScreenshot": {
"description": "首页最近截图为空时的占位文案"
},
"homeUsage": "今日使用时长", "homeUsage": "今日使用时长",
"@homeUsage": { "@homeUsage": {
"description": "首页使用时长卡片" "description": "首页使用时长卡片"
@@ -344,6 +436,30 @@
"@opLocate": { "@opLocate": {
"description": "设备操作:定位" "description": "设备操作:定位"
}, },
"opConfirmTitle": "操作确认",
"@opConfirmTitle": {
"description": "设备操作:确认弹窗标题"
},
"opRestartConfirm": "确定要重启设备吗?",
"@opRestartConfirm": {
"description": "设备操作:重启确认文案"
},
"opShutdownConfirm": "确定要关机吗?",
"@opShutdownConfirm": {
"description": "设备操作:关机确认文案"
},
"opCmdSent": "操作指令已发送",
"@opCmdSent": {
"description": "设备操作:指令下发成功"
},
"opCmdFailed": "操作失败,请稍后重试",
"@opCmdFailed": {
"description": "设备操作:指令下发失败"
},
"opScreenshotHint": "设备锁屏状态下可能显示为黑色",
"@opScreenshotHint": {
"description": "截屏操作下发成功后,提醒用户设备锁屏时截图可能为黑色"
},
"cfFileTransfer": "文件传输", "cfFileTransfer": "文件传输",
"@cfFileTransfer": { "@cfFileTransfer": {
"description": "常用功能:文件传输" "description": "常用功能:文件传输"
@@ -408,6 +524,26 @@
"@deviceBound": { "@deviceBound": {
"description": "我的页已绑定设备分组" "description": "我的页已绑定设备分组"
}, },
"deviceLoadFailed": "设备信息加载失败",
"@deviceLoadFailed": {
"description": "设备信息加载失败提示"
},
"deviceNone": "暂无已绑定设备",
"@deviceNone": {
"description": "暂无已绑定设备提示"
},
"deviceOnline": "在线",
"@deviceOnline": {
"description": "设备在线状态"
},
"deviceNoInfo": "暂无详情",
"@deviceNoInfo": {
"description": "设备暂无详细信息"
},
"deviceManage": "管理",
"@deviceManage": {
"description": "设备「管理」操作按钮"
},
"devTablet": "我的平板 Pro", "devTablet": "我的平板 Pro",
"@devTablet": { "@devTablet": {
"description": "绑定设备:平板" "description": "绑定设备:平板"

View File

@@ -1,17 +1,35 @@
import 'package:material_ui/material_ui.dart' show WidgetsFlutterBinding; import 'package:material_ui/material_ui.dart' show WidgetsFlutterBinding;
import 'package:flutter/widgets.dart' show runApp; 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/app.dart';
import 'app/constants/app_constants.dart';
import 'app/router/app_router.dart'; import 'app/router/app_router.dart';
import 'core/storage/token_storage.dart'; import 'core/storage/token_storage.dart';
import 'core/utils/device_info_util.dart'; import 'core/utils/device_info_util.dart';
import 'core/utils/system_ui_util.dart';
/// 应用入口。 /// 应用入口。
/// ///
/// 职责:初始化存储、打印设备信息、构建路由与根组件。不包含任何业务或 UI 逻辑。 /// 职责:初始化存储、配置系统 UI、打印设备信息、构建路由与根组件。不包含任何业务或 UI 逻辑。
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); 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() await TokenStorage.initialize()
.timeout(const Duration(seconds: 5), onTimeout: () {}); .timeout(const Duration(seconds: 5), onTimeout: () {});

File diff suppressed because it is too large Load Diff

View File

@@ -35,7 +35,13 @@ dependencies:
sdk: flutter sdk: flutter
mmkv: ^2.4.0 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) # 跨平台 WebRTC(同时支持 Android 与 iOS)
flutter_webrtc: ^1.5.2 flutter_webrtc: ^1.5.2

View File

@@ -9,6 +9,7 @@
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h> #include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <mmkv_win32/mmkv_win32_plugin.h> #include <mmkv_win32/mmkv_win32_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
@@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin")); registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
MmkvWin32PluginRegisterWithRegistrar( MmkvWin32PluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("MmkvWin32Plugin")); registry->GetRegistrarForPlugin("MmkvWin32Plugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
} }

View File

@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows flutter_secure_storage_windows
flutter_webrtc flutter_webrtc
mmkv_win32 mmkv_win32
permission_handler_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST