// ChainModuleSheet — 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/error_presentation.dart'; import '../data/hub.dart'; import '../l10n/app_localizations.dart'; import '../pages/welcome.dart' show showFaiDoc; import '../theme/theme.dart'; import '../theme/tokens.dart'; import 'chain_pill.dart'; class ChainModuleSheet extends StatefulWidget { final String moduleName; const ChainModuleSheet({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, elevation: 8, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(ChainRadius.md)), ), builder: (_) => ChainModuleSheet(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)!; // When more than one version of the same module is // installed side-by-side, ask the operator which one to // remove. The hub's uninstall RPC otherwise falls back to // "remove the highest version", which is rarely what an // operator means when they've explicitly kept multiple // versions around. final versions = await HubService.instance.installedVersions(detail.name); if (!mounted) return; String? targetVersion; if (versions.length > 1) { targetVersion = await showDialog( context: context, builder: (ctx) => _UninstallVersionPickerDialog( moduleName: detail.name, versions: versions, ), ); if (targetVersion == null || !mounted) return; } final displayVersion = targetVersion ?? detail.version; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(l.modulesUninstallTitle), content: Text(l.modulesUninstallBody(detail.name, displayVersion)), 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, version: targetVersion ?? '', ); 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); showFaiErrorSnack(context, 'modules.uninstall', e); } } @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(ChainSpace.xxxl), child: CircularProgressIndicator(), ) else if (snapshot.hasError) Padding( padding: const EdgeInsets.all(ChainSpace.xl), child: Text( AppLocalizations.of( context, )!.moduleSheetFailedToLoad(snapshot.error.toString()), 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: ChainSpace.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); final l = AppLocalizations.of(context)!; return ListView( padding: const EdgeInsets.fromLTRB( ChainSpace.xxl, ChainSpace.md, ChainSpace.xxl, ChainSpace.xxl, ), shrinkWrap: true, children: [ Row( children: [ Text( detail.name, style: theme.textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(width: ChainSpace.md), ChainPill( label: 'v${detail.version}', tone: ChainPillTone.accent, monospace: true, ), ], ), const SizedBox(height: ChainSpace.xs), SelectableText( detail.directory, style: ChainTheme.mono( size: 11, color: theme.colorScheme.onSurfaceVariant, ), ), const SizedBox(height: ChainSpace.xl), _SectionHeader(l.moduleSheetCapabilities), const SizedBox(height: ChainSpace.sm), Wrap( spacing: ChainSpace.xs, runSpacing: ChainSpace.xs, children: detail.capabilities .map( (c) => ChainPill( label: c, tone: ChainPillTone.accent, monospace: true, ), ) .toList(), ), const SizedBox(height: ChainSpace.xl), Row( children: [ _SectionHeader(l.moduleSheetPermissions), const SizedBox(width: 4), InkWell( onTap: () => showFaiDoc(context, 'security'), borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(4), child: Icon( Icons.help_outline, size: 14, color: theme.colorScheme.onSurfaceVariant, ), ), ), ], ), const SizedBox(height: ChainSpace.sm), if (detail.permissions.isEmpty) Text( l.moduleSheetNoPermissions, 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: ChainSpace.sm), SelectableText( p, style: ChainTheme.mono( size: 12, color: theme.colorScheme.onSurface, ), ), ], ), ), ) .toList(), ), const SizedBox(height: ChainSpace.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; } } /// Modal that asks the operator to pick which installed /// version to uninstall when more than one version of the same /// `(provider, name)` is on disk side-by-side. Pops the picked /// version string, or `null` on cancel. class _UninstallVersionPickerDialog extends StatefulWidget { final String moduleName; final List versions; const _UninstallVersionPickerDialog({ required this.moduleName, required this.versions, }); @override State<_UninstallVersionPickerDialog> createState() => _UninstallVersionPickerDialogState(); } class _UninstallVersionPickerDialogState extends State<_UninstallVersionPickerDialog> { late String _picked; @override void initState() { super.initState(); // Default to the highest version — same fallback the hub // would pick without an explicit version, so the operator // can confirm with one click in the common case. _picked = widget.versions.last; } @override Widget build(BuildContext context) { final l = AppLocalizations.of(context)!; return AlertDialog( title: Text(l.uninstallVersionPickerTitle), content: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text(l.uninstallVersionPickerBody(widget.moduleName)), const SizedBox(height: ChainSpace.md), RadioGroup( groupValue: _picked, onChanged: (val) { if (val != null) setState(() => _picked = val); }, child: Column( mainAxisSize: MainAxisSize.min, children: [ for (final v in widget.versions) RadioListTile( value: v, title: Text( 'v$v', style: const TextStyle(fontFamily: 'monospace'), ), dense: true, contentPadding: EdgeInsets.zero, ), ], ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(l.buttonCancel), ), FilledButton( onPressed: () => Navigator.pop(context, _picked), child: Text(l.uninstallVersionPickerContinue), ), ], ); } } 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, ), ); } }