diff --git a/lib/features/settings/settings_account_panel.dart b/lib/features/settings/settings_account_panel.dart index 70eaf398..ecfd5b9a 100644 --- a/lib/features/settings/settings_account_panel.dart +++ b/lib/features/settings/settings_account_panel.dart @@ -113,6 +113,7 @@ class SettingsAccountPanel extends StatelessWidget { accountState: accountState, accountBusy: accountBusy, accountStatus: accountStatus, + onSaveAccountProfile: onSaveAccountProfile, onSync: onSync, onLogout: onLogout, ); @@ -417,6 +418,7 @@ class _SignedInAccountPanel extends StatelessWidget { required this.accountState, required this.accountBusy, required this.accountStatus, + required this.onSaveAccountProfile, required this.onSync, required this.onLogout, }); @@ -426,11 +428,17 @@ class _SignedInAccountPanel extends StatelessWidget { final AccountSyncState? accountState; final bool accountBusy; final String accountStatus; + final Future Function() onSaveAccountProfile; final Future Function() onSync; final Future Function() onLogout; @override Widget build(BuildContext context) { + final mode = _signedInAccountModeFromSettings( + settings: settings, + accountState: accountState, + ); + final isAccountSyncMode = mode == _SignedInAccountMode.accountSync; final cloudSync = settings.acpBridgeServerModeConfig.cloudSynced; final serviceUrl = cloudSync.accountBaseUrl.trim().isNotEmpty ? cloudSync.accountBaseUrl.trim() @@ -450,11 +458,25 @@ class _SignedInAccountPanel extends StatelessWidget { final syncMessage = accountState?.syncMessage.trim().isNotEmpty == true ? accountState!.syncMessage.trim() : appText('尚未同步远端配置', 'Remote config not synced yet'); - final effectiveSyncState = accountBusy ? 'syncing' : syncState; - final effectiveSyncMessage = - accountBusy && accountStatus.trim().isNotEmpty + final modeStateLabel = accountBusy + ? (isAccountSyncMode ? appText('同步中', 'Syncing') : appText('保存中', 'Saving')) + : (isAccountSyncMode + ? _describeAccountSyncState(syncState) + : _describeBridgeSaveState(settings)); + final modeStatusLabel = accountBusy && accountStatus.trim().isNotEmpty ? accountStatus.trim() : syncMessage; + final modeIcon = isAccountSyncMode ? Icons.cloud_outlined : Icons.link_outlined; + final modeTitle = isAccountSyncMode + ? appText('账号同步', 'Account Sync') + : appText('手动 Bridge', 'Manual Bridge'); + final primaryActionLabel = isAccountSyncMode + ? appText('重新同步', 'Resync') + : appText('重新设置', 'Reset'); + final primaryActionKey = isAccountSyncMode + ? 'settings-account-sync-button' + : 'settings-account-manual-reset-button'; + final primaryAction = isAccountSyncMode ? onSync : onSaveAccountProfile; final mfaEnabled = accountSession?.totpEnabled == true || accountSession?.mfaEnabled == true; @@ -468,24 +490,11 @@ class _SignedInAccountPanel extends StatelessWidget { const SizedBox(height: 8), Text( appText( - '登录身份和 svc.plus 托管连接同步已合并到这里,直接查看状态并执行重新同步或断开。', - 'Identity and managed svc.plus connection sync now live together here.', + '登录后只保留状态条和主动作,详细信息默认折叠。', + 'After sign-in, keep only the status bar and primary actions; details stay collapsed by default.', ), ), const SizedBox(height: 16), - Text( - accountSession?.email.trim().isNotEmpty == true - ? accountSession!.email.trim() - : appText('当前账号', 'Current account'), - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 8), - Text( - '${appText('同步状态', 'Sync Status')}: $effectiveSyncState · $effectiveSyncMessage', - key: const ValueKey('settings-account-sync-status'), - style: Theme.of(context).textTheme.bodySmall, - ), - const SizedBox(height: 16), Container( width: double.infinity, padding: const EdgeInsets.all(14), @@ -494,96 +503,244 @@ class _SignedInAccountPanel extends StatelessWidget { borderRadius: BorderRadius.circular(16), ), child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - appText('登录与同步状态', 'Login and Sync Status'), - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - Text( - '${appText('服务地址', 'Service URL')}: ${serviceUrl.isEmpty ? appText('待配置', 'Pending') : serviceUrl}', - key: const ValueKey('settings-account-summary-service-url'), - ), - const SizedBox(height: 6), - Text( - '${appText('账户标识', 'Account Identifier')}: ${accountIdentifier.isEmpty ? appText('待登录', 'Not signed in') : accountIdentifier}', - key: const ValueKey( - 'settings-account-summary-account-identifier', - ), - ), - const SizedBox(height: 6), - Text( - '${appText('连接来源', 'Connection Source')}: ${appText('svc.plus 托管配置', 'svc.plus managed profile')}', - key: const ValueKey( - 'settings-account-summary-connection-source', - ), - ), - const SizedBox(height: 6), - Text( - '${appText('远端摘要', 'Remote Summary')}: ${remoteSummary.isEmpty ? appText('待同步', 'Pending sync') : remoteSummary}', - key: const ValueKey('settings-account-summary-remote-summary'), - ), - const SizedBox(height: 6), - Text( - '${appText('最近同步', 'Last Sync')}: ${_formatSyncTime(cloudSync.lastSyncAt)}', - key: const ValueKey('settings-account-summary-last-sync'), - ), - const SizedBox(height: 6), - Text( - '${appText('MFA 状态', 'MFA Status')}: ${mfaEnabled ? appText('已启用', 'Enabled') : appText('未启用', 'Disabled')}', - key: const ValueKey('settings-account-summary-mfa-status'), - ), - const SizedBox(height: 6), - Text( - '${appText('同步范围', 'Sync Scope')}: $syncScope', - key: const ValueKey('settings-account-summary-sync-scope'), - ), - const SizedBox(height: 6), - _TokenConfiguredSummary(accountState: accountState), - ], - ), - ), - const SizedBox(height: 16), - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - FilledButton.tonal( - key: const ValueKey('settings-account-sync-button'), - onPressed: accountBusy ? null : () => onSync(), - child: accountBusy - ? Row( - mainAxisSize: MainAxisSize.min, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(modeIcon, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - key: const ValueKey( - 'settings-account-sync-progress', - ), - strokeWidth: 2, + Text( + modeTitle, + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text( + isAccountSyncMode + ? '${appText('账号同步状态', 'Account Sync Status')}: $modeStateLabel' + : '${appText('保存状态', 'Save Status')}: $modeStateLabel', + key: const ValueKey('settings-account-sync-status'), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 4), + Text( + accountSession?.email.trim().isNotEmpty == true + ? accountSession!.email.trim() + : appText('当前账号', 'Current account'), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.78), ), ), - const SizedBox(width: 8), - Text(appText('同步中', 'Syncing')), ], - ) - : Text(appText('重新同步', 'Sync Again')), - ), - TextButton( - key: const ValueKey('settings-account-logout-button'), - onPressed: accountBusy ? null : () => onLogout(), - child: Text(appText('退出登录', 'Log Out')), - ), - ], + ), + ), + const SizedBox(width: 12), + Wrap( + spacing: 12, + runSpacing: 12, + alignment: WrapAlignment.end, + children: [ + FilledButton.tonal( + key: ValueKey(primaryActionKey), + onPressed: accountBusy ? null : () => primaryAction(), + child: accountBusy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + key: const ValueKey( + 'settings-account-sync-progress', + ), + strokeWidth: 2, + ), + ), + const SizedBox(width: 8), + Text( + isAccountSyncMode + ? appText('同步中', 'Syncing') + : appText('保存中', 'Saving'), + ), + ], + ) + : Text(primaryActionLabel), + ), + TextButton( + key: const ValueKey('settings-account-logout-button'), + onPressed: accountBusy ? null : () => onLogout(), + child: Text(appText('退出', 'Exit')), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Text( + isAccountSyncMode + ? '${appText('同步说明', 'Sync Summary')}: $modeStatusLabel' + : '${appText('保存说明', 'Save Summary')}: $modeStatusLabel', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color?.withValues(alpha: 0.78), + ), + ), + const SizedBox(height: 8), + ExpansionTile( + key: const ValueKey('settings-account-summary-expansion'), + initiallyExpanded: false, + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(top: 8), + title: Text( + appText('详细信息', 'Details'), + style: Theme.of(context).textTheme.titleSmall, + ), + subtitle: Text( + appText('查看服务地址、令牌与远端摘要', 'View service URL, tokens, and remote summary'), + style: Theme.of(context).textTheme.bodySmall, + ), + children: [ + _SignedInAccountDetails( + settings: settings, + accountSession: accountSession, + accountState: accountState, + serviceUrl: serviceUrl, + accountIdentifier: accountIdentifier, + remoteSummary: remoteSummary, + syncScope: syncScope, + mfaEnabled: mfaEnabled, + ), + ], + ), + ], + ), ), ], ); } } +class _SignedInAccountDetails extends StatelessWidget { + const _SignedInAccountDetails({ + required this.settings, + required this.accountSession, + required this.accountState, + required this.serviceUrl, + required this.accountIdentifier, + required this.remoteSummary, + required this.syncScope, + required this.mfaEnabled, + }); + + final SettingsSnapshot settings; + final AccountSessionSummary? accountSession; + final AccountSyncState? accountState; + final String serviceUrl; + final String accountIdentifier; + final String remoteSummary; + final String syncScope; + final bool mfaEnabled; + + @override + Widget build(BuildContext context) { + final cloudSync = settings.acpBridgeServerModeConfig.cloudSynced; + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${appText('服务地址', 'Service URL')}: ${serviceUrl.isEmpty ? appText('待配置', 'Pending') : serviceUrl}', + key: const ValueKey('settings-account-summary-service-url'), + ), + const SizedBox(height: 6), + Text( + '${appText('账户标识', 'Account Identifier')}: ${accountIdentifier.isEmpty ? appText('待登录', 'Not signed in') : accountIdentifier}', + key: const ValueKey('settings-account-summary-account-identifier'), + ), + const SizedBox(height: 6), + Text( + '${appText('连接来源', 'Connection Source')}: ${_connectionSourceLabel(settings, accountState)}', + key: const ValueKey('settings-account-summary-connection-source'), + ), + const SizedBox(height: 6), + Text( + '${appText('远端摘要', 'Remote Summary')}: ${remoteSummary.isEmpty ? appText('待同步', 'Pending sync') : remoteSummary}', + key: const ValueKey('settings-account-summary-remote-summary'), + ), + const SizedBox(height: 6), + Text( + '${appText('最近同步', 'Last Sync')}: ${_formatSyncTime(cloudSync.lastSyncAt)}', + key: const ValueKey('settings-account-summary-last-sync'), + ), + const SizedBox(height: 6), + Text( + '${appText('MFA 状态', 'MFA Status')}: ${mfaEnabled ? appText('已启用', 'Enabled') : appText('未启用', 'Disabled')}', + key: const ValueKey('settings-account-summary-mfa-status'), + ), + const SizedBox(height: 6), + Text( + '${appText('同步范围', 'Sync Scope')}: $syncScope', + key: const ValueKey('settings-account-summary-sync-scope'), + ), + const SizedBox(height: 6), + _TokenConfiguredSummary(accountState: accountState), + ], + ), + ); + } +} + +enum _SignedInAccountMode { accountSync, manualBridge } + +_SignedInAccountMode _signedInAccountModeFromSettings({ + required SettingsSnapshot settings, + required AccountSyncState? accountState, +}) { + if (accountState?.profileScope.trim().toLowerCase() == 'bridge') { + return _SignedInAccountMode.accountSync; + } + return _SignedInAccountMode.manualBridge; +} + +String _describeAccountSyncState(String syncState) { + final normalized = syncState.trim().toLowerCase(); + switch (normalized) { + case 'ready': + return appText('已同步', 'Synced'); + case 'syncing': + return appText('同步中', 'Syncing'); + case 'blocked': + case 'error': + return appText('失败', 'Failed'); + default: + return appText('待同步', 'Pending sync'); + } +} + +String _describeBridgeSaveState(SettingsSnapshot settings) { + final configured = settings.acpBridgeServerModeConfig.selfHosted.isConfigured; + return configured ? appText('已保存', 'Saved') : appText('未保存', 'Not saved'); +} + +String _connectionSourceLabel( + SettingsSnapshot settings, + AccountSyncState? accountState, +) { + final mode = _signedInAccountModeFromSettings( + settings: settings, + accountState: accountState, + ); + return mode == _SignedInAccountMode.accountSync + ? appText('svc.plus 托管配置', 'svc.plus managed profile') + : appText('手动 Bridge 配置', 'Manual Bridge configuration'); +} + class _TokenConfiguredSummary extends StatelessWidget { const _TokenConfiguredSummary({required this.accountState}); diff --git a/test/features/settings/settings_account_panel_test.dart b/test/features/settings/settings_account_panel_test.dart index 3c86d6de..c7cfcec7 100644 --- a/test/features/settings/settings_account_panel_test.dart +++ b/test/features/settings/settings_account_panel_test.dart @@ -64,7 +64,7 @@ void main() { expect(loginCount, 1); }); - testWidgets('shows sync and logout actions on the same row', ( + testWidgets('shows account sync status, resync, and exit in signed-in mode', ( tester, ) async { final controllers = _TestControllers(); @@ -141,22 +141,18 @@ void main() { ); expect(find.text('账号登录与同步'), findsOneWidget); - expect(find.textContaining('svc.plus 托管配置'), findsOneWidget); + expect(find.text('账号同步'), findsOneWidget); + expect(find.textContaining('账号同步状态'), findsOneWidget); + expect(find.byKey(const ValueKey('settings-account-sync-button')), findsOneWidget); + expect(find.byKey(const ValueKey('settings-account-manual-reset-button')), findsNothing); expect( - find.byKey(const ValueKey('settings-account-disconnect-button')), - findsNothing, - ); - expect(find.textContaining('本地配置'), findsNothing); - expect(find.textContaining('已断开'), findsNothing); - expect(find.textContaining('当前使用本地连接配置'), findsNothing); - - final syncTop = tester.getTopLeft( - find.byKey(const ValueKey('settings-account-sync-button')), - ); - final logoutTop = tester.getTopLeft( find.byKey(const ValueKey('settings-account-logout-button')), + findsOneWidget, ); - expect(syncTop.dy, logoutTop.dy); + + expect(find.byKey(const ValueKey('settings-account-summary-service-url')), findsNothing); + expect(find.byKey(const ValueKey('settings-account-summary-account-identifier')), findsNothing); + expect(find.byKey(const ValueKey('settings-account-summary-remote-summary')), findsNothing); await tester.tap( find.byKey(const ValueKey('settings-account-sync-button')), @@ -171,7 +167,7 @@ void main() { expect(logoutCount, 1); }); - testWidgets('keeps managed connection copy when account is signed in', ( + testWidgets('keeps details collapsed by default and expands diagnostics', ( tester, ) async { final controllers = _TestControllers(); @@ -193,6 +189,12 @@ void main() { accountState: AccountSyncState.defaults().copyWith( syncState: 'ready', syncMessage: 'Bridge access synced', + profileScope: 'bridge', + tokenConfigured: const AccountTokenConfigured( + bridge: true, + vault: false, + apisix: false, + ), ), accountBusy: false, accountSignedIn: true, @@ -213,14 +215,88 @@ void main() { ), ); - expect(find.textContaining('svc.plus 托管配置'), findsOneWidget); - expect(find.textContaining('本地配置'), findsNothing); - expect(find.textContaining('已断开'), findsNothing); - expect(find.textContaining('当前使用本地连接配置'), findsNothing); + expect(find.byKey(const ValueKey('settings-account-summary-service-url')), findsNothing); + expect(find.byKey(const ValueKey('settings-account-summary-account-identifier')), findsNothing); expect( - find.byKey(const ValueKey('settings-account-disconnect-button')), - findsNothing, + find.byKey(const ValueKey('settings-account-summary-expansion')), + findsOneWidget, ); + + await tester.tap(find.byType(ExpansionTile)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('settings-account-summary-service-url')), findsOneWidget); + expect(find.byKey(const ValueKey('settings-account-summary-account-identifier')), findsOneWidget); + expect(find.byKey(const ValueKey('settings-account-summary-remote-summary')), findsOneWidget); + expect(find.byKey(const ValueKey('settings-account-summary-last-sync')), findsOneWidget); + expect(find.byKey(const ValueKey('settings-account-summary-mfa-status')), findsOneWidget); + expect(find.byKey(const ValueKey('settings-account-summary-sync-scope')), findsOneWidget); + expect(find.byKey(const ValueKey('settings-account-summary-token-configured')), findsOneWidget); + }); + + testWidgets('shows manual bridge save status and reset action when not account sync', ( + tester, + ) async { + final controllers = _TestControllers(); + addTearDown(controllers.dispose); + + var saveCount = 0; + var logoutCount = 0; + + await tester.pumpWidget( + _buildTestApp( + child: SettingsAccountPanel( + settings: SettingsSnapshot.defaults().copyWith( + acpBridgeServerModeConfig: AcpBridgeServerModeConfig.defaults().copyWith( + selfHosted: AcpBridgeServerModeConfig.defaults().selfHosted.copyWith( + serverUrl: 'https://xworkmate-bridge.svc.plus', + passwordRef: 'bridge-token-ref', + ), + ), + ), + accountSession: const AccountSessionSummary( + userId: 'u-1', + email: 'review@svc.plus', + name: 'Review User', + role: 'operator', + mfaEnabled: false, + ), + accountState: null, + accountBusy: false, + accountSignedIn: true, + accountMfaRequired: false, + accountBaseUrlController: controllers.baseUrl, + accountIdentifierController: controllers.identifier, + accountPasswordController: controllers.password, + accountMfaCodeController: controllers.mfaCode, + bridgeUrlController: controllers.bridgeUrl, + bridgeTokenController: controllers.bridgeToken, + onSaveAccountProfile: () async { + saveCount += 1; + }, + onLogin: () async {}, + onVerifyMfa: () async {}, + onCancelMfa: () async {}, + onSync: () async {}, + onLogout: () async { + logoutCount += 1; + }, + ), + ), + ); + + expect(find.text('手动 Bridge'), findsOneWidget); + expect(find.textContaining('保存状态'), findsOneWidget); + expect(find.byKey(const ValueKey('settings-account-manual-reset-button')), findsOneWidget); + expect(find.byKey(const ValueKey('settings-account-sync-button')), findsNothing); + + await tester.tap(find.byKey(const ValueKey('settings-account-manual-reset-button'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('settings-account-logout-button'))); + await tester.pump(); + + expect(saveCount, 1); + expect(logoutCount, 1); }); testWidgets('shows live syncing feedback while resync is running', (