feat(studio): hardware-aware model recommendations + sidebar clock (v0.14.0)
System-AI editor now fetches the detected hardware tier and the hub's curated model database on open. Each model chip's colour + tooltip combines both: green star when curated quality is good/excellent AND min_hw_tier ≤ host; orange when quality is fine but the host is below min_hw_tier; warning for "basic" curated entries. Tooltip exposes the curator's note, parameter count, context window, and licence. Banner above the form shows "Detected: <summary> · curation reviewed <date>". Sidebar footer gains a small monospaced live clock between the theme toggle and the settings icon. Tooltip exposes full local ISO + UTC for cross-checking against audit-log timestamps. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
a83a138259
commit
942c29a395
5 changed files with 395 additions and 35 deletions
|
|
@ -230,6 +230,46 @@ class HubService {
|
|||
);
|
||||
}
|
||||
|
||||
/// Detected host hardware. Used by the System-AI editor to
|
||||
/// mark models as "recommended" / "may be slow" against the
|
||||
/// operator's actual machine.
|
||||
Future<HardwareSnapshot> hardwareInfo() async {
|
||||
final r = await _client.hardwareInfo();
|
||||
return HardwareSnapshot(
|
||||
tier: r.tier,
|
||||
cpuBrand: r.cpuBrand,
|
||||
cpuCores: r.cpuCores,
|
||||
ramMb: r.ramMb.toInt(),
|
||||
appleSilicon: r.appleSilicon,
|
||||
dedicatedGpu: r.dedicatedGpu,
|
||||
summary: r.summary,
|
||||
);
|
||||
}
|
||||
|
||||
/// Bundled curated model database. Refresh requires a hub
|
||||
/// redeploy; the `lastReviewed` field shows how stale the
|
||||
/// curation is.
|
||||
Future<CuratedSnapshot> listSystemAiCuratedModels() async {
|
||||
final r = await _client.listSystemAiCuratedModels();
|
||||
return CuratedSnapshot(
|
||||
lastReviewed: r.lastReviewed,
|
||||
models: r.models
|
||||
.map(
|
||||
(m) => CuratedModelInfo(
|
||||
id: m.id,
|
||||
family: m.family,
|
||||
paramsB: m.paramsB,
|
||||
qualityTier: m.qualityTier,
|
||||
minHwTier: m.minHwTier,
|
||||
contextK: m.contextK,
|
||||
notes: m.notes,
|
||||
license: m.license,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Active channel + per-channel daemon status snapshot.
|
||||
Future<ChannelStatusSnapshot> channelStatus() async {
|
||||
final r = await _client.channelStatus();
|
||||
|
|
@ -707,6 +747,103 @@ class ChannelStatusSnapshot {
|
|||
});
|
||||
}
|
||||
|
||||
/// Detected host hardware — see
|
||||
/// docs/architecture/system-ai.md → "Hardware tiers".
|
||||
class HardwareSnapshot {
|
||||
/// One of: tiny / small / balanced / large / unknown.
|
||||
final String tier;
|
||||
final String cpuBrand;
|
||||
final int cpuCores;
|
||||
final int ramMb;
|
||||
final bool appleSilicon;
|
||||
final bool dedicatedGpu;
|
||||
/// Human-readable summary the editor renders verbatim.
|
||||
final String summary;
|
||||
|
||||
const HardwareSnapshot({
|
||||
required this.tier,
|
||||
required this.cpuBrand,
|
||||
required this.cpuCores,
|
||||
required this.ramMb,
|
||||
required this.appleSilicon,
|
||||
required this.dedicatedGpu,
|
||||
required this.summary,
|
||||
});
|
||||
|
||||
/// Numeric rank used for "is `min_hw_tier` ≤ detected?"
|
||||
/// comparisons. Unknown sits at -1 so it never qualifies as
|
||||
/// "at least" anything; the editor falls back to size-only
|
||||
/// heuristics in that case.
|
||||
int get rank {
|
||||
switch (tier) {
|
||||
case 'tiny':
|
||||
return 0;
|
||||
case 'small':
|
||||
return 1;
|
||||
case 'balanced':
|
||||
return 2;
|
||||
case 'large':
|
||||
return 3;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One curated model with editorial metadata. Wire-faithful
|
||||
/// with `crates/fai_hub/system-ai/models.yaml`.
|
||||
class CuratedModelInfo {
|
||||
final String id;
|
||||
final String family;
|
||||
final double paramsB;
|
||||
/// basic / good / excellent.
|
||||
final String qualityTier;
|
||||
/// tiny / small / balanced / large.
|
||||
final String minHwTier;
|
||||
final int contextK;
|
||||
final String notes;
|
||||
final String license;
|
||||
|
||||
const CuratedModelInfo({
|
||||
required this.id,
|
||||
required this.family,
|
||||
required this.paramsB,
|
||||
required this.qualityTier,
|
||||
required this.minHwTier,
|
||||
required this.contextK,
|
||||
required this.notes,
|
||||
required this.license,
|
||||
});
|
||||
|
||||
/// Same rank as HardwareSnapshot; lets us compare with `<=`.
|
||||
int get minHwRank {
|
||||
switch (minHwTier) {
|
||||
case 'tiny':
|
||||
return 0;
|
||||
case 'small':
|
||||
return 1;
|
||||
case 'balanced':
|
||||
return 2;
|
||||
case 'large':
|
||||
return 3;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Curated database snapshot returned from the hub.
|
||||
class CuratedSnapshot {
|
||||
/// ISO date string from `models.yaml`, e.g. "2026-05-07".
|
||||
final String lastReviewed;
|
||||
final List<CuratedModelInfo> models;
|
||||
|
||||
const CuratedSnapshot({
|
||||
required this.lastReviewed,
|
||||
required this.models,
|
||||
});
|
||||
}
|
||||
|
||||
/// One row from the store-index search.
|
||||
class StoreItem {
|
||||
final String name;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import 'widgets/widgets.dart';
|
|||
/// Studio's own build version. Bump on every UI commit so the
|
||||
/// running app self-identifies — visible in the sidebar header
|
||||
/// and quick-glance proof that you're seeing the current build.
|
||||
const String kStudioVersion = '0.13.1';
|
||||
const String kStudioVersion = '0.14.0';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
@ -334,13 +334,20 @@ class _Sidebar extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
// Footer: theme toggle + settings.
|
||||
// Footer: theme toggle · live wall-clock · settings.
|
||||
// Clock sits between the two icons so it reads as a
|
||||
// status line — gives the operator a wall-clock
|
||||
// reference to cross-check audit-log timestamps
|
||||
// without taking sidebar real estate from anything
|
||||
// load-bearing.
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.md),
|
||||
child: Row(
|
||||
children: [
|
||||
_ThemeToggle(),
|
||||
const Spacer(),
|
||||
const Expanded(
|
||||
child: Center(child: _SidebarClock()),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined, size: 16),
|
||||
tooltip: 'Settings (⌘;)',
|
||||
|
|
@ -546,3 +553,79 @@ class _ThemeToggle extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small ticking wall-clock for the sidebar. Aligns the on-screen
|
||||
/// time with the timestamps the audit log writes — operators
|
||||
/// glance here when an event timestamp looks off and want a
|
||||
/// reference point. Updates on the second; tooltip exposes the
|
||||
/// full ISO + UTC offset for unambiguous comparison.
|
||||
class _SidebarClock extends StatefulWidget {
|
||||
const _SidebarClock();
|
||||
|
||||
@override
|
||||
State<_SidebarClock> createState() => _SidebarClockState();
|
||||
}
|
||||
|
||||
class _SidebarClockState extends State<_SidebarClock> {
|
||||
late DateTime _now;
|
||||
Timer? _ticker;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_now = DateTime.now();
|
||||
_scheduleNextTick();
|
||||
}
|
||||
|
||||
void _scheduleNextTick() {
|
||||
// Align ticks on the second boundary so the seconds digit
|
||||
// changes when the wall-clock second changes, not 0–999 ms
|
||||
// late depending on when the widget mounted.
|
||||
final delay = Duration(milliseconds: 1000 - _now.millisecond);
|
||||
_ticker = Timer(delay, () {
|
||||
if (!mounted) return;
|
||||
setState(() => _now = DateTime.now());
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _now = DateTime.now());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final hh = _now.hour.toString().padLeft(2, '0');
|
||||
final mm = _now.minute.toString().padLeft(2, '0');
|
||||
final ss = _now.second.toString().padLeft(2, '0');
|
||||
final yyyy = _now.year.toString().padLeft(4, '0');
|
||||
final mo = _now.month.toString().padLeft(2, '0');
|
||||
final dd = _now.day.toString().padLeft(2, '0');
|
||||
|
||||
final offsetMin = _now.timeZoneOffset.inMinutes;
|
||||
final sign = offsetMin >= 0 ? '+' : '-';
|
||||
final absMin = offsetMin.abs();
|
||||
final offH = (absMin ~/ 60).toString().padLeft(2, '0');
|
||||
final offM = (absMin % 60).toString().padLeft(2, '0');
|
||||
final tooltip =
|
||||
'Local: $yyyy-$mo-$dd $hh:$mm:$ss $sign$offH:$offM\n'
|
||||
'UTC: ${_now.toUtc().toIso8601String()}';
|
||||
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Text(
|
||||
'$hh:$mm:$ss',
|
||||
style: FaiTheme.mono(
|
||||
size: 11,
|
||||
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.85),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
|||
String? _modelsError;
|
||||
AskAiResult? _testResult;
|
||||
String? _error;
|
||||
HardwareSnapshot? _hw;
|
||||
CuratedSnapshot? _curated;
|
||||
Map<String, CuratedModelInfo> _curatedById = const {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -151,9 +154,29 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
|||
if (_endpoint.text.trim().isNotEmpty) {
|
||||
_refreshModels();
|
||||
}
|
||||
_loadHardwareAndCurated();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadHardwareAndCurated() async {
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
HubService.instance.hardwareInfo(),
|
||||
HubService.instance.listSystemAiCuratedModels(),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
final hw = results[0] as HardwareSnapshot;
|
||||
final curated = results[1] as CuratedSnapshot;
|
||||
setState(() {
|
||||
_hw = hw;
|
||||
_curated = curated;
|
||||
_curatedById = {for (final m in curated.models) m.id: m};
|
||||
});
|
||||
} catch (_) {
|
||||
// Non-fatal: editor still works using the size-only heuristic.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_endpoint.dispose();
|
||||
|
|
@ -327,6 +350,10 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
|||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (_hw != null) ...[
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
_HardwareBanner(hw: _hw!, lastReviewed: _curated?.lastReviewed),
|
||||
],
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_ProviderDropdown(
|
||||
value: _preset,
|
||||
|
|
@ -357,6 +384,8 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
|||
modelsError: _modelsError,
|
||||
loading: _loadingModels,
|
||||
pulling: _pulling,
|
||||
hw: _hw,
|
||||
curatedById: _curatedById,
|
||||
onRefresh: _saving || _testing || _loadingModels || _pulling
|
||||
? null
|
||||
: _refreshModels,
|
||||
|
|
@ -620,6 +649,15 @@ class _ModelPicker extends StatelessWidget {
|
|||
final String? modelsError;
|
||||
final bool loading;
|
||||
final bool pulling;
|
||||
/// Detected host hardware. Used by [_suitabilityFor] to mark
|
||||
/// curated models as "recommended" when their `min_hw_tier` is
|
||||
/// at or below the operator's tier. Null while the hub call is
|
||||
/// in flight or unreachable — chips fall back to the size-only
|
||||
/// heuristic.
|
||||
final HardwareSnapshot? hw;
|
||||
/// Curated DB indexed by model id. Empty while the hub call is
|
||||
/// in flight; chips then fall back to the size-only heuristic.
|
||||
final Map<String, CuratedModelInfo> curatedById;
|
||||
final VoidCallback? onRefresh;
|
||||
final VoidCallback? onPull;
|
||||
|
||||
|
|
@ -630,6 +668,8 @@ class _ModelPicker extends StatelessWidget {
|
|||
required this.modelsError,
|
||||
required this.loading,
|
||||
required this.pulling,
|
||||
required this.hw,
|
||||
required this.curatedById,
|
||||
required this.onRefresh,
|
||||
required this.onPull,
|
||||
});
|
||||
|
|
@ -719,16 +759,25 @@ class _ModelPicker extends StatelessWidget {
|
|||
spacing: FaiSpace.xs,
|
||||
runSpacing: FaiSpace.xs,
|
||||
children: [
|
||||
for (final id in _sortedBySuitability(models!))
|
||||
for (final id in _sortedBySuitability(
|
||||
models!,
|
||||
hw: hw,
|
||||
curatedById: curatedById,
|
||||
))
|
||||
_ModelChip(
|
||||
id: id,
|
||||
suitability: _suitabilityFor(id),
|
||||
suitability: _suitabilityFor(
|
||||
id,
|
||||
hw: hw,
|
||||
curatedById: curatedById,
|
||||
),
|
||||
curated: curatedById[id.trim().toLowerCase()],
|
||||
onTap: () => controller.text = id,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_SuitabilityLegend(),
|
||||
_SuitabilityLegend(hw: hw),
|
||||
],
|
||||
],
|
||||
],
|
||||
|
|
@ -785,27 +834,39 @@ extension _SuitabilityCopy on _Suitability {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
/// Suitability rating for a model id. Curated DB + detected
|
||||
/// hardware win over the size-only heuristic when both are
|
||||
/// available; otherwise we fall back to parsing the param count
|
||||
/// out of the id.
|
||||
///
|
||||
/// Lookup order:
|
||||
/// 1. Curated entry → use `quality_tier` AND compare
|
||||
/// `min_hw_tier` against detected hardware. Only `good` or
|
||||
/// `excellent` curated entries that the hardware can actually
|
||||
/// run get the green star.
|
||||
/// 2. Size-only heuristic — same fallback logic as before.
|
||||
_Suitability _suitabilityFor(
|
||||
String rawId, {
|
||||
HardwareSnapshot? hw,
|
||||
Map<String, CuratedModelInfo> curatedById = const {},
|
||||
}) {
|
||||
final id = rawId.trim().toLowerCase();
|
||||
if (_kRecommendedModels.contains(id)) {
|
||||
return _Suitability.recommended;
|
||||
final curated = curatedById[id];
|
||||
if (curated != null) {
|
||||
final hwOk = hw == null || hw.rank < 0 || curated.minHwRank <= hw.rank;
|
||||
if ((curated.qualityTier == 'good' || curated.qualityTier == 'excellent') &&
|
||||
hwOk) {
|
||||
return _Suitability.recommended;
|
||||
}
|
||||
if (curated.qualityTier == 'good' || curated.qualityTier == 'excellent') {
|
||||
// Curated quality is fine, but hardware is below `min_hw_tier`.
|
||||
// Mark as large (orange) to signal "may be slow on this host".
|
||||
return _Suitability.large;
|
||||
}
|
||||
// Curated as `basic` — quality may be insufficient.
|
||||
return _Suitability.small;
|
||||
}
|
||||
// Try to extract a parameter count from common naming
|
||||
// Fallback: 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.
|
||||
|
|
@ -819,8 +880,8 @@ _Suitability _suitabilityFor(String rawId) {
|
|||
return _Suitability.huge;
|
||||
}
|
||||
}
|
||||
// OpenAI / Anthropic without size in the name — use known
|
||||
// family heuristics.
|
||||
// OpenAI / Anthropic without size in the name — known-family
|
||||
// heuristics.
|
||||
if (id.contains('mini') || id.contains('haiku') || id.contains('flash')) {
|
||||
return _Suitability.balanced;
|
||||
}
|
||||
|
|
@ -833,7 +894,11 @@ _Suitability _suitabilityFor(String rawId) {
|
|||
/// 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) {
|
||||
List<String> _sortedBySuitability(
|
||||
List<String> ids, {
|
||||
HardwareSnapshot? hw,
|
||||
Map<String, CuratedModelInfo> curatedById = const {},
|
||||
}) {
|
||||
int rank(_Suitability s) {
|
||||
switch (s) {
|
||||
case _Suitability.recommended:
|
||||
|
|
@ -853,8 +918,8 @@ List<String> _sortedBySuitability(List<String> ids) {
|
|||
|
||||
final out = [...ids];
|
||||
out.sort((a, b) {
|
||||
final ra = rank(_suitabilityFor(a));
|
||||
final rb = rank(_suitabilityFor(b));
|
||||
final ra = rank(_suitabilityFor(a, hw: hw, curatedById: curatedById));
|
||||
final rb = rank(_suitabilityFor(b, hw: hw, curatedById: curatedById));
|
||||
if (ra != rb) return ra.compareTo(rb);
|
||||
return a.compareTo(b);
|
||||
});
|
||||
|
|
@ -864,20 +929,44 @@ List<String> _sortedBySuitability(List<String> ids) {
|
|||
class _ModelChip extends StatelessWidget {
|
||||
final String id;
|
||||
final _Suitability suitability;
|
||||
/// Curated entry for this id, when one exists. Drives the
|
||||
/// tooltip — operators get the editorial note ("Solid 4B
|
||||
/// all-rounder…") rather than just the suitability label.
|
||||
final CuratedModelInfo? curated;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ModelChip({
|
||||
required this.id,
|
||||
required this.suitability,
|
||||
required this.curated,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
String _tooltipFor(BuildContext _) {
|
||||
final lines = <String>[suitability.label];
|
||||
final c = curated;
|
||||
if (c != null) {
|
||||
lines.add(
|
||||
'${c.qualityTier} · ${c.paramsB > 0 ? "${_fmtParams(c.paramsB)} params · " : ""}'
|
||||
'${c.contextK}K ctx · min hw: ${c.minHwTier}',
|
||||
);
|
||||
if (c.notes.isNotEmpty) lines.add(c.notes);
|
||||
if (c.license.isNotEmpty) lines.add('license: ${c.license}');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
static String _fmtParams(double b) {
|
||||
if (b == b.roundToDouble()) return '${b.toInt()}B';
|
||||
return '${b.toStringAsFixed(1)}B';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final color = suitability.color(theme.colorScheme);
|
||||
return Tooltip(
|
||||
message: suitability.label,
|
||||
message: _tooltipFor(context),
|
||||
child: ActionChip(
|
||||
avatar: Icon(
|
||||
suitability == _Suitability.recommended
|
||||
|
|
@ -894,7 +983,57 @@ class _ModelChip extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Compact banner that shows the detected hardware tier plus the
|
||||
/// curated DB's `last_reviewed` date, so the operator knows which
|
||||
/// "recommended" judgement they are reading.
|
||||
class _HardwareBanner extends StatelessWidget {
|
||||
final HardwareSnapshot hw;
|
||||
final String? lastReviewed;
|
||||
|
||||
const _HardwareBanner({required this.hw, required this.lastReviewed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final lr = (lastReviewed != null && lastReviewed!.isNotEmpty)
|
||||
? ' · curation reviewed $lastReviewed'
|
||||
: '';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.sm,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.memory,
|
||||
size: 13,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Detected: ${hw.summary}$lr',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SuitabilityLegend extends StatelessWidget {
|
||||
final HardwareSnapshot? hw;
|
||||
const _SuitabilityLegend({required this.hw});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
|
@ -915,6 +1054,7 @@ class _SuitabilityLegend extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
);
|
||||
final tierTag = (hw == null || hw!.tier == 'unknown') ? '' : ' (${hw!.tier})';
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
|
|
@ -926,7 +1066,7 @@ class _SuitabilityLegend extends StatelessWidget {
|
|||
Icon(Icons.star, size: 11, color: FaiColors.success),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'tested with F∆I',
|
||||
'recommended for this hardware$tierTag',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontSize: 11,
|
||||
|
|
@ -936,7 +1076,7 @@ class _SuitabilityLegend extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
dot(theme.colorScheme.primary, 'balanced (4-8B)'),
|
||||
dot(FaiColors.warning, 'small <3B / large 9-15B'),
|
||||
dot(FaiColors.warning, 'small <3B / large 9-15B / above hardware'),
|
||||
dot(theme.colorScheme.error, 'huge >15B'),
|
||||
dot(theme.colorScheme.outline, 'size unknown'),
|
||||
],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue