xworkmate-app/lib/features/settings/settings_account_panel.dart
2026-06-01 13:11:30 +08:00

835 lines
31 KiB
Dart

import 'package:flutter/material.dart';
import '../../i18n/app_language.dart';
import '../../runtime/runtime_models.dart';
class SettingsAccountPanel extends StatefulWidget {
const SettingsAccountPanel({
super.key,
required this.settings,
required this.accountSession,
required this.accountState,
required this.accountBusy,
this.accountStatus = '',
required this.accountSignedIn,
required this.accountMfaRequired,
required this.accountBaseUrlController,
required this.accountIdentifierController,
required this.accountPasswordController,
required this.accountMfaCodeController,
required this.bridgeUrlController,
required this.bridgeTokenController,
required this.onSaveAccountProfile,
required this.onLogin,
required this.onVerifyMfa,
required this.onCancelMfa,
required this.onSync,
required this.onLogout,
});
final SettingsSnapshot settings;
final AccountSessionSummary? accountSession;
final AccountSyncState? accountState;
final bool accountBusy;
final String accountStatus;
final bool accountSignedIn;
final bool accountMfaRequired;
final TextEditingController accountBaseUrlController;
final TextEditingController accountIdentifierController;
final TextEditingController accountPasswordController;
final TextEditingController accountMfaCodeController;
final TextEditingController bridgeUrlController;
final TextEditingController bridgeTokenController;
final Future<void> Function({required bool isManualBridge})
onSaveAccountProfile;
final Future<void> Function() onLogin;
final Future<void> Function() onVerifyMfa;
final Future<void> Function() onCancelMfa;
final Future<void> Function() onSync;
final Future<void> Function() onLogout;
@override
State<SettingsAccountPanel> createState() => _SettingsAccountPanelState();
}
class _SettingsAccountPanelState extends State<SettingsAccountPanel>
with SingleTickerProviderStateMixin {
late final TabController _signedOutTabController;
@override
void initState() {
super.initState();
_signedOutTabController = TabController(
length: 2,
vsync: this,
initialIndex: _tabIndexFor(widget.settings),
);
}
@override
void didUpdateWidget(SettingsAccountPanel oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.accountSignedIn != widget.accountSignedIn ||
oldWidget.accountMfaRequired != widget.accountMfaRequired) {
_signedOutTabController.index = _tabIndexFor(widget.settings);
}
}
@override
void dispose() {
_signedOutTabController.dispose();
super.dispose();
}
int _tabIndexFor(SettingsSnapshot settings) {
return settings.acpBridgeServerModeConfig.effective.source == 'bridge'
? 1
: 0;
}
@override
Widget build(BuildContext context) {
if (!widget.accountSignedIn && !widget.accountMfaRequired) {
return AnimatedBuilder(
animation: _signedOutTabController,
builder: (context, _) {
return Column(
children: [
TabBar(
controller: _signedOutTabController,
tabs: [
Tab(text: appText('svc.plus 云端同步', 'svc.plus Cloud Sync')),
Tab(text: appText('手动 Bridge 配置', 'Manual Bridge Config')),
],
),
const SizedBox(height: 24),
SizedBox(
height: 480,
child: IndexedStack(
index: _signedOutTabController.index,
children: [
_SignedOutAccountPanel(
accountBusy: widget.accountBusy,
accountBaseUrlController: widget.accountBaseUrlController,
accountIdentifierController:
widget.accountIdentifierController,
accountPasswordController:
widget.accountPasswordController,
onSaveAccountProfile: widget.onSaveAccountProfile,
onLogin: widget.onLogin,
),
_ManualBridgePanel(
settings: widget.settings,
accountBusy: widget.accountBusy,
bridgeUrlController: widget.bridgeUrlController,
bridgeTokenController: widget.bridgeTokenController,
onSaveAccountProfile: widget.onSaveAccountProfile,
),
],
),
),
],
);
},
);
}
if (widget.accountMfaRequired) {
return _PendingMfaAccountPanel(
accountBusy: widget.accountBusy,
accountBaseUrlController: widget.accountBaseUrlController,
accountIdentifierController: widget.accountIdentifierController,
accountMfaCodeController: widget.accountMfaCodeController,
onVerifyMfa: widget.onVerifyMfa,
onCancelMfa: widget.onCancelMfa,
);
}
return _SignedInAccountPanel(
settings: widget.settings,
accountSession: widget.accountSession,
accountState: widget.accountState,
accountBusy: widget.accountBusy,
accountStatus: widget.accountStatus,
onSaveAccountProfile: widget.onSaveAccountProfile,
onSync: widget.onSync,
onLogout: widget.onLogout,
);
}
}
class _ManualBridgePanel extends StatelessWidget {
const _ManualBridgePanel({
required this.settings,
required this.accountBusy,
required this.bridgeUrlController,
required this.bridgeTokenController,
required this.onSaveAccountProfile,
});
final SettingsSnapshot settings;
final bool accountBusy;
final TextEditingController bridgeUrlController;
final TextEditingController bridgeTokenController;
final Future<void> Function({required bool isManualBridge})
onSaveAccountProfile;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.link_outlined,
size: 72,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
appText('手动 Bridge 配置', 'Manual Bridge Config'),
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
appText(
'直接配置本地或私有 xworkmate-bridge 地址与令牌。',
'Configure local or private xworkmate-bridge address and token directly.',
),
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('settings-manual-bridge-url-field'),
controller: bridgeUrlController,
decoration: InputDecoration(
labelText: appText('Bridge 地址', 'Bridge URL'),
prefixIcon: const Icon(Icons.dns_outlined),
hintText: 'https://xworkmate-bridge.svc.plus',
),
onFieldSubmitted: (_) =>
onSaveAccountProfile(isManualBridge: true),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('settings-manual-bridge-token-field'),
controller: bridgeTokenController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('鉴权令牌 (TOKEN)', 'Auth Token'),
prefixIcon: const Icon(Icons.key_outlined),
),
onFieldSubmitted: (_) =>
onSaveAccountProfile(isManualBridge: true),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
key: const ValueKey('settings-manual-bridge-save-button'),
onPressed: accountBusy
? null
: () => onSaveAccountProfile(isManualBridge: true),
child: Text(appText('保存配置', 'Save Configuration')),
),
),
],
),
),
);
}
}
class _SignedOutAccountPanel extends StatelessWidget {
const _SignedOutAccountPanel({
required this.accountBusy,
required this.accountBaseUrlController,
required this.accountIdentifierController,
required this.accountPasswordController,
required this.onSaveAccountProfile,
required this.onLogin,
});
final bool accountBusy;
final TextEditingController accountBaseUrlController;
final TextEditingController accountIdentifierController;
final TextEditingController accountPasswordController;
final Future<void> Function({required bool isManualBridge})
onSaveAccountProfile;
final Future<void> Function() onLogin;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.cloud_outlined,
size: 72,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
appText('账号登录', 'Account Sign In'),
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
appText(
'登录后可直接同步 svc.plus 托管连接配置。',
'Sign in to sync the managed svc.plus connection profile.',
),
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('settings-account-base-url-field'),
controller: accountBaseUrlController,
decoration: InputDecoration(
labelText: appText('服务地址', 'Service URL'),
prefixIcon: const Icon(Icons.dns_outlined),
),
onFieldSubmitted: (_) =>
onSaveAccountProfile(isManualBridge: false),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('settings-account-identifier-field'),
controller: accountIdentifierController,
decoration: InputDecoration(
labelText: appText('邮箱或账号', 'Email or Username'),
prefixIcon: const Icon(Icons.person_outline_rounded),
),
onFieldSubmitted: (_) =>
onSaveAccountProfile(isManualBridge: false),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('settings-account-password-field'),
controller: accountPasswordController,
obscureText: true,
decoration: InputDecoration(
labelText: appText('密码', 'Password'),
prefixIcon: const Icon(Icons.lock_outline_rounded),
),
onFieldSubmitted: (_) => onLogin(),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: FilledButton(
key: const ValueKey('settings-account-login-button'),
onPressed: accountBusy ? null : () => onLogin(),
child: Text(appText('登录', 'Sign In')),
),
),
],
),
),
);
}
}
class _PendingMfaAccountPanel extends StatelessWidget {
const _PendingMfaAccountPanel({
required this.accountBusy,
required this.accountBaseUrlController,
required this.accountIdentifierController,
required this.accountMfaCodeController,
required this.onVerifyMfa,
required this.onCancelMfa,
});
final bool accountBusy;
final TextEditingController accountBaseUrlController;
final TextEditingController accountIdentifierController;
final TextEditingController accountMfaCodeController;
final Future<void> Function() onVerifyMfa;
final Future<void> Function() onCancelMfa;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 760),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
Icons.verified_user_outlined,
size: 72,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(
appText('双重验证', 'Multi-Factor Authentication'),
style: theme.textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
appText(
'请输入验证码完成登录并同步设置。',
'Enter your code to finish signing in and sync settings.',
),
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('settings-account-base-url-field'),
controller: accountBaseUrlController,
readOnly: true,
decoration: InputDecoration(
labelText: appText('服务地址', 'Service URL'),
prefixIcon: const Icon(Icons.dns_outlined),
),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('settings-account-identifier-field'),
controller: accountIdentifierController,
readOnly: true,
decoration: InputDecoration(
labelText: appText('邮箱或账号', 'Email or Username'),
prefixIcon: const Icon(Icons.person_outline_rounded),
),
),
const SizedBox(height: 16),
TextFormField(
key: const ValueKey('settings-account-mfa-code-field'),
controller: accountMfaCodeController,
decoration: InputDecoration(
labelText: appText('双重验证代码', 'MFA Code'),
prefixIcon: const Icon(Icons.key_outlined),
),
onFieldSubmitted: (_) => onVerifyMfa(),
),
const SizedBox(height: 24),
Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 12,
children: [
FilledButton(
key: const ValueKey('settings-account-mfa-verify-button'),
onPressed: accountBusy ? null : () => onVerifyMfa(),
child: Text(appText('验证并同步', 'Verify & Sync')),
),
FilledButton.tonal(
key: const ValueKey('settings-account-mfa-cancel-button'),
onPressed: accountBusy ? null : () => onCancelMfa(),
child: Text(appText('返回编辑', 'Back to Edit')),
),
],
),
],
),
),
);
}
}
class _SignedInAccountPanel extends StatelessWidget {
const _SignedInAccountPanel({
required this.settings,
required this.accountSession,
required this.accountState,
required this.accountBusy,
required this.accountStatus,
required this.onSaveAccountProfile,
required this.onSync,
required this.onLogout,
});
final SettingsSnapshot settings;
final AccountSessionSummary? accountSession;
final AccountSyncState? accountState;
final bool accountBusy;
final String accountStatus;
final Future<void> Function({required bool isManualBridge})
onSaveAccountProfile;
final Future<void> Function() onSync;
final Future<void> 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()
: settings.accountBaseUrl.trim();
final accountIdentifier = cloudSync.accountIdentifier.trim().isNotEmpty
? cloudSync.accountIdentifier.trim()
: settings.accountUsername.trim().isNotEmpty
? settings.accountUsername.trim()
: (accountSession?.email.trim() ?? '');
final remoteSummary = cloudSync.remoteServerSummary.endpoint.trim();
final syncScope = accountState?.profileScope.trim().isNotEmpty == true
? accountState!.profileScope.trim()
: appText('待同步', 'Pending sync');
final syncState = accountState?.syncState.trim().isNotEmpty == true
? accountState!.syncState.trim()
: 'idle';
final syncMessage = accountState?.syncMessage.trim().isNotEmpty == true
? accountState!.syncMessage.trim()
: appText('尚未同步远端配置', 'Remote config not synced yet');
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(isManualBridge: true);
final mfaEnabled =
accountSession?.totpEnabled == true ||
accountSession?.mfaEnabled == true;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('账号登录与同步', 'Account Sign In & Sync'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
appText(
'登录后只保留状态条和主动作,详细信息默认折叠。',
'After sign-in, keep only the status bar and primary actions; details stay collapsed by default.',
),
),
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(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(modeIcon, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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: 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});
final AccountSyncState? accountState;
@override
Widget build(BuildContext context) {
final configured = <String>[
if (accountState?.tokenConfigured.bridge == true)
appText('Bridge Token', 'Bridge Token'),
if (accountState?.tokenConfigured.vault == true) 'Vault Token',
];
final summary = configured.isEmpty
? appText('未配置', 'Not configured')
: configured.join(' / ');
return Text(
'${appText('已同步令牌', 'Synced Tokens')}: $summary',
key: const ValueKey('settings-account-summary-token-configured'),
);
}
}
String _formatSyncTime(int lastSyncAtMs) {
if (lastSyncAtMs <= 0) {
return appText('尚未同步', 'Not synced yet');
}
return DateTime.fromMillisecondsSinceEpoch(
lastSyncAtMs,
).toLocal().toIso8601String();
}