xworkmate-app/lib/features/mobile/mobile_shell_sheet.dart

675 lines
25 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.

// ignore_for_file: unused_import, unnecessary_import
import 'dart:async';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/ui_feature_manifest.dart';
import '../../app/workspace_page_registry.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
import '../../theme/app_theme.dart';
import '../../widgets/detail_drawer.dart';
import 'mobile_gateway_pairing_guide_page.dart';
import 'mobile_shell_core.dart';
import 'mobile_shell_strip.dart';
import 'mobile_shell_nav.dart';
class MobileSafeSheetInternal extends StatelessWidget {
const MobileSafeSheetInternal({
super.key,
required this.controller,
required this.onClose,
required this.onOpenGatewayConnect,
});
final AppController controller;
final VoidCallback onClose;
final VoidCallback onOpenGatewayConnect;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Material(
color: Colors.transparent,
child: Container(
key: const ValueKey('mobile-safe-sheet'),
margin: const EdgeInsets.fromLTRB(12, 12, 12, 12),
decoration: BoxDecoration(
color: palette.surfacePrimary.withValues(alpha: 0.98),
borderRadius: BorderRadius.circular(AppRadius.dialog + 2),
border: Border.all(color: palette.strokeSoft),
boxShadow: [palette.chromeShadowAmbient],
),
child: SafeArea(
top: false,
child: AnimatedBuilder(
animation: controller,
builder: (context, _) {
final theme = Theme.of(context);
final connection = controller.connection;
final devices = controller.devices;
final hasPendingRun =
controller.hasAssistantPendingRun ||
controller.activeRunId != null;
final securePathLabel = mobileSecurePathLabelInternal(
profile: controller.settings.primaryGatewayProfile,
connection: connection,
);
final localDeviceLabel =
connection.deviceId ?? appText('未初始化', 'Not initialized');
final devicesError = controller.devicesController.error;
Future<void> handleConnect() async {
if (controller.canQuickConnectGateway) {
await controller.connectSavedGateway();
await controller.refreshDevices(quiet: true);
return;
}
onOpenGatewayConnect();
}
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Mobile-safe',
style: theme.textTheme.headlineSmall?.copyWith(
color: palette.textPrimary,
),
),
const SizedBox(height: 6),
Text(
appText(
'移动端只提供结构化审批、配对管理和运行保护动作,不暴露全局 shell 放权。',
'Mobile only exposes structured approvals, pairing controls, and run-safe actions. No global shell approvals.',
),
style: theme.textTheme.bodyMedium?.copyWith(
color: palette.textSecondary,
),
),
],
),
),
const SizedBox(width: 12),
IconButton(
onPressed: onClose,
icon: const Icon(Icons.close_rounded),
),
],
),
const SizedBox(height: 16),
MobileSafeSectionInternal(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('安全直连', 'Secure Direct'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
MobileFactChipInternal(
icon: Icons.lock_outline_rounded,
label: securePathLabel,
color: palette.accent,
background: palette.accentMuted,
),
MobileFactChipInternal(
icon: Icons.monitor_heart_outlined,
label: connection.status.label,
color:
connection.status ==
RuntimeConnectionStatus.connected
? palette.success
: palette.textSecondary,
background:
connection.status ==
RuntimeConnectionStatus.connected
? palette.success.withValues(alpha: 0.14)
: palette.surfaceSecondary,
),
],
),
const SizedBox(height: 10),
Text(
mobileTargetLabelInternal(controller),
style: theme.textTheme.titleSmall?.copyWith(
color: palette.textPrimary,
),
),
const SizedBox(height: 4),
Text(
appText(
'本机设备 ID$localDeviceLabel',
'Local device ID: $localDeviceLabel',
),
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
if (controller.runtime.isConnected) ...[
OutlinedButton(
onPressed: () async {
await controller.refreshGatewayHealth();
await controller.refreshDevices(
quiet: true,
);
},
child: Text(appText('刷新', 'Refresh')),
),
OutlinedButton(
onPressed: controller.disconnectGateway,
child: Text(appText('断开', 'Disconnect')),
),
] else
FilledButton(
key: const ValueKey(
'mobile-safe-sheet-connect-button',
),
onPressed: () => unawaited(handleConnect()),
child: Text(
controller.canQuickConnectGateway
? appText('快速连接', 'Quick Connect')
: appText('配对网关', 'Pair Gateway'),
),
),
if (hasPendingRun)
FilledButton.tonal(
onPressed: controller.abortRun,
child: Text(appText('停止运行', 'Stop Run')),
),
],
),
],
),
),
if (connection.gatewayTokenMissing) ...[
const SizedBox(height: 12),
MobileSafetyNoticeInternal(
tone: palette.danger.withValues(alpha: 0.1),
borderColor: palette.danger.withValues(alpha: 0.2),
icon: Icons.key_off_outlined,
title: appText('缺少共享 Token', 'Shared Token Missing'),
message: appText(
'首次连接需要共享 Token配对完成后可继续使用 device token。',
'The first connection needs a shared token; after pairing, the device token can continue.',
),
),
],
if ((devicesError ?? '').isNotEmpty) ...[
const SizedBox(height: 12),
MobileSafetyNoticeInternal(
tone: palette.danger.withValues(alpha: 0.1),
borderColor: palette.danger.withValues(alpha: 0.2),
icon: Icons.error_outline_rounded,
title: appText('设备列表错误', 'Devices Error'),
message: devicesError!,
),
],
const SizedBox(height: 18),
Text(
appText('待审批请求', 'Pending Requests'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
if (!controller.runtime.isConnected)
Text(
appText(
'恢复 xworkmate-bridge 连接后加载待审批设备与已配对设备。',
'Pending and paired devices load again after xworkmate-bridge reconnects.',
),
style: theme.textTheme.bodyMedium,
)
else if (devices.pending.isEmpty)
Text(
appText('当前没有待审批设备。', 'No pending pairing requests.'),
style: theme.textTheme.bodyMedium,
)
else
Column(
key: const ValueKey('mobile-safe-pending-section'),
children: devices.pending
.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: MobilePendingApprovalCardInternal(
controller: controller,
item: item,
),
),
)
.toList(),
),
const SizedBox(height: 18),
Text(
appText('已配对设备', 'Paired Devices'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
if (!controller.runtime.isConnected)
Text(
appText(
'恢复 xworkmate-bridge 连接后可查看 paired device并在移动端直接吊销。',
'Paired devices are visible again after xworkmate-bridge reconnects, and can be revoked from mobile.',
),
style: theme.textTheme.bodyMedium,
)
else if (devices.paired.isEmpty)
Text(
appText('当前没有已配对设备。', 'No paired devices yet.'),
style: theme.textTheme.bodyMedium,
)
else
Column(
key: const ValueKey('mobile-safe-paired-section'),
children: devices.paired
.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: MobilePairedDeviceCardInternal(
controller: controller,
item: item,
),
),
)
.toList(),
),
],
),
);
},
),
),
),
);
}
}
class MobileSafeSectionInternal extends StatelessWidget {
const MobileSafeSectionInternal({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final palette = context.palette;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: palette.surfaceSecondary.withValues(alpha: 0.78),
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: palette.strokeSoft),
),
child: child,
);
}
}
class MobileFactChipInternal extends StatelessWidget {
const MobileFactChipInternal({
super.key,
required this.icon,
required this.label,
required this.color,
required this.background,
});
final IconData icon;
final String label;
final Color color;
final Color background;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(AppRadius.chip),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Text(
label,
style: theme.textTheme.labelMedium?.copyWith(color: color),
),
],
),
);
}
}
class MobileSafetyNoticeInternal extends StatelessWidget {
const MobileSafetyNoticeInternal({
super.key,
required this.tone,
required this.borderColor,
required this.icon,
required this.title,
required this.message,
});
final Color tone;
final Color borderColor;
final IconData icon;
final String title;
final String message;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: tone,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: borderColor),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: palette.textPrimary),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Text(message, style: theme.textTheme.bodySmall),
],
),
),
],
),
);
}
}
class MobilePendingApprovalCardInternal extends StatelessWidget {
const MobilePendingApprovalCardInternal({
super.key,
required this.controller,
required this.item,
});
final AppController controller;
final GatewayPendingDevice item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final metadata = <String>[
if ((item.role ?? '').isNotEmpty) 'role: ${item.role}',
if (item.scopes.isNotEmpty) item.scopes.join(', '),
if ((item.remoteIp ?? '').isNotEmpty) item.remoteIp!,
mobileRelativeTimeInternal(item.requestedAtMs),
if (item.isRepair) appText('修复请求', 'repair'),
];
return MobileSafeSectionInternal(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.label, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Text(
item.deviceId,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
],
),
),
if (item.isRepair)
MobileFactChipInternal(
icon: Icons.build_circle_outlined,
label: appText('修复', 'Repair'),
color: palette.warning,
background: palette.warning.withValues(alpha: 0.12),
),
],
),
const SizedBox(height: 8),
Text(
metadata.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonal(
onPressed: () =>
controller.approveDevicePairing(item.requestId),
child: Text(appText('批准配对', 'Approve Pairing')),
),
OutlinedButton(
onPressed: () async {
final confirmed = await confirmMobileActionInternal(
context,
title: appText('拒绝配对请求', 'Reject Pairing Request'),
message: appText(
'确定拒绝 ${item.label} 的配对请求吗?',
'Reject the pairing request from ${item.label}?',
),
);
if (confirmed == true) {
await controller.rejectDevicePairing(item.requestId);
}
},
child: Text(appText('拒绝配对', 'Reject Pairing')),
),
],
),
],
),
);
}
}
class MobilePairedDeviceCardInternal extends StatelessWidget {
const MobilePairedDeviceCardInternal({
super.key,
required this.controller,
required this.item,
});
final AppController controller;
final GatewayPairedDevice item;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final palette = context.palette;
final metadata = <String>[
if (item.roles.isNotEmpty) 'roles: ${item.roles.join(', ')}',
if (item.scopes.isNotEmpty) 'scopes: ${item.scopes.join(', ')}',
if ((item.remoteIp ?? '').isNotEmpty) item.remoteIp!,
if (item.currentDevice) appText('当前设备', 'current device'),
];
return MobileSafeSectionInternal(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.label, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Text(
item.deviceId,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall,
),
],
),
),
if (item.currentDevice)
MobileFactChipInternal(
icon: Icons.smartphone_outlined,
label: appText('当前设备', 'Current'),
color: palette.success,
background: palette.success.withValues(alpha: 0.12),
),
],
),
const SizedBox(height: 8),
Text(
metadata.join(' · '),
style: theme.textTheme.bodySmall?.copyWith(
color: palette.textSecondary,
),
),
if (item.tokens.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
appText(
'角色令牌:${item.tokens.first.role}',
'Role token: ${item.tokens.first.role}',
),
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 10),
OutlinedButton(
onPressed: () async {
final confirmed = await confirmMobileActionInternal(
context,
title: appText('吊销已配对设备', 'Revoke Paired Device'),
message: appText(
'确定吊销 ${item.label} 吗?该设备之后需要重新配对。',
'Revoke ${item.label}? The device will need pairing again.',
),
);
if (confirmed == true) {
await controller.removePairedDevice(item.deviceId);
}
},
child: Text(appText('吊销设备', 'Revoke Device')),
),
],
),
);
}
}
Future<bool?> confirmMobileActionInternal(
BuildContext context, {
required String title,
required String message,
}) {
return showDialog<bool>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(appText('取消', 'Cancel')),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(appText('确认', 'Confirm')),
),
],
);
},
);
}
String mobileSecurePathLabelInternal({
required GatewayConnectionProfile profile,
required GatewayConnectionSnapshot connection,
}) {
final mode = connection.mode == RuntimeConnectionMode.unconfigured
? profile.mode
: connection.mode;
return switch (mode) {
RuntimeConnectionMode.remote =>
profile.tls
? appText('Secure Direct TLS', 'Secure Direct TLS')
: appText('Remote Non-TLS', 'Remote Non-TLS'),
RuntimeConnectionMode.unconfigured => appText(
'Gateway 未配置',
'Gateway Not Configured',
),
};
}
String mobileTargetLabelInternal(AppController controller) {
final connection = controller.connection;
if ((connection.remoteAddress ?? '').isNotEmpty) {
return connection.remoteAddress!;
}
final profile = controller.settings.primaryGatewayProfile;
final host = profile.host.trim();
if (host.isNotEmpty && profile.port > 0) {
return '$host:${profile.port}';
}
return appText('未连接目标', 'No target');
}
String mobileRelativeTimeInternal(int? timestampMs) {
if (timestampMs == null || timestampMs <= 0) {
return appText('刚刚', 'just now');
}
final delta = DateTime.now().difference(
DateTime.fromMillisecondsSinceEpoch(timestampMs),
);
if (delta.inMinutes < 1) {
return appText('刚刚', 'just now');
}
if (delta.inHours < 1) {
return appText('${delta.inMinutes} 分钟前', '${delta.inMinutes}m ago');
}
if (delta.inDays < 1) {
return appText('${delta.inHours} 小时前', '${delta.inHours}h ago');
}
return appText('${delta.inDays} 天前', '${delta.inDays}d ago');
}