- 配置 edge-to-edge 沉浸式状态栏与刘海屏适配 - 新增百度地图权限、AK 配置及定位权限说明 - 接入 MethodChannel 实现返回键退后台热启动优化 - 配置自定义签名并更新构建逻辑 - 完善 token 刷新与鉴权失效统一处理 - 新增地图页与截图页路由 - 修复返回键拦截导致的 PopScope 失效问题
322 lines
10 KiB
Dart
322 lines
10 KiB
Dart
import 'package:cupertino_ui/cupertino_ui.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:ttstd_family_care/app/constants/app_constants.dart';
|
||
import 'package:ttstd_family_care/core/widgets/feature_ui.dart';
|
||
import 'package:ttstd_family_care/features/device/data/device_providers.dart';
|
||
import 'package:ttstd_family_care/features/device/domain/device_models.dart';
|
||
import 'package:ttstd_family_care/l10n/app_localizations.dart';
|
||
|
||
/// 应用列表页(对接 client「已安装应用」接口)。
|
||
class AppsPage extends ConsumerWidget {
|
||
const AppsPage({super.key});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final l10n = AppLocalizations.of(context);
|
||
final sn = ref.watch(selectedDeviceSnProvider);
|
||
final appsAsync = sn == null
|
||
? const AsyncValue<List<DeviceApkInfo>>.data(const [])
|
||
: ref.watch(deviceApksProvider(sn));
|
||
|
||
return PopScope(
|
||
// 拦截系统返回键:优先出栈;若无法出栈则跳回「管理」页,避免直接退到桌面。
|
||
canPop: false,
|
||
onPopInvokedWithResult: (didPop, _) {
|
||
if (didPop) return;
|
||
if (context.canPop()) {
|
||
context.pop();
|
||
} else {
|
||
context.go('/messages');
|
||
}
|
||
},
|
||
child: CupertinoPageScaffold(
|
||
navigationBar: CupertinoNavigationBar(
|
||
middle: Text(l10n.appsTitle),
|
||
leading: CupertinoButton(
|
||
padding: EdgeInsets.zero,
|
||
child: const Icon(CupertinoIcons.back),
|
||
onPressed: () => context.pop(),
|
||
),
|
||
),
|
||
child: SafeArea(
|
||
child: appsAsync.when(
|
||
loading: () => const Center(child: CupertinoActivityIndicator()),
|
||
error: (e, _) => Center(
|
||
child: Text('${l10n.appsLoadFailed}:$e',
|
||
style: const TextStyle(color: AppColors.sub)),
|
||
),
|
||
data: (apps) {
|
||
if (apps.isEmpty) {
|
||
return Center(
|
||
child: Text(l10n.appsEmpty,
|
||
style: const TextStyle(color: AppColors.sub)),
|
||
);
|
||
}
|
||
return ListView.builder(
|
||
padding: const EdgeInsets.all(16),
|
||
itemCount: apps.length,
|
||
itemBuilder: (context, index) {
|
||
final app = apps[index];
|
||
return _appCell(context, ref, l10n, sn, app);
|
||
},
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _appCell(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
AppLocalizations l10n,
|
||
String? sn,
|
||
DeviceApkInfo app,
|
||
) =>
|
||
Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
padding: const EdgeInsets.all(14),
|
||
decoration: BoxDecoration(
|
||
color: CupertinoColors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
boxShadow: const [
|
||
BoxShadow(
|
||
color: Color(0x08000000),
|
||
blurRadius: 10,
|
||
offset: Offset(0, 4),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
width: 44,
|
||
height: 44,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.blue.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(12),
|
||
child: _appIcon(app.iconUrl),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(app.appName ?? app.packageName,
|
||
style: const TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.ink)),
|
||
const SizedBox(height: 4),
|
||
Text('${app.packageName}${app.versionName != null ? ' · v${app.versionName}' : ''}',
|
||
style:
|
||
const TextStyle(fontSize: 12, color: AppColors.sub),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis),
|
||
],
|
||
),
|
||
),
|
||
if (app.systemApp)
|
||
Container(
|
||
padding:
|
||
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.sub.withValues(alpha: 0.12),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: const Text('系统',
|
||
style: TextStyle(fontSize: 11, color: AppColors.sub)),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
_actionButton(context, ref, l10n, sn, app, 'launch',
|
||
l10n.appOpLaunch),
|
||
const SizedBox(width: 8),
|
||
_actionButton(context, ref, l10n, sn, app, 'stop',
|
||
l10n.appOpStop),
|
||
const SizedBox(width: 8),
|
||
_actionButton(context, ref, l10n, sn, app, 'clear_data',
|
||
l10n.appOpClearData),
|
||
const SizedBox(width: 8),
|
||
_actionButton(
|
||
context,
|
||
ref,
|
||
l10n,
|
||
sn,
|
||
app,
|
||
'uninstall',
|
||
l10n.appOpUninstall,
|
||
destructive: true,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
/// 应用操作按钮:点击后执行对应操作;卸载/清除数据等危险操作先弹确认框。
|
||
Widget _actionButton(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
AppLocalizations l10n,
|
||
String? sn,
|
||
DeviceApkInfo app,
|
||
String op,
|
||
String label, {
|
||
bool destructive = false,
|
||
}) {
|
||
final Color color = destructive ? AppColors.red : AppColors.blue;
|
||
return Expanded(
|
||
child: GestureDetector(
|
||
onTap: () {
|
||
if (op == 'uninstall') {
|
||
_confirmAppOp(context, ref, l10n, sn, app, 'uninstall',
|
||
l10n.appOpUninstallConfirm);
|
||
} else if (op == 'clear_data') {
|
||
_confirmAppOp(context, ref, l10n, sn, app, 'clear_data',
|
||
l10n.appOpClearDataConfirm);
|
||
} else {
|
||
_runAppOp(context, ref, l10n, sn, app.packageName, op);
|
||
}
|
||
},
|
||
child: Container(
|
||
height: 34,
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.1),
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
color: color,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 危险应用操作(卸载/清除数据)确认弹窗。
|
||
Future<void> _confirmAppOp(
|
||
BuildContext context,
|
||
WidgetRef ref,
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 应用图标:后台返回的是相对路径(如 /static/app_icon/xxx.png),
|
||
/// 需要拼接接口 Host 才能作为网络图片加载;无图标或加载失败时回退到占位图标。
|
||
Widget _appIcon(String? iconUrl) {
|
||
final resolved = _resolveIconUrl(iconUrl);
|
||
if (resolved == null) {
|
||
return const Center(
|
||
child: Icon(CupertinoIcons.app_fill, color: AppColors.blue, size: 22),
|
||
);
|
||
}
|
||
return Image.network(
|
||
resolved,
|
||
width: 44,
|
||
height: 44,
|
||
fit: BoxFit.cover,
|
||
errorBuilder: (_, __, ___) => const Center(
|
||
child: Icon(CupertinoIcons.app_fill, color: AppColors.blue, size: 22),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 将相对或绝对图标地址解析为完整可访问的 URL。
|
||
String? _resolveIconUrl(String? iconUrl) {
|
||
if (iconUrl == null || iconUrl.isEmpty) return null;
|
||
if (iconUrl.startsWith('http://') || iconUrl.startsWith('https://')) {
|
||
return iconUrl;
|
||
}
|
||
// 去除 /api 前缀,保留协议与 Host。
|
||
final base = AppConstants.kBaseUrl.replaceFirst(RegExp(r'/api/?$'), '');
|
||
return '$base$iconUrl';
|
||
}
|
||
}
|