diff --git a/lib/data/hub_auth_token.dart b/lib/data/hub_auth_token.dart index cc5bb6c..f9f9375 100644 --- a/lib/data/hub_auth_token.dart +++ b/lib/data/hub_auth_token.dart @@ -15,12 +15,10 @@ import 'package:path/path.dart' as p; /// based on its own `auth.tokens:` config. class HubAuthToken { static String _faiHome() { - final home = Platform.environment['HOME'] ?? - Platform.environment['USERPROFILE']; + final home = + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; if (home == null || home.isEmpty) { - throw StateError( - 'Cannot resolve home directory (no HOME / USERPROFILE)', - ); + throw StateError('Cannot resolve home directory (no HOME / USERPROFILE)'); } return p.join(home, '.fai'); } @@ -79,7 +77,9 @@ class HubAuthToken { if ((Platform.isLinux || Platform.isMacOS) && !parentExisted) { try { await Process.run('chmod', ['700', parent.path]); - } catch (_) {/* best-effort */} + } catch (_) { + /* best-effort */ + } } // Write to the temp path with restrictive mode set up // BEFORE the rename — the final file is never visible @@ -88,7 +88,9 @@ class HubAuthToken { if (Platform.isLinux || Platform.isMacOS) { try { await Process.run('chmod', ['600', tmp.path]); - } catch (_) {/* best-effort */} + } catch (_) { + /* best-effort */ + } } // Atomic rename: on POSIX, replaces the destination // in one syscall. On Windows, Dart's File.rename uses @@ -105,7 +107,9 @@ class HubAuthToken { if (Platform.isLinux || Platform.isMacOS) { try { await Process.run('chmod', ['600', f.path]); - } catch (_) {/* best-effort */} + } catch (_) { + /* best-effort */ + } } await tmp.delete(); } diff --git a/lib/data/registry_token.dart b/lib/data/registry_token.dart index 359d11f..b20a02c 100644 --- a/lib/data/registry_token.dart +++ b/lib/data/registry_token.dart @@ -10,7 +10,8 @@ import 'package:path/path.dart' as p; /// requires the operator to fiddle with shell env vars. class RegistryToken { static String _faiHome() { - final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + final home = + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; if (home == null || home.isEmpty) { throw StateError('Cannot resolve home directory (no HOME / USERPROFILE)'); } diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index 71515a6..fc48ce6 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -152,7 +152,8 @@ class SystemActions { return ( ok: false, 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.', ); } diff --git a/lib/data/theme_plugin.dart b/lib/data/theme_plugin.dart index 86a7c07..701ce31 100644 --- a/lib/data/theme_plugin.dart +++ b/lib/data/theme_plugin.dart @@ -39,8 +39,9 @@ class ThemePluginSchemes { Future> listThemePluginCapabilities() async { final caps = await HubService.instance.allCapabilities(); return caps - .where((c) => - c.kind == 'wasm' && c.capability.startsWith('studio.theme.')) + .where( + (c) => c.kind == 'wasm' && c.capability.startsWith('studio.theme.'), + ) .map((c) => c.capability) .toList() ..sort(); @@ -121,6 +122,7 @@ ColorScheme _tokensToColorScheme(List tokens, Brightness brightness) { ? const Color(0xFF1A1A1A) : const Color(0xFFEEEEEE); } + return ColorScheme( brightness: brightness, primary: slot(0), diff --git a/lib/data/today_story_loader.dart b/lib/data/today_story_loader.dart index 6788f34..2dce740 100644 --- a/lib/data/today_story_loader.dart +++ b/lib/data/today_story_loader.dart @@ -58,7 +58,8 @@ class TodayStoryLoader { /// Resolves to `~/.fai/today/active.yaml` on every supported /// host. The pipeline writes here from [tools/today/accept.sh]. static String activePath() { - final home = Platform.environment['HOME'] ?? + final home = + Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? ''; return p.join(home, '.fai', 'today', 'active.yaml'); @@ -89,8 +90,8 @@ class TodayStoryLoader { String s(String key) => (doc[key] ?? '').toString().trim(); if (_bannedWords.hasMatch( - '${s('title_en')} ${s('title_de')} ${s('body_en')} ${s('body_de')}', - )) { + '${s('title_en')} ${s('title_de')} ${s('body_en')} ${s('body_de')}', + )) { return null; } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b3faa86..7b86571 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -108,6 +108,12 @@ "themePluginNone": "Standard (kein Plugin)", "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.", + "themePluginCustom": "Eigene Farbe", + "themePluginCustomDialogTitle": "Eigenes Theme — Seedfarbe wählen", + "themePluginCustomHexLabel": "Hex-Farbe", + "themePluginCustomPreview": "Live-Vorschau", + "themePluginCustomCancel": "Abbrechen", + "themePluginCustomApply": "Anwenden", "settingsTitle": "Hub-Endpunkt", "settingsHost": "Host", "settingsPort": "Port", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e6988af..285652a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -111,6 +111,12 @@ "themePluginNone": "Built-in (no plugin)", "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.", + "themePluginCustom": "Custom colour", + "themePluginCustomDialogTitle": "Custom theme — pick a seed colour", + "themePluginCustomHexLabel": "Hex colour", + "themePluginCustomPreview": "Live preview", + "themePluginCustomCancel": "Cancel", + "themePluginCustomApply": "Apply", "settingsTitle": "Hub endpoint", "settingsHost": "Host", "settingsPort": "Port", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5f61633..e19ae51 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -662,6 +662,42 @@ abstract class AppLocalizations { /// **'No theme plugins installed. Open the Store and pick one in the studio-plugin category.'** 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. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index b5335f1..ffd8c5e 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -326,6 +326,24 @@ class AppLocalizationsDe extends AppLocalizations { String get themePluginEmpty => '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 String get settingsTitle => 'Hub-Endpunkt'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ad38b08..fb9da6a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -326,6 +326,25 @@ class AppLocalizationsEn extends AppLocalizations { String get themePluginEmpty => '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 String get settingsTitle => 'Hub endpoint'; diff --git a/lib/main.dart b/lib/main.dart index 6db8370..6ed6b07 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -37,16 +37,19 @@ Future main() async { final themeMode = await HubService.instance.loadThemeMode(); final locale = await HubService.instance.loadLocale(); final themePlugin = await loadActiveThemePlugin(); - runApp(StudioApp( - initialThemeMode: themeMode, - initialLocale: locale, - initialThemePlugin: themePlugin, - )); + runApp( + StudioApp( + initialThemeMode: themeMode, + initialLocale: locale, + initialThemePlugin: themePlugin, + ), + ); } class StudioApp extends StatefulWidget { final ThemeModeValue initialThemeMode; final Locale initialLocale; + /// Capability name of the active theme plugin (e.g. /// `studio.theme.solarized`) or `null` for the built-in /// FaiTheme. @@ -75,10 +78,12 @@ class StudioAppState extends State { /// `MaterialApp(home: const StudioShell())` because the /// const child's element doesn't get a `didUpdateWidget`. late final ValueNotifier modeNotifier; + /// Active app locale. The sidebar's language toggle flips /// this, MaterialApp re-renders with the new /// AppLocalizations, and every translated string updates. late final ValueNotifier localeNotifier; + /// Capability name of the active `studio.theme.*` plugin /// (e.g. `studio.theme.solarized`) or `null` for /// FaiTheme's built-in palette. Flipping this triggers a @@ -132,16 +137,45 @@ class StudioAppState extends State { /// theme plugin. Returns immediately with `null` when no /// plugin is selected; otherwise awaits the plugin's /// `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( String? capability, ) async { if (capability == null || capability.isEmpty) { 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 { final schemes = await loadThemePluginSchemes(capability); 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!), ); } catch (_) { @@ -160,27 +194,29 @@ class StudioAppState extends State { valueListenable: localeNotifier, builder: (_, locale, _) => ValueListenableBuilder( valueListenable: themePluginNotifier, - builder: (_, pluginCap, _) => FutureBuilder<({ThemeData? light, ThemeData? dark})>( - // Re-key on the capability so a plugin change - // discards the in-flight future for the previous - // plugin and starts fresh. - key: ValueKey('plugin_themes:$pluginCap'), - future: _pluginThemes(pluginCap), - builder: (context, snap) { - final p = snap.data ?? (light: null, dark: null); - return MaterialApp( - title: 'F∆I Studio', - debugShowCheckedModeBanner: false, - theme: p.light ?? FaiTheme.light(), - darkTheme: p.dark ?? FaiTheme.dark(), - themeMode: _flutterMode(mode), - locale: locale, - supportedLocales: AppLocalizations.supportedLocales, - localizationsDelegates: AppLocalizations.localizationsDelegates, - home: const StudioShell(), - ); - }, - ), + builder: (_, pluginCap, _) => + FutureBuilder<({ThemeData? light, ThemeData? dark})>( + // Re-key on the capability so a plugin change + // discards the in-flight future for the previous + // plugin and starts fresh. + key: ValueKey('plugin_themes:$pluginCap'), + future: _pluginThemes(pluginCap), + builder: (context, snap) { + final p = snap.data ?? (light: null, dark: null); + return MaterialApp( + title: 'F∆I Studio', + debugShowCheckedModeBanner: false, + theme: p.light ?? FaiTheme.light(), + darkTheme: p.dark ?? FaiTheme.dark(), + themeMode: _flutterMode(mode), + locale: locale, + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: + AppLocalizations.localizationsDelegates, + home: const StudioShell(), + ); + }, + ), ), ), ); @@ -328,7 +364,9 @@ class _StudioShellState extends State { SingleActivator( LogicalKeyboardKey(LogicalKeyboardKey.digit1.keyId + i), meta: true, - ): _GoToPageIntent(i), + ): _GoToPageIntent( + i, + ), // Settings dialog. macOS reserves Cmd+, for the native // app-Preferences menu item, which Flutter Desktop // doesn't wire up — the keystroke is captured by the OS @@ -381,25 +419,22 @@ class _StudioShellState extends State { endpointLabel: HubService.instance.endpointLabel, activeChannel: _activeChannel, ), - Container( - width: 1, - color: theme.colorScheme.outlineVariant, - ), + Container(width: 1, color: theme.colorScheme.outlineVariant), Expanded( child: AnimatedSwitcher( - duration: FaiMotion.base, - switchInCurve: FaiMotion.easing, - switchOutCurve: FaiMotion.easing, - transitionBuilder: (child, anim) => FadeTransition( - opacity: anim, - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.02), - end: Offset.zero, - ).animate(anim), - child: child, - ), - ), + duration: FaiMotion.base, + switchInCurve: FaiMotion.easing, + switchOutCurve: FaiMotion.easing, + transitionBuilder: (child, anim) => FadeTransition( + opacity: anim, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.02), + end: Offset.zero, + ).animate(anim), + child: child, + ), + ), child: KeyedSubtree( key: ValueKey(_selectedIndex), child: _pages[_selectedIndex].page, @@ -435,6 +470,7 @@ class _Sidebar extends StatefulWidget { final List<_NavPage> pages; final bool? connected; final String endpointLabel; + /// Active channel name from the running daemon /// (`local`/`dev`/`beta`/`production`). Null while the /// initial fetch is in flight or the hub is unreachable. @@ -506,7 +542,9 @@ class _SidebarState extends State<_Sidebar> @override Widget build(BuildContext 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 = widget.activeChannel != null && widget.activeChannel!.isNotEmpty; return MouseRegion( @@ -516,7 +554,8 @@ class _SidebarState extends State<_Sidebar> animation: _ctrl, builder: (context, _) { final t = Curves.easeInOutCubic.transform(_ctrl.value); - final width = _collapsedWidth + (_expandedWidth - _collapsedWidth) * t; + final width = + _collapsedWidth + (_expandedWidth - _collapsedWidth) * t; final labelsInteractive = t > 0.5; return Container( width: width, @@ -551,8 +590,8 @@ class _SidebarState extends State<_Sidebar> final caption = widget.connected == true ? l.connectionConnected : widget.connected == false - ? l.connectionTapToStart - : l.connectionConnecting; + ? l.connectionTapToStart + : l.connectionConnecting; return SizedBox( height: _connRowH, child: _sidebarRow( @@ -590,9 +629,7 @@ class _SidebarState extends State<_Sidebar> channel: widget.activeChannel!, ), ), - label: _ChannelPill( - channel: widget.activeChannel!, - ), + label: _ChannelPill(channel: widget.activeChannel!), t: t, labelsInteractive: labelsInteractive, ) @@ -789,8 +826,8 @@ class _CollapsedConnectionDot extends StatelessWidget { final color = connected == true ? FaiColors.success : connected == false - ? theme.colorScheme.error - : FaiColors.warning; + ? theme.colorScheme.error + : FaiColors.warning; return Container( width: 10, height: 10, @@ -851,11 +888,7 @@ class _CollapsedChannelChip extends StatelessWidget { alignment: Alignment.center, child: Text( _letter, - style: FaiTheme.mono( - size: 10, - weight: FontWeight.w700, - color: accent, - ), + style: FaiTheme.mono(size: 10, weight: FontWeight.w700, color: accent), ), ); } @@ -915,8 +948,8 @@ class _ConnectionLabel extends StatelessWidget { final caption = connected == true ? l.connectionConnected : connected == false - ? l.connectionTapToStart - : l.connectionConnecting; + ? l.connectionTapToStart + : l.connectionConnecting; return Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, @@ -948,6 +981,7 @@ class _ConnectionLabel extends StatelessWidget { class _SidebarItem extends StatefulWidget { final _NavPage page; final bool selected; + /// 0 = fully collapsed, 1 = fully expanded. Drives label /// opacity in lockstep with the parent rail's width /// animation — there is no second timer for the label, so @@ -979,13 +1013,13 @@ class _SidebarItemState extends State<_SidebarItem> { final color = widget.selected ? theme.colorScheme.primary : _hovered - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant; + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant; final bg = widget.selected ? theme.colorScheme.primary.withValues(alpha: 0.08) : _hovered - ? theme.colorScheme.surfaceContainerHigh - : Colors.transparent; + ? theme.colorScheme.surfaceContainerHigh + : Colors.transparent; final label = widget.page.labelOf(context); final icon = Icon( @@ -1017,7 +1051,9 @@ class _SidebarItemState extends State<_SidebarItem> { label, overflow: TextOverflow.fade, 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 // is visible inline the tooltip becomes redundant noise. if (widget.t > 0.5) return inner; - return Tooltip( - message: label, - preferBelow: false, - child: inner, - ); + return Tooltip(message: label, preferBelow: false, child: inner); } } @@ -1107,9 +1139,7 @@ class _Footer extends StatelessWidget { children: [ _ThemeToggle(), _LanguageToggle(), - const Expanded( - child: Center(child: _SidebarClock()), - ), + const Expanded(child: Center(child: _SidebarClock())), ], ), ), diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index 5a0ad15..3efddf0 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -21,6 +21,7 @@ class _ApprovalsPageState extends State late final TabController _tab; late Future> _pendingFuture; late Future> _historyFuture; + /// IDs the operator currently has multi-selected for a /// batch operation. Empty set hides the batch action bar /// and falls back to per-row Approve / Reject buttons. @@ -32,7 +33,8 @@ class _ApprovalsPageState extends State late final String _reviewer = _defaultReviewer(); static String _defaultReviewer() { - final user = Platform.environment['USER'] ?? + final user = + Platform.environment['USER'] ?? Platform.environment['USERNAME'] ?? 'studio'; return '$user@studio'; @@ -124,9 +126,11 @@ class _ApprovalsPageState extends State } if (!mounted) return; setState(() => _batchInFlight = false); - _toast(failed == 0 - ? l.approvalsBatchApproveDoneToast(ok) - : l.approvalsBatchPartialFailure(ok, failed)); + _toast( + failed == 0 + ? l.approvalsBatchApproveDoneToast(ok) + : l.approvalsBatchPartialFailure(ok, failed), + ); _refresh(); } @@ -148,9 +152,11 @@ class _ApprovalsPageState extends State } if (!mounted) return; setState(() => _batchInFlight = false); - _toast(failed == 0 - ? l.approvalsBatchRejectDoneToast(ok) - : l.approvalsBatchPartialFailure(ok, failed)); + _toast( + failed == 0 + ? l.approvalsBatchRejectDoneToast(ok) + : l.approvalsBatchPartialFailure(ok, failed), + ); _refresh(); } @@ -231,10 +237,7 @@ class _ApprovalsPageState extends State onBatchReject: _batchReject, onRetry: _refresh, ), - _HistoryList( - future: _historyFuture, - onRetry: _refresh, - ), + _HistoryList(future: _historyFuture, onRetry: _refresh), ], ), ); @@ -391,8 +394,11 @@ class _BatchActionBar extends StatelessWidget { ), child: Row( children: [ - Icon(Icons.check_box_outlined, - size: 18, color: theme.colorScheme.primary), + Icon( + Icons.check_box_outlined, + size: 18, + color: theme.colorScheme.primary, + ), const SizedBox(width: FaiSpace.sm), Text( l.approvalsBatchSelected(selectedCount), @@ -546,10 +552,7 @@ class _ApprovalCard extends StatelessWidget { ], ), const SizedBox(height: FaiSpace.lg), - Text( - approval.prompt, - style: theme.textTheme.bodyLarge, - ), + Text(approval.prompt, style: theme.textTheme.bodyLarge), if (approval.payloadPreview != null) ...[ const SizedBox(height: FaiSpace.md), Padding( @@ -648,8 +651,7 @@ class _HistoryRow extends StatelessWidget { final theme = Theme.of(context); final (tone, icon, label) = _statusPresentation(context, record.status); final decided = record.decidedAt; - final timeStr = - decided == null ? '' : _formatTimestamp(decided.toLocal()); + final timeStr = decided == null ? '' : _formatTimestamp(decided.toLocal()); return InkWell( onTap: () => showDialog( context: context, @@ -728,8 +730,10 @@ class _HistoryDialog extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final (tone, icon, label) = - _HistoryRow._statusPresentation(context, record.status); + final (tone, icon, label) = _HistoryRow._statusPresentation( + context, + record.status, + ); return AlertDialog( title: Row( children: [ @@ -752,14 +756,23 @@ class _HistoryDialog extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (record.decidedAt != null) - _kv(theme, AppLocalizations.of(context)!.approvalsDialogDecided, - '${_formatTimestamp(record.decidedAt!.toLocal())} ' - '· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'), - _kv(theme, AppLocalizations.of(context)!.approvalsDialogCreated, - _formatTimestamp(record.createdAt.toLocal())), + _kv( + theme, + AppLocalizations.of(context)!.approvalsDialogDecided, + '${_formatTimestamp(record.decidedAt!.toLocal())} ' + '· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}', + ), + _kv( + theme, + AppLocalizations.of(context)!.approvalsDialogCreated, + _formatTimestamp(record.createdAt.toLocal()), + ), if (record.reason.isNotEmpty) - _kv(theme, AppLocalizations.of(context)!.approvalsDialogReason, - record.reason), + _kv( + theme, + AppLocalizations.of(context)!.approvalsDialogReason, + record.reason, + ), const SizedBox(height: FaiSpace.md), Text( AppLocalizations.of(context)!.approvalsDialogPrompt, @@ -769,10 +782,7 @@ class _HistoryDialog extends StatelessWidget { ), ), const SizedBox(height: 4), - SelectableText( - record.prompt, - style: theme.textTheme.bodyMedium, - ), + SelectableText(record.prompt, style: theme.textTheme.bodyMedium), if (record.payloadPreview != null) ...[ const SizedBox(height: FaiSpace.md), Text( @@ -834,12 +844,7 @@ class _HistoryDialog extends StatelessWidget { ), ), ), - Expanded( - child: SelectableText( - v, - style: theme.textTheme.bodySmall, - ), - ), + Expanded(child: SelectableText(v, style: theme.textTheme.bodySmall)), ], ), ); diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 6477d13..01b48ce 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -28,10 +28,7 @@ class _AuditPageState extends State { void initState() { super.initState(); _refresh(); - _poller = Timer.periodic( - const Duration(seconds: 2), - (_) => _refresh(), - ); + _poller = Timer.periodic(const Duration(seconds: 2), (_) => _refresh()); } @override @@ -56,9 +53,9 @@ class _AuditPageState extends State { _refresh(); } catch (e) { if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.auditClearFailed(_friendly(e)))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.auditClearFailed(_friendly(e))))); } } @@ -124,26 +121,25 @@ class _AuditPageState extends State { child: !_initialLoaded ? const Center(child: CircularProgressIndicator()) : _error != null && _events.isEmpty - ? FaiEmptyState( - icon: Icons.cloud_off_outlined, - iconColor: theme.colorScheme.error, - title: AppLocalizations.of(context)!.hubUnreachable, - hint: AppLocalizations.of(context)!.hubUnreachableHint, - ) - : filtered.isEmpty - ? FaiEmptyState( - icon: Icons.timeline_outlined, - title: AppLocalizations.of(context)!.auditNoEvents, - hint: - AppLocalizations.of(context)!.auditNoEventsHint, - ) - : _GroupedEventList( - events: filtered, - allEvents: _events, - toneFor: (t) => _toneFor(t, theme), - formatTime: _formatTime, - contextLine: _contextLine, - ), + ? FaiEmptyState( + icon: Icons.cloud_off_outlined, + iconColor: theme.colorScheme.error, + title: AppLocalizations.of(context)!.hubUnreachable, + hint: AppLocalizations.of(context)!.hubUnreachableHint, + ) + : filtered.isEmpty + ? FaiEmptyState( + icon: Icons.timeline_outlined, + title: AppLocalizations.of(context)!.auditNoEvents, + hint: AppLocalizations.of(context)!.auditNoEventsHint, + ) + : _GroupedEventList( + events: filtered, + allEvents: _events, + toneFor: (t) => _toneFor(t, theme), + formatTime: _formatTime, + contextLine: _contextLine, + ), ), ], ), @@ -164,7 +160,8 @@ class _AuditPageState extends State { final mm = local.minute.toString().padLeft(2, '0'); final ss = local.second.toString().padLeft(2, '0'); final time = '$hh:$mm:$ss'; - final sameDay = local.year == now.year && + final sameDay = + local.year == now.year && local.month == now.month && local.day == now.day; if (sameDay) return time; @@ -252,10 +249,8 @@ class _GroupedEventList extends StatelessWidget { trailing: e.durationMs != null ? '${e.durationMs}ms' : null, onTap: () => showDialog( context: context, - builder: (_) => _EventDetailDialog( - event: e, - allEvents: allEvents, - ), + builder: (_) => + _EventDetailDialog(event: e, allEvents: allEvents), ), ), ); @@ -285,11 +280,7 @@ class _GroupedEventList extends StatelessWidget { /// timezone so an event at 23:55 yesterday in Berlin doesn't /// land in "today" because UTC happened to spill into a new /// day. - static String _bucketLabel( - DateTime ts, - DateTime now, - AppLocalizations l, - ) { + static String _bucketLabel(DateTime ts, DateTime now, AppLocalizations l) { final local = ts.toLocal(); final localNow = now.toLocal(); final today = DateTime(localNow.year, localNow.month, localNow.day); @@ -371,13 +362,13 @@ class _ChipButtonState extends State<_ChipButton> { final fg = widget.selected ? theme.colorScheme.primary : _hovered - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurfaceVariant; + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurfaceVariant; final bg = widget.selected ? theme.colorScheme.primary.withValues(alpha: 0.12) : _hovered - ? theme.colorScheme.surfaceContainerHigh - : Colors.transparent; + ? theme.colorScheme.surfaceContainerHigh + : Colors.transparent; return MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), @@ -444,9 +435,7 @@ class _LiveStatusBar extends StatelessWidget { ), const SizedBox(width: FaiSpace.sm), Text( - live - ? l.auditLiveStatus(eventCount) - : l.auditDisconnected(error!), + live ? l.auditLiveStatus(eventCount) : l.auditDisconnected(error!), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -478,6 +467,7 @@ class _LiveStatusBar extends StatelessWidget { class _EventDetailDialog extends StatefulWidget { final AuditEvent event; + /// Full event window the audit page already fetched. Lets /// the dialog surface every event sharing this event's /// `flow_execution` without a fresh round-trip — flow runs @@ -485,10 +475,7 @@ class _EventDetailDialog extends StatefulWidget { /// page polls on. final List allEvents; - const _EventDetailDialog({ - required this.event, - required this.allEvents, - }); + const _EventDetailDialog({required this.event, required this.allEvents}); @override State<_EventDetailDialog> createState() => _EventDetailDialogState(); @@ -544,8 +531,10 @@ class _EventDetailDialogState extends State<_EventDetailDialog> { ..writeln('event_type: ${e.type}') ..writeln('flow: ${e.flowName ?? "(none)"}') ..writeln('step: ${e.stepId ?? "(none)"}') - ..writeln('module: ${e.moduleName ?? "(none)"}' - '${e.moduleVersion != null ? "@${e.moduleVersion}" : ""}') + ..writeln( + 'module: ${e.moduleName ?? "(none)"}' + '${e.moduleVersion != null ? "@${e.moduleVersion}" : ""}', + ) ..writeln('error: ${e.error ?? "(none)"}') ..writeln('duration: ${e.durationMs ?? 0}ms'); if (mode == 'full' && e.detail != null) { @@ -640,14 +629,9 @@ class _EventDetailDialogState extends State<_EventDetailDialog> { decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), - border: Border.all( - color: theme.colorScheme.outlineVariant, - ), - ), - child: SelectableText( - detail, - style: FaiTheme.mono(size: 11), + border: Border.all(color: theme.colorScheme.outlineVariant), ), + child: SelectableText(detail, style: FaiTheme.mono(size: 11)), ), ], if (_explanation != null || _explaining) ...[ @@ -690,11 +674,13 @@ class _EventDetailDialogState extends State<_EventDetailDialog> { _explaining ? l.auditAsking : _explanation == null - ? l.auditExplain - : l.auditReask, + ? l.auditExplain + : l.auditReask, ), ) - else if (_aiStatus != null && !_aiStatus!.enabled && (event.error != null)) + else if (_aiStatus != null && + !_aiStatus!.enabled && + (event.error != null)) Tooltip( message: l.auditConfigureSystemAi, child: OutlinedButton.icon( @@ -734,10 +720,9 @@ class _FlowRunDialog extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; - final related = allEvents - .where((e) => e.flowExecution == flowExecution) - .toList() - ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); + final related = + allEvents.where((e) => e.flowExecution == flowExecution).toList() + ..sort((a, b) => a.timestamp.compareTo(b.timestamp)); final maxHeight = MediaQuery.of(context).size.height * 0.75; return AlertDialog( title: Text(l.auditFlowRunDialogTitle(flowName)), @@ -761,8 +746,7 @@ class _FlowRunDialog extends StatelessWidget { child: ListView.separated( shrinkWrap: true, itemCount: related.length, - separatorBuilder: (_, _) => - const SizedBox(height: FaiSpace.xs), + separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.xs), itemBuilder: (context, i) { final e = related[i]; return FaiDataRow( @@ -770,8 +754,7 @@ class _FlowRunDialog extends StatelessWidget { leading: _formatRelativeTime(e.timestamp), title: e.type, subtitle: _stepLine(e), - trailing: - e.durationMs != null ? '${e.durationMs}ms' : null, + trailing: e.durationMs != null ? '${e.durationMs}ms' : null, ); }, ), @@ -865,6 +848,7 @@ class _ExplanationPanel extends StatelessWidget { final bool explaining; final AskAiResult? result; final String privacyMode; + /// Triggered by the "Regenerate" affordance — passes /// `forceFresh: true` so the next askAi skips the cache and /// hits the live provider. Null while [explaining] is true. @@ -885,8 +869,8 @@ class _ExplanationPanel extends StatelessWidget { final color = explaining ? theme.colorScheme.primary : ok - ? theme.colorScheme.primary - : theme.colorScheme.error; + ? theme.colorScheme.primary + : theme.colorScheme.error; return Container( width: double.infinity, padding: const EdgeInsets.all(FaiSpace.md), @@ -945,7 +929,9 @@ class _ExplanationPanel extends StatelessWidget { color: theme.colorScheme.onSurfaceVariant, ), ), - if (result != null && result!.isSuccess && onRegenerate != null) ...[ + if (result != null && + result!.isSuccess && + onRegenerate != null) ...[ const SizedBox(width: FaiSpace.sm), Tooltip( message: l.auditRegenerateTooltip, @@ -980,7 +966,9 @@ class _ExplanationPanel extends StatelessWidget { SelectableText( result!.text, 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) ...[ @@ -1040,7 +1028,8 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> { @override void initState() { super.initState(); - final user = Platform.environment['USER'] ?? + final user = + Platform.environment['USER'] ?? Platform.environment['USERNAME'] ?? 'operator'; _reviewer = TextEditingController(text: '$user@studio'); @@ -1111,12 +1100,12 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> { onPressed: _reason.text.trim().isEmpty ? null : () => Navigator.pop( - context, - _ClearOutcome( - reviewer: _reviewer.text.trim(), - reason: _reason.text.trim(), - ), + context, + _ClearOutcome( + reviewer: _reviewer.text.trim(), + reason: _reason.text.trim(), ), + ), style: FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.error, foregroundColor: Theme.of(context).colorScheme.onError, diff --git a/lib/pages/doctor.dart b/lib/pages/doctor.dart index 6ebd52d..2eca97a 100644 --- a/lib/pages/doctor.dart +++ b/lib/pages/doctor.dart @@ -24,8 +24,8 @@ class _DoctorPageState extends State { } void _refresh() => setState(() { - _future = HubService.instance.doctor(); - }); + _future = HubService.instance.doctor(); + }); @override Widget build(BuildContext context) { @@ -62,9 +62,9 @@ class _DoctorPageState extends State { ); } final s = snapshot.data!; - final showUpdate = s.update.updateAvailable || - (!s.update.manifestReachable && - s.update.localVersion.isNotEmpty); + final showUpdate = + s.update.updateAvailable || + (!s.update.manifestReachable && s.update.localVersion.isNotEmpty); return ListView( padding: const EdgeInsets.all(FaiSpace.xl), children: [ @@ -74,14 +74,13 @@ class _DoctorPageState extends State { const SizedBox(height: FaiSpace.xl), _Section( title: AppLocalizations.of(context)!.doctorEventLogSection, - child: _EventLogPanel( - snapshot: s, - onRefresh: _refresh, - ), + child: _EventLogPanel(snapshot: s, onRefresh: _refresh), ), const SizedBox(height: FaiSpace.lg), _Section( - title: AppLocalizations.of(context)!.doctorModulesApprovalsSection, + title: AppLocalizations.of( + context, + )!.doctorModulesApprovalsSection, child: _ModulesPanel(snapshot: s), ), const SizedBox(height: FaiSpace.lg), @@ -120,7 +119,10 @@ class _Section extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.only(left: FaiSpace.xs, bottom: FaiSpace.sm), + padding: const EdgeInsets.only( + left: FaiSpace.xs, + bottom: FaiSpace.sm, + ), child: Text( title.toUpperCase(), style: theme.textTheme.labelSmall?.copyWith( @@ -259,6 +261,7 @@ class _StatTile extends StatelessWidget { class _EventLogPanel extends StatelessWidget { final DoctorSnapshot snapshot; + /// Re-runs the doctor() call which re-verifies the chain. /// Wired to a "Verify now" button so operators can re-check /// after import / restore without restarting the daemon. @@ -361,12 +364,7 @@ class _DaemonPathsPanel extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final e in entries) - _PathRow( - label: e.$1, - path: e.$2, - icon: e.$3, - isDirectory: e.$4, - ), + _PathRow(label: e.$1, path: e.$2, icon: e.$3, isDirectory: e.$4), ], ), ); @@ -485,10 +483,11 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { if (!mounted) return; setState(() { _busy = false; - _output = (r.ok - ? 'OK · $label\n${r.stdout}' - : 'Failed · $label\n${r.stderr.isEmpty ? r.stdout : r.stderr}') - .trim(); + _output = + (r.ok + ? 'OK · $label\n${r.stdout}' + : 'Failed · $label\n${r.stderr.isEmpty ? r.stdout : r.stderr}') + .trim(); }); // Refresh the status pill — restart / stop / start all // change the running flag. @@ -535,8 +534,8 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { active == null ? l.doctorStatusUnknown : active.running - ? l.doctorRunningOn(active.name, active.endpoint) - : l.doctorStoppedOn(active.name), + ? l.doctorRunningOn(active.name, active.endpoint) + : l.doctorStoppedOn(active.name), style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w500, ), @@ -572,9 +571,9 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { onPressed: _busy ? null : () => _run( - 'daemon restart', - () => SystemActions.faiDaemon(['restart']), - ), + 'daemon restart', + () => SystemActions.faiDaemon(['restart']), + ), icon: const Icon(Icons.restart_alt, size: 16), label: Text(l.doctorRestart), ), @@ -583,9 +582,9 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { onPressed: _busy ? null : () => _run( - 'daemon start', - () => SystemActions.faiDaemon(['start']), - ), + 'daemon start', + () => SystemActions.faiDaemon(['start']), + ), icon: const Icon(Icons.play_arrow, size: 16), label: Text(l.doctorStart), ), @@ -593,9 +592,9 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { onPressed: _busy ? null : () => _run( - 'daemon stop', - () => SystemActions.faiDaemon(['stop']), - ), + 'daemon stop', + () => SystemActions.faiDaemon(['stop']), + ), icon: const Icon(Icons.stop_circle_outlined, size: 16), label: Text(l.doctorStop), ), @@ -603,9 +602,9 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> { onPressed: _busy ? null : () => _run( - 'daemon status', - () => SystemActions.faiDaemon(['status']), - ), + 'daemon status', + () => SystemActions.faiDaemon(['status']), + ), icon: const Icon(Icons.health_and_safety_outlined, size: 16), label: Text(l.doctorStatusAction), ), @@ -801,10 +800,7 @@ class _ServicesPanel extends StatelessWidget { children: [ for (var i = 0; i < snapshot.services.length; i++) ...[ if (i > 0) - Divider( - height: 1, - color: theme.colorScheme.outlineVariant, - ), + Divider(height: 1, color: theme.colorScheme.outlineVariant), Padding( padding: const EdgeInsets.all(FaiSpace.lg), child: Row( @@ -940,7 +936,9 @@ class _UpdateBannerState extends State<_UpdateBanner> { child: CircularProgressIndicator(strokeWidth: 2), ) : 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), FaiPill( diff --git a/lib/pages/flows.dart b/lib/pages/flows.dart index b84f68b..f682eba 100644 --- a/lib/pages/flows.dart +++ b/lib/pages/flows.dart @@ -48,11 +48,9 @@ class _FlowsPageState extends State { Future> _loadCapabilities() async { try { final caps = await HubService.instance.allCapabilities(); - final entries = caps - .map((c) => '${c.capability}@${c.version}') - .toSet() - .toList() - ..sort(); + final entries = + caps.map((c) => '${c.capability}@${c.version}').toSet().toList() + ..sort(); return entries; } catch (_) { return const []; diff --git a/lib/pages/modules.dart b/lib/pages/modules.dart index d81587e..88628db 100644 --- a/lib/pages/modules.dart +++ b/lib/pages/modules.dart @@ -32,9 +32,9 @@ class _ModulesPageState extends State { } void _refresh() => setState(() { - _future = HubService.instance.listModules(); - _historyFuture = _loadHistory(); - }); + _future = HubService.instance.listModules(); + _historyFuture = _loadHistory(); + }); @override Widget build(BuildContext context) { @@ -87,8 +87,10 @@ class _ModulesPageState extends State { if (i > 0) const SizedBox(height: FaiSpace.md), GestureDetector( onTap: () async { - final uninstalled = - await FaiModuleSheet.show(context, modules[i].name); + final uninstalled = await FaiModuleSheet.show( + context, + modules[i].name, + ); if (uninstalled) _refresh(); }, behavior: HitTestBehavior.opaque, @@ -171,7 +173,6 @@ class _ModuleCard extends StatelessWidget { } } - /// Compact "Recent activity" header listing the last few /// install / uninstall events from the audit log. Lets /// operators trace "wait, when did that module appear?" @@ -220,8 +221,9 @@ class _RecentActivityPanel extends StatelessWidget { ), const SizedBox(width: FaiSpace.sm), Text( - AppLocalizations.of(context)! - .modulesRecentActivityHint(events.length), + AppLocalizations.of( + context, + )!.modulesRecentActivityHint(events.length), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -292,7 +294,8 @@ String _formatTimestamp(DateTime local) { final mm = local.minute.toString().padLeft(2, '0'); final ss = local.second.toString().padLeft(2, '0'); final time = '$hh:$mm:$ss'; - final sameDay = local.year == now.year && + final sameDay = + local.year == now.year && local.month == now.month && local.day == now.day; if (sameDay) return time; diff --git a/lib/theme/theme.dart b/lib/theme/theme.dart index 53c118b..67fb7bd 100644 --- a/lib/theme/theme.dart +++ b/lib/theme/theme.dart @@ -33,10 +33,7 @@ class FaiTheme { fontWeight: FontWeight.w600, letterSpacing: 0, ), - titleSmall: GoogleFonts.inter( - fontSize: 13, - fontWeight: FontWeight.w500, - ), + titleSmall: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500), bodyLarge: GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w400, @@ -52,10 +49,7 @@ class FaiTheme { fontWeight: FontWeight.w400, height: 1.4, ), - labelMedium: GoogleFonts.inter( - fontSize: 12, - fontWeight: FontWeight.w500, - ), + labelMedium: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w500), labelSmall: GoogleFonts.inter( fontSize: 11, fontWeight: FontWeight.w500, @@ -70,13 +64,12 @@ class FaiTheme { double size = 12, FontWeight weight = FontWeight.w400, Color? color, - }) => - GoogleFonts.jetBrainsMono( - fontSize: size, - fontWeight: weight, - color: color, - height: 1.4, - ); + }) => GoogleFonts.jetBrainsMono( + fontSize: size, + fontWeight: weight, + color: color, + height: 1.4, + ); /// Shared markdown style used by every inline doc renderer /// (Welcome DocReaderSheet, Store module-detail DocsPanel, @@ -107,9 +100,7 @@ class FaiTheme { code: mono( size: 12, color: theme.colorScheme.onSurface, - ).copyWith( - backgroundColor: Colors.transparent, - ), + ).copyWith(backgroundColor: Colors.transparent), codeblockDecoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), @@ -123,10 +114,7 @@ class FaiTheme { color: theme.colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border( - left: BorderSide( - color: theme.colorScheme.primary, - width: 3, - ), + left: BorderSide(color: theme.colorScheme.primary, width: 3), ), ), blockquotePadding: const EdgeInsets.fromLTRB( @@ -282,20 +270,22 @@ class FaiTheme { ), ), iconButtonTheme: IconButtonThemeData( - style: IconButton.styleFrom( - foregroundColor: scheme.onSurfaceVariant, - ), + style: IconButton.styleFrom(foregroundColor: scheme.onSurfaceVariant), ), navigationRailTheme: NavigationRailThemeData( backgroundColor: scheme.surfaceContainerLow, indicatorColor: scheme.primaryContainer, selectedIconTheme: IconThemeData(color: scheme.primary, size: 22), - unselectedIconTheme: - IconThemeData(color: scheme.onSurfaceVariant, size: 22), - selectedLabelTextStyle: - textTheme.labelMedium?.copyWith(color: scheme.primary), - unselectedLabelTextStyle: textTheme.labelMedium - ?.copyWith(color: scheme.onSurfaceVariant), + unselectedIconTheme: IconThemeData( + color: scheme.onSurfaceVariant, + size: 22, + ), + selectedLabelTextStyle: textTheme.labelMedium?.copyWith( + color: scheme.primary, + ), + unselectedLabelTextStyle: textTheme.labelMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), useIndicator: true, ), dividerColor: scheme.outlineVariant, @@ -306,8 +296,9 @@ class FaiTheme { ), snackBarTheme: SnackBarThemeData( backgroundColor: scheme.surfaceContainerHigh, - contentTextStyle: - textTheme.bodyMedium?.copyWith(color: scheme.onSurface), + contentTextStyle: textTheme.bodyMedium?.copyWith( + color: scheme.onSurface, + ), behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(FaiRadius.sm), diff --git a/lib/widgets/fai_delta_mark.dart b/lib/widgets/fai_delta_mark.dart index 604488e..49d19ee 100644 --- a/lib/widgets/fai_delta_mark.dart +++ b/lib/widgets/fai_delta_mark.dart @@ -137,8 +137,7 @@ class _DeltaPainter extends CustomPainter { final pulseStrength = mode == FaiDeltaMode.live ? t : 1.0; final glow = Paint() ..color = color.withValues(alpha: 0.30 + 0.40 * pulseStrength) - ..maskFilter = - MaskFilter.blur(BlurStyle.normal, 8 + 14 * pulseStrength) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, 8 + 14 * pulseStrength) ..style = PaintingStyle.fill; canvas.drawPath(path, glow); } diff --git a/lib/widgets/fai_en_badge.dart b/lib/widgets/fai_en_badge.dart index 42b837d..9af93d0 100644 --- a/lib/widgets/fai_en_badge.dart +++ b/lib/widgets/fai_en_badge.dart @@ -43,9 +43,7 @@ class FaiEnBadge extends StatelessWidget { decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(FaiRadius.sm), - border: Border.all( - color: theme.colorScheme.outlineVariant, - ), + border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Text( 'EN', diff --git a/lib/widgets/fai_error_box.dart b/lib/widgets/fai_error_box.dart index c413ce0..fecad0c 100644 --- a/lib/widgets/fai_error_box.dart +++ b/lib/widgets/fai_error_box.dart @@ -20,15 +20,18 @@ class FaiErrorBox extends StatefulWidget { /// monospace, selectable, multi-line. Ignored when [error] is /// supplied. final String text; + /// When true, the box border + foreground colour come from the /// error palette. False renders neutral chrome — useful for /// success / informational copy that still wants the copy /// button. final bool isError; + /// Optional max-height before the content scrolls inside the /// box. Falls back to no constraint when null so short /// messages don't get a useless scrollbar. final double? maxHeight; + /// When non-null, the box renders the [friendlyError] mapping /// of this error: a one-line headline, a recovery-hint line /// (when one applies), and the verbatim original message @@ -45,9 +48,9 @@ class FaiErrorBox extends StatefulWidget { this.maxHeight, this.error, }) : assert( - text != '' || error != null, - 'FaiErrorBox needs either text or error', - ); + text != '' || error != null, + 'FaiErrorBox needs either text or error', + ); @override State createState() => _FaiErrorBoxState(); @@ -139,15 +142,15 @@ class _FaiErrorBoxState extends State { mainAxisSize: MainAxisSize.min, children: [ Icon( - _detailExpanded - ? Icons.expand_less - : Icons.expand_more, + _detailExpanded ? Icons.expand_less : Icons.expand_more, size: 14, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: 4), Text( - _detailExpanded ? l.buttonHideDetails : l.buttonShowDetails, + _detailExpanded + ? l.buttonHideDetails + : l.buttonShowDetails, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), diff --git a/lib/widgets/fai_flow_output.dart b/lib/widgets/fai_flow_output.dart index f8c4239..72af67a 100644 --- a/lib/widgets/fai_flow_output.dart +++ b/lib/widgets/fai_flow_output.dart @@ -126,10 +126,7 @@ class _CodeBlock extends StatelessWidget { ), child: SelectableText( text, - style: FaiTheme.mono( - size: 12, - color: theme.colorScheme.onSurface, - ), + style: FaiTheme.mono(size: 12, color: theme.colorScheme.onSurface), ), ); } @@ -170,14 +167,14 @@ class _BytesView extends StatelessWidget { try { await File(path).writeAsBytes(bytes, flush: true); if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.flowsOutputSavedAt(path))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.flowsOutputSavedAt(path)))); } catch (e) { if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.flowsOutputSaveFailed('$e'))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.flowsOutputSaveFailed('$e')))); } } @@ -239,9 +236,9 @@ class _FileView extends StatelessWidget { final r = await SystemActions.openInOs(target); if (!context.mounted || r.ok) return; final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr)))); } @override @@ -261,10 +258,7 @@ class _FileView extends StatelessWidget { children: [ SelectableText( uri, - style: FaiTheme.mono( - size: 11, - color: theme.colorScheme.primary, - ), + style: FaiTheme.mono(size: 11, color: theme.colorScheme.primary), ), if (mimeType.isNotEmpty) ...[ const SizedBox(height: 4), diff --git a/lib/widgets/fai_module_sheet.dart b/lib/widgets/fai_module_sheet.dart index 2fe89d1..fe53e1a 100644 --- a/lib/widgets/fai_module_sheet.dart +++ b/lib/widgets/fai_module_sheet.dart @@ -25,9 +25,7 @@ class FaiModuleSheet extends StatefulWidget { backgroundColor: Theme.of(context).colorScheme.surfaceContainer, isScrollControlled: true, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(FaiRadius.md), - ), + borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)), ), builder: (_) => FaiModuleSheet(moduleName: name), ); @@ -56,8 +54,7 @@ class _FaiModuleSheetState extends State { // "remove the highest version", which is rarely what an // operator means when they've explicitly kept multiple // versions around. - final versions = - await HubService.instance.installedVersions(detail.name); + final versions = await HubService.instance.installedVersions(detail.name); if (!mounted) return; String? targetVersion; if (versions.length > 1) { @@ -101,9 +98,7 @@ class _FaiModuleSheetState extends State { ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l.modulesUninstalledToast(r.name, r.version)), - ), + SnackBar(content: Text(l.modulesUninstalledToast(r.name, r.version))), ); Navigator.pop(context, true); } catch (e) { @@ -138,8 +133,9 @@ class _FaiModuleSheetState extends State { Padding( padding: const EdgeInsets.all(FaiSpace.xl), child: Text( - AppLocalizations.of(context)! - .moduleSheetFailedToLoad(snapshot.error.toString()), + AppLocalizations.of( + context, + )!.moduleSheetFailedToLoad(snapshot.error.toString()), style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.error, ), diff --git a/lib/widgets/fai_pill.dart b/lib/widgets/fai_pill.dart index 9622ee2..2ab2261 100644 --- a/lib/widgets/fai_pill.dart +++ b/lib/widgets/fai_pill.dart @@ -6,13 +6,7 @@ import 'package:flutter/material.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; -enum FaiPillTone { - neutral, - accent, - success, - warning, - danger, -} +enum FaiPillTone { neutral, accent, success, warning, danger } class FaiPill extends StatelessWidget { final String label; @@ -36,10 +30,7 @@ class FaiPill extends StatelessWidget { ? FaiTheme.mono(size: 11, weight: FontWeight.w500, color: fg) : theme.textTheme.labelSmall?.copyWith(color: fg); return Container( - padding: const EdgeInsets.symmetric( - horizontal: FaiSpace.sm, - vertical: 3, - ), + padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 3), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(FaiRadius.sm), @@ -62,10 +53,7 @@ class FaiPill extends StatelessWidget { case FaiPillTone.neutral: return (scheme.surfaceContainerHighest, scheme.onSurfaceVariant); case FaiPillTone.accent: - return ( - scheme.primary.withValues(alpha: 0.15), - scheme.primary, - ); + return (scheme.primary.withValues(alpha: 0.15), scheme.primary); case FaiPillTone.success: return ( FaiColors.success.withValues(alpha: 0.15), @@ -81,10 +69,7 @@ class FaiPill extends StatelessWidget { : const Color(0xFFB45309), ); case FaiPillTone.danger: - return ( - scheme.errorContainer, - scheme.onErrorContainer, - ); + return (scheme.errorContainer, scheme.onErrorContainer); } } } diff --git a/lib/widgets/fai_search_palette.dart b/lib/widgets/fai_search_palette.dart index 9850caf..776b3bb 100644 --- a/lib/widgets/fai_search_palette.dart +++ b/lib/widgets/fai_search_palette.dart @@ -21,12 +21,15 @@ class FaiSearchHit { final String label; final String hint; final IconData icon; + /// Free-form group used for the section header in the /// results list ("Pages", "Modules", "Store", "Flows"). final String group; + /// Lowercase haystack — name + hint + group. Filtered against /// the lowercased query. final String haystack; + /// Action to run when this hit is selected. final VoidCallback onSelect; @@ -103,60 +106,67 @@ class _FaiSearchPaletteState extends State { final hits = []; for (final m in modules) { - hits.add(FaiSearchHit( - label: m.name, - hint: l.searchInstalledModuleHint(m.version), - icon: Icons.extension, - group: l.searchGroupModules, - onSelect: () { - Navigator.pop(context); - FaiModuleSheet.show(context, m.name); - }, - )); + hits.add( + FaiSearchHit( + label: m.name, + hint: l.searchInstalledModuleHint(m.version), + icon: Icons.extension, + group: l.searchGroupModules, + onSelect: () { + Navigator.pop(context); + FaiModuleSheet.show(context, m.name); + }, + ), + ); } final storeGroup = l.searchGroupStore; final pagesGroup = l.searchGroupPages; for (final s in store) { - hits.add(FaiSearchHit( - label: s.name, - hint: s.taglineEn.isEmpty - ? l.searchStoreHintWithCategory( - s.category.isEmpty ? l.searchStoreUncategorized : s.category) - : l.searchStoreHintWithTagline(s.taglineEn), - icon: Icons.storefront_outlined, - group: storeGroup, - // Selecting a store hit just closes the palette and - // focuses the Store tab; deep-linking to the detail - // sheet would require thread-through plumbing we don't - // have yet. - onSelect: () { - Navigator.pop(context); - for (final h in widget.staticHits) { - if (h.label == storeGroup && h.group == pagesGroup) { - h.onSelect(); - return; + hits.add( + FaiSearchHit( + label: s.name, + hint: s.taglineEn.isEmpty + ? l.searchStoreHintWithCategory( + s.category.isEmpty ? l.searchStoreUncategorized : s.category, + ) + : l.searchStoreHintWithTagline(s.taglineEn), + icon: Icons.storefront_outlined, + group: storeGroup, + // Selecting a store hit just closes the palette and + // focuses the Store tab; deep-linking to the detail + // sheet would require thread-through plumbing we don't + // have yet. + onSelect: () { + Navigator.pop(context); + for (final h in widget.staticHits) { + if (h.label == storeGroup && h.group == pagesGroup) { + h.onSelect(); + return; + } } - } - }, - )); + }, + ), + ); } final flowsGroup = l.searchGroupFlows; for (final f in flows) { - hits.add(FaiSearchHit( - label: f.name, - hint: l.searchSavedFlowHint, - icon: Icons.account_tree_outlined, - group: flowsGroup, - onSelect: () { - Navigator.pop(context); - for (final h in widget.staticHits) { - if (h.label == flowsGroup && h.group == pagesGroup) { - h.onSelect(); - return; + hits.add( + FaiSearchHit( + label: f.name, + hint: l.searchSavedFlowHint, + icon: Icons.account_tree_outlined, + group: flowsGroup, + onSelect: () { + Navigator.pop(context); + for (final h in widget.staticHits) { + if (h.label == flowsGroup && h.group == pagesGroup) { + h.onSelect(); + return; + } } - } - }, - )); + }, + ), + ); } return hits; } @@ -211,9 +221,7 @@ class _FaiSearchPaletteState extends State { return Dialog( alignment: Alignment.topCenter, insetPadding: const EdgeInsets.only(top: 80, left: 24, right: 24), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 640, maxHeight: 540), child: FutureBuilder>( @@ -240,17 +248,17 @@ class _FaiSearchPaletteState extends State { controller: _query, autofocus: true, decoration: InputDecoration( - prefixIcon: - const Icon(Icons.search, size: 20), + prefixIcon: const Icon(Icons.search, size: 20), hintText: l.searchHint, border: InputBorder.none, - suffixIcon: snap.connectionState == - ConnectionState.waiting + suffixIcon: + snap.connectionState == ConnectionState.waiting ? const SizedBox( width: 14, height: 14, - child: - CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + ), ) : null, ), @@ -283,11 +291,10 @@ class _FaiSearchPaletteState extends State { itemBuilder: (context, i) { final h = hits[i]; final highlighted = i == _highlight; - final showHeader = i == 0 || - hits[i - 1].group != h.group; + final showHeader = + i == 0 || hits[i - 1].group != h.group; return Column( - crossAxisAlignment: - CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showHeader) Padding( @@ -301,11 +308,12 @@ class _FaiSearchPaletteState extends State { h.group.toUpperCase(), style: theme.textTheme.labelSmall ?.copyWith( - color: theme - .colorScheme.onSurfaceVariant, - letterSpacing: 0.6, - fontSize: 10, - ), + color: theme + .colorScheme + .onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), ), ), InkWell( @@ -317,8 +325,9 @@ class _FaiSearchPaletteState extends State { }, child: Container( color: highlighted - ? theme.colorScheme - .surfaceContainerHigh + ? theme + .colorScheme + .surfaceContainerHigh : null, padding: const EdgeInsets.symmetric( horizontal: 16, @@ -340,21 +349,24 @@ class _FaiSearchPaletteState extends State { Text( h.label, style: theme - .textTheme.bodyMedium + .textTheme + .bodyMedium ?.copyWith( - fontWeight: - FontWeight.w500, - ), + fontWeight: + FontWeight.w500, + ), ), if (h.hint.isNotEmpty) Text( h.hint, - style: theme.textTheme + style: theme + .textTheme .bodySmall ?.copyWith( - color: theme.colorScheme - .onSurfaceVariant, - ), + color: theme + .colorScheme + .onSurfaceVariant, + ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -367,7 +379,8 @@ class _FaiSearchPaletteState extends State { Icons.keyboard_return, size: 14, color: theme - .colorScheme.onSurfaceVariant, + .colorScheme + .onSurfaceVariant, ), ], ), diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index c1fdb5c..98785f3 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -9,14 +9,13 @@ import '../data/hub.dart'; import '../data/hub_auth_token.dart'; import '../data/registry_token.dart'; import '../data/system_actions.dart'; -import '../data/theme_plugin.dart'; -import '../main.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import 'fai_error_box.dart'; import 'fai_pill.dart'; import 'fai_system_ai_editor.dart'; +import 'theme_picker_grid.dart'; class FaiSettingsDialog extends StatefulWidget { const FaiSettingsDialog({super.key}); @@ -45,10 +44,12 @@ class _FaiSettingsDialogState extends State { SystemAiStatus? _aiStatus; List? _mcpClients; List? _n8nEndpoints; + /// Length of the configured registry token, or null when the /// `~/.fai/registry-token` file is missing / empty. Reloaded /// after save / clear. int? _registryTokenChars; + /// Length of the configured hub gRPC bearer token, or null /// when `~/.fai/hub-auth-token` is missing / empty. Reloaded /// after save / clear; UI only ever sees the trimmed length. @@ -74,7 +75,9 @@ class _FaiSettingsDialogState extends State { final n = await HubAuthToken.charCount(); if (!mounted) return; setState(() => _hubAuthTokenChars = n); - } catch (_) {/* fail-quiet */} + } catch (_) { + /* fail-quiet */ + } } Future _saveHubAuthToken(String token) async { @@ -84,9 +87,9 @@ class _FaiSettingsDialogState extends State { await _loadHubAuthToken(); if (!mounted) return; final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.hubAuthTokenSavedToast)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.hubAuthTokenSavedToast))); } catch (e) { if (!mounted) return; final l = AppLocalizations.of(context)!; @@ -103,9 +106,9 @@ class _FaiSettingsDialogState extends State { await _loadHubAuthToken(); if (!mounted) return; final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.hubAuthTokenClearedToast)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.hubAuthTokenClearedToast))); } catch (e) { if (!mounted) return; final l = AppLocalizations.of(context)!; @@ -120,7 +123,9 @@ class _FaiSettingsDialogState extends State { final n = await RegistryToken.charCount(); if (!mounted) return; setState(() => _registryTokenChars = n); - } catch (_) {/* fail-quiet */} + } catch (_) { + /* fail-quiet */ + } } Future _saveRegistryToken(String token) async { @@ -129,9 +134,9 @@ class _FaiSettingsDialogState extends State { await _loadRegistryToken(); if (!mounted) return; final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.registryTokenSavedToast)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.registryTokenSavedToast))); } catch (e) { if (!mounted) return; final l = AppLocalizations.of(context)!; @@ -147,9 +152,9 @@ class _FaiSettingsDialogState extends State { await _loadRegistryToken(); if (!mounted) return; final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.registryTokenClearedToast)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.registryTokenClearedToast))); } catch (e) { if (!mounted) return; final l = AppLocalizations.of(context)!; @@ -164,7 +169,9 @@ class _FaiSettingsDialogState extends State { final list = await HubService.instance.listN8nEndpoints(); if (!mounted) return; setState(() => _n8nEndpoints = list); - } catch (_) {/* fail-quiet */} + } catch (_) { + /* fail-quiet */ + } } Future _addN8nEndpoint(N8nEndpointDraft draft) async { @@ -178,15 +185,15 @@ class _FaiSettingsDialogState extends State { if (!mounted) return; setState(() => _n8nEndpoints = list); final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.addedN8nToast(draft.name))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.addedN8nToast(draft.name)))); } catch (e) { if (!mounted) return; final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.addFailedToast(e.toString()))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString())))); } } @@ -239,15 +246,15 @@ class _FaiSettingsDialogState extends State { if (!mounted) return; setState(() => _mcpClients = list); final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.addedMcpToast(draft.name))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.addedMcpToast(draft.name)))); } catch (e) { if (!mounted) return; final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.addFailedToast(e.toString()))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString())))); } } @@ -402,197 +409,201 @@ class _FaiSettingsDialogState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - l.settingsHubEndpointHint, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: FaiSpace.lg), - TextField( - controller: _host, - decoration: InputDecoration( - labelText: l.settingsHost, - border: const OutlineInputBorder(), - isDense: true, - ), - autofocus: true, - ), - const SizedBox(height: FaiSpace.md), - Row( - children: [ - Expanded( - flex: 2, - child: TextField( - controller: _port, - decoration: InputDecoration( - labelText: l.settingsPort, - border: const OutlineInputBorder(), - isDense: true, - ), - keyboardType: TextInputType.number, - ), - ), - const SizedBox(width: FaiSpace.md), - Expanded( - flex: 3, - child: SwitchListTile( - contentPadding: EdgeInsets.zero, - dense: true, - title: Text(l.settingsTls), - subtitle: Text( - l.settingsTlsSubtitle, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - value: _secure, - onChanged: (v) => setState(() => _secure = v), - ), - ), - ], - ), - if (_error != null) ...[ - const SizedBox(height: FaiSpace.md), Text( - _error!, + l.settingsHubEndpointHint, style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.error, + color: theme.colorScheme.onSurfaceVariant, ), ), - ], - const SizedBox(height: FaiSpace.md), - Container( - padding: const EdgeInsets.all(FaiSpace.sm), - decoration: BoxDecoration( - color: theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(FaiRadius.sm), + const SizedBox(height: FaiSpace.lg), + TextField( + controller: _host, + decoration: InputDecoration( + labelText: l.settingsHost, + border: const OutlineInputBorder(), + isDense: true, + ), + autofocus: true, ), - child: Row( + const SizedBox(height: FaiSpace.md), + Row( children: [ - Icon( - Icons.link, - size: 14, - color: theme.colorScheme.onSurfaceVariant, + Expanded( + flex: 2, + child: TextField( + controller: _port, + decoration: InputDecoration( + labelText: l.settingsPort, + border: const OutlineInputBorder(), + isDense: true, + ), + keyboardType: TextInputType.number, + ), ), - const SizedBox(width: FaiSpace.sm), - Text( - _previewUrl(), - style: FaiTheme.mono( - size: 11, - color: theme.colorScheme.onSurface, + const SizedBox(width: FaiSpace.md), + Expanded( + flex: 3, + child: SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: Text(l.settingsTls), + subtitle: Text( + l.settingsTlsSubtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + value: _secure, + onChanged: (v) => setState(() => _secure = v), ), ), ], ), - ), - if (_channels != null) ...[ - const SizedBox(height: FaiSpace.lg), - Text( - l.channelsHeader, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - letterSpacing: 0.6, - fontSize: 10, + if (_error != null) ...[ + const SizedBox(height: FaiSpace.md), + Text( + _error!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + const SizedBox(height: FaiSpace.md), + Container( + padding: const EdgeInsets.all(FaiSpace.sm), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + ), + child: Row( + children: [ + Icon( + Icons.link, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: FaiSpace.sm), + Text( + _previewUrl(), + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + ), + if (_channels != null) ...[ + const SizedBox(height: FaiSpace.lg), + Text( + l.channelsHeader, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ), + const SizedBox(height: 2), + Text( + l.channelsBlurb, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.sm), + for (final ch in _channels!.channels) + _ChannelRow( + channel: ch, + active: ch.name == _channels!.active, + onConnect: _saving ? null : () => _connectToChannel(ch), + onSwitch: _saving ? null : () => _switchChannel(ch.name), + onEnableAutostart: _saving + ? null + : () => _runDaemon( + 'enable autostart', + () => SystemActions.faiDaemonEnable(ch.name), + ), + onDisableAutostart: _saving + ? null + : () => _runDaemon( + 'disable autostart', + () => SystemActions.faiDaemonDisable(ch.name), + ), + ), + if (_channelToast != null) ...[ + const SizedBox(height: FaiSpace.sm), + FaiErrorBox(text: _channelToast!, maxHeight: 200), + ], + ], + if (_aiStatus != null) ...[ + const SizedBox(height: FaiSpace.lg), + _SystemAiPanel( + status: _aiStatus!, + onEdit: () async { + final updated = await FaiSystemAiEditor.show( + context, + _aiStatus!, + ); + if (updated != null && mounted) { + setState(() => _aiStatus = updated); + } + }, + ), + ], + if (_mcpClients != null) ...[ + const SizedBox(height: FaiSpace.lg), + _McpClientsPanel( + clients: _mcpClients!, + onAdd: _addMcpClient, + onRemove: _removeMcpClient, + onRefresh: _refreshMcpClients, + ), + ], + if (_n8nEndpoints != null) ...[ + const SizedBox(height: FaiSpace.lg), + _N8nEndpointsPanel( + endpoints: _n8nEndpoints!, + onAdd: _addN8nEndpoint, + onRemove: _removeN8nEndpoint, + onRefresh: _refreshN8nEndpoints, ), - ), - const SizedBox(height: 2), - Text( - l.channelsBlurb, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: FaiSpace.sm), - for (final ch in _channels!.channels) - _ChannelRow( - channel: ch, - active: ch.name == _channels!.active, - onConnect: _saving ? null : () => _connectToChannel(ch), - onSwitch: _saving ? null : () => _switchChannel(ch.name), - onEnableAutostart: _saving ? null : () => _runDaemon( - 'enable autostart', - () => SystemActions.faiDaemonEnable(ch.name), - ), - onDisableAutostart: _saving ? null : () => _runDaemon( - 'disable autostart', - () => SystemActions.faiDaemonDisable(ch.name), - ), - ), - if (_channelToast != null) ...[ - const SizedBox(height: FaiSpace.sm), - FaiErrorBox(text: _channelToast!, maxHeight: 200), ], - ], - if (_aiStatus != null) ...[ const SizedBox(height: FaiSpace.lg), - _SystemAiPanel( - status: _aiStatus!, - onEdit: () async { - final updated = await FaiSystemAiEditor.show( - context, - _aiStatus!, + _RegistryCredentialsPanel( + configuredChars: _registryTokenChars, + onSave: _saveRegistryToken, + onClear: _clearRegistryToken, + ), + const SizedBox(height: FaiSpace.lg), + _HubAuthTokenPanel( + configuredChars: _hubAuthTokenChars, + onSave: _saveHubAuthToken, + onClear: _clearHubAuthToken, + ), + const SizedBox(height: FaiSpace.lg), + const _DefaultScopePanel(), + const SizedBox(height: FaiSpace.lg), + const _ThemePluginPanel(), + const SizedBox(height: FaiSpace.lg), + _MaintenancePanel( + onResetDone: () async { + // Daemon just restarted under the same channel + + // port. Bouncing the gRPC connection picks up the + // fresh state without the operator having to + // close+reopen Settings. + await HubService.instance.reconnect( + HubService.instance.currentEndpoint, ); - if (updated != null && mounted) { - setState(() => _aiStatus = updated); - } + if (!mounted) return; + await _loadChannels(); + await _loadAiStatus(); + await _loadMcpClients(); + await _loadN8nEndpoints(); + await _loadRegistryToken(); }, ), ], - if (_mcpClients != null) ...[ - const SizedBox(height: FaiSpace.lg), - _McpClientsPanel( - clients: _mcpClients!, - onAdd: _addMcpClient, - onRemove: _removeMcpClient, - onRefresh: _refreshMcpClients, - ), - ], - if (_n8nEndpoints != null) ...[ - const SizedBox(height: FaiSpace.lg), - _N8nEndpointsPanel( - endpoints: _n8nEndpoints!, - onAdd: _addN8nEndpoint, - onRemove: _removeN8nEndpoint, - onRefresh: _refreshN8nEndpoints, - ), - ], - const SizedBox(height: FaiSpace.lg), - _RegistryCredentialsPanel( - configuredChars: _registryTokenChars, - onSave: _saveRegistryToken, - onClear: _clearRegistryToken, - ), - const SizedBox(height: FaiSpace.lg), - _HubAuthTokenPanel( - configuredChars: _hubAuthTokenChars, - onSave: _saveHubAuthToken, - onClear: _clearHubAuthToken, - ), - const SizedBox(height: FaiSpace.lg), - const _DefaultScopePanel(), - const SizedBox(height: FaiSpace.lg), - const _ThemePluginPanel(), - const SizedBox(height: FaiSpace.lg), - _MaintenancePanel( - onResetDone: () async { - // Daemon just restarted under the same channel + - // port. Bouncing the gRPC connection picks up the - // fresh state without the operator having to - // close+reopen Settings. - await HubService.instance.reconnect( - HubService.instance.currentEndpoint, - ); - if (!mounted) return; - await _loadChannels(); - await _loadAiStatus(); - await _loadMcpClients(); - await _loadN8nEndpoints(); - await _loadRegistryToken(); - }, - ), - ], - ), + ), ), ), actions: [ @@ -616,8 +627,7 @@ class _FaiSettingsDialogState extends State { String _previewUrl() { final scheme = _secure ? 'https' : 'http'; - final host = - _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(); + final host = _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(); final port = _port.text.trim().isEmpty ? '50051' : _port.text.trim(); return '$scheme://$host:$port'; } @@ -627,6 +637,7 @@ class _ChannelRow extends StatelessWidget { final ChannelInfo channel; final bool active; final VoidCallback? onConnect; + /// Switches the active channel pointer (`fai channel switch`). /// Distinct from [onConnect], which only re-points Studio's /// gRPC wire at a different running daemon. @@ -691,7 +702,10 @@ class _ChannelRow extends StatelessWidget { if (active) Padding( padding: const EdgeInsets.only(right: FaiSpace.sm), - child: FaiPill(label: l.channelsActive, tone: FaiPillTone.success), + child: FaiPill( + label: l.channelsActive, + tone: FaiPillTone.success, + ), ), PopupMenuButton( tooltip: l.channelsActionsTooltip, @@ -716,18 +730,12 @@ class _ChannelRow extends StatelessWidget { PopupMenuItem( value: 'connect', enabled: channel.running && onConnect != null, - child: _MenuRow( - icon: Icons.link, - text: l.channelsConnect, - ), + child: _MenuRow(icon: Icons.link, text: l.channelsConnect), ), PopupMenuItem( value: 'switch', enabled: !active && onSwitch != null, - child: _MenuRow( - icon: Icons.swap_horiz, - text: l.channelsSwitch, - ), + child: _MenuRow(icon: Icons.swap_horiz, text: l.channelsSwitch), ), const PopupMenuDivider(), PopupMenuItem( @@ -812,7 +820,9 @@ class _SystemAiPanel extends StatelessWidget { TextButton.icon( onPressed: onEdit, icon: const Icon(Icons.edit_outlined, size: 14), - label: Text(status.enabled ? l.systemAiEdit : l.systemAiConfigure), + label: Text( + status.enabled ? l.systemAiEdit : l.systemAiConfigure, + ), ), ], ), @@ -822,7 +832,11 @@ class _SystemAiPanel extends StatelessWidget { _StatRow(label: 'endpoint', value: status.endpoint, mono: true), _StatRow(label: 'model', value: status.model, mono: true), if (status.apiKeyEnv.isNotEmpty) - _StatRow(label: 'api_key_env', value: '\$${status.apiKeyEnv}', mono: true), + _StatRow( + label: 'api_key_env', + value: '\$${status.apiKeyEnv}', + mono: true, + ), ] else ...[ Padding( padding: const EdgeInsets.only(top: 2), @@ -844,11 +858,7 @@ class _StatRow extends StatelessWidget { final String value; final bool mono; - const _StatRow({ - required this.label, - required this.value, - this.mono = false, - }); + const _StatRow({required this.label, required this.value, this.mono = false}); @override Widget build(BuildContext context) { @@ -974,10 +984,7 @@ class _McpClientsPanel extends StatelessWidget { ) else for (final c in clients) - _McpClientRow( - client: c, - onRemove: () => onRemove(c.name), - ), + _McpClientRow(client: c, onRemove: () => onRemove(c.name)), ], ); } @@ -1309,10 +1316,7 @@ class _N8nEndpointsPanel extends StatelessWidget { ) else for (final e in endpoints) - _N8nEndpointRow( - endpoint: e, - onRemove: () => onRemove(e.name), - ), + _N8nEndpointRow(endpoint: e, onRemove: () => onRemove(e.name)), ], ); } @@ -1497,7 +1501,8 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> { ), FilledButton( onPressed: () { - if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty) return; + if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty) + return; Navigator.pop( context, N8nEndpointDraft( @@ -1529,27 +1534,13 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> { /// When no theme plugin is installed the panel renders a /// short hint pointing at the Store rather than a useless /// empty dropdown. -class _ThemePluginPanel extends StatefulWidget { +class _ThemePluginPanel extends StatelessWidget { const _ThemePluginPanel(); - @override - State<_ThemePluginPanel> createState() => _ThemePluginPanelState(); -} - -class _ThemePluginPanelState extends State<_ThemePluginPanel> { - Future>? _capsFuture; - - @override - void initState() { - super.initState(); - _capsFuture = listThemePluginCapabilities(); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; - final app = StudioApp.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1561,76 +1552,12 @@ class _ThemePluginPanelState extends State<_ThemePluginPanel> { fontSize: 10, ), ), - const SizedBox(height: 4), - FutureBuilder>( - 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 []; - 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( - value: null, - child: Text(l.themePluginNone), - ), - for (final cap in caps) - DropdownMenuItem( - 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( - value: active, - items: items, - isDense: true, - onChanged: app == null - ? null - : (v) { - app.setThemePlugin(v); - setState(() {}); - }, - ), - const SizedBox(height: 4), - Text( - l.themePluginHint, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ); - }, - ), + const SizedBox(height: 8), + // Rich grid of theme tiles. Each shows live swatches + // from the plugin's own palette so the operator can + // preview without applying. The "Custom" tile opens + // a seed-colour dialog. + const ThemePickerGrid(), ], ); } @@ -1782,11 +1709,7 @@ class _MaintenancePanelState extends State<_MaintenancePanel> { ), if (_resultText != null) ...[ const SizedBox(height: FaiSpace.sm), - FaiErrorBox( - text: _resultText!, - isError: !_resultOk, - maxHeight: 240, - ), + FaiErrorBox(text: _resultText!, isError: !_resultOk, maxHeight: 240), ], ], ); @@ -1822,8 +1745,7 @@ class _RegistryCredentialsPanel extends StatefulWidget { _RegistryCredentialsPanelState(); } -class _RegistryCredentialsPanelState - extends State<_RegistryCredentialsPanel> { +class _RegistryCredentialsPanelState extends State<_RegistryCredentialsPanel> { final _controller = TextEditingController(); bool _obscured = true; @@ -2017,9 +1939,7 @@ class _HubAuthTokenPanelState extends State<_HubAuthTokenPanel> { Row( children: [ Icon( - isConfigured - ? Icons.check_circle - : Icons.radio_button_unchecked, + isConfigured ? Icons.check_circle : Icons.radio_button_unchecked, size: 14, color: isConfigured ? theme.colorScheme.primary @@ -2066,8 +1986,7 @@ class _HubAuthTokenPanelState extends State<_HubAuthTokenPanel> { _obscured ? Icons.visibility : Icons.visibility_off, size: 16, ), - onPressed: () => - setState(() => _obscured = !_obscured), + onPressed: () => setState(() => _obscured = !_obscured), ), ), ), @@ -2103,6 +2022,7 @@ class _McpSuggestion { final IconData icon; final String endpoint; final String apiKeyEnv; + /// Localizable description. Use [resolveDescription] to fetch /// the actual text in the active locale — the const list /// can't hold a closure that takes [AppLocalizations], and @@ -2305,9 +2225,9 @@ class _DefaultScopePanelState extends State<_DefaultScopePanel> { _saving = false; }); final l = AppLocalizations.of(context)!; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l.defaultScopeSavedToast)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l.defaultScopeSavedToast))); } catch (e) { if (!mounted) return; setState(() { @@ -2333,8 +2253,9 @@ class _DefaultScopePanelState extends State<_DefaultScopePanel> { if (current.length == 1) { // Hub rejects empty; surface the constraint inline rather // than letting the RPC fail. - setState(() => _error = AppLocalizations.of(context)! - .defaultScopeNonEmptyError); + setState( + () => _error = AppLocalizations.of(context)!.defaultScopeNonEmptyError, + ); return; } current.removeAt(i); @@ -2513,10 +2434,7 @@ class _ScopeRow extends StatelessWidget { ), ), Expanded( - child: Text( - entry, - style: const TextStyle(fontFamily: 'monospace'), - ), + child: Text(entry, style: const TextStyle(fontFamily: 'monospace')), ), IconButton( icon: const Icon(Icons.arrow_upward, size: 16), diff --git a/lib/widgets/fai_status_dot.dart b/lib/widgets/fai_status_dot.dart index f2dc948..136aac6 100644 --- a/lib/widgets/fai_status_dot.dart +++ b/lib/widgets/fai_status_dot.dart @@ -88,14 +88,8 @@ class _FaiStatusDotState extends State /// Convenience presets. class FaiStatusDots { FaiStatusDots._(); - static Widget live() => const FaiStatusDot( - color: FaiColors.success, - pulsing: true, - ); - static Widget idle() => const FaiStatusDot( - color: FaiColors.muted, - ); - static Widget down() => const FaiStatusDot( - color: FaiColors.danger, - ); + static Widget live() => + const FaiStatusDot(color: FaiColors.success, pulsing: true); + static Widget idle() => const FaiStatusDot(color: FaiColors.muted); + static Widget down() => const FaiStatusDot(color: FaiColors.danger); } diff --git a/lib/widgets/fai_system_ai_editor.dart b/lib/widgets/fai_system_ai_editor.dart index ce260e5..6ad98b1 100644 --- a/lib/widgets/fai_system_ai_editor.dart +++ b/lib/widgets/fai_system_ai_editor.dart @@ -83,10 +83,7 @@ class _ProviderPreset { ]; static _ProviderPreset byWire(String wire) { - return all.firstWhere( - (p) => p.wire == wire, - orElse: () => all.first, - ); + return all.firstWhere((p) => p.wire == wire, orElse: () => all.first); } } @@ -141,9 +138,7 @@ class _FaiSystemAiEditorState extends State { ); _model = TextEditingController(text: init.model); _apiKeyEnv = TextEditingController( - text: init.apiKeyEnv.isEmpty - ? _preset.defaultApiKeyEnv - : init.apiKeyEnv, + text: init.apiKeyEnv.isEmpty ? _preset.defaultApiKeyEnv : init.apiKeyEnv, ); _privacyMode = init.privacyMode.isEmpty ? 'redacted' : init.privacyMode; // Pull the model list immediately so the operator sees the @@ -376,10 +371,7 @@ class _FaiSystemAiEditorState extends State { _HardwareBanner(hw: _hw!, lastReviewed: _curated?.lastReviewed), ], const SizedBox(height: FaiSpace.lg), - _ProviderDropdown( - value: _preset, - onChanged: _onProviderChanged, - ), + _ProviderDropdown(value: _preset, onChanged: _onProviderChanged), const SizedBox(height: 4), Text( _preset.description, @@ -495,10 +487,7 @@ class _ProviderDropdown extends StatelessWidget { final _ProviderPreset value; final void Function(_ProviderPreset) onChanged; - const _ProviderDropdown({ - required this.value, - required this.onChanged, - }); + const _ProviderDropdown({required this.value, required this.onChanged}); @override Widget build(BuildContext context) { @@ -512,12 +501,7 @@ class _ProviderDropdown extends StatelessWidget { isDense: true, ), items: _ProviderPreset.all - .map( - (p) => DropdownMenuItem( - value: p.wire, - child: Text(p.label), - ), - ) + .map((p) => DropdownMenuItem(value: p.wire, child: Text(p.label))) .toList(), onChanged: (v) { if (v == null) return; @@ -531,10 +515,7 @@ class _PrivacyModeChips extends StatelessWidget { final String value; final ValueChanged onChanged; - const _PrivacyModeChips({ - required this.value, - required this.onChanged, - }); + const _PrivacyModeChips({required this.value, required this.onChanged}); @override Widget build(BuildContext context) { @@ -547,11 +528,7 @@ class _PrivacyModeChips extends StatelessWidget { l.systemAiPrivacyRedactedLabel, l.systemAiPrivacyRedactedDesc, ), - ( - 'full', - l.systemAiPrivacyFullLabel, - l.systemAiPrivacyFullDesc, - ), + ('full', l.systemAiPrivacyFullLabel, l.systemAiPrivacyFullDesc), ]; // Material 3 deprecated per-Radio `groupValue` / `onChanged` // after Flutter 3.32. The modern pattern wraps the radios in @@ -643,9 +620,7 @@ class _TestResultPanel extends StatelessWidget { const SizedBox(width: FaiSpace.xs), Text( ok ? l.systemAiConnectionOk : l.systemAiConnectionFailed, - style: theme.textTheme.bodyMedium?.copyWith( - color: color, - ), + style: theme.textTheme.bodyMedium?.copyWith(color: color), ), const Spacer(), if (ok && result.latencyMs > 0) @@ -659,10 +634,7 @@ class _TestResultPanel extends StatelessWidget { const SizedBox(height: 4), SelectableText( ok ? l.systemAiReplyPrefix(result.text) : result.text, - style: FaiTheme.mono( - size: 11, - color: theme.colorScheme.onSurface, - ), + style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface), ), if (!ok && result.fixHint.isNotEmpty) ...[ const SizedBox(height: FaiSpace.xs), @@ -686,12 +658,14 @@ class _ModelPicker extends StatelessWidget { final String? modelsError; final bool loading; final bool pulling; + /// Detected host hardware. Used by [_suitabilityFor] to mark /// curated models as "recommended" when their `min_hw_tier` is /// at or below the operator's tier. Null while the hub call is /// in flight or unreachable — chips fall back to the size-only /// heuristic. final HardwareSnapshot? hw; + /// Curated DB indexed by model id. Empty while the hub call is /// in flight; chips then fall back to the size-only heuristic. final Map curatedById; @@ -736,8 +710,8 @@ class _ModelPicker extends StatelessWidget { : preset.modelHint, helperText: controller.text.trim().isEmpty ? (_isOllama - ? l.systemAiModelHelperOllama - : l.systemAiModelHelperBase) + ? l.systemAiModelHelperOllama + : l.systemAiModelHelperBase) : null, helperMaxLines: 2, border: const OutlineInputBorder(), @@ -787,9 +761,7 @@ class _ModelPicker extends StatelessWidget { const SizedBox(height: FaiSpace.sm), if (models!.isEmpty) Text( - _isOllama - ? l.systemAiNoModelsOllama - : l.systemAiNoModelsGeneric, + _isOllama ? l.systemAiNoModelsOllama : l.systemAiNoModelsGeneric, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -970,6 +942,7 @@ List _sortedBySuitability( class _ModelChip extends StatelessWidget { final String id; final _Suitability suitability; + /// Curated entry for this id, when one exists. Drives the /// tooltip — operators get the editorial note ("Solid 4B /// all-rounder…") rather than just the suitability label. @@ -1010,9 +983,7 @@ class _ModelChip extends StatelessWidget { message: _tooltipFor(context), child: ActionChip( avatar: Icon( - suitability == _Suitability.recommended - ? Icons.star - : Icons.circle, + suitability == _Suitability.recommended ? Icons.star : Icons.circle, size: suitability == _Suitability.recommended ? 14 : 9, color: color, ), @@ -1041,10 +1012,7 @@ class _HardwareBanner extends StatelessWidget { ? l.systemAiHwReviewed(lastReviewed!) : ''; return Container( - padding: const EdgeInsets.symmetric( - horizontal: FaiSpace.sm, - vertical: 6, - ), + padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 6), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), @@ -1081,23 +1049,25 @@ class _SuitabilityLegend extends StatelessWidget { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; Widget dot(Color c, String label) => Padding( - padding: const EdgeInsets.only(right: FaiSpace.md), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.circle, size: 8, color: c), - const SizedBox(width: 4), - Text( - label, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - fontSize: 11, - ), - ), - ], + padding: const EdgeInsets.only(right: FaiSpace.md), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.circle, size: 8, color: c), + const SizedBox(width: 4), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 11, + ), ), - ); - final tierTag = (hw == null || hw!.tier == 'unknown') ? '' : ' (${hw!.tier})'; + ], + ), + ); + final tierTag = (hw == null || hw!.tier == 'unknown') + ? '' + : ' (${hw!.tier})'; return Wrap( crossAxisAlignment: WrapCrossAlignment.center, children: [ @@ -1143,10 +1113,7 @@ class _CacheStatusRow extends StatelessWidget { final theme = Theme.of(context); final l = AppLocalizations.of(context)!; return Container( - padding: const EdgeInsets.symmetric( - horizontal: FaiSpace.sm, - vertical: 6, - ), + padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 6), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(FaiRadius.sm), @@ -1154,11 +1121,7 @@ class _CacheStatusRow extends StatelessWidget { ), child: Row( children: [ - Icon( - Icons.bolt, - size: 14, - color: theme.colorScheme.onSurfaceVariant, - ), + Icon(Icons.bolt, size: 14, color: theme.colorScheme.onSurfaceVariant), const SizedBox(width: 6), Expanded( child: Text( diff --git a/lib/widgets/theme_picker_grid.dart b/lib/widgets/theme_picker_grid.dart new file mode 100644 index 0000000..7be766a --- /dev/null +++ b/lib/widgets/theme_picker_grid.dart @@ -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 createState() => _ThemePickerGridState(); +} + +class _ThemePickerGridState extends State { + late Future> _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 _previews = {}; + + @override + void initState() { + super.initState(); + _capsFuture = listThemePluginCapabilities(); + } + + Future _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>( + 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 []; + // 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 + ? [scheme.primary, scheme.secondary, scheme.tertiary] + : [ + 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 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 _showColorPickerDialog( + BuildContext context, { + required Color initial, +}) { + return showDialog( + 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 _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', + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 93788c0..4bd2e3a 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -125,10 +125,10 @@ packages: description: path: "." ref: main - resolved-ref: "3a6e92fe09ae8f423ab99d990c9459d937810717" + resolved-ref: "7b256b5e35204771b8eeca3deb1f4071d86a035b" url: "https://git.flemming.ai/fai/studio-flow-editor" source: git - version: "0.10.2" + version: "0.11.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ff8b637..c9b75f5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.57.3 +version: 0.58.0 environment: sdk: ^3.11.0-200.1.beta