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

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

View File

@@ -0,0 +1,163 @@
import 'package:cupertino_ui/cupertino_ui.dart';
import 'package:ttstd_family_care/app/constants/app_constants.dart';
import 'package:ttstd_family_care/core/network/api_exception.dart';
import 'package:ttstd_family_care/core/network/dio_client.dart';
import 'package:ttstd_family_care/features/device/domain/device_models.dart';
/// 客户端(家属端)设备接口访问层。
///
/// 后端接口前缀为 `/api/v1/client/sn`(与登录接口所在的 `/api/v1/open` 不同),
/// 由于 Dio 在 path 以 `/` 开头时会替换 baseUrl 的完整 path 段(保留 host
/// 这里直接使用绝对路径即可正确命中 client 接口。
/// [DioClient.get] 已统一解析为后台返回体中的 `data` 字段;业务失败时抛出
/// [ApiException],本层将其降级为「空数据」以适配家属端展示。
class DeviceApi {
const DeviceApi();
/// 获取当前用户已绑定的设备列表。
Future<List<DeviceBrief>> getMyDevices() async {
try {
final data = await DioClient.get(
'v1/client/sn/my-devices',
);
return _parseList(data, DeviceBrief.fromJson);
} on ApiException {
return const [];
}
}
/// 获取已绑定设备基本信息(无上报数据时返回 null
Future<DeviceSystemInfo?> getDeviceInfo(String sn) async {
try {
final data = await DioClient.get(
'v1/client/sn/device-info',
queryParameters: {'sn': sn},
);
return data is Map<String, dynamic> ? DeviceSystemInfo.fromJson(data) : null;
} on ApiException {
return null;
}
}
/// 获取已绑定设备最新定位信息(无上报数据时返回 null
Future<DeviceLocation?> getLocation(String sn) async {
try {
final data = await DioClient.get(
'v1/client/sn/location',
queryParameters: {'sn': sn},
);
return data is Map<String, dynamic> ? DeviceLocation.fromJson(data) : null;
} on ApiException {
return null;
}
}
/// 获取已绑定设备已安装应用列表。
Future<List<DeviceApkInfo>> getApks(String sn) async {
try {
final data = await DioClient.get(
'v1/client/sn/apks',
queryParameters: {'sn': sn},
);
return _parseList(data, DeviceApkInfo.fromJson);
} on ApiException {
return const [];
}
}
/// 向设备下发操作指令(重启/关机/截屏/刷新/定位等)。
///
/// [op] 为后端操作标识,如 `reboot` / `shutdown` / `screenshot` / `refresh` / `locate`
/// 对应接口路径 `/api/v1/client/sn/ops/{op}?sn=xxx`。
/// 成功返回 null失败返回后台错误提示。
Future<String?> sendDeviceOp(String sn, String op) async {
try {
final data = await DioClient.post(
'v1/client/sn/ops/$op',
queryParameters: {'sn': sn},
);
debugPrint('[DeviceApi] sendDeviceOp op=$op sn=$sn data=$data');
return null;
} on ApiException catch (e) {
debugPrint('[DeviceApi] sendDeviceOp op=$op sn=$sn failed: ${e.message}');
return e.message;
}
}
/// 对指定应用执行操作(打开/停止/卸载/清除数据)。
///
/// [op] 为后端操作标识:`launch` / `stop` / `uninstall` / `clear_data`
/// 对应接口路径 `/api/v1/client/sn/ops/app/{op}?sn=xxx`,请求体携带应用包名。
/// 成功返回 null失败返回后台错误提示。
Future<String?> sendAppOp(String sn, String op, String packageName) async {
try {
final data = await DioClient.post(
'v1/client/sn/ops/app/$op',
queryParameters: {'sn': sn},
data: {'packageName': packageName},
);
debugPrint('[DeviceApi] sendAppOp op=$op sn=$sn pkg=$packageName data=$data');
return null;
} on ApiException catch (e) {
debugPrint('[DeviceApi] sendAppOp op=$op sn=$sn pkg=$packageName failed: ${e.message}');
return e.message;
}
}
// ---------------------------------------------------------------------------
static List<T> _parseList<T>(
dynamic data,
T Function(Map<String, dynamic>) fromJson,
) {
if (data is! List) return const [];
return data
.whereType<Map<String, dynamic>>()
.map(fromJson)
.toList(growable: false);
}
// ---------------------------------------------------------------------------
/// 获取设备最近截图列表(家属端)。
///
/// [sn] 为设备序列号(可空,由后台按绑定关系补全)。
/// 返回 [ScreenshotVO] 列表,并自动补全图片可访问的完整 URL拼接 baseUrl
Future<List<ScreenshotVO>> getRecentScreenshots({String? sn}) async {
final query = sn != null && sn.isNotEmpty ? {'sn': sn} : <String, dynamic>{};
// DioClient.get 已统一解析出响应体中的 data 字段,
// 后台返回结构为 { code, msg, data: [...] },故此处 payload 即为截图数组。
final payload = await DioClient.get(
'v1/client/sn/upload-screenshot',
queryParameters: query,
);
// 防御性提取:兼容后台直接返回 List或返回 { data: [...] } 的包裹结构。
final List<dynamic> listData;
if (payload is List) {
listData = payload;
} else if (payload is Map && payload['data'] is List) {
listData = payload['data'] as List;
} else {
debugPrint('[DeviceApi] getRecentScreenshots 返回非预期结构: sn=$sn payload=$payload');
return const [];
}
debugPrint('[DeviceApi] getRecentScreenshots sn=$sn count=${listData.length}');
final origin = Uri.parse(AppConstants.kBaseUrl).origin;
return listData.whereType<Map<String, dynamic>>().map((e) {
final vo = ScreenshotVO.fromJson(e);
// 后台返回的 url 为相对路径(如 /static/screenshot/xxx补全为完整地址。
final raw = vo.url;
final fullUrl = (raw != null && raw.startsWith('/'))
? '$origin$raw'
: (raw ?? '');
return ScreenshotVO(
id: vo.id,
sn: vo.sn,
fileName: vo.fileName,
fileSize: vo.fileSize,
url: fullUrl,
uploadTime: vo.uploadTime,
);
}).toList(growable: false);
}
}

