// 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 '../l10n/app_localizations.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. Resolves /// to `true` when the operator uninstalled the module — the /// caller (Modules page) refreshes its list on that signal. static Future show(BuildContext context, String name) async { final r = await 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), ); return r ?? false; } @override State createState() => _FaiModuleSheetState(); } class _FaiModuleSheetState extends State { late final Future _future; bool _uninstalling = false; @override void initState() { super.initState(); _future = HubService.instance.moduleInfo(widget.moduleName); } Future _confirmAndUninstall(ModuleDetail detail) async { final l = AppLocalizations.of(context)!; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(l.modulesUninstallTitle), content: Text(l.modulesUninstallBody(detail.name, detail.version)), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: Text(l.buttonCancel), ), FilledButton( onPressed: () => Navigator.pop(ctx, true), style: FilledButton.styleFrom( backgroundColor: Theme.of(ctx).colorScheme.error, foregroundColor: Theme.of(ctx).colorScheme.onError, ), child: Text(l.buttonUninstall), ), ], ), ); if (ok != true || !mounted) return; setState(() => _uninstalling = true); try { final r = await HubService.instance.uninstallModule(detail.name); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(l.modulesUninstalledToast(r.name, r.version)), ), ); Navigator.pop(context, true); } catch (e) { if (!mounted) return; setState(() => _uninstalling = false); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l.modulesUninstallFailed(e.toString()))), ); } } @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!, uninstalling: _uninstalling, onUninstall: () => _confirmAndUninstall(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; final bool uninstalling; final VoidCallback onUninstall; const _Body({ required this.detail, required this.uninstalling, required this.onUninstall, }); @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(), ), const SizedBox(height: FaiSpace.xxl), Row( children: [ const Spacer(), OutlinedButton.icon( onPressed: uninstalling ? null : onUninstall, icon: uninstalling ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.delete_outline, size: 16), label: Text( uninstalling ? AppLocalizations.of(context)!.modulesUninstalling : AppLocalizations.of(context)!.buttonUninstall, ), style: OutlinedButton.styleFrom( foregroundColor: theme.colorScheme.error, side: BorderSide( color: theme.colorScheme.error.withValues(alpha: 0.5), ), ), ), ], ), ], ); } 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, ), ); } }