feat(studio): rich theme picker (presets + custom colour) + editor 0.11.0
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:
flemming-it 2026-06-02 01:29:34 +02:00
parent 69864da934
commit c1c60d5434
30 changed files with 1280 additions and 846 deletions

View file

@ -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)),
],
),
);

View file

@ -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,

View file

@ -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(

View file

@ -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>[];

View file

@ -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;