diff --git a/docs/plans/2026-03-30-settings-section-shell-implementation-plan.md b/docs/plans/2026-03-30-settings-section-shell-implementation-plan.md new file mode 100644 index 00000000..d9e7749d --- /dev/null +++ b/docs/plans/2026-03-30-settings-section-shell-implementation-plan.md @@ -0,0 +1,121 @@ +# Settings Section Shell Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Extract a shared Settings page shell so Desktop and Web reuse the same top-level layout, ordered section composition, and global apply bar behavior without changing the three-column workspace skeleton. + +**Architecture:** Introduce a small shared shell widget layer under `lib/widgets/` that owns the common settings-page frame and ordered section assembly. Keep Desktop and Web-specific controllers, gateway sub-tabs, and section content builders in their existing feature modules. + +**Tech Stack:** Flutter, Dart, existing `TopBar`/`SurfaceCard` widgets, Desktop/Web app controllers. + +--- + +### Task 1: Add a shared settings page shell module + +**Files:** +- Create: `lib/widgets/settings_page_shell.dart` + +**Step 1: Add a shared apply-bar widget** + +- Implement a widget that renders: + - title `设置提交流程 / Settings Submission` + - resolved draft/apply status copy + - `settings-global-apply-button` +- Pass behavior through parameters instead of depending on a specific controller type. + +**Step 2: Add a shared page body shell** + +- Implement a widget that renders: + - `TopBar` + - optional shared apply bar + - page body children +- Keep paddings configurable so Desktop and Web can preserve their current spacing. + +**Step 3: Add a shared ordered-section helper** + +- Implement a helper that: + - takes `availableTabs`, `currentTab` + - asks a callback for each tab’s content + - interleaves `SizedBox(height: 24)` between non-empty sections + +### Task 2: Move Desktop Settings page onto the shared shell + +**Files:** +- Modify: `lib/features/settings/settings_page_core.dart` +- Modify: `lib/features/settings/settings_page_sections.dart` + +**Step 1: Replace duplicated page frame markup** + +- Swap the current top-level `SingleChildScrollView -> Column -> TopBar -> apply bar -> body` block to the shared shell widget. + +**Step 2: Replace duplicated ordered overview assembly** + +- Route overview ordering through the shared helper instead of local inline assembly. + +**Step 3: Keep detail-mode behavior unchanged** + +- Ensure the existing desktop-only detail flow still owns: + - breadcrumbs + - back button + - detail intro cards + - gateway navigation hints + +### Task 3: Move Web Settings page onto the shared shell + +**Files:** +- Modify: `lib/web/web_settings_page_core.dart` +- Modify: `lib/web/web_settings_page_sections.dart` + +**Step 1: Reuse the shared page frame** + +- Keep `DesktopWorkspaceScaffold` in Web. +- Replace the inner duplicated page body with the shared shell widget. + +**Step 2: Reuse the ordered-section helper where still needed** + +- Keep Web tab availability constraints intact. +- Use the same helper for any ordered overview logic that remains. + +**Step 3: Preserve Web-specific gateway behavior** + +- Keep: + - web search field key + - browser persistence copy + - ACP-specific apply-bar gating + +### Task 4: Verify the refactor + +**Files:** +- Modify as needed: `test/features/settings_page_suite.dart` +- Modify as needed: `test/web/web_ui_browser_test.dart` + +**Step 1: Run static analysis** + +Run: + +```bash +flutter analyze lib/widgets/settings_page_shell.dart lib/features/settings/settings_page_core.dart lib/features/settings/settings_page_sections.dart lib/web/web_settings_page_core.dart lib/web/web_settings_page_sections.dart test/features/settings_page_suite.dart test/web/web_ui_browser_test.dart +``` + +**Step 2: Run targeted Desktop settings tests** + +Run: + +```bash +flutter test test/features/settings_page_suite.dart +``` + +**Step 3: Run targeted Web browser regression** + +Run: + +```bash +flutter test --platform chrome test/web/web_ui_browser_test.dart +``` + +**Step 4: Record residual risk** + +- Note whether any remaining Desktop/Web duplication still lives in: + - gateway sub-tab section builders + - detail-only desktop flows + - web-only persistence cards diff --git a/lib/features/settings/settings_page_core.dart b/lib/features/settings/settings_page_core.dart index d4b735ac..2d983c0f 100644 --- a/lib/features/settings/settings_page_core.dart +++ b/lib/features/settings/settings_page_core.dart @@ -14,6 +14,7 @@ import '../../runtime/runtime_controllers.dart'; import '../../runtime/runtime_models.dart'; import 'codex_integration_card.dart'; import 'skill_directory_authorization_card.dart'; +import '../../widgets/settings_page_shell.dart'; import '../../widgets/surface_card.dart'; import '../../widgets/top_bar.dart'; import 'settings_page_sections.dart'; @@ -218,62 +219,53 @@ class SettingsPageStateInternal extends State { (tabInternal != SettingsTab.gateway || integrationSubTabInternal == GatewayIntegrationSubTabInternal.acp); - return SingleChildScrollView( + return SettingsPageBodyShell( padding: const EdgeInsets.fromLTRB(32, 32, 32, 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TopBar( - breadcrumbs: buildSettingsBreadcrumbs( - controller, - tab: tabInternal, - detail: detailInternal, - navigationContext: navigationContextInternal, + breadcrumbs: buildSettingsBreadcrumbs( + controller, + tab: tabInternal, + detail: detailInternal, + navigationContext: navigationContextInternal, + ), + title: appText('设置', 'Settings'), + subtitle: showingDetail + ? appText( + '当前正在编辑详细设置参数,保存后会回写到对应状态页。', + 'You are editing detailed settings. Saved values flow back to the related status page.', + ) + : appText( + '配置 $kProductBrandName 工作区、网关默认项、界面与诊断选项', + 'Configure workspace, gateway defaults, appearance, and diagnostics for $kProductBrandName.', ), - title: appText('设置', 'Settings'), - subtitle: showingDetail - ? appText( - '当前正在编辑详细设置参数,保存后会回写到对应状态页。', - 'You are editing detailed settings. Saved values flow back to the related status page.', - ) - : appText( - '配置 $kProductBrandName 工作区、网关默认项、界面与诊断选项', - 'Configure workspace, gateway defaults, appearance, and diagnostics for $kProductBrandName.', - ), - trailing: SizedBox( - width: showingDetail ? 168 : 220, - child: showingDetail - ? OutlinedButton.icon( - onPressed: () { - controller.closeSettingsDetail(); - setState(() { - detailInternal = null; - navigationContextInternal = null; - }); - }, - icon: const Icon(Icons.arrow_back_rounded), - label: Text(appText('返回概览', 'Back to overview')), - ) - : TextField( - decoration: InputDecoration( - hintText: appText('搜索设置', 'Search settings'), - prefixIcon: Icon(Icons.search_rounded), - ), - ), - ), - ), - const SizedBox(height: 24), - if (showGlobalApplyBar) ...[ - buildGlobalApplyBarInternal(context, controller), - const SizedBox(height: 16), - ], - ...buildContentForCurrentStateInternal( - context, - controller, - settings, - uiFeatures, - ), - ], + trailing: SizedBox( + width: showingDetail ? 168 : 220, + child: showingDetail + ? OutlinedButton.icon( + onPressed: () { + controller.closeSettingsDetail(); + setState(() { + detailInternal = null; + navigationContextInternal = null; + }); + }, + icon: const Icon(Icons.arrow_back_rounded), + label: Text(appText('返回概览', 'Back to overview')), + ) + : TextField( + decoration: InputDecoration( + hintText: appText('搜索设置', 'Search settings'), + prefixIcon: Icon(Icons.search_rounded), + ), + ), + ), + globalApplyBar: showGlobalApplyBar + ? buildGlobalApplyBarInternal(context, controller) + : null, + bodyChildren: buildContentForCurrentStateInternal( + context, + controller, + settings, + uiFeatures, ), ); }, diff --git a/lib/features/settings/settings_page_sections.dart b/lib/features/settings/settings_page_sections.dart index 88f3eb02..e206219f 100644 --- a/lib/features/settings/settings_page_sections.dart +++ b/lib/features/settings/settings_page_sections.dart @@ -15,6 +15,7 @@ import '../../runtime/runtime_models.dart'; import 'codex_integration_card.dart'; import 'skill_directory_authorization_card.dart'; import '../../widgets/section_tabs.dart'; +import '../../widgets/settings_page_shell.dart'; import '../../widgets/surface_card.dart'; import '../../widgets/top_bar.dart'; import 'settings_page_core.dart'; @@ -46,10 +47,10 @@ extension SettingsPageSectionsMixinInternal on SettingsPageStateInternal { SettingsSnapshot settings, UiFeatureAccess uiFeatures, ) { - final orderedTabs = orderedOverviewTabsInternal(controller, uiFeatures); - final sections = []; - for (final tab in orderedTabs) { - final content = switch (tab) { + return buildOrderedSettingsSections( + availableTabs: uiFeatures.availableSettingsTabs, + currentTab: uiFeatures.sanitizeSettingsTab(controller.settingsTab), + buildTabContent: (tab) => switch (tab) { SettingsTab.general => buildGeneralInternal( context, controller, @@ -77,16 +78,8 @@ extension SettingsPageSectionsMixinInternal on SettingsPageStateInternal { uiFeatures, ), SettingsTab.about => buildAboutInternal(context, controller), - }; - if (content.isEmpty) { - continue; - } - if (sections.isNotEmpty) { - sections.add(const SizedBox(height: 24)); - } - sections.addAll(content); - } - return sections; + }, + ); } List buildContentForCurrentStateInternal( @@ -246,63 +239,31 @@ extension SettingsPageSectionsMixinInternal on SettingsPageStateInternal { BuildContext context, AppController controller, ) { - final theme = Theme.of(context); final hasDraft = controller.hasSettingsDraftChanges; final hasPendingApply = controller.hasPendingSettingsApply; final message = controller.settingsDraftStatusMessage; - return SurfaceCard( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - appText('设置提交流程', 'Settings Submission'), - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 6), - Text( - message.isNotEmpty - ? message - : hasDraft - ? appText( - '当前存在未保存草稿。保存并生效:按当前配置立即更新。', - 'There are unsaved drafts. Save & apply updates the current configuration immediately.', - ) - : hasPendingApply - ? appText( - '当前存在待生效更改。保存并生效:立即按当前配置更新。', - 'There are saved changes waiting to be applied. Save & apply updates the current configuration immediately.', - ) - : (message.isEmpty - ? appText( - '当前没有待提交更改。', - 'There are no pending settings changes.', - ) - : message), - style: theme.textTheme.bodyMedium, - ), - ], + return SettingsGlobalApplyCard( + title: appText('设置提交流程', 'Settings Submission'), + message: message.isNotEmpty + ? message + : hasDraft + ? appText( + '当前存在未保存草稿。保存并生效:按当前配置立即更新。', + 'There are unsaved drafts. Save & apply updates the current configuration immediately.', + ) + : hasPendingApply + ? appText( + '当前存在待生效更改。保存并生效:立即按当前配置更新。', + 'There are saved changes waiting to be applied. Save & apply updates the current configuration immediately.', + ) + : appText( + '当前没有待提交更改。', + 'There are no pending settings changes.', ), - ), - const SizedBox(width: 16), - Wrap( - spacing: 10, - runSpacing: 10, - children: [ - FilledButton.tonal( - key: const ValueKey('settings-global-apply-button'), - onPressed: (!hasDraft && !hasPendingApply) - ? null - : () => handleTopLevelApplyInternal(controller), - child: Text(appText('保存并生效', 'Save & apply')), - ), - ], - ), - ], - ), + applyLabel: appText('保存并生效', 'Save & apply'), + onApply: (!hasDraft && !hasPendingApply) + ? null + : () => handleTopLevelApplyInternal(controller), ); } diff --git a/lib/web/web_settings_page_core.dart b/lib/web/web_settings_page_core.dart index b3cd7b3c..f309f4fe 100644 --- a/lib/web/web_settings_page_core.dart +++ b/lib/web/web_settings_page_core.dart @@ -10,6 +10,7 @@ import '../models/app_models.dart'; import '../runtime/runtime_models.dart'; import '../theme/app_palette.dart'; import '../widgets/desktop_workspace_scaffold.dart'; +import '../widgets/settings_page_shell.dart'; import '../widgets/surface_card.dart'; import '../widgets/top_bar.dart'; import 'web_settings_page_sections.dart'; @@ -249,52 +250,43 @@ class WebSettingsPageStateInternal extends State { currentTab != SettingsTab.gateway || gatewaySubTabInternal == WebGatewaySettingsSubTabInternal.acp; return DesktopWorkspaceScaffold( - child: SingleChildScrollView( + child: SettingsPageBodyShell( padding: const EdgeInsets.fromLTRB(24, 24, 24, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TopBar( - breadcrumbs: [ - AppBreadcrumbItem( - label: appText('主页', 'Home'), - icon: Icons.home_rounded, - onTap: controller.navigateHome, - ), - AppBreadcrumbItem( - label: appText('设置', 'Settings'), - onTap: () => controller.openSettings(tab: currentTab), - ), - AppBreadcrumbItem(label: currentTab.label), - ], - title: appText('设置', 'Settings'), - subtitle: appText( - '配置 XWorkmate Web 工作区、网关默认项、界面与诊断选项', - 'Configure workspace, gateway defaults, appearance, and diagnostics for XWorkmate Web.', - ), - trailing: SizedBox( - width: 260, - child: TextField( - key: const ValueKey('web-settings-search-field'), - decoration: InputDecoration( - hintText: appText('搜索设置', 'Search settings'), - prefixIcon: const Icon(Icons.search_rounded), - ), - ), - ), + breadcrumbs: [ + AppBreadcrumbItem( + label: appText('主页', 'Home'), + icon: Icons.home_rounded, + onTap: controller.navigateHome, + ), + AppBreadcrumbItem( + label: appText('设置', 'Settings'), + onTap: () => controller.openSettings(tab: currentTab), + ), + AppBreadcrumbItem(label: currentTab.label), + ], + title: appText('设置', 'Settings'), + subtitle: appText( + '配置 XWorkmate Web 工作区、网关默认项、界面与诊断选项', + 'Configure workspace, gateway defaults, appearance, and diagnostics for XWorkmate Web.', + ), + trailing: SizedBox( + width: 260, + child: TextField( + key: const ValueKey('web-settings-search-field'), + decoration: InputDecoration( + hintText: appText('搜索设置', 'Search settings'), + prefixIcon: const Icon(Icons.search_rounded), ), - const SizedBox(height: 24), - if (showGlobalApplyBar) ...[ - buildGlobalApplyBarInternal(context, controller), - const SizedBox(height: 16), - ], - ...buildTabContentInternal( - context, - controller, - controller.settingsDraft, - currentTab, - ), - ], + ), + ), + globalApplyBar: showGlobalApplyBar + ? buildGlobalApplyBarInternal(context, controller) + : null, + bodyChildren: buildTabContentInternal( + context, + controller, + controller.settingsDraft, + currentTab, ), ), ); diff --git a/lib/web/web_settings_page_sections.dart b/lib/web/web_settings_page_sections.dart index fa96bc46..3c075ee7 100644 --- a/lib/web/web_settings_page_sections.dart +++ b/lib/web/web_settings_page_sections.dart @@ -11,6 +11,7 @@ import '../runtime/runtime_models.dart'; import '../theme/app_palette.dart'; import '../widgets/desktop_workspace_scaffold.dart'; import '../widgets/section_tabs.dart'; +import '../widgets/settings_page_shell.dart'; import '../widgets/surface_card.dart'; import '../widgets/top_bar.dart'; import 'web_settings_page_core.dart'; @@ -39,86 +40,45 @@ extension WebSettingsPageSectionsMixinInternal on WebSettingsPageStateInternal { List availableTabs, SettingsTab currentTab, ) { - final orderedTabs = [ - currentTab, - ...availableTabs.where((item) => item != currentTab), - ]; - final sections = []; - for (final tab in orderedTabs) { - final content = buildTabContentInternal(context, controller, settings, tab); - if (content.isEmpty) { - continue; - } - if (sections.isNotEmpty) { - sections.add(const SizedBox(height: 24)); - } - sections.addAll(content); - } - return sections; + return buildOrderedSettingsSections( + availableTabs: availableTabs, + currentTab: currentTab, + buildTabContent: (tab) => + buildTabContentInternal(context, controller, settings, tab), + ); } Widget buildGlobalApplyBarInternal( BuildContext context, AppController controller, ) { - final theme = Theme.of(context); final hasDraft = controller.hasSettingsDraftChanges; final hasPendingApply = controller.hasPendingSettingsApply; final message = controller.settingsDraftStatusMessage; - return SurfaceCard( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - appText('设置提交流程', 'Settings Submission'), - style: theme.textTheme.titleMedium, - ), - const SizedBox(height: 6), - Text( - message.isNotEmpty - ? message - : hasDraft - ? appText( - '当前存在未保存草稿。保存并生效:按当前配置立即更新。', - 'There are unsaved drafts. Save & apply updates the current configuration immediately.', - ) - : hasPendingApply - ? appText( - '当前存在待生效更改。保存并生效:立即按当前配置更新。', - 'There are saved changes waiting to be applied. Save & apply updates the current configuration immediately.', - ) - : appText( - '当前没有待提交更改。', - 'There are no pending settings changes.', - ), - ), - ], + return SettingsGlobalApplyCard( + title: appText('设置提交流程', 'Settings Submission'), + message: message.isNotEmpty + ? message + : hasDraft + ? appText( + '当前存在未保存草稿。保存并生效:按当前配置立即更新。', + 'There are unsaved drafts. Save & apply updates the current configuration immediately.', + ) + : hasPendingApply + ? appText( + '当前存在待生效更改。保存并生效:立即按当前配置更新。', + 'There are saved changes waiting to be applied. Save & apply updates the current configuration immediately.', + ) + : appText( + '当前没有待提交更改。', + 'There are no pending settings changes.', ), - ), - const SizedBox(width: 16), - Wrap( - spacing: 10, - runSpacing: 10, - children: [ - FilledButton.tonal( - key: const ValueKey('settings-global-apply-button'), - onPressed: - (hasDraft || - hasPendingApply || - gatewaySubTabInternal == - WebGatewaySettingsSubTabInternal.acp) - ? () => handleTopLevelApplyInternal(controller) - : null, - child: Text(appText('保存并生效', 'Save & apply')), - ), - ], - ), - ], - ), + applyLabel: appText('保存并生效', 'Save & apply'), + onApply: (hasDraft || + hasPendingApply || + gatewaySubTabInternal == WebGatewaySettingsSubTabInternal.acp) + ? () => handleTopLevelApplyInternal(controller) + : null, ); } diff --git a/lib/widgets/settings_page_shell.dart b/lib/widgets/settings_page_shell.dart new file mode 100644 index 00000000..dae6b7b2 --- /dev/null +++ b/lib/widgets/settings_page_shell.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; + +import '../models/app_models.dart'; +import 'surface_card.dart'; +import 'top_bar.dart'; + +List buildOrderedSettingsSections({ + required List availableTabs, + required SettingsTab currentTab, + required List Function(SettingsTab tab) buildTabContent, + double gap = 24, +}) { + final orderedTabs = [ + currentTab, + ...availableTabs.where((item) => item != currentTab), + ]; + final sections = []; + for (final tab in orderedTabs) { + final content = buildTabContent(tab); + if (content.isEmpty) { + continue; + } + if (sections.isNotEmpty) { + sections.add(SizedBox(height: gap)); + } + sections.addAll(content); + } + return sections; +} + +class SettingsGlobalApplyCard extends StatelessWidget { + const SettingsGlobalApplyCard({ + super.key, + required this.message, + required this.onApply, + this.applyLabel = 'Save & apply', + this.title = 'Settings Submission', + }); + + final String title; + final String message; + final String applyLabel; + final VoidCallback? onApply; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SurfaceCard( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleMedium), + const SizedBox(height: 6), + Text(message, style: theme.textTheme.bodyMedium), + ], + ), + ), + const SizedBox(width: 16), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + FilledButton.tonal( + key: const ValueKey('settings-global-apply-button'), + onPressed: onApply, + child: Text(applyLabel), + ), + ], + ), + ], + ), + ); + } +} + +class SettingsPageBodyShell extends StatelessWidget { + const SettingsPageBodyShell({ + super.key, + required this.padding, + required this.breadcrumbs, + required this.title, + required this.subtitle, + required this.trailing, + required this.bodyChildren, + this.globalApplyBar, + }); + + final EdgeInsetsGeometry padding; + final List breadcrumbs; + final String title; + final String subtitle; + final Widget trailing; + final Widget? globalApplyBar; + final List bodyChildren; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TopBar( + breadcrumbs: breadcrumbs, + title: title, + subtitle: subtitle, + trailing: trailing, + ), + const SizedBox(height: 24), + if (globalApplyBar != null) ...[ + globalApplyBar!, + const SizedBox(height: 16), + ], + ...bodyChildren, + ], + ), + ); + } +}