feat(studio): rich theme picker (presets + custom colour) + editor 0.11.0
Some checks failed
Security / Security check (push) Failing after 1s
Some checks failed
Security / Security check (push) Failing after 1s
Settings dialog's Theme Plugin section is now a grid of swatched tiles: - Built-in (none) — falls back to FaiTheme.light/.dark - One tile per installed studio.theme.* plugin, each showing the plugin's primary/secondary/tertiary as live colour dots. Tile loads its preview lazily so a dozen installed themes don't block the picker. - Custom — opens a colour-picker dialog with 12 curated Material presets + a hex input + live preview. Selecting applies ColorScheme.fromSeed for both brightnesses. main.dart's _pluginThemes parses a 'custom:#RRGGBB' sigil in the same notifier slot as plugin capability ids, so the existing persistence + restoration paths cover the custom case with no new state. Bumps editor to 0.11.0 (type-checked port connections + dynamic card width fix + card-height border allowance) and Studio to 0.58.0. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
69864da934
commit
c1c60d5434
30 changed files with 1280 additions and 846 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,8 +39,9 @@ class ThemePluginSchemes {
|
|||
Future<List<String>> 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<int> tokens, Brightness brightness) {
|
|||
? const Color(0xFF1A1A1A)
|
||||
: const Color(0xFFEEEEEE);
|
||||
}
|
||||
|
||||
return ColorScheme(
|
||||
brightness: brightness,
|
||||
primary: slot(0),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
178
lib/main.dart
178
lib/main.dart
|
|
@ -37,16 +37,19 @@ Future<void> 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<StudioApp> {
|
|||
/// `MaterialApp(home: const StudioShell())` because the
|
||||
/// const child's element doesn't get a `didUpdateWidget`.
|
||||
late final ValueNotifier<ThemeModeValue> 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<Locale> 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<StudioApp> {
|
|||
/// 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<StudioApp> {
|
|||
valueListenable: localeNotifier,
|
||||
builder: (_, locale, _) => ValueListenableBuilder<String?>(
|
||||
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<StudioShell> {
|
|||
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<StudioShell> {
|
|||
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<Offset>(
|
||||
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<Offset>(
|
||||
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())),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
late final TabController _tab;
|
||||
late Future<List<ApprovalRecord>> _pendingFuture;
|
||||
late Future<List<ApprovalRecord>> _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<ApprovalsPage>
|
|||
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<ApprovalsPage>
|
|||
}
|
||||
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<ApprovalsPage>
|
|||
}
|
||||
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<ApprovalsPage>
|
|||
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<void>(
|
||||
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -28,10 +28,7 @@ class _AuditPageState extends State<AuditPage> {
|
|||
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<AuditPage> {
|
|||
_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<AuditPage> {
|
|||
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<AuditPage> {
|
|||
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<void>(
|
||||
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<AuditEvent> 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,
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ class _DoctorPageState extends State<DoctorPage> {
|
|||
}
|
||||
|
||||
void _refresh() => setState(() {
|
||||
_future = HubService.instance.doctor();
|
||||
});
|
||||
_future = HubService.instance.doctor();
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -62,9 +62,9 @@ class _DoctorPageState extends State<DoctorPage> {
|
|||
);
|
||||
}
|
||||
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<DoctorPage> {
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -48,11 +48,9 @@ class _FlowsPageState extends State<FlowsPage> {
|
|||
Future<List<String>> _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 <String>[];
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ class _ModulesPageState extends State<ModulesPage> {
|
|||
}
|
||||
|
||||
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<ModulesPage> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -137,8 +137,7 @@ class _DeltaPainter extends CustomPainter {
|
|||
final pulseStrength = mode == FaiDeltaMode.live ? t : 1.0;
|
||||
final glow = Paint()
|
||||
..color = color.withValues(alpha: 0.30 + 0.40 * pulseStrength)
|
||||
..maskFilter =
|
||||
MaskFilter.blur(BlurStyle.normal, 8 + 14 * pulseStrength)
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, 8 + 14 * pulseStrength)
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawPath(path, glow);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,9 +43,7 @@ class FaiEnBadge extends StatelessWidget {
|
|||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Text(
|
||||
'EN',
|
||||
|
|
|
|||
|
|
@ -20,15 +20,18 @@ class FaiErrorBox extends StatefulWidget {
|
|||
/// monospace, selectable, multi-line. Ignored when [error] is
|
||||
/// supplied.
|
||||
final String text;
|
||||
|
||||
/// When true, the box border + foreground colour come from the
|
||||
/// error palette. False renders neutral chrome — useful for
|
||||
/// success / informational copy that still wants the copy
|
||||
/// button.
|
||||
final bool isError;
|
||||
|
||||
/// Optional max-height before the content scrolls inside the
|
||||
/// box. Falls back to no constraint when null so short
|
||||
/// messages don't get a useless scrollbar.
|
||||
final double? maxHeight;
|
||||
|
||||
/// When non-null, the box renders the [friendlyError] mapping
|
||||
/// of this error: a one-line headline, a recovery-hint line
|
||||
/// (when one applies), and the verbatim original message
|
||||
|
|
@ -45,9 +48,9 @@ class FaiErrorBox extends StatefulWidget {
|
|||
this.maxHeight,
|
||||
this.error,
|
||||
}) : assert(
|
||||
text != '' || error != null,
|
||||
'FaiErrorBox needs either text or error',
|
||||
);
|
||||
text != '' || error != null,
|
||||
'FaiErrorBox needs either text or error',
|
||||
);
|
||||
|
||||
@override
|
||||
State<FaiErrorBox> createState() => _FaiErrorBoxState();
|
||||
|
|
@ -139,15 +142,15 @@ class _FaiErrorBoxState extends State<FaiErrorBox> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_detailExpanded
|
||||
? Icons.expand_less
|
||||
: Icons.expand_more,
|
||||
_detailExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_detailExpanded ? l.buttonHideDetails : l.buttonShowDetails,
|
||||
_detailExpanded
|
||||
? l.buttonHideDetails
|
||||
: l.buttonShowDetails,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -126,10 +126,7 @@ class _CodeBlock extends StatelessWidget {
|
|||
),
|
||||
child: SelectableText(
|
||||
text,
|
||||
style: FaiTheme.mono(
|
||||
size: 12,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
style: FaiTheme.mono(size: 12, color: theme.colorScheme.onSurface),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -170,14 +167,14 @@ class _BytesView extends StatelessWidget {
|
|||
try {
|
||||
await File(path).writeAsBytes(bytes, flush: true);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.flowsOutputSavedAt(path))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.flowsOutputSavedAt(path))));
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.flowsOutputSaveFailed('$e'))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.flowsOutputSaveFailed('$e'))));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,9 +236,9 @@ class _FileView extends StatelessWidget {
|
|||
final r = await SystemActions.openInOs(target);
|
||||
if (!context.mounted || r.ok) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.flowsOutputOpenFailed(r.stderr))));
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -261,10 +258,7 @@ class _FileView extends StatelessWidget {
|
|||
children: [
|
||||
SelectableText(
|
||||
uri,
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
style: FaiTheme.mono(size: 11, color: theme.colorScheme.primary),
|
||||
),
|
||||
if (mimeType.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
|
|
|
|||
|
|
@ -25,9 +25,7 @@ class FaiModuleSheet extends StatefulWidget {
|
|||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(FaiRadius.md),
|
||||
),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)),
|
||||
),
|
||||
builder: (_) => FaiModuleSheet(moduleName: name),
|
||||
);
|
||||
|
|
@ -56,8 +54,7 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
|
|||
// "remove the highest version", which is rarely what an
|
||||
// operator means when they've explicitly kept multiple
|
||||
// versions around.
|
||||
final versions =
|
||||
await HubService.instance.installedVersions(detail.name);
|
||||
final versions = await HubService.instance.installedVersions(detail.name);
|
||||
if (!mounted) return;
|
||||
String? targetVersion;
|
||||
if (versions.length > 1) {
|
||||
|
|
@ -101,9 +98,7 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
|
|||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l.modulesUninstalledToast(r.name, r.version)),
|
||||
),
|
||||
SnackBar(content: Text(l.modulesUninstalledToast(r.name, r.version))),
|
||||
);
|
||||
Navigator.pop(context, true);
|
||||
} catch (e) {
|
||||
|
|
@ -138,8 +133,9 @@ class _FaiModuleSheetState extends State<FaiModuleSheet> {
|
|||
Padding(
|
||||
padding: const EdgeInsets.all(FaiSpace.xl),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!
|
||||
.moduleSheetFailedToLoad(snapshot.error.toString()),
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.moduleSheetFailedToLoad(snapshot.error.toString()),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -6,13 +6,7 @@ import 'package:flutter/material.dart';
|
|||
import '../theme/theme.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
enum FaiPillTone {
|
||||
neutral,
|
||||
accent,
|
||||
success,
|
||||
warning,
|
||||
danger,
|
||||
}
|
||||
enum FaiPillTone { neutral, accent, success, warning, danger }
|
||||
|
||||
class FaiPill extends StatelessWidget {
|
||||
final String label;
|
||||
|
|
@ -36,10 +30,7 @@ class FaiPill extends StatelessWidget {
|
|||
? FaiTheme.mono(size: 11, weight: FontWeight.w500, color: fg)
|
||||
: theme.textTheme.labelSmall?.copyWith(color: fg);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.sm,
|
||||
vertical: 3,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
|
|
@ -62,10 +53,7 @@ class FaiPill extends StatelessWidget {
|
|||
case FaiPillTone.neutral:
|
||||
return (scheme.surfaceContainerHighest, scheme.onSurfaceVariant);
|
||||
case FaiPillTone.accent:
|
||||
return (
|
||||
scheme.primary.withValues(alpha: 0.15),
|
||||
scheme.primary,
|
||||
);
|
||||
return (scheme.primary.withValues(alpha: 0.15), scheme.primary);
|
||||
case FaiPillTone.success:
|
||||
return (
|
||||
FaiColors.success.withValues(alpha: 0.15),
|
||||
|
|
@ -81,10 +69,7 @@ class FaiPill extends StatelessWidget {
|
|||
: const Color(0xFFB45309),
|
||||
);
|
||||
case FaiPillTone.danger:
|
||||
return (
|
||||
scheme.errorContainer,
|
||||
scheme.onErrorContainer,
|
||||
);
|
||||
return (scheme.errorContainer, scheme.onErrorContainer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,12 +21,15 @@ class FaiSearchHit {
|
|||
final String label;
|
||||
final String hint;
|
||||
final IconData icon;
|
||||
|
||||
/// Free-form group used for the section header in the
|
||||
/// results list ("Pages", "Modules", "Store", "Flows").
|
||||
final String group;
|
||||
|
||||
/// Lowercase haystack — name + hint + group. Filtered against
|
||||
/// the lowercased query.
|
||||
final String haystack;
|
||||
|
||||
/// Action to run when this hit is selected.
|
||||
final VoidCallback onSelect;
|
||||
|
||||
|
|
@ -103,60 +106,67 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
|
||||
final hits = <FaiSearchHit>[];
|
||||
for (final m in modules) {
|
||||
hits.add(FaiSearchHit(
|
||||
label: m.name,
|
||||
hint: l.searchInstalledModuleHint(m.version),
|
||||
icon: Icons.extension,
|
||||
group: l.searchGroupModules,
|
||||
onSelect: () {
|
||||
Navigator.pop(context);
|
||||
FaiModuleSheet.show(context, m.name);
|
||||
},
|
||||
));
|
||||
hits.add(
|
||||
FaiSearchHit(
|
||||
label: m.name,
|
||||
hint: l.searchInstalledModuleHint(m.version),
|
||||
icon: Icons.extension,
|
||||
group: l.searchGroupModules,
|
||||
onSelect: () {
|
||||
Navigator.pop(context);
|
||||
FaiModuleSheet.show(context, m.name);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
final storeGroup = l.searchGroupStore;
|
||||
final pagesGroup = l.searchGroupPages;
|
||||
for (final s in store) {
|
||||
hits.add(FaiSearchHit(
|
||||
label: s.name,
|
||||
hint: s.taglineEn.isEmpty
|
||||
? l.searchStoreHintWithCategory(
|
||||
s.category.isEmpty ? l.searchStoreUncategorized : s.category)
|
||||
: l.searchStoreHintWithTagline(s.taglineEn),
|
||||
icon: Icons.storefront_outlined,
|
||||
group: storeGroup,
|
||||
// Selecting a store hit just closes the palette and
|
||||
// focuses the Store tab; deep-linking to the detail
|
||||
// sheet would require thread-through plumbing we don't
|
||||
// have yet.
|
||||
onSelect: () {
|
||||
Navigator.pop(context);
|
||||
for (final h in widget.staticHits) {
|
||||
if (h.label == storeGroup && h.group == pagesGroup) {
|
||||
h.onSelect();
|
||||
return;
|
||||
hits.add(
|
||||
FaiSearchHit(
|
||||
label: s.name,
|
||||
hint: s.taglineEn.isEmpty
|
||||
? l.searchStoreHintWithCategory(
|
||||
s.category.isEmpty ? l.searchStoreUncategorized : s.category,
|
||||
)
|
||||
: l.searchStoreHintWithTagline(s.taglineEn),
|
||||
icon: Icons.storefront_outlined,
|
||||
group: storeGroup,
|
||||
// Selecting a store hit just closes the palette and
|
||||
// focuses the Store tab; deep-linking to the detail
|
||||
// sheet would require thread-through plumbing we don't
|
||||
// have yet.
|
||||
onSelect: () {
|
||||
Navigator.pop(context);
|
||||
for (final h in widget.staticHits) {
|
||||
if (h.label == storeGroup && h.group == pagesGroup) {
|
||||
h.onSelect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
final flowsGroup = l.searchGroupFlows;
|
||||
for (final f in flows) {
|
||||
hits.add(FaiSearchHit(
|
||||
label: f.name,
|
||||
hint: l.searchSavedFlowHint,
|
||||
icon: Icons.account_tree_outlined,
|
||||
group: flowsGroup,
|
||||
onSelect: () {
|
||||
Navigator.pop(context);
|
||||
for (final h in widget.staticHits) {
|
||||
if (h.label == flowsGroup && h.group == pagesGroup) {
|
||||
h.onSelect();
|
||||
return;
|
||||
hits.add(
|
||||
FaiSearchHit(
|
||||
label: f.name,
|
||||
hint: l.searchSavedFlowHint,
|
||||
icon: Icons.account_tree_outlined,
|
||||
group: flowsGroup,
|
||||
onSelect: () {
|
||||
Navigator.pop(context);
|
||||
for (final h in widget.staticHits) {
|
||||
if (h.label == flowsGroup && h.group == pagesGroup) {
|
||||
h.onSelect();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
|
@ -211,9 +221,7 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
return Dialog(
|
||||
alignment: Alignment.topCenter,
|
||||
insetPadding: const EdgeInsets.only(top: 80, left: 24, right: 24),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 640, maxHeight: 540),
|
||||
child: FutureBuilder<List<FaiSearchHit>>(
|
||||
|
|
@ -240,17 +248,17 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
controller: _query,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon:
|
||||
const Icon(Icons.search, size: 20),
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
hintText: l.searchHint,
|
||||
border: InputBorder.none,
|
||||
suffixIcon: snap.connectionState ==
|
||||
ConnectionState.waiting
|
||||
suffixIcon:
|
||||
snap.connectionState == ConnectionState.waiting
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
|
|
@ -283,11 +291,10 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
itemBuilder: (context, i) {
|
||||
final h = hits[i];
|
||||
final highlighted = i == _highlight;
|
||||
final showHeader = i == 0 ||
|
||||
hits[i - 1].group != h.group;
|
||||
final showHeader =
|
||||
i == 0 || hits[i - 1].group != h.group;
|
||||
return Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showHeader)
|
||||
Padding(
|
||||
|
|
@ -301,11 +308,12 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
h.group.toUpperCase(),
|
||||
style: theme.textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: theme
|
||||
.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
fontSize: 10,
|
||||
),
|
||||
color: theme
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
|
|
@ -317,8 +325,9 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
},
|
||||
child: Container(
|
||||
color: highlighted
|
||||
? theme.colorScheme
|
||||
.surfaceContainerHigh
|
||||
? theme
|
||||
.colorScheme
|
||||
.surfaceContainerHigh
|
||||
: null,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
|
|
@ -340,21 +349,24 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
Text(
|
||||
h.label,
|
||||
style: theme
|
||||
.textTheme.bodyMedium
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.copyWith(
|
||||
fontWeight:
|
||||
FontWeight.w500,
|
||||
),
|
||||
fontWeight:
|
||||
FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (h.hint.isNotEmpty)
|
||||
Text(
|
||||
h.hint,
|
||||
style: theme.textTheme
|
||||
style: theme
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: theme.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
color: theme
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
|
|
@ -367,7 +379,8 @@ class _FaiSearchPaletteState extends State<FaiSearchPalette> {
|
|||
Icons.keyboard_return,
|
||||
size: 14,
|
||||
color: theme
|
||||
.colorScheme.onSurfaceVariant,
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -9,14 +9,13 @@ import '../data/hub.dart';
|
|||
import '../data/hub_auth_token.dart';
|
||||
import '../data/registry_token.dart';
|
||||
import '../data/system_actions.dart';
|
||||
import '../data/theme_plugin.dart';
|
||||
import '../main.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../theme/theme.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import 'fai_error_box.dart';
|
||||
import 'fai_pill.dart';
|
||||
import 'fai_system_ai_editor.dart';
|
||||
import 'theme_picker_grid.dart';
|
||||
|
||||
class FaiSettingsDialog extends StatefulWidget {
|
||||
const FaiSettingsDialog({super.key});
|
||||
|
|
@ -45,10 +44,12 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
SystemAiStatus? _aiStatus;
|
||||
List<McpClientInfo>? _mcpClients;
|
||||
List<N8nEndpointInfo>? _n8nEndpoints;
|
||||
|
||||
/// Length of the configured registry token, or null when the
|
||||
/// `~/.fai/registry-token` file is missing / empty. Reloaded
|
||||
/// after save / clear.
|
||||
int? _registryTokenChars;
|
||||
|
||||
/// Length of the configured hub gRPC bearer token, or null
|
||||
/// when `~/.fai/hub-auth-token` is missing / empty. Reloaded
|
||||
/// after save / clear; UI only ever sees the trimmed length.
|
||||
|
|
@ -74,7 +75,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
final n = await HubAuthToken.charCount();
|
||||
if (!mounted) return;
|
||||
setState(() => _hubAuthTokenChars = n);
|
||||
} catch (_) {/* fail-quiet */}
|
||||
} catch (_) {
|
||||
/* fail-quiet */
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveHubAuthToken(String token) async {
|
||||
|
|
@ -84,9 +87,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
await _loadHubAuthToken();
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.hubAuthTokenSavedToast)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenSavedToast)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
|
|
@ -103,9 +106,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
await _loadHubAuthToken();
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.hubAuthTokenClearedToast)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.hubAuthTokenClearedToast)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
|
|
@ -120,7 +123,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
final n = await RegistryToken.charCount();
|
||||
if (!mounted) return;
|
||||
setState(() => _registryTokenChars = n);
|
||||
} catch (_) {/* fail-quiet */}
|
||||
} catch (_) {
|
||||
/* fail-quiet */
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveRegistryToken(String token) async {
|
||||
|
|
@ -129,9 +134,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
await _loadRegistryToken();
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.registryTokenSavedToast)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.registryTokenSavedToast)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
|
|
@ -147,9 +152,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
await _loadRegistryToken();
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.registryTokenClearedToast)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.registryTokenClearedToast)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
|
|
@ -164,7 +169,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
final list = await HubService.instance.listN8nEndpoints();
|
||||
if (!mounted) return;
|
||||
setState(() => _n8nEndpoints = list);
|
||||
} catch (_) {/* fail-quiet */}
|
||||
} catch (_) {
|
||||
/* fail-quiet */
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addN8nEndpoint(N8nEndpointDraft draft) async {
|
||||
|
|
@ -178,15 +185,15 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
if (!mounted) return;
|
||||
setState(() => _n8nEndpoints = list);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.addedN8nToast(draft.name))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.addedN8nToast(draft.name))));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.addFailedToast(e.toString()))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString()))));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -239,15 +246,15 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
if (!mounted) return;
|
||||
setState(() => _mcpClients = list);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.addedMcpToast(draft.name))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.addedMcpToast(draft.name))));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.addFailedToast(e.toString()))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.addFailedToast(e.toString()))));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -402,197 +409,201 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
l.settingsHubEndpointHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
TextField(
|
||||
controller: _host,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.settingsHost,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
controller: _port,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.settingsPort,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text(l.settingsTls),
|
||||
subtitle: Text(
|
||||
l.settingsTlsSubtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
value: _secure,
|
||||
onChanged: (v) => setState(() => _secure = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(
|
||||
_error!,
|
||||
l.settingsHubEndpointHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(FaiSpace.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
TextField(
|
||||
controller: _host,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.settingsHost,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
autofocus: true,
|
||||
),
|
||||
child: Row(
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.link,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
controller: _port,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.settingsPort,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
_previewUrl(),
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.onSurface,
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: Text(l.settingsTls),
|
||||
subtitle: Text(
|
||||
l.settingsTlsSubtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
value: _secure,
|
||||
onChanged: (v) => setState(() => _secure = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_channels != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
Text(
|
||||
l.channelsHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
fontSize: 10,
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(
|
||||
_error!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(FaiSpace.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.link,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
_previewUrl(),
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_channels != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
Text(
|
||||
l.channelsHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l.channelsBlurb,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
for (final ch in _channels!.channels)
|
||||
_ChannelRow(
|
||||
channel: ch,
|
||||
active: ch.name == _channels!.active,
|
||||
onConnect: _saving ? null : () => _connectToChannel(ch),
|
||||
onSwitch: _saving ? null : () => _switchChannel(ch.name),
|
||||
onEnableAutostart: _saving
|
||||
? null
|
||||
: () => _runDaemon(
|
||||
'enable autostart',
|
||||
() => SystemActions.faiDaemonEnable(ch.name),
|
||||
),
|
||||
onDisableAutostart: _saving
|
||||
? null
|
||||
: () => _runDaemon(
|
||||
'disable autostart',
|
||||
() => SystemActions.faiDaemonDisable(ch.name),
|
||||
),
|
||||
),
|
||||
if (_channelToast != null) ...[
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
FaiErrorBox(text: _channelToast!, maxHeight: 200),
|
||||
],
|
||||
],
|
||||
if (_aiStatus != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_SystemAiPanel(
|
||||
status: _aiStatus!,
|
||||
onEdit: () async {
|
||||
final updated = await FaiSystemAiEditor.show(
|
||||
context,
|
||||
_aiStatus!,
|
||||
);
|
||||
if (updated != null && mounted) {
|
||||
setState(() => _aiStatus = updated);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
if (_mcpClients != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_McpClientsPanel(
|
||||
clients: _mcpClients!,
|
||||
onAdd: _addMcpClient,
|
||||
onRemove: _removeMcpClient,
|
||||
onRefresh: _refreshMcpClients,
|
||||
),
|
||||
],
|
||||
if (_n8nEndpoints != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_N8nEndpointsPanel(
|
||||
endpoints: _n8nEndpoints!,
|
||||
onAdd: _addN8nEndpoint,
|
||||
onRemove: _removeN8nEndpoint,
|
||||
onRefresh: _refreshN8nEndpoints,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
l.channelsBlurb,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
for (final ch in _channels!.channels)
|
||||
_ChannelRow(
|
||||
channel: ch,
|
||||
active: ch.name == _channels!.active,
|
||||
onConnect: _saving ? null : () => _connectToChannel(ch),
|
||||
onSwitch: _saving ? null : () => _switchChannel(ch.name),
|
||||
onEnableAutostart: _saving ? null : () => _runDaemon(
|
||||
'enable autostart',
|
||||
() => SystemActions.faiDaemonEnable(ch.name),
|
||||
),
|
||||
onDisableAutostart: _saving ? null : () => _runDaemon(
|
||||
'disable autostart',
|
||||
() => SystemActions.faiDaemonDisable(ch.name),
|
||||
),
|
||||
),
|
||||
if (_channelToast != null) ...[
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
FaiErrorBox(text: _channelToast!, maxHeight: 200),
|
||||
],
|
||||
],
|
||||
if (_aiStatus != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_SystemAiPanel(
|
||||
status: _aiStatus!,
|
||||
onEdit: () async {
|
||||
final updated = await FaiSystemAiEditor.show(
|
||||
context,
|
||||
_aiStatus!,
|
||||
_RegistryCredentialsPanel(
|
||||
configuredChars: _registryTokenChars,
|
||||
onSave: _saveRegistryToken,
|
||||
onClear: _clearRegistryToken,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_HubAuthTokenPanel(
|
||||
configuredChars: _hubAuthTokenChars,
|
||||
onSave: _saveHubAuthToken,
|
||||
onClear: _clearHubAuthToken,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
const _DefaultScopePanel(),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
const _ThemePluginPanel(),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_MaintenancePanel(
|
||||
onResetDone: () async {
|
||||
// Daemon just restarted under the same channel +
|
||||
// port. Bouncing the gRPC connection picks up the
|
||||
// fresh state without the operator having to
|
||||
// close+reopen Settings.
|
||||
await HubService.instance.reconnect(
|
||||
HubService.instance.currentEndpoint,
|
||||
);
|
||||
if (updated != null && mounted) {
|
||||
setState(() => _aiStatus = updated);
|
||||
}
|
||||
if (!mounted) return;
|
||||
await _loadChannels();
|
||||
await _loadAiStatus();
|
||||
await _loadMcpClients();
|
||||
await _loadN8nEndpoints();
|
||||
await _loadRegistryToken();
|
||||
},
|
||||
),
|
||||
],
|
||||
if (_mcpClients != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_McpClientsPanel(
|
||||
clients: _mcpClients!,
|
||||
onAdd: _addMcpClient,
|
||||
onRemove: _removeMcpClient,
|
||||
onRefresh: _refreshMcpClients,
|
||||
),
|
||||
],
|
||||
if (_n8nEndpoints != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_N8nEndpointsPanel(
|
||||
endpoints: _n8nEndpoints!,
|
||||
onAdd: _addN8nEndpoint,
|
||||
onRemove: _removeN8nEndpoint,
|
||||
onRefresh: _refreshN8nEndpoints,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_RegistryCredentialsPanel(
|
||||
configuredChars: _registryTokenChars,
|
||||
onSave: _saveRegistryToken,
|
||||
onClear: _clearRegistryToken,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_HubAuthTokenPanel(
|
||||
configuredChars: _hubAuthTokenChars,
|
||||
onSave: _saveHubAuthToken,
|
||||
onClear: _clearHubAuthToken,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
const _DefaultScopePanel(),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
const _ThemePluginPanel(),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_MaintenancePanel(
|
||||
onResetDone: () async {
|
||||
// Daemon just restarted under the same channel +
|
||||
// port. Bouncing the gRPC connection picks up the
|
||||
// fresh state without the operator having to
|
||||
// close+reopen Settings.
|
||||
await HubService.instance.reconnect(
|
||||
HubService.instance.currentEndpoint,
|
||||
);
|
||||
if (!mounted) return;
|
||||
await _loadChannels();
|
||||
await _loadAiStatus();
|
||||
await _loadMcpClients();
|
||||
await _loadN8nEndpoints();
|
||||
await _loadRegistryToken();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
|
|
@ -616,8 +627,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
|
||||
String _previewUrl() {
|
||||
final scheme = _secure ? 'https' : 'http';
|
||||
final host =
|
||||
_host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
|
||||
final host = _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
|
||||
final port = _port.text.trim().isEmpty ? '50051' : _port.text.trim();
|
||||
return '$scheme://$host:$port';
|
||||
}
|
||||
|
|
@ -627,6 +637,7 @@ class _ChannelRow extends StatelessWidget {
|
|||
final ChannelInfo channel;
|
||||
final bool active;
|
||||
final VoidCallback? onConnect;
|
||||
|
||||
/// Switches the active channel pointer (`fai channel switch`).
|
||||
/// Distinct from [onConnect], which only re-points Studio's
|
||||
/// gRPC wire at a different running daemon.
|
||||
|
|
@ -691,7 +702,10 @@ class _ChannelRow extends StatelessWidget {
|
|||
if (active)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: FaiSpace.sm),
|
||||
child: FaiPill(label: l.channelsActive, tone: FaiPillTone.success),
|
||||
child: FaiPill(
|
||||
label: l.channelsActive,
|
||||
tone: FaiPillTone.success,
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
tooltip: l.channelsActionsTooltip,
|
||||
|
|
@ -716,18 +730,12 @@ class _ChannelRow extends StatelessWidget {
|
|||
PopupMenuItem(
|
||||
value: 'connect',
|
||||
enabled: channel.running && onConnect != null,
|
||||
child: _MenuRow(
|
||||
icon: Icons.link,
|
||||
text: l.channelsConnect,
|
||||
),
|
||||
child: _MenuRow(icon: Icons.link, text: l.channelsConnect),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'switch',
|
||||
enabled: !active && onSwitch != null,
|
||||
child: _MenuRow(
|
||||
icon: Icons.swap_horiz,
|
||||
text: l.channelsSwitch,
|
||||
),
|
||||
child: _MenuRow(icon: Icons.swap_horiz, text: l.channelsSwitch),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
|
|
@ -812,7 +820,9 @@ class _SystemAiPanel extends StatelessWidget {
|
|||
TextButton.icon(
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit_outlined, size: 14),
|
||||
label: Text(status.enabled ? l.systemAiEdit : l.systemAiConfigure),
|
||||
label: Text(
|
||||
status.enabled ? l.systemAiEdit : l.systemAiConfigure,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -822,7 +832,11 @@ class _SystemAiPanel extends StatelessWidget {
|
|||
_StatRow(label: 'endpoint', value: status.endpoint, mono: true),
|
||||
_StatRow(label: 'model', value: status.model, mono: true),
|
||||
if (status.apiKeyEnv.isNotEmpty)
|
||||
_StatRow(label: 'api_key_env', value: '\$${status.apiKeyEnv}', mono: true),
|
||||
_StatRow(
|
||||
label: 'api_key_env',
|
||||
value: '\$${status.apiKeyEnv}',
|
||||
mono: true,
|
||||
),
|
||||
] else ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
|
|
@ -844,11 +858,7 @@ class _StatRow extends StatelessWidget {
|
|||
final String value;
|
||||
final bool mono;
|
||||
|
||||
const _StatRow({
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.mono = false,
|
||||
});
|
||||
const _StatRow({required this.label, required this.value, this.mono = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -974,10 +984,7 @@ class _McpClientsPanel extends StatelessWidget {
|
|||
)
|
||||
else
|
||||
for (final c in clients)
|
||||
_McpClientRow(
|
||||
client: c,
|
||||
onRemove: () => onRemove(c.name),
|
||||
),
|
||||
_McpClientRow(client: c, onRemove: () => onRemove(c.name)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -1309,10 +1316,7 @@ class _N8nEndpointsPanel extends StatelessWidget {
|
|||
)
|
||||
else
|
||||
for (final e in endpoints)
|
||||
_N8nEndpointRow(
|
||||
endpoint: e,
|
||||
onRemove: () => onRemove(e.name),
|
||||
),
|
||||
_N8nEndpointRow(endpoint: e, onRemove: () => onRemove(e.name)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -1497,7 +1501,8 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
|||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty) return;
|
||||
if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty)
|
||||
return;
|
||||
Navigator.pop(
|
||||
context,
|
||||
N8nEndpointDraft(
|
||||
|
|
@ -1529,27 +1534,13 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
|||
/// When no theme plugin is installed the panel renders a
|
||||
/// short hint pointing at the Store rather than a useless
|
||||
/// empty dropdown.
|
||||
class _ThemePluginPanel extends StatefulWidget {
|
||||
class _ThemePluginPanel extends StatelessWidget {
|
||||
const _ThemePluginPanel();
|
||||
|
||||
@override
|
||||
State<_ThemePluginPanel> createState() => _ThemePluginPanelState();
|
||||
}
|
||||
|
||||
class _ThemePluginPanelState extends State<_ThemePluginPanel> {
|
||||
Future<List<String>>? _capsFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_capsFuture = listThemePluginCapabilities();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final app = StudioApp.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
|
@ -1561,76 +1552,12 @@ class _ThemePluginPanelState extends State<_ThemePluginPanel> {
|
|||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
FutureBuilder<List<String>>(
|
||||
future: _capsFuture,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
||||
child: SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
final caps = snap.data ?? const <String>[];
|
||||
if (caps.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Text(
|
||||
l.themePluginEmpty,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Dropdown shows "Built-in" + every installed
|
||||
// studio.theme.* cap. Selection writes through
|
||||
// StudioApp's notifier so the MaterialApp rebuilds
|
||||
// with the new ColorScheme without a restart.
|
||||
final active = app?.themePluginNotifier.value;
|
||||
final items = <DropdownMenuItem<String?>>[
|
||||
DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text(l.themePluginNone),
|
||||
),
|
||||
for (final cap in caps)
|
||||
DropdownMenuItem<String?>(
|
||||
value: cap,
|
||||
child: Text(cap, style: FaiTheme.mono(size: 12)),
|
||||
),
|
||||
];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButton<String?>(
|
||||
value: active,
|
||||
items: items,
|
||||
isDense: true,
|
||||
onChanged: app == null
|
||||
? null
|
||||
: (v) {
|
||||
app.setThemePlugin(v);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
l.themePluginHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Rich grid of theme tiles. Each shows live swatches
|
||||
// from the plugin's own palette so the operator can
|
||||
// preview without applying. The "Custom" tile opens
|
||||
// a seed-colour dialog.
|
||||
const ThemePickerGrid(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -1782,11 +1709,7 @@ class _MaintenancePanelState extends State<_MaintenancePanel> {
|
|||
),
|
||||
if (_resultText != null) ...[
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
FaiErrorBox(
|
||||
text: _resultText!,
|
||||
isError: !_resultOk,
|
||||
maxHeight: 240,
|
||||
),
|
||||
FaiErrorBox(text: _resultText!, isError: !_resultOk, maxHeight: 240),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
|
@ -1822,8 +1745,7 @@ class _RegistryCredentialsPanel extends StatefulWidget {
|
|||
_RegistryCredentialsPanelState();
|
||||
}
|
||||
|
||||
class _RegistryCredentialsPanelState
|
||||
extends State<_RegistryCredentialsPanel> {
|
||||
class _RegistryCredentialsPanelState extends State<_RegistryCredentialsPanel> {
|
||||
final _controller = TextEditingController();
|
||||
bool _obscured = true;
|
||||
|
||||
|
|
@ -2017,9 +1939,7 @@ class _HubAuthTokenPanelState extends State<_HubAuthTokenPanel> {
|
|||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
isConfigured
|
||||
? Icons.check_circle
|
||||
: Icons.radio_button_unchecked,
|
||||
isConfigured ? Icons.check_circle : Icons.radio_button_unchecked,
|
||||
size: 14,
|
||||
color: isConfigured
|
||||
? theme.colorScheme.primary
|
||||
|
|
@ -2066,8 +1986,7 @@ class _HubAuthTokenPanelState extends State<_HubAuthTokenPanel> {
|
|||
_obscured ? Icons.visibility : Icons.visibility_off,
|
||||
size: 16,
|
||||
),
|
||||
onPressed: () =>
|
||||
setState(() => _obscured = !_obscured),
|
||||
onPressed: () => setState(() => _obscured = !_obscured),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
@ -2103,6 +2022,7 @@ class _McpSuggestion {
|
|||
final IconData icon;
|
||||
final String endpoint;
|
||||
final String apiKeyEnv;
|
||||
|
||||
/// Localizable description. Use [resolveDescription] to fetch
|
||||
/// the actual text in the active locale — the const list
|
||||
/// can't hold a closure that takes [AppLocalizations], and
|
||||
|
|
@ -2305,9 +2225,9 @@ class _DefaultScopePanelState extends State<_DefaultScopePanel> {
|
|||
_saving = false;
|
||||
});
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.defaultScopeSavedToast)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.defaultScopeSavedToast)));
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
|
|
@ -2333,8 +2253,9 @@ class _DefaultScopePanelState extends State<_DefaultScopePanel> {
|
|||
if (current.length == 1) {
|
||||
// Hub rejects empty; surface the constraint inline rather
|
||||
// than letting the RPC fail.
|
||||
setState(() => _error = AppLocalizations.of(context)!
|
||||
.defaultScopeNonEmptyError);
|
||||
setState(
|
||||
() => _error = AppLocalizations.of(context)!.defaultScopeNonEmptyError,
|
||||
);
|
||||
return;
|
||||
}
|
||||
current.removeAt(i);
|
||||
|
|
@ -2513,10 +2434,7 @@ class _ScopeRow extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
child: Text(entry, style: const TextStyle(fontFamily: 'monospace')),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_upward, size: 16),
|
||||
|
|
|
|||
|
|
@ -88,14 +88,8 @@ class _FaiStatusDotState extends State<FaiStatusDot>
|
|||
/// Convenience presets.
|
||||
class FaiStatusDots {
|
||||
FaiStatusDots._();
|
||||
static Widget live() => const FaiStatusDot(
|
||||
color: FaiColors.success,
|
||||
pulsing: true,
|
||||
);
|
||||
static Widget idle() => const FaiStatusDot(
|
||||
color: FaiColors.muted,
|
||||
);
|
||||
static Widget down() => const FaiStatusDot(
|
||||
color: FaiColors.danger,
|
||||
);
|
||||
static Widget live() =>
|
||||
const FaiStatusDot(color: FaiColors.success, pulsing: true);
|
||||
static Widget idle() => const FaiStatusDot(color: FaiColors.muted);
|
||||
static Widget down() => const FaiStatusDot(color: FaiColors.danger);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,10 +83,7 @@ class _ProviderPreset {
|
|||
];
|
||||
|
||||
static _ProviderPreset byWire(String wire) {
|
||||
return all.firstWhere(
|
||||
(p) => p.wire == wire,
|
||||
orElse: () => all.first,
|
||||
);
|
||||
return all.firstWhere((p) => p.wire == wire, orElse: () => all.first);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -141,9 +138,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
|||
);
|
||||
_model = TextEditingController(text: init.model);
|
||||
_apiKeyEnv = TextEditingController(
|
||||
text: init.apiKeyEnv.isEmpty
|
||||
? _preset.defaultApiKeyEnv
|
||||
: init.apiKeyEnv,
|
||||
text: init.apiKeyEnv.isEmpty ? _preset.defaultApiKeyEnv : init.apiKeyEnv,
|
||||
);
|
||||
_privacyMode = init.privacyMode.isEmpty ? 'redacted' : init.privacyMode;
|
||||
// Pull the model list immediately so the operator sees the
|
||||
|
|
@ -376,10 +371,7 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
|||
_HardwareBanner(hw: _hw!, lastReviewed: _curated?.lastReviewed),
|
||||
],
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_ProviderDropdown(
|
||||
value: _preset,
|
||||
onChanged: _onProviderChanged,
|
||||
),
|
||||
_ProviderDropdown(value: _preset, onChanged: _onProviderChanged),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_preset.description,
|
||||
|
|
@ -495,10 +487,7 @@ class _ProviderDropdown extends StatelessWidget {
|
|||
final _ProviderPreset value;
|
||||
final void Function(_ProviderPreset) onChanged;
|
||||
|
||||
const _ProviderDropdown({
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
const _ProviderDropdown({required this.value, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -512,12 +501,7 @@ class _ProviderDropdown extends StatelessWidget {
|
|||
isDense: true,
|
||||
),
|
||||
items: _ProviderPreset.all
|
||||
.map(
|
||||
(p) => DropdownMenuItem(
|
||||
value: p.wire,
|
||||
child: Text(p.label),
|
||||
),
|
||||
)
|
||||
.map((p) => DropdownMenuItem(value: p.wire, child: Text(p.label)))
|
||||
.toList(),
|
||||
onChanged: (v) {
|
||||
if (v == null) return;
|
||||
|
|
@ -531,10 +515,7 @@ class _PrivacyModeChips extends StatelessWidget {
|
|||
final String value;
|
||||
final ValueChanged<String> onChanged;
|
||||
|
||||
const _PrivacyModeChips({
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
});
|
||||
const _PrivacyModeChips({required this.value, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -547,11 +528,7 @@ class _PrivacyModeChips extends StatelessWidget {
|
|||
l.systemAiPrivacyRedactedLabel,
|
||||
l.systemAiPrivacyRedactedDesc,
|
||||
),
|
||||
(
|
||||
'full',
|
||||
l.systemAiPrivacyFullLabel,
|
||||
l.systemAiPrivacyFullDesc,
|
||||
),
|
||||
('full', l.systemAiPrivacyFullLabel, l.systemAiPrivacyFullDesc),
|
||||
];
|
||||
// Material 3 deprecated per-Radio `groupValue` / `onChanged`
|
||||
// after Flutter 3.32. The modern pattern wraps the radios in
|
||||
|
|
@ -643,9 +620,7 @@ class _TestResultPanel extends StatelessWidget {
|
|||
const SizedBox(width: FaiSpace.xs),
|
||||
Text(
|
||||
ok ? l.systemAiConnectionOk : l.systemAiConnectionFailed,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: color,
|
||||
),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(color: color),
|
||||
),
|
||||
const Spacer(),
|
||||
if (ok && result.latencyMs > 0)
|
||||
|
|
@ -659,10 +634,7 @@ class _TestResultPanel extends StatelessWidget {
|
|||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
ok ? l.systemAiReplyPrefix(result.text) : result.text,
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface),
|
||||
),
|
||||
if (!ok && result.fixHint.isNotEmpty) ...[
|
||||
const SizedBox(height: FaiSpace.xs),
|
||||
|
|
@ -686,12 +658,14 @@ class _ModelPicker extends StatelessWidget {
|
|||
final String? modelsError;
|
||||
final bool loading;
|
||||
final bool pulling;
|
||||
|
||||
/// Detected host hardware. Used by [_suitabilityFor] to mark
|
||||
/// curated models as "recommended" when their `min_hw_tier` is
|
||||
/// at or below the operator's tier. Null while the hub call is
|
||||
/// in flight or unreachable — chips fall back to the size-only
|
||||
/// heuristic.
|
||||
final HardwareSnapshot? hw;
|
||||
|
||||
/// Curated DB indexed by model id. Empty while the hub call is
|
||||
/// in flight; chips then fall back to the size-only heuristic.
|
||||
final Map<String, CuratedModelInfo> curatedById;
|
||||
|
|
@ -736,8 +710,8 @@ class _ModelPicker extends StatelessWidget {
|
|||
: preset.modelHint,
|
||||
helperText: controller.text.trim().isEmpty
|
||||
? (_isOllama
|
||||
? l.systemAiModelHelperOllama
|
||||
: l.systemAiModelHelperBase)
|
||||
? l.systemAiModelHelperOllama
|
||||
: l.systemAiModelHelperBase)
|
||||
: null,
|
||||
helperMaxLines: 2,
|
||||
border: const OutlineInputBorder(),
|
||||
|
|
@ -787,9 +761,7 @@ class _ModelPicker extends StatelessWidget {
|
|||
const SizedBox(height: FaiSpace.sm),
|
||||
if (models!.isEmpty)
|
||||
Text(
|
||||
_isOllama
|
||||
? l.systemAiNoModelsOllama
|
||||
: l.systemAiNoModelsGeneric,
|
||||
_isOllama ? l.systemAiNoModelsOllama : l.systemAiNoModelsGeneric,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -970,6 +942,7 @@ List<String> _sortedBySuitability(
|
|||
class _ModelChip extends StatelessWidget {
|
||||
final String id;
|
||||
final _Suitability suitability;
|
||||
|
||||
/// Curated entry for this id, when one exists. Drives the
|
||||
/// tooltip — operators get the editorial note ("Solid 4B
|
||||
/// all-rounder…") rather than just the suitability label.
|
||||
|
|
@ -1010,9 +983,7 @@ class _ModelChip extends StatelessWidget {
|
|||
message: _tooltipFor(context),
|
||||
child: ActionChip(
|
||||
avatar: Icon(
|
||||
suitability == _Suitability.recommended
|
||||
? Icons.star
|
||||
: Icons.circle,
|
||||
suitability == _Suitability.recommended ? Icons.star : Icons.circle,
|
||||
size: suitability == _Suitability.recommended ? 14 : 9,
|
||||
color: color,
|
||||
),
|
||||
|
|
@ -1041,10 +1012,7 @@ class _HardwareBanner extends StatelessWidget {
|
|||
? l.systemAiHwReviewed(lastReviewed!)
|
||||
: '';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.sm,
|
||||
vertical: 6,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
|
|
@ -1081,23 +1049,25 @@ class _SuitabilityLegend extends StatelessWidget {
|
|||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
Widget dot(Color c, String label) => Padding(
|
||||
padding: const EdgeInsets.only(right: FaiSpace.md),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.circle, size: 8, color: c),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
padding: const EdgeInsets.only(right: FaiSpace.md),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.circle, size: 8, color: c),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
);
|
||||
final tierTag = (hw == null || hw!.tier == 'unknown') ? '' : ' (${hw!.tier})';
|
||||
],
|
||||
),
|
||||
);
|
||||
final tierTag = (hw == null || hw!.tier == 'unknown')
|
||||
? ''
|
||||
: ' (${hw!.tier})';
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
|
|
@ -1143,10 +1113,7 @@ class _CacheStatusRow extends StatelessWidget {
|
|||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.sm,
|
||||
vertical: 6,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
|
|
@ -1154,11 +1121,7 @@ class _CacheStatusRow extends StatelessWidget {
|
|||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.bolt,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Icon(Icons.bolt, size: 14, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
|
|
|
|||
463
lib/widgets/theme_picker_grid.dart
Normal file
463
lib/widgets/theme_picker_grid.dart
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
// Rich theme picker — grid of swatched theme tiles, plus a
|
||||
// custom-seed-color tile that opens a small color picker
|
||||
// dialog. Replaces the dropdown that used to live inside the
|
||||
// Settings dialog's Theme section.
|
||||
//
|
||||
// The picker writes through StudioApp.setThemePlugin. Built-in
|
||||
// = null. Installed plugin = its capability identifier
|
||||
// (e.g. studio.theme.solarized). Custom = `custom:#RRGGBB`.
|
||||
// See main.dart's _pluginThemes for the parsing side.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/theme_plugin.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../main.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
class ThemePickerGrid extends StatefulWidget {
|
||||
const ThemePickerGrid({super.key});
|
||||
|
||||
@override
|
||||
State<ThemePickerGrid> createState() => _ThemePickerGridState();
|
||||
}
|
||||
|
||||
class _ThemePickerGridState extends State<ThemePickerGrid> {
|
||||
late Future<List<String>> _capsFuture;
|
||||
// Per-capability cached schemes for the tile previews. We
|
||||
// load them lazily once the list arrives so the grid
|
||||
// doesn't block while every plugin's theme_for() round-
|
||||
// trips. A null entry means "loaded, no scheme" — the
|
||||
// tile falls back to a generic palette.
|
||||
final Map<String, ThemePluginSchemes?> _previews = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_capsFuture = listThemePluginCapabilities();
|
||||
}
|
||||
|
||||
Future<void> _ensurePreview(String cap) async {
|
||||
if (_previews.containsKey(cap)) return;
|
||||
_previews[cap] = null;
|
||||
final schemes = await loadThemePluginSchemes(cap);
|
||||
if (!mounted) return;
|
||||
setState(() => _previews[cap] = schemes);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final app = StudioApp.of(context);
|
||||
return FutureBuilder<List<String>>(
|
||||
future: _capsFuture,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
||||
child: SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
final caps = snap.data ?? const <String>[];
|
||||
// Trigger preview loads for any plugin we haven't
|
||||
// fetched yet — best-effort, fire-and-forget.
|
||||
for (final cap in caps) {
|
||||
if (!_previews.containsKey(cap)) {
|
||||
// ignore: discarded_futures
|
||||
_ensurePreview(cap);
|
||||
}
|
||||
}
|
||||
final active = app?.themePluginNotifier.value;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
_builtInTile(theme, l, active),
|
||||
for (final cap in caps) _pluginTile(theme, cap, active),
|
||||
_customTile(theme, l, active),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
Text(
|
||||
l.themePluginHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _builtInTile(ThemeData theme, AppLocalizations l, String? active) {
|
||||
return _Tile(
|
||||
label: l.themePluginNone,
|
||||
swatches: [
|
||||
theme.colorScheme.primary,
|
||||
theme.colorScheme.secondary,
|
||||
theme.colorScheme.tertiary,
|
||||
],
|
||||
selected: active == null,
|
||||
onTap: () {
|
||||
StudioApp.of(context)?.setThemePlugin(null);
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _pluginTile(ThemeData theme, String cap, String? active) {
|
||||
// Pretty name: studio.theme.sunflower → "Sunflower".
|
||||
final display = _displayName(cap);
|
||||
final preview = _previews[cap];
|
||||
// Swatches come from the plugin's matching-brightness
|
||||
// scheme so the tile literally previews what selecting
|
||||
// it does. Fall back to the active theme's accents
|
||||
// while the plugin's preview hasn't loaded yet.
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final scheme = isDark ? preview?.dark : preview?.light;
|
||||
final swatches = scheme != null
|
||||
? <Color>[scheme.primary, scheme.secondary, scheme.tertiary]
|
||||
: <Color>[
|
||||
theme.colorScheme.primary,
|
||||
theme.colorScheme.secondary,
|
||||
theme.colorScheme.tertiary,
|
||||
];
|
||||
return _Tile(
|
||||
label: display,
|
||||
swatches: swatches,
|
||||
selected: active == cap,
|
||||
loading: preview == null,
|
||||
onTap: () {
|
||||
StudioApp.of(context)?.setThemePlugin(cap);
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _customTile(ThemeData theme, AppLocalizations l, String? active) {
|
||||
final isCustom = active != null && active.startsWith('custom:');
|
||||
final seedHex = isCustom ? active.substring('custom:'.length) : null;
|
||||
final seed = seedHex != null
|
||||
? _parseHexColor(seedHex)
|
||||
: theme.colorScheme.primary;
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: seed ?? theme.colorScheme.primary,
|
||||
brightness: theme.brightness,
|
||||
);
|
||||
return _Tile(
|
||||
label: l.themePluginCustom,
|
||||
swatches: [scheme.primary, scheme.secondary, scheme.tertiary],
|
||||
selected: isCustom,
|
||||
onTap: () async {
|
||||
final picked = await _showColorPickerDialog(
|
||||
context,
|
||||
initial: seed ?? theme.colorScheme.primary,
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
final hex = picked.toARGB32().toRadixString(16).padLeft(8, '0');
|
||||
// Strip alpha component — we only persist the RGB.
|
||||
final rgb = hex.substring(2).toUpperCase();
|
||||
StudioApp.of(context)?.setThemePlugin('custom:#$rgb');
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _displayName(String cap) {
|
||||
// studio.theme.glass-apple → "Glass Apple"
|
||||
final last = cap.split('.').last;
|
||||
return last
|
||||
.split(RegExp(r'[-_]'))
|
||||
.map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}')
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
Color? _parseHexColor(String s) {
|
||||
final cleaned = s.replaceAll('#', '');
|
||||
if (cleaned.length != 6) return null;
|
||||
final raw = int.tryParse(cleaned, radix: 16);
|
||||
if (raw == null) return null;
|
||||
return Color(0xFF000000 | raw);
|
||||
}
|
||||
|
||||
class _Tile extends StatelessWidget {
|
||||
final String label;
|
||||
final List<Color> swatches;
|
||||
final bool selected;
|
||||
final bool loading;
|
||||
final VoidCallback onTap;
|
||||
const _Tile({
|
||||
required this.label,
|
||||
required this.swatches,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.loading = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SizedBox(
|
||||
width: 132,
|
||||
height: 76,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
for (final c in swatches) ...[
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: c,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withValues(
|
||||
alpha: 0.4,
|
||||
),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
if (loading)
|
||||
const SizedBox(
|
||||
width: 10,
|
||||
height: 10,
|
||||
child: CircularProgressIndicator(strokeWidth: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: selected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w500,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected)
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
size: 14,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Color?> _showColorPickerDialog(
|
||||
BuildContext context, {
|
||||
required Color initial,
|
||||
}) {
|
||||
return showDialog<Color>(
|
||||
context: context,
|
||||
builder: (ctx) => _ColorPickerDialog(initial: initial),
|
||||
);
|
||||
}
|
||||
|
||||
class _ColorPickerDialog extends StatefulWidget {
|
||||
final Color initial;
|
||||
const _ColorPickerDialog({required this.initial});
|
||||
@override
|
||||
State<_ColorPickerDialog> createState() => _ColorPickerDialogState();
|
||||
}
|
||||
|
||||
class _ColorPickerDialogState extends State<_ColorPickerDialog> {
|
||||
late Color _color = widget.initial;
|
||||
late final TextEditingController _hex = TextEditingController(
|
||||
text: _hexOf(widget.initial),
|
||||
);
|
||||
|
||||
/// Curated 12-colour palette — primary Material 3 hues at
|
||||
/// the 500 step, picked so each is visually distinct + each
|
||||
/// yields a usable ColorScheme.fromSeed. The operator can
|
||||
/// override via the hex field below.
|
||||
static const List<int> _presets = [
|
||||
0xFF1976D2, // Blue
|
||||
0xFF00ACC1, // Cyan
|
||||
0xFF43A047, // Green
|
||||
0xFFFFB300, // Amber
|
||||
0xFFF4511E, // Deep Orange
|
||||
0xFFE53935, // Red
|
||||
0xFFD81B60, // Pink
|
||||
0xFF8E24AA, // Purple
|
||||
0xFF5E35B1, // Deep Purple
|
||||
0xFF3949AB, // Indigo
|
||||
0xFF00897B, // Teal
|
||||
0xFF6D4C41, // Brown
|
||||
];
|
||||
|
||||
String _hexOf(Color c) =>
|
||||
'#${c.toARGB32().toRadixString(16).padLeft(8, '0').substring(2).toUpperCase()}';
|
||||
|
||||
void _pickPreset(int argb) {
|
||||
setState(() {
|
||||
_color = Color(argb);
|
||||
_hex.text = _hexOf(_color);
|
||||
});
|
||||
}
|
||||
|
||||
void _commitHex(String input) {
|
||||
final cleaned = input.replaceAll('#', '').trim();
|
||||
if (cleaned.length != 6) return;
|
||||
final raw = int.tryParse(cleaned, radix: 16);
|
||||
if (raw == null) return;
|
||||
setState(() => _color = Color(0xFF000000 | raw));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final preview = ColorScheme.fromSeed(
|
||||
seedColor: _color,
|
||||
brightness: theme.brightness,
|
||||
);
|
||||
return AlertDialog(
|
||||
title: Text(l.themePluginCustomDialogTitle),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final argb in _presets)
|
||||
GestureDetector(
|
||||
onTap: () => _pickPreset(argb),
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(argb),
|
||||
border: Border.all(
|
||||
color: _color.toARGB32() == argb
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant,
|
||||
width: _color.toARGB32() == argb ? 2.5 : 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
TextField(
|
||||
controller: _hex,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.themePluginCustomHexLabel,
|
||||
hintText: '#1976D2',
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: _commitHex,
|
||||
onSubmitted: _commitHex,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(l.themePluginCustomPreview, style: theme.textTheme.labelSmall),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
_swatch(preview.primary, 'P'),
|
||||
_swatch(preview.secondary, 'S'),
|
||||
_swatch(preview.tertiary, 'T'),
|
||||
_swatch(preview.surface, '∎'),
|
||||
_swatch(preview.error, '!'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l.themePluginCustomCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, _color),
|
||||
child: Text(l.themePluginCustomApply),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _swatch(Color c, String letter) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: c,
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
letter,
|
||||
style: TextStyle(
|
||||
color: ThemeData.estimateBrightnessForColor(c) == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue