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
|
|
@ -21,6 +21,7 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
late final TabController _tab;
|
||||
late Future<List<ApprovalRecord>> _pendingFuture;
|
||||
late Future<List<ApprovalRecord>> _historyFuture;
|
||||
|
||||
/// IDs the operator currently has multi-selected for a
|
||||
/// batch operation. Empty set hides the batch action bar
|
||||
/// and falls back to per-row Approve / Reject buttons.
|
||||
|
|
@ -32,7 +33,8 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
late final String _reviewer = _defaultReviewer();
|
||||
|
||||
static String _defaultReviewer() {
|
||||
final user = Platform.environment['USER'] ??
|
||||
final user =
|
||||
Platform.environment['USER'] ??
|
||||
Platform.environment['USERNAME'] ??
|
||||
'studio';
|
||||
return '$user@studio';
|
||||
|
|
@ -124,9 +126,11 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _batchInFlight = false);
|
||||
_toast(failed == 0
|
||||
? l.approvalsBatchApproveDoneToast(ok)
|
||||
: l.approvalsBatchPartialFailure(ok, failed));
|
||||
_toast(
|
||||
failed == 0
|
||||
? l.approvalsBatchApproveDoneToast(ok)
|
||||
: l.approvalsBatchPartialFailure(ok, failed),
|
||||
);
|
||||
_refresh();
|
||||
}
|
||||
|
||||
|
|
@ -148,9 +152,11 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _batchInFlight = false);
|
||||
_toast(failed == 0
|
||||
? l.approvalsBatchRejectDoneToast(ok)
|
||||
: l.approvalsBatchPartialFailure(ok, failed));
|
||||
_toast(
|
||||
failed == 0
|
||||
? l.approvalsBatchRejectDoneToast(ok)
|
||||
: l.approvalsBatchPartialFailure(ok, failed),
|
||||
);
|
||||
_refresh();
|
||||
}
|
||||
|
||||
|
|
@ -231,10 +237,7 @@ class _ApprovalsPageState extends State<ApprovalsPage>
|
|||
onBatchReject: _batchReject,
|
||||
onRetry: _refresh,
|
||||
),
|
||||
_HistoryList(
|
||||
future: _historyFuture,
|
||||
onRetry: _refresh,
|
||||
),
|
||||
_HistoryList(future: _historyFuture, onRetry: _refresh),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -391,8 +394,11 @@ class _BatchActionBar extends StatelessWidget {
|
|||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.check_box_outlined,
|
||||
size: 18, color: theme.colorScheme.primary),
|
||||
Icon(
|
||||
Icons.check_box_outlined,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
l.approvalsBatchSelected(selectedCount),
|
||||
|
|
@ -546,10 +552,7 @@ class _ApprovalCard extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
Text(
|
||||
approval.prompt,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
Text(approval.prompt, style: theme.textTheme.bodyLarge),
|
||||
if (approval.payloadPreview != null) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Padding(
|
||||
|
|
@ -648,8 +651,7 @@ class _HistoryRow extends StatelessWidget {
|
|||
final theme = Theme.of(context);
|
||||
final (tone, icon, label) = _statusPresentation(context, record.status);
|
||||
final decided = record.decidedAt;
|
||||
final timeStr =
|
||||
decided == null ? '' : _formatTimestamp(decided.toLocal());
|
||||
final timeStr = decided == null ? '' : _formatTimestamp(decided.toLocal());
|
||||
return InkWell(
|
||||
onTap: () => showDialog<void>(
|
||||
context: context,
|
||||
|
|
@ -728,8 +730,10 @@ class _HistoryDialog extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final (tone, icon, label) =
|
||||
_HistoryRow._statusPresentation(context, record.status);
|
||||
final (tone, icon, label) = _HistoryRow._statusPresentation(
|
||||
context,
|
||||
record.status,
|
||||
);
|
||||
return AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
|
|
@ -752,14 +756,23 @@ class _HistoryDialog extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (record.decidedAt != null)
|
||||
_kv(theme, AppLocalizations.of(context)!.approvalsDialogDecided,
|
||||
'${_formatTimestamp(record.decidedAt!.toLocal())} '
|
||||
'· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'),
|
||||
_kv(theme, AppLocalizations.of(context)!.approvalsDialogCreated,
|
||||
_formatTimestamp(record.createdAt.toLocal())),
|
||||
_kv(
|
||||
theme,
|
||||
AppLocalizations.of(context)!.approvalsDialogDecided,
|
||||
'${_formatTimestamp(record.decidedAt!.toLocal())} '
|
||||
'· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}',
|
||||
),
|
||||
_kv(
|
||||
theme,
|
||||
AppLocalizations.of(context)!.approvalsDialogCreated,
|
||||
_formatTimestamp(record.createdAt.toLocal()),
|
||||
),
|
||||
if (record.reason.isNotEmpty)
|
||||
_kv(theme, AppLocalizations.of(context)!.approvalsDialogReason,
|
||||
record.reason),
|
||||
_kv(
|
||||
theme,
|
||||
AppLocalizations.of(context)!.approvalsDialogReason,
|
||||
record.reason,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.approvalsDialogPrompt,
|
||||
|
|
@ -769,10 +782,7 @@ class _HistoryDialog extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
record.prompt,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
SelectableText(record.prompt, style: theme.textTheme.bodyMedium),
|
||||
if (record.payloadPreview != null) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Text(
|
||||
|
|
@ -834,12 +844,7 @@ class _HistoryDialog extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SelectableText(
|
||||
v,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
Expanded(child: SelectableText(v, style: theme.textTheme.bodySmall)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -28,10 +28,7 @@ class _AuditPageState extends State<AuditPage> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
_refresh();
|
||||
_poller = Timer.periodic(
|
||||
const Duration(seconds: 2),
|
||||
(_) => _refresh(),
|
||||
);
|
||||
_poller = Timer.periodic(const Duration(seconds: 2), (_) => _refresh());
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -56,9 +53,9 @@ class _AuditPageState extends State<AuditPage> {
|
|||
_refresh();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(l.auditClearFailed(_friendly(e)))),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(l.auditClearFailed(_friendly(e)))));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -124,26 +121,25 @@ class _AuditPageState extends State<AuditPage> {
|
|||
child: !_initialLoaded
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null && _events.isEmpty
|
||||
? FaiEmptyState(
|
||||
icon: Icons.cloud_off_outlined,
|
||||
iconColor: theme.colorScheme.error,
|
||||
title: AppLocalizations.of(context)!.hubUnreachable,
|
||||
hint: AppLocalizations.of(context)!.hubUnreachableHint,
|
||||
)
|
||||
: filtered.isEmpty
|
||||
? FaiEmptyState(
|
||||
icon: Icons.timeline_outlined,
|
||||
title: AppLocalizations.of(context)!.auditNoEvents,
|
||||
hint:
|
||||
AppLocalizations.of(context)!.auditNoEventsHint,
|
||||
)
|
||||
: _GroupedEventList(
|
||||
events: filtered,
|
||||
allEvents: _events,
|
||||
toneFor: (t) => _toneFor(t, theme),
|
||||
formatTime: _formatTime,
|
||||
contextLine: _contextLine,
|
||||
),
|
||||
? FaiEmptyState(
|
||||
icon: Icons.cloud_off_outlined,
|
||||
iconColor: theme.colorScheme.error,
|
||||
title: AppLocalizations.of(context)!.hubUnreachable,
|
||||
hint: AppLocalizations.of(context)!.hubUnreachableHint,
|
||||
)
|
||||
: filtered.isEmpty
|
||||
? FaiEmptyState(
|
||||
icon: Icons.timeline_outlined,
|
||||
title: AppLocalizations.of(context)!.auditNoEvents,
|
||||
hint: AppLocalizations.of(context)!.auditNoEventsHint,
|
||||
)
|
||||
: _GroupedEventList(
|
||||
events: filtered,
|
||||
allEvents: _events,
|
||||
toneFor: (t) => _toneFor(t, theme),
|
||||
formatTime: _formatTime,
|
||||
contextLine: _contextLine,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -164,7 +160,8 @@ class _AuditPageState extends State<AuditPage> {
|
|||
final mm = local.minute.toString().padLeft(2, '0');
|
||||
final ss = local.second.toString().padLeft(2, '0');
|
||||
final time = '$hh:$mm:$ss';
|
||||
final sameDay = local.year == now.year &&
|
||||
final sameDay =
|
||||
local.year == now.year &&
|
||||
local.month == now.month &&
|
||||
local.day == now.day;
|
||||
if (sameDay) return time;
|
||||
|
|
@ -252,10 +249,8 @@ class _GroupedEventList extends StatelessWidget {
|
|||
trailing: e.durationMs != null ? '${e.durationMs}ms' : null,
|
||||
onTap: () => showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => _EventDetailDialog(
|
||||
event: e,
|
||||
allEvents: allEvents,
|
||||
),
|
||||
builder: (_) =>
|
||||
_EventDetailDialog(event: e, allEvents: allEvents),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -285,11 +280,7 @@ class _GroupedEventList extends StatelessWidget {
|
|||
/// timezone so an event at 23:55 yesterday in Berlin doesn't
|
||||
/// land in "today" because UTC happened to spill into a new
|
||||
/// day.
|
||||
static String _bucketLabel(
|
||||
DateTime ts,
|
||||
DateTime now,
|
||||
AppLocalizations l,
|
||||
) {
|
||||
static String _bucketLabel(DateTime ts, DateTime now, AppLocalizations l) {
|
||||
final local = ts.toLocal();
|
||||
final localNow = now.toLocal();
|
||||
final today = DateTime(localNow.year, localNow.month, localNow.day);
|
||||
|
|
@ -371,13 +362,13 @@ class _ChipButtonState extends State<_ChipButton> {
|
|||
final fg = widget.selected
|
||||
? theme.colorScheme.primary
|
||||
: _hovered
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant;
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurfaceVariant;
|
||||
final bg = widget.selected
|
||||
? theme.colorScheme.primary.withValues(alpha: 0.12)
|
||||
: _hovered
|
||||
? theme.colorScheme.surfaceContainerHigh
|
||||
: Colors.transparent;
|
||||
? theme.colorScheme.surfaceContainerHigh
|
||||
: Colors.transparent;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hovered = true),
|
||||
onExit: (_) => setState(() => _hovered = false),
|
||||
|
|
@ -444,9 +435,7 @@ class _LiveStatusBar extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
live
|
||||
? l.auditLiveStatus(eventCount)
|
||||
: l.auditDisconnected(error!),
|
||||
live ? l.auditLiveStatus(eventCount) : l.auditDisconnected(error!),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -478,6 +467,7 @@ class _LiveStatusBar extends StatelessWidget {
|
|||
|
||||
class _EventDetailDialog extends StatefulWidget {
|
||||
final AuditEvent event;
|
||||
|
||||
/// Full event window the audit page already fetched. Lets
|
||||
/// the dialog surface every event sharing this event's
|
||||
/// `flow_execution` without a fresh round-trip — flow runs
|
||||
|
|
@ -485,10 +475,7 @@ class _EventDetailDialog extends StatefulWidget {
|
|||
/// page polls on.
|
||||
final List<AuditEvent> allEvents;
|
||||
|
||||
const _EventDetailDialog({
|
||||
required this.event,
|
||||
required this.allEvents,
|
||||
});
|
||||
const _EventDetailDialog({required this.event, required this.allEvents});
|
||||
|
||||
@override
|
||||
State<_EventDetailDialog> createState() => _EventDetailDialogState();
|
||||
|
|
@ -544,8 +531,10 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
|
|||
..writeln('event_type: ${e.type}')
|
||||
..writeln('flow: ${e.flowName ?? "(none)"}')
|
||||
..writeln('step: ${e.stepId ?? "(none)"}')
|
||||
..writeln('module: ${e.moduleName ?? "(none)"}'
|
||||
'${e.moduleVersion != null ? "@${e.moduleVersion}" : ""}')
|
||||
..writeln(
|
||||
'module: ${e.moduleName ?? "(none)"}'
|
||||
'${e.moduleVersion != null ? "@${e.moduleVersion}" : ""}',
|
||||
)
|
||||
..writeln('error: ${e.error ?? "(none)"}')
|
||||
..writeln('duration: ${e.durationMs ?? 0}ms');
|
||||
if (mode == 'full' && e.detail != null) {
|
||||
|
|
@ -640,14 +629,9 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
|
|||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: SelectableText(
|
||||
detail,
|
||||
style: FaiTheme.mono(size: 11),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: SelectableText(detail, style: FaiTheme.mono(size: 11)),
|
||||
),
|
||||
],
|
||||
if (_explanation != null || _explaining) ...[
|
||||
|
|
@ -690,11 +674,13 @@ class _EventDetailDialogState extends State<_EventDetailDialog> {
|
|||
_explaining
|
||||
? l.auditAsking
|
||||
: _explanation == null
|
||||
? l.auditExplain
|
||||
: l.auditReask,
|
||||
? l.auditExplain
|
||||
: l.auditReask,
|
||||
),
|
||||
)
|
||||
else if (_aiStatus != null && !_aiStatus!.enabled && (event.error != null))
|
||||
else if (_aiStatus != null &&
|
||||
!_aiStatus!.enabled &&
|
||||
(event.error != null))
|
||||
Tooltip(
|
||||
message: l.auditConfigureSystemAi,
|
||||
child: OutlinedButton.icon(
|
||||
|
|
@ -734,10 +720,9 @@ class _FlowRunDialog extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
final related = allEvents
|
||||
.where((e) => e.flowExecution == flowExecution)
|
||||
.toList()
|
||||
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
final related =
|
||||
allEvents.where((e) => e.flowExecution == flowExecution).toList()
|
||||
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
|
||||
final maxHeight = MediaQuery.of(context).size.height * 0.75;
|
||||
return AlertDialog(
|
||||
title: Text(l.auditFlowRunDialogTitle(flowName)),
|
||||
|
|
@ -761,8 +746,7 @@ class _FlowRunDialog extends StatelessWidget {
|
|||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: related.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(height: FaiSpace.xs),
|
||||
separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.xs),
|
||||
itemBuilder: (context, i) {
|
||||
final e = related[i];
|
||||
return FaiDataRow(
|
||||
|
|
@ -770,8 +754,7 @@ class _FlowRunDialog extends StatelessWidget {
|
|||
leading: _formatRelativeTime(e.timestamp),
|
||||
title: e.type,
|
||||
subtitle: _stepLine(e),
|
||||
trailing:
|
||||
e.durationMs != null ? '${e.durationMs}ms' : null,
|
||||
trailing: e.durationMs != null ? '${e.durationMs}ms' : null,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -865,6 +848,7 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
final bool explaining;
|
||||
final AskAiResult? result;
|
||||
final String privacyMode;
|
||||
|
||||
/// Triggered by the "Regenerate" affordance — passes
|
||||
/// `forceFresh: true` so the next askAi skips the cache and
|
||||
/// hits the live provider. Null while [explaining] is true.
|
||||
|
|
@ -885,8 +869,8 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
final color = explaining
|
||||
? theme.colorScheme.primary
|
||||
: ok
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.error;
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.error;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(FaiSpace.md),
|
||||
|
|
@ -945,7 +929,9 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (result != null && result!.isSuccess && onRegenerate != null) ...[
|
||||
if (result != null &&
|
||||
result!.isSuccess &&
|
||||
onRegenerate != null) ...[
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Tooltip(
|
||||
message: l.auditRegenerateTooltip,
|
||||
|
|
@ -980,7 +966,9 @@ class _ExplanationPanel extends StatelessWidget {
|
|||
SelectableText(
|
||||
result!.text,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: ok ? theme.colorScheme.onSurface : theme.colorScheme.error,
|
||||
color: ok
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
if (result!.fixHint.isNotEmpty) ...[
|
||||
|
|
@ -1040,7 +1028,8 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final user = Platform.environment['USER'] ??
|
||||
final user =
|
||||
Platform.environment['USER'] ??
|
||||
Platform.environment['USERNAME'] ??
|
||||
'operator';
|
||||
_reviewer = TextEditingController(text: '$user@studio');
|
||||
|
|
@ -1111,12 +1100,12 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> {
|
|||
onPressed: _reason.text.trim().isEmpty
|
||||
? null
|
||||
: () => Navigator.pop(
|
||||
context,
|
||||
_ClearOutcome(
|
||||
reviewer: _reviewer.text.trim(),
|
||||
reason: _reason.text.trim(),
|
||||
),
|
||||
context,
|
||||
_ClearOutcome(
|
||||
reviewer: _reviewer.text.trim(),
|
||||
reason: _reason.text.trim(),
|
||||
),
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
foregroundColor: Theme.of(context).colorScheme.onError,
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@ class _DoctorPageState extends State<DoctorPage> {
|
|||
}
|
||||
|
||||
void _refresh() => setState(() {
|
||||
_future = HubService.instance.doctor();
|
||||
});
|
||||
_future = HubService.instance.doctor();
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -62,9 +62,9 @@ class _DoctorPageState extends State<DoctorPage> {
|
|||
);
|
||||
}
|
||||
final s = snapshot.data!;
|
||||
final showUpdate = s.update.updateAvailable ||
|
||||
(!s.update.manifestReachable &&
|
||||
s.update.localVersion.isNotEmpty);
|
||||
final showUpdate =
|
||||
s.update.updateAvailable ||
|
||||
(!s.update.manifestReachable && s.update.localVersion.isNotEmpty);
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(FaiSpace.xl),
|
||||
children: [
|
||||
|
|
@ -74,14 +74,13 @@ class _DoctorPageState extends State<DoctorPage> {
|
|||
const SizedBox(height: FaiSpace.xl),
|
||||
_Section(
|
||||
title: AppLocalizations.of(context)!.doctorEventLogSection,
|
||||
child: _EventLogPanel(
|
||||
snapshot: s,
|
||||
onRefresh: _refresh,
|
||||
),
|
||||
child: _EventLogPanel(snapshot: s, onRefresh: _refresh),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
_Section(
|
||||
title: AppLocalizations.of(context)!.doctorModulesApprovalsSection,
|
||||
title: AppLocalizations.of(
|
||||
context,
|
||||
)!.doctorModulesApprovalsSection,
|
||||
child: _ModulesPanel(snapshot: s),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
|
|
@ -120,7 +119,10 @@ class _Section extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: FaiSpace.xs, bottom: FaiSpace.sm),
|
||||
padding: const EdgeInsets.only(
|
||||
left: FaiSpace.xs,
|
||||
bottom: FaiSpace.sm,
|
||||
),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
|
|
@ -259,6 +261,7 @@ class _StatTile extends StatelessWidget {
|
|||
|
||||
class _EventLogPanel extends StatelessWidget {
|
||||
final DoctorSnapshot snapshot;
|
||||
|
||||
/// Re-runs the doctor() call which re-verifies the chain.
|
||||
/// Wired to a "Verify now" button so operators can re-check
|
||||
/// after import / restore without restarting the daemon.
|
||||
|
|
@ -361,12 +364,7 @@ class _DaemonPathsPanel extends StatelessWidget {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final e in entries)
|
||||
_PathRow(
|
||||
label: e.$1,
|
||||
path: e.$2,
|
||||
icon: e.$3,
|
||||
isDirectory: e.$4,
|
||||
),
|
||||
_PathRow(label: e.$1, path: e.$2, icon: e.$3, isDirectory: e.$4),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -485,10 +483,11 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
if (!mounted) return;
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_output = (r.ok
|
||||
? 'OK · $label\n${r.stdout}'
|
||||
: 'Failed · $label\n${r.stderr.isEmpty ? r.stdout : r.stderr}')
|
||||
.trim();
|
||||
_output =
|
||||
(r.ok
|
||||
? 'OK · $label\n${r.stdout}'
|
||||
: 'Failed · $label\n${r.stderr.isEmpty ? r.stdout : r.stderr}')
|
||||
.trim();
|
||||
});
|
||||
// Refresh the status pill — restart / stop / start all
|
||||
// change the running flag.
|
||||
|
|
@ -535,8 +534,8 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
active == null
|
||||
? l.doctorStatusUnknown
|
||||
: active.running
|
||||
? l.doctorRunningOn(active.name, active.endpoint)
|
||||
: l.doctorStoppedOn(active.name),
|
||||
? l.doctorRunningOn(active.name, active.endpoint)
|
||||
: l.doctorStoppedOn(active.name),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
|
|
@ -572,9 +571,9 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
onPressed: _busy
|
||||
? null
|
||||
: () => _run(
|
||||
'daemon restart',
|
||||
() => SystemActions.faiDaemon(['restart']),
|
||||
),
|
||||
'daemon restart',
|
||||
() => SystemActions.faiDaemon(['restart']),
|
||||
),
|
||||
icon: const Icon(Icons.restart_alt, size: 16),
|
||||
label: Text(l.doctorRestart),
|
||||
),
|
||||
|
|
@ -583,9 +582,9 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
onPressed: _busy
|
||||
? null
|
||||
: () => _run(
|
||||
'daemon start',
|
||||
() => SystemActions.faiDaemon(['start']),
|
||||
),
|
||||
'daemon start',
|
||||
() => SystemActions.faiDaemon(['start']),
|
||||
),
|
||||
icon: const Icon(Icons.play_arrow, size: 16),
|
||||
label: Text(l.doctorStart),
|
||||
),
|
||||
|
|
@ -593,9 +592,9 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
onPressed: _busy
|
||||
? null
|
||||
: () => _run(
|
||||
'daemon stop',
|
||||
() => SystemActions.faiDaemon(['stop']),
|
||||
),
|
||||
'daemon stop',
|
||||
() => SystemActions.faiDaemon(['stop']),
|
||||
),
|
||||
icon: const Icon(Icons.stop_circle_outlined, size: 16),
|
||||
label: Text(l.doctorStop),
|
||||
),
|
||||
|
|
@ -603,9 +602,9 @@ class _DaemonActionsCardState extends State<_DaemonActionsCard> {
|
|||
onPressed: _busy
|
||||
? null
|
||||
: () => _run(
|
||||
'daemon status',
|
||||
() => SystemActions.faiDaemon(['status']),
|
||||
),
|
||||
'daemon status',
|
||||
() => SystemActions.faiDaemon(['status']),
|
||||
),
|
||||
icon: const Icon(Icons.health_and_safety_outlined, size: 16),
|
||||
label: Text(l.doctorStatusAction),
|
||||
),
|
||||
|
|
@ -801,10 +800,7 @@ class _ServicesPanel extends StatelessWidget {
|
|||
children: [
|
||||
for (var i = 0; i < snapshot.services.length; i++) ...[
|
||||
if (i > 0)
|
||||
Divider(
|
||||
height: 1,
|
||||
color: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
Divider(height: 1, color: theme.colorScheme.outlineVariant),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(FaiSpace.lg),
|
||||
child: Row(
|
||||
|
|
@ -940,7 +936,9 @@ class _UpdateBannerState extends State<_UpdateBanner> {
|
|||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.system_update_alt, size: 16),
|
||||
label: Text(_applying ? l.doctorApplying : l.doctorApplyUpdate),
|
||||
label: Text(
|
||||
_applying ? l.doctorApplying : l.doctorApplyUpdate,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
FaiPill(
|
||||
|
|
|
|||
|
|
@ -48,11 +48,9 @@ class _FlowsPageState extends State<FlowsPage> {
|
|||
Future<List<String>> _loadCapabilities() async {
|
||||
try {
|
||||
final caps = await HubService.instance.allCapabilities();
|
||||
final entries = caps
|
||||
.map((c) => '${c.capability}@${c.version}')
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
final entries =
|
||||
caps.map((c) => '${c.capability}@${c.version}').toSet().toList()
|
||||
..sort();
|
||||
return entries;
|
||||
} catch (_) {
|
||||
return const <String>[];
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ class _ModulesPageState extends State<ModulesPage> {
|
|||
}
|
||||
|
||||
void _refresh() => setState(() {
|
||||
_future = HubService.instance.listModules();
|
||||
_historyFuture = _loadHistory();
|
||||
});
|
||||
_future = HubService.instance.listModules();
|
||||
_historyFuture = _loadHistory();
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -87,8 +87,10 @@ class _ModulesPageState extends State<ModulesPage> {
|
|||
if (i > 0) const SizedBox(height: FaiSpace.md),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final uninstalled =
|
||||
await FaiModuleSheet.show(context, modules[i].name);
|
||||
final uninstalled = await FaiModuleSheet.show(
|
||||
context,
|
||||
modules[i].name,
|
||||
);
|
||||
if (uninstalled) _refresh();
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
|
|
@ -171,7 +173,6 @@ class _ModuleCard extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
/// Compact "Recent activity" header listing the last few
|
||||
/// install / uninstall events from the audit log. Lets
|
||||
/// operators trace "wait, when did that module appear?"
|
||||
|
|
@ -220,8 +221,9 @@ class _RecentActivityPanel extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
AppLocalizations.of(context)!
|
||||
.modulesRecentActivityHint(events.length),
|
||||
AppLocalizations.of(
|
||||
context,
|
||||
)!.modulesRecentActivityHint(events.length),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -292,7 +294,8 @@ String _formatTimestamp(DateTime local) {
|
|||
final mm = local.minute.toString().padLeft(2, '0');
|
||||
final ss = local.second.toString().padLeft(2, '0');
|
||||
final time = '$hh:$mm:$ss';
|
||||
final sameDay = local.year == now.year &&
|
||||
final sameDay =
|
||||
local.year == now.year &&
|
||||
local.month == now.month &&
|
||||
local.day == now.day;
|
||||
if (sameDay) return time;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue