xworkmate-app/lib/features/mobile/mobile_settings_page.dart

678 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/ui_feature_manifest.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
import '../../runtime/runtime_controllers.dart';
import '../../runtime/runtime_models.dart';
import '../../theme/app_palette.dart';
import 'mobile_settings_page_widgets.dart';
import '../settings/settings_help_panel.dart';
class MobileSettingsPage extends StatefulWidget {
const MobileSettingsPage({super.key, required this.controller});
final AppController controller;
@override
State<MobileSettingsPage> createState() => _MobileSettingsPageState();
}
class _MobileSettingsPageState extends State<MobileSettingsPage> {
late final TextEditingController accountBaseUrlController;
late final TextEditingController accountIdentifierController;
late final TextEditingController accountPasswordController;
late final TextEditingController accountMfaCodeController;
late final TextEditingController bridgeUrlController;
late final TextEditingController bridgeTokenController;
String lastSavedAccountBaseUrl = '';
String lastSavedAccountIdentifier = '';
String lastSavedBridgeUrl = '';
@override
void initState() {
super.initState();
final settings = widget.controller.settings;
lastSavedAccountBaseUrl = settings.accountBaseUrl;
lastSavedAccountIdentifier = settings.accountUsername;
lastSavedBridgeUrl =
settings.acpBridgeServerModeConfig.selfHosted.serverUrl;
accountBaseUrlController = TextEditingController(
text: lastSavedAccountBaseUrl,
);
accountIdentifierController = TextEditingController(
text: lastSavedAccountIdentifier,
);
accountPasswordController = TextEditingController();
accountMfaCodeController = TextEditingController();
bridgeUrlController = TextEditingController(text: lastSavedBridgeUrl);
bridgeTokenController = TextEditingController();
unawaited(loadBridgeToken());
}
@override
void dispose() {
accountBaseUrlController.dispose();
accountIdentifierController.dispose();
accountPasswordController.dispose();
accountMfaCodeController.dispose();
bridgeUrlController.dispose();
bridgeTokenController.dispose();
super.dispose();
}
Future<void> loadBridgeToken() async {
final token = await widget.controller.settingsController
.loadSecretValueByRef(
widget
.controller
.settings
.acpBridgeServerModeConfig
.selfHosted
.passwordRef,
);
if (!mounted) {
return;
}
bridgeTokenController.text = token;
}
void syncControllers(SettingsSnapshot settings) {
if (accountBaseUrlController.text == lastSavedAccountBaseUrl &&
settings.accountBaseUrl != lastSavedAccountBaseUrl) {
accountBaseUrlController.text = settings.accountBaseUrl;
}
if (accountIdentifierController.text == lastSavedAccountIdentifier &&
settings.accountUsername != lastSavedAccountIdentifier) {
accountIdentifierController.text = settings.accountUsername;
}
lastSavedAccountBaseUrl = settings.accountBaseUrl;
lastSavedAccountIdentifier = settings.accountUsername;
final bridgeConfig = settings.acpBridgeServerModeConfig;
if (bridgeUrlController.text == lastSavedBridgeUrl &&
bridgeConfig.selfHosted.serverUrl != lastSavedBridgeUrl) {
bridgeUrlController.text = bridgeConfig.selfHosted.serverUrl;
}
lastSavedBridgeUrl = bridgeConfig.selfHosted.serverUrl;
}
Future<void> persistAccountProfileSettings({
required SettingsSnapshot settings,
required bool isManualBridge,
bool refreshAfterSave = true,
}) async {
final nextSettings = await widget.controller.settingsController
.buildSavedAccountProfileSettings(
settings: settings,
accountBaseUrl: accountBaseUrlController.text,
accountIdentifier: accountIdentifierController.text,
bridgeServerUrl: bridgeUrlController.text,
bridgeToken: bridgeTokenController.text,
isManualBridge: isManualBridge,
);
await widget.controller.saveSettings(
nextSettings,
refreshAfterSave: isManualBridge ? false : refreshAfterSave,
);
lastSavedAccountBaseUrl = nextSettings.accountBaseUrl;
lastSavedAccountIdentifier = nextSettings.accountUsername;
lastSavedBridgeUrl =
nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl;
if (isManualBridge &&
nextSettings.acpBridgeServerModeConfig.selfHosted.isConfigured) {
unawaited(refreshBridgeCapabilities());
}
}
Future<void> loginAccount(SettingsSnapshot settings) async {
try {
final baseUrl = accountBaseUrlController.text.trim();
final identifier = accountIdentifierController.text.trim();
await widget.controller.settingsController.loginAccount(
baseUrl: baseUrl,
identifier: identifier,
password: accountPasswordController.text,
);
if (!widget.controller.settingsController.accountSignedIn) {
return;
}
await persistAccountProfileSettings(
settings: widget.controller.settings,
isManualBridge: false,
refreshAfterSave: false,
);
unawaited(refreshBridgeCapabilities());
} finally {
accountPasswordController.clear();
}
}
Future<void> syncAccount(SettingsSnapshot settings) async {
await persistAccountProfileSettings(
settings: settings,
isManualBridge: false,
refreshAfterSave: false,
);
await widget.controller.settingsController.syncAccountSettings(
baseUrl: accountBaseUrlController.text.trim(),
);
unawaited(refreshBridgeCapabilities());
}
Future<void> verifyMfa(SettingsSnapshot settings) async {
try {
await persistAccountProfileSettings(
settings: settings,
isManualBridge: false,
refreshAfterSave: false,
);
await widget.controller.settingsController.verifyAccountMfa(
baseUrl: accountBaseUrlController.text.trim(),
code: accountMfaCodeController.text.trim(),
);
unawaited(refreshBridgeCapabilities());
} finally {
accountMfaCodeController.clear();
}
}
Future<void> refreshBridgeCapabilities() async {
final dynamic controller = widget.controller;
try {
await controller.refreshSingleAgentCapabilitiesInternal(
forceRefresh: true,
);
} catch (e, stackTrace) {
debugPrint('Error: $e\n$stackTrace');
// Account login should not fail only because runtime refresh is transient.
}
try {
await controller.refreshAcpCapabilitiesInternal(forceRefresh: true);
} catch (e, stackTrace) {
debugPrint('Error: $e\n$stackTrace');
// Runtime capabilities can be refreshed again from Assistant.
}
}
Future<void> logoutAccount() async {
await widget.controller.settingsController.logoutAccount();
accountPasswordController.clear();
accountMfaCodeController.clear();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: Listenable.merge(<Listenable>[
widget.controller,
widget.controller.settingsController,
]),
builder: (context, _) {
final controller = widget.controller;
final settings = controller.settings;
syncControllers(settings);
final features = controller.featuresFor(
resolveUiFeaturePlatformFromContext(context),
);
final availableTabs = features.availableSettingsTabs;
final currentTab = availableTabs.contains(controller.settingsTab)
? controller.settingsTab
: SettingsTab.gateway;
final palette = context.palette;
final bottomPadding = MediaQuery.viewPaddingOf(context).bottom + 16;
return ColoredBox(
key: const Key('mobile-settings-page'),
color: palette.canvas,
child: CustomScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.fromLTRB(16, 14, 16, bottomPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => controller.navigateTo(
WorkspaceDestination.assistant,
),
behavior: HitTestBehavior.opaque,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.arrow_back_ios_new_rounded,
size: 16,
color: palette.textSecondary,
),
const SizedBox(width: 6),
Text(
appText('返回对话主页', 'Back to Chat'),
style: TextStyle(
color: palette.textSecondary,
fontSize: 16,
),
),
],
),
),
const SizedBox(height: 24),
Text(
appText('设置', 'Settings'),
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
if (availableTabs.length > 1) ...[
MobileSettingsTabSelectorInternal(
currentTab: currentTab,
availableTabs: availableTabs,
onChanged: (tab) => controller.openSettings(tab: tab),
),
const SizedBox(height: 12),
],
if (currentTab == SettingsTab.archivedTasks)
_ArchivedTasksSection(controller: controller)
else if (currentTab == SettingsTab.help)
const SettingsHelpPanel()
else
_AccountSection(
settings: settings,
accountSession:
controller.settingsController.accountSession,
accountState:
controller.settingsController.accountSyncState,
accountBusy:
controller.settingsController.accountBusy,
accountStatus:
controller.settingsController.accountStatus,
accountSignedIn:
controller.settingsController.accountSignedIn,
accountMfaRequired:
controller.settingsController.accountMfaRequired,
accountBaseUrlController: accountBaseUrlController,
accountIdentifierController:
accountIdentifierController,
accountPasswordController: accountPasswordController,
accountMfaCodeController: accountMfaCodeController,
bridgeUrlController: bridgeUrlController,
bridgeTokenController: bridgeTokenController,
onLogin: () => loginAccount(settings),
onVerifyMfa: () => verifyMfa(settings),
onCancelMfa: () async {
await controller.settingsController
.cancelAccountMfaChallenge();
accountPasswordController.clear();
accountMfaCodeController.clear();
},
onSync: () => syncAccount(settings),
onLogout: logoutAccount,
onSaveManualBridge: () =>
persistAccountProfileSettings(
settings: settings,
isManualBridge: true,
),
),
],
),
),
),
],
),
);
},
);
}
}
class _AccountSection extends StatelessWidget {
const _AccountSection({
required this.settings,
required this.accountSession,
required this.accountState,
required this.accountBusy,
required 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.onLogin,
required this.onVerifyMfa,
required this.onCancelMfa,
required this.onSync,
required this.onLogout,
required this.onSaveManualBridge,
});
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() onLogin;
final Future<void> Function() onVerifyMfa;
final Future<void> Function() onCancelMfa;
final Future<void> Function() onSync;
final Future<void> Function() onLogout;
final Future<void> Function() onSaveManualBridge;
@override
Widget build(BuildContext context) {
if (accountMfaRequired) {
return MobileSettingsCardInternal(
key: const Key('mobile-settings-mfa-card'),
icon: Icons.verified_user_outlined,
title: appText('双重验证', 'Multi-Factor Authentication'),
subtitle: appText(
'输入验证码完成登录并同步托管 Bridge。',
'Enter the code to finish sign-in and sync the managed Bridge.',
),
children: [
MobileSettingsTextFieldInternal(
key: const Key('mobile-settings-account-mfa-code-field'),
controller: accountMfaCodeController,
label: appText('验证码', 'Code'),
icon: Icons.key_outlined,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
onSubmitted: (_) => onVerifyMfa(),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FilledButton(
key: const Key('mobile-settings-account-mfa-verify-button'),
onPressed: accountBusy ? null : onVerifyMfa,
child: Text(appText('验证', 'Verify')),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.tonal(
key: const Key('mobile-settings-account-mfa-cancel-button'),
onPressed: accountBusy ? null : onCancelMfa,
child: Text(appText('返回', 'Back')),
),
),
],
),
],
);
}
if (accountSignedIn) {
final email = accountSession?.email.trim().isNotEmpty == true
? accountSession!.email.trim()
: settings.accountUsername.trim();
final status = accountState?.syncMessage.trim().isNotEmpty == true
? accountState!.syncMessage.trim()
: accountStatus.trim();
return Column(
children: [
MobileSettingsCardInternal(
key: const Key('mobile-settings-account-signed-in-card'),
icon: Icons.cloud_done_outlined,
title: email.isEmpty ? appText('已登录', 'Signed In') : email,
subtitle: status.isEmpty
? appText('svc.plus 托管 Bridge 已就绪。', 'Managed Bridge is ready.')
: status,
children: [
MobileSettingsMetaRowInternal(
icon: Icons.hub_outlined,
label: appText('托管入口', 'Managed Endpoint'),
value: kManagedBridgeServerUrl,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: FilledButton.icon(
key: const Key('mobile-settings-account-sync-button'),
onPressed: accountBusy ? null : onSync,
icon: const Icon(Icons.sync_rounded),
label: Text(appText('同步', 'Sync')),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.tonalIcon(
key: const Key('mobile-settings-account-logout-button'),
onPressed: accountBusy ? null : onLogout,
icon: const Icon(Icons.logout_rounded),
label: Text(appText('退出', 'Sign Out')),
),
),
],
),
],
),
const SizedBox(height: 12),
_ManualBridgeCard(
accountBusy: accountBusy,
bridgeUrlController: bridgeUrlController,
bridgeTokenController: bridgeTokenController,
onSaveManualBridge: onSaveManualBridge,
),
],
);
}
return Column(
children: [
MobileSettingsCardInternal(
key: const Key('mobile-settings-account-login-card'),
icon: Icons.cloud_outlined,
title: appText('svc.plus 登录', 'svc.plus Sign In'),
subtitle: appText(
'登录后同步托管 Bridge助手会直接使用统一入口。',
'Sign in to sync the managed Bridge for Assistant.',
),
children: [
if (accountStatus.trim().isNotEmpty &&
accountStatus.trim() != 'Signed out') ...[
MobileSettingsMetaRowInternal(
icon: accountBusy
? Icons.sync_rounded
: Icons.info_outline_rounded,
label: appText('登录状态', 'Sign-in Status'),
value: accountStatus.trim(),
),
const SizedBox(height: 12),
],
MobileSettingsTextFieldInternal(
key: const Key('mobile-settings-account-base-url-field'),
controller: accountBaseUrlController,
label: appText('服务地址', 'Service URL'),
icon: Icons.dns_outlined,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.url],
),
const SizedBox(height: 10),
MobileSettingsTextFieldInternal(
key: const Key('mobile-settings-account-identifier-field'),
controller: accountIdentifierController,
label: appText('邮箱或账号', 'Email or Username'),
icon: Icons.person_outline_rounded,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
),
const SizedBox(height: 10),
MobileSettingsTextFieldInternal(
key: const Key('mobile-settings-account-password-field'),
controller: accountPasswordController,
label: appText('密码', 'Password'),
icon: Icons.lock_outline_rounded,
obscureText: true,
keyboardType: TextInputType.visiblePassword,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
onSubmitted: (_) => onLogin(),
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
key: const Key('mobile-settings-account-login-button'),
onPressed: accountBusy ? null : onLogin,
icon: accountBusy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.login_rounded),
label: Text(appText('登录', 'Sign In')),
),
),
],
),
const SizedBox(height: 12),
_ManualBridgeCard(
accountBusy: accountBusy,
bridgeUrlController: bridgeUrlController,
bridgeTokenController: bridgeTokenController,
onSaveManualBridge: onSaveManualBridge,
),
],
);
}
}
class _ManualBridgeCard extends StatelessWidget {
const _ManualBridgeCard({
required this.accountBusy,
required this.bridgeUrlController,
required this.bridgeTokenController,
required this.onSaveManualBridge,
});
final bool accountBusy;
final TextEditingController bridgeUrlController;
final TextEditingController bridgeTokenController;
final Future<void> Function() onSaveManualBridge;
@override
Widget build(BuildContext context) {
return MobileSettingsCardInternal(
key: const Key('mobile-settings-manual-bridge-card'),
icon: Icons.link_outlined,
title: appText('手动 Bridge', 'Manual Bridge'),
subtitle: appText(
'仅用于私有或本地 Bridge远端托管登录优先。',
'Use only for private or local Bridge; managed sign-in is preferred.',
),
children: [
MobileSettingsTextFieldInternal(
key: const Key('mobile-settings-manual-bridge-url-field'),
controller: bridgeUrlController,
label: appText('Bridge 地址', 'Bridge URL'),
icon: Icons.dns_outlined,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
),
const SizedBox(height: 10),
MobileSettingsTextFieldInternal(
key: const Key('mobile-settings-manual-bridge-token-field'),
controller: bridgeTokenController,
label: appText('鉴权令牌', 'Auth Token'),
icon: Icons.key_outlined,
obscureText: true,
keyboardType: TextInputType.visiblePassword,
textInputAction: TextInputAction.done,
onSubmitted: (_) => onSaveManualBridge(),
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
key: const Key('mobile-settings-manual-bridge-save-button'),
onPressed: accountBusy ? null : onSaveManualBridge,
icon: const Icon(Icons.save_outlined),
label: Text(appText('保存手动配置', 'Save Manual Config')),
),
),
],
);
}
}
class _ArchivedTasksSection extends StatelessWidget {
const _ArchivedTasksSection({required this.controller});
final AppController controller;
@override
Widget build(BuildContext context) {
final sessions = controller.archivedAssistantSessions;
if (sessions.isEmpty) {
return MobileSettingsCardInternal(
key: const Key('mobile-settings-archived-empty-card'),
icon: Icons.inventory_2_outlined,
title: appText('归档任务', 'Archived Tasks'),
subtitle: appText('暂无归档任务。', 'No archived tasks.'),
children: const [],
);
}
return Column(
children: [
for (final session in sessions)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: MobileSettingsCardInternal(
icon: Icons.inventory_2_outlined,
title: session.label.trim().isEmpty
? appText('未命名任务', 'Untitled Task')
: session.label.trim(),
subtitle: session.lastMessagePreview?.trim() ?? '',
children: [
Row(
children: [
Expanded(
child: FilledButton.tonalIcon(
onPressed: () => controller.saveAssistantTaskArchived(
session.key,
false,
),
icon: const Icon(Icons.unarchive_outlined),
label: Text(appText('恢复', 'Restore')),
),
),
const SizedBox(width: 10),
Expanded(
child: TextButton.icon(
onPressed: () =>
controller.deleteArchivedAssistantTask(session.key),
icon: const Icon(Icons.delete_outline_rounded),
label: Text(appText('删除', 'Delete')),
),
),
],
),
],
),
),
],
);
}
}