View File

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

View File

@@ -0,0 +1,35 @@
import 'package:ttstd_family_care/features/device/data/device_api.dart';
import 'package:ttstd_family_care/features/device/domain/device_models.dart';
import 'package:ttstd_family_care/features/device/domain/device_repository.dart';
/// [DeviceRepository] 的默认实现,委托给 [DeviceApi]。
class DeviceRepositoryImpl implements DeviceRepository {
const DeviceRepositoryImpl(this._api);
final DeviceApi _api;
@override
Future<List<DeviceBrief>> getMyDevices() => _api.getMyDevices();
@override
Future<DeviceSystemInfo?> getDeviceInfo(String sn) => _api.getDeviceInfo(sn);
@override
Future<DeviceLocation?> getLocation(String sn) => _api.getLocation(sn);
@override
Future<List<DeviceApkInfo>> getApks(String sn) => _api.getApks(sn);
@override
Future<List<ScreenshotVO>> getRecentScreenshots(String sn) =>
_api.getRecentScreenshots(sn: sn);
@override
Future<String?> sendDeviceOp(String sn, String op) =>
_api.sendDeviceOp(sn, op);
@override
Future<String?> sendAppOp(String sn, String op, String packageName) =>
_api.sendAppOp(sn, op, packageName);
}

View File

