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