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
|
|
@ -137,8 +137,7 @@ class _DeltaPainter extends CustomPainter {
|
|||
final pulseStrength = mode == FaiDeltaMode.live ? t : 1.0;
|
||||
final glow = Paint()
|
||||
..color = color.withValues(alpha: 0.30 + 0.40 * pulseStrength)
|
||||
..maskFilter =
|
||||
MaskFilter.blur(BlurStyle.normal, 8 + 14 * pulseStrength)
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, 8 + 14 * pulseStrength)
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawPath(path, glow);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,9 +43,7 @@ class FaiEnBadge extends StatelessWidget {
|
|||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Text(
|
||||
'EN',
|
||||
|
|
|
|||
|
|
@ -20,15 +20,18 @@ class FaiErrorBox extends StatefulWidget {
|
|||
/// monospace, selectable, multi-line. Ignored when [error] is
|
||||
/// supplied.
|
||||
final String text;
|
||||
|
||||
/// When true, the box border + foreground colour come from the
|
||||
/// error palette. False renders neutral chrome — useful for
|
||||
/// success / informational copy that still wants the copy
|
||||
/// button.
|
||||
final bool isError;
|
||||
|
||||
/// Optional max-height before the content scrolls inside the
|
||||
/// box. Falls back to no constraint when null so short
|
||||
/// messages don't get a useless scrollbar.
|
||||
final double? maxHeight;
|
||||
|
||||
/// When non-null, the box renders the [friendlyError] mapping
|
||||
/// of this error: a one-line headline, a recovery-hint line
|
||||
/// (when one applies), and the verbatim original message
|
||||
|
|
@ -45,9 +48,9 @@ class FaiErrorBox extends StatefulWidget {
|
|||
this.maxHeight,
|
||||
this.error,
|
||||
}) : assert(
|
||||
text != '' || error != null,
|
||||
'FaiErrorBox needs either text or error',
|
||||
);
|
||||
text != '' || error != null,
|
||||
'FaiErrorBox needs either text or error',
|
||||
);
|
||||
|
||||
@override
|
||||
State<FaiErrorBox> createState() => _FaiErrorBoxState();
|
||||
|
|
@ -139,15 +142,15 @@ class _FaiErrorBoxState extends State<FaiErrorBox> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_detailExpanded
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
_detailExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_detailExpanded ? l.buttonHideDetails : l.buttonShowDetails,
|
||||
_detailExpanded
|
||||
? l.buttonHideDetails
|
||||
: l.buttonShowDetails,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -126,10 +126,7 @@ class _CodeBlock extends StatelessWidget {
|
|||
),
|
||||
child: SelectableText(
|
||||
text,
|
||||
style: FaiTheme.mono(
|
||||
size: 12,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
style: FaiTheme.mono(size: 12, color: theme.colorScheme.onSurface),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -170,14 +167,14 @@ class _BytesView extends StatelessWidget {
|
|||
try {
|
||||
await File(path).writeAsBytes(bytes, flush: true);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.flowsOutputSavedAt(path))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.flowsOutputSavedAt(path))));
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.flowsOutputSaveFailed('$e'))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.flowsOutputSaveFailed('$e'))));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,9 +236,9 @@ class _FileView extends StatelessWidget {
|
|||
final r = await SystemActions.openInOs(target);
|
||||
if (!context.mounted || r.ok) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr))));
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -261,10 +258,7 @@ class _FileView extends StatelessWidget {
|
|||
children: [
|
||||
SelectableText(
|
||||
uri,
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
style: FaiTheme.mono(size: 11, color: theme.colorScheme.primary),
|
||||
),
|
||||
if (mimeType.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
|
|
|
|||
|
|
@ -25,9 +25,7 @@ class FaiModuleSheet extends StatefulWidget {
|
|||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(FaiRadius.md),
|
||||
),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
|
||||
),
|
||||
builder: (_) => FaiModuleSheet(moduleName: name),
|
||||
);
|
||||
|
|
@ -56,8 +54,7 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
|
|||
// "remove the highest version", which is rarely what an
|
||||
// operator means when they've explicitly kept multiple
|
||||
// versions around.
|
||||
final versions =
|
||||
await HubService.instance.installedVersions(detail.name);
|
||||
final versions = await HubService.instance.installedVersions(detail.name);
|
||||
if (!mounted) return;
|
||||
String? targetVersion;
|
||||
if (versions.length > 1) {
|
||||
|
|
@ -101,9 +98,7 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
|
|||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l.modulesUninstalledToast(r.name, r.version)),
|
||||
),
|
||||
SnackBar(content: Text(l.modulesUninstalledToast(r.name, r.version))),
|
||||
);
|
||||
Navigator.pop(context, true);
|
||||
} catch (e) {
|
||||
|
|
@ -138,8 +133,9 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
|
|||
Padding(
|
||||
padding: const EdgeInsets.all(FaiSpace.xl),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!
|
||||
.moduleSheetFailedToLoad(snapshot.error.toString()),
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.moduleSheetFailedToLoad(snapshot.error.toString()),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -6,13 +6,7 @@ import 'package:flutter/material.dart';
|
|||
import '../theme/theme.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
enum FaiPillTone {
|
||||
neutral,
|
||||
accent,
|
||||
success,
|
||||
warning,
|
||||
danger,
|
||||
}
|
||||
enum FaiPillTone { neutral, accent, success, warning, danger }
|
||||
|
||||
class FaiPill extends StatelessWidget {
|
||||
final String label;
|
||||
|
|
@ -36,10 +30,7 @@ class FaiPill extends StatelessWidget {
|
|||
? FaiTheme.mono(size: 11, weight: FontWeight.w500, color: fg)
|
||||
: theme.textTheme.labelSmall?.copyWith(color: fg);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.sm,
|
||||
vertical: 3,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
|
|
@ -62,10 +53,7 @@ class FaiPill extends StatelessWidget {
|
|||
case FaiPillTone.neutral:
|
||||
return (scheme.surfaceContainerHighest, scheme.onSurfaceVariant);
|
||||
case FaiPillTone.accent:
|
||||
return (
|
||||
scheme.primary.withValues(alpha: 0.15),
|
||||
scheme.primary,
|
||||
);
|
||||
return (scheme.primary.withValues(alpha: 0.15), scheme.primary);
|
||||
case FaiPillTone.success:
|
||||
return (
|
||||
FaiColors.success.withValues(alpha: 0.15),
|
||||
|
|
@ -81,10 +69,7 @@ class FaiPill extends StatelessWidget {
|
|||
: const Color(0xFFB45309),
|
||||
);
|
||||
case FaiPillTone.danger:
|
||||
return (
|
||||
scheme.errorContainer,
|
||||
scheme.onErrorContainer,
|
||||
);
|
||||
return (scheme.errorContainer, scheme.onErrorContainer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,12 +21,15 @@ class FaiSearchHit {
|
|||
final String label;
|
||||
final String hint;
|
||||
final IconData icon;
|
||||
|
||||
/// Free-form group used for the section header in the
|
||||
/// results list ("Pages", "Modules", "Store", "Flows").
|
||||
final String group;
|
||||
|
||||
/// Lowercase haystack — name + hint + group. Filtered against
|
||||
/// the lowercased query.
|
||||
final String haystack;
|
||||
|
||||
/// Action to run when this hit is selected.
|
||||
final VoidCallback onSelect;
|
||||
|
||||
|
|
@ -103,60 +106,67 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
|
||||
final hits = <FaiSearchHit>[];
|
||||
for (final m in modules) {
|
||||
hits.add(FaiSearchHit(
|
||||
label: m.name,
|
||||
hint: l.searchInstalledModuleHint(m.version),
|
||||
icon: Icons.extension,
|
||||
group: l.searchGroupModules,
|
||||
onSelect: () {
|
||||
Navigator.pop(context);
|
||||
FaiModuleSheet.show(context, m.name);
|
||||
},
|
||||
));
|
||||
hits.add(
|
||||
FaiSearchHit(
|
||||
label: m.name,
|
||||
hint: l.searchInstalledModuleHint(m.version),
|
||||
icon: Icons.extension,
|
||||
group: l.searchGroupModules,
|
||||
onSelect: () {
|
||||
Navigator.pop(context);
|
||||
FaiModuleSheet.show(context, m.name);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
final storeGroup = l.searchGroupStore;
|
||||
final pagesGroup = l.searchGroupPages;
|
||||
for (final s in store) {
|
||||
hits.add(FaiSearchHit(
|
||||
label: s.name,
|
||||
hint: s.taglineEn.isEmpty
|
||||
? l.searchStoreHintWithCategory(
|
||||
s.category.isEmpty ? l.searchStoreUncategorized : s.category)
|
||||
: l.searchStoreHintWithTagline(s.taglineEn),
|
||||
icon: Icons.storefront_outlined,
|
||||
group: storeGroup,
|
||||
// Selecting a store hit just closes the palette and
|
||||
// focuses the Store tab; deep-linking to the detail
|
||||
// sheet would require thread-through plumbing we don't
|
||||
// have yet.
|
||||
onSelect: () {
|
||||
Navigator.pop(context);
|
||||
for (final h in widget.staticHits) {
|
||||
if (h.label == storeGroup && h.group == pagesGroup) {
|
||||
h.onSelect();
|
||||
return;
|
||||
hits.add(
|
||||
FaiSearchHit(
|
||||
label: s.name,
|
||||
hint: s.taglineEn.isEmpty
|
||||
? l.searchStoreHintWithCategory(
|
||||
s.category.isEmpty ? l.searchStoreUncategorized : s.category,
|
||||
)
|
||||
: l.searchStoreHintWithTagline(s.taglineEn),
|
||||
icon: Icons.storefront_outlined,
|
||||
group: storeGroup,
|
||||
// Selecting a store hit just closes the palette and
|
||||
// focuses the Store tab; deep-linking to the detail
|
||||
// sheet would require thread-through plumbing we don't
|
||||
// have yet.
|
||||
onSelect: () {
|
||||
Navigator.pop(context);
|
||||
for (final h in widget.staticHits) {
|
||||
if (h.label == storeGroup && h.group == pagesGroup) {
|
||||
h.onSelect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
final flowsGroup = l.searchGroupFlows;
|
||||
for (final f in flows) {
|
||||
hits.add(FaiSearchHit(
|
||||
label: f.name,
|
||||
hint: l.searchSavedFlowHint,
|
||||
icon: Icons.account_tree_outlined,
|
||||
group: flowsGroup,
|
||||
onSelect: () {
|
||||
Navigator.pop(context);
|
||||
for (final h in widget.staticHits) {
|
||||
if (h.label == flowsGroup && h.group == pagesGroup) {
|
||||
h.onSelect();
|
||||
return;
|
||||
hits.add(
|
||||
FaiSearchHit(
|
||||
label: f.name,
|
||||
hint: l.searchSavedFlowHint,
|
||||
icon: Icons.account_tree_outlined,
|
||||
group: flowsGroup,
|
||||
onSelect: () {
|
||||
Navigator.pop(context);
|
||||
for (final h in widget.staticHits) {
|
||||
if (h.label == flowsGroup && h.group == pagesGroup) {
|
||||
h.onSelect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
|
@ -211,9 +221,7 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
return Dialog(
|
||||
alignment: Alignment.topCenter,
|
||||
insetPadding: const EdgeInsets.only(top: 80, left: 24, right: 24),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640, maxHeight: 540),
|
||||
child: FutureBuilder<List<FaiSearchHit>>(
|
||||
|
|
@ -240,17 +248,17 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
controller: _query,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon:
|
||||
const Icon(Icons.search, size: 20),
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
hintText: l.searchHint,
|
||||
border: InputBorder.none,
|
||||
suffixIcon: snap.connectionState ==
|
||||
ConnectionState.waiting
|
||||
suffixIcon:
|
||||
snap.connectionState == ConnectionState.waiting
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
|
@ -283,11 +291,10 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
itemBuilder: (context, i) {
|
||||
final h = hits[i];
|
||||
final highlighted = i == _highlight;
|
||||
final showHeader = i == 0 ||
|
||||
hits[i - 1].group != h.group;
|
||||
final showHeader =
|
||||
i == 0 || hits[i - 1].group != h.group;
|
||||
return Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showHeader)
|
||||
Padding(
|
||||
|
|
@ -301,11 +308,12 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
h.group.toUpperCase(),
|
||||
style: theme.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: theme
|
||||
.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
fontSize: 10,
|
||||
),
|
||||
color: theme
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
|
|
@ -317,8 +325,9 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
},
|
||||
child: Container(
|
||||
color: highlighted
|
||||
? theme.colorScheme
|
||||
.surfaceContainerHigh
|
||||
? theme
|
||||
.colorScheme
|
||||
.surfaceContainerHigh
|
||||
: null,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
|
|
@ -340,21 +349,24 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
Text(
|
||||
h.label,
|
||||
style: theme
|
||||
.textTheme.bodyMedium
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
fontWeight:
|
||||
FontWeight.w500,
|
||||
),
|
||||
fontWeight:
|
||||
FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (h.hint.isNotEmpty)
|
||||
Text(
|
||||
h.hint,
|
||||
style: theme.textTheme
|
||||
style: theme
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: theme.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
color: theme
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
|
|
@ -367,7 +379,8 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
Icons.keyboard_return,
|
||||
size: 14,
|
||||
color: theme
|
||||
.colorScheme.onSurfaceVariant,
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -88,14 +88,8 @@ class _FaiStatusDotState extends State<FaiStatusDot>
|
|||
/// Convenience presets.
|
||||
class FaiStatusDots {
|
||||
FaiStatusDots._();
|
||||
static Widget live() => const FaiStatusDot(
|
||||
color: FaiColors.success,
|
||||
pulsing: true,
|
||||
);
|
||||
static Widget idle() => const FaiStatusDot(
|
||||
color: FaiColors.muted,
|
||||
);
|
||||
static Widget down() => const FaiStatusDot(
|
||||
color: FaiColors.danger,
|
||||
);
|
||||
static Widget live() =>
|
||||
const FaiStatusDot(color: FaiColors.success, pulsing: true);
|
||||
static Widget idle() => const FaiStatusDot(color: FaiColors.muted);
|
||||
static Widget down() => const FaiStatusDot(color: FaiColors.danger);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,10 +83,7 @@ class _ProviderPreset {
|
|||
];
|
||||
|
||||
static _ProviderPreset byWire(String wire) {
|
||||
return all.firstWhere(
|
||||
(p) => p.wire == wire,
|
||||
orElse: () => all.first,
|
||||
);
|
||||
return all.firstWhere((p) => p.wire == wire, orElse: () => all.first);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,9 +138,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
|||
);
|
||||
_model = TextEditingController(text: init.model);
|
||||
_apiKeyEnv = TextEditingController(
|
||||
text: init.apiKeyEnv.isEmpty
|
||||
? _preset.defaultApiKeyEnv
|
||||
: init.apiKeyEnv,
|
||||
text: init.apiKeyEnv.isEmpty ? _preset.defaultApiKeyEnv : init.apiKeyEnv,
|
||||
);
|
||||
_privacyMode = init.privacyMode.isEmpty ? 'redacted' : init.privacyMode;
|
||||
// Pull the model list immediately so the operator sees the
|
||||
|
|
@ -376,10 +371,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
|||
_HardwareBanner(hw: _hw!, lastReviewed: _curated?.lastReviewed),
|
||||
],
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_ProviderDropdown(
|
||||
value: _preset,
|
||||
onChanged: _onProviderChanged,
|
||||
),
|
||||
_ProviderDropdown(value: _preset, onChanged: _onProviderChanged),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_preset.description,
|
||||
|
|
@ -495,10 +487,7 @@ class _ProviderDropdown extends StatelessWidget {
|
|||
final _ProviderPreset value;
|
||||
final void Function(_ProviderPreset) onChanged;
|
||||
|
||||
const _ProviderDropdown({
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
const _ProviderDropdown({required this.value, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -512,12 +501,7 @@ class _ProviderDropdown extends StatelessWidget {
|
|||
isDense: true,
|
||||
),
|
||||
items: _ProviderPreset.all
|
||||
.map(
|
||||
(p) => DropdownMenuItem(
|
||||
value: p.wire,
|
||||
child: Text(p.label),
|
||||
),
|
||||
)
|
||||
.map((p) => DropdownMenuItem(value: p.wire, child: Text(p.label)))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
|
|
@ -531,10 +515,7 @@ class _PrivacyModeChips extends StatelessWidget {
|
|||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
const _PrivacyModeChips({
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
const _PrivacyModeChips({required this.value, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -547,11 +528,7 @@ class _PrivacyModeChips extends StatelessWidget {
|
|||
l.systemAiPrivacyRedactedLabel,
|
||||
l.systemAiPrivacyRedactedDesc,
|
||||
),
|
||||
(
|
||||
'full',
|
||||
l.systemAiPrivacyFullLabel,
|
||||
l.systemAiPrivacyFullDesc,
|
||||
),
|
||||
('full', l.systemAiPrivacyFullLabel, l.systemAiPrivacyFullDesc),
|
||||
];
|
||||
// Material 3 deprecated per-Radio `groupValue` / `onChanged`
|
||||
// after Flutter 3.32. The modern pattern wraps the radios in
|
||||
|
|
@ -643,9 +620,7 @@ class _TestResultPanel extends StatelessWidget {
|
|||
const SizedBox(width: FaiSpace.xs),
|
||||
Text(
|
||||
ok ? l.systemAiConnectionOk : l.systemAiConnectionFailed,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: color,
|
||||
),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: color),
|
||||
),
|
||||
const Spacer(),
|
||||
if (ok && result.latencyMs > 0)
|
||||
|
|
@ -659,10 +634,7 @@ class _TestResultPanel extends StatelessWidget {
|
|||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
ok ? l.systemAiReplyPrefix(result.text) : result.text,
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface),
|
||||
),
|
||||
if (!ok && result.fixHint.isNotEmpty) ...[
|
||||
const SizedBox(height: FaiSpace.xs),
|
||||
|
|
@ -686,12 +658,14 @@ class _ModelPicker extends StatelessWidget {
|
|||
final String? modelsError;
|
||||
final bool loading;
|
||||
final bool pulling;
|
||||
|
||||
/// Detected host hardware. Used by [_suitabilityFor] to mark
|
||||
/// curated models as "recommended" when their `min_hw_tier` is
|
||||
/// at or below the operator's tier. Null while the hub call is
|
||||
/// in flight or unreachable — chips fall back to the size-only
|
||||
/// heuristic.
|
||||
final HardwareSnapshot? hw;
|
||||
|
||||
/// Curated DB indexed by model id. Empty while the hub call is
|
||||
/// in flight; chips then fall back to the size-only heuristic.
|
||||
final Map<String, CuratedModelInfo> curatedById;
|
||||
|
|
@ -736,8 +710,8 @@ class _ModelPicker extends StatelessWidget {
|
|||
: preset.modelHint,
|
||||
helperText: controller.text.trim().isEmpty
|
||||
? (_isOllama
|
||||
? l.systemAiModelHelperOllama
|
||||
: l.systemAiModelHelperBase)
|
||||
? l.systemAiModelHelperOllama
|
||||
: l.systemAiModelHelperBase)
|
||||
: null,
|
||||
helperMaxLines: 2,
|
||||
border: const OutlineInputBorder(),
|
||||
|
|
@ -787,9 +761,7 @@ class _ModelPicker extends StatelessWidget {
|
|||
const SizedBox(height: FaiSpace.sm),
|
||||
if (models!.isEmpty)
|
||||
Text(
|
||||
_isOllama
|
||||
? l.systemAiNoModelsOllama
|
||||
: l.systemAiNoModelsGeneric,
|
||||
_isOllama ? l.systemAiNoModelsOllama : l.systemAiNoModelsGeneric,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -970,6 +942,7 @@ List<String> _sortedBySuitability(
|
|||
class _ModelChip extends StatelessWidget {
|
||||
final String id;
|
||||
final _Suitability suitability;
|
||||
|
||||
/// Curated entry for this id, when one exists. Drives the
|
||||
/// tooltip — operators get the editorial note ("Solid 4B
|
||||
/// all-rounder…") rather than just the suitability label.
|
||||
|
|
@ -1010,9 +983,7 @@ class _ModelChip extends StatelessWidget {
|
|||
message: _tooltipFor(context),
|
||||
child: ActionChip(
|
||||
avatar: Icon(
|
||||
suitability == _Suitability.recommended
|
||||
? Icons.star
|
||||
: Icons.circle,
|
||||
suitability == _Suitability.recommended ? Icons.star : Icons.circle,
|
||||
size: suitability == _Suitability.recommended ? 14 : 9,
|
||||
color: color,
|
||||
),
|
||||
|
|
@ -1041,10 +1012,7 @@ class _HardwareBanner extends StatelessWidget {
|
|||
? l.systemAiHwReviewed(lastReviewed!)
|
||||
: '';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.sm,
|
||||
vertical: 6,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
|
|
@ -1081,23 +1049,25 @@ class _SuitabilityLegend extends StatelessWidget {
|
|||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
Widget dot(Color c, String label) => Padding(
|
||||
padding: const EdgeInsets.only(right: FaiSpace.md),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.circle, size: 8, color: c),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
padding: const EdgeInsets.only(right: FaiSpace.md),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.circle, size: 8, color: c),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
);
|
||||
final tierTag = (hw == null || hw!.tier == 'unknown') ? '' : ' (${hw!.tier})';
|
||||
],
|
||||
),
|
||||
);
|
||||
final tierTag = (hw == null || hw!.tier == 'unknown')
|
||||
? ''
|
||||
: ' (${hw!.tier})';
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
|
|
@ -1143,10 +1113,7 @@ class _CacheStatusRow extends StatelessWidget {
|
|||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.sm,
|
||||
vertical: 6,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
|
|
@ -1154,11 +1121,7 @@ class _CacheStatusRow extends StatelessWidget {
|
|||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bolt,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Icon(Icons.bolt, size: 14, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
|
|
|
|||
463
lib/widgets/theme_picker_grid.dart
Normal file
463
lib/widgets/theme_picker_grid.dart
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
// Rich theme picker — grid of swatched theme tiles, plus a
|
||||
// custom-seed-color tile that opens a small color picker
|
||||
// dialog. Replaces the dropdown that used to live inside the
|
||||
// Settings dialog's Theme section.
|
||||
//
|
||||
// The picker writes through StudioApp.setThemePlugin. Built-in
|
||||
// = null. Installed plugin = its capability identifier
|
||||
// (e.g. studio.theme.solarized). Custom = `custom:#RRGGBB`.
|
||||
// See main.dart's _pluginThemes for the parsing side.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/theme_plugin.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../main.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
class ThemePickerGrid extends StatefulWidget {
|
||||
const ThemePickerGrid({super.key});
|
||||
|
||||
@override
|
||||
State<ThemePickerGrid> createState() => _ThemePickerGridState();
|
||||
}
|
||||
|
||||
class _ThemePickerGridState extends State<ThemePickerGrid> {
|
||||
late Future<List<String>> _capsFuture;
|
||||
// Per-capability cached schemes for the tile previews. We
|
||||
// load them lazily once the list arrives so the grid
|
||||
// doesn't block while every plugin's theme_for() round-
|
||||
// trips. A null entry means "loaded, no scheme" — the
|
||||
// tile falls back to a generic palette.
|
||||
final Map<String, ThemePluginSchemes?> _previews = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_capsFuture = listThemePluginCapabilities();
|
||||
}
|
||||
|
||||
Future<void> _ensurePreview(String cap) async {
|
||||
if (_previews.containsKey(cap)) return;
|
||||
_previews[cap] = null;
|
||||
final schemes = await loadThemePluginSchemes(cap);
|
||||
if (!mounted) return;
|
||||
setState(() => _previews[cap] = schemes);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final app = StudioApp.of(context);
|
||||
return 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>[];
|
||||
// Trigger preview loads for any plugin we haven't
|
||||
// fetched yet — best-effort, fire-and-forget.
|
||||
for (final cap in caps) {
|
||||
if (!_previews.containsKey(cap)) {
|
||||
// ignore: discarded_futures
|
||||
_ensurePreview(cap);
|
||||
}
|
||||
}
|
||||
final active = app?.themePluginNotifier.value;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
_builtInTile(theme, l, active),
|
||||
for (final cap in caps) _pluginTile(theme, cap, active),
|
||||
_customTile(theme, l, active),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
Text(
|
||||
l.themePluginHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _builtInTile(ThemeData theme, AppLocalizations l, String? active) {
|
||||
return _Tile(
|
||||
label: l.themePluginNone,
|
||||
swatches: [
|
||||
theme.colorScheme.primary,
|
||||
theme.colorScheme.secondary,
|
||||
theme.colorScheme.tertiary,
|
||||
],
|
||||
selected: active == null,
|
||||
onTap: () {
|
||||
StudioApp.of(context)?.setThemePlugin(null);
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pluginTile(ThemeData theme, String cap, String? active) {
|
||||
// Pretty name: studio.theme.sunflower → "Sunflower".
|
||||
final display = _displayName(cap);
|
||||
final preview = _previews[cap];
|
||||
// Swatches come from the plugin's matching-brightness
|
||||
// scheme so the tile literally previews what selecting
|
||||
// it does. Fall back to the active theme's accents
|
||||
// while the plugin's preview hasn't loaded yet.
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final scheme = isDark ? preview?.dark : preview?.light;
|
||||
final swatches = scheme != null
|
||||
? <Color>[scheme.primary, scheme.secondary, scheme.tertiary]
|
||||
: <Color>[
|
||||
theme.colorScheme.primary,
|
||||
theme.colorScheme.secondary,
|
||||
theme.colorScheme.tertiary,
|
||||
];
|
||||
return _Tile(
|
||||
label: display,
|
||||
swatches: swatches,
|
||||
selected: active == cap,
|
||||
loading: preview == null,
|
||||
onTap: () {
|
||||
StudioApp.of(context)?.setThemePlugin(cap);
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _customTile(ThemeData theme, AppLocalizations l, String? active) {
|
||||
final isCustom = active != null && active.startsWith('custom:');
|
||||
final seedHex = isCustom ? active.substring('custom:'.length) : null;
|
||||
final seed = seedHex != null
|
||||
? _parseHexColor(seedHex)
|
||||
: theme.colorScheme.primary;
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: seed ?? theme.colorScheme.primary,
|
||||
brightness: theme.brightness,
|
||||
);
|
||||
return _Tile(
|
||||
label: l.themePluginCustom,
|
||||
swatches: [scheme.primary, scheme.secondary, scheme.tertiary],
|
||||
selected: isCustom,
|
||||
onTap: () async {
|
||||
final picked = await _showColorPickerDialog(
|
||||
context,
|
||||
initial: seed ?? theme.colorScheme.primary,
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
final hex = picked.toARGB32().toRadixString(16).padLeft(8, '0');
|
||||
// Strip alpha component — we only persist the RGB.
|
||||
final rgb = hex.substring(2).toUpperCase();
|
||||
StudioApp.of(context)?.setThemePlugin('custom:#$rgb');
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _displayName(String cap) {
|
||||
// studio.theme.glass-apple → "Glass Apple"
|
||||
final last = cap.split('.').last;
|
||||
return last
|
||||
.split(RegExp(r'[-_]'))
|
||||
.map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}')
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
Color? _parseHexColor(String s) {
|
||||
final cleaned = s.replaceAll('#', '');
|
||||
if (cleaned.length != 6) return null;
|
||||
final raw = int.tryParse(cleaned, radix: 16);
|
||||
if (raw == null) return null;
|
||||
return Color(0xFF000000 | raw);
|
||||
}
|
||||
|
||||
class _Tile extends StatelessWidget {
|
||||
final String label;
|
||||
final List<Color> swatches;
|
||||
final bool selected;
|
||||
final bool loading;
|
||||
final VoidCallback onTap;
|
||||
const _Tile({
|
||||
required this.label,
|
||||
required this.swatches,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.loading = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SizedBox(
|
||||
width: 132,
|
||||
height: 76,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
for (final c in swatches) ...[
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: c,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
if (loading)
|
||||
const SizedBox(
|
||||
width: 10,
|
||||
height: 10,
|
||||
child: CircularProgressIndicator(strokeWidth: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: selected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected)
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 14,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Color?> _showColorPickerDialog(
|
||||
BuildContext context, {
|
||||
required Color initial,
|
||||
}) {
|
||||
return showDialog<Color>(
|
||||
context: context,
|
||||
builder: (ctx) => _ColorPickerDialog(initial: initial),
|
||||
);
|
||||
}
|
||||
|
||||
class _ColorPickerDialog extends StatefulWidget {
|
||||
final Color initial;
|
||||
const _ColorPickerDialog({required this.initial});
|
||||
@override
|
||||
State<_ColorPickerDialog> createState() => _ColorPickerDialogState();
|
||||
}
|
||||
|
||||
class _ColorPickerDialogState extends State<_ColorPickerDialog> {
|
||||
late Color _color = widget.initial;
|
||||
late final TextEditingController _hex = TextEditingController(
|
||||
text: _hexOf(widget.initial),
|
||||
);
|
||||
|
||||
/// Curated 12-colour palette — primary Material 3 hues at
|
||||
/// the 500 step, picked so each is visually distinct + each
|
||||
/// yields a usable ColorScheme.fromSeed. The operator can
|
||||
/// override via the hex field below.
|
||||
static const List<int> _presets = [
|
||||
0xFF1976D2, // Blue
|
||||
0xFF00ACC1, // Cyan
|
||||
0xFF43A047, // Green
|
||||
0xFFFFB300, // Amber
|
||||
0xFFF4511E, // Deep Orange
|
||||
0xFFE53935, // Red
|
||||
0xFFD81B60, // Pink
|
||||
0xFF8E24AA, // Purple
|
||||
0xFF5E35B1, // Deep Purple
|
||||
0xFF3949AB, // Indigo
|
||||
0xFF00897B, // Teal
|
||||
0xFF6D4C41, // Brown
|
||||
];
|
||||
|
||||
String _hexOf(Color c) =>
|
||||
'#${c.toARGB32().toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}';
|
||||
|
||||
void _pickPreset(int argb) {
|
||||
setState(() {
|
||||
_color = Color(argb);
|
||||
_hex.text = _hexOf(_color);
|
||||
});
|
||||
}
|
||||
|
||||
void _commitHex(String input) {
|
||||
final cleaned = input.replaceAll('#', '').trim();
|
||||
if (cleaned.length != 6) return;
|
||||
final raw = int.tryParse(cleaned, radix: 16);
|
||||
if (raw == null) return;
|
||||
setState(() => _color = Color(0xFF000000 | raw));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final preview = ColorScheme.fromSeed(
|
||||
seedColor: _color,
|
||||
brightness: theme.brightness,
|
||||
);
|
||||
return AlertDialog(
|
||||
title: Text(l.themePluginCustomDialogTitle),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final argb in _presets)
|
||||
GestureDetector(
|
||||
onTap: () => _pickPreset(argb),
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(argb),
|
||||
border: Border.all(
|
||||
color: _color.toARGB32() == argb
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant,
|
||||
width: _color.toARGB32() == argb ? 2.5 : 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
TextField(
|
||||
controller: _hex,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.themePluginCustomHexLabel,
|
||||
hintText: '#1976D2',
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: _commitHex,
|
||||
onSubmitted: _commitHex,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(l.themePluginCustomPreview, style: theme.textTheme.labelSmall),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
_swatch(preview.primary, 'P'),
|
||||
_swatch(preview.secondary, 'S'),
|
||||
_swatch(preview.tertiary, 'T'),
|
||||
_swatch(preview.surface, '∎'),
|
||||
_swatch(preview.error, '!'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l.themePluginCustomCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, _color),
|
||||
child: Text(l.themePluginCustomApply),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _swatch(Color c, String letter) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: c,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
letter,
|
||||
style: TextStyle(
|
||||
color: ThemeData.estimateBrightnessForColor(c) == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue