fix: stabilize iOS login storage and mobile settings
This commit is contained in:
parent
88fa597c8e
commit
22c49c2e37
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../features/assistant/assistant_page.dart';
|
||||
import '../features/mobile/mobile_assistant_page.dart';
|
||||
import '../features/mobile/mobile_settings_page.dart';
|
||||
import '../features/settings/settings_page.dart';
|
||||
import '../models/app_models.dart';
|
||||
import 'app_controller.dart';
|
||||
@ -64,10 +65,7 @@ final Map<WorkspaceDestination, WorkspacePageSpec> workspacePageSpecsInternal =
|
||||
initialTab: controller.settingsTab,
|
||||
),
|
||||
mobileBuilder: (controller, onOpenDetail, mobileActions) =>
|
||||
SettingsPage(
|
||||
controller: controller,
|
||||
initialTab: controller.settingsTab,
|
||||
),
|
||||
MobileSettingsPage(controller: controller),
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
634
lib/features/mobile/mobile_settings_page.dart
Normal file
634
lib/features/mobile/mobile_settings_page.dart
Normal file
@ -0,0 +1,634 @@
|
||||
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';
|
||||
|
||||
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,
|
||||
}) async {
|
||||
final bridgeConfig = settings.acpBridgeServerModeConfig;
|
||||
final nextBridgeConfig = bridgeConfig.copyWith(
|
||||
selfHosted: bridgeConfig.selfHosted.copyWith(
|
||||
serverUrl: bridgeUrlController.text.trim(),
|
||||
username: isManualBridge ? 'admin' : bridgeConfig.selfHosted.username,
|
||||
),
|
||||
);
|
||||
final nextEffective = widget.controller.settingsController
|
||||
.resolveAcpBridgeServerEffectiveConfig(config: nextBridgeConfig);
|
||||
final nextSettings = settings.copyWith(
|
||||
accountBaseUrl: accountBaseUrlController.text.trim(),
|
||||
accountUsername: accountIdentifierController.text.trim(),
|
||||
acpBridgeServerModeConfig: nextBridgeConfig.copyWith(
|
||||
effective: nextEffective,
|
||||
),
|
||||
);
|
||||
if (isManualBridge && bridgeTokenController.text.isNotEmpty) {
|
||||
await widget.controller.settingsController.saveSecretValueByRef(
|
||||
nextSettings.acpBridgeServerModeConfig.selfHosted.passwordRef,
|
||||
bridgeTokenController.text,
|
||||
provider: 'Bridge',
|
||||
module: 'Manual',
|
||||
);
|
||||
}
|
||||
await widget.controller.saveSettings(nextSettings);
|
||||
lastSavedAccountBaseUrl = nextSettings.accountBaseUrl;
|
||||
lastSavedAccountIdentifier = nextSettings.accountUsername;
|
||||
lastSavedBridgeUrl =
|
||||
nextSettings.acpBridgeServerModeConfig.selfHosted.serverUrl;
|
||||
}
|
||||
|
||||
Future<void> loginAccount(SettingsSnapshot settings) async {
|
||||
try {
|
||||
await persistAccountProfileSettings(
|
||||
settings: settings,
|
||||
isManualBridge: false,
|
||||
);
|
||||
await widget.controller.settingsController.loginAccount(
|
||||
baseUrl: accountBaseUrlController.text.trim(),
|
||||
identifier: accountIdentifierController.text.trim(),
|
||||
password: accountPasswordController.text,
|
||||
);
|
||||
await refreshBridgeCapabilities();
|
||||
} finally {
|
||||
accountPasswordController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> syncAccount(SettingsSnapshot settings) async {
|
||||
await persistAccountProfileSettings(
|
||||
settings: settings,
|
||||
isManualBridge: false,
|
||||
);
|
||||
await widget.controller.settingsController.syncAccountSettings(
|
||||
baseUrl: accountBaseUrlController.text.trim(),
|
||||
);
|
||||
await refreshBridgeCapabilities();
|
||||
}
|
||||
|
||||
Future<void> verifyMfa(SettingsSnapshot settings) async {
|
||||
try {
|
||||
await persistAccountProfileSettings(
|
||||
settings: settings,
|
||||
isManualBridge: false,
|
||||
);
|
||||
await widget.controller.settingsController.verifyAccountMfa(
|
||||
baseUrl: accountBaseUrlController.text.trim(),
|
||||
code: accountMfaCodeController.text.trim(),
|
||||
);
|
||||
await refreshBridgeCapabilities();
|
||||
} finally {
|
||||
accountMfaCodeController.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshBridgeCapabilities() async {
|
||||
final dynamic controller = widget.controller;
|
||||
try {
|
||||
await controller.refreshSingleAgentCapabilitiesInternal(
|
||||
forceRefresh: true,
|
||||
);
|
||||
} catch (_) {
|
||||
// Account login should not fail only because runtime refresh is transient.
|
||||
}
|
||||
try {
|
||||
await controller.refreshAcpCapabilitiesInternal(forceRefresh: true);
|
||||
} catch (_) {
|
||||
// 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: [
|
||||
Text(
|
||||
appText('设置', 'Settings'),
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
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
|
||||
_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: [
|
||||
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')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
185
lib/features/mobile/mobile_settings_page_widgets.dart
Normal file
185
lib/features/mobile/mobile_settings_page_widgets.dart
Normal file
@ -0,0 +1,185 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/app_models.dart';
|
||||
import '../../theme/app_palette.dart';
|
||||
|
||||
class MobileSettingsTabSelectorInternal extends StatelessWidget {
|
||||
const MobileSettingsTabSelectorInternal({
|
||||
super.key,
|
||||
required this.currentTab,
|
||||
required this.availableTabs,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final SettingsTab currentTab;
|
||||
final List<SettingsTab> availableTabs;
|
||||
final ValueChanged<SettingsTab> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SegmentedButton<SettingsTab>(
|
||||
key: const Key('mobile-settings-tab-selector'),
|
||||
segments: [
|
||||
for (final tab in availableTabs)
|
||||
ButtonSegment<SettingsTab>(
|
||||
value: tab,
|
||||
icon: Icon(
|
||||
tab == SettingsTab.archivedTasks
|
||||
? Icons.inventory_2_outlined
|
||||
: Icons.hub_outlined,
|
||||
),
|
||||
label: Text(tab.label),
|
||||
),
|
||||
],
|
||||
selected: <SettingsTab>{currentTab},
|
||||
onSelectionChanged: (selection) {
|
||||
if (selection.isNotEmpty) {
|
||||
onChanged(selection.first);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MobileSettingsCardInternal extends StatelessWidget {
|
||||
const MobileSettingsCardInternal({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: palette.surfacePrimary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: palette.strokeSoft),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: palette.accent, size: 24),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
if (subtitle.trim().isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: palette.textSecondary),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (children.isNotEmpty) ...[
|
||||
const SizedBox(height: 14),
|
||||
...children,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MobileSettingsTextFieldInternal extends StatelessWidget {
|
||||
const MobileSettingsTextFieldInternal({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
this.obscureText = false,
|
||||
this.keyboardType,
|
||||
this.textInputAction,
|
||||
this.autofillHints,
|
||||
this.onSubmitted,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final bool obscureText;
|
||||
final TextInputType? keyboardType;
|
||||
final TextInputAction? textInputAction;
|
||||
final Iterable<String>? autofillHints;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
obscureText: obscureText,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
autofillHints: autofillHints,
|
||||
autocorrect: false,
|
||||
enableSuggestions: !obscureText,
|
||||
decoration: InputDecoration(labelText: label, prefixIcon: Icon(icon)),
|
||||
onFieldSubmitted: onSubmitted,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MobileSettingsMetaRowInternal extends StatelessWidget {
|
||||
const MobileSettingsMetaRowInternal({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: palette.textSecondary),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelMedium?.copyWith(color: palette.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(value, style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -145,7 +145,9 @@ class StoreLayoutResolver {
|
||||
if (supportRootPath == null) {
|
||||
// Fallback to a temporary directory instead of failing fast with an error.
|
||||
// This ensures the app remains usable in "memory-only" or "ephemeral" mode.
|
||||
final tempDir = await Directory.systemTemp.createTemp('xworkmate-fallback-');
|
||||
final tempDir = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-fallback-',
|
||||
);
|
||||
final layout = StoreLayout(
|
||||
rootDirectory: tempDir,
|
||||
configDirectory: await ensureDirectory('${tempDir.path}/config'),
|
||||
@ -243,19 +245,24 @@ Future<Directory> ensureDirectory(String path) async {
|
||||
}
|
||||
|
||||
Future<void> ensureOwnerOnlyDirectory(Directory directory) async {
|
||||
if (Platform.isWindows) {
|
||||
if (!shouldApplyUnixOwnerOnlyPermissionsInternal()) {
|
||||
return;
|
||||
}
|
||||
await _setUnixPermissions(directory.path, '700');
|
||||
}
|
||||
|
||||
Future<void> ensureOwnerOnlyFile(File file) async {
|
||||
if (Platform.isWindows) {
|
||||
if (!shouldApplyUnixOwnerOnlyPermissionsInternal()) {
|
||||
return;
|
||||
}
|
||||
await _setUnixPermissions(file.path, '600');
|
||||
}
|
||||
|
||||
bool shouldApplyUnixOwnerOnlyPermissionsInternal({String? operatingSystem}) {
|
||||
final os = operatingSystem ?? Platform.operatingSystem;
|
||||
return os == 'linux' || os == 'macos';
|
||||
}
|
||||
|
||||
String encodeStableFileKey(String key) {
|
||||
return base64Url.encode(utf8.encode(key)).replaceAll('=', '');
|
||||
}
|
||||
|
||||
92
test/features/mobile/mobile_settings_page_test.dart
Normal file
92
test/features/mobile/mobile_settings_page_test.dart
Normal file
@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/app/app_shell_desktop.dart';
|
||||
import 'package:xworkmate/features/mobile/mobile_settings_page.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
group('MobileSettingsPage', () {
|
||||
testWidgets(
|
||||
'mobile shell renders mobile settings instead of desktop page',
|
||||
(tester) async {
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(430, 932);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
controller.openSettings();
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light().copyWith(platform: TargetPlatform.iOS),
|
||||
home: AppShell(controller: controller),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('mobile-settings-page')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('settings-account-panel-card')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.text('搜索设置'), findsNothing);
|
||||
expect(
|
||||
find.byKey(const Key('mobile-settings-account-login-card')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('mobile-settings-manual-bridge-card')),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('login form uses mobile-friendly input hints and controls', (
|
||||
tester,
|
||||
) async {
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light().copyWith(platform: TargetPlatform.iOS),
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(size: Size(390, 844)),
|
||||
child: Scaffold(body: MobileSettingsPage(controller: controller)),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final emailField = tester.widget<TextField>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('mobile-settings-account-identifier-field')),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
);
|
||||
final passwordField = tester.widget<TextField>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('mobile-settings-account-password-field')),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
);
|
||||
|
||||
expect(emailField.keyboardType, TextInputType.emailAddress);
|
||||
expect(emailField.textInputAction, TextInputAction.next);
|
||||
expect(passwordField.keyboardType, TextInputType.visiblePassword);
|
||||
expect(passwordField.textInputAction, TextInputAction.done);
|
||||
expect(passwordField.obscureText, isTrue);
|
||||
expect(
|
||||
find.byKey(const Key('mobile-settings-account-login-button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
35
test/runtime/file_store_support_test.dart
Normal file
35
test/runtime/file_store_support_test.dart
Normal file
@ -0,0 +1,35 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/runtime/file_store_support.dart';
|
||||
|
||||
void main() {
|
||||
group('file store owner-only permissions', () {
|
||||
test('uses chmod only on desktop Unix platforms', () {
|
||||
expect(
|
||||
shouldApplyUnixOwnerOnlyPermissionsInternal(operatingSystem: 'macos'),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
shouldApplyUnixOwnerOnlyPermissionsInternal(operatingSystem: 'linux'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not require chmod on mobile sandbox platforms', () {
|
||||
expect(
|
||||
shouldApplyUnixOwnerOnlyPermissionsInternal(operatingSystem: 'ios'),
|
||||
isFalse,
|
||||
);
|
||||
expect(
|
||||
shouldApplyUnixOwnerOnlyPermissionsInternal(operatingSystem: 'android'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not require chmod on Windows', () {
|
||||
expect(
|
||||
shouldApplyUnixOwnerOnlyPermissionsInternal(operatingSystem: 'windows'),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user