Merge account and ACP settings into advanced config

This commit is contained in:
Haitao Pan 2026-04-09 08:41:05 +08:00
parent 5e5965be63
commit c3ef4bbb60
15 changed files with 608 additions and 321 deletions

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import '../features/account/account_page.dart';
import '../features/mobile/mobile_shell.dart';
import '../i18n/app_language.dart';
import '../models/app_models.dart';
@ -189,7 +188,7 @@ class _AppShellState extends State<AppShell> {
constraints.maxWidth > 1280;
final mobileDestination =
controller.destination == WorkspaceDestination.account
? WorkspaceDestination.assistant
? WorkspaceDestination.settings
: controller.destination;
final availableMobileDestinations = _mobileDestinations
.where(controller.capabilities.supportsDestination)
@ -220,33 +219,6 @@ class _AppShellState extends State<AppShell> {
);
}
void openAccountSheet() {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return Container(
margin: EdgeInsets.fromLTRB(
12,
MediaQuery.of(sheetContext).padding.top + 12,
12,
12,
),
decoration: BoxDecoration(
color: palette.surfacePrimary,
borderRadius: BorderRadius.circular(28),
border: Border.all(color: palette.strokeSoft),
),
child: SafeArea(
top: false,
child: AccountPage(controller: controller),
),
);
},
);
}
if (isCompactMobile) {
return MobileShell(controller: controller);
}
@ -313,21 +285,7 @@ class _AppShellState extends State<AppShell> {
),
],
),
Positioned(
right: 24,
bottom: 96,
child:
controller.capabilities.supportsDestination(
WorkspaceDestination.account,
)
? FloatingActionButton.small(
onPressed: openAccountSheet,
child: const Icon(
Icons.account_circle_rounded,
),
)
: const SizedBox.shrink(),
),
const SizedBox.shrink(),
],
);
}

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import '../features/account/account_page.dart';
import '../features/assistant/assistant_page.dart';
import '../features/claw_hub/claw_hub_page.dart';
import '../features/mcp_server/mcp_server_page.dart';
@ -121,22 +120,22 @@ workspacePageSpecsInternal = <WorkspaceDestination, WorkspacePageSpec>{
initialTab: controller.settingsTab,
initialDetail: controller.settingsDetail,
navigationContext: controller.settingsNavigationContext,
showSectionTabs: false,
showSectionTabs: true,
),
mobileBuilder: (controller, onOpenDetail) => SettingsPage(
controller: controller,
initialTab: controller.settingsTab,
initialDetail: controller.settingsDetail,
navigationContext: controller.settingsNavigationContext,
showSectionTabs: false,
showSectionTabs: true,
),
),
WorkspaceDestination.account: WorkspacePageSpec(
destination: WorkspaceDestination.account,
desktopBuilder: (controller, onOpenDetail) =>
AccountPage(controller: controller),
SettingsPage(controller: controller, initialTab: SettingsTab.gateway),
mobileBuilder: (controller, onOpenDetail) =>
AccountPage(controller: controller),
SettingsPage(controller: controller, initialTab: SettingsTab.gateway),
),
};

View File

@ -101,10 +101,10 @@ class MobileShellStateInternal extends State<MobileShell> {
WorkspaceDestination.agents ||
WorkspaceDestination.mcpServer ||
WorkspaceDestination.clawHub ||
WorkspaceDestination.aiGateway ||
WorkspaceDestination.account => MobileShellTab.workspace,
WorkspaceDestination.aiGateway => MobileShellTab.workspace,
WorkspaceDestination.secrets => MobileShellTab.secrets,
WorkspaceDestination.settings => MobileShellTab.settings,
WorkspaceDestination.account => MobileShellTab.settings,
};
}

View File

