Merge branch 'codex/rewrite-task-dialog-modes'
This commit is contained in:
commit
5d992f884a
10
Makefile
10
Makefile
@ -13,8 +13,12 @@ PUBSPEC_BUILD_ID := $(shell sed -n 's/^build-id:[[:space:]]*//p' pubspec.yaml |
|
||||
APP_VERSION := $(firstword $(subst +, ,$(PUBSPEC_VERSION_LINE)))
|
||||
APP_BUILD_NUMBER_RAW := $(word 2,$(subst +, ,$(PUBSPEC_VERSION_LINE)))
|
||||
APP_BUILD_NUMBER := $(if $(APP_BUILD_NUMBER_RAW),$(APP_BUILD_NUMBER_RAW),1)
|
||||
APP_BUILD_DATE := $(if $(PUBSPEC_BUILD_DATE),$(PUBSPEC_BUILD_DATE),unknown)
|
||||
APP_BUILD_COMMIT := $(if $(PUBSPEC_BUILD_ID),$(PUBSPEC_BUILD_ID),unknown)
|
||||
APP_DART_DEFINE_VERSION ?= --dart-define=XWORKMATE_DISPLAY_VERSION=$(APP_VERSION)
|
||||
APP_DART_DEFINE_BUILD ?= --dart-define=XWORKMATE_BUILD_NUMBER=$(APP_BUILD_NUMBER)
|
||||
APP_DART_DEFINE_BUILD_DATE ?= --dart-define=XWORKMATE_BUILD_DATE=$(APP_BUILD_DATE)
|
||||
APP_DART_DEFINE_BUILD_COMMIT ?= --dart-define=XWORKMATE_BUILD_COMMIT=$(APP_BUILD_COMMIT)
|
||||
|
||||
.PHONY: help deps analyze test test-all test-flutter test-golden test-integration test-integration-macos test-patrol test-go test-ci check format run open-macos-xcode sync-version build-linux build-macos build-ios-sim package-deb package-rpm package-linux package-mac install-mac clean build-go-core render-release-docs check-export-compliance test-real-env-login-chain inspect-xworkmate-bridge-service
|
||||
|
||||
@ -81,14 +85,14 @@ open-macos-xcode: ## Open the supported macOS Xcode workspace entrypoint
|
||||
open macos/Runner.xcworkspace
|
||||
|
||||
build-linux: ## Build the Linux app in release mode
|
||||
$(FLUTTER) build linux --release
|
||||
$(FLUTTER) build linux --release --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD) $(APP_DART_DEFINE_BUILD_DATE) $(APP_DART_DEFINE_BUILD_COMMIT)
|
||||
|
||||
build-macos: ## Build the macOS app in release mode
|
||||
$(FLUTTER) build macos --release $(APP_STORE_DART_DEFINE) --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD)
|
||||
$(FLUTTER) build macos --release $(APP_STORE_DART_DEFINE) --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD) $(APP_DART_DEFINE_BUILD_DATE) $(APP_DART_DEFINE_BUILD_COMMIT)
|
||||
bash scripts/check-apple-export-compliance.sh build/macos/Build/Products/Release/XWorkmate.app
|
||||
|
||||
build-ios-sim: ## Build the iOS app for the simulator
|
||||
$(FLUTTER) build ios --simulator $(APP_STORE_DART_DEFINE) --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD)
|
||||
$(FLUTTER) build ios --simulator $(APP_STORE_DART_DEFINE) --build-name=$(APP_VERSION) --build-number=$(APP_BUILD_NUMBER) $(APP_DART_DEFINE_VERSION) $(APP_DART_DEFINE_BUILD) $(APP_DART_DEFINE_BUILD_DATE) $(APP_DART_DEFINE_BUILD_COMMIT)
|
||||
bash scripts/check-apple-export-compliance.sh build/ios/iphonesimulator/Runner.app
|
||||
|
||||
build-go-core: ## Build the external ACP bridge helper from xworkmate-bridge
|
||||
|
||||
@ -79,6 +79,10 @@ flowchart TD
|
||||
- `acp.capabilities.providerCatalog` 是 assistant provider picker 的唯一上游真源
|
||||
- 持久化在线程上的 `providerId` 只表示用户历史选择,不负责反向生成 catalog
|
||||
- provider unavailable 文案与 resolved provider 都来自 `xworkmate.routing.resolve`
|
||||
- 任务对话模式的 provider 菜单按 execution target 分流:
|
||||
- `agent` 只展示 bridge-owned provider catalog,即 `codex / opencode / gemini`
|
||||
- `gateway` 只展示 canonical gateway provider,即 `OpenClaw`
|
||||
- 这里不保留旧的 provider matrix、preset fallback 或双真源选择路径
|
||||
|
||||
### Gateway Truth
|
||||
|
||||
|
||||
@ -149,6 +149,7 @@ Status: `Active`
|
||||
当前 Assistant 事实:
|
||||
|
||||
- provider catalog 只来自 bridge capabilities,不再恢复任何 preset / backfill / fallback provider truth
|
||||
- 任务对话模式按 execution target 分流:`智能体` 只提供 `codex / opencode / gemini`,`Gateway` 只提供 `OpenClaw`
|
||||
- task state 仍在 assistant 内被消费,但不再拥有独立 `TasksPage`
|
||||
- skills 数据仍在 assistant 内被消费,但不再拥有独立 `SkillsPage`
|
||||
- assistant focus 只保留仍有真实落点的 `settings / language / theme`
|
||||
|
||||
@ -17,6 +17,7 @@ and the two key client-side parsing assertions:
|
||||
|
||||
- `BRIDGE_SERVER_URL` may be retained in account sync metadata, but does not drive runtime endpoint selection
|
||||
- `BRIDGE_AUTH_TOKEN` is written into secure storage
|
||||
- account sync no longer parses `INTERNAL_SERVICE_TOKEN` as a bridge token fallback
|
||||
|
||||
## Sync Chain
|
||||
|
||||
@ -81,5 +82,6 @@ flowchart TD
|
||||
- The app-facing managed bridge origin is fixed to `https://xworkmate-bridge.svc.plus`.
|
||||
- `BRIDGE_SERVER_URL`, when present, is metadata only.
|
||||
- `BRIDGE_AUTH_TOKEN` is the only bridge token field used by the sync contract.
|
||||
- `INTERNAL_SERVICE_TOKEN` is not part of the app-side account sync token contract.
|
||||
- `BRIDGE_AUTH_TOKEN` must never be written into normal settings snapshot, profile JSON, or UI-visible text.
|
||||
- Client requests must assemble the header as `Authorization: Bearer <token>`.
|
||||
|
||||
@ -678,7 +678,6 @@ extension AppControllerDesktopRuntimeHelpers on AppController {
|
||||
if (matchesBridgeEndpoint) {
|
||||
final bridgeToken =
|
||||
runtimeEnvironmentValueInternal('BRIDGE_AUTH_TOKEN') ??
|
||||
runtimeEnvironmentValueInternal('INTERNAL_SERVICE_TOKEN') ??
|
||||
(await storeInternal.loadAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
))?.trim();
|
||||
|
||||
@ -11,4 +11,12 @@ const kAppBuildNumber = String.fromEnvironment(
|
||||
'XWORKMATE_BUILD_NUMBER',
|
||||
defaultValue: '20260311',
|
||||
);
|
||||
const kAppBuildDate = String.fromEnvironment(
|
||||
'XWORKMATE_BUILD_DATE',
|
||||
defaultValue: '2026-03-28',
|
||||
);
|
||||
const kAppBuildCommit = String.fromEnvironment(
|
||||
'XWORKMATE_BUILD_COMMIT',
|
||||
defaultValue: 'f153d7b',
|
||||
);
|
||||
const kAppVersionLabel = 'Version $kAppVersion';
|
||||
|
||||
@ -35,6 +35,7 @@ import 'assistant_page_composer_skill_models.dart';
|
||||
import 'assistant_page_composer_skill_picker.dart';
|
||||
import 'assistant_page_composer_clipboard.dart';
|
||||
import 'assistant_page_components_core.dart';
|
||||
import 'assistant_page_task_dialog_controls.dart';
|
||||
|
||||
class ComposerBarInternal extends StatefulWidget {
|
||||
const ComposerBarInternal({
|
||||
@ -310,34 +311,7 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
|
||||
final uiFeatures = controller.featuresFor(
|
||||
resolveUiFeaturePlatformFromContext(context),
|
||||
);
|
||||
final visibleExecutionTargets = controller.visibleAssistantExecutionTargets(
|
||||
uiFeatures.availableExecutionTargets,
|
||||
);
|
||||
final currentExecutionTarget = controller.assistantExecutionTarget;
|
||||
final executionTarget =
|
||||
visibleExecutionTargets.contains(currentExecutionTarget)
|
||||
? currentExecutionTarget
|
||||
: (visibleExecutionTargets.isNotEmpty
|
||||
? visibleExecutionTargets.first
|
||||
: currentExecutionTarget);
|
||||
final compactExecutionTargets = compactAssistantExecutionTargets(
|
||||
visibleExecutionTargets,
|
||||
);
|
||||
final compactExecutionTarget = collapseAssistantExecutionTargetForDisplay(
|
||||
executionTarget,
|
||||
);
|
||||
final permissionLevel = controller.assistantPermissionLevel;
|
||||
final availableProviders = controller.assistantProviderCatalog;
|
||||
final selectedProvider = controller.assistantProviderForSession(
|
||||
controller.currentSessionKey,
|
||||
);
|
||||
final providerMenuProviders = switch (executionTarget) {
|
||||
AssistantExecutionTarget.gateway =>
|
||||
selectedProvider.isUnspecified
|
||||
? const <SingleAgentProvider>[]
|
||||
: <SingleAgentProvider>[selectedProvider],
|
||||
AssistantExecutionTarget.agent => availableProviders,
|
||||
};
|
||||
final selectedSkills = widget.availableSkills
|
||||
.where((skill) => widget.selectedSkillKeys.contains(skill.key))
|
||||
.toList(growable: false);
|
||||
@ -382,110 +356,8 @@ class ComposerBarStateInternal extends State<ComposerBarInternal> {
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
if (compactExecutionTargets.isNotEmpty) ...[
|
||||
PopupMenuButton<AssistantExecutionTarget>(
|
||||
key: const Key('assistant-execution-target-button'),
|
||||
tooltip: appText('任务对话模式', 'Task Dialog Mode'),
|
||||
onSelected: (value) async {
|
||||
final resolvedTarget =
|
||||
resolveAssistantExecutionTargetFromVisibleTargets(
|
||||
visibleExecutionTargets,
|
||||
currentTarget: value,
|
||||
);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
resolvedTarget,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => compactExecutionTargets
|
||||
.map(
|
||||
(value) => PopupMenuItem<AssistantExecutionTarget>(
|
||||
value: value,
|
||||
key: Key(
|
||||
'assistant-execution-target-menu-item-${value.name}',
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(value.icon, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(value.compactLabel)),
|
||||
if (value == compactExecutionTarget)
|
||||
const Icon(Icons.check_rounded, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: ComposerToolbarChipInternal(
|
||||
icon: compactExecutionTarget.icon,
|
||||
tooltip: executionTargetTooltipInternal(
|
||||
compactExecutionTarget,
|
||||
),
|
||||
showChevron: true,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
if (providerMenuProviders.isNotEmpty) ...[
|
||||
PopupMenuButton<String>(
|
||||
key: const Key('assistant-provider-button'),
|
||||
tooltip: appText('智能体 Provider', 'Agent Provider'),
|
||||
onSelected: (providerId) async {
|
||||
if (executionTarget.isGateway) {
|
||||
return;
|
||||
}
|
||||
await controller.setAssistantSingleAgentProvider(
|
||||
controller.resolveAssistantProvider(providerId),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => providerMenuProviders
|
||||
.map(
|
||||
(provider) => PopupMenuItem<String>(
|
||||
value: provider.providerId,
|
||||
key: Key(
|
||||
'assistant-provider-menu-item-${provider.providerId}',
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SingleAgentProviderBadgeInternal(
|
||||
key: Key(
|
||||
'assistant-provider-menu-badge-${provider.providerId}',
|
||||
),
|
||||
provider: provider,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(provider.label)),
|
||||
if (provider == selectedProvider)
|
||||
const Icon(Icons.check_rounded, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
child: ComposerToolbarChipInternal(
|
||||
leading: SingleAgentProviderBadgeInternal(
|
||||
key: const Key('assistant-provider-badge'),
|
||||
provider: selectedProvider,
|
||||
),
|
||||
tooltip: providerTooltipInternal(selectedProvider),
|
||||
showChevron: true,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
AssistantTaskDialogModeControlsInternal(controller: controller),
|
||||
const SizedBox(width: 4),
|
||||
if (widget.showModelControl) ...[
|
||||
widget.modelOptions.isEmpty
|
||||
? ComposerToolbarChipInternal(
|
||||
|
||||
254
lib/features/assistant/assistant_page_task_dialog_controls.dart
Normal file
254
lib/features/assistant/assistant_page_task_dialog_controls.dart
Normal file
@ -0,0 +1,254 @@
|
||||
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 '../../runtime/runtime_models.dart';
|
||||
import '../../theme/app_palette.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import 'assistant_page_composer_support.dart';
|
||||
|
||||
class AssistantTaskDialogModeControlsInternal extends StatelessWidget {
|
||||
const AssistantTaskDialogModeControlsInternal({
|
||||
super.key,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final AppController controller;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final uiFeatures = controller.featuresFor(
|
||||
resolveUiFeaturePlatformFromContext(context),
|
||||
);
|
||||
final visibleExecutionTargets = controller.visibleAssistantExecutionTargets(
|
||||
uiFeatures.availableExecutionTargets,
|
||||
);
|
||||
if (visibleExecutionTargets.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final currentExecutionTarget =
|
||||
resolveAssistantExecutionTargetFromVisibleTargets(
|
||||
visibleExecutionTargets,
|
||||
currentTarget: controller.assistantExecutionTarget,
|
||||
);
|
||||
final executionTarget = collapseAssistantExecutionTargetForDisplay(
|
||||
currentExecutionTarget,
|
||||
);
|
||||
final providerMenuProviders = _taskDialogProviderCatalogForTarget(
|
||||
controller: controller,
|
||||
executionTarget: executionTarget,
|
||||
);
|
||||
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_TaskDialogExecutionTargetMenuButtonInternal(
|
||||
controller: controller,
|
||||
executionTarget: executionTarget,
|
||||
visibleExecutionTargets: visibleExecutionTargets,
|
||||
),
|
||||
if (providerMenuProviders.isNotEmpty)
|
||||
_TaskDialogProviderMenuButtonInternal(
|
||||
controller: controller,
|
||||
executionTarget: executionTarget,
|
||||
selectedProvider: controller.assistantProviderForSession(
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
providers: providerMenuProviders,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<SingleAgentProvider> _taskDialogProviderCatalogForTarget({
|
||||
required AppController controller,
|
||||
required AssistantExecutionTarget executionTarget,
|
||||
}) {
|
||||
if (executionTarget.isGateway) {
|
||||
return <SingleAgentProvider>[
|
||||
controller.assistantProviderForSession(controller.currentSessionKey),
|
||||
];
|
||||
}
|
||||
return controller.assistantProviderCatalog;
|
||||
}
|
||||
|
||||
class _TaskDialogExecutionTargetMenuButtonInternal extends StatelessWidget {
|
||||
const _TaskDialogExecutionTargetMenuButtonInternal({
|
||||
required this.controller,
|
||||
required this.executionTarget,
|
||||
required this.visibleExecutionTargets,
|
||||
});
|
||||
|
||||
final AppController controller;
|
||||
final AssistantExecutionTarget executionTarget;
|
||||
final List<AssistantExecutionTarget> visibleExecutionTargets;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final compactExecutionTargets = compactAssistantExecutionTargets(
|
||||
visibleExecutionTargets,
|
||||
);
|
||||
final palette = context.palette;
|
||||
final selectedLabel = executionTarget.label;
|
||||
|
||||
return PopupMenuButton<AssistantExecutionTarget>(
|
||||
key: const Key('assistant-execution-target-button'),
|
||||
tooltip: appText('任务对话模式', 'Task Dialog Mode'),
|
||||
onSelected: (value) {
|
||||
unawaited(_handleExecutionTargetSelected(value));
|
||||
},
|
||||
itemBuilder: (context) => compactExecutionTargets
|
||||
.map(
|
||||
(value) => PopupMenuItem<AssistantExecutionTarget>(
|
||||
value: value,
|
||||
key: Key('assistant-execution-target-menu-item-${value.name}'),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(value.icon, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(value.label)),
|
||||
if (value == executionTarget)
|
||||
const Icon(Icons.check_rounded, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
child: _TaskDialogSelectorChipInternal(
|
||||
leading: Icon(executionTarget.icon, size: 14, color: palette.textMuted),
|
||||
label: selectedLabel,
|
||||
tooltip: appText('任务对话模式', 'Task Dialog Mode'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleExecutionTargetSelected(
|
||||
AssistantExecutionTarget value,
|
||||
) async {
|
||||
final resolvedTarget = resolveAssistantExecutionTargetFromVisibleTargets(
|
||||
visibleExecutionTargets,
|
||||
currentTarget: value,
|
||||
);
|
||||
await controller.setAssistantExecutionTarget(resolvedTarget);
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskDialogProviderMenuButtonInternal extends StatelessWidget {
|
||||
const _TaskDialogProviderMenuButtonInternal({
|
||||
required this.controller,
|
||||
required this.executionTarget,
|
||||
required this.selectedProvider,
|
||||
required this.providers,
|
||||
});
|
||||
|
||||
final AppController controller;
|
||||
final AssistantExecutionTarget executionTarget;
|
||||
final SingleAgentProvider selectedProvider;
|
||||
final List<SingleAgentProvider> providers;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final displayProvider = selectedProvider.isUnspecified
|
||||
? SingleAgentProvider.openclaw
|
||||
: selectedProvider;
|
||||
|
||||
return PopupMenuButton<SingleAgentProvider>(
|
||||
key: const Key('assistant-provider-button'),
|
||||
tooltip: appText('智能体 Provider', 'Agent Provider'),
|
||||
onSelected: (provider) {
|
||||
unawaited(_handleProviderSelected(provider));
|
||||
},
|
||||
itemBuilder: (context) => providers
|
||||
.map(
|
||||
(provider) => PopupMenuItem<SingleAgentProvider>(
|
||||
value: provider,
|
||||
key: Key('assistant-provider-menu-item-${provider.providerId}'),
|
||||
child: Row(
|
||||
children: [
|
||||
SingleAgentProviderBadgeInternal(
|
||||
key: Key(
|
||||
'assistant-provider-menu-badge-${provider.providerId}',
|
||||
),
|
||||
provider: provider,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(provider.label)),
|
||||
if (provider == displayProvider)
|
||||
const Icon(Icons.check_rounded, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
child: _TaskDialogSelectorChipInternal(
|
||||
leading: SingleAgentProviderBadgeInternal(
|
||||
key: const Key('assistant-provider-badge'),
|
||||
provider: displayProvider,
|
||||
),
|
||||
label: displayProvider.label,
|
||||
tooltip: appText('智能体 Provider', 'Agent Provider'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleProviderSelected(SingleAgentProvider provider) async {
|
||||
if (executionTarget.isGateway) {
|
||||
return;
|
||||
}
|
||||
await controller.setAssistantSingleAgentProvider(provider);
|
||||
}
|
||||
}
|
||||
|
||||
class _TaskDialogSelectorChipInternal extends StatelessWidget {
|
||||
const _TaskDialogSelectorChipInternal({
|
||||
required this.leading,
|
||||
required this.label,
|
||||
required this.tooltip,
|
||||
});
|
||||
|
||||
final Widget leading;
|
||||
final String label;
|
||||
final String tooltip;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final palette = context.palette;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xs,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: palette.surfaceSecondary,
|
||||
borderRadius: BorderRadius.circular(AppRadius.chip),
|
||||
border: Border.all(color: palette.strokeSoft),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
leading,
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: theme.textTheme.labelMedium),
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
Icons.keyboard_arrow_down_rounded,
|
||||
size: 14,
|
||||
color: palette.textMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
189
lib/features/settings/settings_about_panel.dart
Normal file
189
lib/features/settings/settings_about_panel.dart
Normal file
@ -0,0 +1,189 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../i18n/app_language.dart';
|
||||
|
||||
@immutable
|
||||
class SettingsAboutSnapshot {
|
||||
const SettingsAboutSnapshot({
|
||||
required this.appVersion,
|
||||
required this.appBuildNumber,
|
||||
required this.appBuildDate,
|
||||
required this.appCommit,
|
||||
required this.bridgeEndpoint,
|
||||
required this.bridgeStatus,
|
||||
required this.bridgeVersion,
|
||||
required this.bridgeBuildDate,
|
||||
required this.bridgeCommit,
|
||||
required this.bridgeImage,
|
||||
});
|
||||
|
||||
final String appVersion;
|
||||
final String appBuildNumber;
|
||||
final String appBuildDate;
|
||||
final String appCommit;
|
||||
final String bridgeEndpoint;
|
||||
final String bridgeStatus;
|
||||
final String bridgeVersion;
|
||||
final String bridgeBuildDate;
|
||||
final String bridgeCommit;
|
||||
final String bridgeImage;
|
||||
|
||||
const SettingsAboutSnapshot.defaults()
|
||||
: appVersion = '',
|
||||
appBuildNumber = '',
|
||||
appBuildDate = '',
|
||||
appCommit = '',
|
||||
bridgeEndpoint = '',
|
||||
bridgeStatus = '',
|
||||
bridgeVersion = '',
|
||||
bridgeBuildDate = '',
|
||||
bridgeCommit = '',
|
||||
bridgeImage = '';
|
||||
}
|
||||
|
||||
class SettingsAboutPanel extends StatelessWidget {
|
||||
const SettingsAboutPanel({
|
||||
super.key,
|
||||
required this.snapshot,
|
||||
required this.busy,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
final SettingsAboutSnapshot snapshot;
|
||||
final bool busy;
|
||||
final Future<void> Function() onRefresh;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('关于', 'About'),
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
appText(
|
||||
'直接查看当前应用构建与 Bridge 运行时版本信息,便于排查发布与联调问题。',
|
||||
'Review the current app build and bridge runtime information in one place.',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton.tonal(
|
||||
key: const ValueKey('settings-about-refresh-button'),
|
||||
onPressed: busy ? null : () => onRefresh(),
|
||||
child: busy
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
key: const ValueKey(
|
||||
'settings-about-refresh-progress',
|
||||
),
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(appText('刷新中', 'Refreshing')),
|
||||
],
|
||||
)
|
||||
: Text(appText('刷新版本信息', 'Refresh Build Info')),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('应用构建', 'App Build'),
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${appText('Version', 'Version')}: ${_displayValue(snapshot.appVersion)}',
|
||||
key: const ValueKey('settings-about-app-version'),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${appText('Build Number', 'Build Number')}: ${_displayValue(snapshot.appBuildNumber)}',
|
||||
key: const ValueKey('settings-about-app-build-number'),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${appText('Build Date', 'Build Date')}: ${_displayValue(snapshot.appBuildDate)}',
|
||||
key: const ValueKey('settings-about-app-build-date'),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Commit: ${_displayValue(snapshot.appCommit)}',
|
||||
key: const ValueKey('settings-about-app-commit'),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
appText('Bridge 运行时', 'Bridge Runtime'),
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${appText('Endpoint', 'Endpoint')}: ${_displayValue(snapshot.bridgeEndpoint)}',
|
||||
key: const ValueKey('settings-about-bridge-endpoint'),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${appText('Status', 'Status')}: ${_displayValue(snapshot.bridgeStatus)}',
|
||||
key: const ValueKey('settings-about-bridge-status'),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${appText('Version', 'Version')}: ${_displayValue(snapshot.bridgeVersion)}',
|
||||
key: const ValueKey('settings-about-bridge-version'),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${appText('Build Date', 'Build Date')}: ${_displayValue(snapshot.bridgeBuildDate)}',
|
||||
key: const ValueKey('settings-about-bridge-build-date'),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Commit: ${_displayValue(snapshot.bridgeCommit)}',
|
||||
key: const ValueKey('settings-about-bridge-commit'),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'${appText('Image', 'Image')}: ${_displayValue(snapshot.bridgeImage)}',
|
||||
key: const ValueKey('settings-about-bridge-image'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _displayValue(String value) {
|
||||
return value.trim().isEmpty ? appText('不可用', 'Unavailable') : value.trim();
|
||||
}
|
||||
@ -10,6 +10,7 @@ class SettingsAccountPanel extends StatelessWidget {
|
||||
required this.accountSession,
|
||||
required this.accountState,
|
||||
required this.accountBusy,
|
||||
this.accountStatus = '',
|
||||
required this.accountSignedIn,
|
||||
required this.accountMfaRequired,
|
||||
required this.accountBaseUrlController,
|
||||
@ -28,6 +29,7 @@ class SettingsAccountPanel extends StatelessWidget {
|
||||
final AccountSessionSummary? accountSession;
|
||||
final AccountSyncState? accountState;
|
||||
final bool accountBusy;
|
||||
final String accountStatus;
|
||||
final bool accountSignedIn;
|
||||
final bool accountMfaRequired;
|
||||
final TextEditingController accountBaseUrlController;
|
||||
@ -68,6 +70,7 @@ class SettingsAccountPanel extends StatelessWidget {
|
||||
accountSession: accountSession,
|
||||
accountState: accountState,
|
||||
accountBusy: accountBusy,
|
||||
accountStatus: accountStatus,
|
||||
onSync: onSync,
|
||||
onLogout: onLogout,
|
||||
);
|
||||
@ -284,6 +287,7 @@ class _SignedInAccountPanel extends StatelessWidget {
|
||||
required this.accountSession,
|
||||
required this.accountState,
|
||||
required this.accountBusy,
|
||||
required this.accountStatus,
|
||||
required this.onSync,
|
||||
required this.onLogout,
|
||||
});
|
||||
@ -292,6 +296,7 @@ class _SignedInAccountPanel extends StatelessWidget {
|
||||
final AccountSessionSummary? accountSession;
|
||||
final AccountSyncState? accountState;
|
||||
final bool accountBusy;
|
||||
final String accountStatus;
|
||||
final Future<void> Function() onSync;
|
||||
final Future<void> Function() onLogout;
|
||||
|
||||
@ -316,6 +321,11 @@ class _SignedInAccountPanel extends StatelessWidget {
|
||||
final syncMessage = accountState?.syncMessage.trim().isNotEmpty == true
|
||||
? accountState!.syncMessage.trim()
|
||||
: appText('尚未同步远端配置', 'Remote config not synced yet');
|
||||
final effectiveSyncState = accountBusy ? 'syncing' : syncState;
|
||||
final effectiveSyncMessage =
|
||||
accountBusy && accountStatus.trim().isNotEmpty
|
||||
? accountStatus.trim()
|
||||
: syncMessage;
|
||||
final mfaEnabled =
|
||||
accountSession?.totpEnabled == true ||
|
||||
accountSession?.mfaEnabled == true;
|
||||
@ -342,7 +352,7 @@ class _SignedInAccountPanel extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${appText('同步状态', 'Sync Status')}: $syncState · $syncMessage',
|
||||
'${appText('同步状态', 'Sync Status')}: $effectiveSyncState · $effectiveSyncMessage',
|
||||
key: const ValueKey('settings-account-sync-status'),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
@ -413,7 +423,25 @@ class _SignedInAccountPanel extends StatelessWidget {
|
||||
FilledButton.tonal(
|
||||
key: const ValueKey('settings-account-sync-button'),
|
||||
onPressed: accountBusy ? null : () => onSync(),
|
||||
child: Text(appText('重新同步', 'Sync Again')),
|
||||
child: accountBusy
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
key: const ValueKey(
|
||||
'settings-account-sync-progress',
|
||||
),
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(appText('同步中', 'Syncing')),
|
||||
],
|
||||
)
|
||||
: Text(appText('重新同步', 'Sync Again')),
|
||||
),
|
||||
TextButton(
|
||||
key: const ValueKey('settings-account-logout-button'),
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_controller.dart';
|
||||
import '../../app/app_metadata.dart';
|
||||
import '../../app/workspace_navigation.dart';
|
||||
import '../../i18n/app_language.dart';
|
||||
import '../../models/app_models.dart';
|
||||
@ -9,6 +14,7 @@ import '../../runtime/runtime_models.dart';
|
||||
import '../../widgets/settings_page_shell.dart';
|
||||
import '../../widgets/surface_card.dart';
|
||||
import 'settings_account_panel.dart';
|
||||
import 'settings_about_panel.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
const SettingsPage({
|
||||
@ -34,6 +40,8 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
late final TextEditingController _accountIdentifierController;
|
||||
late final TextEditingController _accountPasswordController;
|
||||
late final TextEditingController _accountMfaCodeController;
|
||||
SettingsAboutSnapshot _aboutSnapshot = const SettingsAboutSnapshot.defaults();
|
||||
bool _aboutBusy = false;
|
||||
String _lastSavedAccountBaseUrl = '';
|
||||
String _lastSavedAccountIdentifier = '';
|
||||
|
||||
@ -51,6 +59,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
);
|
||||
_accountPasswordController = TextEditingController();
|
||||
_accountMfaCodeController = TextEditingController();
|
||||
unawaited(_refreshAboutSnapshot());
|
||||
}
|
||||
|
||||
@override
|
||||
@ -108,6 +117,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
baseUrl: _accountBaseUrlController.text.trim(),
|
||||
);
|
||||
await _refreshBridgeCapabilities();
|
||||
await _refreshAboutSnapshot();
|
||||
}
|
||||
|
||||
Future<void> _verifyAccountMfa(SettingsSnapshot settings) async {
|
||||
@ -150,6 +160,87 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
await widget.controller.settingsController.logoutAccount();
|
||||
_accountPasswordController.clear();
|
||||
_accountMfaCodeController.clear();
|
||||
await _refreshAboutSnapshot();
|
||||
}
|
||||
|
||||
Future<void> _refreshAboutSnapshot() async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_aboutBusy = true;
|
||||
});
|
||||
final snapshot = await _loadAboutSnapshot();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_aboutSnapshot = snapshot;
|
||||
_aboutBusy = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<SettingsAboutSnapshot> _loadAboutSnapshot() async {
|
||||
final bridgeMetadata = await _loadBridgeMetadata();
|
||||
return SettingsAboutSnapshot(
|
||||
appVersion: kAppVersion,
|
||||
appBuildNumber: kAppBuildNumber,
|
||||
appBuildDate: kAppBuildDate,
|
||||
appCommit: kAppBuildCommit,
|
||||
bridgeEndpoint: kManagedBridgeServerUrl,
|
||||
bridgeStatus: _stringValue(bridgeMetadata['status']),
|
||||
bridgeVersion: _resolveBridgeVersion(bridgeMetadata),
|
||||
bridgeBuildDate: _resolveBridgeBuildDate(bridgeMetadata),
|
||||
bridgeCommit: _stringValue(bridgeMetadata['commit']),
|
||||
bridgeImage: _stringValue(bridgeMetadata['image']),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _loadBridgeMetadata() async {
|
||||
final client = HttpClient()..connectionTimeout = const Duration(seconds: 4);
|
||||
try {
|
||||
final request = await client
|
||||
.getUrl(Uri.parse('$kManagedBridgeServerUrl/api/ping'))
|
||||
.timeout(const Duration(seconds: 4));
|
||||
request.headers.set(HttpHeaders.acceptHeader, 'application/json');
|
||||
final response = await request.close().timeout(const Duration(seconds: 4));
|
||||
final body = await utf8
|
||||
.decodeStream(response)
|
||||
.timeout(const Duration(seconds: 4));
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
return <String, dynamic>{
|
||||
'status': 'error',
|
||||
'version': '',
|
||||
'commit': '',
|
||||
'image': '',
|
||||
'buildDate': '',
|
||||
};
|
||||
}
|
||||
final decoded = jsonDecode(body);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is Map) {
|
||||
return decoded.cast<String, dynamic>();
|
||||
}
|
||||
} catch (_) {
|
||||
return const <String, dynamic>{
|
||||
'status': 'unavailable',
|
||||
'version': '',
|
||||
'commit': '',
|
||||
'image': '',
|
||||
'buildDate': '',
|
||||
};
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
return const <String, dynamic>{
|
||||
'status': 'unavailable',
|
||||
'version': '',
|
||||
'commit': '',
|
||||
'image': '',
|
||||
'buildDate': '',
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
@ -165,6 +256,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
_syncAccountControllers(currentSettings);
|
||||
final accountState = controller.settingsController.accountSyncState;
|
||||
final accountBusy = controller.settingsController.accountBusy;
|
||||
final accountStatus = controller.settingsController.accountStatus;
|
||||
final accountSignedIn = controller.settingsController.accountSignedIn;
|
||||
final accountMfaRequired =
|
||||
controller.settingsController.accountMfaRequired;
|
||||
@ -201,6 +293,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
accountSession: accountSession,
|
||||
accountState: accountState,
|
||||
accountBusy: accountBusy,
|
||||
accountStatus: accountStatus,
|
||||
accountSignedIn: accountSignedIn,
|
||||
accountMfaRequired: accountMfaRequired,
|
||||
accountBaseUrlController: _accountBaseUrlController,
|
||||
@ -216,9 +309,50 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
onLogout: _logoutAccount,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SurfaceCard(
|
||||
key: const ValueKey('settings-about-panel-card'),
|
||||
child: SettingsAboutPanel(
|
||||
snapshot: _aboutSnapshot,
|
||||
busy: _aboutBusy,
|
||||
onRefresh: _refreshAboutSnapshot,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _stringValue(Object? value) {
|
||||
return value == null ? '' : value.toString().trim();
|
||||
}
|
||||
|
||||
String _resolveBridgeVersion(Map<String, dynamic> payload) {
|
||||
final explicit = _stringValue(payload['version']);
|
||||
if (explicit.isNotEmpty) {
|
||||
return explicit;
|
||||
}
|
||||
final tag = _stringValue(payload['tag']);
|
||||
if (tag.isNotEmpty) {
|
||||
return tag;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String _resolveBridgeBuildDate(Map<String, dynamic> payload) {
|
||||
final candidates = <Object?>[
|
||||
payload['buildDate'],
|
||||
payload['build-date'],
|
||||
payload['builtAt'],
|
||||
payload['build_at'],
|
||||
];
|
||||
for (final candidate in candidates) {
|
||||
final value = _stringValue(candidate);
|
||||
if (value.isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@ -153,13 +153,17 @@ class AccountRuntimeClient {
|
||||
}
|
||||
|
||||
Future<AccountSessionSummary> loadSession({required String token}) async {
|
||||
final payload = await _requestJson(
|
||||
final payload = await loadProfile(token: token);
|
||||
final user = _asMap(payload['user']);
|
||||
return _accountSessionSummaryFromUserJson(user);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> loadProfile({required String token}) {
|
||||
return _requestJson(
|
||||
method: 'GET',
|
||||
path: '/api/auth/session',
|
||||
bearerToken: token,
|
||||
);
|
||||
final user = _asMap(payload['user']);
|
||||
return _accountSessionSummaryFromUserJson(user);
|
||||
}
|
||||
|
||||
Future<BridgeBootstrapIssue> createBridgeBootstrapTicket({
|
||||
|
||||
@ -147,6 +147,7 @@ Future<void> completeAccountSignInSettingsInternal(
|
||||
baseUrl: baseUrl,
|
||||
bridgeTokenOverride: _resolveBridgeAuthorizationToken(payload),
|
||||
bridgeServerUrlOverride: _resolveBridgeServerUrl(payload),
|
||||
profilePayloadOverride: payload,
|
||||
quiet: true,
|
||||
);
|
||||
await controller.reloadDerivedStateInternal();
|
||||
@ -179,7 +180,8 @@ Future<void> restoreAccountSessionSettingsInternal(
|
||||
|
||||
try {
|
||||
final client = controller.buildAccountClient(normalizedBaseUrl);
|
||||
final session = await client.loadSession(token: token);
|
||||
final payload = await client.loadProfile(token: token);
|
||||
final session = _accountSessionSummaryFromUserPayload(_asMap(payload['user']));
|
||||
await controller.storeInternal.saveAccountSessionSummary(session);
|
||||
if (session.userId.trim().isNotEmpty) {
|
||||
await controller.storeInternal.saveAccountSessionUserId(session.userId);
|
||||
@ -198,6 +200,7 @@ Future<void> restoreAccountSessionSettingsInternal(
|
||||
await syncAccountSettingsInternal(
|
||||
controller,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
profilePayloadOverride: payload,
|
||||
quiet: true,
|
||||
);
|
||||
} on AccountRuntimeException catch (error) {
|
||||
@ -225,28 +228,21 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
|
||||
bool quiet = false,
|
||||
String bridgeTokenOverride = '',
|
||||
String bridgeServerUrlOverride = '',
|
||||
Map<String, dynamic> profilePayloadOverride = const <String, dynamic>{},
|
||||
}) async {
|
||||
final normalizedBaseUrl = normalizeAccountBaseUrlSettingsInternal(
|
||||
baseUrl,
|
||||
fallback: controller.snapshotInternal.accountBaseUrl,
|
||||
);
|
||||
final sessionToken =
|
||||
(await controller.storeInternal.loadAccountSessionToken())?.trim() ?? '';
|
||||
if (sessionToken.isEmpty) {
|
||||
final nextState = AccountSyncState.defaults().copyWith(
|
||||
syncState: 'blocked',
|
||||
syncMessage: 'Account session is unavailable',
|
||||
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
lastSyncError: 'Account session is unavailable',
|
||||
profileScope: 'bridge',
|
||||
);
|
||||
await _persistAccountSyncStateInternal(controller, nextState);
|
||||
const result = AccountSyncResult(
|
||||
return _persistAccountSyncFailureInternal(
|
||||
controller,
|
||||
state: 'blocked',
|
||||
message: 'Account session is unavailable',
|
||||
quiet: quiet,
|
||||
);
|
||||
controller.accountStatusInternal = result.message;
|
||||
if (!quiet) {
|
||||
controller.accountBusyInternal = false;
|
||||
controller.notifyListeners();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!quiet) {
|
||||
@ -255,100 +251,125 @@ Future<AccountSyncResult> syncAccountSettingsInternal(
|
||||
controller.notifyListeners();
|
||||
}
|
||||
|
||||
final bridgeToken = bridgeTokenOverride.trim().isNotEmpty
|
||||
? bridgeTokenOverride.trim()
|
||||
: ((await controller.storeInternal.loadAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
))?.trim() ??
|
||||
'');
|
||||
if (bridgeToken.isEmpty) {
|
||||
const result = AccountSyncResult(
|
||||
state: 'blocked',
|
||||
message: 'Bridge authorization is unavailable',
|
||||
);
|
||||
await _persistAccountSyncStateInternal(
|
||||
try {
|
||||
Map<String, dynamic> profilePayload = profilePayloadOverride;
|
||||
if (profilePayload.isEmpty) {
|
||||
if (normalizedBaseUrl.isEmpty) {
|
||||
return _persistAccountSyncFailureInternal(
|
||||
controller,
|
||||
state: 'blocked',
|
||||
message: 'Account base URL is required',
|
||||
quiet: quiet,
|
||||
);
|
||||
}
|
||||
final client = controller.buildAccountClient(normalizedBaseUrl);
|
||||
profilePayload = await client.loadProfile(token: sessionToken);
|
||||
}
|
||||
await _persistAccountSessionSummaryFromProfilePayloadInternal(
|
||||
controller,
|
||||
AccountSyncState.defaults().copyWith(
|
||||
syncState: result.state,
|
||||
syncMessage: result.message,
|
||||
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
lastSyncError: result.message,
|
||||
profileScope: 'bridge',
|
||||
profilePayload,
|
||||
);
|
||||
|
||||
final profileBridgeToken = _resolveBridgeAuthorizationToken(profilePayload);
|
||||
final bridgeToken = bridgeTokenOverride.trim().isNotEmpty
|
||||
? bridgeTokenOverride.trim()
|
||||
: profileBridgeToken.trim().isNotEmpty
|
||||
? profileBridgeToken.trim()
|
||||
: ((await controller.storeInternal.loadAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
))?.trim() ??
|
||||
'');
|
||||
if (bridgeToken.isEmpty) {
|
||||
return _persistAccountSyncFailureInternal(
|
||||
controller,
|
||||
state: 'blocked',
|
||||
message: 'Bridge authorization is unavailable',
|
||||
quiet: quiet,
|
||||
);
|
||||
}
|
||||
|
||||
await controller.storeInternal.saveAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
value: bridgeToken,
|
||||
);
|
||||
final resolvedBridgeServerUrl = _resolveCurrentBridgeServerUrl(
|
||||
controller,
|
||||
bridgeServerUrlOverride: bridgeServerUrlOverride.trim().isNotEmpty
|
||||
? bridgeServerUrlOverride
|
||||
: _resolveBridgeServerUrl(profilePayload),
|
||||
);
|
||||
await controller.storeInternal.clearAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetAIGatewayAccessToken,
|
||||
);
|
||||
await controller.storeInternal.clearAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetOllamaCloudApiKey,
|
||||
);
|
||||
|
||||
final nextState = AccountSyncState.defaults().copyWith(
|
||||
syncedDefaults: AccountRemoteProfile.defaults().copyWith(
|
||||
bridgeServerUrl: resolvedBridgeServerUrl,
|
||||
),
|
||||
syncState: 'ready',
|
||||
syncMessage: 'Bridge access synced',
|
||||
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
lastSyncSource: resolvedBridgeServerUrl,
|
||||
lastSyncError: '',
|
||||
profileScope: 'bridge',
|
||||
tokenConfigured: const AccountTokenConfigured(
|
||||
bridge: true,
|
||||
vault: false,
|
||||
apisix: false,
|
||||
),
|
||||
);
|
||||
controller.accountStatusInternal = result.message;
|
||||
await controller.storeInternal.saveAccountSyncState(nextState);
|
||||
final currentSettings = controller.snapshotInternal;
|
||||
final currentModeConfig = currentSettings.acpBridgeServerModeConfig;
|
||||
final nextModeConfig = currentModeConfig.copyWith(
|
||||
cloudSynced: currentModeConfig.cloudSynced.copyWith(
|
||||
accountBaseUrl: '',
|
||||
accountIdentifier: '',
|
||||
lastSyncAt: nextState.lastSyncAtMs,
|
||||
remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary
|
||||
.copyWith(
|
||||
endpoint: resolvedBridgeServerUrl,
|
||||
hasAdvancedOverrides: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
final sanitizedSettings = _sanitizeBridgeOnlyAccountSyncSettings(
|
||||
currentSettings.copyWith(acpBridgeServerModeConfig: nextModeConfig),
|
||||
);
|
||||
if (sanitizedSettings.toJsonString() != currentSettings.toJsonString()) {
|
||||
await controller.saveSnapshot(sanitizedSettings);
|
||||
}
|
||||
await controller.reloadDerivedStateInternal();
|
||||
final email = controller.accountSessionInternal?.email.trim() ?? '';
|
||||
controller.accountStatusInternal = email.isEmpty
|
||||
? 'Signed in'
|
||||
: 'Signed in as $email';
|
||||
if (!quiet) {
|
||||
controller.accountBusyInternal = false;
|
||||
controller.notifyListeners();
|
||||
}
|
||||
return result;
|
||||
return const AccountSyncResult(
|
||||
state: 'ready',
|
||||
message: 'Bridge access synced',
|
||||
);
|
||||
} on AccountRuntimeException catch (error) {
|
||||
return _persistAccountSyncFailureInternal(
|
||||
controller,
|
||||
state: 'error',
|
||||
message: error.message,
|
||||
quiet: quiet,
|
||||
);
|
||||
} catch (error) {
|
||||
return _persistAccountSyncFailureInternal(
|
||||
controller,
|
||||
state: 'error',
|
||||
message: error.toString(),
|
||||
quiet: quiet,
|
||||
);
|
||||
}
|
||||
|
||||
await controller.storeInternal.saveAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
value: bridgeToken,
|
||||
);
|
||||
final resolvedBridgeServerUrl = _resolveCurrentBridgeServerUrl(
|
||||
controller,
|
||||
bridgeServerUrlOverride: bridgeServerUrlOverride,
|
||||
);
|
||||
await controller.storeInternal.clearAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetAIGatewayAccessToken,
|
||||
);
|
||||
await controller.storeInternal.clearAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetOllamaCloudApiKey,
|
||||
);
|
||||
|
||||
final nextState = AccountSyncState.defaults().copyWith(
|
||||
syncedDefaults: AccountRemoteProfile.defaults().copyWith(
|
||||
bridgeServerUrl: resolvedBridgeServerUrl,
|
||||
),
|
||||
syncState: 'ready',
|
||||
syncMessage: 'Bridge access synced',
|
||||
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
lastSyncSource: resolvedBridgeServerUrl,
|
||||
lastSyncError: '',
|
||||
profileScope: 'bridge',
|
||||
tokenConfigured: const AccountTokenConfigured(
|
||||
bridge: true,
|
||||
vault: false,
|
||||
apisix: false,
|
||||
),
|
||||
);
|
||||
await controller.storeInternal.saveAccountSyncState(nextState);
|
||||
final currentSettings = controller.snapshotInternal;
|
||||
final currentModeConfig = currentSettings.acpBridgeServerModeConfig;
|
||||
final nextModeConfig = currentModeConfig.copyWith(
|
||||
cloudSynced: currentModeConfig.cloudSynced.copyWith(
|
||||
accountBaseUrl: '',
|
||||
accountIdentifier: '',
|
||||
lastSyncAt: nextState.lastSyncAtMs,
|
||||
remoteServerSummary: currentModeConfig.cloudSynced.remoteServerSummary
|
||||
.copyWith(
|
||||
endpoint: resolvedBridgeServerUrl,
|
||||
hasAdvancedOverrides: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
final sanitizedSettings = _sanitizeBridgeOnlyAccountSyncSettings(
|
||||
currentSettings.copyWith(acpBridgeServerModeConfig: nextModeConfig),
|
||||
);
|
||||
if (sanitizedSettings.toJsonString() != currentSettings.toJsonString()) {
|
||||
await controller.saveSnapshot(sanitizedSettings);
|
||||
}
|
||||
await controller.reloadDerivedStateInternal();
|
||||
final email = controller.accountSessionInternal?.email.trim() ?? '';
|
||||
controller.accountStatusInternal = email.isEmpty
|
||||
? 'Signed in'
|
||||
: 'Signed in as $email';
|
||||
if (!quiet) {
|
||||
controller.accountBusyInternal = false;
|
||||
controller.notifyListeners();
|
||||
}
|
||||
return const AccountSyncResult(
|
||||
state: 'ready',
|
||||
message: 'Bridge access synced',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> logoutAccountSettingsInternal(
|
||||
@ -456,22 +477,64 @@ SettingsSnapshot _sanitizeBridgeOnlyAccountSyncSettings(
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _persistAccountSessionSummaryFromProfilePayloadInternal(
|
||||
SettingsController controller,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
final user = _asMap(payload['user']);
|
||||
if (user.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final summary = _accountSessionSummaryFromUserPayload(user);
|
||||
final hasSessionDetails =
|
||||
summary.userId.trim().isNotEmpty ||
|
||||
summary.email.trim().isNotEmpty ||
|
||||
summary.name.trim().isNotEmpty ||
|
||||
summary.role.trim().isNotEmpty;
|
||||
if (!hasSessionDetails) {
|
||||
return;
|
||||
}
|
||||
await controller.storeInternal.saveAccountSessionSummary(summary);
|
||||
if (summary.userId.trim().isNotEmpty) {
|
||||
await controller.storeInternal.saveAccountSessionUserId(summary.userId);
|
||||
}
|
||||
final identifier = summary.email.trim().isNotEmpty
|
||||
? summary.email.trim()
|
||||
: (await controller.storeInternal.loadAccountSessionIdentifier())
|
||||
?.trim() ??
|
||||
controller.snapshotInternal.accountUsername.trim();
|
||||
if (identifier.isNotEmpty) {
|
||||
await controller.storeInternal.saveAccountSessionIdentifier(identifier);
|
||||
}
|
||||
}
|
||||
|
||||
Future<AccountSyncResult> _persistAccountSyncFailureInternal(
|
||||
SettingsController controller, {
|
||||
required String state,
|
||||
required String message,
|
||||
required bool quiet,
|
||||
}) async {
|
||||
await _persistAccountSyncStateInternal(
|
||||
controller,
|
||||
AccountSyncState.defaults().copyWith(
|
||||
syncState: state,
|
||||
syncMessage: message,
|
||||
lastSyncAtMs: DateTime.now().millisecondsSinceEpoch,
|
||||
lastSyncError: message,
|
||||
profileScope: 'bridge',
|
||||
),
|
||||
);
|
||||
controller.accountStatusInternal = message;
|
||||
if (!quiet) {
|
||||
controller.accountBusyInternal = false;
|
||||
controller.notifyListeners();
|
||||
}
|
||||
return AccountSyncResult(state: state, message: message);
|
||||
}
|
||||
|
||||
String _resolveBridgeAuthorizationToken(Map<String, dynamic> payload) {
|
||||
final explicit = _stringValue(payload['BRIDGE_AUTH_TOKEN']);
|
||||
if (explicit.isNotEmpty) {
|
||||
return explicit;
|
||||
}
|
||||
final uppercaseInternalServiceToken = _stringValue(
|
||||
payload['INTERNAL_SERVICE_TOKEN'],
|
||||
);
|
||||
if (uppercaseInternalServiceToken.isNotEmpty) {
|
||||
return uppercaseInternalServiceToken;
|
||||
}
|
||||
final internalServiceToken = _stringValue(payload['internalServiceToken']);
|
||||
if (internalServiceToken.isNotEmpty) {
|
||||
return internalServiceToken;
|
||||
}
|
||||
return '';
|
||||
return explicit;
|
||||
}
|
||||
|
||||
String _resolveBridgeServerUrl(Map<String, dynamic> payload) {
|
||||
|
||||
@ -50,12 +50,16 @@ if [[ -z "$VERSION_LINE" ]]; then
|
||||
echo "Unable to read version from $PUBSPEC_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
BUILD_DATE_LINE="$(sed -n 's/^build-date:[[:space:]]*//p' "$PUBSPEC_PATH" | head -n 1)"
|
||||
BUILD_ID_LINE="$(sed -n 's/^build-id:[[:space:]]*//p' "$PUBSPEC_PATH" | head -n 1)"
|
||||
|
||||
APP_VERSION="${VERSION_LINE%%+*}"
|
||||
APP_BUILD="${VERSION_LINE#*+}"
|
||||
if [[ "$APP_BUILD" == "$VERSION_LINE" ]]; then
|
||||
APP_BUILD="1"
|
||||
fi
|
||||
APP_BUILD_DATE="${BUILD_DATE_LINE:-unknown}"
|
||||
APP_BUILD_COMMIT="${BUILD_ID_LINE:-unknown}"
|
||||
|
||||
BUILD_APP_PATH="$APP_DIR/build/macos/Build/Products/$PRODUCTS_DIR_NAME/$APP_NAME.app"
|
||||
DIST_APP_PATH="$DIST_DIR/$APP_NAME.app"
|
||||
@ -77,6 +81,8 @@ BUILD_ARGS=(
|
||||
--build-number="$APP_BUILD"
|
||||
--dart-define="XWORKMATE_DISPLAY_VERSION=$APP_VERSION"
|
||||
--dart-define="XWORKMATE_BUILD_NUMBER=$APP_BUILD"
|
||||
--dart-define="XWORKMATE_BUILD_DATE=$APP_BUILD_DATE"
|
||||
--dart-define="XWORKMATE_BUILD_COMMIT=$APP_BUILD_COMMIT"
|
||||
"$APP_STORE_DEFINE"
|
||||
)
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ import 'package:xworkmate/widgets/surface_card.dart';
|
||||
|
||||
void main() {
|
||||
group('AssistantLowerPaneInternal', () {
|
||||
testWidgets('shows agent and gateway task dialog modes', (tester) async {
|
||||
testWidgets('shows mode-specific provider catalogs', (tester) async {
|
||||
final controller = AppController(
|
||||
initialBridgeProviderCatalog: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
@ -27,28 +27,51 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('assistant-provider-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('assistant-provider-button')),
|
||||
find.byKey(const Key('assistant-provider-menu-item-codex')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-provider-menu-item-opencode')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-provider-menu-item-gemini')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const Key('assistant-execution-target-button')),
|
||||
find.byKey(const Key('assistant-provider-menu-item-codex')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('assistant-execution-target-menu-item-agent')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-execution-target-menu-item-gateway')),
|
||||
find.byKey(const Key('assistant-execution-target-button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const Key('assistant-execution-target-menu-item-gateway')),
|
||||
final gatewayThread = controller
|
||||
.requireTaskThreadForSessionInternal('session-1')
|
||||
.copyWith(
|
||||
executionBinding: ExecutionBinding(
|
||||
executionMode: threadExecutionModeFromAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.gateway,
|
||||
),
|
||||
executorId: SingleAgentProvider.openclaw.providerId,
|
||||
providerId: SingleAgentProvider.openclaw.providerId,
|
||||
endpointId: '',
|
||||
executionModeSource: ThreadSelectionSource.explicit,
|
||||
providerSource: ThreadSelectionSource.explicit,
|
||||
),
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
controller.taskThreadRepositoryInternal.replace(
|
||||
gatewayThread,
|
||||
persist: false,
|
||||
);
|
||||
controller.notifyListeners();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.assistantExecutionTarget.name, 'gateway');
|
||||
@ -64,18 +87,43 @@ void main() {
|
||||
find.byKey(const Key('assistant-provider-menu-item-openclaw')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-provider-menu-item-codex')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-provider-menu-item-opencode')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-provider-menu-item-gemini')),
|
||||
findsNothing,
|
||||
);
|
||||
await tester.tap(
|
||||
find.byKey(const Key('assistant-provider-menu-item-openclaw')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const Key('assistant-execution-target-button')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byKey(const Key('assistant-execution-target-menu-item-agent')),
|
||||
final agentThread = controller
|
||||
.requireTaskThreadForSessionInternal('session-1')
|
||||
.copyWith(
|
||||
executionBinding: ExecutionBinding(
|
||||
executionMode: threadExecutionModeFromAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.agent,
|
||||
),
|
||||
executorId: SingleAgentProvider.codex.providerId,
|
||||
providerId: SingleAgentProvider.codex.providerId,
|
||||
endpointId: '',
|
||||
executionModeSource: ThreadSelectionSource.explicit,
|
||||
providerSource: ThreadSelectionSource.explicit,
|
||||
),
|
||||
updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
);
|
||||
controller.taskThreadRepositoryInternal.replace(
|
||||
agentThread,
|
||||
persist: false,
|
||||
);
|
||||
controller.notifyListeners();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(controller.assistantExecutionTarget.name, 'agent');
|
||||
@ -83,6 +131,26 @@ void main() {
|
||||
find.byKey(const Key('assistant-provider-button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('assistant-provider-button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('assistant-provider-menu-item-codex')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-provider-menu-item-openclaw')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-provider-menu-item-opencode')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-provider-menu-item-gemini')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('shows assistant providers and allows switching provider', (
|
||||
|
||||
63
test/features/settings/settings_about_panel_test.dart
Normal file
63
test/features/settings/settings_about_panel_test.dart
Normal file
@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/features/settings/settings_about_panel.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
import 'package:xworkmate/widgets/surface_card.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('renders app and bridge build metadata clearly', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Material(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 1100,
|
||||
child: SurfaceCard(
|
||||
child: SettingsAboutPanel(
|
||||
snapshot: const SettingsAboutSnapshot(
|
||||
appVersion: '1.0.0-beta.2',
|
||||
appBuildNumber: '4',
|
||||
appBuildDate: '2026-03-28',
|
||||
appCommit: 'f153d7b',
|
||||
bridgeEndpoint: 'https://xworkmate-bridge.svc.plus',
|
||||
bridgeStatus: 'ok',
|
||||
bridgeVersion: '991ecb0ae2f270cdf6cc7bd456d4391cce664ae2',
|
||||
bridgeBuildDate: '2026-04-13T09:00:00Z',
|
||||
bridgeCommit: '991ecb0ae2f270cdf6cc7bd456d4391cce664ae2',
|
||||
bridgeImage:
|
||||
'ghcr.io/x-evor/xworkmate-bridge:991ecb0ae2f270cdf6cc7bd456d4391cce664ae2',
|
||||
),
|
||||
busy: false,
|
||||
onRefresh: () async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('关于'), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const ValueKey('settings-about-app-version')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.textContaining('Version: 1.0.0-beta.2'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.textContaining('Build Date: 2026-03-28'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.textContaining('Commit: 991ecb0ae2f270cdf6cc7bd456d4391cce664ae2'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('settings-about-refresh-button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -216,6 +216,56 @@ void main() {
|
||||
findsNothing,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('shows live syncing feedback while resync is running', (
|
||||
tester,
|
||||
) async {
|
||||
final controllers = _TestControllers();
|
||||
addTearDown(controllers.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
_buildTestApp(
|
||||
child: SettingsAccountPanel(
|
||||
settings: SettingsSnapshot.defaults().copyWith(
|
||||
accountBaseUrl: 'https://accounts.svc.plus',
|
||||
accountUsername: 'review@svc.plus',
|
||||
),
|
||||
accountSession: const AccountSessionSummary(
|
||||
userId: 'u-1',
|
||||
email: 'review@svc.plus',
|
||||
name: 'Review User',
|
||||
role: 'operator',
|
||||
mfaEnabled: true,
|
||||
),
|
||||
accountState: AccountSyncState.defaults().copyWith(
|
||||
syncState: 'ready',
|
||||
syncMessage: 'Bridge access synced',
|
||||
profileScope: 'bridge',
|
||||
),
|
||||
accountBusy: true,
|
||||
accountStatus: 'Syncing bridge access...',
|
||||
accountSignedIn: true,
|
||||
accountMfaRequired: false,
|
||||
accountBaseUrlController: controllers.baseUrl,
|
||||
accountIdentifierController: controllers.identifier,
|
||||
accountPasswordController: controllers.password,
|
||||
accountMfaCodeController: controllers.mfaCode,
|
||||
onSaveAccountProfile: () async {},
|
||||
onLogin: () async {},
|
||||
onVerifyMfa: () async {},
|
||||
onCancelMfa: () async {},
|
||||
onSync: () async {},
|
||||
onLogout: () async {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.textContaining('Syncing bridge access...'), findsOneWidget);
|
||||
final syncButton = tester.widget<FilledButton>(
|
||||
find.byKey(const ValueKey('settings-account-sync-button')),
|
||||
);
|
||||
expect(syncButton.onPressed, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 38 KiB |
BIN
test/golden/goldens/settings_about_panel/default.png
Normal file
BIN
test/golden/goldens/settings_about_panel/default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
51
test/golden/settings_about_panel_golden_test.dart
Normal file
51
test/golden/settings_about_panel_golden_test.dart
Normal file
@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:xworkmate/features/settings/settings_about_panel.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
import 'package:xworkmate/widgets/surface_card.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('settings about panel matches baseline', (tester) async {
|
||||
await tester.binding.setSurfaceSize(const Size(1280, 780));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Material(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 1100,
|
||||
child: SurfaceCard(
|
||||
child: SettingsAboutPanel(
|
||||
snapshot: const SettingsAboutSnapshot(
|
||||
appVersion: '1.0.0-beta.2',
|
||||
appBuildNumber: '4',
|
||||
appBuildDate: '2026-03-28',
|
||||
appCommit: 'f153d7b',
|
||||
bridgeEndpoint: 'https://xworkmate-bridge.svc.plus',
|
||||
bridgeStatus: 'ok',
|
||||
bridgeVersion: '991ecb0ae2f270cdf6cc7bd456d4391cce664ae2',
|
||||
bridgeBuildDate: '',
|
||||
bridgeCommit:
|
||||
'991ecb0ae2f270cdf6cc7bd456d4391cce664ae2',
|
||||
bridgeImage:
|
||||
'ghcr.io/x-evor/xworkmate-bridge:991ecb0ae2f270cdf6cc7bd456d4391cce664ae2',
|
||||
),
|
||||
busy: false,
|
||||
onRefresh: () async {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byType(SettingsAboutPanel),
|
||||
matchesGoldenFile('goldens/settings_about_panel/default.png'),
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -118,6 +118,25 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'ignores legacy INTERNAL_SERVICE_TOKEN for managed bridge auth resolution',
|
||||
() async {
|
||||
final controller = AppController(
|
||||
environmentOverride: const <String, String>{
|
||||
'INTERNAL_SERVICE_TOKEN': 'legacy-bridge-token',
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
final bridgeHeader = await controller
|
||||
.resolveGatewayAcpAuthorizationHeaderInternal(
|
||||
Uri.parse('https://xworkmate-bridge.svc.plus/acp/rpc'),
|
||||
);
|
||||
|
||||
expect(bridgeHeader, isNull);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'runtime coordinator only exposes remote and offline gateway modes',
|
||||
() {
|
||||
|
||||
@ -34,7 +34,14 @@ void main() {
|
||||
);
|
||||
await store.saveAccountSessionToken('session-token');
|
||||
|
||||
final controller = SettingsController(store);
|
||||
final client = _FakeAccountRuntimeClient(
|
||||
loginPayload: const <String, dynamic>{},
|
||||
profilePayload: const <String, dynamic>{},
|
||||
);
|
||||
final controller = SettingsController(
|
||||
store,
|
||||
accountClientFactory: (_) => client,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.initialize();
|
||||
|
||||
@ -56,12 +63,11 @@ void main() {
|
||||
'Bridge authorization is unavailable',
|
||||
);
|
||||
expect(controller.accountStatus, 'Bridge authorization is unavailable');
|
||||
expect(client.loadProfileCallCount, 1);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'login sync stores the current bridge contract from login payload',
|
||||
() async {
|
||||
test('login sync stores BRIDGE_AUTH_TOKEN from login payload', () async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-account-sync-uppercase-token-',
|
||||
);
|
||||
@ -89,7 +95,7 @@ void main() {
|
||||
accountClientFactory: (_) => _FakeAccountRuntimeClient(
|
||||
loginPayload: <String, dynamic>{
|
||||
'token': 'session-token',
|
||||
'INTERNAL_SERVICE_TOKEN': 'bridge-token-from-login',
|
||||
'BRIDGE_AUTH_TOKEN': 'bridge-token-from-login',
|
||||
'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-alt.svc.plus',
|
||||
'user': <String, dynamic>{
|
||||
'id': 'user-1',
|
||||
@ -131,6 +137,65 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'login sync ignores legacy INTERNAL_SERVICE_TOKEN fallback',
|
||||
() async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-account-sync-legacy-token-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
await storeRoot.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final store = SecureConfigStore(
|
||||
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
|
||||
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
|
||||
supportRootPathResolver: () async => '${storeRoot.path}/support',
|
||||
enableSecureStorage: false,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
accountBaseUrl: 'https://accounts.svc.plus',
|
||||
),
|
||||
);
|
||||
|
||||
final controller = SettingsController(
|
||||
store,
|
||||
accountClientFactory: (_) => _FakeAccountRuntimeClient(
|
||||
loginPayload: <String, dynamic>{
|
||||
'token': 'session-token',
|
||||
'INTERNAL_SERVICE_TOKEN': 'legacy-bridge-token',
|
||||
'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-alt.svc.plus',
|
||||
'user': <String, dynamic>{
|
||||
'id': 'user-1',
|
||||
'email': 'review@svc.plus',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.initialize();
|
||||
|
||||
await controller.loginAccount(
|
||||
baseUrl: 'https://accounts.svc.plus',
|
||||
identifier: 'review@svc.plus',
|
||||
password: 'password',
|
||||
);
|
||||
|
||||
expect(controller.accountSyncState, isNotNull);
|
||||
expect(controller.accountSyncState!.syncState, 'blocked');
|
||||
expect(
|
||||
await store.loadAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
),
|
||||
isNull,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('syncAccountSettings pins the managed bridge cloud entry', () async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-account-managed-bridge-',
|
||||
@ -160,7 +225,14 @@ void main() {
|
||||
value: 'bridge-token',
|
||||
);
|
||||
|
||||
final controller = SettingsController(store);
|
||||
final client = _FakeAccountRuntimeClient(
|
||||
loginPayload: const <String, dynamic>{},
|
||||
profilePayload: const <String, dynamic>{},
|
||||
);
|
||||
final controller = SettingsController(
|
||||
store,
|
||||
accountClientFactory: (_) => client,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.initialize();
|
||||
|
||||
@ -183,8 +255,77 @@ void main() {
|
||||
.endpoint,
|
||||
kManagedBridgeServerUrl,
|
||||
);
|
||||
expect(client.loadProfileCallCount, 1);
|
||||
});
|
||||
|
||||
test(
|
||||
'syncAccountSettings refreshes managed bridge contract from protected account profile',
|
||||
() async {
|
||||
final storeRoot = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-account-managed-bridge-refresh-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await storeRoot.exists()) {
|
||||
await storeRoot.delete(recursive: true);
|
||||
}
|
||||
});
|
||||
|
||||
final store = SecureConfigStore(
|
||||
secretRootPathResolver: () async => '${storeRoot.path}/secrets',
|
||||
appDataRootPathResolver: () async => '${storeRoot.path}/app-data',
|
||||
supportRootPathResolver: () async => '${storeRoot.path}/support',
|
||||
enableSecureStorage: false,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
accountBaseUrl: 'https://accounts.svc.plus',
|
||||
accountUsername: 'review@svc.plus',
|
||||
),
|
||||
);
|
||||
await store.saveAccountSessionToken('session-token');
|
||||
await store.saveAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
value: 'stale-bridge-token',
|
||||
);
|
||||
|
||||
final client = _FakeAccountRuntimeClient(
|
||||
loginPayload: const <String, dynamic>{},
|
||||
profilePayload: <String, dynamic>{
|
||||
'BRIDGE_AUTH_TOKEN': 'fresh-bridge-token',
|
||||
'BRIDGE_SERVER_URL': 'https://xworkmate-bridge-new.svc.plus',
|
||||
'user': <String, dynamic>{
|
||||
'id': 'user-1',
|
||||
'email': 'review@svc.plus',
|
||||
},
|
||||
},
|
||||
);
|
||||
final controller = SettingsController(
|
||||
store,
|
||||
accountClientFactory: (_) => client,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.initialize();
|
||||
|
||||
final result = await controller.syncAccountSettings(
|
||||
baseUrl: 'https://accounts.svc.plus',
|
||||
);
|
||||
|
||||
expect(result.state, 'ready');
|
||||
expect(client.loadProfileCallCount, 1);
|
||||
expect(
|
||||
await store.loadAccountManagedSecret(
|
||||
target: kAccountManagedSecretTargetBridgeAuthToken,
|
||||
),
|
||||
'fresh-bridge-token',
|
||||
);
|
||||
expect(
|
||||
controller.accountSyncState!.syncedDefaults.bridgeServerUrl,
|
||||
'https://xworkmate-bridge-new.svc.plus',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'does not recover bridge sync state from stale cloud-synced snapshot state',
|
||||
() async {
|
||||
@ -243,10 +384,15 @@ void main() {
|
||||
}
|
||||
|
||||
class _FakeAccountRuntimeClient extends AccountRuntimeClient {
|
||||
_FakeAccountRuntimeClient({required this.loginPayload})
|
||||
_FakeAccountRuntimeClient({
|
||||
required this.loginPayload,
|
||||
this.profilePayload = const <String, dynamic>{},
|
||||
})
|
||||
: super(baseUrl: 'https://accounts.svc.plus');
|
||||
|
||||
final Map<String, dynamic> loginPayload;
|
||||
final Map<String, dynamic> profilePayload;
|
||||
int loadProfileCallCount = 0;
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> login({
|
||||
@ -255,4 +401,10 @@ class _FakeAccountRuntimeClient extends AccountRuntimeClient {
|
||||
}) async {
|
||||
return loginPayload;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> loadProfile({required String token}) async {
|
||||
loadProfileCallCount += 1;
|
||||
return profilePayload;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user