import 'package:flutter/material.dart'; import '../data/hub.dart'; import '../l10n/app_localizations.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; class ModulesPage extends StatefulWidget { const ModulesPage({super.key}); @override State createState() => _ModulesPageState(); } class _ModulesPageState extends State { late Future> _future; late Future> _historyFuture; @override void initState() { super.initState(); _future = HubService.instance.listModules(); _historyFuture = _loadHistory(); } Future> _loadHistory() { return HubService.instance.recentEvents( limit: 10, types: const ['module.installed', 'module.uninstalled'], ); } void _refresh() => setState(() { _future = HubService.instance.listModules(); _historyFuture = _loadHistory(); }); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Theme.of(context).scaffoldBackgroundColor, appBar: AppBar( title: Text(AppLocalizations.of(context)!.modulesTitle), actions: [ IconButton( icon: const Icon(Icons.refresh, size: 18), tooltip: AppLocalizations.of(context)!.modulesReloadTooltip, onPressed: _refresh, ), const SizedBox(width: FaiSpace.sm), ], ), body: FutureBuilder>( future: _future, builder: (context, snapshot) { final l = AppLocalizations.of(context)!; if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return FaiEmptyState( icon: Icons.cloud_off_outlined, iconColor: Theme.of(context).colorScheme.error, title: l.hubUnreachable, hint: l.hubUnreachableHint, action: FilledButton.tonal( onPressed: _refresh, child: Text(l.buttonRetry), ), ); } final modules = snapshot.data ?? []; if (modules.isEmpty) { return FaiEmptyState( icon: Icons.extension_outlined, title: l.modulesNoneTitle, hint: l.modulesNoneHint, ); } return ListView( padding: const EdgeInsets.all(FaiSpace.xl), children: [ _RecentActivityPanel(future: _historyFuture), const SizedBox(height: FaiSpace.md), for (var i = 0; i < modules.length; i++) ...[ if (i > 0) const SizedBox(height: FaiSpace.md), GestureDetector( onTap: () async { final uninstalled = await FaiModuleSheet.show( context, modules[i].name, ); if (uninstalled) _refresh(); }, behavior: HitTestBehavior.opaque, child: MouseRegion( cursor: SystemMouseCursors.click, child: _ModuleCard(module: modules[i]), ), ), ], ], ); }, ), ); } } class _ModuleCard extends StatelessWidget { final ModuleSummary module; const _ModuleCard({required this.module}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return FaiCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( module.name, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(width: FaiSpace.sm), FaiPill( label: 'v${module.version}', tone: FaiPillTone.neutral, monospace: true, ), ], ), const SizedBox(height: FaiSpace.md), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 110, child: Text( AppLocalizations.of(context)!.modulesCapabilitiesLabel, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.4, ), ), ), Expanded( child: Wrap( spacing: FaiSpace.xs, runSpacing: FaiSpace.xs, children: module.capabilities .map( (c) => FaiPill( label: c, tone: FaiPillTone.accent, monospace: true, ), ) .toList(), ), ), ], ), ], ), ); } } /// Compact "Recent activity" header listing the last few /// install / uninstall events from the audit log. Lets /// operators trace "wait, when did that module appear?" /// without leaving the Modules page. Hidden when the audit /// log has no relevant events yet. class _RecentActivityPanel extends StatelessWidget { final Future> future; const _RecentActivityPanel({required this.future}); @override Widget build(BuildContext context) { final theme = Theme.of(context); return FutureBuilder>( future: future, builder: (context, snap) { final events = snap.data ?? const []; if (events.isEmpty) return const SizedBox.shrink(); return Container( padding: const EdgeInsets.symmetric( horizontal: FaiSpace.md, vertical: FaiSpace.sm, ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(FaiRadius.sm), border: Border.all(color: theme.colorScheme.outlineVariant), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.history, size: 14, color: theme.colorScheme.onSurfaceVariant, ), const SizedBox(width: 6), Text( AppLocalizations.of(context)!.modulesRecentActivity, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, fontSize: 10, ), ), const SizedBox(width: FaiSpace.sm), Text( AppLocalizations.of( context, )!.modulesRecentActivityHint(events.length), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), ], ), const SizedBox(height: FaiSpace.xs), for (final e in events) _ActivityRow(event: e), ], ), ); }, ); } } class _ActivityRow extends StatelessWidget { final AuditEvent event; const _ActivityRow({required this.event}); @override Widget build(BuildContext context) { final theme = Theme.of(context); final installed = event.type == 'module.installed'; final accent = installed ? FaiColors.success : FaiColors.warning; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( children: [ Icon( installed ? Icons.add_circle_outline : Icons.remove_circle_outline, size: 14, color: accent, ), const SizedBox(width: 6), SizedBox( width: 150, child: Text( _formatTimestamp(event.timestamp.toLocal()), style: FaiTheme.mono( size: 10, color: theme.colorScheme.onSurfaceVariant, ), ), ), Expanded( child: Text( '${installed ? AppLocalizations.of(context)!.modulesActivityInstalled : AppLocalizations.of(context)!.modulesActivityUninstalled} ' '${event.moduleName ?? "(unknown)"}' '${event.moduleVersion != null ? " v${event.moduleVersion}" : ""}', style: theme.textTheme.bodySmall, overflow: TextOverflow.ellipsis, ), ), ], ), ); } } /// Locale-unambiguous timestamp shared by the activity rows. /// Same-day events get HH:mm:ss; older events get the full /// YYYY-MM-DD HH:mm:ss so an operator never wonders what day /// "21:25" was. String _formatTimestamp(DateTime local) { final now = DateTime.now(); final hh = local.hour.toString().padLeft(2, '0'); 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 && local.month == now.month && local.day == now.day; if (sameDay) return time; final mo = local.month.toString().padLeft(2, '0'); final dd = local.day.toString().padLeft(2, '0'); return '${local.year}-$mo-$dd $time'; }