@ -72,15 +72,6 @@ class MobileWorkspaceLauncherInternal extends StatelessWidget {
iconColor: palette.accent,
iconBackground: palette.accentMuted,
),
WorkspaceEntryInternal(
destination: WorkspaceDestination.account,
subtitle: appText(
'身份、工作区与会话',
'Identity, workspace and sessions',
),
iconColor: palette.success,
iconBackground: palette.success.withValues(alpha: 0.12),
),
]
.where(
(entry) =>

View File

@ -60,6 +60,10 @@ class SettingsPageStateInternal extends State<SettingsPage> {
late final TextEditingController aiGatewayApiKeyRefControllerInternal;
late final TextEditingController aiGatewayApiKeyControllerInternal;
late final TextEditingController aiGatewayModelSearchControllerInternal;
late final TextEditingController accountBaseUrlControllerInternal;
late final TextEditingController accountUsernameControllerInternal;
late final TextEditingController accountPasswordControllerInternal;
late final TextEditingController accountMfaCodeControllerInternal;
late final TextEditingController gatewaySetupCodeControllerInternal;
late final TextEditingController gatewayHostControllerInternal;
late final TextEditingController gatewayPortControllerInternal;
@ -73,6 +77,8 @@ class SettingsPageStateInternal extends State<SettingsPage> {
late final TextEditingController acpBridgeServerUrlControllerInternal;
late final TextEditingController acpBridgeServerUsernameControllerInternal;
late final TextEditingController acpBridgeServerPasswordControllerInternal;
String accountBaseUrlSyncedValueInternal = '';
String accountUsernameSyncedValueInternal = '';
late final Map<String, TextEditingController>
externalAcpLabelControllersInternal;
late final Map<String, TextEditingController>
@ -135,6 +141,10 @@ class SettingsPageStateInternal extends State<SettingsPage> {
aiGatewayApiKeyRefControllerInternal = TextEditingController();
aiGatewayApiKeyControllerInternal = TextEditingController();
aiGatewayModelSearchControllerInternal = TextEditingController();
accountBaseUrlControllerInternal = TextEditingController();
accountUsernameControllerInternal = TextEditingController();
accountPasswordControllerInternal = TextEditingController();
accountMfaCodeControllerInternal = TextEditingController();
gatewaySetupCodeControllerInternal = TextEditingController();
gatewayHostControllerInternal = TextEditingController();
gatewayPortControllerInternal = TextEditingController();
@ -244,6 +254,10 @@ class SettingsPageStateInternal extends State<SettingsPage> {
aiGatewayApiKeyRefControllerInternal.dispose();
aiGatewayApiKeyControllerInternal.dispose();
aiGatewayModelSearchControllerInternal.dispose();
accountBaseUrlControllerInternal.dispose();
accountUsernameControllerInternal.dispose();
accountPasswordControllerInternal.dispose();
accountMfaCodeControllerInternal.dispose();
gatewaySetupCodeControllerInternal.dispose();
gatewayHostControllerInternal.dispose();
gatewayPortControllerInternal.dispose();

View File

@ -64,10 +64,12 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
'SKILLS 目录授权',
'SKILLS Directory Authorization',
),
GatewayIntegrationSubTabInternal.advancedConfig => appText(
'高级自定义配置',
'Advanced Custom Configuration',
),
};
return [
buildAcpBridgeServerModeCardInternal(context, controller, settings),
const SizedBox(height: 16),
SectionTabs(
items: <String>[
'OpenClaw Gateway',
@ -75,6 +77,7 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
appText('LLM 接入点', 'LLM Endpoints'),
appText('ACP 外部接入', 'External ACP'),
appText('SKILLS 目录授权', 'SKILLS Directory Authorization'),
appText('高级自定义配置', 'Advanced Custom Configuration'),
],
value: tabLabel,
onChanged: (value) => setStateInternal(() {
@ -86,7 +89,11 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
GatewayIntegrationSubTabInternal.llm,
_ when value == appText('ACP 外部接入', 'External ACP') =>
GatewayIntegrationSubTabInternal.acp,
_ => GatewayIntegrationSubTabInternal.skills,
_
when value ==
appText('SKILLS 目录授权', 'SKILLS Directory Authorization') =>
GatewayIntegrationSubTabInternal.skills,
_ => GatewayIntegrationSubTabInternal.advancedConfig,
};
}),
),
@ -210,6 +217,11 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
),
),
],
GatewayIntegrationSubTabInternal.advancedConfig => <Widget>[
buildOnlineAccountCardInternal(context, controller, settings),
const SizedBox(height: 16),
buildAcpBridgeServerModeCardInternal(context, controller, settings),
],
},
];
}
@ -224,8 +236,6 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
settings.acpBridgeServerModeConfig.mode ==
AcpBridgeServerMode.advancedCustom;
return [
buildAcpBridgeServerModeCardInternal(context, controller, settings),
const SizedBox(height: 16),
Opacity(
opacity: advancedEditable ? 1 : 0.72,
child: IgnorePointer(
@ -237,7 +247,11 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
onChanged: (value) => setStateInternal(() {
openClawGatewayExpandedInternal = value;
}),
child: buildOpenClawGatewayCardInternal(context, controller, settings),
child: buildOpenClawGatewayCardInternal(
context,
controller,
settings,
),
),
),
),
@ -254,7 +268,11 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
onChanged: (value) => setStateInternal(() {
vaultServerExpandedInternal = value;
}),
child: buildVaultProviderCardInternal(context, controller, settings),
child: buildVaultProviderCardInternal(
context,
controller,
settings,
),
),
),
)
@ -280,7 +298,11 @@ extension SettingsPageGatewayMixinInternal on SettingsPageStateInternal {
onChanged: (value) => setStateInternal(() {
aiGatewayExpandedInternal = value;
}),
child: buildLlmEndpointManagerInternal(context, controller, settings),
child: buildLlmEndpointManagerInternal(
context,
controller,
settings,
),
),
),
),

View File

