Files
ttstd_family_care/lib/features/apps/presentation/apps_page.dart
TongTongStudio 06cdc77172 feat(android): 支持沉浸式状态栏、地图定位与自定义签名
- 配置 edge-to-edge 沉浸式状态栏与刘海屏适配
- 新增百度地图权限、AK 配置及定位权限说明
- 接入 MethodChannel 实现返回键退后台热启动优化
- 配置自定义签名并更新构建逻辑
- 完善 token 刷新与鉴权失效统一处理
- 新增地图页与截图页路由
- 修复返回键拦截导致的 PopScope 失效问题
2026-08-19 03:43:12 +08:00

322 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
}
}