From 22c49c2e372f9326dc8742ba9c65fe06d46f7765 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Mon, 25 May 2026 09:43:57 +0800 Subject: [PATCH] fix: stabilize iOS login storage and mobile settings --- lib/app/workspace_page_registry.dart | 6 +- lib/features/mobile/mobile_settings_page.dart | 634 ++++++++++++++++++ .../mobile/mobile_settings_page_widgets.dart | 185 +++++ lib/runtime/file_store_support.dart | 13 +- .../mobile/mobile_settings_page_test.dart | 92 +++ test/runtime/file_store_support_test.dart | 35 + 6 files changed, 958 insertions(+), 7 deletions(-) create mode 100644 lib/features/mobile/mobile_settings_page.dart create mode 100644 lib/features/mobile/mobile_settings_page_widgets.dart create mode 100644 test/features/mobile/mobile_settings_page_test.dart create mode 100644 test/runtime/file_store_support_test.dart diff --git a/lib/app/workspace_page_registry.dart b/lib/app/workspace_page_registry.dart index edefbcd0..59107450 100644 --- a/lib/app/workspace_page_registry.dart +++ b/lib/app/workspace_page_registry.dart @@ -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 workspacePageSpecsInternal = initialTab: controller.settingsTab, ), mobileBuilder: (controller, onOpenDetail, mobileActions) => - SettingsPage( - controller: controller, - initialTab: controller.settingsTab, - ), + MobileSettingsPage(controller: controller), ), }; diff --git a/lib/features/mobile/mobile_settings_page.dart b/lib/features/mobile/mobile_settings_page.dart new file mode 100644 index 00000000..53cd2d98 --- /dev/null +++ b/lib/features/mobile/mobile_settings_page.dart @@ -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 createState() => _MobileSettingsPageState(); +} + +class _MobileSettingsPageState extends State { + 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 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 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 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 syncAccount(SettingsSnapshot settings) async { + await persistAccountProfileSettings( + settings: settings, + isManualBridge: false, + ); + await widget.controller.settingsController.syncAccountSettings( + baseUrl: accountBaseUrlController.text.trim(), + ); + await refreshBridgeCapabilities(); + } + + Future 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 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 logoutAccount() async { + await widget.controller.settingsController.logoutAccount(); + accountPasswordController.clear(); + accountMfaCodeController.clear(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: Listenable.merge([ + 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 Function() onLogin; + final Future Function() onVerifyMfa; + final Future Function() onCancelMfa; + final Future Function() onSync; + final Future Function() onLogout; + final Future 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 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')), + ), + ), + ], + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/mobile/mobile_settings_page_widgets.dart b/lib/features/mobile/mobile_settings_page_widgets.dart new file mode 100644 index 00000000..3ba78e15 --- /dev/null +++ b/lib/features/mobile/mobile_settings_page_widgets.dart @@ -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 availableTabs; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return SegmentedButton( + key: const Key('mobile-settings-tab-selector'), + segments: [ + for (final tab in availableTabs) + ButtonSegment( + value: tab, + icon: Icon( + tab == SettingsTab.archivedTasks + ? Icons.inventory_2_outlined + : Icons.hub_outlined, + ), + label: Text(tab.label), + ), + ], + selected: {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 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? autofillHints; + final ValueChanged? 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), + ], + ), + ), + ], + ); + } +} diff --git a/lib/runtime/file_store_support.dart b/lib/runtime/file_store_support.dart index ed8b3b8d..95b81708 100644 --- a/lib/runtime/file_store_support.dart +++ b/lib/runtime/file_store_support.dart @@ -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 ensureDirectory(String path) async { } Future ensureOwnerOnlyDirectory(Directory directory) async { - if (Platform.isWindows) { + if (!shouldApplyUnixOwnerOnlyPermissionsInternal()) { return; } await _setUnixPermissions(directory.path, '700'); } Future 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('=', ''); } diff --git a/test/features/mobile/mobile_settings_page_test.dart b/test/features/mobile/mobile_settings_page_test.dart new file mode 100644 index 00000000..c230ec8a --- /dev/null +++ b/test/features/mobile/mobile_settings_page_test.dart @@ -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 {}, + ); + 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 {}, + ); + 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( + find.descendant( + of: find.byKey(const Key('mobile-settings-account-identifier-field')), + matching: find.byType(TextField), + ), + ); + final passwordField = tester.widget( + 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, + ); + }); + }); +} diff --git a/test/runtime/file_store_support_test.dart b/test/runtime/file_store_support_test.dart new file mode 100644 index 00000000..e6ceb23f --- /dev/null +++ b/test/runtime/file_store_support_test.dart @@ -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, + ); + }); + }); +}