@ -39,17 +39,17 @@ String describeExternalAcpTestFailure(Object error, {Uri? endpoint}) {
final bodyRead = detailMap['bodyRead'] == true ? 'yes' : 'no';
return appText(
'连接不稳定:服务端在响应体接收完成前提前关闭了连接。'
'${requestUrl.isEmpty ? '' : '\nURL: $requestUrl'}'
'\nHTTP: $statusCode'
'\ncontent-type: ${contentType == null || contentType.isEmpty ? 'n/a' : contentType}'
'\nbody received: $bodyRead'
'\n应用会对这类瞬时错误自动重试一次;如果仍失败,请检查上游服务或反向代理是否提前断流。',
'${requestUrl.isEmpty ? '' : '\nURL: $requestUrl'}'
'\nHTTP: $statusCode'
'\ncontent-type: ${contentType == null || contentType.isEmpty ? 'n/a' : contentType}'
'\nbody received: $bodyRead'
'\n应用会对这类瞬时错误自动重试一次;如果仍失败,请检查上游服务或反向代理是否提前断流。',
'Connection was interrupted before the response body finished arriving.'
'${requestUrl.isEmpty ? '' : '\nURL: $requestUrl'}'
'\nHTTP: $statusCode'
'\ncontent-type: ${contentType == null || contentType.isEmpty ? 'n/a' : contentType}'
'\nbody received: $bodyRead'
'\nThe app retries this transient error once automatically. If it still fails, inspect the upstream service or reverse proxy for early connection termination.',
'${requestUrl.isEmpty ? '' : '\nURL: $requestUrl'}'
'\nHTTP: $statusCode'
'\ncontent-type: ${contentType == null || contentType.isEmpty ? 'n/a' : contentType}'
'\nbody received: $bodyRead'
'\nThe app retries this transient error once automatically. If it still fails, inspect the upstream service or reverse proxy for early connection termination.',
);
}
}
@ -150,6 +150,299 @@ bool shouldRetryExternalAcpTestFailure(Object error) {
}
extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
void syncAccountDraftControllersInternal(SettingsSnapshot settings) {
if (accountBaseUrlControllerInternal.text ==
accountBaseUrlSyncedValueInternal &&
settings.accountBaseUrl != accountBaseUrlSyncedValueInternal) {
accountBaseUrlControllerInternal.text = settings.accountBaseUrl;
}
if (accountUsernameControllerInternal.text ==
accountUsernameSyncedValueInternal &&
settings.accountUsername != accountUsernameSyncedValueInternal) {
accountUsernameControllerInternal.text = settings.accountUsername;
}
accountBaseUrlSyncedValueInternal = settings.accountBaseUrl;
accountUsernameSyncedValueInternal = settings.accountUsername;
}
Future<void> saveAccountProfileInternal(SettingsSnapshot settings) async {
final nextSettings = settings.copyWith(
accountBaseUrl: accountBaseUrlControllerInternal.text.trim(),
accountUsername: accountUsernameControllerInternal.text.trim(),
);
await saveSettingsInternal(widget.controller, nextSettings);
accountBaseUrlSyncedValueInternal = nextSettings.accountBaseUrl;
accountUsernameSyncedValueInternal = nextSettings.accountUsername;
}
Future<void> loginAccountInternal(SettingsSnapshot settings) async {
await saveAccountProfileInternal(settings);
try {
await widget.controller.settingsController.loginAccount(
baseUrl: accountBaseUrlControllerInternal.text.trim(),
identifier: accountUsernameControllerInternal.text.trim(),
password: accountPasswordControllerInternal.text,
);
} finally {
accountPasswordControllerInternal.clear();
}
}
Future<void> verifyAccountMfaInternal() async {
try {
await widget.controller.settingsController.verifyAccountMfa(
baseUrl: accountBaseUrlControllerInternal.text.trim(),
code: accountMfaCodeControllerInternal.text.trim(),
);
} finally {
accountMfaCodeControllerInternal.clear();
}
}
Future<void> syncAccountSettingsInternal(SettingsSnapshot settings) async {
await saveAccountProfileInternal(settings);
await widget.controller.settingsController.syncAccountSettings(
baseUrl: accountBaseUrlControllerInternal.text.trim(),
);
}
Future<void> logoutAccountInternal() async {
await widget.controller.settingsController.logoutAccount();
accountPasswordControllerInternal.clear();
accountMfaCodeControllerInternal.clear();
}
Future<void> cancelAccountMfaInternal() async {
await widget.controller.settingsController.cancelAccountMfaChallenge();
accountPasswordControllerInternal.clear();
accountMfaCodeControllerInternal.clear();
}
Widget buildOnlineAccountCardInternal(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
syncAccountDraftControllersInternal(settings);
final accountController = controller.settingsController;
final accountSession = accountController.accountSession;
final accountSyncState = accountController.accountSyncState;
final accountBusy = accountController.accountBusy;
final accountSignedIn = accountController.accountSignedIn;
final accountMfaRequired = accountController.accountMfaRequired;
final signedInLabel = accountSession?.email.trim().isNotEmpty == true
? accountSession!.email.trim()
: accountSession?.name.trim().isNotEmpty == true
? accountSession!.name.trim()
: appText('在线账户', 'Online Account');
final sessionStatusText = accountSignedIn
? appText('已登录:$signedInLabel', 'Signed in: $signedInLabel')
: accountMfaRequired
? appText('等待双重验证', 'Waiting for MFA verification')
: appText('未登录', 'Signed out');
final syncStatusText = accountSyncState == null
? appText('idle · 尚未同步远程配置', 'idle · Remote config not synced yet')
: '${accountSyncState.syncState} · ${accountSyncState.syncMessage}';
Widget buildSignedOutLoginCard() {
final theme = Theme.of(context);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 840),
child: SurfaceCard(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 36),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.cloud_outlined,
size: 72,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
appText('在线账户', 'Online Account'),
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
appText(
'请先登录 ACP Bridge Server',
'Please sign in to ACP Bridge Server',
),
style: theme.textTheme.titleMedium?.copyWith(
color: theme.textTheme.bodyMedium?.color?.withValues(
alpha: 0.8,
),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 28),
TextFormField(
key: const ValueKey('account-base-url-field'),
controller: accountBaseUrlControllerInternal,
decoration: InputDecoration(
labelText: appText('服务地址', 'Service URL'),
prefixIcon: const Icon(Icons.dns_outlined),
),
onFieldSubmitted: (_) => saveAccountProfileInternal(settings),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('account-username-field'),
controller: accountUsernameControllerInternal,
decoration: InputDecoration(
labelText: appText('邮箱或账号', 'Email or Username'),
prefixIcon: const Icon(Icons.person_outline_rounded),
),
onFieldSubmitted: (_) => saveAccountProfileInternal(settings),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('account-password-field'),
controller: accountPasswordControllerInternal,
obscureText: true,
decoration: InputDecoration(
labelText: appText('密码', 'Password'),
prefixIcon: const Icon(Icons.lock_outline_rounded),
),
onFieldSubmitted: (_) => loginAccountInternal(settings),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
key: const ValueKey('account-login-button'),
onPressed: accountBusy
? null
: () => loginAccountInternal(settings),
child: Text(appText('登录', 'Sign In')),
),
),
],
),
),
),
);
}
Widget buildSignedInProfileCard() {
return SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
accountSignedIn
? signedInLabel
: settings.accountUsername.trim().isEmpty
? appText('本地操作员', 'Local Operator')
: settings.accountUsername,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
appText(
'这里继续只负责在线账户身份、MFA、工作区与同步摘要。ACP Bridge Server 的本地连接与高级配置在下面统一收口。',
'This card focuses on online account identity, MFA, workspace, and sync summary. Local ACP Bridge Server connection and advanced config are unified below.',
),
),
const SizedBox(height: 16),
Text(
sessionStatusText,
key: const ValueKey('account-session-status'),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 6),
Text(
syncStatusText,
key: const ValueKey('account-sync-status'),
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('在线账户同步摘要', 'Online account sync summary'),
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Text(
'${appText('服务地址', 'Service URL')}: ${settings.accountBaseUrl.trim().isEmpty ? appText('未填写', 'Not set') : settings.accountBaseUrl}',
),
const SizedBox(height: 6),
Text(
'${appText('在线账户', 'Online Account')}: ${settings.accountUsername.trim().isEmpty ? appText('未填写', 'Not set') : settings.accountUsername}',
),
const SizedBox(height: 6),
Text(
'${appText('最近同步', 'Last Sync')}: ${accountSyncState == null || settings.acpBridgeServerModeConfig.cloudSynced.lastSyncAt <= 0 ? appText('尚未同步', 'Not synced yet') : DateTime.fromMillisecondsSinceEpoch(settings.acpBridgeServerModeConfig.cloudSynced.lastSyncAt).toLocal().toIso8601String()}',
),
],
),
),
const SizedBox(height: 16),
if (accountMfaRequired) ...[
TextFormField(
key: const ValueKey('account-mfa-code-field'),
controller: accountMfaCodeControllerInternal,
decoration: InputDecoration(
labelText: appText('双重验证代码', 'MFA Code'),
),
onFieldSubmitted: (_) => verifyAccountMfaInternal(),
),
const SizedBox(height: 16),
],
Wrap(
spacing: 12,
runSpacing: 12,
children: [
if (accountMfaRequired)
FilledButton.tonal(
key: const ValueKey('account-verify-mfa-button'),
onPressed: accountBusy ? null : verifyAccountMfaInternal,
child: Text(appText('验证并同步', 'Verify & Sync')),
),
if (accountMfaRequired)
FilledButton.tonal(
key: const ValueKey('account-edit-button'),
onPressed: accountBusy ? null : cancelAccountMfaInternal,
child: Text(appText('返回编辑', 'Back to Edit')),
),
if (accountSignedIn)
FilledButton.tonal(
key: const ValueKey('account-sync-button'),
onPressed: accountBusy
? null
: () => syncAccountSettingsInternal(settings),
child: Text(appText('重新同步', 'Sync Again')),
),
if (accountSignedIn)
FilledButton.tonal(
key: const ValueKey('account-logout-button'),
onPressed: accountBusy ? null : logoutAccountInternal,
child: Text(appText('退出登录', 'Log Out')),
),
],
),
],
),
);
}
return accountSignedIn || accountMfaRequired
? buildSignedInProfileCard()
: buildSignedOutLoginCard();
}
Widget buildAcpBridgeServerModeCardInternal(
BuildContext context,
AppController controller,
@ -164,9 +457,9 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
final cloudSync = modeConfig.cloudSynced;
final remoteSummary = cloudSync.remoteServerSummary;
final currentSource = switch (modeConfig.sourceTag) {
'cloudSynced' => appText('云端同步', 'Cloud Sync'),
'selfHosted' => appText('本地 Server', 'Self-hosted Server'),
_ => appText('高级覆盖', 'Advanced Override'),
'cloudSynced' => appText('在线账户', 'Online Account'),
'selfHosted' => appText('本地账户', 'Local Account'),
_ => appText('高级模式', 'Advanced Mode'),
};
final syncStatus = accountSyncState?.syncState.trim().isNotEmpty == true
? accountSyncState!.syncState
@ -181,14 +474,17 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'XWorkmate ACP Bridge Server',
appText(
'ACP Bridge Server 连接模式',
'ACP Bridge Server Connection Mode',
),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
appText(
'XWorkmate App 继续只承担纯客户端职责:配置、会话、安全存储与连接编排。云端、自托管和高级自定义都通过这里统一收口,不在 App 内承载服务端逻辑。',
'XWorkmate App remains a pure client: configuration, session, secure storage, and connection orchestration only. Cloud, self-hosted, and advanced custom flows are unified here without embedding server responsibilities into the app.',
'在线账户负责云端同步,本地账户负责连接 ACP Bridge Server高级模式是在本地账户基础上再叠加 advanced config 覆盖层。App 只负责配置、会话、安全存储与连接编排,不承载服务端逻辑。',
'Online account handles cloud sync, local account connects to ACP Bridge Server, and advanced mode layers advanced config on top of the local account. The app stays a pure client for configuration, session handling, secure storage, and connection orchestration.',
),
),
const SizedBox(height: 16),
@ -198,7 +494,7 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
children: [
ChoiceChip(
key: const ValueKey('acp-bridge-mode-cloud'),
label: Text(appText('在线同步配置', 'Cloud Sync')),
label: Text(appText('在线账户', 'Online Account')),
selected: modeConfig.mode == AcpBridgeServerMode.cloudSynced,
onSelected: (_) => saveSettingsInternal(
controller,
@ -212,7 +508,7 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
),
ChoiceChip(
key: const ValueKey('acp-bridge-mode-self-hosted'),
label: Text(appText('本地模式', 'Self-hosted')),
label: Text(appText('本地账户', 'Local Account')),
selected: modeConfig.mode == AcpBridgeServerMode.selfHosted,
onSelected: (_) => saveSettingsInternal(
controller,
@ -226,7 +522,7 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
),
ChoiceChip(
key: const ValueKey('acp-bridge-mode-advanced'),
label: Text(appText('高级自定义', 'Advanced Custom')),
label: Text(appText('高级模式', 'Advanced Mode')),
selected: modeConfig.mode == AcpBridgeServerMode.advancedCustom,
onSelected: (_) => saveSettingsInternal(
controller,
@ -246,7 +542,7 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
runSpacing: 12,
children: [
StatusChipInternal(
label: '${appText('当前来源', 'Source')}: $currentSource',
label: '${appText('当前模式', 'Mode')}: $currentSource',
tone: StatusChipToneInternal.ready,
),
StatusChipInternal(
@ -263,23 +559,14 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
Text(
accountSignedIn
? appText(
'已登录云账户,可直接同步远端 ACP Bridge Server 配置。',
'Signed in to the cloud account. You can sync the remote ACP Bridge Server configuration directly.',
'已登录在线账户,可直接同步云端 ACP Bridge Server 默认配置。',
'Signed in to the online account. You can sync the cloud ACP Bridge Server defaults directly.',
)
: appText(
'当前未登录云账户。普通用户建议先登录,再从云端同步默认配置。',
'No cloud account is signed in. For most users, sign in first and sync the default configuration from the cloud.',
'当前未登录在线账户。建议先登录,再从云端同步默认配置。',
'No online account is signed in. Sign in first, then sync the default configuration from the cloud.',
),
),
const SizedBox(height: 12),
Text(
'${appText('服务地址', 'Service URL')}: ${cloudSync.accountBaseUrl.trim().isEmpty ? settings.accountBaseUrl : cloudSync.accountBaseUrl}',
),
const SizedBox(height: 6),
Text(
'${appText('账号', 'Account')}: ${cloudSync.accountIdentifier.trim().isEmpty ? settings.accountUsername : cloudSync.accountIdentifier}',
),
const SizedBox(height: 6),
Text(
'${appText('远端摘要', 'Remote Summary')}: ${remoteSummary.endpoint.trim().isEmpty ? appText('待同步', 'Pending sync') : remoteSummary.endpoint}',
),
@ -297,43 +584,29 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
spacing: 10,
runSpacing: 10,
children: [
FilledButton.tonal(
key: const ValueKey('acp-bridge-cloud-open-account'),
onPressed: () =>
controller.navigateTo(WorkspaceDestination.account),
child: Text(appText('登录 / 管理账号', 'Open Account')),
),
FilledButton.tonal(
key: const ValueKey('acp-bridge-cloud-sync'),
onPressed: accountBusy || !accountSignedIn
? null
: () => accountController.syncAccountSettings(
baseUrl: settings.accountBaseUrl,
),
: () => syncAccountSettingsInternal(settings),
child: Text(appText('重新同步', 'Sync Again')),
),
FilledButton.tonal(
key: const ValueKey('acp-bridge-cloud-disconnect'),
onPressed: accountBusy || !accountSignedIn
? null
: accountController.logoutAccount,
: logoutAccountInternal,
child: Text(appText('断开', 'Disconnect')),
),
],
),
],
AcpBridgeServerMode.selfHosted => <Widget>[
buildAcpBridgeServerSelfHostedPanelInternal(
context,
controller,
settings,
),
],
AcpBridgeServerMode.selfHosted => <Widget>[],
AcpBridgeServerMode.advancedCustom => <Widget>[
Text(
appText(
'高级自定义会把下面的 OpenClaw Gateway / Vault Server / LLM Endpoint / 外部 ACP Server endpoint / SKILLS 目录 当作覆盖层。未覆盖的值继续继承当前基础模式。',
'Advanced custom mode treats the OpenClaw Gateway / Vault Server / LLM Endpoint / external ACP server endpoint / SKILLS directory below as overrides. Fields you do not override keep inheriting from the current base mode.',
'高级模式 = 本地账户 + advanced config。下面先保留本地 ACP Bridge Server 连接,再把 OpenClaw Gateway / Vault Server / LLM Endpoint / 外部 ACP Server endpoint / SKILLS 目录 当作覆盖层。未覆盖的值继续继承当前基础模式。',
'Advanced mode = local account + advanced config. Keep the local ACP Bridge Server connection below, then treat the OpenClaw Gateway / Vault Server / LLM Endpoint / external ACP server endpoint / SKILLS directory as overrides. Fields you do not override keep inheriting from the current base mode.',
),
),
const SizedBox(height: 12),
@ -347,6 +620,15 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
),
],
},
const SizedBox(height: 16),
buildAcpBridgeServerSelfHostedPanelInternal(
context,
controller,
settings,
targetMode: modeConfig.mode == AcpBridgeServerMode.advancedCustom
? AcpBridgeServerMode.advancedCustom
: AcpBridgeServerMode.selfHosted,
),
],
),
);
@ -355,12 +637,25 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
Widget buildAcpBridgeServerSelfHostedPanelInternal(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
SettingsSnapshot settings, {
AcpBridgeServerMode targetMode = AcpBridgeServerMode.selfHosted,
}) {
final selfHosted = settings.acpBridgeServerModeConfig.selfHosted;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('连接 ACP Bridge Server', 'Connect to ACP Bridge Server'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
appText(
'填写本地或私有部署的 ACP Bridge Server 地址、用户名和密码,然后测试连接并保存到安全存储。',
'Enter the URL, username, and password for your local or private ACP Bridge Server, then test the connection and save it into secure storage.',
),
),
const SizedBox(height: 12),
TextField(
key: const ValueKey('acp-bridge-self-hosted-url'),
controller: acpBridgeServerUrlControllerInternal,
@ -375,9 +670,7 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
TextField(
key: const ValueKey('acp-bridge-self-hosted-username'),
controller: acpBridgeServerUsernameControllerInternal,
decoration: InputDecoration(
labelText: appText('用户', 'Username'),
),
decoration: InputDecoration(labelText: appText('用户', 'Username')),
),
const SizedBox(height: 12),
TextField(
@ -393,9 +686,7 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
),
),
const SizedBox(height: 8),
Text(
'${appText('密码引用', 'Password Ref')}: ${selfHosted.passwordRef}',
),
Text('${appText('密码引用', 'Password Ref')}: ${selfHosted.passwordRef}'),
const SizedBox(height: 14),
Wrap(
spacing: 10,
@ -417,6 +708,7 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
onPressed: () => saveAcpBridgeServerSelfHostedInternal(
controller,
settings,
targetMode: targetMode,
),
child: Text(appText('保存', 'Save')),
),
@ -425,6 +717,7 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
onPressed: () => connectAcpBridgeServerSelfHostedInternal(
controller,
settings,
targetMode: targetMode,
),
child: Text(appText('连接', 'Connect')),
),
@ -488,8 +781,9 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
Future<void> saveAcpBridgeServerSelfHostedInternal(
AppController controller,
SettingsSnapshot settings,
) async {
SettingsSnapshot settings, {
AcpBridgeServerMode targetMode = AcpBridgeServerMode.selfHosted,
}) async {
final modeConfig = settings.acpBridgeServerModeConfig;
final nextSelfHosted = modeConfig.selfHosted.copyWith(
serverUrl: acpBridgeServerUrlControllerInternal.text,
@ -504,16 +798,15 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
module: 'Settings',
);
}
final nextSettings = settings.captureAcpBridgeServerAdvancedOverrides().copyWith(
accountLocalMode: true,
acpBridgeServerModeConfig: settings
.captureAcpBridgeServerAdvancedOverrides()
.acpBridgeServerModeConfig
.copyWith(
mode: AcpBridgeServerMode.selfHosted,
selfHosted: nextSelfHosted,
),
);
final nextSettings = settings
.captureAcpBridgeServerAdvancedOverrides()
.copyWith(
accountLocalMode: true,
acpBridgeServerModeConfig: settings
.captureAcpBridgeServerAdvancedOverrides()
.acpBridgeServerModeConfig
.copyWith(mode: targetMode, selfHosted: nextSelfHosted),
);
await saveSettingsInternal(controller, nextSettings);
if (!mounted) {
return;
@ -521,17 +814,22 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
acpBridgeServerPasswordControllerInternal.clear();
setStateInternal(() {
acpBridgeServerSelfHostedMessageInternal = appText(
'Self-hosted 配置已保存,密码已进入 secure storage。',
'The self-hosted configuration was saved and the password is now in secure storage.',
'ACP Bridge Server 配置已保存,密码已进入 secure storage。',
'The ACP Bridge Server configuration was saved and the password is now in secure storage.',
);
});
}
Future<void> connectAcpBridgeServerSelfHostedInternal(
AppController controller,
SettingsSnapshot settings,
) async {
await saveAcpBridgeServerSelfHostedInternal(controller, settings);
SettingsSnapshot settings, {
AcpBridgeServerMode targetMode = AcpBridgeServerMode.selfHosted,
}) async {
await saveAcpBridgeServerSelfHostedInternal(
controller,
settings,
targetMode: targetMode,
);
if (!mounted) {
return;
}
@ -566,7 +864,11 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
acpBridgeServerPasswordControllerInternal.text.trim().isNotEmpty
? acpBridgeServerPasswordControllerInternal.text.trim()
: await controller.settingsController.loadSecretValueByRef(
controller.settings.acpBridgeServerModeConfig.selfHosted.passwordRef,
controller
.settings
.acpBridgeServerModeConfig
.selfHosted
.passwordRef,
);
final authorization = password.isEmpty
? ''
@ -836,20 +1138,22 @@ extension SettingsPageGatewayAcpMixinInternal on SettingsPageStateInternal {
);
GatewayAcpCapabilities capabilities;
try {
capabilities = await controller.gatewayAcpClientInternal.loadCapabilities(
forceRefresh: true,
endpointOverride: endpoint,
authorizationOverride: authorization,
);
capabilities = await controller.gatewayAcpClientInternal
.loadCapabilities(
forceRefresh: true,
endpointOverride: endpoint,
authorizationOverride: authorization,
);
} catch (error) {
if (!shouldRetryExternalAcpTestFailure(error)) {
rethrow;
}
capabilities = await controller.gatewayAcpClientInternal.loadCapabilities(
forceRefresh: true,
endpointOverride: endpoint,
authorizationOverride: authorization,
);
capabilities = await controller.gatewayAcpClientInternal
.loadCapabilities(
forceRefresh: true,
endpointOverride: endpoint,
authorizationOverride: authorization,
);
}
if (!mounted) {
return;

View File

@ -68,9 +68,16 @@ extension SettingsPageSectionsMixinInternal on SettingsPageStateInternal {
settings,
uiFeatures,
),
SettingsTab.agents => buildAgentsInternal(context, controller, settings),
SettingsTab.agents => buildAgentsInternal(
context,
controller,
settings,
),
SettingsTab.appearance => buildAppearanceInternal(context, controller),
SettingsTab.diagnostics => buildDiagnosticsInternal(context, controller),
SettingsTab.diagnostics => buildDiagnosticsInternal(
context,
controller,
),
SettingsTab.experimental => buildExperimentalInternal(
context,
controller,
@ -256,10 +263,7 @@ extension SettingsPageSectionsMixinInternal on SettingsPageStateInternal {
'当前存在待生效更改。保存并生效:立即按当前配置更新。',
'There are saved changes waiting to be applied. Save & apply updates the current configuration immediately.',
)
: appText(
'当前没有待提交更改。',
'There are no pending settings changes.',
),
: appText('当前没有待提交更改。', 'There are no pending settings changes.'),
applyLabel: appText('保存并生效', 'Save & apply'),
onApply: (!hasDraft && !hasPendingApply)
? null
@ -311,43 +315,6 @@ extension SettingsPageSectionsMixinInternal on SettingsPageStateInternal {
),
if (controller.supportsDesktopIntegration)
buildLinuxDesktopIntegrationInternal(context, controller, settings),
if (uiFeatures.supportsAccountAccess)
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('账号访问', 'Account Access'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
EditableFieldInternal(
label: appText('账号服务地址', 'Account Base URL'),
value: settings.accountBaseUrl,
onSubmitted: (value) => saveSettingsInternal(
controller,
settings.copyWith(accountBaseUrl: value),
),
),
EditableFieldInternal(
label: appText('账号用户名', 'Account Username'),
value: settings.accountUsername,
onSubmitted: (value) => saveSettingsInternal(
controller,
settings.copyWith(accountUsername: value),
),
),
EditableFieldInternal(
label: appText('工作区名称', 'Workspace Label'),
value: settings.accountWorkspace,
onSubmitted: (value) => saveSettingsInternal(
controller,
settings.copyWith(accountWorkspace: value),
),
),
],
),
),
];
}

