feat(android): 支持沉浸式状态栏、地图定位与自定义签名
- 配置 edge-to-edge 沉浸式状态栏与刘海屏适配 - 新增百度地图权限、AK 配置及定位权限说明 - 接入 MethodChannel 实现返回键退后台热启动优化 - 配置自定义签名并更新构建逻辑 - 完善 token 刷新与鉴权失效统一处理 - 新增地图页与截图页路由 - 修复返回键拦截导致的 PopScope 失效问题
This commit is contained in:
32
AGENTS.md
32
AGENTS.md
@@ -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,
|
||||||
|
```
|
||||||
@@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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。
|
||||||
|
*/
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
0
assets/images/ic_device_location.png
Normal file
0
assets/images/ic_device_location.png
Normal 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>
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
lib/core/utils/coord_util.dart
Normal file
50
lib/core/utils/coord_util.dart
Normal 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);
|
||||||
|
}
|
||||||
49
lib/core/utils/system_ui_util.dart
Normal file
49
lib/core/utils/system_ui_util.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
lib/core/utils/time_format.dart
Normal file
57
lib/core/utils/time_format.dart
Normal 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');
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
95
lib/features/auth/data/auth_api.dart
Normal file
95
lib/features/auth/data/auth_api.dart
Normal 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
163
lib/features/device/data/device_api.dart
Normal file
163
lib/features/device/data/device_api.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
lib/features/device/data/device_providers.dart
Normal file
61
lib/features/device/data/device_providers.dart
Normal 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);
|
||||||
|
});
|
||||||
35
lib/features/device/data/device_repository_impl.dart
Normal file
35
lib/features/device/data/device_repository_impl.dart
Normal 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);
|
||||||
|
}
|
||||||
256
lib/features/device/domain/device_models.dart
Normal file
256
lib/features/device/domain/device_models.dart
Normal 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;
|
||||||
|
}
|
||||||
31
lib/features/device/domain/device_repository.dart
Normal file
31
lib/features/device/domain/device_repository.dart
Normal 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);
|
||||||
|
}
|
||||||
225
lib/features/home/data/location_controller.dart
Normal file
225
lib/features/home/data/location_controller.dart
Normal 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,
|
||||||
|
);
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
318
lib/features/home/presentation/location_map_view.dart
Normal file
318
lib/features/home/presentation/location_map_view.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
238
lib/features/home/presentation/map_page.dart
Normal file
238
lib/features/home/presentation/map_page.dart
Normal 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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
112
lib/features/home/presentation/screenshot_page.dart
Normal file
112
lib/features/home/presentation/screenshot_page.dart
Normal 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),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "绑定设备:平板"
|
||||||
|
|||||||
@@ -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: () {});
|
||||||
|
|||||||
464
pubspec.lock
464
pubspec.lock
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user