feat: add mobile gateway pairing guide
This commit is contained in:
parent
3bd16e1f89
commit
332ee750b3
@ -30,6 +30,8 @@
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>XWorkmate uses your local network only when you explicitly connect to a user-configured OpenClaw Gateway on the same network.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>XWorkmate uses the camera only when you explicitly scan a gateway pairing QR code.</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@ -270,7 +270,7 @@ mobile:
|
||||
description: Mobile Vault server integration section
|
||||
ui_surface: settings_page
|
||||
gateway_setup_code:
|
||||
enabled: false
|
||||
enabled: true
|
||||
release_tier: experimental
|
||||
build_modes: [debug, profile, release]
|
||||
description: Mobile gateway setup code editor
|
||||
|
||||
516
lib/features/mobile/mobile_gateway_pairing_guide_page.dart
Normal file
516
lib/features/mobile/mobile_gateway_pairing_guide_page.dart
Normal file
@ -0,0 +1,516 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
import '../../i18n/app_language.dart';
|
||||
import '../../runtime/gateway_runtime.dart';
|
||||
import '../../theme/app_palette.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
|
||||
class MobileGatewayPairingGuidePage extends StatelessWidget {
|
||||
const MobileGatewayPairingGuidePage({
|
||||
super.key,
|
||||
required this.supportsQrScan,
|
||||
required this.onManualInput,
|
||||
required this.onScannedSetupCode,
|
||||
});
|
||||
|
||||
final bool supportsQrScan;
|
||||
final VoidCallback onManualInput;
|
||||
final Future<void> Function(String setupCode) onScannedSetupCode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF3F1EF),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
_HeaderCircleButton(
|
||||
key: const ValueKey('pairing-guide-close-button'),
|
||||
icon: Icons.close_rounded,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'配对网关',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 56),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 28),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 118,
|
||||
height: 118,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
border: Border.all(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.hub_outlined,
|
||||
size: 56,
|
||||
color: palette.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
Text(
|
||||
'配对你的 OpenClaw 主机',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'在 Mac、Windows 或云端部署的 OpenClaw 主机上安装 xworkmate,然后生成配对二维码或配置码。',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: palette.textSecondary,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_GuideCard(
|
||||
key: const ValueKey('pairing-guide-install-card'),
|
||||
title: '自主安装',
|
||||
subtitle: '按下面两步在主机上安装 XWorkmate CLI,然后生成配对码。',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'1. 安装',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_CommandBlock(
|
||||
key: const ValueKey(
|
||||
'pairing-guide-install-command',
|
||||
),
|
||||
command: 'npm install -g xworkmate',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'2. 配对',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
_CommandBlock(
|
||||
key: const ValueKey('pairing-guide-pair-command'),
|
||||
command: 'xworkmate pair',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
key: const ValueKey('pairing-guide-scan-button'),
|
||||
onPressed: () async {
|
||||
if (!supportsQrScan) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
appText(
|
||||
'Android 扫码即将支持,当前请先使用手动输入代码。',
|
||||
'Android QR scanning is coming soon. Use manual code entry for now.',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final result = await Navigator.of(context)
|
||||
.push<String>(
|
||||
MaterialPageRoute<String>(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) =>
|
||||
const MobileGatewayQrScannerPage(),
|
||||
),
|
||||
);
|
||||
if (result == null || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop();
|
||||
await onScannedSetupCode(result);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
backgroundColor: const Color(0xFF151517),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppRadius.button,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'扫描二维码',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
key: const ValueKey('pairing-guide-manual-button'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
onManualInput();
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: palette.textPrimary,
|
||||
side: BorderSide(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppRadius.button,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'手动输入代码',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MobileGatewayQrScannerPage extends StatefulWidget {
|
||||
const MobileGatewayQrScannerPage({super.key});
|
||||
|
||||
@override
|
||||
State<MobileGatewayQrScannerPage> createState() =>
|
||||
_MobileGatewayQrScannerPageState();
|
||||
}
|
||||
|
||||
class _MobileGatewayQrScannerPageState
|
||||
extends State<MobileGatewayQrScannerPage> {
|
||||
bool _hasHandledDetection = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: _QrScannerSurface(onCodeDetected: _handleDetectedCode),
|
||||
),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_HeaderCircleButton(
|
||||
key: const ValueKey('pairing-scanner-close-button'),
|
||||
icon: Icons.close_rounded,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: Colors.black.withValues(alpha: 0.28),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(AppRadius.dialog),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'扫描配对二维码',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'将二维码放入取景框内。扫描成功后会自动把配置码带入 Gateway 设置页。',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.82),
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleDetectedCode(String raw) {
|
||||
if (_hasHandledDetection) {
|
||||
return;
|
||||
}
|
||||
final setupCode = resolveGatewaySetupCodeFromScan(raw);
|
||||
if (setupCode == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
appText('未识别到有效配置码,请重试。', 'No valid setup code found. Try again.'),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_hasHandledDetection = true;
|
||||
Navigator.of(context).pop(setupCode);
|
||||
}
|
||||
}
|
||||
|
||||
String? resolveGatewaySetupCodeFromScan(String raw) {
|
||||
final trimmed = raw.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final candidate = _extractSetupCodeFromJsonPayload(trimmed) ?? trimmed;
|
||||
return decodeGatewaySetupCode(candidate) != null ? candidate : null;
|
||||
}
|
||||
|
||||
String? _extractSetupCodeFromJsonPayload(String raw) {
|
||||
final normalized = raw.trim();
|
||||
if (!normalized.startsWith('{')) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final dynamic decoded = jsonDecode(normalized);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
final setupCode = decoded['setupCode'];
|
||||
if (setupCode is! String || setupCode.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return setupCode.trim();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _GuideCard extends StatelessWidget {
|
||||
const _GuideCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(subtitle, style: theme.textTheme.bodyLarge),
|
||||
const SizedBox(height: 18),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommandBlock extends StatelessWidget {
|
||||
const _CommandBlock({super.key, required this.command});
|
||||
|
||||
final String command;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final palette = context.palette;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(18, 14, 14, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8F6F4),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
command,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: palette.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(ClipboardData(text: command));
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(appText('已复制命令。', 'Command copied.'))),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.content_copy_rounded),
|
||||
tooltip: appText('复制命令', 'Copy command'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderCircleButton extends StatelessWidget {
|
||||
const _HeaderCircleButton({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.foregroundColor,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
final Color? foregroundColor;
|
||||
final Color? backgroundColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
return SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? Colors.white.withValues(alpha: 0.9),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon),
|
||||
color: foregroundColor ?? palette.textPrimary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QrScannerSurface extends StatelessWidget {
|
||||
const _QrScannerSurface({required this.onCodeDetected});
|
||||
|
||||
final ValueChanged<String> onCodeDetected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MobileScanner(
|
||||
key: const ValueKey('pairing-guide-ios-scanner'),
|
||||
onDetect: (capture) {
|
||||
final code = capture.barcodes
|
||||
.map((item) => item.rawValue?.trim() ?? '')
|
||||
.firstWhere((item) => item.isNotEmpty, orElse: () => '');
|
||||
if (code.isEmpty) {
|
||||
return;
|
||||
}
|
||||
onCodeDetected(code);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -11,6 +11,7 @@ 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';
|
||||
|
||||
enum MobileShellTab { assistant, tasks, workspace, secrets, settings }
|
||||
|
||||
@ -152,6 +153,54 @@ class _MobileShellState extends State<MobileShell> {
|
||||
rootLabel: appText('移动端', 'Mobile'),
|
||||
destination: WorkspaceDestination.settings,
|
||||
sectionLabel: appText('集成', 'Integrations'),
|
||||
gatewayProfileIndex: kGatewayRemoteProfileIndex,
|
||||
prefersGatewaySetupCode: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openGatewaySetupCodeEntry({String? prefilledSetupCode}) async {
|
||||
final setupCode = prefilledSetupCode?.trim() ?? '';
|
||||
if (setupCode.isNotEmpty) {
|
||||
final current = widget
|
||||
.controller
|
||||
.settingsDraft
|
||||
.gatewayProfiles[kGatewayRemoteProfileIndex];
|
||||
await widget.controller.saveSettingsDraft(
|
||||
widget.controller.settingsDraft.copyWithGatewayProfileAt(
|
||||
kGatewayRemoteProfileIndex,
|
||||
current.copyWith(useSetupCode: true, setupCode: setupCode),
|
||||
),
|
||||
);
|
||||
}
|
||||
widget.controller.openSettings(
|
||||
detail: SettingsDetailPage.gatewayConnection,
|
||||
navigationContext: SettingsNavigationContext(
|
||||
rootLabel: appText('移动端', 'Mobile'),
|
||||
destination: WorkspaceDestination.settings,
|
||||
sectionLabel: appText('集成', 'Integrations'),
|
||||
gatewayProfileIndex: kGatewayRemoteProfileIndex,
|
||||
prefersGatewaySetupCode: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showPairingGuidePage() {
|
||||
unawaited(_showPairingGuidePageFlow());
|
||||
}
|
||||
|
||||
Future<void> _showPairingGuidePageFlow() async {
|
||||
final supportsQrScan = Theme.of(context).platform == TargetPlatform.iOS;
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => MobileGatewayPairingGuidePage(
|
||||
supportsQrScan: supportsQrScan,
|
||||
onManualInput: () => unawaited(_openGatewaySetupCodeEntry()),
|
||||
onScannedSetupCode: (setupCode) async {
|
||||
await _openGatewaySetupCodeEntry(prefilledSetupCode: setupCode);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -172,7 +221,7 @@ class _MobileShellState extends State<MobileShell> {
|
||||
Navigator.of(sheetContext).pop();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
_showConnectSheet();
|
||||
_showPairingGuidePage();
|
||||
}
|
||||
});
|
||||
},
|
||||
@ -267,7 +316,7 @@ class _MobileShellState extends State<MobileShell> {
|
||||
_MobileSafeStrip(
|
||||
controller: widget.controller,
|
||||
onOpenSafeSheet: _showMobileSafeSheet,
|
||||
onOpenGatewayConnect: _showConnectSheet,
|
||||
onOpenGatewayConnect: _showPairingGuidePage,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Expanded(
|
||||
@ -491,11 +540,11 @@ class _MobileSafeStrip extends StatelessWidget {
|
||||
else
|
||||
FilledButton(
|
||||
key: const ValueKey('mobile-safe-connect-button'),
|
||||
onPressed: handlePrimaryConnect,
|
||||
onPressed: () => unawaited(handlePrimaryConnect()),
|
||||
child: Text(
|
||||
controller.canQuickConnectGateway
|
||||
? appText('快速连接', 'Quick Connect')
|
||||
: appText('连接 Gateway', 'Connect Gateway'),
|
||||
: appText('配对网关', 'Pair Gateway'),
|
||||
),
|
||||
),
|
||||
if (hasPendingRun)
|
||||
@ -678,14 +727,11 @@ class _MobileSafeSheet extends StatelessWidget {
|
||||
key: const ValueKey(
|
||||
'mobile-safe-sheet-connect-button',
|
||||
),
|
||||
onPressed: handleConnect,
|
||||
onPressed: () => unawaited(handleConnect()),
|
||||
child: Text(
|
||||
controller.canQuickConnectGateway
|
||||
? appText('快速连接', 'Quick Connect')
|
||||
: appText(
|
||||
'打开集成设置',
|
||||
'Open Integrations',
|
||||
),
|
||||
: appText('配对网关', 'Pair Gateway'),
|
||||
),
|
||||
),
|
||||
if (hasPendingRun)
|
||||
|
||||
@ -133,6 +133,32 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
if (widget.navigationContext != _navigationContext) {
|
||||
_navigationContext = widget.navigationContext;
|
||||
}
|
||||
_applyGatewayNavigationHints();
|
||||
}
|
||||
|
||||
void _applyGatewayNavigationHints() {
|
||||
final detail = _detail;
|
||||
final navigationContext = _navigationContext;
|
||||
if (detail != SettingsDetailPage.gatewayConnection ||
|
||||
navigationContext == null) {
|
||||
return;
|
||||
}
|
||||
final gatewayProfileIndex = navigationContext.gatewayProfileIndex;
|
||||
if (gatewayProfileIndex == null) {
|
||||
return;
|
||||
}
|
||||
_selectedGatewayProfileIndex = gatewayProfileIndex.clamp(
|
||||
0,
|
||||
kGatewayProfileListLength - 1,
|
||||
);
|
||||
}
|
||||
|
||||
bool _prefersGatewaySetupCodeForCurrentContext(BuildContext context) {
|
||||
return resolveUiFeaturePlatformFromContext(context) ==
|
||||
UiFeaturePlatform.mobile &&
|
||||
_detail == SettingsDetailPage.gatewayConnection &&
|
||||
_navigationContext?.prefersGatewaySetupCode == true &&
|
||||
_selectedGatewayProfileIndex != kGatewayLocalProfileIndex;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -169,6 +195,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_tab = uiFeatures.sanitizeSettingsTab(controller.settingsTab);
|
||||
_detail = controller.settingsDetail;
|
||||
_navigationContext = controller.settingsNavigationContext;
|
||||
_applyGatewayNavigationHints();
|
||||
final settings = controller.settingsDraft;
|
||||
final showingDetail = _detail != null;
|
||||
final showGlobalApplyBar =
|
||||
@ -1160,9 +1187,13 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
resolveUiFeaturePlatformFromContext(context),
|
||||
);
|
||||
final setupCodeFeatureEnabled = uiFeatures.supportsGatewaySetupCode;
|
||||
final forceSetupCodeMode = _prefersGatewaySetupCodeForCurrentContext(
|
||||
context,
|
||||
);
|
||||
final useSetupCode = selectedProfileIndex == kGatewayLocalProfileIndex
|
||||
? false
|
||||
: setupCodeFeatureEnabled && gatewayProfile.useSetupCode;
|
||||
: forceSetupCodeMode ||
|
||||
(setupCodeFeatureEnabled && gatewayProfile.useSetupCode);
|
||||
final gatewayTls = gatewayMode == RuntimeConnectionMode.local
|
||||
? false
|
||||
: gatewayProfile.tls;
|
||||
@ -1220,6 +1251,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (selectedProfileIndex != kGatewayLocalProfileIndex &&
|
||||
!forceSetupCodeMode &&
|
||||
setupCodeFeatureEnabled) ...[
|
||||
SectionTabs(
|
||||
items: [appText('配置码', 'Setup Code'), appText('手动配置', 'Manual')],
|
||||
@ -1245,6 +1277,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
TextField(
|
||||
key: const ValueKey('gateway-setup-code-field'),
|
||||
controller: _gatewaySetupCodeController,
|
||||
autofocus: forceSetupCodeMode,
|
||||
minLines: 4,
|
||||
maxLines: 6,
|
||||
decoration: InputDecoration(
|
||||
@ -3162,9 +3195,13 @@ XWorkmate Privacy Policy
|
||||
_selectedGatewayProfileIndex,
|
||||
current,
|
||||
);
|
||||
final forceSetupCodeMode =
|
||||
_navigationContext?.prefersGatewaySetupCode == true &&
|
||||
_detail == SettingsDetailPage.gatewayConnection &&
|
||||
_selectedGatewayProfileIndex != kGatewayLocalProfileIndex;
|
||||
final useSetupCode = mode == RuntimeConnectionMode.local
|
||||
? false
|
||||
: current.useSetupCode;
|
||||
: forceSetupCodeMode || current.useSetupCode;
|
||||
final tls = mode == RuntimeConnectionMode.local ? false : current.tls;
|
||||
final parsedPort = int.tryParse(_gatewayPortController.text.trim());
|
||||
final decoded = useSetupCode
|
||||
|
||||
@ -278,6 +278,8 @@ class SettingsNavigationContext {
|
||||
this.secretsTab,
|
||||
this.aiGatewayTab,
|
||||
this.settingsTab,
|
||||
this.gatewayProfileIndex,
|
||||
this.prefersGatewaySetupCode,
|
||||
});
|
||||
|
||||
final String rootLabel;
|
||||
@ -287,6 +289,8 @@ class SettingsNavigationContext {
|
||||
final SecretsTab? secretsTab;
|
||||
final AiGatewayTab? aiGatewayTab;
|
||||
final SettingsTab? settingsTab;
|
||||
final int? gatewayProfileIndex;
|
||||
final bool? prefersGatewaySetupCode;
|
||||
}
|
||||
|
||||
enum AccountTab { profile, workspace, sessions }
|
||||
|
||||
@ -8,6 +8,7 @@ import Foundation
|
||||
import device_info_plus
|
||||
import file_selector_macos
|
||||
import irondash_engine_context
|
||||
import mobile_scanner
|
||||
import package_info_plus
|
||||
import shared_preferences_foundation
|
||||
import super_native_extensions
|
||||
@ -16,6 +17,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
|
||||
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin"))
|
||||
|
||||
@ -380,6 +380,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
mobile_scanner:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: mobile_scanner
|
||||
sha256: "0b466a0a8a211b366c2e87f3345715faef9b6011c7147556ad22f37de6ba3173"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.11"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -23,6 +23,7 @@ dependencies:
|
||||
flutter_markdown: ^0.7.7+1
|
||||
http: ^1.5.0
|
||||
markdown: ^7.3.0
|
||||
mobile_scanner: ^6.0.7
|
||||
package_info_plus: ^8.3.1
|
||||
path_provider: ^2.1.5
|
||||
shared_preferences: ^2.5.3
|
||||
|
||||
@ -3,6 +3,7 @@ library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app_shell.dart';
|
||||
import 'package:xworkmate/app/ui_feature_manifest.dart';
|
||||
@ -14,6 +15,34 @@ import 'package:xworkmate/theme/app_theme.dart';
|
||||
import '../../test_support.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const mobileScannerChannel = MethodChannel(
|
||||
'dev.steenbakker.mobile_scanner/scanner/method',
|
||||
);
|
||||
|
||||
setUp(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(mobileScannerChannel, (call) async {
|
||||
return switch (call.method) {
|
||||
'state' => 1,
|
||||
'request' => true,
|
||||
'start' => <Object?, Object?>{
|
||||
'textureId': 1,
|
||||
'size': <Object?, Object?>{'width': 1080.0, 'height': 1920.0},
|
||||
'numberOfCameras': 1,
|
||||
'currentTorchMode': 0,
|
||||
},
|
||||
_ => null,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(mobileScannerChannel, null);
|
||||
});
|
||||
|
||||
Future<void> pumpMobileShell(
|
||||
WidgetTester tester, {
|
||||
required Widget child,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user