Finish secure settings storage and refresh workspace UI

This commit is contained in:
Haitao Pan 2026-03-18 17:04:00 +08:00
parent c2ad563a35
commit d524c74047
30 changed files with 1461 additions and 675 deletions

View File

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

View File

@ -86,6 +86,7 @@ class AppController extends ChangeNotifier {
bool _initializing = true;
String? _bootstrapError;
StreamSubscription<GatewayPushEvent>? _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<DerivedTaskItem> 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<void> _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<String?> _resolveCodexCliPath() async {
@ -1100,6 +1127,13 @@ class AppController extends ChangeNotifier {
}
void _relayChildChange() {
_notifyIfActive();
}
void _notifyIfActive() {
if (_disposed) {
return;
}
notifyListeners();
}

View File

@ -43,10 +43,11 @@ class _AppShellState extends State<AppShell> {
];
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<AppShell> {
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),
],
),
),
),
),

View File

@ -389,7 +389,6 @@ class _AssistantPageState extends State<AssistantPage> {
: 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<AssistantPage> {
onOpenGateway: _showConnectDialog,
onReconnectGateway: _connectFromSavedSettingsOrShowDialog,
onPickAttachments: _pickAttachments,
suggestions: _buildSuggestions(controller),
onSuggestionSelected: _applySuggestion,
onSend: _submitPrompt,
),
),
@ -534,105 +531,6 @@ class _AssistantPageState extends State<AssistantPage> {
});
}
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<void> _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<String> modelOptions;
final List<_ComposerAttachment> attachments;
final String? autoAgentLabel;
final ValueChanged<String> onModeChanged;
final ValueChanged<String> onThinkingChanged;
final Future<void> Function(String modelId) onModelChanged;
@ -1299,8 +1206,6 @@ class _AssistantLowerPane extends StatelessWidget {
final VoidCallback onOpenGateway;
final Future<void> Function() onReconnectGateway;
final VoidCallback onPickAttachments;
final List<_AssistantSuggestion> suggestions;
final ValueChanged<_AssistantSuggestion> onSuggestionSelected;
final Future<void> 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<String>('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<String> modelOptions;
final List<_ComposerAttachment> attachments;
final String? autoAgentLabel;
final ValueChanged<String> onModeChanged;
final ValueChanged<String> onThinkingChanged;
final Future<void> Function(String modelId) onModelChanged;
@ -1967,8 +1886,6 @@ class _ComposerBar extends StatelessWidget {
final VoidCallback onOpenGateway;
final Future<void> Function() onReconnectGateway;
final VoidCallback onPickAttachments;
final List<_AssistantSuggestion> suggestions;
final ValueChanged<_AssistantSuggestion> onSuggestionSelected;
final Future<void> 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<String>(
'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<String>(
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<String>(
value: 'gateway',
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
connected
? Icons.lan_rounded
: Icons.link_rounded,
),
title: Text(appText('连接网关', 'Connect gateway')),
),
),
PopupMenuItem<String>(
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;
}

View File

@ -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<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
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<SettingsPage> {
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<SettingsPage> {
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<SettingsPage> {
),
),
),
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<SettingsPage> {
);
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<SettingsPage> {
),
),
),
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<SettingsPage> {
),
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<SettingsPage> {
}
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<SettingsPage> {
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<SettingsPage> {
) 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<SettingsPage> {
.toString();
}
Widget _buildSecureField({
required TextEditingController controller,
required String label,
required bool hasStoredValue,
required _SecretFieldUiState fieldState,
required ValueChanged<_SecretFieldUiState> onStateChanged,
required Future<String> Function() loadValue,
required Future<void> 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<void> _toggleSecureFieldVisibility({
required TextEditingController controller,
required bool hasStoredValue,
required _SecretFieldUiState fieldState,
required ValueChanged<_SecretFieldUiState> onStateChanged,
required Future<String> 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<void> _persistSecureFieldIfNeeded({
required TextEditingController controller,
required bool hasStoredValue,
required _SecretFieldUiState fieldState,
required ValueChanged<_SecretFieldUiState> onStateChanged,
required Future<void> 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<void> _persistAiGatewayApiKeyIfNeeded(
AppController controller, {
required bool hasStoredValue,
}) {
return _persistSecureFieldIfNeeded(
controller: _aiGatewayApiKeyController,
hasStoredValue: hasStoredValue,
fieldState: _aiGatewayApiKeyState,
onStateChanged: (value) => setState(() => _aiGatewayApiKeyState = value),
onSubmitted: controller.settingsController.saveAiGatewayApiKey,
);
}
Future<void> _persistVaultTokenIfNeeded(
AppController controller, {
required bool hasStoredValue,
}) {
return _persistSecureFieldIfNeeded(
controller: _vaultTokenController,
hasStoredValue: hasStoredValue,
fieldState: _vaultTokenState,
onStateChanged: (value) => setState(() => _vaultTokenState = value),
onSubmitted: controller.settingsController.saveVaultToken,
);
}
Future<void> _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});

View File

@ -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<SkillsPage> {
),
],
),
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,

View File

@ -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<TasksPage> {
),
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<String>('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,

View File

@ -135,6 +135,10 @@ class SettingsController extends ChangeNotifier {
notifyListeners();
}
Future<String> loadOllamaCloudApiKey() async {
return (await _store.loadOllamaCloudApiKey())?.trim() ?? '';
}
Future<void> saveVaultToken(String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
@ -155,6 +159,10 @@ class SettingsController extends ChangeNotifier {
notifyListeners();
}
Future<String> loadVaultToken() async {
return (await _store.loadVaultToken())?.trim() ?? '';
}
Future<void> saveAiGatewayApiKey(String value) async {
final trimmed = value.trim();
if (trimmed.isEmpty) {
@ -175,6 +183,10 @@ class SettingsController extends ChangeNotifier {
notifyListeners();
}
Future<String> loadAiGatewayApiKey() async {
return (await _store.loadAiGatewayApiKey())?.trim() ?? '';
}
Future<void> appendAudit(SecretAuditEntry entry) async {
await _store.appendAudit(entry);
_auditTrail = await _store.loadAuditTrail();

View File

@ -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<String?> Function()? fallbackDirectoryPathResolver})
: _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver;
SecureConfigStore({
Future<String?> Function()? fallbackDirectoryPathResolver,
Future<String?> 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<String, Object?> _memoryPrefs = <String, Object?>{};
final Map<String, String> _memoryStore = <String, String>{};
final Map<String, String> _memorySecure = <String, String>{};
final Future<String?> Function()? _fallbackDirectoryPathResolver;
final Future<String?> Function()? _databasePathResolver;
final bool _enableSecureStorage;
bool _initialized = false;
Future<void> 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<SettingsSnapshot> loadSettingsSnapshot() async {
await initialize();
return SettingsSnapshot.fromJsonString(await _readPrefString(_settingsKey));
return SettingsSnapshot.fromJsonString(
await _readStoredString(_settingsKey),
);
}
Future<void> saveSettingsSnapshot(SettingsSnapshot snapshot) async {
await initialize();
await _writePrefString(_settingsKey, snapshot.toJsonString());
await _writeStoredString(_settingsKey, snapshot.toJsonString());
}
Future<List<SecretAuditEntry>> 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<String?> _readPrefString(String key) async {
if (_prefs != null) {
return _prefs!.getString(key);
Future<void> _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<void> _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<void> _migrateLegacyPrefs() async {
if (_database == null || _prefs == null) {
return;
}
_memoryPrefs[key] = value;
await _migrateLegacyPrefEntry(_settingsKey);
await _migrateLegacyPrefEntry(_auditKey);
}
Future<void> _migrateLegacyPrefEntry(String key) async {
if (_database == null || _prefs == null) {
return;
}
try {
final existing = _database!.select(
'SELECT value FROM $_databaseTableName WHERE storage_key = ? LIMIT 1',
<Object?>[key],
);
if (existing.isNotEmpty) {
return;
}
final legacyValue = _prefs!.getString(key);
if (legacyValue == null || legacyValue.trim().isEmpty) {
return;
}
_writeStoredStringInternal(key, legacyValue);
} catch (_) {
return;
}
}
Future<String?> _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<String?> _readStoredString(String key) async {
if (_database != null) {
try {
final result = _database!.select(
'SELECT value FROM $_databaseTableName WHERE storage_key = ? LIMIT 1',
<Object?>[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<void> _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
''',
<Object?>[key, value, DateTime.now().millisecondsSinceEpoch],
);
}
Future<String?> _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<void> _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<void> _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) {

View File

@ -47,49 +47,49 @@ class AppPalette extends ThemeExtension<AppPalette> {
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

View File

@ -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 <String>[
'.SF NS Text',
'.SF Pro Text',
'.SF NS Text',
'PingFang SC',
],
_ => const <String>[
'Inter',
'Segoe UI',
'Noto Sans CJK SC',
'PingFang SC',
'Helvetica Neue',
],
_ => const <String>['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,
),
),
);

View File

@ -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,
),
),
],

View File

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

View File

@ -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,
),
),
),

View File

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

View File

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

View File

@ -36,12 +36,17 @@ class _SurfaceCardState extends State<SurfaceCard> {
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,

View File

@ -8,6 +8,7 @@
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
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);
}

View File

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

View File

@ -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"))
}

View File

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

View File

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

View File

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

View File

@ -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<String>('assistant-suggestion-幻灯片')),
);
await tester.tap(
find.byKey(const ValueKey<String>('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);
});
}

View File

@ -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);
});

View File

@ -11,7 +11,19 @@ void main() {
'SecureConfigStore persists settings and secure refs in test runners',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
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(<String, Object>{});
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(<String, Object>{});
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 {

View File

@ -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<AppController> createTestController(WidgetTester tester) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
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();

View File

@ -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);
});
}

View File

@ -8,10 +8,13 @@
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) {
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
Sqlite3FlutterLibsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("Sqlite3FlutterLibsPlugin"));
}

View File

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