View File

@ -498,7 +498,14 @@ class WorkflowStepInternal extends StatelessWidget {
}
}
enum GatewayIntegrationSubTabInternal { gateway, vault, llm, acp, skills }
enum GatewayIntegrationSubTabInternal {
gateway,
vault,
llm,
acp,
skills,
advancedConfig,
}
enum LlmEndpointSlotInternal { aiGateway, ollamaLocal, ollamaCloud }

View File

@ -28,7 +28,7 @@ extension WorkspaceDestinationCopy on WorkspaceDestination {
WorkspaceDestination.secrets => appText('密钥', 'Secrets'),
WorkspaceDestination.aiGateway => 'LLM API',
WorkspaceDestination.settings => appText('设置', 'Settings'),
WorkspaceDestination.account => appText('账号', 'Account'),
WorkspaceDestination.account => appText('在线账户', 'Online Account'),
};
IconData get icon => switch (this) {
@ -87,8 +87,8 @@ extension WorkspaceDestinationCopy on WorkspaceDestination {
'Global settings and diagnostics, separated from business modules.',
),
WorkspaceDestination.account => appText(
'用户身份、工作区切换与登录会话管理。',
'Identity, workspace switching, and session management.',
'在线账户、工作区切换、登录会话与 ACP Bridge Server 同步管理。',
'Online account, workspace switching, login sessions, and ACP Bridge Server sync.',
),
};

