feat(studio): rich theme picker (presets + custom colour) + editor 0.11.0
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:
flemming-it 2026-06-02 01:29:34 +02:00
parent 69864da934
commit c1c60d5434
30 changed files with 1280 additions and 846 deletions

View file

@ -15,12 +15,10 @@ import 'package:path/path.dart' as p;
/// based on its own `auth.tokens:` config. /// based on its own `auth.tokens:` config.
class HubAuthToken { class HubAuthToken {
static String _faiHome() { static String _faiHome() {
final home = Platform.environment['HOME'] ?? final home =
Platform.environment['USERPROFILE']; Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
if (home == null || home.isEmpty) { if (home == null || home.isEmpty) {
throw StateError( throw StateError('Cannot resolve home directory (no HOME / USERPROFILE)');
'Cannot resolve home directory (no HOME / USERPROFILE)',
);
} }
return p.join(home, '.fai'); return p.join(home, '.fai');
} }
@ -79,7 +77,9 @@ class HubAuthToken {
if ((Platform.isLinux || Platform.isMacOS) && !parentExisted) { if ((Platform.isLinux || Platform.isMacOS) && !parentExisted) {
try { try {
await Process.run('chmod', ['700', parent.path]); await Process.run('chmod', ['700', parent.path]);
} catch (_) {/* best-effort */} } catch (_) {
/* best-effort */
}
} }
// Write to the temp path with restrictive mode set up // Write to the temp path with restrictive mode set up
// BEFORE the rename the final file is never visible // BEFORE the rename the final file is never visible
@ -88,7 +88,9 @@ class HubAuthToken {
if (Platform.isLinux || Platform.isMacOS) { if (Platform.isLinux || Platform.isMacOS) {
try { try {
await Process.run('chmod', ['600', tmp.path]); await Process.run('chmod', ['600', tmp.path]);
} catch (_) {/* best-effort */} } catch (_) {
/* best-effort */
}
} }
// Atomic rename: on POSIX, replaces the destination // Atomic rename: on POSIX, replaces the destination
// in one syscall. On Windows, Dart's File.rename uses // in one syscall. On Windows, Dart's File.rename uses
@ -105,7 +107,9 @@ class HubAuthToken {
if (Platform.isLinux || Platform.isMacOS) { if (Platform.isLinux || Platform.isMacOS) {
try { try {
await Process.run('chmod', ['600', f.path]); await Process.run('chmod', ['600', f.path]);
} catch (_) {/* best-effort */} } catch (_) {
/* best-effort */
}
} }
await tmp.delete(); await tmp.delete();
} }

View file

@ -10,7 +10,8 @@ import 'package:path/path.dart' as p;
/// requires the operator to fiddle with shell env vars. /// requires the operator to fiddle with shell env vars.
class RegistryToken { class RegistryToken {
static String _faiHome() { static String _faiHome() {
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; final home =
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
if (home == null || home.isEmpty) { if (home == null || home.isEmpty) {
throw StateError('Cannot resolve home directory (no HOME / USERPROFILE)'); throw StateError('Cannot resolve home directory (no HOME / USERPROFILE)');
} }

View file

@ -152,7 +152,8 @@ class SystemActions {
return ( return (
ok: false, ok: false,
stdout: '', stdout: '',
stderr: 'Could not locate the `fai` binary. Install via the ' stderr:
'Could not locate the `fai` binary. Install via the '
'platform installer or set FAI_BIN to its full path.', 'platform installer or set FAI_BIN to its full path.',
); );
} }

View file

@ -39,8 +39,9 @@ class ThemePluginSchemes {
Future<List<String>> listThemePluginCapabilities() async { Future<List<String>> listThemePluginCapabilities() async {
final caps = await HubService.instance.allCapabilities(); final caps = await HubService.instance.allCapabilities();
return caps return caps
.where((c) => .where(
c.kind == 'wasm' && c.capability.startsWith('studio.theme.')) (c) => c.kind == 'wasm' && c.capability.startsWith('studio.theme.'),
)
.map((c) => c.capability) .map((c) => c.capability)
.toList() .toList()
..sort(); ..sort();
@ -121,6 +122,7 @@ ColorScheme _tokensToColorScheme(List<int> tokens, Brightness brightness) {
? const Color(0xFF1A1A1A) ? const Color(0xFF1A1A1A)
: const Color(0xFFEEEEEE); : const Color(0xFFEEEEEE);
} }
return ColorScheme( return ColorScheme(
brightness: brightness, brightness: brightness,
primary: slot(0), primary: slot(0),

View file

@ -58,7 +58,8 @@ class TodayStoryLoader {
/// Resolves to `~/.fai/today/active.yaml` on every supported /// Resolves to `~/.fai/today/active.yaml` on every supported
/// host. The pipeline writes here from [tools/today/accept.sh]. /// host. The pipeline writes here from [tools/today/accept.sh].
static String activePath() { static String activePath() {
final home = Platform.environment['HOME'] ?? final home =
Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ?? Platform.environment['USERPROFILE'] ??
''; '';
return p.join(home, '.fai', 'today', 'active.yaml'); return p.join(home, '.fai', 'today', 'active.yaml');

View file

@ -108,6 +108,12 @@
"themePluginNone": "Standard (kein Plugin)", "themePluginNone": "Standard (kein Plugin)",
"themePluginHint": "Installiertes studio.theme.*-Plugin auswählen, um das ganze Studio umzustylen. Erst eines aus dem Store installieren.", "themePluginHint": "Installiertes studio.theme.*-Plugin auswählen, um das ganze Studio umzustylen. Erst eines aus dem Store installieren.",
"themePluginEmpty": "Kein Theme-Plugin installiert. Im Store eines aus der Kategorie studio-plugin auswählen.", "themePluginEmpty": "Kein Theme-Plugin installiert. Im Store eines aus der Kategorie studio-plugin auswählen.",
"themePluginCustom": "Eigene Farbe",
"themePluginCustomDialogTitle": "Eigenes Theme — Seedfarbe wählen",
"themePluginCustomHexLabel": "Hex-Farbe",
"themePluginCustomPreview": "Live-Vorschau",
"themePluginCustomCancel": "Abbrechen",
"themePluginCustomApply": "Anwenden",
"settingsTitle": "Hub-Endpunkt", "settingsTitle": "Hub-Endpunkt",
"settingsHost": "Host", "settingsHost": "Host",
"settingsPort": "Port", "settingsPort": "Port",

View file

@ -111,6 +111,12 @@
"themePluginNone": "Built-in (no plugin)", "themePluginNone": "Built-in (no plugin)",
"themePluginHint": "Pick an installed studio.theme.* plugin to re-skin the whole app. Install one from the Store first.", "themePluginHint": "Pick an installed studio.theme.* plugin to re-skin the whole app. Install one from the Store first.",
"themePluginEmpty": "No theme plugins installed. Open the Store and pick one in the studio-plugin category.", "themePluginEmpty": "No theme plugins installed. Open the Store and pick one in the studio-plugin category.",
"themePluginCustom": "Custom colour",
"themePluginCustomDialogTitle": "Custom theme — pick a seed colour",
"themePluginCustomHexLabel": "Hex colour",
"themePluginCustomPreview": "Live preview",
"themePluginCustomCancel": "Cancel",
"themePluginCustomApply": "Apply",
"settingsTitle": "Hub endpoint", "settingsTitle": "Hub endpoint",
"settingsHost": "Host", "settingsHost": "Host",
"settingsPort": "Port", "settingsPort": "Port",

View file

@ -662,6 +662,42 @@ abstract class AppLocalizations {
/// **'No theme plugins installed. Open the Store and pick one in the studio-plugin category.'** /// **'No theme plugins installed. Open the Store and pick one in the studio-plugin category.'**
String get themePluginEmpty; String get themePluginEmpty;
/// No description provided for @themePluginCustom.
///
/// In en, this message translates to:
/// **'Custom colour'**
String get themePluginCustom;
/// No description provided for @themePluginCustomDialogTitle.
///
/// In en, this message translates to:
/// **'Custom theme — pick a seed colour'**
String get themePluginCustomDialogTitle;
/// No description provided for @themePluginCustomHexLabel.
///
/// In en, this message translates to:
/// **'Hex colour'**
String get themePluginCustomHexLabel;
/// No description provided for @themePluginCustomPreview.
///
/// In en, this message translates to:
/// **'Live preview'**
String get themePluginCustomPreview;
/// No description provided for @themePluginCustomCancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get themePluginCustomCancel;
/// No description provided for @themePluginCustomApply.
///
/// In en, this message translates to:
/// **'Apply'**
String get themePluginCustomApply;
/// No description provided for @settingsTitle. /// No description provided for @settingsTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -326,6 +326,24 @@ class AppLocalizationsDe extends AppLocalizations {
String get themePluginEmpty => String get themePluginEmpty =>
'Kein Theme-Plugin installiert. Im Store eines aus der Kategorie studio-plugin auswählen.'; 'Kein Theme-Plugin installiert. Im Store eines aus der Kategorie studio-plugin auswählen.';
@override
String get themePluginCustom => 'Eigene Farbe';
@override
String get themePluginCustomDialogTitle => 'Eigenes Theme — Seedfarbe wählen';
@override
String get themePluginCustomHexLabel => 'Hex-Farbe';
@override
String get themePluginCustomPreview => 'Live-Vorschau';
@override
String get themePluginCustomCancel => 'Abbrechen';
@override
String get themePluginCustomApply => 'Anwenden';
@override @override
String get settingsTitle => 'Hub-Endpunkt'; String get settingsTitle => 'Hub-Endpunkt';

View file

@ -326,6 +326,25 @@ class AppLocalizationsEn extends AppLocalizations {
String get themePluginEmpty => String get themePluginEmpty =>
'No theme plugins installed. Open the Store and pick one in the studio-plugin category.'; 'No theme plugins installed. Open the Store and pick one in the studio-plugin category.';
@override
String get themePluginCustom => 'Custom colour';
@override
String get themePluginCustomDialogTitle =>
'Custom theme — pick a seed colour';
@override
String get themePluginCustomHexLabel => 'Hex colour';
@override
String get themePluginCustomPreview => 'Live preview';
@override
String get themePluginCustomCancel => 'Cancel';
@override
String get themePluginCustomApply => 'Apply';
@override @override
String get settingsTitle => 'Hub endpoint'; String get settingsTitle => 'Hub endpoint';

View file

@ -37,16 +37,19 @@ Future<void> main() async {
final themeMode = await HubService.instance.loadThemeMode(); final themeMode = await HubService.instance.loadThemeMode();
final locale = await HubService.instance.loadLocale(); final locale = await HubService.instance.loadLocale();
final themePlugin = await loadActiveThemePlugin(); final themePlugin = await loadActiveThemePlugin();
runApp(StudioApp( runApp(
StudioApp(
initialThemeMode: themeMode, initialThemeMode: themeMode,
initialLocale: locale, initialLocale: locale,
initialThemePlugin: themePlugin, initialThemePlugin: themePlugin,
)); ),
);
} }
class StudioApp extends StatefulWidget { class StudioApp extends StatefulWidget {
final ThemeModeValue initialThemeMode; final ThemeModeValue initialThemeMode;
final Locale initialLocale; final Locale initialLocale;
/// Capability name of the active theme plugin (e.g. /// Capability name of the active theme plugin (e.g.
/// `studio.theme.solarized`) or `null` for the built-in /// `studio.theme.solarized`) or `null` for the built-in
/// FaiTheme. /// FaiTheme.
@ -75,10 +78,12 @@ class StudioAppState extends State<StudioApp> {
/// `MaterialApp(home: const StudioShell())` because the /// `MaterialApp(home: const StudioShell())` because the
/// const child's element doesn't get a `didUpdateWidget`. /// const child's element doesn't get a `didUpdateWidget`.
late final ValueNotifier<ThemeModeValue> modeNotifier; late final ValueNotifier<ThemeModeValue> modeNotifier;
/// Active app locale. The sidebar's language toggle flips /// Active app locale. The sidebar's language toggle flips
/// this, MaterialApp re-renders with the new /// this, MaterialApp re-renders with the new
/// AppLocalizations, and every translated string updates. /// AppLocalizations, and every translated string updates.
late final ValueNotifier<Locale> localeNotifier; late final ValueNotifier<Locale> localeNotifier;
/// Capability name of the active `studio.theme.*` plugin /// Capability name of the active `studio.theme.*` plugin
/// (e.g. `studio.theme.solarized`) or `null` for /// (e.g. `studio.theme.solarized`) or `null` for
/// FaiTheme's built-in palette. Flipping this triggers a /// FaiTheme's built-in palette. Flipping this triggers a
@ -132,16 +137,45 @@ class StudioAppState extends State<StudioApp> {
/// theme plugin. Returns immediately with `null` when no /// theme plugin. Returns immediately with `null` when no
/// plugin is selected; otherwise awaits the plugin's /// plugin is selected; otherwise awaits the plugin's
/// `theme_for` round-trip for both brightnesses. /// `theme_for` round-trip for both brightnesses.
///
/// The capability slot also carries the `custom:#RRGGBB`
/// sigil for free-color themes: when the value starts with
/// `custom:`, the suffix is parsed as a hex seed and the
/// ColorScheme is derived via Material 3's seed algorithm.
/// That single channel covers both "pick from installed
/// plugins" and "pick a seed colour" without a parallel
/// notifier.
Future<({ThemeData? light, ThemeData? dark})> _pluginThemes( Future<({ThemeData? light, ThemeData? dark})> _pluginThemes(
String? capability, String? capability,
) async { ) async {
if (capability == null || capability.isEmpty) { if (capability == null || capability.isEmpty) {
return (light: null, dark: null); return (light: null, dark: null);
} }
if (capability.startsWith('custom:')) {
final hex = capability.substring('custom:'.length).replaceAll('#', '');
if (hex.length != 6) return (light: null, dark: null);
final raw = int.tryParse(hex, radix: 16);
if (raw == null) return (light: null, dark: null);
final seed = Color(0xFF000000 | raw);
final light = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.light,
);
final dark = ColorScheme.fromSeed(
seedColor: seed,
brightness: Brightness.dark,
);
return (
light: themeDataFromScheme(light),
dark: themeDataFromScheme(dark),
);
}
try { try {
final schemes = await loadThemePluginSchemes(capability); final schemes = await loadThemePluginSchemes(capability);
return ( return (
light: schemes.light == null ? null : themeDataFromScheme(schemes.light!), light: schemes.light == null
? null
: themeDataFromScheme(schemes.light!),
dark: schemes.dark == null ? null : themeDataFromScheme(schemes.dark!), dark: schemes.dark == null ? null : themeDataFromScheme(schemes.dark!),
); );
} catch (_) { } catch (_) {
@ -160,7 +194,8 @@ class StudioAppState extends State<StudioApp> {
valueListenable: localeNotifier, valueListenable: localeNotifier,
builder: (_, locale, _) => ValueListenableBuilder<String?>( builder: (_, locale, _) => ValueListenableBuilder<String?>(
valueListenable: themePluginNotifier, valueListenable: themePluginNotifier,
builder: (_, pluginCap, _) => FutureBuilder<({ThemeData? light, ThemeData? dark})>( builder: (_, pluginCap, _) =>
FutureBuilder<({ThemeData? light, ThemeData? dark})>(
// Re-key on the capability so a plugin change // Re-key on the capability so a plugin change
// discards the in-flight future for the previous // discards the in-flight future for the previous
// plugin and starts fresh. // plugin and starts fresh.
@ -176,7 +211,8 @@ class StudioAppState extends State<StudioApp> {
themeMode: _flutterMode(mode), themeMode: _flutterMode(mode),
locale: locale, locale: locale,
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates, localizationsDelegates:
AppLocalizations.localizationsDelegates,
home: const StudioShell(), home: const StudioShell(),
); );
}, },
@ -328,7 +364,9 @@ class _StudioShellState extends State<StudioShell> {
SingleActivator( SingleActivator(
LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i), LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i),
meta: true, meta: true,
): _GoToPageIntent(i), ): _GoToPageIntent(
i,
),
// Settings dialog. macOS reserves Cmd+, for the native // Settings dialog. macOS reserves Cmd+, for the native
// app-Preferences menu item, which Flutter Desktop // app-Preferences menu item, which Flutter Desktop
// doesn't wire up — the keystroke is captured by the OS // doesn't wire up — the keystroke is captured by the OS
@ -381,10 +419,7 @@ class _StudioShellState extends State<StudioShell> {
endpointLabel: HubService.instance.endpointLabel, endpointLabel: HubService.instance.endpointLabel,
activeChannel: _activeChannel, activeChannel: _activeChannel,
), ),
Container( Container(width: 1, color: theme.colorScheme.outlineVariant),
width: 1,
color: theme.colorScheme.outlineVariant,
),
Expanded( Expanded(
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: FaiMotion.base, duration: FaiMotion.base,
@ -435,6 +470,7 @@ class _Sidebar extends StatefulWidget {
final List<_NavPage> pages; final List<_NavPage> pages;
final bool? connected; final bool? connected;
final String endpointLabel; final String endpointLabel;
/// Active channel name from the running daemon /// Active channel name from the running daemon
/// (`local`/`dev`/`beta`/`production`). Null while the /// (`local`/`dev`/`beta`/`production`). Null while the
/// initial fetch is in flight or the hub is unreachable. /// initial fetch is in flight or the hub is unreachable.
@ -506,7 +542,9 @@ class _SidebarState extends State<_Sidebar>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final mode = widget.connected == true ? FaiDeltaMode.live : FaiDeltaMode.idle; final mode = widget.connected == true
? FaiDeltaMode.live
: FaiDeltaMode.idle;
final hasChannel = final hasChannel =
widget.activeChannel != null && widget.activeChannel!.isNotEmpty; widget.activeChannel != null && widget.activeChannel!.isNotEmpty;
return MouseRegion( return MouseRegion(
@ -516,7 +554,8 @@ class _SidebarState extends State<_Sidebar>
animation: _ctrl, animation: _ctrl,
builder: (context, _) { builder: (context, _) {
final t = Curves.easeInOutCubic.transform(_ctrl.value); final t = Curves.easeInOutCubic.transform(_ctrl.value);
final width = _collapsedWidth + (_expandedWidth - _collapsedWidth) * t; final width =
_collapsedWidth + (_expandedWidth - _collapsedWidth) * t;
final labelsInteractive = t > 0.5; final labelsInteractive = t > 0.5;
return Container( return Container(
width: width, width: width,
@ -590,9 +629,7 @@ class _SidebarState extends State<_Sidebar>
channel: widget.activeChannel!, channel: widget.activeChannel!,
), ),
), ),
label: _ChannelPill( label: _ChannelPill(channel: widget.activeChannel!),
channel: widget.activeChannel!,
),
t: t, t: t,
labelsInteractive: labelsInteractive, labelsInteractive: labelsInteractive,
) )
@ -851,11 +888,7 @@ class _CollapsedChannelChip extends StatelessWidget {
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
_letter, _letter,
style: FaiTheme.mono( style: FaiTheme.mono(size: 10, weight: FontWeight.w700, color: accent),
size: 10,
weight: FontWeight.w700,
color: accent,
),
), ),
); );
} }
@ -948,6 +981,7 @@ class _ConnectionLabel extends StatelessWidget {
class _SidebarItem extends StatefulWidget { class _SidebarItem extends StatefulWidget {
final _NavPage page; final _NavPage page;
final bool selected; final bool selected;
/// 0 = fully collapsed, 1 = fully expanded. Drives label /// 0 = fully collapsed, 1 = fully expanded. Drives label
/// opacity in lockstep with the parent rail's width /// opacity in lockstep with the parent rail's width
/// animation there is no second timer for the label, so /// animation there is no second timer for the label, so
@ -1017,7 +1051,9 @@ class _SidebarItemState extends State<_SidebarItem> {
label, label,
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
softWrap: false, softWrap: false,
style: theme.textTheme.labelMedium?.copyWith(color: color), style: theme.textTheme.labelMedium?.copyWith(
color: color,
),
), ),
), ),
), ),
@ -1052,11 +1088,7 @@ class _SidebarItemState extends State<_SidebarItem> {
// Tooltip only while the label is hidden once the label // Tooltip only while the label is hidden once the label
// is visible inline the tooltip becomes redundant noise. // is visible inline the tooltip becomes redundant noise.
if (widget.t > 0.5) return inner; if (widget.t > 0.5) return inner;
return Tooltip( return Tooltip(message: label, preferBelow: false, child: inner);
message: label,
preferBelow: false,
child: inner,
);
} }
} }
@ -1107,9 +1139,7 @@ class _Footer extends StatelessWidget {
children: [ children: [
_ThemeToggle(), _ThemeToggle(),
_LanguageToggle(), _LanguageToggle(),
const Expanded( const Expanded(child: Center(child: _SidebarClock())),
child: Center(child: _SidebarClock()),
),
], ],
), ),
), ),

View file

@ -21,6 +21,7 @@ class _ApprovalsPageState extends State<ApprovalsPage>
late final TabController _tab; late final TabController _tab;
late Future<List<ApprovalRecord>> _pendingFuture; late Future<List<ApprovalRecord>> _pendingFuture;
late Future<List<ApprovalRecord>> _historyFuture; late Future<List<ApprovalRecord>> _historyFuture;
/// IDs the operator currently has multi-selected for a /// IDs the operator currently has multi-selected for a
/// batch operation. Empty set hides the batch action bar /// batch operation. Empty set hides the batch action bar
/// and falls back to per-row Approve / Reject buttons. /// and falls back to per-row Approve / Reject buttons.
@ -32,7 +33,8 @@ class _ApprovalsPageState extends State<ApprovalsPage>
late final String _reviewer = _defaultReviewer(); late final String _reviewer = _defaultReviewer();
static String _defaultReviewer() { static String _defaultReviewer() {
final user = Platform.environment['USER'] ?? final user =
Platform.environment['USER'] ??
Platform.environment['USERNAME'] ?? Platform.environment['USERNAME'] ??
'studio'; 'studio';
return '$user@studio'; return '$user@studio';
@ -124,9 +126,11 @@ class _ApprovalsPageState extends State<ApprovalsPage>
} }
if (!mounted) return; if (!mounted) return;
setState(() => _batchInFlight = false); setState(() => _batchInFlight = false);
_toast(failed == 0 _toast(
failed == 0
? l.approvalsBatchApproveDoneToast(ok) ? l.approvalsBatchApproveDoneToast(ok)
: l.approvalsBatchPartialFailure(ok, failed)); : l.approvalsBatchPartialFailure(ok, failed),
);
_refresh(); _refresh();
} }
@ -148,9 +152,11 @@ class _ApprovalsPageState extends State<ApprovalsPage>
} }
if (!mounted) return; if (!mounted) return;
setState(() => _batchInFlight = false); setState(() => _batchInFlight = false);
_toast(failed == 0 _toast(
failed == 0
? l.approvalsBatchRejectDoneToast(ok) ? l.approvalsBatchRejectDoneToast(ok)
: l.approvalsBatchPartialFailure(ok, failed)); : l.approvalsBatchPartialFailure(ok, failed),
);
_refresh(); _refresh();
} }
@ -231,10 +237,7 @@ class _ApprovalsPageState extends State<ApprovalsPage>
onBatchReject: _batchReject, onBatchReject: _batchReject,
onRetry: _refresh, onRetry: _refresh,
), ),
_HistoryList( _HistoryList(future: _historyFuture, onRetry: _refresh),
future: _historyFuture,
onRetry: _refresh,
),
], ],
), ),
); );
@ -391,8 +394,11 @@ class _BatchActionBar extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
Icon(Icons.check_box_outlined, Icon(
size: 18, color: theme.colorScheme.primary), Icons.check_box_outlined,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
Text( Text(
l.approvalsBatchSelected(selectedCount), l.approvalsBatchSelected(selectedCount),
@ -546,10 +552,7 @@ class _ApprovalCard extends StatelessWidget {
], ],
), ),
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.lg),
Text( Text(approval.prompt, style: theme.textTheme.bodyLarge),
approval.prompt,
style: theme.textTheme.bodyLarge,
),
if (approval.payloadPreview != null) ...[ if (approval.payloadPreview != null) ...[
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
Padding( Padding(
@ -648,8 +651,7 @@ class _HistoryRow extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final (tone, icon, label) = _statusPresentation(context, record.status); final (tone, icon, label) = _statusPresentation(context, record.status);
final decided = record.decidedAt; final decided = record.decidedAt;
final timeStr = final timeStr = decided == null ? '' : _formatTimestamp(decided.toLocal());
decided == null ? '' : _formatTimestamp(decided.toLocal());
return InkWell( return InkWell(
onTap: () => showDialog<void>( onTap: () => showDialog<void>(
context: context, context: context,
@ -728,8 +730,10 @@ class _HistoryDialog extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final (tone, icon, label) = final (tone, icon, label) = _HistoryRow._statusPresentation(
_HistoryRow._statusPresentation(context, record.status); context,
record.status,
);
return AlertDialog( return AlertDialog(
title: Row( title: Row(
children: [ children: [
@ -752,14 +756,23 @@ class _HistoryDialog extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (record.decidedAt != null) if (record.decidedAt != null)
_kv(theme, AppLocalizations.of(context)!.approvalsDialogDecided, _kv(
theme,
AppLocalizations.of(context)!.approvalsDialogDecided,
'${_formatTimestamp(record.decidedAt!.toLocal())} ' '${_formatTimestamp(record.decidedAt!.toLocal())} '
'· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'), '· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}',
_kv(theme, AppLocalizations.of(context)!.approvalsDialogCreated, ),
_formatTimestamp(record.createdAt.toLocal())), _kv(
theme,
AppLocalizations.of(context)!.approvalsDialogCreated,
_formatTimestamp(record.createdAt.toLocal()),
),
if (record.reason.isNotEmpty) if (record.reason.isNotEmpty)
_kv(theme, AppLocalizations.of(context)!.approvalsDialogReason, _kv(
record.reason), theme,
AppLocalizations.of(context)!.approvalsDialogReason,
record.reason,
),
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
Text( Text(
AppLocalizations.of(context)!.approvalsDialogPrompt, AppLocalizations.of(context)!.approvalsDialogPrompt,
@ -769,10 +782,7 @@ class _HistoryDialog extends StatelessWidget {
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
SelectableText( SelectableText(record.prompt, style: theme.textTheme.bodyMedium),
record.prompt,
style: theme.textTheme.bodyMedium,
),
if (record.payloadPreview != null) ...[ if (record.payloadPreview != null) ...[
const SizedBox(height: FaiSpace.md), const SizedBox(height: FaiSpace.md),
Text( Text(
@ -834,12 +844,7 @@ class _HistoryDialog extends StatelessWidget {
), ),
), ),
), ),
Expanded( Expanded(child: SelectableText(v, style: theme.textTheme.bodySmall)),
child: SelectableText(
v,
style: theme.textTheme.bodySmall,
),
),
], ],
), ),
); );

View file

@ -28,10 +28,7 @@ class _AuditPageState extends State<AuditPage> {
void initState() { void initState() {
super.initState(); super.initState();
_refresh(); _refresh();
_poller = Timer.periodic( _poller = Timer.periodic(const Duration(seconds: 2), (_) => _refresh());
const Duration(seconds: 2),
(_) => _refresh(),
);
} }
@override @override
@ -56,9 +53,9 @@ class _AuditPageState extends State<AuditPage> {
_refresh(); _refresh();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.auditClearFailed(_friendly(e)))), context,
); ).showSnackBar(SnackBar(content: Text(l.auditClearFailed(_friendly(e)))));
} }
} }
@ -134,8 +131,7 @@ class _AuditPageState extends State<AuditPage> {
? FaiEmptyState( ? FaiEmptyState(
icon: Icons.timeline_outlined, icon: Icons.timeline_outlined,
title: AppLocalizations.of(context)!.auditNoEvents, title: AppLocalizations.of(context)!.auditNoEvents,
hint: hint: AppLocalizations.of(context)!.auditNoEventsHint,
AppLocalizations.of(context)!.auditNoEventsHint,
) )
: _GroupedEventList( : _GroupedEventList(
events: filtered, events: filtered,
@ -164,7 +160,8 @@ class _AuditPageState extends State<AuditPage> {
final mm = local.minute.toString().padLeft(2, '0'); final mm = local.minute.toString().padLeft(2, '0');
final ss = local.second.toString().padLeft(2, '0'); final ss = local.second.toString().padLeft(2, '0');
final time = '$hh:$mm:$ss'; final time = '$hh:$mm:$ss';
final sameDay = local.year == now.year && final sameDay =
local.year == now.year &&
local.month == now.month && local.month == now.month &&
local.day == now.day; local.day == now.day;
if (sameDay) return time; if (sameDay) return time;
@ -252,10 +249,8 @@ class _GroupedEventList extends StatelessWidget {
trailing: e.durationMs != null ? '${e.durationMs}ms' : null, trailing: e.durationMs != null ? '${e.durationMs}ms' : null,
onTap: () => showDialog<void>( onTap: () => showDialog<void>(
context: context, context: context,
builder: (_) => _EventDetailDialog( builder: (_) =>
event: e, _EventDetailDialog(event: e, allEvents: allEvents),
allEvents: allEvents,
),
), ),
), ),
); );
@ -285,11 +280,7 @@ class _GroupedEventList extends StatelessWidget {
/// timezone so an event at 23:55 yesterday in Berlin doesn't /// timezone so an event at 23:55 yesterday in Berlin doesn't
/// land in "today" because UTC happened to spill into a new /// land in "today" because UTC happened to spill into a new
/// day. /// day.
static String _bucketLabel( static String _bucketLabel(DateTime ts, DateTime now, AppLocalizations l) {
DateTime ts,
DateTime now,
AppLocalizations l,
) {
final local = ts.toLocal(); final local = ts.toLocal();
final localNow = now.toLocal(); final localNow = now.toLocal();
final today = DateTime(localNow.year, localNow.month, localNow.day); final today = DateTime(localNow.year, localNow.month, localNow.day);
@ -444,9 +435,7 @@ class _LiveStatusBar extends StatelessWidget {
), ),
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
Text( Text(
live live ? l.auditLiveStatus(eventCount) : l.auditDisconnected(error!),
? l.auditLiveStatus(eventCount)
: l.auditDisconnected(error!),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -478,6 +467,7 @@ class _LiveStatusBar extends StatelessWidget {
class _EventDetailDialog extends StatefulWidget { class _EventDetailDialog extends StatefulWidget {
final AuditEvent event; final AuditEvent event;
/// Full event window the audit page already fetched. Lets /// Full event window the audit page already fetched. Lets
/// the dialog surface every event sharing this event's /// the dialog surface every event sharing this event's
/// `flow_execution` without a fresh round-trip flow runs /// `flow_execution` without a fresh round-trip flow runs
@ -485,10 +475,7 @@ class _EventDetailDialog extends StatefulWidget {
/// page polls on. /// page polls on.
final List<AuditEvent> allEvents; final List<AuditEvent> allEvents;
const _EventDetailDialog({ const _EventDetailDialog({required this.event, required this.allEvents});
required this.event,
required this.allEvents,
});
@override @override
State<_EventDetailDialog> createState() => _EventDetailDialogState(); State<_EventDetailDialog> createState() => _EventDetailDialogState();
@ -544,8 +531,10 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
..writeln('event_type: ${e.type}') ..writeln('event_type: ${e.type}')
..writeln('flow: ${e.flowName ?? "(none)"}') ..writeln('flow: ${e.flowName ?? "(none)"}')
..writeln('step: ${e.stepId ?? "(none)"}') ..writeln('step: ${e.stepId ?? "(none)"}')
..writeln('module: ${e.moduleName ?? "(none)"}' ..writeln(
'${e.moduleVersion != null ? "@${e.moduleVersion}" : ""}') 'module: ${e.moduleName ?? "(none)"}'
'${e.moduleVersion != null ? "@${e.moduleVersion}" : ""}',
)
..writeln('error: ${e.error ?? "(none)"}') ..writeln('error: ${e.error ?? "(none)"}')
..writeln('duration: ${e.durationMs ?? 0}ms'); ..writeln('duration: ${e.durationMs ?? 0}ms');
if (mode == 'full' && e.detail != null) { if (mode == 'full' && e.detail != null) {
@ -640,14 +629,9 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh, color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm), borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all( border: Border.all(color: theme.colorScheme.outlineVariant),
color: theme.colorScheme.outlineVariant,
),
),
child: SelectableText(
detail,
style: FaiTheme.mono(size: 11),
), ),
child: SelectableText(detail, style: FaiTheme.mono(size: 11)),
), ),
], ],
if (_explanation != null || _explaining) ...[ if (_explanation != null || _explaining) ...[
@ -694,7 +678,9 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
: l.auditReask, : l.auditReask,
), ),
) )
else if (_aiStatus != null && !_aiStatus!.enabled && (event.error != null)) else if (_aiStatus != null &&
!_aiStatus!.enabled &&
(event.error != null))
Tooltip( Tooltip(
message: l.auditConfigureSystemAi, message: l.auditConfigureSystemAi,
child: OutlinedButton.icon( child: OutlinedButton.icon(
@ -734,9 +720,8 @@ class _FlowRunDialog extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final related = allEvents final related =
.where((e) => e.flowExecution == flowExecution) allEvents.where((e) => e.flowExecution == flowExecution).toList()
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp)); ..sort((a, b) => a.timestamp.compareTo(b.timestamp));
final maxHeight = MediaQuery.of(context).size.height * 0.75; final maxHeight = MediaQuery.of(context).size.height * 0.75;
return AlertDialog( return AlertDialog(
@ -761,8 +746,7 @@ class _FlowRunDialog extends StatelessWidget {
child: ListView.separated( child: ListView.separated(
shrinkWrap: true, shrinkWrap: true,
itemCount: related.length, itemCount: related.length,
separatorBuilder: (_, _) => separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.xs),
const SizedBox(height: FaiSpace.xs),
itemBuilder: (context, i) { itemBuilder: (context, i) {
final e = related[i]; final e = related[i];
return FaiDataRow( return FaiDataRow(
@ -770,8 +754,7 @@ class _FlowRunDialog extends StatelessWidget {
leading: _formatRelativeTime(e.timestamp), leading: _formatRelativeTime(e.timestamp),
title: e.type, title: e.type,
subtitle: _stepLine(e), subtitle: _stepLine(e),
trailing: trailing: e.durationMs != null ? '${e.durationMs}ms' : null,
e.durationMs != null ? '${e.durationMs}ms' : null,
); );
}, },
), ),
@ -865,6 +848,7 @@ class _ExplanationPanel extends StatelessWidget {
final bool explaining; final bool explaining;
final AskAiResult? result; final AskAiResult? result;
final String privacyMode; final String privacyMode;
/// Triggered by the "Regenerate" affordance passes /// Triggered by the "Regenerate" affordance passes
/// `forceFresh: true` so the next askAi skips the cache and /// `forceFresh: true` so the next askAi skips the cache and
/// hits the live provider. Null while [explaining] is true. /// hits the live provider. Null while [explaining] is true.
@ -945,7 +929,9 @@ class _ExplanationPanel extends StatelessWidget {
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
if (result != null && result!.isSuccess && onRegenerate != null) ...[ if (result != null &&
result!.isSuccess &&
onRegenerate != null) ...[
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
Tooltip( Tooltip(
message: l.auditRegenerateTooltip, message: l.auditRegenerateTooltip,
@ -980,7 +966,9 @@ class _ExplanationPanel extends StatelessWidget {
SelectableText( SelectableText(
result!.text, result!.text,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: ok ? theme.colorScheme.onSurface : theme.colorScheme.error, color: ok
? theme.colorScheme.onSurface
: theme.colorScheme.error,
), ),
), ),
if (result!.fixHint.isNotEmpty) ...[ if (result!.fixHint.isNotEmpty) ...[
@ -1040,7 +1028,8 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final user = Platform.environment['USER'] ?? final user =
Platform.environment['USER'] ??
Platform.environment['USERNAME'] ?? Platform.environment['USERNAME'] ??
'operator'; 'operator';
_reviewer = TextEditingController(text: '$user@studio'); _reviewer = TextEditingController(text: '$user@studio');

View file

@ -62,9 +62,9 @@ class _DoctorPageState extends State<DoctorPage> {
); );
} }
final s = snapshot.data!; final s = snapshot.data!;
final showUpdate = s.update.updateAvailable || final showUpdate =
(!s.update.manifestReachable && s.update.updateAvailable ||
s.update.localVersion.isNotEmpty); (!s.update.manifestReachable && s.update.localVersion.isNotEmpty);
return ListView( return ListView(
padding: const EdgeInsets.all(FaiSpace.xl), padding: const EdgeInsets.all(FaiSpace.xl),
children: [ children: [
@ -74,14 +74,13 @@ class _DoctorPageState extends State<DoctorPage> {
const SizedBox(height: FaiSpace.xl), const SizedBox(height: FaiSpace.xl),
_Section( _Section(
title: AppLocalizations.of(context)!.doctorEventLogSection, title: AppLocalizations.of(context)!.doctorEventLogSection,
child: _EventLogPanel( child: _EventLogPanel(snapshot: s, onRefresh: _refresh),
snapshot: s,
onRefresh: _refresh,
),
), ),
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.lg),
_Section( _Section(
title: AppLocalizations.of(context)!.doctorModulesApprovalsSection, title: AppLocalizations.of(
context,
)!.doctorModulesApprovalsSection,
child: _ModulesPanel(snapshot: s), child: _ModulesPanel(snapshot: s),
), ),
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.lg),
@ -120,7 +119,10 @@ class _Section extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(left: FaiSpace.xs, bottom: FaiSpace.sm), padding: const EdgeInsets.only(
left: FaiSpace.xs,
bottom: FaiSpace.sm,
),
child: Text( child: Text(
title.toUpperCase(), title.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
@ -259,6 +261,7 @@ class _StatTile extends StatelessWidget {
class _EventLogPanel extends StatelessWidget { class _EventLogPanel extends StatelessWidget {
final DoctorSnapshot snapshot; final DoctorSnapshot snapshot;
/// Re-runs the doctor() call which re-verifies the chain. /// Re-runs the doctor() call which re-verifies the chain.
/// Wired to a "Verify now" button so operators can re-check /// Wired to a "Verify now" button so operators can re-check
/// after import / restore without restarting the daemon. /// after import / restore without restarting the daemon.
@ -361,12 +364,7 @@ class _DaemonPathsPanel extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
for (final e in entries) for (final e in entries)
_PathRow( _PathRow(label: e.$1, path: e.$2, icon: e.$3, isDirectory: e.$4),
label: e.$1,
path: e.$2,
icon: e.$3,
isDirectory: e.$4,
),
], ],
), ),
); );
@ -485,7 +483,8 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_busy = false; _busy = false;
_output = (r.ok _output =
(r.ok
? 'OK · $label\n${r.stdout}' ? 'OK · $label\n${r.stdout}'
: 'Failed · $label\n${r.stderr.isEmpty ? r.stdout : r.stderr}') : 'Failed · $label\n${r.stderr.isEmpty ? r.stdout : r.stderr}')
.trim(); .trim();
@ -801,10 +800,7 @@ class _ServicesPanel extends StatelessWidget {
children: [ children: [
for (var i = 0; i < snapshot.services.length; i++) ...[ for (var i = 0; i < snapshot.services.length; i++) ...[
if (i > 0) if (i > 0)
Divider( Divider(height: 1, color: theme.colorScheme.outlineVariant),
height: 1,
color: theme.colorScheme.outlineVariant,
),
Padding( Padding(
padding: const EdgeInsets.all(FaiSpace.lg), padding: const EdgeInsets.all(FaiSpace.lg),
child: Row( child: Row(
@ -940,7 +936,9 @@ class _UpdateBannerState extends State<_UpdateBanner> {
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.system_update_alt, size: 16), : const Icon(Icons.system_update_alt, size: 16),
label: Text(_applying ? l.doctorApplying : l.doctorApplyUpdate), label: Text(
_applying ? l.doctorApplying : l.doctorApplyUpdate,
),
), ),
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
FaiPill( FaiPill(

View file

@ -48,10 +48,8 @@ class _FlowsPageState extends State<FlowsPage> {
Future<List<String>> _loadCapabilities() async { Future<List<String>> _loadCapabilities() async {
try { try {
final caps = await HubService.instance.allCapabilities(); final caps = await HubService.instance.allCapabilities();
final entries = caps final entries =
.map((c) => '${c.capability}@${c.version}') caps.map((c) => '${c.capability}@${c.version}').toSet().toList()
.toSet()
.toList()
..sort(); ..sort();
return entries; return entries;
} catch (_) { } catch (_) {

View file

@ -87,8 +87,10 @@ class _ModulesPageState extends State<ModulesPage> {
if (i > 0) const SizedBox(height: FaiSpace.md), if (i > 0) const SizedBox(height: FaiSpace.md),
GestureDetector( GestureDetector(
onTap: () async { onTap: () async {
final uninstalled = final uninstalled = await FaiModuleSheet.show(
await FaiModuleSheet.show(context, modules[i].name); context,
modules[i].name,
);
if (uninstalled) _refresh(); if (uninstalled) _refresh();
}, },
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
@ -171,7 +173,6 @@ class _ModuleCard extends StatelessWidget {
} }
} }
/// Compact "Recent activity" header listing the last few /// Compact "Recent activity" header listing the last few
/// install / uninstall events from the audit log. Lets /// install / uninstall events from the audit log. Lets
/// operators trace "wait, when did that module appear?" /// operators trace "wait, when did that module appear?"
@ -220,8 +221,9 @@ class _RecentActivityPanel extends StatelessWidget {
), ),
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
Text( Text(
AppLocalizations.of(context)! AppLocalizations.of(
.modulesRecentActivityHint(events.length), context,
)!.modulesRecentActivityHint(events.length),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -292,7 +294,8 @@ String _formatTimestamp(DateTime local) {
final mm = local.minute.toString().padLeft(2, '0'); final mm = local.minute.toString().padLeft(2, '0');
final ss = local.second.toString().padLeft(2, '0'); final ss = local.second.toString().padLeft(2, '0');
final time = '$hh:$mm:$ss'; final time = '$hh:$mm:$ss';
final sameDay = local.year == now.year && final sameDay =
local.year == now.year &&
local.month == now.month && local.month == now.month &&
local.day == now.day; local.day == now.day;
if (sameDay) return time; if (sameDay) return time;

View file

@ -33,10 +33,7 @@ class FaiTheme {
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0, letterSpacing: 0,
), ),
titleSmall: GoogleFonts.inter( titleSmall: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500),
fontSize: 13,
fontWeight: FontWeight.w500,
),
bodyLarge: GoogleFonts.inter( bodyLarge: GoogleFonts.inter(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
@ -52,10 +49,7 @@ class FaiTheme {
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
height: 1.4, height: 1.4,
), ),
labelMedium: GoogleFonts.inter( labelMedium: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w500),
fontSize: 12,
fontWeight: FontWeight.w500,
),
labelSmall: GoogleFonts.inter( labelSmall: GoogleFonts.inter(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@ -70,8 +64,7 @@ class FaiTheme {
double size = 12, double size = 12,
FontWeight weight = FontWeight.w400, FontWeight weight = FontWeight.w400,
Color? color, Color? color,
}) => }) => GoogleFonts.jetBrainsMono(
GoogleFonts.jetBrainsMono(
fontSize: size, fontSize: size,
fontWeight: weight, fontWeight: weight,
color: color, color: color,
@ -107,9 +100,7 @@ class FaiTheme {
code: mono( code: mono(
size: 12, size: 12,
color: theme.colorScheme.onSurface, color: theme.colorScheme.onSurface,
).copyWith( ).copyWith(backgroundColor: Colors.transparent),
backgroundColor: Colors.transparent,
),
codeblockDecoration: BoxDecoration( codeblockDecoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh, color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm), borderRadius: BorderRadius.circular(FaiRadius.sm),
@ -123,10 +114,7 @@ class FaiTheme {
color: theme.colorScheme.surfaceContainer, color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.sm), borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border( border: Border(
left: BorderSide( left: BorderSide(color: theme.colorScheme.primary, width: 3),
color: theme.colorScheme.primary,
width: 3,
),
), ),
), ),
blockquotePadding: const EdgeInsets.fromLTRB( blockquotePadding: const EdgeInsets.fromLTRB(
@ -282,20 +270,22 @@ class FaiTheme {
), ),
), ),
iconButtonTheme: IconButtonThemeData( iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom( style: IconButton.styleFrom(foregroundColor: scheme.onSurfaceVariant),
foregroundColor: scheme.onSurfaceVariant,
),
), ),
navigationRailTheme: NavigationRailThemeData( navigationRailTheme: NavigationRailThemeData(
backgroundColor: scheme.surfaceContainerLow, backgroundColor: scheme.surfaceContainerLow,
indicatorColor: scheme.primaryContainer, indicatorColor: scheme.primaryContainer,
selectedIconTheme: IconThemeData(color: scheme.primary, size: 22), selectedIconTheme: IconThemeData(color: scheme.primary, size: 22),
unselectedIconTheme: unselectedIconTheme: IconThemeData(
IconThemeData(color: scheme.onSurfaceVariant, size: 22), color: scheme.onSurfaceVariant,
selectedLabelTextStyle: size: 22,
textTheme.labelMedium?.copyWith(color: scheme.primary), ),
unselectedLabelTextStyle: textTheme.labelMedium selectedLabelTextStyle: textTheme.labelMedium?.copyWith(
?.copyWith(color: scheme.onSurfaceVariant), color: scheme.primary,
),
unselectedLabelTextStyle: textTheme.labelMedium?.copyWith(
color: scheme.onSurfaceVariant,
),
useIndicator: true, useIndicator: true,
), ),
dividerColor: scheme.outlineVariant, dividerColor: scheme.outlineVariant,
@ -306,8 +296,9 @@ class FaiTheme {
), ),
snackBarTheme: SnackBarThemeData( snackBarTheme: SnackBarThemeData(
backgroundColor: scheme.surfaceContainerHigh, backgroundColor: scheme.surfaceContainerHigh,
contentTextStyle: contentTextStyle: textTheme.bodyMedium?.copyWith(
textTheme.bodyMedium?.copyWith(color: scheme.onSurface), color: scheme.onSurface,
),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.sm), borderRadius: BorderRadius.circular(FaiRadius.sm),

View file

@ -137,8 +137,7 @@ class _DeltaPainter extends CustomPainter {
final pulseStrength = mode == FaiDeltaMode.live ? t : 1.0; final pulseStrength = mode == FaiDeltaMode.live ? t : 1.0;
final glow = Paint() final glow = Paint()
..color = color.withValues(alpha: 0.30 + 0.40 * pulseStrength) ..color = color.withValues(alpha: 0.30 + 0.40 * pulseStrength)
..maskFilter = ..maskFilter = MaskFilter.blur(BlurStyle.normal, 8 + 14 * pulseStrength)
MaskFilter.blur(BlurStyle.normal, 8 + 14 * pulseStrength)
..style = PaintingStyle.fill; ..style = PaintingStyle.fill;
canvas.drawPath(path, glow); canvas.drawPath(path, glow);
} }

View file

@ -43,9 +43,7 @@ class FaiEnBadge extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest, color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(FaiRadius.sm), borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all( border: Border.all(color: theme.colorScheme.outlineVariant),
color: theme.colorScheme.outlineVariant,
),
), ),
child: Text( child: Text(
'EN', 'EN',

View file

@ -20,15 +20,18 @@ class FaiErrorBox extends StatefulWidget {
/// monospace, selectable, multi-line. Ignored when [error] is /// monospace, selectable, multi-line. Ignored when [error] is
/// supplied. /// supplied.
final String text; final String text;
/// When true, the box border + foreground colour come from the /// When true, the box border + foreground colour come from the
/// error palette. False renders neutral chrome useful for /// error palette. False renders neutral chrome useful for
/// success / informational copy that still wants the copy /// success / informational copy that still wants the copy
/// button. /// button.
final bool isError; final bool isError;
/// Optional max-height before the content scrolls inside the /// Optional max-height before the content scrolls inside the
/// box. Falls back to no constraint when null so short /// box. Falls back to no constraint when null so short
/// messages don't get a useless scrollbar. /// messages don't get a useless scrollbar.
final double? maxHeight; final double? maxHeight;
/// When non-null, the box renders the [friendlyError] mapping /// When non-null, the box renders the [friendlyError] mapping
/// of this error: a one-line headline, a recovery-hint line /// of this error: a one-line headline, a recovery-hint line
/// (when one applies), and the verbatim original message /// (when one applies), and the verbatim original message
@ -139,15 +142,15 @@ class _FaiErrorBoxState extends State<FaiErrorBox> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
_detailExpanded _detailExpanded ? Icons.expand_less : Icons.expand_more,
? Icons.expand_less
: Icons.expand_more,
size: 14, size: 14,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_detailExpanded ? l.buttonHideDetails : l.buttonShowDetails, _detailExpanded
? l.buttonHideDetails
: l.buttonShowDetails,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),

View file

@ -126,10 +126,7 @@ class _CodeBlock extends StatelessWidget {
), ),
child: SelectableText( child: SelectableText(
text, text,
style: FaiTheme.mono( style: FaiTheme.mono(size: 12, color: theme.colorScheme.onSurface),
size: 12,
color: theme.colorScheme.onSurface,
),
), ),
); );
} }
@ -170,14 +167,14 @@ class _BytesView extends StatelessWidget {
try { try {
await File(path).writeAsBytes(bytes, flush: true); await File(path).writeAsBytes(bytes, flush: true);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.flowsOutputSavedAt(path))), context,
); ).showSnackBar(SnackBar(content: Text(l.flowsOutputSavedAt(path))));
} catch (e) { } catch (e) {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.flowsOutputSaveFailed('$e'))), context,
); ).showSnackBar(SnackBar(content: Text(l.flowsOutputSaveFailed('$e'))));
} }
} }
@ -239,9 +236,9 @@ class _FileView extends StatelessWidget {
final r = await SystemActions.openInOs(target); final r = await SystemActions.openInOs(target);
if (!context.mounted || r.ok) return; if (!context.mounted || r.ok) return;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr))), context,
); ).showSnackBar(SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr))));
} }
@override @override
@ -261,10 +258,7 @@ class _FileView extends StatelessWidget {
children: [ children: [
SelectableText( SelectableText(
uri, uri,
style: FaiTheme.mono( style: FaiTheme.mono(size: 11, color: theme.colorScheme.primary),
size: 11,
color: theme.colorScheme.primary,
),
), ),
if (mimeType.isNotEmpty) ...[ if (mimeType.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),

View file

@ -25,9 +25,7 @@ class FaiModuleSheet extends StatefulWidget {
backgroundColor: Theme.of(context).colorScheme.surfaceContainer, backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
isScrollControlled: true, isScrollControlled: true,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical( borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
top: Radius.circular(FaiRadius.md),
),
), ),
builder: (_) => FaiModuleSheet(moduleName: name), builder: (_) => FaiModuleSheet(moduleName: name),
); );
@ -56,8 +54,7 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
// "remove the highest version", which is rarely what an // "remove the highest version", which is rarely what an
// operator means when they've explicitly kept multiple // operator means when they've explicitly kept multiple
// versions around. // versions around.
final versions = final versions = await HubService.instance.installedVersions(detail.name);
await HubService.instance.installedVersions(detail.name);
if (!mounted) return; if (!mounted) return;
String? targetVersion; String? targetVersion;
if (versions.length > 1) { if (versions.length > 1) {
@ -101,9 +98,7 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
); );
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(content: Text(l.modulesUninstalledToast(r.name, r.version))),
content: Text(l.modulesUninstalledToast(r.name, r.version)),
),
); );
Navigator.pop(context, true); Navigator.pop(context, true);
} catch (e) { } catch (e) {
@ -138,8 +133,9 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
Padding( Padding(
padding: const EdgeInsets.all(FaiSpace.xl), padding: const EdgeInsets.all(FaiSpace.xl),
child: Text( child: Text(
AppLocalizations.of(context)! AppLocalizations.of(
.moduleSheetFailedToLoad(snapshot.error.toString()), context,
)!.moduleSheetFailedToLoad(snapshot.error.toString()),
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error, color: theme.colorScheme.error,
), ),

View file

@ -6,13 +6,7 @@ import 'package:flutter/material.dart';
import '../theme/theme.dart'; import '../theme/theme.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
enum FaiPillTone { enum FaiPillTone { neutral, accent, success, warning, danger }
neutral,
accent,
success,
warning,
danger,
}
class FaiPill extends StatelessWidget { class FaiPill extends StatelessWidget {
final String label; final String label;
@ -36,10 +30,7 @@ class FaiPill extends StatelessWidget {
? FaiTheme.mono(size: 11, weight: FontWeight.w500, color: fg) ? FaiTheme.mono(size: 11, weight: FontWeight.w500, color: fg)
: theme.textTheme.labelSmall?.copyWith(color: fg); : theme.textTheme.labelSmall?.copyWith(color: fg);
return Container( return Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 3),
horizontal: FaiSpace.sm,
vertical: 3,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bg, color: bg,
borderRadius: BorderRadius.circular(FaiRadius.sm), borderRadius: BorderRadius.circular(FaiRadius.sm),
@ -62,10 +53,7 @@ class FaiPill extends StatelessWidget {
case FaiPillTone.neutral: case FaiPillTone.neutral:
return (scheme.surfaceContainerHighest, scheme.onSurfaceVariant); return (scheme.surfaceContainerHighest, scheme.onSurfaceVariant);
case FaiPillTone.accent: case FaiPillTone.accent:
return ( return (scheme.primary.withValues(alpha: 0.15), scheme.primary);
scheme.primary.withValues(alpha: 0.15),
scheme.primary,
);
case FaiPillTone.success: case FaiPillTone.success:
return ( return (
FaiColors.success.withValues(alpha: 0.15), FaiColors.success.withValues(alpha: 0.15),
@ -81,10 +69,7 @@ class FaiPill extends StatelessWidget {
: const Color(0xFFB45309), : const Color(0xFFB45309),
); );
case FaiPillTone.danger: case FaiPillTone.danger:
return ( return (scheme.errorContainer, scheme.onErrorContainer);
scheme.errorContainer,
scheme.onErrorContainer,
);
} }
} }
} }

View file

@ -21,12 +21,15 @@ class FaiSearchHit {
final String label; final String label;
final String hint; final String hint;
final IconData icon; final IconData icon;
/// Free-form group used for the section header in the /// Free-form group used for the section header in the
/// results list ("Pages", "Modules", "Store", "Flows"). /// results list ("Pages", "Modules", "Store", "Flows").
final String group; final String group;
/// Lowercase haystack name + hint + group. Filtered against /// Lowercase haystack name + hint + group. Filtered against
/// the lowercased query. /// the lowercased query.
final String haystack; final String haystack;
/// Action to run when this hit is selected. /// Action to run when this hit is selected.
final VoidCallback onSelect; final VoidCallback onSelect;
@ -103,7 +106,8 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
final hits = <FaiSearchHit>[]; final hits = <FaiSearchHit>[];
for (final m in modules) { for (final m in modules) {
hits.add(FaiSearchHit( hits.add(
FaiSearchHit(
label: m.name, label: m.name,
hint: l.searchInstalledModuleHint(m.version), hint: l.searchInstalledModuleHint(m.version),
icon: Icons.extension, icon: Icons.extension,
@ -112,16 +116,19 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
Navigator.pop(context); Navigator.pop(context);
FaiModuleSheet.show(context, m.name); FaiModuleSheet.show(context, m.name);
}, },
)); ),
);
} }
final storeGroup = l.searchGroupStore; final storeGroup = l.searchGroupStore;
final pagesGroup = l.searchGroupPages; final pagesGroup = l.searchGroupPages;
for (final s in store) { for (final s in store) {
hits.add(FaiSearchHit( hits.add(
FaiSearchHit(
label: s.name, label: s.name,
hint: s.taglineEn.isEmpty hint: s.taglineEn.isEmpty
? l.searchStoreHintWithCategory( ? l.searchStoreHintWithCategory(
s.category.isEmpty ? l.searchStoreUncategorized : s.category) s.category.isEmpty ? l.searchStoreUncategorized : s.category,
)
: l.searchStoreHintWithTagline(s.taglineEn), : l.searchStoreHintWithTagline(s.taglineEn),
icon: Icons.storefront_outlined, icon: Icons.storefront_outlined,
group: storeGroup, group: storeGroup,
@ -138,11 +145,13 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
} }
} }
}, },
)); ),
);
} }
final flowsGroup = l.searchGroupFlows; final flowsGroup = l.searchGroupFlows;
for (final f in flows) { for (final f in flows) {
hits.add(FaiSearchHit( hits.add(
FaiSearchHit(
label: f.name, label: f.name,
hint: l.searchSavedFlowHint, hint: l.searchSavedFlowHint,
icon: Icons.account_tree_outlined, icon: Icons.account_tree_outlined,
@ -156,7 +165,8 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
} }
} }
}, },
)); ),
);
} }
return hits; return hits;
} }
@ -211,9 +221,7 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
return Dialog( return Dialog(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
insetPadding: const EdgeInsets.only(top: 80, left: 24, right: 24), insetPadding: const EdgeInsets.only(top: 80, left: 24, right: 24),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
borderRadius: BorderRadius.circular(12),
),
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640, maxHeight: 540), constraints: const BoxConstraints(maxWidth: 640, maxHeight: 540),
child: FutureBuilder<List<FaiSearchHit>>( child: FutureBuilder<List<FaiSearchHit>>(
@ -240,17 +248,17 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
controller: _query, controller: _query,
autofocus: true, autofocus: true,
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: prefixIcon: const Icon(Icons.search, size: 20),
const Icon(Icons.search, size: 20),
hintText: l.searchHint, hintText: l.searchHint,
border: InputBorder.none, border: InputBorder.none,
suffixIcon: snap.connectionState == suffixIcon:
ConnectionState.waiting snap.connectionState == ConnectionState.waiting
? const SizedBox( ? const SizedBox(
width: 14, width: 14,
height: 14, height: 14,
child: child: CircularProgressIndicator(
CircularProgressIndicator(strokeWidth: 2), strokeWidth: 2,
),
) )
: null, : null,
), ),
@ -283,11 +291,10 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
itemBuilder: (context, i) { itemBuilder: (context, i) {
final h = hits[i]; final h = hits[i];
final highlighted = i == _highlight; final highlighted = i == _highlight;
final showHeader = i == 0 || final showHeader =
hits[i - 1].group != h.group; i == 0 || hits[i - 1].group != h.group;
return Column( return Column(
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [ children: [
if (showHeader) if (showHeader)
Padding( Padding(
@ -302,7 +309,8 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
style: theme.textTheme.labelSmall style: theme.textTheme.labelSmall
?.copyWith( ?.copyWith(
color: theme color: theme
.colorScheme.onSurfaceVariant, .colorScheme
.onSurfaceVariant,
letterSpacing: 0.6, letterSpacing: 0.6,
fontSize: 10, fontSize: 10,
), ),
@ -317,7 +325,8 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
}, },
child: Container( child: Container(
color: highlighted color: highlighted
? theme.colorScheme ? theme
.colorScheme
.surfaceContainerHigh .surfaceContainerHigh
: null, : null,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -340,7 +349,8 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
Text( Text(
h.label, h.label,
style: theme style: theme
.textTheme.bodyMedium .textTheme
.bodyMedium
?.copyWith( ?.copyWith(
fontWeight: fontWeight:
FontWeight.w500, FontWeight.w500,
@ -349,10 +359,12 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
if (h.hint.isNotEmpty) if (h.hint.isNotEmpty)
Text( Text(
h.hint, h.hint,
style: theme.textTheme style: theme
.textTheme
.bodySmall .bodySmall
?.copyWith( ?.copyWith(
color: theme.colorScheme color: theme
.colorScheme
.onSurfaceVariant, .onSurfaceVariant,
), ),
maxLines: 1, maxLines: 1,
@ -367,7 +379,8 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
Icons.keyboard_return, Icons.keyboard_return,
size: 14, size: 14,
color: theme color: theme
.colorScheme.onSurfaceVariant, .colorScheme
.onSurfaceVariant,
), ),
], ],
), ),

View file

@ -9,14 +9,13 @@ import '../data/hub.dart';
import '../data/hub_auth_token.dart'; import '../data/hub_auth_token.dart';
import '../data/registry_token.dart'; import '../data/registry_token.dart';
import '../data/system_actions.dart'; import '../data/system_actions.dart';
import '../data/theme_plugin.dart';
import '../main.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/theme.dart'; import '../theme/theme.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import 'fai_error_box.dart'; import 'fai_error_box.dart';
import 'fai_pill.dart'; import 'fai_pill.dart';
import 'fai_system_ai_editor.dart'; import 'fai_system_ai_editor.dart';
import 'theme_picker_grid.dart';
class FaiSettingsDialog extends StatefulWidget { class FaiSettingsDialog extends StatefulWidget {
const FaiSettingsDialog({super.key}); const FaiSettingsDialog({super.key});
@ -45,10 +44,12 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
SystemAiStatus? _aiStatus; SystemAiStatus? _aiStatus;
List<McpClientInfo>? _mcpClients; List<McpClientInfo>? _mcpClients;
List<N8nEndpointInfo>? _n8nEndpoints; List<N8nEndpointInfo>? _n8nEndpoints;
/// Length of the configured registry token, or null when the /// Length of the configured registry token, or null when the
/// `~/.fai/registry-token` file is missing / empty. Reloaded /// `~/.fai/registry-token` file is missing / empty. Reloaded
/// after save / clear. /// after save / clear.
int? _registryTokenChars; int? _registryTokenChars;
/// Length of the configured hub gRPC bearer token, or null /// Length of the configured hub gRPC bearer token, or null
/// when `~/.fai/hub-auth-token` is missing / empty. Reloaded /// when `~/.fai/hub-auth-token` is missing / empty. Reloaded
/// after save / clear; UI only ever sees the trimmed length. /// after save / clear; UI only ever sees the trimmed length.
@ -74,7 +75,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
final n = await HubAuthToken.charCount(); final n = await HubAuthToken.charCount();
if (!mounted) return; if (!mounted) return;
setState(() => _hubAuthTokenChars = n); setState(() => _hubAuthTokenChars = n);
} catch (_) {/* fail-quiet */} } catch (_) {
/* fail-quiet */
}
} }
Future<void> _saveHubAuthToken(String token) async { Future<void> _saveHubAuthToken(String token) async {
@ -84,9 +87,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
await _loadHubAuthToken(); await _loadHubAuthToken();
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.hubAuthTokenSavedToast)), context,
); ).showSnackBar(SnackBar(content: Text(l.hubAuthTokenSavedToast)));
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
@ -103,9 +106,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
await _loadHubAuthToken(); await _loadHubAuthToken();
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.hubAuthTokenClearedToast)), context,
); ).showSnackBar(SnackBar(content: Text(l.hubAuthTokenClearedToast)));
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
@ -120,7 +123,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
final n = await RegistryToken.charCount(); final n = await RegistryToken.charCount();
if (!mounted) return; if (!mounted) return;
setState(() => _registryTokenChars = n); setState(() => _registryTokenChars = n);
} catch (_) {/* fail-quiet */} } catch (_) {
/* fail-quiet */
}
} }
Future<void> _saveRegistryToken(String token) async { Future<void> _saveRegistryToken(String token) async {
@ -129,9 +134,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
await _loadRegistryToken(); await _loadRegistryToken();
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.registryTokenSavedToast)), context,
); ).showSnackBar(SnackBar(content: Text(l.registryTokenSavedToast)));
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
@ -147,9 +152,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
await _loadRegistryToken(); await _loadRegistryToken();
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.registryTokenClearedToast)), context,
); ).showSnackBar(SnackBar(content: Text(l.registryTokenClearedToast)));
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
@ -164,7 +169,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
final list = await HubService.instance.listN8nEndpoints(); final list = await HubService.instance.listN8nEndpoints();
if (!mounted) return; if (!mounted) return;
setState(() => _n8nEndpoints = list); setState(() => _n8nEndpoints = list);
} catch (_) {/* fail-quiet */} } catch (_) {
/* fail-quiet */
}
} }
Future<void> _addN8nEndpoint(N8nEndpointDraft draft) async { Future<void> _addN8nEndpoint(N8nEndpointDraft draft) async {
@ -178,15 +185,15 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
if (!mounted) return; if (!mounted) return;
setState(() => _n8nEndpoints = list); setState(() => _n8nEndpoints = list);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.addedN8nToast(draft.name))), context,
); ).showSnackBar(SnackBar(content: Text(l.addedN8nToast(draft.name))));
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.addFailedToast(e.toString()))), context,
); ).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString()))));
} }
} }
@ -239,15 +246,15 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
if (!mounted) return; if (!mounted) return;
setState(() => _mcpClients = list); setState(() => _mcpClients = list);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.addedMcpToast(draft.name))), context,
); ).showSnackBar(SnackBar(content: Text(l.addedMcpToast(draft.name))));
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.addFailedToast(e.toString()))), context,
); ).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString()))));
} }
} }
@ -510,11 +517,15 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
active: ch.name == _channels!.active, active: ch.name == _channels!.active,
onConnect: _saving ? null : () => _connectToChannel(ch), onConnect: _saving ? null : () => _connectToChannel(ch),
onSwitch: _saving ? null : () => _switchChannel(ch.name), onSwitch: _saving ? null : () => _switchChannel(ch.name),
onEnableAutostart: _saving ? null : () => _runDaemon( onEnableAutostart: _saving
? null
: () => _runDaemon(
'enable autostart', 'enable autostart',
() => SystemActions.faiDaemonEnable(ch.name), () => SystemActions.faiDaemonEnable(ch.name),
), ),
onDisableAutostart: _saving ? null : () => _runDaemon( onDisableAutostart: _saving
? null
: () => _runDaemon(
'disable autostart', 'disable autostart',
() => SystemActions.faiDaemonDisable(ch.name), () => SystemActions.faiDaemonDisable(ch.name),
), ),
@ -616,8 +627,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
String _previewUrl() { String _previewUrl() {
final scheme = _secure ? 'https' : 'http'; final scheme = _secure ? 'https' : 'http';
final host = final host = _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
_host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
final port = _port.text.trim().isEmpty ? '50051' : _port.text.trim(); final port = _port.text.trim().isEmpty ? '50051' : _port.text.trim();
return '$scheme://$host:$port'; return '$scheme://$host:$port';
} }
@ -627,6 +637,7 @@ class _ChannelRow extends StatelessWidget {
final ChannelInfo channel; final ChannelInfo channel;
final bool active; final bool active;
final VoidCallback? onConnect; final VoidCallback? onConnect;
/// Switches the active channel pointer (`fai channel switch`). /// Switches the active channel pointer (`fai channel switch`).
/// Distinct from [onConnect], which only re-points Studio's /// Distinct from [onConnect], which only re-points Studio's
/// gRPC wire at a different running daemon. /// gRPC wire at a different running daemon.
@ -691,7 +702,10 @@ class _ChannelRow extends StatelessWidget {
if (active) if (active)
Padding( Padding(
padding: const EdgeInsets.only(right: FaiSpace.sm), 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>( PopupMenuButton<String>(
tooltip: l.channelsActionsTooltip, tooltip: l.channelsActionsTooltip,
@ -716,18 +730,12 @@ class _ChannelRow extends StatelessWidget {
PopupMenuItem( PopupMenuItem(
value: 'connect', value: 'connect',
enabled: channel.running && onConnect != null, enabled: channel.running && onConnect != null,
child: _MenuRow( child: _MenuRow(icon: Icons.link, text: l.channelsConnect),
icon: Icons.link,
text: l.channelsConnect,
),
), ),
PopupMenuItem( PopupMenuItem(
value: 'switch', value: 'switch',
enabled: !active && onSwitch != null, enabled: !active && onSwitch != null,
child: _MenuRow( child: _MenuRow(icon: Icons.swap_horiz, text: l.channelsSwitch),
icon: Icons.swap_horiz,
text: l.channelsSwitch,
),
), ),
const PopupMenuDivider(), const PopupMenuDivider(),
PopupMenuItem( PopupMenuItem(
@ -812,7 +820,9 @@ class _SystemAiPanel extends StatelessWidget {
TextButton.icon( TextButton.icon(
onPressed: onEdit, onPressed: onEdit,
icon: const Icon(Icons.edit_outlined, size: 14), 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: 'endpoint', value: status.endpoint, mono: true),
_StatRow(label: 'model', value: status.model, mono: true), _StatRow(label: 'model', value: status.model, mono: true),
if (status.apiKeyEnv.isNotEmpty) 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 ...[ ] else ...[
Padding( Padding(
padding: const EdgeInsets.only(top: 2), padding: const EdgeInsets.only(top: 2),
@ -844,11 +858,7 @@ class _StatRow extends StatelessWidget {
final String value; final String value;
final bool mono; final bool mono;
const _StatRow({ const _StatRow({required this.label, required this.value, this.mono = false});
required this.label,
required this.value,
this.mono = false,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -974,10 +984,7 @@ class _McpClientsPanel extends StatelessWidget {
) )
else else
for (final c in clients) for (final c in clients)
_McpClientRow( _McpClientRow(client: c, onRemove: () => onRemove(c.name)),
client: c,
onRemove: () => onRemove(c.name),
),
], ],
); );
} }
@ -1309,10 +1316,7 @@ class _N8nEndpointsPanel extends StatelessWidget {
) )
else else
for (final e in endpoints) for (final e in endpoints)
_N8nEndpointRow( _N8nEndpointRow(endpoint: e, onRemove: () => onRemove(e.name)),
endpoint: e,
onRemove: () => onRemove(e.name),
),
], ],
); );
} }
@ -1497,7 +1501,8 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
), ),
FilledButton( FilledButton(
onPressed: () { onPressed: () {
if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty) return; if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty)
return;
Navigator.pop( Navigator.pop(
context, context,
N8nEndpointDraft( N8nEndpointDraft(
@ -1529,27 +1534,13 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
/// When no theme plugin is installed the panel renders a /// When no theme plugin is installed the panel renders a
/// short hint pointing at the Store rather than a useless /// short hint pointing at the Store rather than a useless
/// empty dropdown. /// empty dropdown.
class _ThemePluginPanel extends StatefulWidget { class _ThemePluginPanel extends StatelessWidget {
const _ThemePluginPanel(); const _ThemePluginPanel();
@override
State<_ThemePluginPanel> createState() => _ThemePluginPanelState();
}
class _ThemePluginPanelState extends State<_ThemePluginPanel> {
Future<List<String>>? _capsFuture;
@override
void initState() {
super.initState();
_capsFuture = listThemePluginCapabilities();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final app = StudioApp.of(context);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -1561,76 +1552,12 @@ class _ThemePluginPanelState extends State<_ThemePluginPanel> {
fontSize: 10, fontSize: 10,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 8),
FutureBuilder<List<String>>( // Rich grid of theme tiles. Each shows live swatches
future: _capsFuture, // from the plugin's own palette so the operator can
builder: (context, snap) { // preview without applying. The "Custom" tile opens
if (snap.connectionState != ConnectionState.done) { // a seed-colour dialog.
return const Padding( const ThemePickerGrid(),
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,
),
),
],
),
);
},
),
], ],
); );
} }
@ -1782,11 +1709,7 @@ class _MaintenancePanelState extends State<_MaintenancePanel> {
), ),
if (_resultText != null) ...[ if (_resultText != null) ...[
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
FaiErrorBox( FaiErrorBox(text: _resultText!, isError: !_resultOk, maxHeight: 240),
text: _resultText!,
isError: !_resultOk,
maxHeight: 240,
),
], ],
], ],
); );
@ -1822,8 +1745,7 @@ class _RegistryCredentialsPanel extends StatefulWidget {
_RegistryCredentialsPanelState(); _RegistryCredentialsPanelState();
} }
class _RegistryCredentialsPanelState class _RegistryCredentialsPanelState extends State<_RegistryCredentialsPanel> {
extends State<_RegistryCredentialsPanel> {
final _controller = TextEditingController(); final _controller = TextEditingController();
bool _obscured = true; bool _obscured = true;
@ -2017,9 +1939,7 @@ class _HubAuthTokenPanelState extends State<_HubAuthTokenPanel> {
Row( Row(
children: [ children: [
Icon( Icon(
isConfigured isConfigured ? Icons.check_circle : Icons.radio_button_unchecked,
? Icons.check_circle
: Icons.radio_button_unchecked,
size: 14, size: 14,
color: isConfigured color: isConfigured
? theme.colorScheme.primary ? theme.colorScheme.primary
@ -2066,8 +1986,7 @@ class _HubAuthTokenPanelState extends State<_HubAuthTokenPanel> {
_obscured ? Icons.visibility : Icons.visibility_off, _obscured ? Icons.visibility : Icons.visibility_off,
size: 16, size: 16,
), ),
onPressed: () => onPressed: () => setState(() => _obscured = !_obscured),
setState(() => _obscured = !_obscured),
), ),
), ),
), ),
@ -2103,6 +2022,7 @@ class _McpSuggestion {
final IconData icon; final IconData icon;
final String endpoint; final String endpoint;
final String apiKeyEnv; final String apiKeyEnv;
/// Localizable description. Use [resolveDescription] to fetch /// Localizable description. Use [resolveDescription] to fetch
/// the actual text in the active locale the const list /// the actual text in the active locale the const list
/// can't hold a closure that takes [AppLocalizations], and /// can't hold a closure that takes [AppLocalizations], and
@ -2305,9 +2225,9 @@ class _DefaultScopePanelState extends State<_DefaultScopePanel> {
_saving = false; _saving = false;
}); });
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l.defaultScopeSavedToast)), context,
); ).showSnackBar(SnackBar(content: Text(l.defaultScopeSavedToast)));
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@ -2333,8 +2253,9 @@ class _DefaultScopePanelState extends State<_DefaultScopePanel> {
if (current.length == 1) { if (current.length == 1) {
// Hub rejects empty; surface the constraint inline rather // Hub rejects empty; surface the constraint inline rather
// than letting the RPC fail. // than letting the RPC fail.
setState(() => _error = AppLocalizations.of(context)! setState(
.defaultScopeNonEmptyError); () => _error = AppLocalizations.of(context)!.defaultScopeNonEmptyError,
);
return; return;
} }
current.removeAt(i); current.removeAt(i);
@ -2513,10 +2434,7 @@ class _ScopeRow extends StatelessWidget {
), ),
), ),
Expanded( Expanded(
child: Text( child: Text(entry, style: const TextStyle(fontFamily: 'monospace')),
entry,
style: const TextStyle(fontFamily: 'monospace'),
),
), ),
IconButton( IconButton(
icon: const Icon(Icons.arrow_upward, size: 16), icon: const Icon(Icons.arrow_upward, size: 16),

View file

@ -88,14 +88,8 @@ class _FaiStatusDotState extends State<FaiStatusDot>
/// Convenience presets. /// Convenience presets.
class FaiStatusDots { class FaiStatusDots {
FaiStatusDots._(); FaiStatusDots._();
static Widget live() => const FaiStatusDot( static Widget live() =>
color: FaiColors.success, const FaiStatusDot(color: FaiColors.success, pulsing: true);
pulsing: true, static Widget idle() => const FaiStatusDot(color: FaiColors.muted);
); static Widget down() => const FaiStatusDot(color: FaiColors.danger);
static Widget idle() => const FaiStatusDot(
color: FaiColors.muted,
);
static Widget down() => const FaiStatusDot(
color: FaiColors.danger,
);
} }

View file

@ -83,10 +83,7 @@ class _ProviderPreset {
]; ];
static _ProviderPreset byWire(String wire) { static _ProviderPreset byWire(String wire) {
return all.firstWhere( return all.firstWhere((p) => p.wire == wire, orElse: () => all.first);
(p) => p.wire == wire,
orElse: () => all.first,
);
} }
} }
@ -141,9 +138,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
); );
_model = TextEditingController(text: init.model); _model = TextEditingController(text: init.model);
_apiKeyEnv = TextEditingController( _apiKeyEnv = TextEditingController(
text: init.apiKeyEnv.isEmpty text: init.apiKeyEnv.isEmpty ? _preset.defaultApiKeyEnv : init.apiKeyEnv,
? _preset.defaultApiKeyEnv
: init.apiKeyEnv,
); );
_privacyMode = init.privacyMode.isEmpty ? 'redacted' : init.privacyMode; _privacyMode = init.privacyMode.isEmpty ? 'redacted' : init.privacyMode;
// Pull the model list immediately so the operator sees the // 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), _HardwareBanner(hw: _hw!, lastReviewed: _curated?.lastReviewed),
], ],
const SizedBox(height: FaiSpace.lg), const SizedBox(height: FaiSpace.lg),
_ProviderDropdown( _ProviderDropdown(value: _preset, onChanged: _onProviderChanged),
value: _preset,
onChanged: _onProviderChanged,
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
_preset.description, _preset.description,
@ -495,10 +487,7 @@ class _ProviderDropdown extends StatelessWidget {
final _ProviderPreset value; final _ProviderPreset value;
final void Function(_ProviderPreset) onChanged; final void Function(_ProviderPreset) onChanged;
const _ProviderDropdown({ const _ProviderDropdown({required this.value, required this.onChanged});
required this.value,
required this.onChanged,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -512,12 +501,7 @@ class _ProviderDropdown extends StatelessWidget {
isDense: true, isDense: true,
), ),
items: _ProviderPreset.all items: _ProviderPreset.all
.map( .map((p) => DropdownMenuItem(value: p.wire, child: Text(p.label)))
(p) => DropdownMenuItem(
value: p.wire,
child: Text(p.label),
),
)
.toList(), .toList(),
onChanged: (v) { onChanged: (v) {
if (v == null) return; if (v == null) return;
@ -531,10 +515,7 @@ class _PrivacyModeChips extends StatelessWidget {
final String value; final String value;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
const _PrivacyModeChips({ const _PrivacyModeChips({required this.value, required this.onChanged});
required this.value,
required this.onChanged,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -547,11 +528,7 @@ class _PrivacyModeChips extends StatelessWidget {
l.systemAiPrivacyRedactedLabel, l.systemAiPrivacyRedactedLabel,
l.systemAiPrivacyRedactedDesc, l.systemAiPrivacyRedactedDesc,
), ),
( ('full', l.systemAiPrivacyFullLabel, l.systemAiPrivacyFullDesc),
'full',
l.systemAiPrivacyFullLabel,
l.systemAiPrivacyFullDesc,
),
]; ];
// Material 3 deprecated per-Radio `groupValue` / `onChanged` // Material 3 deprecated per-Radio `groupValue` / `onChanged`
// after Flutter 3.32. The modern pattern wraps the radios in // after Flutter 3.32. The modern pattern wraps the radios in
@ -643,9 +620,7 @@ class _TestResultPanel extends StatelessWidget {
const SizedBox(width: FaiSpace.xs), const SizedBox(width: FaiSpace.xs),
Text( Text(
ok ? l.systemAiConnectionOk : l.systemAiConnectionFailed, ok ? l.systemAiConnectionOk : l.systemAiConnectionFailed,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(color: color),
color: color,
),
), ),
const Spacer(), const Spacer(),
if (ok && result.latencyMs > 0) if (ok && result.latencyMs > 0)
@ -659,10 +634,7 @@ class _TestResultPanel extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
SelectableText( SelectableText(
ok ? l.systemAiReplyPrefix(result.text) : result.text, ok ? l.systemAiReplyPrefix(result.text) : result.text,
style: FaiTheme.mono( style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface),
size: 11,
color: theme.colorScheme.onSurface,
),
), ),
if (!ok && result.fixHint.isNotEmpty) ...[ if (!ok && result.fixHint.isNotEmpty) ...[
const SizedBox(height: FaiSpace.xs), const SizedBox(height: FaiSpace.xs),
@ -686,12 +658,14 @@ class _ModelPicker extends StatelessWidget {
final String? modelsError; final String? modelsError;
final bool loading; final bool loading;
final bool pulling; final bool pulling;
/// Detected host hardware. Used by [_suitabilityFor] to mark /// Detected host hardware. Used by [_suitabilityFor] to mark
/// curated models as "recommended" when their `min_hw_tier` is /// curated models as "recommended" when their `min_hw_tier` is
/// at or below the operator's tier. Null while the hub call 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 /// in flight or unreachable chips fall back to the size-only
/// heuristic. /// heuristic.
final HardwareSnapshot? hw; final HardwareSnapshot? hw;
/// Curated DB indexed by model id. Empty while the hub call is /// Curated DB indexed by model id. Empty while the hub call is
/// in flight; chips then fall back to the size-only heuristic. /// in flight; chips then fall back to the size-only heuristic.
final Map<String, CuratedModelInfo> curatedById; final Map<String, CuratedModelInfo> curatedById;
@ -787,9 +761,7 @@ class _ModelPicker extends StatelessWidget {
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
if (models!.isEmpty) if (models!.isEmpty)
Text( Text(
_isOllama _isOllama ? l.systemAiNoModelsOllama : l.systemAiNoModelsGeneric,
? l.systemAiNoModelsOllama
: l.systemAiNoModelsGeneric,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -970,6 +942,7 @@ List<String> _sortedBySuitability(
class _ModelChip extends StatelessWidget { class _ModelChip extends StatelessWidget {
final String id; final String id;
final _Suitability suitability; final _Suitability suitability;
/// Curated entry for this id, when one exists. Drives the /// Curated entry for this id, when one exists. Drives the
/// tooltip operators get the editorial note ("Solid 4B /// tooltip operators get the editorial note ("Solid 4B
/// all-rounder") rather than just the suitability label. /// all-rounder") rather than just the suitability label.
@ -1010,9 +983,7 @@ class _ModelChip extends StatelessWidget {
message: _tooltipFor(context), message: _tooltipFor(context),
child: ActionChip( child: ActionChip(
avatar: Icon( avatar: Icon(
suitability == _Suitability.recommended suitability == _Suitability.recommended ? Icons.star : Icons.circle,
? Icons.star
: Icons.circle,
size: suitability == _Suitability.recommended ? 14 : 9, size: suitability == _Suitability.recommended ? 14 : 9,
color: color, color: color,
), ),
@ -1041,10 +1012,7 @@ class _HardwareBanner extends StatelessWidget {
? l.systemAiHwReviewed(lastReviewed!) ? l.systemAiHwReviewed(lastReviewed!)
: ''; : '';
return Container( return Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 6),
horizontal: FaiSpace.sm,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh, color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm), borderRadius: BorderRadius.circular(FaiRadius.sm),
@ -1097,7 +1065,9 @@ class _SuitabilityLegend extends StatelessWidget {
], ],
), ),
); );
final tierTag = (hw == null || hw!.tier == 'unknown') ? '' : ' (${hw!.tier})'; final tierTag = (hw == null || hw!.tier == 'unknown')
? ''
: ' (${hw!.tier})';
return Wrap( return Wrap(
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
@ -1143,10 +1113,7 @@ class _CacheStatusRow extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
return Container( return Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 6),
horizontal: FaiSpace.sm,
vertical: 6,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh, color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm), borderRadius: BorderRadius.circular(FaiRadius.sm),
@ -1154,11 +1121,7 @@ class _CacheStatusRow extends StatelessWidget {
), ),
child: Row( child: Row(
children: [ children: [
Icon( Icon(Icons.bolt, size: 14, color: theme.colorScheme.onSurfaceVariant),
Icons.bolt,
size: 14,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: Text( child: Text(

View 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',
),
),
),
);
}
}

View file

@ -125,10 +125,10 @@ packages:
description: description:
path: "." path: "."
ref: main ref: main
resolved-ref: "3a6e92fe09ae8f423ab99d990c9459d937810717" resolved-ref: "7b256b5e35204771b8eeca3deb1f4071d86a035b"
url: "https://git.flemming.ai/fai/studio-flow-editor" url: "https://git.flemming.ai/fai/studio-flow-editor"
source: git source: git
version: "0.10.2" version: "0.11.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:

View file

@ -1,7 +1,7 @@
name: fai_studio name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub" description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none' publish_to: 'none'
version: 0.57.3 version: 0.58.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta