xworkmate-app/lib/widgets/gateway_connect_dialog.dart

332 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import '../app/app_controller.dart';
import '../i18n/app_language.dart';
import '../runtime/runtime_bootstrap.dart';
import '../runtime/runtime_models.dart';
import 'section_tabs.dart';
class GatewayConnectDialog extends StatefulWidget {
const GatewayConnectDialog({
super.key,
required this.controller,
this.compact = false,
this.onDone,
});
final AppController controller;
final bool compact;
final VoidCallback? onDone;
@override
State<GatewayConnectDialog> createState() => _GatewayConnectDialogState();
}
class _GatewayConnectDialogState extends State<GatewayConnectDialog> {
late final TextEditingController _setupCodeController;
late final TextEditingController _hostController;
late final TextEditingController _portController;
final TextEditingController _tokenController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
String _mode = 'setup';
bool _tls = true;
RuntimeConnectionMode _connectionMode = RuntimeConnectionMode.remote;
bool _submitting = false;
@override
void initState() {
super.initState();
final profile = widget.controller.settings.gateway;
_setupCodeController = TextEditingController(text: profile.setupCode);
_hostController = TextEditingController(text: profile.host);
_portController = TextEditingController(text: '${profile.port}');
_tls = profile.tls;
_connectionMode = profile.mode;
_mode = profile.useSetupCode ? 'setup' : 'manual';
_loadBootstrapPrefill();
}
@override
void dispose() {
_setupCodeController.dispose();
_hostController.dispose();
_portController.dispose();
_tokenController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final body = SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
appText('Gateway 访问', 'Gateway Access'),
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
appText(
'通过配置码或手动 Host / TLS 将 XWorkmate 连接到 OpenClaw Gateway。',
'Connect XWorkmate to an OpenClaw gateway with setup code or manual host / TLS.',
),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 18),
SectionTabs(
items: [appText('配置码', 'Setup Code'), appText('手动配置', 'Manual')],
value: _mode == 'setup'
? appText('配置码', 'Setup Code')
: appText('手动配置', 'Manual'),
size: SectionTabsSize.small,
onChanged: (value) => setState(
() => _mode = value == appText('配置码', 'Setup Code')
? 'setup'
: 'manual',
),
),
const SizedBox(height: 18),
_StatusBanner(controller: widget.controller),
const SizedBox(height: 18),
if (_mode == 'setup') ...[
TextField(
controller: _setupCodeController,
minLines: 4,
maxLines: 6,
decoration: InputDecoration(
labelText: appText('配置码', 'Setup Code'),
hintText: appText(
'粘贴 Gateway 配置码或 JSON 负载',
'Paste gateway setup code or JSON payload',
),
),
),
] else ...[
DropdownButtonFormField<RuntimeConnectionMode>(
initialValue: _connectionMode,
decoration: InputDecoration(
labelText: appText('连接模式', 'Connection Mode'),
),
items: RuntimeConnectionMode.values
.map(
(mode) => DropdownMenuItem<RuntimeConnectionMode>(
value: mode,
child: Text(mode.label),
),
)
.toList(),
onChanged: (value) {
if (value == null) {
return;
}
setState(() {
_connectionMode = value;
if (value == RuntimeConnectionMode.local) {
_hostController.text = '127.0.0.1';
_portController.text = '18789';
_tls = false;
}
});
},
),
const SizedBox(height: 12),
TextField(
controller: _hostController,
decoration: InputDecoration(labelText: appText('主机', 'Host')),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextField(
controller: _portController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: appText('端口', 'Port'),
),
),
),
const SizedBox(width: 16),
Expanded(
child: SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: _tls,
title: Text(appText('TLS', 'TLS')),
onChanged: _connectionMode == RuntimeConnectionMode.local
? null
: (value) => setState(() => _tls = value),
),
),
],
),
],
const SizedBox(height: 18),
TextField(
controller: _tokenController,
decoration: InputDecoration(
labelText: appText('共享 Token', 'Shared Token'),
hintText: appText(
'可选:覆盖默认 Gateway Token',
'Optional override for gateway token',
),
),
),
const SizedBox(height: 12),
TextField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('密码', 'Password'),
hintText: appText('可选:共享密码', 'Optional shared password'),
),
),
const SizedBox(height: 20),
Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.end,
children: [
if (widget.controller.connection.status ==
RuntimeConnectionStatus.connected)
OutlinedButton.icon(
onPressed: _submitting
? null
: () async {
setState(() => _submitting = true);
await widget.controller.disconnectGateway();
if (mounted) {
setState(() => _submitting = false);
}
},
icon: const Icon(Icons.link_off_rounded),
label: Text(appText('断开连接', 'Disconnect')),
),
FilledButton.icon(
onPressed: _submitting ? null : _submit,
icon: const Icon(Icons.wifi_tethering_rounded),
label: Text(
_submitting
? appText('连接中…', 'Connecting…')
: appText('连接', 'Connect'),
),
),
],
),
],
),
);
if (widget.compact) {
return body;
}
return Dialog(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: body,
),
);
}
Future<void> _loadBootstrapPrefill() async {
final bootstrap = await RuntimeBootstrapConfig.load();
final preferred = bootstrap.preferredGatewayFor(_connectionMode);
if (!mounted || preferred == null) {
return;
}
final profile = widget.controller.settings.gateway;
final defaults = GatewayConnectionProfile.defaults();
final shouldPrefillEndpoint =
profile.setupCode.trim().isEmpty &&
profile.host.trim() == defaults.host &&
profile.port == defaults.port;
setState(() {
if (shouldPrefillEndpoint) {
_connectionMode = preferred.mode;
_hostController.text = preferred.host;
_portController.text = '${preferred.port}';
_tls = preferred.tls;
}
if (_tokenController.text.trim().isEmpty && preferred.token.isNotEmpty) {
_tokenController.text = preferred.token;
}
});
}
Future<void> _submit() async {
setState(() => _submitting = true);
try {
if (_mode == 'setup') {
await widget.controller.connectWithSetupCode(
setupCode: _setupCodeController.text,
token: _tokenController.text,
password: _passwordController.text,
);
} else {
await widget.controller.connectManual(
host: _hostController.text,
port: int.tryParse(_portController.text.trim()) ?? 0,
tls: _tls,
mode: _connectionMode,
token: _tokenController.text,
password: _passwordController.text,
);
}
widget.onDone?.call();
} finally {
if (mounted) {
setState(() => _submitting = false);
}
}
}
}
class _StatusBanner extends StatelessWidget {
const _StatusBanner({required this.controller});
final AppController controller;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final connection = controller.connection;
final tone = switch (connection.status) {
RuntimeConnectionStatus.connected => theme.colorScheme.primaryContainer,
RuntimeConnectionStatus.error => theme.colorScheme.errorContainer,
RuntimeConnectionStatus.connecting =>
theme.colorScheme.secondaryContainer,
RuntimeConnectionStatus.offline =>
theme.colorScheme.surfaceContainerHighest,
};
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: tone,
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(connection.status.label, style: theme.textTheme.titleMedium),
const SizedBox(height: 6),
Text(
connection.remoteAddress ?? 'No active gateway target',
style: theme.textTheme.bodyMedium,
),
if ((connection.lastError ?? '').isNotEmpty) ...[
const SizedBox(height: 8),
Text(connection.lastError!, style: theme.textTheme.bodySmall),
],
],
),
);
}
}