Finish secure settings storage and refresh workspace UI
This commit is contained in:
parent
c2ad563a35
commit
d524c74047
@ -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
|
||||
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"))
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
16
pubspec.lock
16
pubspec.lock
@ -577,6 +577,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
sqlite3:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "3145bd74dcdb4fd6f5c6dda4d4e4490a8087d7f286a14dee5d37087290f0f8a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.9.4"
|
||||
sqlite3_flutter_libs:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqlite3_flutter_libs
|
||||
sha256: eeb9e3a45207649076b808f8a5a74d68770d0b7f26ccef6d5f43106eee5375ad
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.42"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user