feat(studio): "Explain this" + System AI in Settings (v0.11.0)
Two new operator affordances powered by `HubAdmin.AskAi`:
* **Audit drill-down → "Explain" button.** Tappable on every
failed event when System AI is configured; greyed out with
a "Configure in Settings" tooltip otherwise. Shows an
inline panel under the event fields with the LLM's plain-
language explanation, a "FIX" suggestion, the privacy-mode
badge (`redacted` / `full`), and round-trip latency. Errors
map to actionable fix hints (`env_missing` → "set $VAR in
your shell, then fai daemon restart"; `network` → "is your
provider running?"; etc.) — no raw HTTP errors surfaced.
* **Settings dialog → System AI panel.** Renders the live
HubAdmin.SystemAiStatus (provider, endpoint, model,
api_key_env name, privacy mode). When disabled, shows a
one-line explainer + pointer to docs/architecture/system-ai.md.
Read-only for now; editing the config still requires
~/.fai/config.yaml (Phase-1+ adds in-place editing).
Both surfaces follow the new zero-learning-curve UX rule:
the user never has to look up what the configurable means or
how to fix an error — every state self-explains in place.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
060c8003d5
commit
4c4e2bb1eb
6 changed files with 415 additions and 4 deletions
|
|
@ -106,6 +106,35 @@ class HubService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// System-AI status snapshot — used by the Settings dialog
|
||||||
|
/// and the audit drill-down to decide whether the "Explain"
|
||||||
|
/// affordance is active. Always returns a struct (no
|
||||||
|
/// exceptions on disabled state).
|
||||||
|
Future<SystemAiStatus> systemAiStatus() async {
|
||||||
|
final r = await _client.systemAiStatus();
|
||||||
|
return SystemAiStatus(
|
||||||
|
enabled: r.enabled,
|
||||||
|
provider: r.provider,
|
||||||
|
endpoint: r.endpoint,
|
||||||
|
model: r.model,
|
||||||
|
privacyMode: r.privacyMode,
|
||||||
|
apiKeyEnv: r.apiKeyEnv,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a one-shot prompt to the System AI. Returns the
|
||||||
|
/// answer + latency on success or an [AskAiResult] with
|
||||||
|
/// `errorKind` set on failure (never throws). Studio maps
|
||||||
|
/// the error kind to its inline-fix copy.
|
||||||
|
Future<AskAiResult> askAi(String prompt) async {
|
||||||
|
final r = await _client.askAi(prompt);
|
||||||
|
return AskAiResult(
|
||||||
|
errorKind: r.errorKind,
|
||||||
|
text: r.text,
|
||||||
|
latencyMs: r.latencyMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Active channel + per-channel daemon status snapshot.
|
/// Active channel + per-channel daemon status snapshot.
|
||||||
Future<ChannelStatusSnapshot> channelStatus() async {
|
Future<ChannelStatusSnapshot> channelStatus() async {
|
||||||
final r = await _client.channelStatus();
|
final r = await _client.channelStatus();
|
||||||
|
|
@ -498,6 +527,67 @@ class PendingApproval {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SystemAiStatus {
|
||||||
|
final bool enabled;
|
||||||
|
final String provider;
|
||||||
|
final String endpoint;
|
||||||
|
final String model;
|
||||||
|
/// off / redacted / full.
|
||||||
|
final String privacyMode;
|
||||||
|
final String apiKeyEnv;
|
||||||
|
|
||||||
|
const SystemAiStatus({
|
||||||
|
required this.enabled,
|
||||||
|
required this.provider,
|
||||||
|
required this.endpoint,
|
||||||
|
required this.model,
|
||||||
|
required this.privacyMode,
|
||||||
|
required this.apiKeyEnv,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class AskAiResult {
|
||||||
|
/// "" on success; otherwise one of:
|
||||||
|
/// disabled / env_missing / network / http / parse / empty_response.
|
||||||
|
final String errorKind;
|
||||||
|
/// On success: the assistant's reply.
|
||||||
|
/// On failure: a human-readable diagnostic.
|
||||||
|
final String text;
|
||||||
|
/// Round-trip latency in ms (success only).
|
||||||
|
final int latencyMs;
|
||||||
|
|
||||||
|
const AskAiResult({
|
||||||
|
required this.errorKind,
|
||||||
|
required this.text,
|
||||||
|
required this.latencyMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isSuccess => errorKind.isEmpty;
|
||||||
|
|
||||||
|
/// One-line fix hint per error kind, mirroring
|
||||||
|
/// `docs/architecture/system-ai.md`. Empty on success.
|
||||||
|
String get fixHint {
|
||||||
|
switch (errorKind) {
|
||||||
|
case '':
|
||||||
|
return '';
|
||||||
|
case 'disabled':
|
||||||
|
return 'Configure System AI in Settings (Cmd+,) → System AI panel.';
|
||||||
|
case 'env_missing':
|
||||||
|
return 'The API-key env var is empty. `export <var>=<key>` in your shell, then `fai daemon restart`.';
|
||||||
|
case 'network':
|
||||||
|
return 'Hub cannot reach the configured endpoint. Is your provider running?';
|
||||||
|
case 'http':
|
||||||
|
return 'Provider returned a non-2xx status. Verify endpoint, model name, and API key.';
|
||||||
|
case 'parse':
|
||||||
|
return 'Provider returned an unexpected response shape. Non-OpenAI providers may need a compatibility proxy.';
|
||||||
|
case 'empty_response':
|
||||||
|
return 'Provider answered with no content. Try a different model or rephrase the prompt.';
|
||||||
|
default:
|
||||||
|
return 'Unknown failure kind ($errorKind). See system-ai.md.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ChannelInfo {
|
class ChannelInfo {
|
||||||
final String name;
|
final String name;
|
||||||
final int port;
|
final int port;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import 'widgets/widgets.dart';
|
||||||
/// Studio's own build version. Bump on every UI commit so the
|
/// Studio's own build version. Bump on every UI commit so the
|
||||||
/// running app self-identifies — visible in the sidebar header
|
/// running app self-identifies — visible in the sidebar header
|
||||||
/// and quick-glance proof that you're seeing the current build.
|
/// and quick-glance proof that you're seeing the current build.
|
||||||
const String kStudioVersion = '0.10.0';
|
const String kStudioVersion = '0.11.0';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
|
||||||
|
|
@ -303,11 +303,75 @@ class _LiveStatusBar extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _EventDetailDialog extends StatelessWidget {
|
class _EventDetailDialog extends StatefulWidget {
|
||||||
final AuditEvent event;
|
final AuditEvent event;
|
||||||
|
|
||||||
const _EventDetailDialog({required this.event});
|
const _EventDetailDialog({required this.event});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_EventDetailDialog> createState() => _EventDetailDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventDetailDialogState extends State<_EventDetailDialog> {
|
||||||
|
AskAiResult? _explanation;
|
||||||
|
bool _explaining = false;
|
||||||
|
SystemAiStatus? _aiStatus;
|
||||||
|
|
||||||
|
AuditEvent get event => widget.event;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadAiStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAiStatus() async {
|
||||||
|
try {
|
||||||
|
final s = await HubService.instance.systemAiStatus();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _aiStatus = s);
|
||||||
|
} catch (_) {
|
||||||
|
// Stay null → "Explain" stays hidden.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _explain() async {
|
||||||
|
setState(() {
|
||||||
|
_explaining = true;
|
||||||
|
_explanation = null;
|
||||||
|
});
|
||||||
|
final prompt = _buildPrompt(event, _aiStatus?.privacyMode ?? 'off');
|
||||||
|
final result = await HubService.instance.askAi(prompt);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_explaining = false;
|
||||||
|
_explanation = result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _buildPrompt(AuditEvent e, String mode) {
|
||||||
|
// Privacy: redacted = no detail JSON, full = include it.
|
||||||
|
final buf = StringBuffer()
|
||||||
|
..writeln('A F∆I Platform audit event was logged. Explain what')
|
||||||
|
..writeln('happened in plain language and suggest one concrete')
|
||||||
|
..writeln('fix the operator can apply now.')
|
||||||
|
..writeln()
|
||||||
|
..writeln('event_type: ${e.type}')
|
||||||
|
..writeln('flow: ${e.flowName ?? "(none)"}')
|
||||||
|
..writeln('step: ${e.stepId ?? "(none)"}')
|
||||||
|
..writeln('module: ${e.moduleName ?? "(none)"}'
|
||||||
|
'${e.moduleVersion != null ? "@${e.moduleVersion}" : ""}')
|
||||||
|
..writeln('error: ${e.error ?? "(none)"}')
|
||||||
|
..writeln('duration: ${e.durationMs ?? 0}ms');
|
||||||
|
if (mode == 'full' && e.detail != null) {
|
||||||
|
buf
|
||||||
|
..writeln()
|
||||||
|
..writeln('detail:')
|
||||||
|
..writeln(e.detail);
|
||||||
|
}
|
||||||
|
return buf.toString();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -400,11 +464,40 @@ class _EventDetailDialog extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
if (_explanation != null || _explaining) ...[
|
||||||
|
const SizedBox(height: FaiSpace.lg),
|
||||||
|
_ExplanationPanel(
|
||||||
|
explaining: _explaining,
|
||||||
|
result: _explanation,
|
||||||
|
privacyMode: _aiStatus?.privacyMode ?? 'off',
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
if (_aiStatus?.enabled == true && (event.error != null))
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _explaining ? null : _explain,
|
||||||
|
icon: const Icon(Icons.auto_awesome, size: 16),
|
||||||
|
label: Text(
|
||||||
|
_explaining
|
||||||
|
? 'Asking…'
|
||||||
|
: _explanation == null
|
||||||
|
? 'Explain'
|
||||||
|
: 'Re-ask',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_aiStatus != null && !_aiStatus!.enabled && (event.error != null))
|
||||||
|
Tooltip(
|
||||||
|
message: 'Configure System AI in Settings (Cmd+,)',
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: null,
|
||||||
|
icon: const Icon(Icons.auto_awesome_outlined, size: 16),
|
||||||
|
label: const Text('Explain'),
|
||||||
|
),
|
||||||
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => Navigator.pop(context),
|
||||||
child: const Text('Close'),
|
child: const Text('Close'),
|
||||||
|
|
@ -462,3 +555,112 @@ class _Field extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _ExplanationPanel extends StatelessWidget {
|
||||||
|
final bool explaining;
|
||||||
|
final AskAiResult? result;
|
||||||
|
final String privacyMode;
|
||||||
|
|
||||||
|
const _ExplanationPanel({
|
||||||
|
required this.explaining,
|
||||||
|
required this.result,
|
||||||
|
required this.privacyMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final ok = result?.isSuccess ?? false;
|
||||||
|
final color = explaining
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: ok
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: 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(Icons.auto_awesome, size: 14, color: color),
|
||||||
|
const SizedBox(width: FaiSpace.xs),
|
||||||
|
Text(
|
||||||
|
'SYSTEM AI',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.xs),
|
||||||
|
FaiPill(
|
||||||
|
label: privacyMode,
|
||||||
|
tone: privacyMode == 'full'
|
||||||
|
? FaiPillTone.warning
|
||||||
|
: FaiPillTone.neutral,
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (result != null && result!.isSuccess && result!.latencyMs > 0)
|
||||||
|
Text(
|
||||||
|
'${result!.latencyMs} ms',
|
||||||
|
style: FaiTheme.mono(
|
||||||
|
size: 10,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: FaiSpace.sm),
|
||||||
|
if (explaining)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
Text(
|
||||||
|
'Asking the configured System AI…',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else if (result != null) ...[
|
||||||
|
SelectableText(
|
||||||
|
result!.text,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: ok ? theme.colorScheme.onSurface : theme.colorScheme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (result!.fixHint.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: FaiSpace.sm),
|
||||||
|
Text(
|
||||||
|
'FIX',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
result!.fixHint,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
|
||||||
import '../data/hub.dart';
|
import '../data/hub.dart';
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
|
import 'fai_pill.dart';
|
||||||
|
|
||||||
class FaiSettingsDialog extends StatefulWidget {
|
class FaiSettingsDialog extends StatefulWidget {
|
||||||
const FaiSettingsDialog({super.key});
|
const FaiSettingsDialog({super.key});
|
||||||
|
|
@ -32,6 +33,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
ChannelStatusSnapshot? _channels;
|
ChannelStatusSnapshot? _channels;
|
||||||
|
SystemAiStatus? _aiStatus;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -41,6 +43,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
_port = TextEditingController(text: ep.port.toString());
|
_port = TextEditingController(text: ep.port.toString());
|
||||||
_secure = ep.secure;
|
_secure = ep.secure;
|
||||||
_loadChannels();
|
_loadChannels();
|
||||||
|
_loadAiStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadChannels() async {
|
Future<void> _loadChannels() async {
|
||||||
|
|
@ -55,6 +58,16 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _loadAiStatus() async {
|
||||||
|
try {
|
||||||
|
final s = await HubService.instance.systemAiStatus();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _aiStatus = s);
|
||||||
|
} catch (_) {
|
||||||
|
// Same fail-quiet rule as channels.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _connectToChannel(ChannelInfo ch) async {
|
Future<void> _connectToChannel(ChannelInfo ch) async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_host.text = '127.0.0.1';
|
_host.text = '127.0.0.1';
|
||||||
|
|
@ -221,6 +234,10 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
onConnect: _saving ? null : () => _connectToChannel(ch),
|
onConnect: _saving ? null : () => _connectToChannel(ch),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
if (_aiStatus != null) ...[
|
||||||
|
const SizedBox(height: FaiSpace.lg),
|
||||||
|
_SystemAiPanel(status: _aiStatus!),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -326,3 +343,105 @@ class _ChannelRow extends StatelessWidget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SystemAiPanel extends StatelessWidget {
|
||||||
|
final SystemAiStatus status;
|
||||||
|
|
||||||
|
const _SystemAiPanel({required this.status});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'SYSTEM AI',
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
letterSpacing: 0.6,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
FaiPill(
|
||||||
|
label: status.enabled ? 'enabled' : 'off',
|
||||||
|
tone: status.enabled ? FaiPillTone.success : FaiPillTone.neutral,
|
||||||
|
),
|
||||||
|
if (status.enabled) ...[
|
||||||
|
const SizedBox(width: FaiSpace.xs),
|
||||||
|
FaiPill(
|
||||||
|
label: status.privacyMode,
|
||||||
|
tone: status.privacyMode == 'full'
|
||||||
|
? FaiPillTone.warning
|
||||||
|
: FaiPillTone.neutral,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
if (status.enabled) ...[
|
||||||
|
_StatRow(label: 'provider', value: status.provider),
|
||||||
|
_StatRow(label: 'endpoint', value: status.endpoint, mono: true),
|
||||||
|
_StatRow(label: 'model', value: status.model, mono: true),
|
||||||
|
if (status.apiKeyEnv.isNotEmpty)
|
||||||
|
_StatRow(label: 'api_key_env', value: '\$${status.apiKeyEnv}', mono: true),
|
||||||
|
] else ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Text(
|
||||||
|
'Off — explanations on failed events are disabled. Add a `system_llm:` block to ~/.fai/config.yaml and restart the daemon. See docs/architecture/system-ai.md.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final bool mono;
|
||||||
|
|
||||||
|
const _StatRow({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.mono = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 1),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 96,
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value.isEmpty ? '—' : value,
|
||||||
|
style: mono
|
||||||
|
? FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface)
|
||||||
|
: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ packages:
|
||||||
path: "../fai_dart_sdk"
|
path: "../fai_dart_sdk"
|
||||||
relative: true
|
relative: true
|
||||||
source: path
|
source: path
|
||||||
version: "0.3.0"
|
version: "0.4.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.10.0
|
version: 0.11.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