From d524c740479d2446b67f33f0f41fa7dddd95d607 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 18 Mar 2026 17:04:00 +0800 Subject: [PATCH] Finish secure settings storage and refresh workspace UI --- ios/Podfile.lock | 34 ++ lib/app/app_controller.dart | 40 +- lib/app/app_shell.dart | 51 +- lib/features/assistant/assistant_page.dart | 480 +++++++----------- lib/features/settings/settings_page.dart | 376 ++++++++++++-- lib/features/skills/skills_page.dart | 93 ++-- lib/features/tasks/tasks_page.dart | 91 ++-- lib/runtime/runtime_controllers.dart | 12 + lib/runtime/secure_config_store.dart | 208 +++++++- lib/theme/app_palette.dart | 76 +-- lib/theme/app_theme.dart | 223 ++++---- lib/widgets/desktop_workspace_scaffold.dart | 16 +- lib/widgets/metric_card.dart | 4 +- lib/widgets/section_tabs.dart | 22 +- lib/widgets/sidebar_navigation.dart | 136 +++-- lib/widgets/status_badge.dart | 17 +- lib/widgets/surface_card.dart | 13 +- linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + macos/Podfile.lock | 34 ++ pubspec.lock | 16 + pubspec.yaml | 2 + test/features/assistant_page_test.dart | 19 +- test/features/settings_page_test.dart | 45 +- test/runtime/secure_config_store_test.dart | 103 +++- test/test_support.dart | 11 +- test/widget_test.dart | 3 +- .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 30 files changed, 1461 insertions(+), 675 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9ed44106..63145eb2 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -13,6 +13,31 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - sqlite3 (3.52.0): + - sqlite3/common (= 3.52.0) + - sqlite3/common (3.52.0) + - sqlite3/dbstatvtab (3.52.0): + - sqlite3/common + - sqlite3/fts5 (3.52.0): + - sqlite3/common + - sqlite3/math (3.52.0): + - sqlite3/common + - sqlite3/perf-threadsafe (3.52.0): + - sqlite3/common + - sqlite3/rtree (3.52.0): + - sqlite3/common + - sqlite3/session (3.52.0): + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - Flutter + - FlutterMacOS + - sqlite3 (~> 3.52.0) + - sqlite3/dbstatvtab + - sqlite3/fts5 + - sqlite3/math + - sqlite3/perf-threadsafe + - sqlite3/rtree + - sqlite3/session DEPENDENCIES: - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) @@ -22,6 +47,11 @@ DEPENDENCIES: - integration_test (from `.symlinks/plugins/integration_test/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) + +SPEC REPOS: + trunk: + - sqlite3 EXTERNAL SOURCES: device_info_plus: @@ -38,6 +68,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info_plus/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqlite3_flutter_libs: + :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" SPEC CHECKSUMS: device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe @@ -47,6 +79,8 @@ SPEC CHECKSUMS: integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 + sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index eefcedb9..71b3d608 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -86,6 +86,7 @@ class AppController extends ChangeNotifier { bool _initializing = true; String? _bootstrapError; StreamSubscription? _runtimeEventsSubscription; + bool _disposed = false; WorkspaceDestination get destination => _destination; ThemeMode get themeMode => _themeMode; @@ -693,6 +694,7 @@ class AppController extends ChangeNotifier { void clearRuntimeLogs() { _runtimeCoordinator.gateway.clearLogs(); + _notifyIfActive(); } List taskItemsForTab(String tab) => switch (tab) { @@ -790,6 +792,10 @@ class AppController extends ChangeNotifier { @override void dispose() { + if (_disposed) { + return; + } + _disposed = true; _runtimeEventsSubscription?.cancel(); _detachChildListeners(); _runtimeCoordinator.dispose(); @@ -804,19 +810,29 @@ class AppController extends ChangeNotifier { _cronJobsController.dispose(); _devicesController.dispose(); _tasksController.dispose(); + _store.dispose(); super.dispose(); } Future _initialize() async { try { await _settingsController.initialize(); + if (_disposed) { + return; + } final bootstrap = await RuntimeBootstrapConfig.load( workspacePathHint: settings.workspacePath, cliPathHint: settings.cliPath, ); + if (_disposed) { + return; + } final seeded = bootstrap.mergeIntoSettings(settings); if (seeded.toJsonString() != settings.toJsonString()) { await _settingsController.saveSnapshot(seeded); + if (_disposed) { + return; + } } final normalized = _sanitizeCodeAgentSettings( _settingsController.snapshot, @@ -824,11 +840,17 @@ class AppController extends ChangeNotifier { if (normalized.toJsonString() != _settingsController.snapshot.toJsonString()) { await _settingsController.saveSnapshot(normalized); + if (_disposed) { + return; + } } _modelsController.restoreFromSettings(settings.aiGateway); setActiveAppLanguage(settings.appLanguage); _registerCodexExternalProvider(); await _refreshCodexCliAvailability(); + if (_disposed) { + return; + } _agentsController.restoreSelection(settings.gateway.selectedAgentId); _sessionsController.configure( mainSessionKey: _runtime.snapshot.mainSessionKey ?? 'main', @@ -849,10 +871,15 @@ class AppController extends ChangeNotifier { } } } catch (error) { + if (_disposed) { + return; + } _bootstrapError = error.toString(); } finally { - _initializing = false; - notifyListeners(); + if (!_disposed) { + _initializing = false; + _notifyIfActive(); + } } } @@ -921,7 +948,7 @@ class AppController extends ChangeNotifier { _resolvedCodexCliPath = await _runtimeCoordinator.resolveCodexPath( codexPath: settings.codexCliPath, ); - notifyListeners(); + _notifyIfActive(); } Future _resolveCodexCliPath() async { @@ -1100,6 +1127,13 @@ class AppController extends ChangeNotifier { } void _relayChildChange() { + _notifyIfActive(); + } + + void _notifyIfActive() { + if (_disposed) { + return; + } notifyListeners(); } diff --git a/lib/app/app_shell.dart b/lib/app/app_shell.dart index 7cf61aa5..8f0a729f 100644 --- a/lib/app/app_shell.dart +++ b/lib/app/app_shell.dart @@ -43,10 +43,11 @@ class _AppShellState extends State { ]; double _clampSidebarWidth(double value, double viewportWidth) { - final responsiveMax = (viewportWidth - - _mainContentMinWidth - - _sidebarViewportPadding) - .clamp(_sidebarMinWidth, viewportWidth - _sidebarViewportPadding); + final responsiveMax = + (viewportWidth - _mainContentMinWidth - _sidebarViewportPadding).clamp( + _sidebarMinWidth, + viewportWidth - _sidebarViewportPadding, + ); return value.clamp(_sidebarMinWidth, responsiveMax).toDouble(); } @@ -271,9 +272,45 @@ class _AppShellState extends State { padding: EdgeInsets.only( right: showPinnedDetail ? 392 : 0, ), - child: Container( - color: palette.canvas, - child: _buildCurrentPage(controller.openDetail), + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + palette.canvas, + palette.surfaceSecondary.withValues( + alpha: 0.54, + ), + ], + ), + ), + child: Stack( + children: [ + Positioned( + top: -120, + right: -80, + child: IgnorePointer( + child: Container( + width: 360, + height: 360, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + palette.surfacePrimary + .withValues(alpha: 0.78), + palette.surfacePrimary + .withValues(alpha: 0), + ], + ), + ), + ), + ), + ), + _buildCurrentPage(controller.openDetail), + ], + ), ), ), ), diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 06e4c48e..40190a66 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -389,7 +389,6 @@ class _AssistantPageState extends State { : controller.resolvedDefaultModel, modelOptions: controller.aiGatewayModelChoices, attachments: _attachments, - autoAgentLabel: _lastAutoAgentLabel, controller: controller, onModeChanged: (value) => setState(() => _mode = value), onThinkingChanged: (value) { @@ -406,8 +405,6 @@ class _AssistantPageState extends State { onOpenGateway: _showConnectDialog, onReconnectGateway: _connectFromSavedSettingsOrShowDialog, onPickAttachments: _pickAttachments, - suggestions: _buildSuggestions(controller), - onSuggestionSelected: _applySuggestion, onSend: _submitPrompt, ), ), @@ -534,105 +531,6 @@ class _AssistantPageState extends State { }); } - void _applySuggestion(_AssistantSuggestion suggestion) { - final current = _inputController.text.trim(); - final next = current.isEmpty - ? suggestion.prompt - : '$current\n${suggestion.prompt}'; - _inputController.value = TextEditingValue( - text: next, - selection: TextSelection.collapsed(offset: next.length), - ); - _focusComposer(); - } - - List<_AssistantSuggestion> _buildSuggestions(AppController controller) { - final skillSuggestions = controller.skills - .where((item) => !item.disabled) - .take(6) - .map(_suggestionFromSkill) - .whereType<_AssistantSuggestion>() - .toList(growable: false); - if (skillSuggestions.isNotEmpty) { - return skillSuggestions; - } - return const [ - _AssistantSuggestion( - label: '幻灯片', - prompt: '帮我整理一份演示文稿的大纲和页面结构。', - icon: Icons.slideshow_outlined, - ), - _AssistantSuggestion( - label: '视频生成', - prompt: '帮我规划一个视频脚本、镜头拆解和生成步骤。', - icon: Icons.video_library_outlined, - ), - _AssistantSuggestion( - label: '深度研究', - prompt: '围绕这个主题先做深度研究,再给我结构化结论。', - icon: Icons.travel_explore_outlined, - ), - _AssistantSuggestion( - label: '自动化', - prompt: '帮我把这个重复流程拆成可执行的自动化任务。', - icon: Icons.auto_mode_outlined, - ), - ]; - } - - _AssistantSuggestion? _suggestionFromSkill(GatewaySkillSummary skill) { - final name = skill.name.trim(); - final lower = '$name ${skill.description}'.toLowerCase(); - if (lower.contains('ppt') || - lower.contains('slide') || - lower.contains('幻灯')) { - return _AssistantSuggestion( - label: appText('幻灯片', 'Slides'), - prompt: '使用 $name 帮我整理一份清晰的演示文稿结构。', - icon: Icons.slideshow_outlined, - ); - } - if (lower.contains('video') || lower.contains('视频')) { - return _AssistantSuggestion( - label: appText('视频生成', 'Video'), - prompt: '使用 $name 帮我规划视频脚本与生成步骤。', - icon: Icons.video_library_outlined, - ); - } - if (lower.contains('research') || - lower.contains('研究') || - lower.contains('paper')) { - return _AssistantSuggestion( - label: appText('深度研究', 'Research'), - prompt: '使用 $name 对这个主题做深度研究并输出结论。', - icon: Icons.travel_explore_outlined, - ); - } - if (lower.contains('browser') || - lower.contains('search') || - lower.contains('crawl')) { - return _AssistantSuggestion( - label: appText('网页处理', 'Web task'), - prompt: '使用 $name 帮我浏览网页并提取关键信息。', - icon: Icons.language_rounded, - ); - } - if (lower.contains('automation') || - lower.contains('workflow') || - lower.contains('自动')) { - return _AssistantSuggestion( - label: appText('自动化', 'Automation'), - prompt: '使用 $name 帮我设计一个自动化流程。', - icon: Icons.auto_mode_outlined, - ); - } - return _AssistantSuggestion( - label: name, - prompt: '使用 $name 处理这个任务:', - icon: Icons.auto_awesome_rounded, - ); - } - Future _submitPrompt() async { final controller = widget.controller; final settings = controller.settings; @@ -1141,10 +1039,14 @@ class _AssistantSideTabRail extends StatelessWidget { width: 58, decoration: BoxDecoration( color: palette.sidebar, - borderRadius: BorderRadius.circular(18), - border: Border.all( - color: palette.sidebarBorder.withValues(alpha: 0.72), - ), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.06), + blurRadius: 12, + offset: const Offset(0, 2), + ), + ], ), child: Column( children: [ @@ -1245,13 +1147,22 @@ class _AssistantSideTabButton extends StatelessWidget { width: 42, height: 42, decoration: BoxDecoration( - color: selected ? palette.accentMuted : Colors.transparent, + color: selected ? palette.surfacePrimary : Colors.transparent, borderRadius: BorderRadius.circular(14), + boxShadow: selected + ? [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : const [], ), child: Icon( icon, size: 20, - color: selected ? palette.accent : palette.textSecondary, + color: selected ? palette.textPrimary : palette.textSecondary, ), ), ), @@ -1270,7 +1181,6 @@ class _AssistantLowerPane extends StatelessWidget { required this.modelLabel, required this.modelOptions, required this.attachments, - required this.autoAgentLabel, required this.onModeChanged, required this.onThinkingChanged, required this.onModelChanged, @@ -1278,8 +1188,6 @@ class _AssistantLowerPane extends StatelessWidget { required this.onOpenGateway, required this.onReconnectGateway, required this.onPickAttachments, - required this.suggestions, - required this.onSuggestionSelected, required this.onSend, }); @@ -1291,7 +1199,6 @@ class _AssistantLowerPane extends StatelessWidget { final String modelLabel; final List modelOptions; final List<_ComposerAttachment> attachments; - final String? autoAgentLabel; final ValueChanged onModeChanged; final ValueChanged onThinkingChanged; final Future Function(String modelId) onModelChanged; @@ -1299,8 +1206,6 @@ class _AssistantLowerPane extends StatelessWidget { final VoidCallback onOpenGateway; final Future Function() onReconnectGateway; final VoidCallback onPickAttachments; - final List<_AssistantSuggestion> suggestions; - final ValueChanged<_AssistantSuggestion> onSuggestionSelected; final Future Function() onSend; @override @@ -1318,7 +1223,6 @@ class _AssistantLowerPane extends StatelessWidget { modelLabel: modelLabel, modelOptions: modelOptions, attachments: attachments, - autoAgentLabel: autoAgentLabel, onModeChanged: onModeChanged, onThinkingChanged: onThinkingChanged, onModelChanged: onModelChanged, @@ -1326,8 +1230,6 @@ class _AssistantLowerPane extends StatelessWidget { onOpenGateway: onOpenGateway, onReconnectGateway: onReconnectGateway, onPickAttachments: onPickAttachments, - suggestions: suggestions, - onSuggestionSelected: onSuggestionSelected, onSend: onSend, ), ), @@ -1420,7 +1322,9 @@ class _ConversationArea extends StatelessWidget { Divider(height: 1, color: palette.strokeSoft), Expanded( child: Container( - color: palette.surfaceSecondary, + decoration: BoxDecoration( + color: palette.canvas, + ), child: items.isEmpty ? _AssistantEmptyState( controller: controller, @@ -1702,9 +1606,7 @@ class _AssistantTaskTile extends StatelessWidget { final statusStyle = _pillStyleForStatus(context, entry.status); return Material( - color: entry.isCurrent - ? palette.accentMuted.withValues(alpha: 0.55) - : Colors.transparent, + color: entry.isCurrent ? palette.surfacePrimary : Colors.transparent, borderRadius: BorderRadius.circular(12), child: InkWell( key: ValueKey('assistant-task-item-${entry.sessionKey}'), @@ -1713,10 +1615,17 @@ class _AssistantTaskTile extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), decoration: BoxDecoration( + color: entry.isCurrent ? palette.surfaceSecondary : Colors.transparent, borderRadius: BorderRadius.circular(12), - border: Border.all( - color: entry.isCurrent ? palette.accent : palette.strokeSoft, - ), + boxShadow: entry.isCurrent + ? [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : const [], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1877,51 +1786,65 @@ class _AssistantEmptyState extends StatelessWidget { constraints: const BoxConstraints(maxWidth: 520), child: Padding( padding: const EdgeInsets.all(20), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: theme.textTheme.headlineSmall), - const SizedBox(height: 8), - Text(description, style: theme.textTheme.bodyMedium), - const SizedBox(height: 14), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.icon( - onPressed: connected - ? onFocusComposer - : reconnectAvailable - ? () async { - await onReconnectGateway(); - } - : onOpenGateway, - icon: Icon( - connected - ? Icons.edit_rounded - : reconnectAvailable - ? Icons.refresh_rounded - : Icons.link_rounded, - ), - label: Text( - connected - ? appText('开始输入', 'Start typing') - : reconnectAvailable - ? appText('重新连接', 'Reconnect') - : appText('连接 Gateway', 'Connect gateway'), - ), - ), - if (!connected) - OutlinedButton.icon( - onPressed: onOpenGateway, - icon: const Icon(Icons.settings_rounded), - label: Text(appText('编辑连接', 'Edit connection')), - ), - ], + child: Container( + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + color: context.palette.surfacePrimary.withValues(alpha: 0.92), + borderRadius: BorderRadius.circular(26), + boxShadow: [ + BoxShadow( + color: context.palette.shadow.withValues(alpha: 0.06), + blurRadius: 12, + offset: const Offset(0, 2), ), ], ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.headlineSmall), + const SizedBox(height: 8), + Text(description, style: theme.textTheme.bodyMedium), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: connected + ? onFocusComposer + : reconnectAvailable + ? () async { + await onReconnectGateway(); + } + : onOpenGateway, + icon: Icon( + connected + ? Icons.edit_rounded + : reconnectAvailable + ? Icons.refresh_rounded + : Icons.link_rounded, + ), + label: Text( + connected + ? appText('开始输入', 'Start typing') + : reconnectAvailable + ? appText('重新连接', 'Reconnect') + : appText('连接 Gateway', 'Connect gateway'), + ), + ), + if (!connected) + OutlinedButton.icon( + onPressed: onOpenGateway, + icon: const Icon(Icons.settings_rounded), + label: Text(appText('编辑连接', 'Edit connection')), + ), + ], + ), + ], + ), + ), ), ), ); @@ -1938,7 +1861,6 @@ class _ComposerBar extends StatelessWidget { required this.modelLabel, required this.modelOptions, required this.attachments, - required this.autoAgentLabel, required this.onModeChanged, required this.onThinkingChanged, required this.onModelChanged, @@ -1946,8 +1868,6 @@ class _ComposerBar extends StatelessWidget { required this.onOpenGateway, required this.onReconnectGateway, required this.onPickAttachments, - required this.suggestions, - required this.onSuggestionSelected, required this.onSend, }); @@ -1959,7 +1879,6 @@ class _ComposerBar extends StatelessWidget { final String modelLabel; final List modelOptions; final List<_ComposerAttachment> attachments; - final String? autoAgentLabel; final ValueChanged onModeChanged; final ValueChanged onThinkingChanged; final Future Function(String modelId) onModelChanged; @@ -1967,8 +1886,6 @@ class _ComposerBar extends StatelessWidget { final VoidCallback onOpenGateway; final Future Function() onReconnectGateway; final VoidCallback onPickAttachments; - final List<_AssistantSuggestion> suggestions; - final ValueChanged<_AssistantSuggestion> onSuggestionSelected; final Future Function() onSend; @override @@ -1987,12 +1904,8 @@ class _ComposerBar extends StatelessWidget { : palette.textSecondary; final permissionBackgroundColor = permissionLevel == AssistantPermissionLevel.fullAccess - ? const Color(0xFFFFF1E7) + ? palette.surfacePrimary : palette.surfaceSecondary; - final permissionBorderColor = - permissionLevel == AssistantPermissionLevel.fullAccess - ? const Color(0xFFFFD5B5) - : palette.strokeSoft; final submitLabel = connected ? (mode == 'ask' ? appText('提交', 'Submit') @@ -2004,7 +1917,7 @@ class _ComposerBar extends StatelessWidget { : appText('连接', 'Connect'); return SurfaceCard( - borderRadius: 0, + borderRadius: 24, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -2031,38 +1944,28 @@ class _ComposerBar extends StatelessWidget { minLines: 3, maxLines: 6, decoration: InputDecoration( - border: InputBorder.none, isCollapsed: true, + filled: true, + fillColor: palette.surfacePrimary, + contentPadding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: const BorderSide(color: Colors.transparent), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(22), + borderSide: BorderSide( + color: palette.accent.withValues(alpha: 0.18), + ), + ), hintText: appText( - '输入需求、补充上下文、继续追问,WorkBuddy 会沿用当前任务上下文持续处理。', - 'Describe the task, add context, or continue the thread. WorkBuddy keeps the current task context.', + '输入需求、补充上下文、继续追问,XWorkmate 会沿用当前任务上下文持续处理。', + 'Describe the task, add context, or continue the thread. XWorkmate keeps the current task context.', ), ), onSubmitted: (_) => onSend(), ), const SizedBox(height: 8), - if (suggestions.isNotEmpty) ...[ - SizedBox( - height: 34, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: suggestions.length, - separatorBuilder: (_, _) => const SizedBox(width: 8), - itemBuilder: (context, index) { - final suggestion = suggestions[index]; - return ActionChip( - key: ValueKey( - 'assistant-suggestion-${suggestion.label}', - ), - label: Text(suggestion.label), - avatar: Icon(suggestion.icon, size: 16), - onPressed: () => onSuggestionSelected(suggestion), - ); - }, - ), - ), - const SizedBox(height: 10), - ], Row( children: [ Expanded( @@ -2079,14 +1982,6 @@ class _ComposerBar extends StatelessWidget { case 'attach': onPickAttachments(); break; - case 'plan': - onModeChanged(mode == 'plan' ? 'ask' : 'plan'); - break; - case 'gateway': - onOpenGateway(); - break; - case 'route': - break; } }, itemBuilder: (context) => [ @@ -2098,48 +1993,6 @@ class _ComposerBar extends StatelessWidget { title: Text('添加照片和文件'), ), ), - PopupMenuItem( - value: 'plan', - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - mode == 'plan' - ? Icons.task_alt_rounded - : Icons.alt_route_rounded, - ), - title: Text( - mode == 'plan' - ? appText('退出计划模式', 'Exit plan mode') - : appText('计划模式', 'Plan mode'), - ), - ), - ), - PopupMenuItem( - value: 'gateway', - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - connected - ? Icons.lan_rounded - : Icons.link_rounded, - ), - title: Text(appText('连接网关', 'Connect gateway')), - ), - ), - PopupMenuItem( - value: 'route', - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.hub_rounded), - title: Text( - autoAgentLabel ?? - appText( - '浏览器 / 编码 / 研究', - 'Browser / Coding / Research', - ), - ), - ), - ), ], child: const _ComposerIconButton( icon: Icons.add_rounded, @@ -2221,7 +2074,6 @@ class _ComposerBar extends StatelessWidget { showChevron: true, maxLabelWidth: 112, backgroundColor: permissionBackgroundColor, - borderColor: permissionBorderColor, foregroundColor: permissionForegroundColor, ), ), @@ -2326,12 +2178,12 @@ class _ComposerBar extends StatelessWidget { : onOpenGateway, style: FilledButton.styleFrom( padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, + horizontal: 16, + vertical: 10, ), - minimumSize: const Size(80, 34), + minimumSize: const Size(94, 42), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(18), ), ), child: Row( @@ -2373,8 +2225,14 @@ class _ComposerIconButton extends StatelessWidget { height: 32, decoration: BoxDecoration( color: context.palette.surfaceSecondary, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: context.palette.strokeSoft), + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow( + color: context.palette.shadow.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Icon(icon, size: 16, color: context.palette.textMuted), ); @@ -2387,7 +2245,6 @@ class _ComposerToolbarChip extends StatelessWidget { required this.label, required this.showChevron, this.backgroundColor, - this.borderColor, this.foregroundColor, this.maxLabelWidth = 220, }); @@ -2396,7 +2253,6 @@ class _ComposerToolbarChip extends StatelessWidget { final String label; final bool showChevron; final Color? backgroundColor; - final Color? borderColor; final Color? foregroundColor; final double maxLabelWidth; @@ -2413,7 +2269,13 @@ class _ComposerToolbarChip extends StatelessWidget { decoration: BoxDecoration( color: backgroundColor ?? palette.surfaceSecondary, borderRadius: BorderRadius.circular(AppRadius.chip), - border: Border.all(color: borderColor ?? palette.strokeSoft), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Row( mainAxisSize: MainAxisSize.min, @@ -2463,9 +2325,9 @@ class _MessageBubble extends StatelessWidget { final theme = Theme.of(context); final palette = context.palette; final borderColor = switch (tone) { - _BubbleTone.user => theme.colorScheme.primary.withValues(alpha: 0.18), - _BubbleTone.agent => theme.colorScheme.tertiary.withValues(alpha: 0.18), - _BubbleTone.assistant => palette.strokeSoft, + _BubbleTone.user => theme.colorScheme.primary.withValues(alpha: 0.10), + _BubbleTone.agent => theme.colorScheme.tertiary.withValues(alpha: 0.10), + _BubbleTone.assistant => palette.surfaceSecondary, }; return Align( @@ -2475,9 +2337,15 @@ class _MessageBubble extends StatelessWidget { child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.white, + color: alignRight ? palette.accentMuted : palette.surfacePrimary, borderRadius: BorderRadius.circular(AppRadius.card), - border: Border.all(color: borderColor), + boxShadow: [ + BoxShadow( + color: borderColor.withValues(alpha: 0.24), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -2546,13 +2414,20 @@ class _TaskStatusCard extends StatelessWidget { child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 760), child: Material( - color: Colors.white, + color: palette.surfacePrimary, borderRadius: BorderRadius.circular(AppRadius.card), child: Container( padding: const EdgeInsets.all(AppSpacing.sm), decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppRadius.card), - border: Border.all(color: palette.strokeSoft), + color: palette.surfacePrimary, + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -2697,9 +2572,15 @@ class _ToolCallTileState extends State<_ToolCallTile> { constraints: const BoxConstraints(maxWidth: 760), child: Container( decoration: BoxDecoration( - color: Colors.white, + color: palette.surfacePrimary, borderRadius: BorderRadius.circular(AppRadius.card), - border: Border.all(color: palette.strokeSoft), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Column( children: [ @@ -2826,6 +2707,13 @@ class _StatusPill extends StatelessWidget { backgroundColor ?? Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(AppRadius.badge), + boxShadow: [ + BoxShadow( + color: context.palette.shadow.withValues(alpha: 0.03), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], ), child: Text( label, @@ -2847,12 +2735,11 @@ class _ConnectionChip extends StatelessWidget { final theme = Theme.of(context); final connection = controller.connection; final color = switch (connection.status) { - RuntimeConnectionStatus.connected => theme.colorScheme.primaryContainer, - RuntimeConnectionStatus.connecting => - theme.colorScheme.secondaryContainer, - RuntimeConnectionStatus.error => theme.colorScheme.errorContainer, - RuntimeConnectionStatus.offline => - theme.colorScheme.surfaceContainerHighest, + RuntimeConnectionStatus.connected => context.palette.accentMuted, + RuntimeConnectionStatus.connecting => context.palette.surfaceSecondary, + RuntimeConnectionStatus.error => + context.palette.danger.withValues(alpha: 0.10), + RuntimeConnectionStatus.offline => context.palette.surfaceSecondary, }; return Container( @@ -2863,6 +2750,13 @@ class _ConnectionChip extends StatelessWidget { decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(AppRadius.chip), + boxShadow: [ + BoxShadow( + color: context.palette.shadow.withValues(alpha: 0.03), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], ), child: Text( '${connection.status.label} · ${connection.remoteAddress ?? appText('未连接目标', 'No target')}', @@ -3061,7 +2955,13 @@ class _MetaPill extends StatelessWidget { decoration: BoxDecoration( color: palette.surfaceSecondary, borderRadius: BorderRadius.circular(999), - border: Border.all(color: palette.strokeSoft), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.03), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], ), child: Row( mainAxisSize: MainAxisSize.min, @@ -3093,19 +2993,19 @@ _PillStyle _pillStyleForStatus(BuildContext context, String label) { final normalized = _normalizedTaskStatus(label); return switch (normalized) { 'running' => _PillStyle( - backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.10), + backgroundColor: context.palette.accentMuted, foregroundColor: theme.colorScheme.primary, ), 'queued' => _PillStyle( - backgroundColor: theme.colorScheme.secondary.withValues(alpha: 0.10), - foregroundColor: theme.colorScheme.secondary, + backgroundColor: context.palette.surfaceSecondary, + foregroundColor: context.palette.textSecondary, ), 'failed' || 'error' => _PillStyle( - backgroundColor: theme.colorScheme.error.withValues(alpha: 0.10), + backgroundColor: context.palette.surfacePrimary, foregroundColor: theme.colorScheme.error, ), _ => _PillStyle( - backgroundColor: theme.colorScheme.tertiary.withValues(alpha: 0.12), + backgroundColor: context.palette.surfacePrimary, foregroundColor: theme.colorScheme.tertiary, ), }; @@ -3280,15 +3180,3 @@ class _ComposerAttachment { ); } } - -class _AssistantSuggestion { - const _AssistantSuggestion({ - required this.label, - required this.prompt, - required this.icon, - }); - - final String label; - final String prompt; - final IconData icon; -} diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index fee94ff5..6a98bba8 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -12,16 +12,23 @@ import '../../widgets/surface_card.dart'; import '../../widgets/top_bar.dart'; class SettingsPage extends StatefulWidget { - const SettingsPage({super.key, required this.controller}); + const SettingsPage({ + super.key, + required this.controller, + this.initialTab = SettingsTab.general, + }); final AppController controller; + final SettingsTab initialTab; @override State createState() => _SettingsPageState(); } class _SettingsPageState extends State { - SettingsTab _tab = SettingsTab.general; + static const _storedSecretMask = '****'; + + late SettingsTab _tab; late final TextEditingController _aiGatewayNameController; late final TextEditingController _aiGatewayUrlController; late final TextEditingController _aiGatewayApiKeyRefController; @@ -35,10 +42,14 @@ class _SettingsPageState extends State { String _aiGatewayTestState = 'idle'; String _aiGatewayTestMessage = ''; String _aiGatewayTestEndpoint = ''; + _SecretFieldUiState _aiGatewayApiKeyState = const _SecretFieldUiState(); + _SecretFieldUiState _vaultTokenState = const _SecretFieldUiState(); + _SecretFieldUiState _ollamaApiKeyState = const _SecretFieldUiState(); @override void initState() { super.initState(); + _tab = widget.initialTab; _aiGatewayNameController = TextEditingController(); _aiGatewayUrlController = TextEditingController(); _aiGatewayApiKeyRefController = TextEditingController(); @@ -236,6 +247,9 @@ class _SettingsPageState extends State { AppController controller, SettingsSnapshot settings, ) { + final hasStoredOllamaApiKey = + controller.settingsController.secureRefs['ollama_cloud_api_key'] != + null; return [ SurfaceCard( child: Column( @@ -392,20 +406,36 @@ class _SettingsPageState extends State { ), ), ), - TextField( + _buildSecureField( controller: _ollamaApiKeyController, - obscureText: true, - decoration: InputDecoration( - labelText: - '${appText('API Key', 'API Key')} (${settings.ollamaCloud.apiKeyRef})', - ), + label: + '${appText('API Key', 'API Key')} (${settings.ollamaCloud.apiKeyRef})', + hasStoredValue: hasStoredOllamaApiKey, + fieldState: _ollamaApiKeyState, + onStateChanged: (value) => + setState(() => _ollamaApiKeyState = value), + loadValue: controller.settingsController.loadOllamaCloudApiKey, onSubmitted: controller.settingsController.saveOllamaCloudApiKey, + storedHelperText: appText( + '已安全保存,默认以 **** 显示,点击查看后读取真实值。', + 'Stored securely. Shows as **** until you reveal it.', + ), + emptyHelperText: appText( + '输入后会安全保存到本机密钥存储。', + 'Saving writes to secure local key storage.', + ), ), const SizedBox(height: 12), Align( alignment: Alignment.centerLeft, child: OutlinedButton( - onPressed: () => controller.testOllamaConnection(cloud: true), + onPressed: () async { + await _persistOllamaApiKeyIfNeeded( + controller, + hasStoredValue: hasStoredOllamaApiKey, + ); + await controller.testOllamaConnection(cloud: true); + }, child: Text( '${appText('测试云端', 'Test Cloud')} · ${controller.settingsController.ollamaStatus}', ), @@ -436,6 +466,8 @@ class _SettingsPageState extends State { ); final hasStoredAiGatewayApiKey = controller.settingsController.secureRefs['ai_gateway_api_key'] != null; + final hasStoredVaultToken = + controller.settingsController.secureRefs['vault_token'] != null; final statusTheme = _aiGatewayFeedbackTheme( context, _aiGatewayTestMessage.isEmpty @@ -554,20 +586,36 @@ class _SettingsPageState extends State { ), ), ), - TextField( + _buildSecureField( controller: _vaultTokenController, - obscureText: true, - decoration: InputDecoration( - labelText: - '${appText('Vault Token', 'Vault Token')} (${settings.vault.tokenRef})', - ), + label: + '${appText('Vault Token', 'Vault Token')} (${settings.vault.tokenRef})', + hasStoredValue: hasStoredVaultToken, + fieldState: _vaultTokenState, + onStateChanged: (value) => + setState(() => _vaultTokenState = value), + loadValue: controller.settingsController.loadVaultToken, onSubmitted: controller.settingsController.saveVaultToken, + storedHelperText: appText( + '已安全保存,默认以 **** 显示,点击查看后读取真实值。', + 'Stored securely. Shows as **** until you reveal it.', + ), + emptyHelperText: appText( + '输入后会安全保存到本机密钥存储。', + 'Saving writes to secure local key storage.', + ), ), const SizedBox(height: 12), Align( alignment: Alignment.centerLeft, child: OutlinedButton( - onPressed: controller.testVaultConnection, + onPressed: () async { + await _persistVaultTokenIfNeeded( + controller, + hasStoredValue: hasStoredVaultToken, + ); + await controller.testVaultConnection(); + }, child: Text( '${appText('测试 Vault', 'Test Vault')} · ${controller.settingsController.vaultStatus}', ), @@ -609,23 +657,24 @@ class _SettingsPageState extends State { ), onSubmitted: (_) => _saveAiGatewayDraft(controller, settings), ), - TextField( + _buildSecureField( controller: _aiGatewayApiKeyController, - obscureText: true, - decoration: InputDecoration( - labelText: - '${appText('API Key', 'API Key')} (${_aiGatewayApiKeyRefController.text.trim().isEmpty ? settings.aiGateway.apiKeyRef : _aiGatewayApiKeyRefController.text.trim()})', - helperText: hasStoredAiGatewayApiKey - ? appText( - '已安全保存,可直接同步模型。', - 'Stored securely and ready to sync.', - ) - : appText( - '输入后点击保存或同步模型。', - 'Save or sync to persist securely.', - ), - ), + label: + '${appText('API Key', 'API Key')} (${_aiGatewayApiKeyRefController.text.trim().isEmpty ? settings.aiGateway.apiKeyRef : _aiGatewayApiKeyRefController.text.trim()})', + hasStoredValue: hasStoredAiGatewayApiKey, + fieldState: _aiGatewayApiKeyState, + onStateChanged: (value) => + setState(() => _aiGatewayApiKeyState = value), + loadValue: controller.settingsController.loadAiGatewayApiKey, onSubmitted: controller.settingsController.saveAiGatewayApiKey, + storedHelperText: appText( + '已安全保存,默认以 **** 显示;可直接测试/同步,也可点击查看。', + 'Stored securely. Test or sync directly, or reveal it on demand.', + ), + emptyHelperText: appText( + '输入后点击保存或同步模型。', + 'Save or sync to persist securely.', + ), ), const SizedBox(height: 12), Wrap( @@ -657,14 +706,16 @@ class _SettingsPageState extends State { } final messenger = ScaffoldMessenger.of(context); final draft = _buildAiGatewayDraft(settings); - final apiKey = _aiGatewayApiKeyController.text.trim(); + final apiKey = _secretOverride( + _aiGatewayApiKeyController, + _aiGatewayApiKeyState, + ); setState(() => _aiGatewaySyncing = true); try { - if (apiKey.isNotEmpty) { - await controller.settingsController.saveAiGatewayApiKey( - apiKey, - ); - } + await _persistAiGatewayApiKeyIfNeeded( + controller, + hasStoredValue: hasStoredAiGatewayApiKey, + ); await _saveSettings( controller, settings.copyWith(aiGateway: draft), @@ -1147,10 +1198,12 @@ class _SettingsPageState extends State { AppController controller, SettingsSnapshot settings, ) async { - final apiKey = _aiGatewayApiKeyController.text.trim(); - if (apiKey.isNotEmpty) { - await controller.settingsController.saveAiGatewayApiKey(apiKey); - } + final hasStoredAiGatewayApiKey = + controller.settingsController.secureRefs['ai_gateway_api_key'] != null; + await _persistAiGatewayApiKeyIfNeeded( + controller, + hasStoredValue: hasStoredAiGatewayApiKey, + ); await _saveSettings( controller, settings.copyWith(aiGateway: _buildAiGatewayDraft(settings)), @@ -1163,7 +1216,10 @@ class _SettingsPageState extends State { ) async { final messenger = ScaffoldMessenger.of(context); final draft = _buildAiGatewayDraft(settings); - final apiKey = _aiGatewayApiKeyController.text.trim(); + final apiKey = _secretOverride( + _aiGatewayApiKeyController, + _aiGatewayApiKeyState, + ); setState(() => _aiGatewayTesting = true); try { final result = await controller.settingsController @@ -1218,6 +1274,218 @@ class _SettingsPageState extends State { .toString(); } + Widget _buildSecureField({ + required TextEditingController controller, + required String label, + required bool hasStoredValue, + required _SecretFieldUiState fieldState, + required ValueChanged<_SecretFieldUiState> onStateChanged, + required Future Function() loadValue, + required Future Function(String) onSubmitted, + required String storedHelperText, + required String emptyHelperText, + }) { + _primeSecureFieldController( + controller, + hasStoredValue: hasStoredValue, + fieldState: fieldState, + ); + final showMaskedPlaceholder = + hasStoredValue && !fieldState.showPlaintext && !fieldState.hasDraft; + return TextField( + controller: controller, + obscureText: !fieldState.showPlaintext && fieldState.hasDraft, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: label, + helperText: hasStoredValue ? storedHelperText : emptyHelperText, + suffixIcon: fieldState.loading + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : IconButton( + tooltip: fieldState.showPlaintext + ? appText('隐藏', 'Hide') + : appText('查看', 'Reveal'), + onPressed: () => _toggleSecureFieldVisibility( + controller: controller, + hasStoredValue: hasStoredValue, + fieldState: fieldState, + onStateChanged: onStateChanged, + loadValue: loadValue, + ), + icon: Icon( + fieldState.showPlaintext + ? Icons.visibility_off_rounded + : Icons.visibility_rounded, + ), + ), + ), + onTap: () { + if (!showMaskedPlaceholder) { + return; + } + controller.clear(); + onStateChanged(fieldState.copyWith(hasDraft: true)); + }, + onChanged: (value) { + if (value == _storedSecretMask) { + return; + } + final nextHasDraft = value.trim().isNotEmpty; + if (nextHasDraft == fieldState.hasDraft) { + return; + } + onStateChanged(fieldState.copyWith(hasDraft: nextHasDraft)); + }, + onSubmitted: (_) => _persistSecureFieldIfNeeded( + controller: controller, + hasStoredValue: hasStoredValue, + fieldState: fieldState, + onStateChanged: onStateChanged, + onSubmitted: onSubmitted, + ), + ); + } + + Future _toggleSecureFieldVisibility({ + required TextEditingController controller, + required bool hasStoredValue, + required _SecretFieldUiState fieldState, + required ValueChanged<_SecretFieldUiState> onStateChanged, + required Future Function() loadValue, + }) async { + if (fieldState.showPlaintext) { + if (fieldState.hasDraft) { + onStateChanged(fieldState.copyWith(showPlaintext: false)); + return; + } + if (hasStoredValue) { + _syncControllerValue(controller, _storedSecretMask); + } else { + controller.clear(); + } + onStateChanged(const _SecretFieldUiState()); + return; + } + if (fieldState.hasDraft || !hasStoredValue) { + onStateChanged(fieldState.copyWith(showPlaintext: true, loading: false)); + return; + } + onStateChanged(fieldState.copyWith(loading: true)); + final value = (await loadValue()).trim(); + if (!mounted) { + return; + } + if (value.isNotEmpty) { + _syncControllerValue(controller, value); + } else { + controller.clear(); + } + onStateChanged( + const _SecretFieldUiState(showPlaintext: true, hasDraft: false), + ); + } + + Future _persistSecureFieldIfNeeded({ + required TextEditingController controller, + required bool hasStoredValue, + required _SecretFieldUiState fieldState, + required ValueChanged<_SecretFieldUiState> onStateChanged, + required Future Function(String) onSubmitted, + }) async { + final value = _normalizeSecretValue(controller.text); + if (value.isEmpty) { + return; + } + if (!fieldState.hasDraft && hasStoredValue) { + return; + } + await onSubmitted(value); + if (!mounted) { + return; + } + _syncControllerValue(controller, _storedSecretMask); + onStateChanged(const _SecretFieldUiState()); + } + + Future _persistAiGatewayApiKeyIfNeeded( + AppController controller, { + required bool hasStoredValue, + }) { + return _persistSecureFieldIfNeeded( + controller: _aiGatewayApiKeyController, + hasStoredValue: hasStoredValue, + fieldState: _aiGatewayApiKeyState, + onStateChanged: (value) => setState(() => _aiGatewayApiKeyState = value), + onSubmitted: controller.settingsController.saveAiGatewayApiKey, + ); + } + + Future _persistVaultTokenIfNeeded( + AppController controller, { + required bool hasStoredValue, + }) { + return _persistSecureFieldIfNeeded( + controller: _vaultTokenController, + hasStoredValue: hasStoredValue, + fieldState: _vaultTokenState, + onStateChanged: (value) => setState(() => _vaultTokenState = value), + onSubmitted: controller.settingsController.saveVaultToken, + ); + } + + Future _persistOllamaApiKeyIfNeeded( + AppController controller, { + required bool hasStoredValue, + }) { + return _persistSecureFieldIfNeeded( + controller: _ollamaApiKeyController, + hasStoredValue: hasStoredValue, + fieldState: _ollamaApiKeyState, + onStateChanged: (value) => setState(() => _ollamaApiKeyState = value), + onSubmitted: controller.settingsController.saveOllamaCloudApiKey, + ); + } + + void _primeSecureFieldController( + TextEditingController controller, { + required bool hasStoredValue, + required _SecretFieldUiState fieldState, + }) { + if (fieldState.showPlaintext || fieldState.hasDraft) { + return; + } + final nextValue = hasStoredValue ? _storedSecretMask : ''; + if (controller.text == nextValue) { + return; + } + _syncControllerValue(controller, nextValue); + } + + String _secretOverride( + TextEditingController controller, + _SecretFieldUiState fieldState, + ) { + if (!fieldState.showPlaintext && !fieldState.hasDraft) { + return ''; + } + return _normalizeSecretValue(controller.text); + } + + String _normalizeSecretValue(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty || trimmed == _storedSecretMask) { + return ''; + } + return trimmed; + } + _AiGatewayFeedbackTheme _aiGatewayFeedbackTheme( BuildContext context, String state, @@ -1830,6 +2098,30 @@ class _AiGatewayFeedbackTheme { final Color foreground; } +class _SecretFieldUiState { + const _SecretFieldUiState({ + this.showPlaintext = false, + this.hasDraft = false, + this.loading = false, + }); + + final bool showPlaintext; + final bool hasDraft; + final bool loading; + + _SecretFieldUiState copyWith({ + bool? showPlaintext, + bool? hasDraft, + bool? loading, + }) { + return _SecretFieldUiState( + showPlaintext: showPlaintext ?? this.showPlaintext, + hasDraft: hasDraft ?? this.hasDraft, + loading: loading ?? this.loading, + ); + } +} + class _InfoRow extends StatelessWidget { const _InfoRow({required this.label, required this.value}); diff --git a/lib/features/skills/skills_page.dart b/lib/features/skills/skills_page.dart index 022efcb4..6f8c5f05 100644 --- a/lib/features/skills/skills_page.dart +++ b/lib/features/skills/skills_page.dart @@ -7,6 +7,7 @@ import '../../runtime/runtime_models.dart'; import '../../theme/app_palette.dart'; import '../../widgets/desktop_workspace_scaffold.dart'; import '../../widgets/status_badge.dart'; +import '../../widgets/surface_card.dart'; class SkillsPage extends StatefulWidget { const SkillsPage({ @@ -101,33 +102,37 @@ class _SkillsPageState extends State { ), ], ), - child: Container( - margin: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all(color: context.palette.strokeSoft), - ), - child: Row( - children: [ - SizedBox( - width: 360, - child: _SkillsListPanel( - skills: skills, - selectedSkillKey: selected?.skillKey, - onSelectSkill: (skill) { - setState(() { - _selectedSkillKey = skill.skillKey; - }); - }, - ), + child: Padding( + padding: const EdgeInsets.all(16), + child: SurfaceCard( + padding: EdgeInsets.zero, + borderRadius: 20, + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Row( + children: [ + SizedBox( + width: 360, + child: _SkillsListPanel( + skills: skills, + selectedSkillKey: selected?.skillKey, + onSelectSkill: (skill) { + setState(() { + _selectedSkillKey = skill.skillKey; + }); + }, + ), + ), + Container(width: 1, color: context.palette.strokeSoft), + Expanded( + child: _SkillDetailPanel( + controller: controller, + selected: selected, + ), + ), + ], ), - Container(width: 1, color: context.palette.strokeSoft), - Expanded( - child: _SkillDetailPanel( - controller: controller, - selected: selected, - ), - ), - ], + ), ), ), ); @@ -249,15 +254,25 @@ class _SkillListTile extends StatelessWidget { Widget build(BuildContext context) { final palette = context.palette; return Material( - color: selected ? palette.accentMuted.withValues(alpha: 0.4) : null, + color: selected ? palette.surfacePrimary : Colors.transparent, + borderRadius: BorderRadius.circular(18), child: InkWell( onTap: onTap, + borderRadius: BorderRadius.circular(18), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - border: Border.all( - color: selected ? palette.accent : palette.strokeSoft, - ), + borderRadius: BorderRadius.circular(18), + color: selected ? palette.surfaceSecondary : Colors.transparent, + boxShadow: selected + ? [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : const [], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -397,7 +412,14 @@ class _SkillDetailPanel extends StatelessWidget { padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: palette.surfaceSecondary, - border: Border.all(color: palette.strokeSoft), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -464,7 +486,14 @@ class _DependencyCard extends StatelessWidget { padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: palette.surfaceSecondary, - border: Border.all(color: palette.strokeSoft), + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/features/tasks/tasks_page.dart b/lib/features/tasks/tasks_page.dart index 63878525..008266f8 100644 --- a/lib/features/tasks/tasks_page.dart +++ b/lib/features/tasks/tasks_page.dart @@ -9,6 +9,7 @@ import '../../widgets/desktop_workspace_scaffold.dart'; import '../../widgets/metric_card.dart'; import '../../widgets/section_tabs.dart'; import '../../widgets/status_badge.dart'; +import '../../widgets/surface_card.dart'; class TasksPage extends StatefulWidget { const TasksPage({ @@ -166,34 +167,36 @@ class _TasksPageState extends State { ), const SizedBox(height: 16), Expanded( - child: Container( - decoration: BoxDecoration( - border: Border.all(color: palette.strokeSoft), - ), - child: Row( - children: [ - SizedBox( - width: 360, - child: _TaskListPanel( - tab: _tab, - items: items, - selectedTaskId: selected?.id, - onSelectTask: (task) { - setState(() { - _selectedTaskId = task.id; - }); - }, + child: SurfaceCard( + padding: EdgeInsets.zero, + borderRadius: 20, + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Row( + children: [ + SizedBox( + width: 360, + child: _TaskListPanel( + tab: _tab, + items: items, + selectedTaskId: selected?.id, + onSelectTask: (task) { + setState(() { + _selectedTaskId = task.id; + }); + }, + ), ), - ), - Container(width: 1, color: palette.strokeSoft), - Expanded( - child: _TaskDetailPanel( - controller: controller, - tab: _tab, - selected: selected, + Container(width: 1, color: palette.strokeSoft), + Expanded( + child: _TaskDetailPanel( + controller: controller, + tab: _tab, + selected: selected, + ), ), - ), - ], + ], + ), ), ), ), @@ -325,16 +328,26 @@ class _TaskListTile extends StatelessWidget { Widget build(BuildContext context) { final palette = context.palette; return Material( - color: selected ? palette.accentMuted.withValues(alpha: 0.4) : null, + color: selected ? palette.surfacePrimary : Colors.transparent, + borderRadius: BorderRadius.circular(18), child: InkWell( key: ValueKey('tasks-list-item-${task.id}'), onTap: onTap, + borderRadius: BorderRadius.circular(18), child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - border: Border.all( - color: selected ? palette.accent : palette.strokeSoft, - ), + color: selected ? palette.surfaceSecondary : Colors.transparent, + borderRadius: BorderRadius.circular(18), + boxShadow: selected + ? [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : const [], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -466,7 +479,14 @@ class _TaskDetailPanel extends StatelessWidget { padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: palette.surfaceSecondary, - border: Border.all(color: palette.strokeSoft), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -525,7 +545,14 @@ class _DetailStat extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: palette.surfaceSecondary, - border: Border.all(color: palette.strokeSoft), + borderRadius: BorderRadius.circular(18), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/runtime/runtime_controllers.dart b/lib/runtime/runtime_controllers.dart index e7e30670..36be10a9 100644 --- a/lib/runtime/runtime_controllers.dart +++ b/lib/runtime/runtime_controllers.dart @@ -135,6 +135,10 @@ class SettingsController extends ChangeNotifier { notifyListeners(); } + Future loadOllamaCloudApiKey() async { + return (await _store.loadOllamaCloudApiKey())?.trim() ?? ''; + } + Future saveVaultToken(String value) async { final trimmed = value.trim(); if (trimmed.isEmpty) { @@ -155,6 +159,10 @@ class SettingsController extends ChangeNotifier { notifyListeners(); } + Future loadVaultToken() async { + return (await _store.loadVaultToken())?.trim() ?? ''; + } + Future saveAiGatewayApiKey(String value) async { final trimmed = value.trim(); if (trimmed.isEmpty) { @@ -175,6 +183,10 @@ class SettingsController extends ChangeNotifier { notifyListeners(); } + Future loadAiGatewayApiKey() async { + return (await _store.loadAiGatewayApiKey())?.trim() ?? ''; + } + Future appendAudit(SecretAuditEntry entry) async { await _store.appendAudit(entry); _auditTrail = await _store.loadAuditTrail(); diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index ecec7be2..86e87a58 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -4,15 +4,24 @@ import 'dart:io'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sqlite3/sqlite3.dart' as sqlite; import 'runtime_models.dart'; class SecureConfigStore { - SecureConfigStore({Future Function()? fallbackDirectoryPathResolver}) - : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver; + SecureConfigStore({ + Future Function()? fallbackDirectoryPathResolver, + Future Function()? databasePathResolver, + bool enableSecureStorage = true, + }) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver, + _databasePathResolver = databasePathResolver, + _enableSecureStorage = enableSecureStorage; static const _settingsKey = 'xworkmate.settings.snapshot'; static const _auditKey = 'xworkmate.secrets.audit'; + static const _databaseFileName = 'config-store.sqlite3'; + static const _databaseTableName = 'config_entries'; + static const _secureStorageTimeout = Duration(milliseconds: 400); static const _gatewayTokenKey = 'xworkmate.gateway.token'; static const _gatewayPasswordKey = 'xworkmate.gateway.password'; @@ -27,10 +36,13 @@ class SecureConfigStore { static const _aiGatewayApiKeyKey = 'xworkmate.ai_gateway.api_key'; SharedPreferences? _prefs; + sqlite.Database? _database; FlutterSecureStorage? _secureStorage; - final Map _memoryPrefs = {}; + final Map _memoryStore = {}; final Map _memorySecure = {}; final Future Function()? _fallbackDirectoryPathResolver; + final Future Function()? _databasePathResolver; + final bool _enableSecureStorage; bool _initialized = false; Future initialize() async { @@ -42,27 +54,32 @@ class SecureConfigStore { } catch (_) { _prefs = null; } - try { - _secureStorage = const FlutterSecureStorage(); - } catch (_) { - _secureStorage = null; + await _initializeDatabase(); + if (_enableSecureStorage) { + try { + _secureStorage = const FlutterSecureStorage(); + } catch (_) { + _secureStorage = null; + } } _initialized = true; } Future loadSettingsSnapshot() async { await initialize(); - return SettingsSnapshot.fromJsonString(await _readPrefString(_settingsKey)); + return SettingsSnapshot.fromJsonString( + await _readStoredString(_settingsKey), + ); } Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { await initialize(); - await _writePrefString(_settingsKey, snapshot.toJsonString()); + await _writeStoredString(_settingsKey, snapshot.toJsonString()); } Future> loadAuditTrail() async { await initialize(); - final raw = await _readPrefString(_auditKey); + final raw = await _readStoredString(_auditKey); if (raw == null || raw.trim().isEmpty) { return const []; } @@ -86,7 +103,7 @@ class SecureConfigStore { if (items.length > 40) { items.removeRange(40, items.length); } - await _writePrefString( + await _writeStoredString( _auditKey, jsonEncode(items.map((item) => item.toJson()).toList(growable: false)), ); @@ -237,27 +254,151 @@ class SecureConfigStore { }; } - Future _readPrefString(String key) async { - if (_prefs != null) { - return _prefs!.getString(key); + Future _initializeDatabase() async { + final resolvedPath = await _resolveDatabasePath(); + if (resolvedPath != null && resolvedPath.trim().isNotEmpty) { + try { + final file = File(resolvedPath); + await file.parent.create(recursive: true); + final database = sqlite.sqlite3.open(file.path); + _configureDatabase(database); + _database = database; + } catch (_) { + _database = null; + } } - final value = _memoryPrefs[key]; - return value is String ? value : null; + if (_database == null) { + try { + final database = sqlite.sqlite3.openInMemory(); + _configureDatabase(database); + _database = database; + } catch (_) { + _database = null; + } + } + await _migrateLegacyPrefs(); } - Future _writePrefString(String key, String value) async { - if (_prefs != null) { - await _prefs!.setString(key, value); + void _configureDatabase(sqlite.Database database) { + database.execute(''' + CREATE TABLE IF NOT EXISTS $_databaseTableName ( + storage_key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at_ms INTEGER NOT NULL + ) + '''); + } + + Future _migrateLegacyPrefs() async { + if (_database == null || _prefs == null) { return; } - _memoryPrefs[key] = value; + await _migrateLegacyPrefEntry(_settingsKey); + await _migrateLegacyPrefEntry(_auditKey); + } + + Future _migrateLegacyPrefEntry(String key) async { + if (_database == null || _prefs == null) { + return; + } + try { + final existing = _database!.select( + 'SELECT value FROM $_databaseTableName WHERE storage_key = ? LIMIT 1', + [key], + ); + if (existing.isNotEmpty) { + return; + } + final legacyValue = _prefs!.getString(key); + if (legacyValue == null || legacyValue.trim().isEmpty) { + return; + } + _writeStoredStringInternal(key, legacyValue); + } catch (_) { + return; + } + } + + Future _resolveDatabasePath() async { + try { + final resolvedPath = await _databasePathResolver?.call(); + final trimmed = resolvedPath?.trim() ?? ''; + if (trimmed.isNotEmpty) { + return trimmed; + } + } catch (_) { + // Fall through to the default locations. + } + try { + final supportDirectory = await getApplicationSupportDirectory(); + return '${supportDirectory.path}/xworkmate/$_databaseFileName'; + } catch (_) { + final fallbackRoot = await _fallbackDirectoryPathResolver?.call(); + final trimmed = fallbackRoot?.trim() ?? ''; + if (trimmed.isEmpty) { + return null; + } + return '$trimmed/$_databaseFileName'; + } + } + + Future _readStoredString(String key) async { + if (_database != null) { + try { + final result = _database!.select( + 'SELECT value FROM $_databaseTableName WHERE storage_key = ? LIMIT 1', + [key], + ); + if (result.isNotEmpty) { + final value = result.first['value']; + if (value is String) { + return value; + } + } + } catch (_) { + // Fall through to the in-memory fallback. + } + } + return _memoryStore[key]; + } + + Future _writeStoredString(String key, String value) async { + if (_database != null) { + try { + _writeStoredStringInternal(key, value); + return; + } catch (_) { + // Fall through to the in-memory fallback. + } + } + _memoryStore[key] = value; + } + + void _writeStoredStringInternal(String key, String value) { + if (_database == null) { + _memoryStore[key] = value; + return; + } + _database!.execute( + ''' + INSERT INTO $_databaseTableName (storage_key, value, updated_at_ms) + VALUES (?, ?, ?) + ON CONFLICT(storage_key) DO UPDATE SET + value = excluded.value, + updated_at_ms = excluded.updated_at_ms + ''', + [key, value, DateTime.now().millisecondsSinceEpoch], + ); } Future _readSecure(String key) async { if (_secureStorage != null) { try { - return await _secureStorage!.read(key: key); + return await _secureStorage! + .read(key: key) + .timeout(_secureStorageTimeout); } catch (_) { + _secureStorage = null; // Fall back to in-memory storage for tests and unsupported runners. } } @@ -267,9 +408,12 @@ class SecureConfigStore { Future _writeSecure(String key, String value) async { if (_secureStorage != null) { try { - await _secureStorage!.write(key: key, value: value); + await _secureStorage! + .write(key: key, value: value) + .timeout(_secureStorageTimeout); return; } catch (_) { + _secureStorage = null; // Fall back to in-memory storage for tests and unsupported runners. } } @@ -279,14 +423,32 @@ class SecureConfigStore { Future _deleteSecure(String key) async { if (_secureStorage != null) { try { - await _secureStorage!.delete(key: key); + await _secureStorage!.delete(key: key).timeout(_secureStorageTimeout); } catch (_) { + _secureStorage = null; // Keep the in-memory fallback in sync. } } _memorySecure.remove(key); } + void dispose() { + final database = _database; + _database = null; + if (database != null) { + try { + database.dispose(); + } catch (_) { + // Ignore close errors during teardown. + } + } + _prefs = null; + _secureStorage = null; + _initialized = false; + _memoryStore.clear(); + _memorySecure.clear(); + } + static String maskValue(String value) { final trimmed = value.trim(); if (trimmed.isEmpty) { diff --git a/lib/theme/app_palette.dart b/lib/theme/app_palette.dart index 4fc5772b..6f966e88 100644 --- a/lib/theme/app_palette.dart +++ b/lib/theme/app_palette.dart @@ -47,49 +47,49 @@ class AppPalette extends ThemeExtension { final Color hover; static const AppPalette light = AppPalette( - canvas: Color(0xFFF8FAFC), - sidebar: Color(0xFFF8FAFC), - sidebarBorder: Color(0xFFE5E7EB), + canvas: Color(0xFFF5F5F7), + sidebar: Color(0xFFF1F1F3), + sidebarBorder: Color(0xFFE5E5EA), surfacePrimary: Color(0xFFFFFFFF), - surfaceSecondary: Color(0xFFF8FAFC), - surfaceTertiary: Color(0xFFF1F5F9), - stroke: Color(0xFFE5E7EB), - strokeSoft: Color(0xFFF1F5F9), - accent: Color(0xFF3B82F6), - accentHover: Color(0xFF2563EB), - accentMuted: Color(0xFFDBEAFE), - idle: Color(0xFF94A3B8), - success: Color(0xFF22C55E), - warning: Color(0xFFF59E0B), - danger: Color(0xFFEF4444), - textPrimary: Color(0xFF111827), - textSecondary: Color(0xFF6B7280), - textMuted: Color(0xFF64748B), - shadow: Color(0x0F0F172A), - hover: Color(0xFFEFF6FF), + surfaceSecondary: Color(0xFFF1F1F3), + surfaceTertiary: Color(0xFFECECEF), + stroke: Color(0xFFE5E5EA), + strokeSoft: Color(0xFFECECEF), + accent: Color(0xFF4C8BF5), + accentHover: Color(0xFF5E98F6), + accentMuted: Color(0xFFEEF4FF), + idle: Color(0xFFA1A1A6), + success: Color(0xFF34C759), + warning: Color(0xFFFF9F0A), + danger: Color(0xFFFF3B30), + textPrimary: Color(0xFF0A0A0A), + textSecondary: Color(0xFF6B6B6F), + textMuted: Color(0xFFA1A1A6), + shadow: Color(0x0A000000), + hover: Color(0xFFE5E5EA), ); static const AppPalette dark = AppPalette( - canvas: Color(0xFF0B1220), - sidebar: Color(0xFF0F172A), - sidebarBorder: Color(0xFF1E293B), - surfacePrimary: Color(0xFF111827), - surfaceSecondary: Color(0xFF0F172A), - surfaceTertiary: Color(0xFF172033), - stroke: Color(0xFF223046), - strokeSoft: Color(0xFF162033), - accent: Color(0xFF3B82F6), - accentHover: Color(0xFF2563EB), - accentMuted: Color(0xFF142B52), - idle: Color(0xFF94A3B8), - success: Color(0xFF22C55E), - warning: Color(0xFFF59E0B), - danger: Color(0xFFEF4444), - textPrimary: Color(0xFFF8FAFC), - textSecondary: Color(0xFF94A3B8), - textMuted: Color(0xFF64748B), + canvas: Color(0xFF0E0F12), + sidebar: Color(0xFF15171C), + sidebarBorder: Color(0xFF23262D), + surfacePrimary: Color(0xFF15171C), + surfaceSecondary: Color(0xFF1B1E24), + surfaceTertiary: Color(0xFF22262E), + stroke: Color(0xFF2B3038), + strokeSoft: Color(0xFF22262E), + accent: Color(0xFF4C8BF5), + accentHover: Color(0xFF6A9DF7), + accentMuted: Color(0xFF1A2740), + idle: Color(0xFFA1A1AA), + success: Color(0xFF34C759), + warning: Color(0xFFFF9F0A), + danger: Color(0xFFFF3B30), + textPrimary: Color(0xFFFFFFFF), + textSecondary: Color(0xFFA1A1AA), + textMuted: Color(0xFF737982), shadow: Color(0x52000000), - hover: Color(0xFF11213A), + hover: Color(0xFF1B1E24), ); @override diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 1f904cea..2df6497b 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -3,15 +3,9 @@ import 'package:flutter/material.dart'; import 'app_palette.dart'; -/// Design tokens for the XWorkmate design system. -/// Follows a modern AI developer tool design language with: -/// - 8px grid spacing -/// - Compact, neutral, professional aesthetic -/// - Consistent border radii class AppSpacing { AppSpacing._(); - // 8px grid system static const double xxs = 4.0; static const double xs = 8.0; static const double sm = 12.0; @@ -23,57 +17,54 @@ class AppSpacing { class AppRadius { AppRadius._(); - static const double card = 6.0; - static const double button = 6.0; - static const double input = 6.0; + static const double card = 16.0; + static const double button = 12.0; + static const double input = 16.0; static const double chip = 999.0; static const double badge = 999.0; - static const double dialog = 10.0; - static const double sidebar = 8.0; - static const double icon = 6.0; + static const double dialog = 16.0; + static const double sidebar = 20.0; + static const double icon = 12.0; } class AppTypography { AppTypography._(); - // H1 - 22px weight 600 - static const double h1Size = 22.0; - static const FontWeight h1Weight = FontWeight.w600; - static const double h1Height = 1.25; + static const double displaySize = 28.0; + static const FontWeight displayWeight = FontWeight.w600; + static const double displayHeight = 32 / 28; - // H2 - 18px weight 600 - static const double h2Size = 18.0; - static const FontWeight h2Weight = FontWeight.w600; - static const double h2Height = 1.3; + static const double titleSize = 20.0; + static const FontWeight titleWeight = FontWeight.w600; + static const double titleHeight = 24 / 20; + + static const double sectionSize = 16.0; + static const FontWeight sectionWeight = FontWeight.w500; + static const double sectionHeight = 20 / 16; - // Body - 14px weight 400 static const double bodySize = 14.0; static const FontWeight bodyWeight = FontWeight.w400; - static const double bodyHeight = 1.4; + static const double bodyHeight = 18 / 14; - // Meta - 12px weight 400 - static const double metaSize = 12.0; - static const FontWeight metaWeight = FontWeight.w400; - static const double metaHeight = 1.45; + static const double captionSize = 12.0; + static const FontWeight captionWeight = FontWeight.w400; + static const double captionHeight = 16 / 12; } class AppSizes { AppSizes._(); - // Sidebar - static const double sidebarItemHeight = 36.0; - static const double sidebarIconSize = 18.0; + static const double sidebarItemHeight = 40.0; + static const double sidebarIconSize = 20.0; static const double sidebarTextSize = 14.0; - static const double sidebarExpandedWidth = 204.0; + static const double sidebarExpandedWidth = 212.0; static const double sidebarCollapsedWidth = 72.0; - // Input area static const double textareaHeight = 48.0; - static const double toolbarHeight = 36.0; + static const double toolbarHeight = 40.0; - // Buttons - static const double buttonHeightDesktop = 34.0; - static const double buttonHeightMobile = 36.0; + static const double buttonHeightDesktop = 40.0; + static const double buttonHeightMobile = 40.0; } class AppTheme { @@ -115,7 +106,7 @@ class AppTheme { onInverseSurface: palette.surfacePrimary, shadow: palette.shadow, scrim: Colors.black.withValues( - alpha: brightness == Brightness.dark ? 0.62 : 0.14, + alpha: brightness == Brightness.dark ? 0.62 : 0.12, ), ); @@ -127,11 +118,7 @@ class AppTheme { scaffoldBackgroundColor: palette.canvas, extensions: [palette], ); - final tunedTextTheme = _textTheme( - base.textTheme, - palette: palette, - isDesktop: isDesktop, - ); + final tunedTextTheme = _textTheme(base.textTheme, palette: palette); return base.copyWith( splashFactory: NoSplash.splashFactory, @@ -157,22 +144,30 @@ class AppTheme { surfaceTintColor: Colors.transparent, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.card), - side: BorderSide(color: palette.strokeSoft), ), ), chipTheme: base.chipTheme.copyWith( backgroundColor: palette.surfaceSecondary, - side: BorderSide(color: palette.strokeSoft), + selectedColor: palette.surfacePrimary, + secondarySelectedColor: palette.surfacePrimary, + disabledColor: palette.surfaceSecondary, + side: BorderSide.none, + checkmarkColor: Colors.transparent, labelStyle: tunedTextTheme.labelMedium, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.chip), ), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), ), filledButtonTheme: FilledButtonThemeData( style: FilledButton.styleFrom( + backgroundColor: palette.accent, + foregroundColor: Colors.white, + shadowColor: palette.shadow, + elevation: 0, + surfaceTintColor: Colors.transparent, textStyle: tunedTextTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, ), minimumSize: Size( 0, @@ -180,9 +175,9 @@ class AppTheme { ? AppSizes.buttonHeightDesktop : AppSizes.buttonHeightMobile, ), - padding: EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.button), @@ -191,9 +186,13 @@ class AppTheme { ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( + backgroundColor: palette.surfaceSecondary, foregroundColor: palette.textPrimary, + shadowColor: palette.shadow, + elevation: 0, + surfaceTintColor: Colors.transparent, textStyle: tunedTextTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, ), minimumSize: Size( 0, @@ -201,14 +200,14 @@ class AppTheme { ? AppSizes.buttonHeightDesktop : AppSizes.buttonHeightMobile, ), - padding: EdgeInsets.symmetric( - horizontal: AppSpacing.sm, - vertical: AppSpacing.xs, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.button), ), - side: BorderSide(color: palette.strokeSoft), + side: BorderSide.none, ), ), textButtonTheme: TextButtonThemeData( @@ -218,8 +217,8 @@ class AppTheme { fontWeight: FontWeight.w500, ), minimumSize: Size(0, isDesktop ? 32 : 34), - padding: EdgeInsets.symmetric( - horizontal: AppSpacing.sm, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.xs, ), shape: RoundedRectangleBorder( @@ -231,43 +230,48 @@ class AppTheme { style: IconButton.styleFrom( foregroundColor: palette.textSecondary, backgroundColor: palette.surfaceSecondary, + surfaceTintColor: Colors.transparent, + minimumSize: const Size(40, 40), + padding: const EdgeInsets.all(10), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.icon), - side: BorderSide(color: palette.strokeSoft), ), - minimumSize: const Size(34, 34), - padding: const EdgeInsets.all(8), ), ), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: palette.surfaceSecondary, + fillColor: palette.surfacePrimary, hintStyle: tunedTextTheme.bodyMedium?.copyWith( color: palette.textMuted, ), labelStyle: tunedTextTheme.bodyMedium?.copyWith( color: palette.textMuted, ), - contentPadding: EdgeInsets.symmetric( + floatingLabelStyle: tunedTextTheme.bodyMedium?.copyWith( + color: palette.textSecondary, + ), + contentPadding: const EdgeInsets.symmetric( horizontal: AppSpacing.md, vertical: AppSpacing.sm, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.input), - borderSide: BorderSide(color: palette.strokeSoft), + borderSide: const BorderSide(color: Colors.transparent), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.input), - borderSide: BorderSide(color: palette.strokeSoft), + borderSide: const BorderSide(color: Colors.transparent), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(AppRadius.input), - borderSide: BorderSide(color: palette.accent.withValues(alpha: 0.42)), + borderSide: BorderSide( + color: palette.accent.withValues(alpha: 0.18), + ), ), ), segmentedButtonTheme: SegmentedButtonThemeData( style: ButtonStyle( - side: WidgetStatePropertyAll(BorderSide(color: palette.strokeSoft)), + side: const WidgetStatePropertyAll(BorderSide.none), backgroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.selected)) { return palette.surfacePrimary; @@ -298,28 +302,39 @@ class AppTheme { ), snackBarTheme: SnackBarThemeData( behavior: SnackBarBehavior.floating, - backgroundColor: palette.surfaceTertiary, + backgroundColor: palette.surfacePrimary, contentTextStyle: TextStyle(color: palette.textPrimary), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.dialog), ), ), + popupMenuTheme: PopupMenuThemeData( + color: palette.surfacePrimary, + surfaceTintColor: Colors.transparent, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.dialog), + ), + ), ); } static TextTheme _textTheme( TextTheme base, { required AppPalette palette, - required bool isDesktop, }) { final fallbackFonts = switch (defaultTargetPlatform) { TargetPlatform.macOS || TargetPlatform.iOS => const [ - '.SF NS Text', '.SF Pro Text', + '.SF NS Text', + 'PingFang SC', + ], + _ => const [ + 'Inter', + 'Segoe UI', + 'Noto Sans CJK SC', 'PingFang SC', - 'Helvetica Neue', ], - _ => const ['Inter', 'Noto Sans CJK SC', 'PingFang SC'], }; TextStyle withUiFont(TextStyle? style) { @@ -330,48 +345,50 @@ class AppTheme { } return base.copyWith( - // H1: 22px weight 600 displaySmall: withUiFont( base.displaySmall?.copyWith( - fontSize: AppTypography.h1Size, - fontWeight: AppTypography.h1Weight, - letterSpacing: -0.24, - height: AppTypography.h1Height, + fontSize: AppTypography.displaySize, + fontWeight: AppTypography.displayWeight, + letterSpacing: -0.32, + height: AppTypography.displayHeight, + color: palette.textPrimary, ), ), headlineSmall: withUiFont( base.headlineSmall?.copyWith( - fontSize: AppTypography.h1Size, - fontWeight: AppTypography.h1Weight, - letterSpacing: -0.24, - height: AppTypography.h1Height, + fontSize: AppTypography.titleSize, + fontWeight: AppTypography.titleWeight, + letterSpacing: -0.18, + height: AppTypography.titleHeight, + color: palette.textPrimary, ), ), - // H2: 18px weight 600 titleLarge: withUiFont( base.titleLarge?.copyWith( - fontSize: AppTypography.h2Size, - fontWeight: AppTypography.h2Weight, - letterSpacing: -0.16, - height: AppTypography.h2Height, + fontSize: AppTypography.sectionSize, + fontWeight: FontWeight.w600, + letterSpacing: -0.08, + height: AppTypography.sectionHeight, + color: palette.textPrimary, ), ), titleMedium: withUiFont( base.titleMedium?.copyWith( - fontSize: 16, - fontWeight: FontWeight.w600, + fontSize: AppTypography.sectionSize, + fontWeight: AppTypography.sectionWeight, letterSpacing: -0.08, - height: 1.35, + height: AppTypography.sectionHeight, + color: palette.textPrimary, ), ), titleSmall: withUiFont( base.titleSmall?.copyWith( - fontSize: isDesktop ? 14 : 15, - fontWeight: FontWeight.w500, - height: 1.4, + fontSize: AppTypography.bodySize, + fontWeight: FontWeight.w600, + height: AppTypography.bodyHeight, + color: palette.textPrimary, ), ), - // Body: 14px weight 400 bodyLarge: withUiFont( base.bodyLarge?.copyWith( fontSize: AppTypography.bodySize, @@ -388,34 +405,36 @@ class AppTheme { color: palette.textSecondary, ), ), - // Meta: 12px weight 400 bodySmall: withUiFont( base.bodySmall?.copyWith( - fontSize: AppTypography.metaSize, - fontWeight: AppTypography.metaWeight, - height: AppTypography.metaHeight, + fontSize: AppTypography.captionSize, + fontWeight: AppTypography.captionWeight, + height: AppTypography.captionHeight, color: palette.textMuted, ), ), labelLarge: withUiFont( base.labelLarge?.copyWith( - fontSize: isDesktop ? 13 : 14, - fontWeight: FontWeight.w500, - height: 1.2, + fontSize: AppTypography.bodySize, + fontWeight: FontWeight.w600, + height: AppTypography.bodyHeight, + color: palette.textPrimary, ), ), labelMedium: withUiFont( base.labelMedium?.copyWith( - fontSize: 12, + fontSize: AppTypography.captionSize, fontWeight: FontWeight.w500, - height: 1.2, + height: AppTypography.captionHeight, + color: palette.textSecondary, ), ), labelSmall: withUiFont( base.labelSmall?.copyWith( - fontSize: 11, - fontWeight: FontWeight.w500, - height: 1.2, + fontSize: AppTypography.captionSize, + fontWeight: FontWeight.w400, + height: AppTypography.captionHeight, + color: palette.textMuted, ), ), ); diff --git a/lib/widgets/desktop_workspace_scaffold.dart b/lib/widgets/desktop_workspace_scaffold.dart index 07bd6c65..5983bee6 100644 --- a/lib/widgets/desktop_workspace_scaffold.dart +++ b/lib/widgets/desktop_workspace_scaffold.dart @@ -95,12 +95,22 @@ class DesktopWorkspaceScaffold extends StatelessWidget { ), ), Expanded( - child: Container( + child: DecoratedBox( decoration: BoxDecoration( color: palette.surfacePrimary, - border: Border.all(color: palette.strokeSoft), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.06), + blurRadius: 16, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: child, ), - child: child, ), ), ], diff --git a/lib/widgets/metric_card.dart b/lib/widgets/metric_card.dart index ed2058cd..1e4e6c2c 100644 --- a/lib/widgets/metric_card.dart +++ b/lib/widgets/metric_card.dart @@ -25,10 +25,10 @@ class MetricCard extends StatelessWidget { width: 40, height: 40, decoration: BoxDecoration( - color: palette.accentMuted, + color: palette.surfaceSecondary, borderRadius: BorderRadius.circular(AppRadius.card), ), - child: Icon(metric.icon, color: palette.accent, size: 20), + child: Icon(metric.icon, color: palette.textPrimary, size: 20), ), const Spacer(), if (metric.status != null) diff --git a/lib/widgets/section_tabs.dart b/lib/widgets/section_tabs.dart index 7aa47d79..09d9ae31 100644 --- a/lib/widgets/section_tabs.dart +++ b/lib/widgets/section_tabs.dart @@ -38,7 +38,13 @@ class SectionTabs extends StatelessWidget { decoration: BoxDecoration( color: palette.surfaceSecondary, borderRadius: BorderRadius.circular(AppRadius.chip), - border: Border.all(color: palette.strokeSoft), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: SingleChildScrollView( scrollDirection: Axis.horizontal, @@ -90,14 +96,23 @@ class _SectionTabChipState extends State<_SectionTabChip> { onExit: (_) => setState(() => _hovered = false), child: AnimatedContainer( duration: const Duration(milliseconds: 160), - curve: Curves.easeOutCubic, + curve: Curves.easeInOut, decoration: BoxDecoration( color: widget.selected ? palette.surfacePrimary : _hovered - ? palette.hover + ? palette.surfaceTertiary : Colors.transparent, borderRadius: BorderRadius.circular(AppRadius.button), + boxShadow: widget.selected + ? [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : const [], ), child: Material( color: Colors.transparent, @@ -112,6 +127,7 @@ class _SectionTabChipState extends State<_SectionTabChip> { color: widget.selected ? palette.textPrimary : palette.textSecondary, + fontWeight: widget.selected ? FontWeight.w600 : FontWeight.w500, ), ), ), diff --git a/lib/widgets/sidebar_navigation.dart b/lib/widgets/sidebar_navigation.dart index 8e6ac763..121a6c9a 100644 --- a/lib/widgets/sidebar_navigation.dart +++ b/lib/widgets/sidebar_navigation.dart @@ -85,9 +85,13 @@ class SidebarNavigation extends StatelessWidget { decoration: BoxDecoration( color: palette.sidebar, borderRadius: BorderRadius.circular(AppRadius.sidebar), - border: Border.all( - color: palette.sidebarBorder.withValues(alpha: 0.72), - ), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.06), + blurRadius: 16, + offset: const Offset(0, 2), + ), + ], ), child: Padding( padding: const EdgeInsets.symmetric( @@ -190,9 +194,15 @@ class SidebarHeader extends StatelessWidget { width: isCollapsed ? AppSizes.sidebarItemHeight : 36, height: isCollapsed ? AppSizes.sidebarItemHeight : 36, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(14), color: palette.surfaceSecondary, - border: Border.all(color: palette.strokeSoft), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Icon( Icons.crop_square_rounded, @@ -320,13 +330,15 @@ class _SidebarNavItemState extends State<_SidebarNavItem> { final theme = Theme.of(context); final isPrimary = widget.emphasis == _SidebarItemEmphasis.primary; final background = widget.selected - ? palette.accentMuted + ? palette.surfacePrimary : _hovered - ? palette.hover + ? palette.surfaceTertiary : Colors.transparent; - final iconColor = widget.selected ? palette.accent : palette.textSecondary; - final height = isPrimary ? 46.0 : AppSizes.sidebarItemHeight; - final radius = isPrimary ? 14.0 : AppRadius.button; + final iconColor = widget.selected + ? palette.textPrimary + : palette.textSecondary; + final height = isPrimary ? 48.0 : AppSizes.sidebarItemHeight; + final radius = isPrimary ? 16.0 : AppRadius.button; return Tooltip( message: widget.collapsed ? _sectionLabel(widget.section) : '', @@ -338,6 +350,15 @@ class _SidebarNavItemState extends State<_SidebarNavItem> { decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular(radius), + boxShadow: widget.selected + ? [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : const [], ), child: Material( color: Colors.transparent, @@ -350,7 +371,7 @@ class _SidebarNavItemState extends State<_SidebarNavItem> { child: widget.collapsed ? Center( child: Icon( - _sectionIcon(widget.section), + _sectionIcon(widget.section, active: widget.selected), size: AppSizes.sidebarIconSize, color: iconColor, ), @@ -360,7 +381,10 @@ class _SidebarNavItemState extends State<_SidebarNavItem> { SizedBox( width: isPrimary ? 28 : 24, child: Icon( - _sectionIcon(widget.section), + _sectionIcon( + widget.section, + active: widget.selected, + ), size: AppSizes.sidebarIconSize, color: iconColor, ), @@ -418,19 +442,44 @@ class _SidebarNavItemState extends State<_SidebarNavItem> { ); } - IconData _sectionIcon(WorkspaceDestination section) { + IconData _sectionIcon( + WorkspaceDestination section, { + required bool active, + }) { return switch (section) { - WorkspaceDestination.assistant => Icons.edit_outlined, - WorkspaceDestination.tasks => Icons.schedule_rounded, - WorkspaceDestination.skills => Icons.blur_on_rounded, - WorkspaceDestination.nodes => Icons.developer_board_rounded, - WorkspaceDestination.agents => Icons.hub_rounded, - WorkspaceDestination.mcpServer => Icons.dns_rounded, - WorkspaceDestination.clawHub => Icons.extension_rounded, - WorkspaceDestination.secrets => Icons.key_rounded, - WorkspaceDestination.aiGateway => Icons.smart_toy_rounded, - WorkspaceDestination.settings => Icons.tune_rounded, - WorkspaceDestination.account => Icons.account_circle_rounded, + WorkspaceDestination.assistant => active + ? Icons.chat_bubble_rounded + : Icons.chat_bubble_outline_rounded, + WorkspaceDestination.tasks => active + ? Icons.layers_rounded + : Icons.layers_outlined, + WorkspaceDestination.skills => active + ? Icons.auto_awesome_rounded + : Icons.auto_awesome_outlined, + WorkspaceDestination.nodes => active + ? Icons.developer_board_rounded + : Icons.developer_board_outlined, + WorkspaceDestination.agents => active + ? Icons.hub_rounded + : Icons.hub_outlined, + WorkspaceDestination.mcpServer => active + ? Icons.dns_rounded + : Icons.dns_outlined, + WorkspaceDestination.clawHub => active + ? Icons.extension_rounded + : Icons.extension_outlined, + WorkspaceDestination.secrets => active + ? Icons.key_rounded + : Icons.key_outlined, + WorkspaceDestination.aiGateway => active + ? Icons.smart_toy_rounded + : Icons.smart_toy_outlined, + WorkspaceDestination.settings => active + ? Icons.settings_rounded + : Icons.settings_outlined, + WorkspaceDestination.account => active + ? Icons.account_circle_rounded + : Icons.account_circle_outlined, }; } @@ -498,7 +547,7 @@ class SidebarFooter extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container(height: 1, color: palette.sidebarBorder), + Container(height: 1, color: palette.sidebarBorder.withValues(alpha: 0.7)), const SizedBox(height: AppSpacing.xs), _SidebarLanguageButton( appLanguage: appLanguage, @@ -555,7 +604,7 @@ class SidebarFooter extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - Container(height: 1, color: palette.sidebarBorder), + Container(height: 1, color: palette.sidebarBorder.withValues(alpha: 0.7)), const SizedBox(height: AppSpacing.xs), _SidebarNavItem( section: WorkspaceDestination.settings, @@ -649,7 +698,7 @@ class _SidebarActionButtonState extends State<_SidebarActionButton> { @override Widget build(BuildContext context) { final palette = context.palette; - final background = _hovered ? palette.hover : Colors.transparent; + final resolvedBackground = _hovered ? palette.surfaceTertiary : palette.surfaceSecondary; return Tooltip( message: widget.tooltip ?? '', @@ -659,8 +708,15 @@ class _SidebarActionButtonState extends State<_SidebarActionButton> { child: AnimatedContainer( duration: const Duration(milliseconds: 160), decoration: BoxDecoration( - color: background, + color: resolvedBackground, borderRadius: BorderRadius.circular(AppRadius.button), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Material( color: Colors.transparent, @@ -714,9 +770,9 @@ class _SidebarAccountTileState extends State<_SidebarAccountTile> { Widget build(BuildContext context) { final palette = context.palette; final background = widget.selected - ? palette.accentMuted + ? palette.surfacePrimary : _hovered - ? palette.hover + ? palette.surfaceTertiary : Colors.transparent; return MouseRegion( @@ -729,6 +785,15 @@ class _SidebarAccountTileState extends State<_SidebarAccountTile> { decoration: BoxDecoration( color: background, borderRadius: BorderRadius.circular(AppRadius.button), + boxShadow: widget.selected + ? [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : const [], ), child: Material( color: Colors.transparent, @@ -746,6 +811,7 @@ class _SidebarAccountTileState extends State<_SidebarAccountTile> { children: [ CircleAvatar( radius: 14, + backgroundColor: palette.accentMuted, child: Text( widget.name.trim().isEmpty ? 'X' @@ -836,9 +902,15 @@ class _SidebarLanguageButtonState extends State<_SidebarLanguageButton> { height: size, alignment: Alignment.center, decoration: BoxDecoration( - color: _hovered ? palette.hover : palette.surfaceSecondary, + color: _hovered ? palette.surfaceTertiary : palette.surfaceSecondary, borderRadius: BorderRadius.circular(AppRadius.button), - border: Border.all(color: palette.strokeSoft), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Text( widget.appLanguage.compactLabel, diff --git a/lib/widgets/status_badge.dart b/lib/widgets/status_badge.dart index cfa9a326..cdf90781 100644 --- a/lib/widgets/status_badge.dart +++ b/lib/widgets/status_badge.dart @@ -14,18 +14,18 @@ class StatusBadge extends StatelessWidget { Widget build(BuildContext context) { final palette = context.palette; final tone = switch (status.tone) { - StatusTone.neutral => (palette.surfaceTertiary, palette.textSecondary), + StatusTone.neutral => (palette.surfaceSecondary, palette.textSecondary), StatusTone.accent => (palette.accentMuted, palette.accent), StatusTone.success => ( - palette.success.withValues(alpha: 0.14), + palette.surfacePrimary, palette.success, ), StatusTone.warning => ( - palette.warning.withValues(alpha: 0.14), + palette.surfacePrimary, palette.warning, ), StatusTone.danger => ( - palette.danger.withValues(alpha: 0.14), + palette.surfacePrimary, palette.danger, ), }; @@ -38,12 +38,19 @@ class StatusBadge extends StatelessWidget { decoration: BoxDecoration( color: tone.$1, borderRadius: BorderRadius.circular(AppRadius.badge), + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: 0.05), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Text( status.label, style: Theme.of(context).textTheme.labelMedium?.copyWith( color: tone.$2, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w600, ), ), ); diff --git a/lib/widgets/surface_card.dart b/lib/widgets/surface_card.dart index 91f3d804..fd9f8624 100644 --- a/lib/widgets/surface_card.dart +++ b/lib/widgets/surface_card.dart @@ -36,12 +36,17 @@ class _SurfaceCardState extends State { onExit: (_) => setState(() => _hovered = false), child: AnimatedContainer( duration: const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, + curve: Curves.easeInOut, decoration: BoxDecoration( - color: _hovered ? palette.surfaceSecondary : baseColor, + color: _hovered && widget.onTap != null ? palette.surfaceSecondary : baseColor, borderRadius: BorderRadius.circular(widget.borderRadius), - border: Border.all(color: palette.strokeSoft), - boxShadow: const [], + boxShadow: [ + BoxShadow( + color: palette.shadow.withValues(alpha: _hovered ? 0.10 : 0.06), + blurRadius: _hovered ? 12 : 8, + offset: const Offset(0, 2), + ), + ], ), child: Material( color: Colors.transparent, diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 85a24130..b61ca246 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = @@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); + sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 62e3ed57..a8c56e2a 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_linux flutter_secure_storage_linux + sqlite3_flutter_libs ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 75abf8f5..9bcf621d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -10,6 +10,7 @@ import file_selector_macos import flutter_secure_storage_macos import package_info_plus import shared_preferences_foundation +import sqlite3_flutter_libs func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) @@ -17,4 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index ade0a0ee..788f1705 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -11,6 +11,31 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - sqlite3 (3.52.0): + - sqlite3/common (= 3.52.0) + - sqlite3/common (3.52.0) + - sqlite3/dbstatvtab (3.52.0): + - sqlite3/common + - sqlite3/fts5 (3.52.0): + - sqlite3/common + - sqlite3/math (3.52.0): + - sqlite3/common + - sqlite3/perf-threadsafe (3.52.0): + - sqlite3/common + - sqlite3/rtree (3.52.0): + - sqlite3/common + - sqlite3/session (3.52.0): + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - Flutter + - FlutterMacOS + - sqlite3 (~> 3.52.0) + - sqlite3/dbstatvtab + - sqlite3/fts5 + - sqlite3/math + - sqlite3/perf-threadsafe + - sqlite3/rtree + - sqlite3/session DEPENDENCIES: - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) @@ -19,6 +44,11 @@ DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin`) + +SPEC REPOS: + trunk: + - sqlite3 EXTERNAL SOURCES: device_info_plus: @@ -33,6 +63,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqlite3_flutter_libs: + :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/darwin SPEC CHECKSUMS: device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 @@ -41,6 +73,8 @@ SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb + sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 + sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/pubspec.lock b/pubspec.lock index 4e36aa1e..1127d029 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -577,6 +577,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.10.2" + sqlite3: + dependency: "direct main" + description: + name: sqlite3 + sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + sqlite3_flutter_libs: + dependency: "direct main" + description: + name: sqlite3_flutter_libs + sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad + url: "https://pub.dev" + source: hosted + version: "0.5.42" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d18433ed..19666a52 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,8 @@ dependencies: package_info_plus: ^8.3.1 path_provider: ^2.1.5 shared_preferences: ^2.5.3 + sqlite3: ^2.9.3 + sqlite3_flutter_libs: ^0.5.39 web_socket_channel: ^3.0.3 yaml: ^3.1.3 diff --git a/test/features/assistant_page_test.dart b/test/features/assistant_page_test.dart index 05a71d11..46d7e733 100644 --- a/test/features/assistant_page_test.dart +++ b/test/features/assistant_page_test.dart @@ -164,7 +164,7 @@ void main() { expect(find.text('Gateway 访问'), findsOneWidget); }); - testWidgets('AssistantPage uses persistent composer with suggestion chips', ( + testWidgets('AssistantPage keeps a minimal composer action menu', ( WidgetTester tester, ) async { final controller = await createTestController(tester); @@ -175,17 +175,18 @@ void main() { ); expect(find.textContaining('Claw'), findsNothing); - expect(find.text('幻灯片'), findsOneWidget); + expect(find.text('幻灯片'), findsNothing); + expect(find.text('视频生成'), findsNothing); + expect(find.text('深度研究'), findsNothing); + expect(find.text('自动化'), findsNothing); expect(find.textContaining('输入需求、补充上下文、继续追问'), findsOneWidget); - await tester.ensureVisible( - find.byKey(const ValueKey('assistant-suggestion-幻灯片')), - ); - await tester.tap( - find.byKey(const ValueKey('assistant-suggestion-幻灯片')), - ); + await tester.tap(find.byTooltip('输入区操作')); await tester.pumpAndSettle(); - expect(find.textContaining('帮我整理一份演示文稿'), findsOneWidget); + expect(find.text('添加照片和文件'), findsOneWidget); + expect(find.text('计划模式'), findsNothing); + expect(find.text('连接网关'), findsNothing); + expect(find.text('浏览器 / 编码 / 研究'), findsNothing); }); } diff --git a/test/features/settings_page_test.dart b/test/features/settings_page_test.dart index 187919bc..6fc58f54 100644 --- a/test/features/settings_page_test.dart +++ b/test/features/settings_page_test.dart @@ -5,42 +5,6 @@ import 'package:xworkmate/features/settings/settings_page.dart'; import '../test_support.dart'; void main() { - testWidgets('SettingsPage theme chips update controller theme mode', ( - WidgetTester tester, - ) async { - final controller = await createTestController(tester); - - await pumpPage(tester, child: SettingsPage(controller: controller)); - - await tester.tap(find.text('外观')); - await tester.pumpAndSettle(); - await tester.tap(find.text('深色')); - await tester.pumpAndSettle(); - - expect(controller.themeMode, ThemeMode.dark); - - await tester.tap(find.text('浅色')); - await tester.pumpAndSettle(); - expect(controller.themeMode, ThemeMode.light); - }); - - testWidgets('SettingsPage gateway tab exposes device pairing controls', ( - WidgetTester tester, - ) async { - final controller = await createTestController(tester); - - await pumpPage(tester, child: SettingsPage(controller: controller)); - - await tester.tap(find.text('集成')); - await tester.pumpAndSettle(); - - expect(find.text('打开连接面板'), findsOneWidget); - expect( - find.byKey(const ValueKey('gateway-device-security-card')), - findsOneWidget, - ); - }); - testWidgets('SettingsPage diagnostics tab filters and clears runtime logs', ( WidgetTester tester, ) async { @@ -56,7 +20,10 @@ void main() { message: 'pairing required', ); - await pumpPage(tester, child: SettingsPage(controller: controller)); + await pumpPage( + tester, + child: SettingsPage(controller: controller), + ); await tester.tap(find.text('诊断')); await tester.pumpAndSettle(); @@ -69,13 +36,13 @@ void main() { find.byKey(const ValueKey('runtime-log-filter')), 'pairing', ); - await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 200)); expect(find.textContaining('connected remote gateway'), findsNothing); expect(find.textContaining('pairing required'), findsOneWidget); await tester.tap(find.text('清空')); - await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 200)); expect(find.text('当前没有运行日志。'), findsOneWidget); }); diff --git a/test/runtime/secure_config_store_test.dart b/test/runtime/secure_config_store_test.dart index 40480d1f..a7bf5b71 100644 --- a/test/runtime/secure_config_store_test.dart +++ b/test/runtime/secure_config_store_test.dart @@ -11,7 +11,19 @@ void main() { 'SecureConfigStore persists settings and secure refs in test runners', () async { SharedPreferences.setMockInitialValues({}); - final store = SecureConfigStore(); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-config-store-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final databasePath = '${tempDirectory.path}/settings.sqlite3'; + final store = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); final snapshot = SettingsSnapshot.defaults().copyWith( accountUsername: 'tester', @@ -62,6 +74,95 @@ void main() { }, ); + test( + 'SecureConfigStore persists sqlite-backed settings across instances', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-config-store-cross-instance-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final databasePath = '${tempDirectory.path}/settings.sqlite3'; + + final snapshot = SettingsSnapshot.defaults().copyWith( + accountUsername: 'sqlite-user', + accountWorkspace: 'sqlite-workspace', + gateway: GatewayConnectionProfile.defaults().copyWith( + host: 'sqlite.example.com', + port: 443, + ), + ); + final entry = SecretAuditEntry( + timeLabel: '10:00', + action: 'Updated', + provider: 'Vault', + target: 'vault_token', + module: 'Settings', + status: 'Success', + ); + + final firstStore = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + await firstStore.saveSettingsSnapshot(snapshot); + await firstStore.appendAudit(entry); + + final secondStore = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final loadedSnapshot = await secondStore.loadSettingsSnapshot(); + final loadedAudit = await secondStore.loadAuditTrail(); + + expect(loadedSnapshot.accountUsername, 'sqlite-user'); + expect(loadedSnapshot.accountWorkspace, 'sqlite-workspace'); + expect(loadedSnapshot.gateway.host, 'sqlite.example.com'); + expect(loadedAudit, hasLength(1)); + expect(loadedAudit.first.provider, 'Vault'); + expect(loadedAudit.first.target, 'vault_token'); + }, + ); + + test( + 'SecureConfigStore dispose closes sqlite handle and allows reopening the same database path', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-config-store-dispose-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final databasePath = '${tempDirectory.path}/settings.sqlite3'; + final firstStore = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final snapshot = SettingsSnapshot.defaults().copyWith( + accountUsername: 'dispose-user', + ); + + await firstStore.saveSettingsSnapshot(snapshot); + firstStore.dispose(); + firstStore.dispose(); + + final secondStore = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + ); + final reloadedSnapshot = await secondStore.loadSettingsSnapshot(); + + expect(reloadedSnapshot.accountUsername, 'dispose-user'); + }, + ); + test( 'SecureConfigStore clears gateway token without touching snapshot', () async { diff --git a/test/test_support.dart b/test/test_support.dart index 11f04bf8..42542923 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -1,13 +1,22 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:xworkmate/app/app_controller.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; import 'package:xworkmate/theme/app_theme.dart'; Future createTestController(WidgetTester tester) async { SharedPreferences.setMockInitialValues({}); - final controller = AppController(); + final controller = AppController( + store: SecureConfigStore( + enableSecureStorage: false, + fallbackDirectoryPathResolver: () async => + '${Directory.systemTemp.path}/xworkmate-widget-tests', + ), + ); addTearDown(controller.dispose); await tester.pump(const Duration(milliseconds: 100)); await tester.pumpAndSettle(); diff --git a/test/widget_test.dart b/test/widget_test.dart index bb302aae..895730af 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -17,6 +17,7 @@ void main() { expect(find.text('新对话'), findsWidgets); expect(find.byKey(const Key('assistant-task-rail')), findsOneWidget); - expect(find.text('幻灯片'), findsOneWidget); + expect(find.text('幻灯片'), findsNothing); + expect(find.textContaining('输入需求、补充上下文、继续追问'), findsOneWidget); }); } diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index b53f20e2..89c9c26b 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -8,10 +8,13 @@ #include #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + Sqlite3FlutterLibsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 2b9f993e..1bfb0cc2 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows flutter_secure_storage_windows + sqlite3_flutter_libs ) list(APPEND FLUTTER_FFI_PLUGIN_LIST