refactor: share settings page section shell

This commit is contained in:
Haitao Pan 2026-03-30 09:13:59 +08:00
parent 8539eab3b7
commit f2ba2acbba
6 changed files with 384 additions and 235 deletions

View File

@ -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 tabs 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

View File

@ -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<SettingsPage> {
(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,
),
);
},

View File

@ -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 = <Widget>[];
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<Widget> 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),
);
}

View File

@ -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<WebSettingsPage> {
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>[
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>[
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,
),
),
);

View File

@ -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<SettingsTab> availableTabs,
SettingsTab currentTab,
) {
final orderedTabs = <SettingsTab>[
currentTab,
...availableTabs.where((item) => item != currentTab),
];
final sections = <Widget>[];
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,
);
}

View File

@ -0,0 +1,123 @@
import 'package:flutter/material.dart';
import '../models/app_models.dart';
import 'surface_card.dart';
import 'top_bar.dart';
List<Widget> buildOrderedSettingsSections({
required List<SettingsTab> availableTabs,
required SettingsTab currentTab,
required List<Widget> Function(SettingsTab tab) buildTabContent,
double gap = 24,
}) {
final orderedTabs = <SettingsTab>[
currentTab,
...availableTabs.where((item) => item != currentTab),
];
final sections = <Widget>[];
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<AppBreadcrumbItem> breadcrumbs;
final String title;
final String subtitle;
final Widget trailing;
final Widget? globalApplyBar;
final List<Widget> 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,
],
),
);
}
}