chain-studio/lib/widgets/fai_system_ai_editor.dart
flemming-it a83a138259 feat(system-ai): auto-load models on open + suitability hints (v0.13.1)
Two operator-felt papercuts on the editor:

  * Models only appeared after a manual Refresh click. Now the
    editor calls `_refreshModels()` from initState (post-frame)
    so chips populate immediately. Provider switch also re-fires
    the refresh because endpoint / model set changes too.

  * Operator had no signal which model is suitable. Tiny models
    fail at instruction following; huge models stall on a
    laptop. Adds a heuristic suitability rating (parses
    parameter count from common names like `gemma3:4b`,
    `llama3.2:70b`; falls back to family hints for OpenAI /
    Claude). Each chip now carries:
       - a coloured leading dot (green=balanced, orange=small
         or large, red=huge, grey=unknown)
       - a star icon for the hand-curated "tested with F∆I"
         allowlist (gemma3:4b, llama3.2:3b, qwen2.5-coder:7b,
         gpt-4o-mini, claude-haiku-4-5, …)
       - a tooltip ("balanced", "huge — likely too slow on a
         laptop", …)
    Sorting also moves recommended → balanced → others to the
    front so the operator's eye lands on green first.

  * Compact legend below the chip wrap explains the colour
    code in place — no lookup needed.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-07 14:14:03 +02:00

945 lines
29 KiB
Dart

// FaiSystemAiEditor — modal for configuring the hub-internal
// System AI from inside Studio. Mirrors the YAML block but with
// provider presets, in-place explainers, and a "Test connection"
// button. On save, calls HubAdmin.UpdateSystemAi which both
// persists to ~/.fai/config.yaml and hot-reloads the live hub.
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../theme/theme.dart';
import '../theme/tokens.dart';
import 'fai_pill.dart';
/// Provider preset metadata kept in sync with
/// `fai_hub::operator_config::SystemLlmProvider`. Anything that
/// is operator-visible (label, description, default endpoint,
/// suggested env-var) lives here so the dropdown self-explains
/// without needing to read the source.
class _ProviderPreset {
final String wire;
final String label;
final String defaultEndpoint;
final String defaultApiKeyEnv;
final String description;
final String modelHint;
const _ProviderPreset({
required this.wire,
required this.label,
required this.defaultEndpoint,
required this.defaultApiKeyEnv,
required this.description,
required this.modelHint,
});
static const all = <_ProviderPreset>[
_ProviderPreset(
wire: 'ollama',
label: 'Ollama',
defaultEndpoint: 'http://127.0.0.1:11434/v1',
defaultApiKeyEnv: '',
description:
'Local `ollama serve`. Models stay on your machine. No API key needed.',
modelHint: 'gemma3:4b · llama3.2:3b · qwen2.5-coder:7b',
),
_ProviderPreset(
wire: 'openai',
label: 'OpenAI',
defaultEndpoint: 'https://api.openai.com/v1',
defaultApiKeyEnv: 'OPENAI_API_KEY',
description:
'OpenAI hosted API. Requires an API key. Data leaves your machine.',
modelHint: 'gpt-4o-mini · gpt-4o · o1-mini',
),
_ProviderPreset(
wire: 'lmstudio',
label: 'LM Studio',
defaultEndpoint: 'http://127.0.0.1:1234/v1',
defaultApiKeyEnv: '',
description:
'Local LM Studio server. Models stay on your machine. No API key needed.',
modelHint: 'whichever model you loaded in LM Studio',
),
_ProviderPreset(
wire: 'vllm',
label: 'vLLM (self-hosted)',
defaultEndpoint: '',
defaultApiKeyEnv: '',
description:
'Self-hosted vLLM or any other OpenAI-compatible server. Endpoint required.',
modelHint: 'depends on what your vLLM has loaded',
),
_ProviderPreset(
wire: 'custom',
label: 'Custom (OpenAI-compatible)',
defaultEndpoint: '',
defaultApiKeyEnv: '',
description:
'Anything else that speaks OpenAI-compatible chat-completions wire (LiteLLM proxy, internal mirror, …).',
modelHint: '',
),
];
static _ProviderPreset byWire(String wire) {
return all.firstWhere(
(p) => p.wire == wire,
orElse: () => all.first,
);
}
}
class FaiSystemAiEditor extends StatefulWidget {
final SystemAiStatus initial;
const FaiSystemAiEditor({super.key, required this.initial});
/// Convenience launcher. Returns the new status when the
/// operator saved, null on cancel.
static Future<SystemAiStatus?> show(
BuildContext context,
SystemAiStatus initial,
) {
return showDialog<SystemAiStatus>(
context: context,
builder: (_) => FaiSystemAiEditor(initial: initial),
);
}
@override
State<FaiSystemAiEditor> createState() => _FaiSystemAiEditorState();
}
class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
late _ProviderPreset _preset;
late final TextEditingController _endpoint;
late final TextEditingController _model;
late final TextEditingController _apiKeyEnv;
late String _privacyMode;
bool _saving = false;
bool _testing = false;
bool _loadingModels = false;
bool _pulling = false;
List<String>? _models;
String? _modelsError;
AskAiResult? _testResult;
String? _error;
@override
void initState() {
super.initState();
final init = widget.initial;
_preset = _ProviderPreset.byWire(
init.provider.isEmpty ? 'ollama' : init.provider,
);
_endpoint = TextEditingController(
text: init.endpoint.isEmpty ? _preset.defaultEndpoint : init.endpoint,
);
_model = TextEditingController(text: init.model);
_apiKeyEnv = TextEditingController(
text: init.apiKeyEnv.isEmpty
? _preset.defaultApiKeyEnv
: init.apiKeyEnv,
);
_privacyMode = init.privacyMode.isEmpty ? 'redacted' : init.privacyMode;
// Pull the model list immediately so the operator sees the
// available models when the dialog opens — no Refresh click
// needed. Fail-quiet: if the provider isn't running, the
// dropdown stays empty and the operator can fill the field
// manually or hit Refresh later.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_endpoint.text.trim().isNotEmpty) {
_refreshModels();
}
});
}
@override
void dispose() {
_endpoint.dispose();
_model.dispose();
_apiKeyEnv.dispose();
super.dispose();
}
void _onProviderChanged(_ProviderPreset p) {
setState(() {
_preset = p;
// Only auto-fill the field when the operator hasn't typed
// anything custom yet (i.e. it still matches a different
// preset's default or is empty).
if (_endpoint.text.isEmpty || _isAnyDefaultEndpoint(_endpoint.text)) {
_endpoint.text = p.defaultEndpoint;
}
if (_apiKeyEnv.text.isEmpty || _isAnyDefaultApiKeyEnv(_apiKeyEnv.text)) {
_apiKeyEnv.text = p.defaultApiKeyEnv;
}
_testResult = null;
_models = null;
_modelsError = null;
});
// New provider → new model list. Fire a refresh so the
// chips update before the operator wonders what's available.
if (_endpoint.text.trim().isNotEmpty) {
_refreshModels();
}
}
bool _isAnyDefaultEndpoint(String t) =>
_ProviderPreset.all.any((p) => p.defaultEndpoint == t);
bool _isAnyDefaultApiKeyEnv(String t) =>
_ProviderPreset.all.any((p) => p.defaultApiKeyEnv == t);
Future<void> _save({bool keepOpen = false}) async {
setState(() {
_saving = true;
_error = null;
});
try {
final status = await HubService.instance.updateSystemAi(
provider: _preset.wire,
endpoint: _endpoint.text.trim(),
model: _model.text.trim(),
apiKeyEnv: _apiKeyEnv.text.trim(),
privacyMode: _privacyMode,
);
if (!mounted) return;
if (keepOpen) {
setState(() => _saving = false);
} else {
Navigator.pop(context, status);
}
} catch (e) {
setState(() {
_saving = false;
_error = e.toString();
});
}
}
Future<void> _test() async {
setState(() {
_testing = true;
_testResult = null;
_error = null;
});
try {
// Pass the current form values directly — Test must work
// *before* Save so the operator can validate without
// committing.
final r = await HubService.instance.testSystemAi(
provider: _preset.wire,
endpoint: _endpoint.text.trim(),
model: _model.text.trim(),
apiKeyEnv: _apiKeyEnv.text.trim(),
privacyMode: _privacyMode,
);
if (!mounted) return;
setState(() {
_testing = false;
_testResult = r;
});
} catch (e) {
setState(() {
_testing = false;
_error = e.toString();
});
}
}
Future<void> _refreshModels() async {
setState(() {
_loadingModels = true;
_models = null;
_modelsError = null;
});
final r = await HubService.instance.listSystemAiModels(
provider: _preset.wire,
endpoint: _endpoint.text.trim(),
apiKeyEnv: _apiKeyEnv.text.trim(),
);
if (!mounted) return;
setState(() {
_loadingModels = false;
if (r.errorKind.isEmpty) {
_models = r.ids;
} else {
_modelsError = r.text;
}
});
}
Future<void> _pullModel() async {
final wanted = _model.text.trim();
if (wanted.isEmpty) {
setState(() => _error = 'Type a model id (e.g. gemma3:4b) first.');
return;
}
setState(() {
_pulling = true;
_error = null;
});
final r = await HubService.instance.pullSystemAiModel(
endpoint: _endpoint.text.trim(),
model: wanted,
apiKeyEnv: _apiKeyEnv.text.trim(),
);
if (!mounted) return;
setState(() {
_pulling = false;
if (r.errorKind.isNotEmpty) {
_error = 'Pull failed: ${r.text}';
} else {
_error = null;
}
});
if (r.errorKind.isEmpty) {
// Refresh the model list so the just-pulled model appears
// in the dropdown immediately.
await _refreshModels();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: const Text('System AI'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(FaiRadius.md),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: FaiSpace.xl,
vertical: FaiSpace.lg,
),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520, maxHeight: 600),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'The hub-internal LLM the platform itself uses for inline failure '
'explanations and operator help. Off by default; configure here. '
'See docs/architecture/system-ai.md for what crosses the wire.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.lg),
_ProviderDropdown(
value: _preset,
onChanged: _onProviderChanged,
),
const SizedBox(height: 4),
Text(
_preset.description,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
TextField(
controller: _endpoint,
style: FaiTheme.mono(size: 12),
decoration: const InputDecoration(
labelText: 'Endpoint URL (up to /v1)',
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.md),
_ModelPicker(
controller: _model,
preset: _preset,
models: _models,
modelsError: _modelsError,
loading: _loadingModels,
pulling: _pulling,
onRefresh: _saving || _testing || _loadingModels || _pulling
? null
: _refreshModels,
onPull: _saving || _testing || _loadingModels || _pulling
? null
: _pullModel,
),
const SizedBox(height: FaiSpace.md),
TextField(
controller: _apiKeyEnv,
style: FaiTheme.mono(size: 12),
decoration: InputDecoration(
labelText: _preset.wire == 'openai'
? 'API key env var (required)'
: 'API key env var (optional)',
hintText: 'OPENAI_API_KEY',
prefixText: '\$',
border: const OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 4),
Text(
'Studio never reads or stores the key value — only its env-var name. '
'The hub reads `\$<name>` at request time.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.lg),
_PrivacyModeChips(
value: _privacyMode,
onChanged: (v) => setState(() => _privacyMode = v),
),
if (_error != null) ...[
const SizedBox(height: FaiSpace.md),
Text(
_error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
if (_testResult != null) ...[
const SizedBox(height: FaiSpace.md),
_TestResultPanel(result: _testResult!),
],
],
),
),
),
actions: [
TextButton(
onPressed: _saving || _testing
? null
: () => Navigator.pop(context, null),
child: const Text('Cancel'),
),
OutlinedButton.icon(
onPressed: _saving || _testing ? null : _test,
icon: const Icon(Icons.play_arrow, size: 16),
label: Text(_testing ? 'Testing…' : 'Test connection'),
),
FilledButton(
onPressed: _saving || _testing ? null : () => _save(),
child: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save'),
),
],
);
}
}
class _ProviderDropdown extends StatelessWidget {
final _ProviderPreset value;
final void Function(_ProviderPreset) onChanged;
const _ProviderDropdown({
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<String>(
initialValue: value.wire,
isDense: true,
decoration: const InputDecoration(
labelText: 'Provider',
border: OutlineInputBorder(),
isDense: true,
),
items: _ProviderPreset.all
.map(
(p) => DropdownMenuItem(
value: p.wire,
child: Text(p.label),
),
)
.toList(),
onChanged: (v) {
if (v == null) return;
onChanged(_ProviderPreset.byWire(v));
},
);
}
}
class _PrivacyModeChips extends StatelessWidget {
final String value;
final ValueChanged<String> onChanged;
const _PrivacyModeChips({
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
const modes = <(String, String, String)>[
('off', 'Off', 'Feature disabled. No requests leave the hub.'),
(
'redacted',
'Redacted',
'Only event_type / error / module names. KRITIS-friendly default.',
),
(
'full',
'Full',
'Includes the audit detail JSON (hash-only — no raw payloads).',
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'PRIVACY MODE',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 4),
for (final (wire, label, desc) in modes)
InkWell(
onTap: () => onChanged(wire),
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: FaiSpace.sm,
horizontal: FaiSpace.sm,
),
child: Row(
children: [
Radio<String>(
value: wire,
groupValue: value,
onChanged: (v) => onChanged(v ?? wire),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: theme.textTheme.bodyMedium),
Text(
desc,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
),
],
);
}
}
class _TestResultPanel extends StatelessWidget {
final AskAiResult result;
const _TestResultPanel({required this.result});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final ok = result.isSuccess;
final color = ok ? FaiColors.success : theme.colorScheme.error;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
ok ? Icons.check_circle_outline : Icons.error_outline,
size: 14,
color: color,
),
const SizedBox(width: FaiSpace.xs),
Text(
ok ? 'Connection ok' : 'Connection failed',
style: theme.textTheme.bodyMedium?.copyWith(
color: color,
),
),
const Spacer(),
if (ok && result.latencyMs > 0)
FaiPill(
label: '${result.latencyMs} ms',
tone: FaiPillTone.neutral,
monospace: true,
),
],
),
const SizedBox(height: 4),
SelectableText(
ok ? 'Reply: ${result.text}' : result.text,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
if (!ok && result.fixHint.isNotEmpty) ...[
const SizedBox(height: FaiSpace.xs),
Text(
result.fixHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface,
),
),
],
],
),
);
}
}
class _ModelPicker extends StatelessWidget {
final TextEditingController controller;
final _ProviderPreset preset;
final List<String>? models;
final String? modelsError;
final bool loading;
final bool pulling;
final VoidCallback? onRefresh;
final VoidCallback? onPull;
const _ModelPicker({
required this.controller,
required this.preset,
required this.models,
required this.modelsError,
required this.loading,
required this.pulling,
required this.onRefresh,
required this.onPull,
});
bool get _isOllama => preset.wire == 'ollama';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: controller,
builder: (_, _, _) => TextField(
controller: controller,
style: FaiTheme.mono(size: 12),
decoration: InputDecoration(
labelText: 'Model (required)',
hintText: preset.modelHint.isEmpty
? 'model identifier the provider expects'
: preset.modelHint,
helperText: controller.text.trim().isEmpty
? 'Required: pick from the list (Refresh) or type one. ${_isOllama ? "Use Pull to download from Ollama." : ""}'
: null,
helperMaxLines: 2,
border: const OutlineInputBorder(),
isDense: true,
),
),
),
),
const SizedBox(width: FaiSpace.sm),
OutlinedButton.icon(
onPressed: onRefresh,
icon: loading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: const Text('Refresh'),
),
if (_isOllama) ...[
const SizedBox(width: FaiSpace.sm),
OutlinedButton.icon(
onPressed: onPull,
icon: pulling
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.download, size: 16),
label: Text(pulling ? 'Pulling…' : 'Pull'),
),
],
],
),
if (modelsError != null) ...[
const SizedBox(height: 4),
Text(
'Could not list models: $modelsError',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
if (models != null) ...[
const SizedBox(height: FaiSpace.sm),
if (models!.isEmpty)
Text(
_isOllama
? 'No models on the Ollama server yet. Type one above and click Pull.'
: 'Provider returned no models.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
else ...[
Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: [
for (final id in _sortedBySuitability(models!))
_ModelChip(
id: id,
suitability: _suitabilityFor(id),
onTap: () => controller.text = id,
),
],
),
const SizedBox(height: 6),
_SuitabilityLegend(),
],
],
],
);
}
}
/// Heuristic suitability rating for a model id, used to colour
/// the chips in the model picker. Pure id-based — no /api/show
/// round-trip; works for Ollama tags (`gemma3:4b`), OpenAI names
/// (`gpt-4o-mini`), Anthropic-style (`claude-haiku-4-5`).
enum _Suitability {
recommended, // tested with F∆I System AI
balanced, // 4-8B params or known-good cloud model
small, // <4B params; quality may be insufficient
large, // 9-15B; slower but ok
huge, // >15B; usually too slow on a developer laptop
unknown, // can't tell from the id
}
extension _SuitabilityCopy on _Suitability {
String get label {
switch (this) {
case _Suitability.recommended:
return 'recommended';
case _Suitability.balanced:
return 'balanced';
case _Suitability.small:
return 'small — quality may be limited';
case _Suitability.large:
return 'large — slower';
case _Suitability.huge:
return 'huge — likely too slow on a laptop';
case _Suitability.unknown:
return 'size unknown';
}
}
Color color(ColorScheme cs) {
switch (this) {
case _Suitability.recommended:
return FaiColors.success;
case _Suitability.balanced:
return cs.primary;
case _Suitability.small:
return FaiColors.warning;
case _Suitability.large:
return FaiColors.warning;
case _Suitability.huge:
return cs.error;
case _Suitability.unknown:
return cs.outline;
}
}
}
/// Hand-curated allowlist of model ids tested with the F∆I
/// System-AI prompt. Anything in here gets the green
/// `recommended` badge. Keep small + opinionated.
const _kRecommendedModels = <String>{
'gemma3:4b',
'gemma3:12b',
'llama3.2:3b',
'qwen2.5:7b',
'qwen2.5-coder:7b',
'gpt-4o-mini',
'gpt-4o',
'claude-haiku-4-5',
'claude-sonnet-4-6',
};
_Suitability _suitabilityFor(String rawId) {
final id = rawId.trim().toLowerCase();
if (_kRecommendedModels.contains(id)) {
return _Suitability.recommended;
}
// Try to extract a parameter count from common naming
// conventions: `gemma3:4b`, `llama3.2-70b-instruct`, `qwen-1.5b`,
// `:13b`, `-3.2b`, etc. Match a number (with optional decimal)
// followed by `b`, bordered by a non-letter on both sides.
final m = RegExp(r'(?<![a-z])(\d+(?:\.\d+)?)\s*b(?![a-z])').firstMatch(id);
if (m != null) {
final n = double.tryParse(m.group(1)!);
if (n != null) {
if (n < 3) return _Suitability.small;
if (n <= 8) return _Suitability.balanced;
if (n <= 15) return _Suitability.large;
return _Suitability.huge;
}
}
// OpenAI / Anthropic without size in the name — use known
// family heuristics.
if (id.contains('mini') || id.contains('haiku') || id.contains('flash')) {
return _Suitability.balanced;
}
if (id.contains('opus') || id.contains('o1') || id.contains('large')) {
return _Suitability.large;
}
return _Suitability.unknown;
}
/// Sort by suitability tier first (recommended → balanced →
/// large → small → huge → unknown), then alphabetically — so
/// the operator's eye lands on the green chips immediately.
List<String> _sortedBySuitability(List<String> ids) {
int rank(_Suitability s) {
switch (s) {
case _Suitability.recommended:
return 0;
case _Suitability.balanced:
return 1;
case _Suitability.large:
return 2;
case _Suitability.small:
return 3;
case _Suitability.huge:
return 4;
case _Suitability.unknown:
return 5;
}
}
final out = [...ids];
out.sort((a, b) {
final ra = rank(_suitabilityFor(a));
final rb = rank(_suitabilityFor(b));
if (ra != rb) return ra.compareTo(rb);
return a.compareTo(b);
});
return out;
}
class _ModelChip extends StatelessWidget {
final String id;
final _Suitability suitability;
final VoidCallback onTap;
const _ModelChip({
required this.id,
required this.suitability,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = suitability.color(theme.colorScheme);
return Tooltip(
message: suitability.label,
child: ActionChip(
avatar: Icon(
suitability == _Suitability.recommended
? Icons.star
: Icons.circle,
size: suitability == _Suitability.recommended ? 14 : 9,
color: color,
),
label: Text(id, style: FaiTheme.mono(size: 11)),
side: BorderSide(color: color.withValues(alpha: 0.4)),
onPressed: onTap,
),
);
}
}
class _SuitabilityLegend extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Widget dot(Color c, String label) => Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.circle, size: 8, color: c),
const SizedBox(width: 4),
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
);
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(right: FaiSpace.md),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.star, size: 11, color: FaiColors.success),
const SizedBox(width: 4),
Text(
'tested with F∆I',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontSize: 11,
),
),
],
),
),
dot(theme.colorScheme.primary, 'balanced (4-8B)'),
dot(FaiColors.warning, 'small <3B / large 9-15B'),
dot(theme.colorScheme.error, 'huge >15B'),
dot(theme.colorScheme.outline, 'size unknown'),
],
);
}
}