From b3fdd69139fdfc879d8864057cf9428f792db797 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Tue, 5 May 2026 22:15:41 +0200 Subject: [PATCH] feat(ui): module-detail bottom sheet on tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a module card on the Modules page slides up a detail sheet showing the full manifest data: directory path (monospaced + selectable), every capability with version, and every declared permission with a semantic icon (net = globe, fs.read = folder, fs.write = edit, env = terminal, hub = shield). Empty permission list shows "(none — pure-computation module)" explicitly — the operator should see *why* a module needed zero perms, not be left guessing. Backed by HubAdmin.ModuleInfo. New widget FaiModuleSheet with a small drag-handle, FutureBuilder for the load state, error surfacing if the RPC fails. Bumps fai_studio 0.5.0 -> 0.6.0. Signed-off-by: flemming-it --- lib/data/hub.dart | 30 ++++ lib/pages/modules.dart | 12 +- lib/widgets/fai_module_sheet.dart | 230 ++++++++++++++++++++++++++++++ lib/widgets/widgets.dart | 1 + 4 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 lib/widgets/fai_module_sheet.dart diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 6d5d51c..34e11b1 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -76,6 +76,20 @@ class HubService { ..sort((a, b) => a.name.compareTo(b.name)); } + /// Fully-detailed manifest for one installed module. + Future moduleInfo(String name) async { + final r = await _client.moduleInfo(name); + return ModuleDetail( + name: r.name, + version: r.version, + capabilities: r.provides + .map((c) => '${c.namespace}.${c.name}@${c.versionReq}') + .toList(), + permissions: r.permissions, + directory: r.directory, + ); + } + Future> recentEvents({ int limit = 50, List types = const [], @@ -216,6 +230,22 @@ class ModuleSummary { }); } +class ModuleDetail { + final String name; + final String version; + final List capabilities; + final List permissions; + final String directory; + + const ModuleDetail({ + required this.name, + required this.version, + required this.capabilities, + required this.permissions, + required this.directory, + }); +} + class AuditEvent { final String eventId; final DateTime timestamp; diff --git a/lib/pages/modules.dart b/lib/pages/modules.dart index b1426ee..8943413 100644 --- a/lib/pages/modules.dart +++ b/lib/pages/modules.dart @@ -71,7 +71,17 @@ class _ModulesPageState extends State { padding: const EdgeInsets.all(FaiSpace.xl), itemCount: modules.length, separatorBuilder: (_, _) => const SizedBox(height: FaiSpace.md), - itemBuilder: (context, i) => _ModuleCard(module: modules[i]), + itemBuilder: (context, i) { + final m = modules[i]; + return GestureDetector( + onTap: () => FaiModuleSheet.show(context, m.name), + behavior: HitTestBehavior.opaque, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: _ModuleCard(module: m), + ), + ); + }, ); }, ), diff --git a/lib/widgets/fai_module_sheet.dart b/lib/widgets/fai_module_sheet.dart new file mode 100644 index 0000000..a10667c --- /dev/null +++ b/lib/widgets/fai_module_sheet.dart @@ -0,0 +1,230 @@ +// FaiModuleSheet — modal bottom-sheet showing detailed module +// info: full capability list, declared permissions, on-disk +// directory. Opened by tapping a module card on the Modules +// page. + +import 'package:flutter/material.dart'; + +import '../data/hub.dart'; +import '../theme/theme.dart'; +import '../theme/tokens.dart'; +import 'fai_pill.dart'; + +class FaiModuleSheet extends StatefulWidget { + final String moduleName; + + const FaiModuleSheet({super.key, required this.moduleName}); + + /// Convenience launcher used from the Modules list. + static Future show(BuildContext context, String name) { + return showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(FaiRadius.md), + ), + ), + builder: (_) => FaiModuleSheet(moduleName: name), + ); + } + + @override + State createState() => _FaiModuleSheetState(); +} + +class _FaiModuleSheetState extends State { + late final Future _future; + + @override + void initState() { + super.initState(); + _future = HubService.instance.moduleInfo(widget.moduleName); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final maxHeight = MediaQuery.of(context).size.height * 0.7; + + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: FutureBuilder( + future: _future, + builder: (context, snapshot) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _Handle(), + if (snapshot.connectionState == ConnectionState.waiting) + const Padding( + padding: EdgeInsets.all(FaiSpace.xxxl), + child: CircularProgressIndicator(), + ) + else if (snapshot.hasError) + Padding( + padding: const EdgeInsets.all(FaiSpace.xl), + child: Text( + 'Failed to load: ${snapshot.error}', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ), + ) + else + Flexible(child: _Body(detail: snapshot.data!)), + ], + ); + }, + ), + ); + } +} + +class _Handle extends StatelessWidget { + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } +} + +class _Body extends StatelessWidget { + final ModuleDetail detail; + + const _Body({required this.detail}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ListView( + padding: const EdgeInsets.fromLTRB( + FaiSpace.xxl, + FaiSpace.md, + FaiSpace.xxl, + FaiSpace.xxl, + ), + shrinkWrap: true, + children: [ + Row( + children: [ + Text( + detail.name, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: FaiSpace.md), + FaiPill( + label: 'v${detail.version}', + tone: FaiPillTone.accent, + monospace: true, + ), + ], + ), + const SizedBox(height: FaiSpace.xs), + SelectableText( + detail.directory, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.xl), + _SectionHeader('Capabilities'), + const SizedBox(height: FaiSpace.sm), + Wrap( + spacing: FaiSpace.xs, + runSpacing: FaiSpace.xs, + children: detail.capabilities + .map( + (c) => FaiPill( + label: c, + tone: FaiPillTone.accent, + monospace: true, + ), + ) + .toList(), + ), + const SizedBox(height: FaiSpace.xl), + _SectionHeader('Declared permissions'), + const SizedBox(height: FaiSpace.sm), + if (detail.permissions.isEmpty) + Text( + '(none — pure-computation module)', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: detail.permissions + .map( + (p) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Icon( + _iconFor(p), + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: FaiSpace.sm), + SelectableText( + p, + style: FaiTheme.mono( + size: 12, + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + ), + ) + .toList(), + ), + ], + ); + } + + IconData _iconFor(String permission) { + if (permission.startsWith('net:')) return Icons.public; + if (permission.startsWith('fs.read:')) return Icons.folder_open; + if (permission.startsWith('fs.write:')) return Icons.edit_note; + if (permission.startsWith('env:')) return Icons.terminal; + if (permission.startsWith('hub:')) return Icons.shield_outlined; + return Icons.lock_outline; + } +} + +class _SectionHeader extends StatelessWidget { + final String text; + + const _SectionHeader(this.text); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Text( + text.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 99c641d..256636d 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -8,6 +8,7 @@ export 'fai_card.dart'; export 'fai_data_row.dart'; export 'fai_delta_mark.dart'; export 'fai_empty_state.dart'; +export 'fai_module_sheet.dart'; export 'fai_pill.dart'; export 'fai_settings_dialog.dart'; export 'fai_status_dot.dart';