@@ -0,0 +1,256 @@
/// 设备相关领域实体(家属端对接 client 接口)。
///
/// 以不可变类表达,避免 UI / 仓库直接依赖后台 DTO 结构。
/// 因未运行 build_runner避免改动依赖与生成产物此处手写等价实现
/// 所有字段 final、提供 const 构造与 copyWith并手动实现 JSON 转换。
/// 设备简要信息(「我的设备」列表)。
class DeviceBrief {
const DeviceBrief({
required this.serialno,
this.snName,
this.snModel,
this.snMobile,
this.status,
this.activateTime,
});
final String serialno;
final String? snName;
final String? snModel;
final String? snMobile;
final int? status;
final String? activateTime;
factory DeviceBrief.fromJson(Map<String, dynamic> json) => DeviceBrief(
serialno: json['serialno'] as String,
snName: json['snName'] as String?,
snModel: json['snModel'] as String?,
snMobile: json['snMobile'] as String?,
status: _toInt(json['status']),
activateTime: json['activateTime']?.toString(),
);
}
/// 设备基本信息(系统信息面板)。
class DeviceSystemInfo {
const DeviceSystemInfo({
this.serialno,
this.deviceName,
this.deviceBrand,
this.deviceModel,
this.androidVersion,
this.releaseVersion,
this.screenResolution,
this.osType,
this.osVersion,
this.firmwareVersion,
});
final String? serialno;
final String? deviceName;
final String? deviceBrand;
final String? deviceModel;
final String? androidVersion;
final String? releaseVersion;
final String? screenResolution;
final String? osType;
final String? osVersion;
final String? firmwareVersion;
factory DeviceSystemInfo.fromJson(Map<String, dynamic> json) => DeviceSystemInfo(
serialno: json['serialno'] as String?,
deviceName: json['deviceName'] as String?,
deviceBrand: json['deviceBrand'] as String?,
deviceModel: json['deviceModel'] as String?,
androidVersion: json['androidVersion'] as String?,
releaseVersion: json['releaseVersion'] as String?,
screenResolution: json['screenResolution'] as String?,
osType: json['osType'] as String?,
osVersion: json['osVersion'] as String?,
firmwareVersion: json['firmwareVersion'] as String?,
);
}
/// 设备定位信息。
class DeviceLocation {
const DeviceLocation({
this.sn,
this.country,
this.province,
this.city,
this.district,
this.street,
this.address,
this.locationDescribe,
this.longitude,
this.latitude,
this.lastSuccessfulTime,
});
final String? sn;
final String? country;
final String? province;
final String? city;
final String? district;
final String? street;
final String? address;
final String? locationDescribe;
final String? longitude;
final String? latitude;
final String? lastSuccessfulTime;
factory DeviceLocation.fromJson(Map<String, dynamic> json) => DeviceLocation(
sn: json['sn'] as String?,
country: json['country'] as String?,
province: json['province'] as String?,
city: json['city'] as String?,
district: json['district'] as String?,
street: json['street'] as String?,
address: json['address'] as String?,
locationDescribe: json['locationDescribe'] as String?,
longitude: _toDoubleString(json['longitude']),
latitude: _toDoubleString(json['latitude']),
lastSuccessfulTime: json['lastSuccessfulTime']?.toString(),
);
/// 拼接展示用地址:省+市+区+街道+详细地址。
String get displayAddress {
final parts = [
province,
city,
district,
street,
address,
].where((e) => e != null && e.isNotEmpty);
return parts.join('');
}
}
/// 设备已安装应用信息。
class DeviceApkInfo {
const DeviceApkInfo({
required this.packageName,
this.appName,
this.versionName,
this.versionCode,
this.installTime,
this.lastUpdateTime,
this.apkSize,
this.systemApp = false,
this.iconUrl,
});
final String packageName;
final String? appName;
final String? versionName;
final int? versionCode;
final String? installTime;
final String? lastUpdateTime;
final int? apkSize;
final bool systemApp;
final String? iconUrl;
factory DeviceApkInfo.fromJson(Map<String, dynamic> json) => DeviceApkInfo(
packageName: json['packageName'] as String,
appName: json['appName'] as String?,
versionName: json['versionName'] as String?,
versionCode: _toInt(json['versionCode']),
installTime: json['installTime']?.toString(),
lastUpdateTime: json['lastUpdateTime']?.toString(),
apkSize: _toInt(json['apkSize']),
systemApp: json['systemApp'] as bool? ?? false,
iconUrl: json['iconUrl'] as String?,
);
}
/// 设备截图实体(与后台 SnScreenshot 对齐)。
class SnScreenshot {
const SnScreenshot({
this.id,
this.sn,
this.fileName,
this.filePath,
this.fileSize,
this.fileMd5,
this.fileSha1,
this.fileSha256,
this.uploadTime,
});
final int? id;
final String? sn;
final String? fileName;
final String? filePath;
final int? fileSize;
final String? fileMd5;
final String? fileSha1;
final String? fileSha256;
final String? uploadTime;
factory SnScreenshot.fromJson(Map<String, dynamic> json) => SnScreenshot(
id: _toInt(json['id']),
sn: json['sn'] as String?,
fileName: json['fileName'] as String?,
filePath: json['filePath'] as String?,
fileSize: _toInt(json['fileSize']),
fileMd5: json['fileMd5'] as String?,
fileSha1: json['fileSha1'] as String?,
fileSha256: json['fileSha256'] as String?,
uploadTime: json['uploadTime']?.toString(),
);
}
/// 设备截图视图对象(与后台 ScreenshotVO 对齐)。
class ScreenshotVO {
const ScreenshotVO({
this.id,
this.sn,
this.fileName,
this.fileSize,
this.url,
this.uploadTime,
});
final int? id;
final String? sn;
final String? fileName;
final int? fileSize;
final String? url;
final String? uploadTime;
factory ScreenshotVO.fromJson(Map<String, dynamic> json) => ScreenshotVO(
id: _toInt(json['id']),
sn: json['sn'] as String?,
fileName: json['fileName'] as String?,
fileSize: _toInt(json['fileSize']),
url: json['url'] as String?,
uploadTime: json['uploadTime']?.toString(),
);
}
/// 容错整型解析:后台可能以字符串(如 "123")或数字返回整型字段,
/// 直接用 `as int?` 在字符串场景下会抛出
/// `type 'String' is not a subtype of type 'int'`,故统一在此转换。
int? _toInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}
/// 容错浮点解析(用于经纬度等带小数的字段),返回字符串形式便于上层 `double.tryParse`。
///
/// 经纬度必须保留小数精度:
/// - 后台若以数字(如 `116.404`)返回,直接截断成整数会偏移数公里;
/// - 后台若以字符串(如 `"39.915"`)返回,`int.tryParse` 会解析失败变成 null。
String? _toDoubleString(dynamic value) {
if (value == null) return null;
if (value is num) return value.toString();
if (value is String) {
final parsed = double.tryParse(value);
return parsed?.toString();
}
return null;
}

View File

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