View File

@ -113,10 +113,7 @@ class SidebarNavigation extends StatelessWidget {
children: [
const SizedBox(height: 4),
if (isCollapsed && showCollapseControl) ...[
SidebarHeader(
isCollapsed: true,
onTap: onExpandFromCollapsed,
),
SidebarHeader(isCollapsed: true, onTap: onExpandFromCollapsed),
const SizedBox(height: AppSpacing.xs),
],
Expanded(
@ -158,13 +155,8 @@ class SidebarNavigation extends StatelessWidget {
sidebarState: sidebarState,
onCycleSidebarState: onCycleSidebarState,
onOpenAccount: onOpenAccount,
showAccountButton:
availableDestinations == null ||
availableDestinations!.contains(
WorkspaceDestination.account,
),
accountSelected:
currentSection == WorkspaceDestination.account,
showAccountButton: false,
accountSelected: false,
showCollapseControl: false,
),
],

View File

@ -9,29 +9,45 @@ import 'package:xworkmate/models/app_models.dart';
import '../test_support.dart';
void main() {
testWidgets('SettingsPage shows ACP bridge server mode card on integrations', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
controller.openSettings(tab: SettingsTab.gateway);
testWidgets(
'SettingsPage shows ACP bridge server mode card in advanced custom config',
(WidgetTester tester) async {
final controller = await createTestController(tester);
controller.openSettings(tab: SettingsTab.gateway);
await pumpPage(
tester,
child: SettingsPage(
controller: controller,
initialTab: SettingsTab.gateway,
),
);
await pumpPage(
tester,
child: SettingsPage(
controller: controller,
initialTab: SettingsTab.gateway,
showSectionTabs: true,
),
);
expect(find.text('XWorkmate ACP Bridge Server'), findsOneWidget);
expect(find.byKey(const ValueKey('acp-bridge-mode-cloud')), findsOneWidget);
expect(
find.byKey(const ValueKey('acp-bridge-mode-self-hosted')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('acp-bridge-mode-advanced')),
findsOneWidget,
);
});
await tester.tap(find.byKey(const ValueKey('section-tab-高级自定义配置')));
await tester.pumpAndSettle();
expect(find.text('ACP Bridge Server 连接模式'), findsOneWidget);
expect(
find.byKey(const ValueKey('acp-bridge-mode-cloud')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('acp-bridge-mode-self-hosted')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('acp-bridge-mode-advanced')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('acp-bridge-self-hosted-url')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('acp-bridge-self-hosted-connect')),
findsOneWidget,
);
},
);
}

View File

@ -196,47 +196,50 @@ void main() {
expect(controller.themeMode, ThemeMode.light);
});
testWidgets('SettingsPage hides account access controls by default', (
WidgetTester tester,
) async {
final controller = await createTestController(tester);
testWidgets(
'SettingsPage gateway advanced config tab merges online account and ACP Bridge Server',
(WidgetTester tester) async {
final controller = await createTestController(tester);
controller.setSettingsTab(SettingsTab.gateway);
await pumpPage(
tester,
child: SettingsPage(controller: controller),
platform: TargetPlatform.macOS,
);
await pumpPage(
tester,
child: SettingsPage(
controller: controller,
initialTab: SettingsTab.gateway,
showSectionTabs: true,
),
platform: TargetPlatform.macOS,
);
expect(find.text('账号访问'), findsNothing);
expect(find.text('Account Access'), findsNothing);
expect(find.text('账号本地模式'), findsNothing);
expect(find.text('Account local mode'), findsNothing);
});
expect(
find.byKey(const ValueKey('account-base-url-field')),
findsNothing,
);
expect(find.byKey(const ValueKey('acp-bridge-mode-cloud')), findsNothing);
testWidgets('SettingsPage can expose account access when feature enabled', (
WidgetTester tester,
) async {
final manifest = UiFeatureManifest.fallback().copyWithFeature(
platform: UiFeaturePlatform.desktop,
module: 'settings',
feature: 'account_access',
enabled: true,
releaseTier: UiFeatureReleaseTier.experimental,
);
final controller = await createTestController(
tester,
uiFeatureManifest: manifest,
);
await tester.tap(find.byKey(const ValueKey('section-tab-高级自定义配置')));
await tester.pumpAndSettle();
await pumpPage(
tester,
child: SettingsPage(controller: controller),
platform: TargetPlatform.macOS,
);
expect(find.text('账号访问'), findsOneWidget);
expect(find.text('账号本地模式'), findsOneWidget);
});
expect(find.text('ACP Bridge Server 连接模式'), findsOneWidget);
expect(
find.byKey(const ValueKey('account-base-url-field')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('acp-bridge-mode-cloud')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('acp-bridge-mode-self-hosted')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('acp-bridge-mode-advanced')),
findsOneWidget,
);
},
);
testWidgets(
'SettingsPage workspace tab no longer exposes remote project root',
@ -860,15 +863,16 @@ paths:
final remoteProfile = controller.settings.primaryRemoteGatewayProfile;
setGatewaySnapshotForTest(
controller,
GatewayConnectionSnapshot.initial(mode: RuntimeConnectionMode.remote)
.copyWith(
status: RuntimeConnectionStatus.connected,
statusText: 'Connected',
remoteAddress: '${remoteProfile.host}:${remoteProfile.port}',
lastError: 'NOT_PAIRED: pairing required',
lastErrorCode: 'NOT_PAIRED',
lastErrorDetailCode: 'PAIRING_REQUIRED',
),
GatewayConnectionSnapshot.initial(
mode: RuntimeConnectionMode.remote,
).copyWith(
status: RuntimeConnectionStatus.connected,
statusText: 'Connected',
remoteAddress: '${remoteProfile.host}:${remoteProfile.port}',
lastError: 'NOT_PAIRED: pairing required',
lastErrorCode: 'NOT_PAIRED',
lastErrorDetailCode: 'PAIRING_REQUIRED',
),
);
await _pumpWithoutSettling(
@ -876,7 +880,10 @@ paths:
child: ConnectionChipInternal(controller: controller),
);
expect(find.byKey(const Key('assistant-connection-chip')), findsOneWidget);
expect(
find.byKey(const Key('assistant-connection-chip')),
findsOneWidget,
);
expect(
find.textContaining(
'已连接 · ${remoteProfile.host}:${remoteProfile.port}',

View File

@ -187,38 +187,44 @@ class _TestFakeGatewayRuntime extends GatewayRuntime {
case 'device.pair.list':
return <String, dynamic>{
'pending': _pairingList.pending
.map((item) => <String, dynamic>{
'requestId': item.requestId,
'deviceId': item.deviceId,
'label': item.label,
'role': item.role,
'scopes': item.scopes,
'remoteIp': item.remoteIp,
'requestedAtMs': item.requestedAtMs,
'repair': item.isRepair,
})
.map(
(item) => <String, dynamic>{
'requestId': item.requestId,
'deviceId': item.deviceId,
'label': item.label,
'role': item.role,
'scopes': item.scopes,
'remoteIp': item.remoteIp,
'requestedAtMs': item.requestedAtMs,
'repair': item.isRepair,
},
)
.toList(growable: false),
'paired': _pairingList.paired
.map((item) => <String, dynamic>{
'deviceId': item.deviceId,
'displayName': item.displayName,
'roles': item.roles,
'scopes': item.scopes,
'remoteIp': item.remoteIp,
'tokens': item.tokens
.map((token) => <String, dynamic>{
'role': token.role,
'scopes': token.scopes,
'createdAtMs': token.createdAtMs,
'rotatedAtMs': token.rotatedAtMs,
'revokedAtMs': token.revokedAtMs,
'lastUsedAtMs': token.lastUsedAtMs,
})
.toList(growable: false),
'createdAtMs': item.createdAtMs,
'approvedAtMs': item.approvedAtMs,
'currentDevice': item.currentDevice,
})
.map(
(item) => <String, dynamic>{
'deviceId': item.deviceId,
'displayName': item.displayName,
'roles': item.roles,
'scopes': item.scopes,
'remoteIp': item.remoteIp,
'tokens': item.tokens
.map(
(token) => <String, dynamic>{
'role': token.role,
'scopes': token.scopes,
'createdAtMs': token.createdAtMs,
'rotatedAtMs': token.rotatedAtMs,
'revokedAtMs': token.revokedAtMs,
'lastUsedAtMs': token.lastUsedAtMs,
},
)
.toList(growable: false),
'createdAtMs': item.createdAtMs,
'approvedAtMs': item.approvedAtMs,
'currentDevice': item.currentDevice,
},
)
.toList(growable: false),
};
case 'system-presence':
@ -235,7 +241,9 @@ void setGatewaySnapshotForTest(
) {
final runtime = controller.runtime;
if (runtime is! _TestFakeGatewayRuntime) {
throw StateError('createTestController() runtime does not support mutation');
throw StateError(
'createTestController() runtime does not support mutation',
);
}
runtime.setSnapshotForTest(snapshot);
}
@ -246,7 +254,9 @@ void setGatewayPairingListForTest(
) {
final runtime = controller.runtime;
if (runtime is! _TestFakeGatewayRuntime) {
throw StateError('createTestController() runtime does not support mutation');
throw StateError(
'createTestController() runtime does not support mutation',
);
}
runtime.setDevicePairingForTest(pairingList);
}
@ -262,7 +272,7 @@ class _TestFakeCodexRuntime extends CodexRuntime {
Future<void> pumpPage(
WidgetTester tester, {
required Widget child,
Size size = const Size(1600, 1000),
Size size = const Size(1600, 4000),
TargetPlatform? platform,
}) async {
tester.view.devicePixelRatio = 1;

View File

@ -85,7 +85,7 @@ void main() {
expect(find.text('ClawHub'), findsNothing);
expect(find.text('回到 APP首页'), findsNothing);
expect(find.text('设置'), findsOneWidget);
expect(find.text('账户'), findsOneWidget);
expect(find.text('账户'), findsNothing);
expect(find.text('语言'), findsOneWidget);
expect(find.text('主题'), findsOneWidget);
@ -107,11 +107,11 @@ void main() {
await tester.pumpAndSettle();
expect(themeToggled, 1);
await tester.tap(
expect(
find.byKey(const ValueKey<String>('sidebar-footer-account')),
findsNothing,
);
await tester.pumpAndSettle();
expect(accountOpened, 1);
expect(accountOpened, 0);
await tester.tap(
find.byKey(const Key('workspace-sidebar-collapse-button')),