feat(studio): rich theme picker (presets + custom colour) + editor 0.11.0
Some checks failed
Security / Security check (push) Failing after 1s
Some checks failed
Security / Security check (push) Failing after 1s
Settings dialog's Theme Plugin section is now a grid of swatched tiles: - Built-in (none) — falls back to FaiTheme.light/.dark - One tile per installed studio.theme.* plugin, each showing the plugin's primary/secondary/tertiary as live colour dots. Tile loads its preview lazily so a dozen installed themes don't block the picker. - Custom — opens a colour-picker dialog with 12 curated Material presets + a hex input + live preview. Selecting applies ColorScheme.fromSeed for both brightnesses. main.dart's _pluginThemes parses a 'custom:#RRGGBB' sigil in the same notifier slot as plugin capability ids, so the existing persistence + restoration paths cover the custom case with no new state. Bumps editor to 0.11.0 (type-checked port connections + dynamic card width fix + card-height border allowance) and Studio to 0.58.0. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
69864da934
commit
c1c60d5434
30 changed files with 1280 additions and 846 deletions
|
|
@ -9,14 +9,13 @@ import '../data/hub.dart';
|
|||
import '../data/hub_auth_token.dart';
|
||||
import '../data/registry_token.dart';
|
||||
import '../data/system_actions.dart';
|
||||
import '../data/theme_plugin.dart';
|
||||
import '../main.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import 'fai_error_box.dart';
|
||||
import 'fai_pill.dart';
|
||||
import 'fai_system_ai_editor.dart';
|
||||
import 'theme_picker_grid.dart';
|
||||
|
||||
class FaiSettingsDialog extends StatefulWidget {
|
||||
const FaiSettingsDialog({super.key});
|
||||
|
|
@ -45,10 +44,12 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
SystemAiStatus? _aiStatus;
|
||||
List<McpClientInfo>? _mcpClients;
|
||||
List<N8nEndpointInfo>? _n8nEndpoints;
|
||||
|
||||
/// Length of the configured registry token, or null when the
|
||||
/// `~/.fai/registry-token` file is missing / empty. Reloaded
|
||||
/// after save / clear.
|
||||
int? _registryTokenChars;
|
||||
|
||||
/// Length of the configured hub gRPC bearer token, or null
|
||||
/// when `~/.fai/hub-auth-token` is missing / empty. Reloaded
|
||||
/// after save / clear; UI only ever sees the trimmed length.
|
||||
|
|
@ -74,7 +75,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
final n = await HubAuthToken.charCount();
|
||||
if (!mounted) return;
|
||||
setState(() => _hubAuthTokenChars = n);
|
||||
} catch (_) {/* fail-quiet */}
|
||||
} catch (_) {
|
||||
/* fail-quiet */
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveHubAuthToken(String token) async {
|
||||
|
|
@ -84,9 +87,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
await _loadHubAuthToken();
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.hubAuthTokenSavedToast)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenSavedToast)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
|
|
@ -103,9 +106,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
await _loadHubAuthToken();
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.hubAuthTokenClearedToast)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenClearedToast)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
|
|
@ -120,7 +123,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
final n = await RegistryToken.charCount();
|
||||
if (!mounted) return;
|
||||
setState(() => _registryTokenChars = n);
|
||||
} catch (_) {/* fail-quiet */}
|
||||
} catch (_) {
|
||||
/* fail-quiet */
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveRegistryToken(String token) async {
|
||||
|
|
@ -129,9 +134,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
await _loadRegistryToken();
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.registryTokenSavedToast)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.registryTokenSavedToast)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
|
|
@ -147,9 +152,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
await _loadRegistryToken();
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.registryTokenClearedToast)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.registryTokenClearedToast)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
|
|
@ -164,7 +169,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
final list = await HubService.instance.listN8nEndpoints();
|
||||
if (!mounted) return;
|
||||
setState(() => _n8nEndpoints = list);
|
||||
} catch (_) {/* fail-quiet */}
|
||||
} catch (_) {
|
||||
/* fail-quiet */
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addN8nEndpoint(N8nEndpointDraft draft) async {
|
||||
|
|
@ -178,15 +185,15 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
if (!mounted) return;
|
||||
setState(() => _n8nEndpoints = list);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.addedN8nToast(draft.name))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.addedN8nToast(draft.name))));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.addFailedToast(e.toString()))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString()))));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,15 +246,15 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
if (!mounted) return;
|
||||
setState(() => _mcpClients = list);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.addedMcpToast(draft.name))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.addedMcpToast(draft.name))));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.addFailedToast(e.toString()))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString()))));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -402,197 +409,201 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l.settingsHubEndpointHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
TextField(
|
||||
controller: _host,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.settingsHost,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
controller: _port,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.settingsPort,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text(l.settingsTls),
|
||||
subtitle: Text(
|
||||
l.settingsTlsSubtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
value: _secure,
|
||||
onChanged: (v) => setState(() => _secure = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(
|
||||
_error!,
|
||||
l.settingsHubEndpointHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(FaiSpace.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
TextField(
|
||||
controller: _host,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.settingsHost,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
child: Row(
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.link,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
controller: _port,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.settingsPort,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
_previewUrl(),
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.onSurface,
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text(l.settingsTls),
|
||||
subtitle: Text(
|
||||
l.settingsTlsSubtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
value: _secure,
|
||||
onChanged: (v) => setState(() => _secure = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_channels != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
Text(
|
||||
l.channelsHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
fontSize: 10,
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(
|
||||
_error!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(FaiSpace.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.link,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
_previewUrl(),
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_channels != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
Text(
|
||||
l.channelsHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l.channelsBlurb,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
for (final ch in _channels!.channels)
|
||||
_ChannelRow(
|
||||
channel: ch,
|
||||
active: ch.name == _channels!.active,
|
||||
onConnect: _saving ? null : () => _connectToChannel(ch),
|
||||
onSwitch: _saving ? null : () => _switchChannel(ch.name),
|
||||
onEnableAutostart: _saving
|
||||
? null
|
||||
: () => _runDaemon(
|
||||
'enable autostart',
|
||||
() => SystemActions.faiDaemonEnable(ch.name),
|
||||
),
|
||||
onDisableAutostart: _saving
|
||||
? null
|
||||
: () => _runDaemon(
|
||||
'disable autostart',
|
||||
() => SystemActions.faiDaemonDisable(ch.name),
|
||||
),
|
||||
),
|
||||
if (_channelToast != null) ...[
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
FaiErrorBox(text: _channelToast!, maxHeight: 200),
|
||||
],
|
||||
],
|
||||
if (_aiStatus != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_SystemAiPanel(
|
||||
status: _aiStatus!,
|
||||
onEdit: () async {
|
||||
final updated = await FaiSystemAiEditor.show(
|
||||
context,
|
||||
_aiStatus!,
|
||||
);
|
||||
if (updated != null && mounted) {
|
||||
setState(() => _aiStatus = updated);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
if (_mcpClients != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_McpClientsPanel(
|
||||
clients: _mcpClients!,
|
||||
onAdd: _addMcpClient,
|
||||
onRemove: _removeMcpClient,
|
||||
onRefresh: _refreshMcpClients,
|
||||
),
|
||||
],
|
||||
if (_n8nEndpoints != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_N8nEndpointsPanel(
|
||||
endpoints: _n8nEndpoints!,
|
||||
onAdd: _addN8nEndpoint,
|
||||
onRemove: _removeN8nEndpoint,
|
||||
onRefresh: _refreshN8nEndpoints,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l.channelsBlurb,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
for (final ch in _channels!.channels)
|
||||
_ChannelRow(
|
||||
channel: ch,
|
||||
active: ch.name == _channels!.active,
|
||||
onConnect: _saving ? null : () => _connectToChannel(ch),
|
||||
onSwitch: _saving ? null : () => _switchChannel(ch.name),
|
||||
onEnableAutostart: _saving ? null : () => _runDaemon(
|
||||
'enable autostart',
|
||||
() => SystemActions.faiDaemonEnable(ch.name),
|
||||
),
|
||||
onDisableAutostart: _saving ? null : () => _runDaemon(
|
||||
'disable autostart',
|
||||
() => SystemActions.faiDaemonDisable(ch.name),
|
||||
),
|
||||
),
|
||||
if (_channelToast != null) ...[
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
FaiErrorBox(text: _channelToast!, maxHeight: 200),
|
||||
],
|
||||
],
|
||||
if (_aiStatus != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_SystemAiPanel(
|
||||
status: _aiStatus!,
|
||||
onEdit: () async {
|
||||
final updated = await FaiSystemAiEditor.show(
|
||||
context,
|
||||
_aiStatus!,
|
||||
_RegistryCredentialsPanel(
|
||||
configuredChars: _registryTokenChars,
|
||||
onSave: _saveRegistryToken,
|
||||
onClear: _clearRegistryToken,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_HubAuthTokenPanel(
|
||||
configuredChars: _hubAuthTokenChars,
|
||||
onSave: _saveHubAuthToken,
|
||||
onClear: _clearHubAuthToken,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
const _DefaultScopePanel(),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
const _ThemePluginPanel(),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_MaintenancePanel(
|
||||
onResetDone: () async {
|
||||
// Daemon just restarted under the same channel +
|
||||
// port. Bouncing the gRPC connection picks up the
|
||||
// fresh state without the operator having to
|
||||
// close+reopen Settings.
|
||||
await HubService.instance.reconnect(
|
||||
HubService.instance.currentEndpoint,
|
||||
);
|
||||
if (updated != null && mounted) {
|
||||
setState(() => _aiStatus = updated);
|
||||
}
|
||||
if (!mounted) return;
|
||||
await _loadChannels();
|
||||
await _loadAiStatus();
|
||||
await _loadMcpClients();
|
||||
await _loadN8nEndpoints();
|
||||
await _loadRegistryToken();
|
||||
},
|
||||
),
|
||||
],
|
||||
if (_mcpClients != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_McpClientsPanel(
|
||||
clients: _mcpClients!,
|
||||
onAdd: _addMcpClient,
|
||||
onRemove: _removeMcpClient,
|
||||
onRefresh: _refreshMcpClients,
|
||||
),
|
||||
],
|
||||
if (_n8nEndpoints != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_N8nEndpointsPanel(
|
||||
endpoints: _n8nEndpoints!,
|
||||
onAdd: _addN8nEndpoint,
|
||||
onRemove: _removeN8nEndpoint,
|
||||
onRefresh: _refreshN8nEndpoints,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_RegistryCredentialsPanel(
|
||||
configuredChars: _registryTokenChars,
|
||||
onSave: _saveRegistryToken,
|
||||
onClear: _clearRegistryToken,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_HubAuthTokenPanel(
|
||||
configuredChars: _hubAuthTokenChars,
|
||||
onSave: _saveHubAuthToken,
|
||||
onClear: _clearHubAuthToken,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
const _DefaultScopePanel(),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
const _ThemePluginPanel(),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_MaintenancePanel(
|
||||
onResetDone: () async {
|
||||
// Daemon just restarted under the same channel +
|
||||
// port. Bouncing the gRPC connection picks up the
|
||||
// fresh state without the operator having to
|
||||
// close+reopen Settings.
|
||||
await HubService.instance.reconnect(
|
||||
HubService.instance.currentEndpoint,
|
||||
);
|
||||
if (!mounted) return;
|
||||
await _loadChannels();
|
||||
await _loadAiStatus();
|
||||
await _loadMcpClients();
|
||||
await _loadN8nEndpoints();
|
||||
await _loadRegistryToken();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
|
|
@ -616,8 +627,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
|
||||
String _previewUrl() {
|
||||
final scheme = _secure ? 'https' : 'http';
|
||||
final host =
|
||||
_host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
|
||||
final host = _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
|
||||
final port = _port.text.trim().isEmpty ? '50051' : _port.text.trim();
|
||||
return '$scheme://$host:$port';
|
||||
}
|
||||
|
|
@ -627,6 +637,7 @@ class _ChannelRow extends StatelessWidget {
|
|||
final ChannelInfo channel;
|
||||
final bool active;
|
||||
final VoidCallback? onConnect;
|
||||
|
||||
/// Switches the active channel pointer (`fai channel switch`).
|
||||
/// Distinct from [onConnect], which only re-points Studio's
|
||||
/// gRPC wire at a different running daemon.
|
||||
|
|
@ -691,7 +702,10 @@ class _ChannelRow extends StatelessWidget {
|
|||
if (active)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: FaiSpace.sm),
|
||||
child: FaiPill(label: l.channelsActive, tone: FaiPillTone.success),
|
||||
child: FaiPill(
|
||||
label: l.channelsActive,
|
||||
tone: FaiPillTone.success,
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
tooltip: l.channelsActionsTooltip,
|
||||
|
|
@ -716,18 +730,12 @@ class _ChannelRow extends StatelessWidget {
|
|||
PopupMenuItem(
|
||||
value: 'connect',
|
||||
enabled: channel.running && onConnect != null,
|
||||
child: _MenuRow(
|
||||
icon: Icons.link,
|
||||
text: l.channelsConnect,
|
||||
),
|
||||
child: _MenuRow(icon: Icons.link, text: l.channelsConnect),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'switch',
|
||||
enabled: !active && onSwitch != null,
|
||||
child: _MenuRow(
|
||||
icon: Icons.swap_horiz,
|
||||
text: l.channelsSwitch,
|
||||
),
|
||||
child: _MenuRow(icon: Icons.swap_horiz, text: l.channelsSwitch),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
|
|
@ -812,7 +820,9 @@ class _SystemAiPanel extends StatelessWidget {
|
|||
TextButton.icon(
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit_outlined, size: 14),
|
||||
label: Text(status.enabled ? l.systemAiEdit : l.systemAiConfigure),
|
||||
label: Text(
|
||||
status.enabled ? l.systemAiEdit : l.systemAiConfigure,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -822,7 +832,11 @@ class _SystemAiPanel extends StatelessWidget {
|
|||
_StatRow(label: 'endpoint', value: status.endpoint, mono: true),
|
||||
_StatRow(label: 'model', value: status.model, mono: true),
|
||||
if (status.apiKeyEnv.isNotEmpty)
|
||||
_StatRow(label: 'api_key_env', value: '\$${status.apiKeyEnv}', mono: true),
|
||||
_StatRow(
|
||||
label: 'api_key_env',
|
||||
value: '\$${status.apiKeyEnv}',
|
||||
mono: true,
|
||||
),
|
||||
] else ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
|
|
@ -844,11 +858,7 @@ class _StatRow extends StatelessWidget {
|
|||
final String value;
|
||||
final bool mono;
|
||||
|
||||
const _StatRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.mono = false,
|
||||
});
|
||||
const _StatRow({required this.label, required this.value, this.mono = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -974,10 +984,7 @@ class _McpClientsPanel extends StatelessWidget {
|
|||
)
|
||||
else
|
||||
for (final c in clients)
|
||||
_McpClientRow(
|
||||
client: c,
|
||||
onRemove: () => onRemove(c.name),
|
||||
),
|
||||
_McpClientRow(client: c, onRemove: () => onRemove(c.name)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -1309,10 +1316,7 @@ class _N8nEndpointsPanel extends StatelessWidget {
|
|||
)
|
||||
else
|
||||
for (final e in endpoints)
|
||||
_N8nEndpointRow(
|
||||
endpoint: e,
|
||||
onRemove: () => onRemove(e.name),
|
||||
),
|
||||
_N8nEndpointRow(endpoint: e, onRemove: () => onRemove(e.name)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -1497,7 +1501,8 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
|||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty) return;
|
||||
if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty)
|
||||
return;
|
||||
Navigator.pop(
|
||||
context,
|
||||
N8nEndpointDraft(
|
||||
|
|
@ -1529,27 +1534,13 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
|||
/// When no theme plugin is installed the panel renders a
|
||||
/// short hint pointing at the Store rather than a useless
|
||||
/// empty dropdown.
|
||||
class _ThemePluginPanel extends StatefulWidget {
|
||||
class _ThemePluginPanel extends StatelessWidget {
|
||||
const _ThemePluginPanel();
|
||||
|
||||
@override
|
||||
State<_ThemePluginPanel> createState() => _ThemePluginPanelState();
|
||||
}
|
||||
|
||||
class _ThemePluginPanelState extends State<_ThemePluginPanel> {
|
||||
Future<List<String>>? _capsFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_capsFuture = listThemePluginCapabilities();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final app = StudioApp.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
|
@ -1561,76 +1552,12 @@ class _ThemePluginPanelState extends State<_ThemePluginPanel> {
|
|||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
FutureBuilder<List<String>>(
|
||||
future: _capsFuture,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
||||
child: SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
final caps = snap.data ?? const <String>[];
|
||||
if (caps.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(
|
||||
l.themePluginEmpty,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Dropdown shows "Built-in" + every installed
|
||||
// studio.theme.* cap. Selection writes through
|
||||
// StudioApp's notifier so the MaterialApp rebuilds
|
||||
// with the new ColorScheme without a restart.
|
||||
final active = app?.themePluginNotifier.value;
|
||||
final items = <DropdownMenuItem<String?>>[
|
||||
DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text(l.themePluginNone),
|
||||
),
|
||||
for (final cap in caps)
|
||||
DropdownMenuItem<String?>(
|
||||
value: cap,
|
||||
child: Text(cap, style: FaiTheme.mono(size: 12)),
|
||||
),
|
||||
];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButton<String?>(
|
||||
value: active,
|
||||
items: items,
|
||||
isDense: true,
|
||||
onChanged: app == null
|
||||
? null
|
||||
: (v) {
|
||||
app.setThemePlugin(v);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l.themePluginHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Rich grid of theme tiles. Each shows live swatches
|
||||
// from the plugin's own palette so the operator can
|
||||
// preview without applying. The "Custom" tile opens
|
||||
// a seed-colour dialog.
|
||||
const ThemePickerGrid(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -1782,11 +1709,7 @@ class _MaintenancePanelState extends State<_MaintenancePanel> {
|
|||
),
|
||||
if (_resultText != null) ...[
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
FaiErrorBox(
|
||||
text: _resultText!,
|
||||
isError: !_resultOk,
|
||||
maxHeight: 240,
|
||||
),
|
||||
FaiErrorBox(text: _resultText!, isError: !_resultOk, maxHeight: 240),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
|
@ -1822,8 +1745,7 @@ class _RegistryCredentialsPanel extends StatefulWidget {
|
|||
_RegistryCredentialsPanelState();
|
||||
}
|
||||
|
||||
class _RegistryCredentialsPanelState
|
||||
extends State<_RegistryCredentialsPanel> {
|
||||
class _RegistryCredentialsPanelState extends State<_RegistryCredentialsPanel> {
|
||||
final _controller = TextEditingController();
|
||||
bool _obscured = true;
|
||||
|
||||
|
|
@ -2017,9 +1939,7 @@ class _HubAuthTokenPanelState extends State<_HubAuthTokenPanel> {
|
|||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
isConfigured
|
||||
? Icons.check_circle
|
||||
: Icons.radio_button_unchecked,
|
||||
isConfigured ? Icons.check_circle : Icons.radio_button_unchecked,
|
||||
size: 14,
|
||||
color: isConfigured
|
||||
? theme.colorScheme.primary
|
||||
|
|
@ -2066,8 +1986,7 @@ class _HubAuthTokenPanelState extends State<_HubAuthTokenPanel> {
|
|||
_obscured ? Icons.visibility : Icons.visibility_off,
|
||||
size: 16,
|
||||
),
|
||||
onPressed: () =>
|
||||
setState(() => _obscured = !_obscured),
|
||||
onPressed: () => setState(() => _obscured = !_obscured),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -2103,6 +2022,7 @@ class _McpSuggestion {
|
|||
final IconData icon;
|
||||
final String endpoint;
|
||||
final String apiKeyEnv;
|
||||
|
||||
/// Localizable description. Use [resolveDescription] to fetch
|
||||
/// the actual text in the active locale — the const list
|
||||
/// can't hold a closure that takes [AppLocalizations], and
|
||||
|
|
@ -2305,9 +2225,9 @@ class _DefaultScopePanelState extends State<_DefaultScopePanel> {
|
|||
_saving = false;
|
||||
});
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.defaultScopeSavedToast)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.defaultScopeSavedToast)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
|
@ -2333,8 +2253,9 @@ class _DefaultScopePanelState extends State<_DefaultScopePanel> {
|
|||
if (current.length == 1) {
|
||||
// Hub rejects empty; surface the constraint inline rather
|
||||
// than letting the RPC fail.
|
||||
setState(() => _error = AppLocalizations.of(context)!
|
||||
.defaultScopeNonEmptyError);
|
||||
setState(
|
||||
() => _error = AppLocalizations.of(context)!.defaultScopeNonEmptyError,
|
||||
);
|
||||
return;
|
||||
}
|
||||
current.removeAt(i);
|
||||
|
|
@ -2513,10 +2434,7 @@ class _ScopeRow extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
child: Text(entry, style: const TextStyle(fontFamily: 'monospace')),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_upward, size: 16),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue