diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 1208ee4..4063d25 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -365,7 +365,9 @@ class HubService { (e) => StoreItem( name: e.name, taglineEn: e.taglineEn, + taglineDe: e.taglineDe, descriptionEn: e.descriptionEn, + descriptionDe: e.descriptionDe, category: e.category, tags: e.tags, requiresCapabilities: e.requiresCapabilities, @@ -1028,7 +1030,13 @@ class CuratedSnapshot { class StoreItem { final String name; final String taglineEn; + /// German tagline. Falls back to [taglineEn] in the UI when + /// the index entry does not ship a localized one. + final String taglineDe; final String descriptionEn; + /// German long-form description. Falls back to [descriptionEn] + /// when missing. + final String descriptionDe; final String category; final List tags; final List requiresCapabilities; @@ -1043,7 +1051,9 @@ class StoreItem { const StoreItem({ required this.name, required this.taglineEn, + required this.taglineDe, required this.descriptionEn, + required this.descriptionDe, required this.category, required this.tags, required this.requiresCapabilities, diff --git a/lib/data/system_actions.dart b/lib/data/system_actions.dart index 6beb08a..64dde52 100644 --- a/lib/data/system_actions.dart +++ b/lib/data/system_actions.dart @@ -55,6 +55,32 @@ class SystemActions { return _runFai(['update', 'apply', '--channel', channel]); } + /// Switch the active channel pointer at `~/.fai/current-channel`. + /// The CLI also restarts the daemon for the new channel, so the + /// caller does not need a follow-up restart. + static Future<({bool ok, String stdout, String stderr})> faiChannelSwitch( + String channel, + ) async { + return _runFai(['channel', 'switch', channel]); + } + + /// Install the platform-native autostart unit for [channel] + /// (launchd plist on macOS, systemd-user unit on Linux). On + /// Windows the platform CLI returns a "not supported" exit + /// status that the caller surfaces as an error message. + static Future<({bool ok, String stdout, String stderr})> faiDaemonEnable( + String channel, + ) async { + return _runFai(['daemon', 'enable', '--channel', channel]); + } + + /// Remove the autostart unit installed by [faiDaemonEnable]. + static Future<({bool ok, String stdout, String stderr})> faiDaemonDisable( + String channel, + ) async { + return _runFai(['daemon', 'disable', '--channel', channel]); + } + static Future<({bool ok, String stdout, String stderr})> _runFai( List args, ) async { diff --git a/lib/main.dart b/lib/main.dart index c86f0e1..efe3bef 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,7 +23,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.17.0'; +const String kStudioVersion = '0.18.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/store.dart b/lib/pages/store.dart index ad56b21..b2c884a 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -1,10 +1,14 @@ // Store browser. Lists modules from the hub's bundled -// store-index, with category + status filters and a one-click -// install action. Mirrors `fai store search` on the CLI side. +// store-index. Top-level surface mirrors the look operators +// expect from app stores (search bar, category strip, filter +// chips, status chips), with a per-entry detail sheet that +// renders the full bilingual description, requires-list, and +// repository link. import 'package:flutter/material.dart'; import '../data/hub.dart'; +import '../data/system_actions.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import '../widgets/widgets.dart'; @@ -20,6 +24,8 @@ class _StorePageState extends State { final _queryCtrl = TextEditingController(); String _category = ''; String _status = ''; + bool _installedOnly = false; + String _locale = 'en'; late Future> _future; @override @@ -34,30 +40,33 @@ class _StorePageState extends State { super.dispose(); } - Future> _load() { - return HubService.instance.searchStore( + Future> _load() async { + final all = await HubService.instance.searchStore( query: _queryCtrl.text.trim(), category: _category, status: _status, + // Pull a generous page so the local "installed only" + // filter does not bite into early results. + limit: 200, ); + return _installedOnly ? all.where((e) => e.installed).toList() : all; } void _runSearch() => setState(() => _future = _load()); - Future _install(StoreItem item) async { - // The store entry doesn't carry the bundle URL; for Phase 0.5 - // we prompt the operator for the source (URL or local path). - // Future store entries will include `download_url` per - // platform. - final source = await _SourcePromptDialog.show(context, item); - if (source == null || source.isEmpty) return; - if (!mounted) return; + Future _openDetail(StoreItem item) async { + final changed = await _StoreDetailSheet.show(context, item, _locale); + if (changed) _runSearch(); + } - showDialog( + Future _install(StoreItem item) async { + if (!mounted) return; + final outcome = await showDialog( context: context, barrierDismissible: false, - builder: (_) => _InstallProgressDialog(item: item, source: source), - ).then((_) => _runSearch()); + builder: (_) => _InstallProgressDialog(item: item), + ); + if (outcome == true) _runSearch(); } @override @@ -68,6 +77,13 @@ class _StorePageState extends State { appBar: AppBar( title: const Text('Store'), actions: [ + // Tiny DE/EN toggle — the store-index ships bilingual + // copy and operators in regulated environments often + // share screens with non-English colleagues. + _LocaleToggle( + value: _locale, + onChanged: (v) => setState(() => _locale = v), + ), IconButton( icon: const Icon(Icons.refresh, size: 18), tooltip: 'Reload', @@ -77,25 +93,59 @@ class _StorePageState extends State { ], ), body: Padding( - padding: const EdgeInsets.all(FaiSpace.xl), + padding: const EdgeInsets.fromLTRB( + FaiSpace.xl, + FaiSpace.lg, + FaiSpace.xl, + 0, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _SearchBar( + _SearchField( controller: _queryCtrl, - category: _category, - status: _status, - onCategoryChanged: (v) { - setState(() => _category = v); - _runSearch(); - }, - onStatusChanged: (v) { - setState(() => _status = v); - _runSearch(); - }, onSubmit: _runSearch, + onClear: () { + _queryCtrl.clear(); + _runSearch(); + }, ), - const SizedBox(height: FaiSpace.lg), + const SizedBox(height: FaiSpace.md), + FutureBuilder>( + future: _future, + builder: (context, snap) { + final items = snap.data ?? const []; + final categories = _categoriesIn(items); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CategoryStrip( + categories: categories, + selected: _category, + onSelected: (v) { + setState(() => _category = v); + _runSearch(); + }, + ), + const SizedBox(height: FaiSpace.sm), + _FilterRow( + status: _status, + installedOnly: _installedOnly, + onStatus: (v) { + setState(() => _status = v); + _runSearch(); + }, + onInstalledOnly: (v) { + setState(() => _installedOnly = v); + _runSearch(); + }, + resultCount: items.length, + ), + ], + ); + }, + ), + const SizedBox(height: FaiSpace.md), Expanded( child: FutureBuilder>( future: _future, @@ -115,22 +165,32 @@ class _StorePageState extends State { ), ); } - final items = snap.data ?? const []; + final items = snap.data ?? const []; if (items.isEmpty) { - return const FaiEmptyState( - icon: Icons.store_outlined, + return FaiEmptyState( + icon: Icons.search_off, title: 'No matches', hint: 'Adjust the filters or clear the search to see all entries.', + action: FilledButton.tonal( + onPressed: () { + setState(() { + _category = ''; + _status = ''; + _installedOnly = false; + _queryCtrl.clear(); + }); + _runSearch(); + }, + child: const Text('Clear filters'), + ), ); } - return ListView.separated( - padding: EdgeInsets.zero, - itemCount: items.length, - separatorBuilder: (_, _) => - const SizedBox(height: FaiSpace.md), - itemBuilder: (context, i) => - _StoreCard(item: items[i], onInstall: () => _install(items[i])), + return _StoreGrid( + items: items, + locale: _locale, + onTap: _openDetail, + onInstall: _install, ); }, ), @@ -140,287 +200,790 @@ class _StorePageState extends State { ), ); } + + List _categoriesIn(List items) { + final set = {}; + for (final i in items) { + if (i.category.isNotEmpty) set.add(i.category); + } + final list = set.toList()..sort(); + return list; + } } -class _SearchBar extends StatelessWidget { +/// Big search input that owns the page's primary action. +class _SearchField extends StatelessWidget { final TextEditingController controller; - final String category; - final String status; - final void Function(String) onCategoryChanged; - final void Function(String) onStatusChanged; final VoidCallback onSubmit; + final VoidCallback onClear; - const _SearchBar({ + const _SearchField({ required this.controller, - required this.category, - required this.status, - required this.onCategoryChanged, - required this.onStatusChanged, required this.onSubmit, + required this.onClear, }); @override Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (_, value, _) { + return TextField( + controller: controller, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search, size: 20), + hintText: 'Search modules — name, tagline, tag, capability…', + border: const OutlineInputBorder(), + isDense: true, + suffixIcon: value.text.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: onClear, + ), + ), + onChanged: (_) => onSubmit(), + onSubmitted: (_) => onSubmit(), + ); + }, + ); + } +} + +/// Horizontally-scrollable category chip strip. Always-on +/// "All" chip first, then categories the current result set +/// contains. Disappears when the index is empty. +class _CategoryStrip extends StatelessWidget { + final List categories; + final String selected; + final void Function(String) onSelected; + + const _CategoryStrip({ + required this.categories, + required this.selected, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + if (categories.isEmpty) return const SizedBox.shrink(); + return SizedBox( + height: 36, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + _ChoiceChip( + label: 'All', + selected: selected.isEmpty, + onSelected: () => onSelected(''), + ), + for (final c in categories) + _ChoiceChip( + label: c, + selected: selected == c, + onSelected: () => onSelected(c), + ), + ], + ), + ); + } +} + +class _FilterRow extends StatelessWidget { + final String status; + final bool installedOnly; + final void Function(String) onStatus; + final void Function(bool) onInstalledOnly; + final int resultCount; + + const _FilterRow({ + required this.status, + required this.installedOnly, + required this.onStatus, + required this.onInstalledOnly, + required this.resultCount, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); return Row( children: [ - Expanded( - child: TextField( - controller: controller, - decoration: const InputDecoration( - prefixIcon: Icon(Icons.search, size: 18), - hintText: 'search name, tagline, tag…', - border: OutlineInputBorder(), - isDense: true, - ), - onSubmitted: (_) => onSubmit(), + _ChoiceChip( + label: 'All', + selected: status.isEmpty, + onSelected: () => onStatus(''), + ), + _ChoiceChip( + label: 'Published', + selected: status == 'published', + onSelected: () => onStatus('published'), + ), + _ChoiceChip( + label: 'Alpha', + selected: status == 'alpha', + onSelected: () => onStatus('alpha'), + ), + _ChoiceChip( + label: 'Planned', + selected: status == 'planned', + onSelected: () => onStatus('planned'), + ), + const SizedBox(width: FaiSpace.md), + FilterChip( + label: const Text('Installed'), + selected: installedOnly, + onSelected: onInstalledOnly, + showCheckmark: true, + ), + const Spacer(), + Text( + '$resultCount result${resultCount == 1 ? "" : "s"}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, ), ), - const SizedBox(width: FaiSpace.md), - _DropdownFilter( - label: 'Category', - value: category, - options: const ['', 'llm', 'text', 'system', 'orchestrator'], - onChanged: onCategoryChanged, - ), - const SizedBox(width: FaiSpace.md), - _DropdownFilter( - label: 'Status', - value: status, - options: const ['', 'published', 'alpha', 'planned'], - onChanged: onStatusChanged, - ), ], ); } } -class _DropdownFilter extends StatelessWidget { +class _ChoiceChip extends StatelessWidget { final String label; - final String value; - final List options; - final void Function(String) onChanged; + final bool selected; + final VoidCallback onSelected; - const _DropdownFilter({ + const _ChoiceChip({ required this.label, - required this.value, - required this.options, - required this.onChanged, + required this.selected, + required this.onSelected, }); @override Widget build(BuildContext context) { - return SizedBox( - width: 160, - child: DropdownButtonFormField( - initialValue: value, - isDense: true, - decoration: InputDecoration( - labelText: label, - border: const OutlineInputBorder(), - isDense: true, - ), - items: options - .map( - (o) => DropdownMenuItem( - value: o, - child: Text(o.isEmpty ? 'any' : o), - ), - ) - .toList(), - onChanged: (v) => onChanged(v ?? ''), + return Padding( + padding: const EdgeInsets.only(right: FaiSpace.xs), + child: ChoiceChip( + label: Text(label), + selected: selected, + onSelected: (_) => onSelected(), + showCheckmark: false, ), ); } } +class _LocaleToggle extends StatelessWidget { + final String value; + final ValueChanged onChanged; + const _LocaleToggle({required this.value, required this.onChanged}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: FaiSpace.sm), + child: Tooltip( + message: value == 'en' + ? 'Showing English copy. Click to switch to German.' + : 'Deutsche Beschreibung — Click for English.', + child: TextButton.icon( + onPressed: () => onChanged(value == 'en' ? 'de' : 'en'), + icon: const Icon(Icons.translate, size: 16), + label: Text(value.toUpperCase()), + ), + ), + ); + } +} + +/// Responsive grid — wraps to as many columns as the viewport +/// allows. Cards are click-to-expand; install is also a top- +/// level action so single-click installs are still possible. +class _StoreGrid extends StatelessWidget { + final List items; + final String locale; + final ValueChanged onTap; + final ValueChanged onInstall; + + const _StoreGrid({ + required this.items, + required this.locale, + required this.onTap, + required this.onInstall, + }); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + const minCardWidth = 360.0; + final cols = (constraints.maxWidth / minCardWidth).floor().clamp(1, 4); + return GridView.builder( + padding: EdgeInsets.zero, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + mainAxisSpacing: FaiSpace.md, + crossAxisSpacing: FaiSpace.md, + mainAxisExtent: 168, + ), + itemCount: items.length, + itemBuilder: (context, i) => _StoreCard( + item: items[i], + locale: locale, + onTap: () => onTap(items[i]), + onInstall: () => onInstall(items[i]), + ), + ); + }, + ); + } +} + class _StoreCard extends StatelessWidget { final StoreItem item; + final String locale; + final VoidCallback onTap; final VoidCallback onInstall; - const _StoreCard({required this.item, required this.onInstall}); + const _StoreCard({ + required this.item, + required this.locale, + required this.onTap, + required this.onInstall, + }); @override Widget build(BuildContext context) { final theme = Theme.of(context); - return FaiCard( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - item.installed ? Icons.check_circle_outline : Icons.extension_outlined, - size: 20, - color: item.installed - ? FaiColors.success - : theme.colorScheme.primary, - ), - const SizedBox(width: FaiSpace.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - item.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: FaiSpace.sm), - if (item.bestVersion.isNotEmpty) - FaiPill( - label: 'v${item.bestVersion}', - tone: FaiPillTone.neutral, - monospace: true, - ), - const SizedBox(width: FaiSpace.xs), - if (item.status.isNotEmpty) - FaiPill( - label: item.status, - tone: _toneForStatus(item.status), - ), - ], - ), - if (item.taglineEn.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - item.taglineEn, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + final tagline = locale == 'de' && item.taglineDe.isNotEmpty + ? item.taglineDe + : item.taglineEn; + final installable = item.status == 'published' || item.status == 'alpha'; + return Material( + color: theme.colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(FaiRadius.md), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(FaiRadius.md), + child: Padding( + padding: const EdgeInsets.all(FaiSpace.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: + theme.colorScheme.primary.withValues(alpha: 0.12), + child: Icon( + _iconForCategory(item.category), + size: 18, + color: theme.colorScheme.primary, ), ), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + item.category.isEmpty ? '—' : item.category, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + if (item.installed) + const FaiPill( + label: 'installed', + tone: FaiPillTone.success, + icon: Icons.check, + ) + else if (item.status.isNotEmpty) + FaiPill( + label: item.status, + tone: _toneForStatus(item.status), + ), ], - const SizedBox(height: FaiSpace.xs), - Wrap( - spacing: FaiSpace.xs, - runSpacing: FaiSpace.xs, - children: [ - if (item.category.isNotEmpty) - FaiPill( - label: item.category, - tone: FaiPillTone.neutral, - ), - for (final t in item.tags.take(4)) - FaiPill(label: t, tone: FaiPillTone.neutral), - ], + ), + const SizedBox(height: FaiSpace.sm), + Expanded( + child: Text( + tagline.isEmpty ? '(no tagline)' : tagline, + style: theme.textTheme.bodySmall, + maxLines: 3, + overflow: TextOverflow.ellipsis, ), - ], + ), + const SizedBox(height: FaiSpace.sm), + Row( + children: [ + if (item.bestVersion.isNotEmpty) + FaiPill( + label: 'v${item.bestVersion}', + tone: FaiPillTone.neutral, + monospace: true, + ), + const Spacer(), + if (item.installed) + TextButton.icon( + onPressed: onTap, + icon: const Icon(Icons.info_outline, size: 14), + label: const Text('Details'), + ) + else if (installable) + FilledButton.icon( + onPressed: onInstall, + icon: const Icon(Icons.download, size: 14), + label: const Text('Install'), + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ) + else + OutlinedButton.icon( + onPressed: onTap, + icon: const Icon(Icons.info_outline, size: 14), + label: const Text('Details'), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// Per-module detail sheet. Renders the full bilingual +/// description, the capabilities + services the module +/// requires, plus repository / docs links. Returns `true` when +/// state changed (install / uninstall) so the caller refreshes. +class _StoreDetailSheet extends StatefulWidget { + final StoreItem item; + final String locale; + + const _StoreDetailSheet({required this.item, required this.locale}); + + static Future show( + BuildContext context, + StoreItem item, + String locale, + ) async { + final r = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(FaiRadius.md)), + ), + builder: (_) => _StoreDetailSheet(item: item, locale: locale), + ); + return r ?? false; + } + + @override + State<_StoreDetailSheet> createState() => _StoreDetailSheetState(); +} + +class _StoreDetailSheetState extends State<_StoreDetailSheet> { + late String _locale = widget.locale; + bool _busy = false; + String? _toast; + + Future _install() async { + setState(() { + _busy = true; + _toast = null; + }); + try { + final r = await HubService.instance.installModule(source: widget.item.name); + if (!mounted) return; + setState(() { + _busy = false; + _toast = '${r.name} v${r.version} installed.'; + }); + Navigator.pop(context, true); + } catch (e) { + if (!mounted) return; + setState(() { + _busy = false; + _toast = 'Install failed: $e'; + }); + } + } + + Future _uninstall() async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Uninstall module?'), + content: Text( + 'Removes ${widget.item.name} from this hub. Flows that ' + 'reference it will fail at the next run.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + foregroundColor: Theme.of(ctx).colorScheme.onError, + ), + child: const Text('Uninstall'), + ), + ], + ), + ); + if (ok != true || !mounted) return; + setState(() { + _busy = true; + _toast = null; + }); + try { + final r = await HubService.instance.uninstallModule(widget.item.name); + if (!mounted) return; + setState(() { + _busy = false; + _toast = '${r.name} v${r.version} uninstalled.'; + }); + Navigator.pop(context, true); + } catch (e) { + if (!mounted) return; + setState(() { + _busy = false; + _toast = 'Uninstall failed: $e'; + }); + } + } + + Future _openRepository() async { + if (widget.item.repository.isEmpty) return; + final r = await SystemActions.openInOs(widget.item.repository); + if (!mounted) return; + if (!r.ok) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not open browser: ${r.stderr}')), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final item = widget.item; + final tagline = _locale == 'de' && item.taglineDe.isNotEmpty + ? item.taglineDe + : item.taglineEn; + final description = _locale == 'de' && item.descriptionDe.isNotEmpty + ? item.descriptionDe + : item.descriptionEn; + final installable = item.status == 'published' || item.status == 'alpha'; + final maxHeight = MediaQuery.of(context).size.height * 0.85; + + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: theme.colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), ), ), - const SizedBox(width: FaiSpace.md), - if (item.installed) - FilledButton.tonalIcon( - onPressed: null, - icon: const Icon(Icons.check, size: 16), - label: const Text('Installed'), - ) - else - FilledButton.icon( - onPressed: onInstall, - icon: const Icon(Icons.download, size: 16), - label: const Text('Install'), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + FaiSpace.xxl, + FaiSpace.md, + FaiSpace.xxl, + FaiSpace.xxl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 26, + backgroundColor: theme.colorScheme.primary + .withValues(alpha: 0.12), + child: Icon( + _iconForCategory(item.category), + size: 28, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: FaiSpace.lg), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Wrap( + spacing: FaiSpace.xs, + runSpacing: 4, + children: [ + if (item.bestVersion.isNotEmpty) + FaiPill( + label: 'v${item.bestVersion}', + tone: FaiPillTone.neutral, + monospace: true, + ), + if (item.status.isNotEmpty) + FaiPill( + label: item.status, + tone: _toneForStatus(item.status), + ), + if (item.category.isNotEmpty) + FaiPill( + label: item.category, + tone: FaiPillTone.neutral, + ), + if (item.installed) + const FaiPill( + label: 'installed', + tone: FaiPillTone.success, + icon: Icons.check, + ), + if (item.license.isNotEmpty) + FaiPill( + label: item.license, + tone: FaiPillTone.neutral, + ), + ], + ), + ], + ), + ), + _LocaleToggle( + value: _locale, + onChanged: (v) => setState(() => _locale = v), + ), + ], + ), + const SizedBox(height: FaiSpace.lg), + if (tagline.isNotEmpty) ...[ + Text( + tagline, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.lg), + ], + if (description.isNotEmpty) ...[ + SelectableText( + description, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: FaiSpace.xl), + ], + if (item.tags.isNotEmpty) ...[ + _SectionHeader('Tags'), + const SizedBox(height: FaiSpace.sm), + Wrap( + spacing: FaiSpace.xs, + runSpacing: FaiSpace.xs, + children: [ + for (final t in item.tags) + FaiPill(label: t, tone: FaiPillTone.neutral), + ], + ), + const SizedBox(height: FaiSpace.lg), + ], + if (item.requiresCapabilities.isNotEmpty) ...[ + _SectionHeader('Required capabilities'), + const SizedBox(height: FaiSpace.sm), + Wrap( + spacing: FaiSpace.xs, + runSpacing: FaiSpace.xs, + children: [ + for (final c in item.requiresCapabilities) + FaiPill( + label: c, + tone: FaiPillTone.accent, + monospace: true, + ), + ], + ), + const SizedBox(height: FaiSpace.lg), + ], + if (item.requiresServices.isNotEmpty) ...[ + _SectionHeader('Required host services'), + const SizedBox(height: FaiSpace.sm), + Wrap( + spacing: FaiSpace.xs, + runSpacing: FaiSpace.xs, + children: [ + for (final s in item.requiresServices) + FaiPill( + label: s, + tone: FaiPillTone.warning, + monospace: true, + ), + ], + ), + const SizedBox(height: FaiSpace.lg), + ], + if (item.repository.isNotEmpty) ...[ + _SectionHeader('Source'), + const SizedBox(height: FaiSpace.sm), + InkWell( + onTap: _openRepository, + child: Row( + children: [ + const Icon(Icons.open_in_new, size: 14), + const SizedBox(width: FaiSpace.xs), + Flexible( + child: Text( + item.repository, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.primary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const SizedBox(height: FaiSpace.lg), + ], + if (_toast != null) ...[ + Container( + padding: const EdgeInsets.all(FaiSpace.sm), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all( + color: theme.colorScheme.outlineVariant, + ), + ), + child: SelectableText( + _toast!, + style: FaiTheme.mono(size: 11), + ), + ), + const SizedBox(height: FaiSpace.md), + ], + Row( + children: [ + if (item.repository.isNotEmpty) + OutlinedButton.icon( + onPressed: _openRepository, + icon: const Icon(Icons.menu_book_outlined, size: 16), + label: const Text('Read docs'), + ), + const Spacer(), + if (item.installed) + OutlinedButton.icon( + onPressed: _busy ? null : _uninstall, + icon: _busy + ? const SizedBox( + width: 14, + height: 14, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.delete_outline, size: 16), + label: const Text('Uninstall'), + style: OutlinedButton.styleFrom( + foregroundColor: theme.colorScheme.error, + side: BorderSide( + color: theme.colorScheme.error + .withValues(alpha: 0.5), + ), + ), + ) + else if (installable) + FilledButton.icon( + onPressed: _busy ? null : _install, + icon: _busy + ? const SizedBox( + width: 14, + height: 14, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download, size: 16), + label: Text(_busy ? 'Installing…' : 'Install'), + ) + else + Tooltip( + message: + 'Status "${item.status}" — install path not yet wired.', + child: const FilledButton( + onPressed: null, + child: Text('Not installable'), + ), + ), + ], + ), + ], + ), ), + ), ], ), ); } - - FaiPillTone _toneForStatus(String s) { - switch (s) { - case 'published': - return FaiPillTone.success; - case 'alpha': - return FaiPillTone.warning; - case 'planned': - return FaiPillTone.neutral; - default: - return FaiPillTone.neutral; - } - } } -class _SourcePromptDialog extends StatefulWidget { - final StoreItem item; - - const _SourcePromptDialog({required this.item}); - - static Future show(BuildContext context, StoreItem item) { - return showDialog( - context: context, - builder: (_) => _SourcePromptDialog(item: item), - ); - } - - @override - State<_SourcePromptDialog> createState() => _SourcePromptDialogState(); -} - -class _SourcePromptDialogState extends State<_SourcePromptDialog> { - final _ctrl = TextEditingController(); - - @override - void dispose() { - _ctrl.dispose(); - super.dispose(); - } +class _SectionHeader extends StatelessWidget { + final String text; + const _SectionHeader(this.text); @override Widget build(BuildContext context) { final theme = Theme.of(context); - return AlertDialog( - title: Text('Install ${widget.item.name}'), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(FaiRadius.md), + return Text( + text.toUpperCase(), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, ), - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'The store index does not yet ship binary URLs. Paste the ' - '`.fai` bundle source — a https URL or a local filesystem ' - 'path — and the hub will fetch + verify + unpack.', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: FaiSpace.md), - TextField( - controller: _ctrl, - autofocus: true, - style: FaiTheme.mono(size: 12), - decoration: const InputDecoration( - labelText: 'source', - hintText: 'https://… or /path/to/bundle.fai', - border: OutlineInputBorder(), - isDense: true, - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, null), - child: const Text('Cancel'), - ), - FilledButton.icon( - icon: const Icon(Icons.download, size: 16), - label: const Text('Install'), - onPressed: () => Navigator.pop(context, _ctrl.text.trim()), - ), - ], ); } } class _InstallProgressDialog extends StatefulWidget { final StoreItem item; - final String source; - const _InstallProgressDialog({required this.item, required this.source}); + const _InstallProgressDialog({required this.item}); @override State<_InstallProgressDialog> createState() => _InstallProgressDialogState(); @@ -432,7 +995,10 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> { @override void initState() { super.initState(); - _future = HubService.instance.installModule(source: widget.source); + // Capability-name install: hub resolves the wasm_url from the + // bundled store-index. No source prompt needed for entries + // whose seed.yaml already has wasm_url. + _future = HubService.instance.installModule(source: widget.item.name); } @override @@ -508,10 +1074,47 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> { ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), + onPressed: () => Navigator.pop(context, true), child: const Text('Close'), ), ], ); } } + +FaiPillTone _toneForStatus(String s) { + switch (s) { + case 'published': + return FaiPillTone.success; + case 'alpha': + return FaiPillTone.warning; + case 'planned': + return FaiPillTone.neutral; + default: + return FaiPillTone.neutral; + } +} + +IconData _iconForCategory(String category) { + switch (category) { + case 'llm': + return Icons.psychology_outlined; + case 'text': + case 'text-processing': + return Icons.description_outlined; + case 'system': + return Icons.settings_outlined; + case 'orchestrator': + return Icons.account_tree_outlined; + case 'debug': + return Icons.bug_report_outlined; + case 'auth': + return Icons.lock_outline; + case 'storage': + return Icons.storage_outlined; + case 'channel': + return Icons.forum_outlined; + default: + return Icons.extension_outlined; + } +} diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index 3e2b64b..ddc08cb 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -6,6 +6,7 @@ import 'package:fai_dart_sdk/fai_dart_sdk.dart'; import 'package:flutter/material.dart'; import '../data/hub.dart'; +import '../data/system_actions.dart'; import '../theme/theme.dart'; import '../theme/tokens.dart'; import 'fai_pill.dart'; @@ -33,6 +34,7 @@ class _FaiSettingsDialogState extends State { bool _secure = false; bool _saving = false; String? _error; + String? _channelToast; ChannelStatusSnapshot? _channels; SystemAiStatus? _aiStatus; @@ -69,6 +71,40 @@ class _FaiSettingsDialogState extends State { } } + Future _switchChannel(String name) async { + setState(() { + _saving = true; + _channelToast = null; + }); + final r = await SystemActions.faiChannelSwitch(name); + if (!mounted) return; + setState(() { + _saving = false; + _channelToast = r.ok + ? 'Switched active channel to "$name". Daemon restarted.\n${r.stdout.trim()}' + : 'Channel switch failed: ${r.stderr.isEmpty ? r.stdout : r.stderr}'; + }); + if (r.ok) await _loadChannels(); + } + + Future _runDaemon( + String label, + Future<({bool ok, String stdout, String stderr})> Function() action, + ) async { + setState(() { + _saving = true; + _channelToast = null; + }); + final r = await action(); + if (!mounted) return; + setState(() { + _saving = false; + _channelToast = r.ok + ? 'OK · $label\n${r.stdout.trim()}' + : 'Failed · $label\n${(r.stderr.isEmpty ? r.stdout : r.stderr).trim()}'; + }); + } + Future _connectToChannel(ChannelInfo ch) async { setState(() { _host.text = '127.0.0.1'; @@ -227,13 +263,49 @@ class _FaiSettingsDialogState extends State { fontSize: 10, ), ), - const SizedBox(height: 4), + const SizedBox(height: 2), + Text( + 'Switch hub channel (writes ~/.fai/current-channel and ' + 'restarts the daemon). Connect just changes Studio\'s wire.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: FaiSpace.sm), for (final ch in _channels!.channels) _ChannelRow( channel: ch, active: ch.name == _channels!.active, onConnect: _saving ? null : () => _connectToChannel(ch), + onSwitch: _saving ? null : () => _switchChannel(ch.name), + onEnableAutostart: _saving ? null : () => _runDaemon( + 'enable autostart', + () => SystemActions.faiDaemonEnable(ch.name), + ), + onDisableAutostart: _saving ? null : () => _runDaemon( + 'disable autostart', + () => SystemActions.faiDaemonDisable(ch.name), + ), ), + if (_channelToast != null) ...[ + const SizedBox(height: FaiSpace.sm), + Container( + width: double.infinity, + padding: const EdgeInsets.all(FaiSpace.sm), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: SelectableText( + _channelToast!, + style: FaiTheme.mono( + size: 11, + color: theme.colorScheme.onSurface, + ), + ), + ), + ], ], if (_aiStatus != null) ...[ const SizedBox(height: FaiSpace.lg), @@ -285,18 +357,27 @@ class _ChannelRow extends StatelessWidget { final ChannelInfo channel; final bool active; final VoidCallback? onConnect; + /// Switches the active channel pointer (`fai channel switch`). + /// Distinct from [onConnect], which only re-points Studio's + /// gRPC wire at a different running daemon. + final VoidCallback? onSwitch; + final VoidCallback? onEnableAutostart; + final VoidCallback? onDisableAutostart; const _ChannelRow({ required this.channel, required this.active, required this.onConnect, + required this.onSwitch, + required this.onEnableAutostart, + required this.onDisableAutostart, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), + padding: const EdgeInsets.symmetric(vertical: 4), child: Row( children: [ Icon( @@ -337,18 +418,64 @@ class _ChannelRow extends StatelessWidget { ), ), if (active) - Padding( - padding: const EdgeInsets.only(right: FaiSpace.sm), - child: Text( - 'active', - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.primary, + const Padding( + padding: EdgeInsets.only(right: FaiSpace.sm), + child: FaiPill(label: 'active', tone: FaiPillTone.success), + ), + PopupMenuButton( + tooltip: 'Channel actions', + icon: const Icon(Icons.more_vert, size: 18), + onSelected: (v) { + switch (v) { + case 'connect': + onConnect?.call(); + break; + case 'switch': + onSwitch?.call(); + break; + case 'enable': + onEnableAutostart?.call(); + break; + case 'disable': + onDisableAutostart?.call(); + break; + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'connect', + enabled: channel.running && onConnect != null, + child: const _MenuRow( + icon: Icons.link, + text: 'Connect Studio to this channel', ), ), - ), - TextButton( - onPressed: channel.running ? onConnect : null, - child: const Text('Connect'), + PopupMenuItem( + value: 'switch', + enabled: !active && onSwitch != null, + child: const _MenuRow( + icon: Icons.swap_horiz, + text: 'Make this the active channel', + ), + ), + const PopupMenuDivider(), + PopupMenuItem( + value: 'enable', + enabled: onEnableAutostart != null, + child: const _MenuRow( + icon: Icons.play_circle_outline, + text: 'Enable autostart at login', + ), + ), + PopupMenuItem( + value: 'disable', + enabled: onDisableAutostart != null, + child: const _MenuRow( + icon: Icons.pause_circle_outline, + text: 'Disable autostart', + ), + ), + ], ), ], ), @@ -356,6 +483,23 @@ class _ChannelRow extends StatelessWidget { } } +class _MenuRow extends StatelessWidget { + final IconData icon; + final String text; + const _MenuRow({required this.icon, required this.text}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 14), + const SizedBox(width: FaiSpace.sm), + Text(text), + ], + ); + } +} + class _SystemAiPanel extends StatelessWidget { final SystemAiStatus status; final VoidCallback onEdit; diff --git a/pubspec.yaml b/pubspec.yaml index a145533..90dd562 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.17.0 +version: 0.18.0 environment: sdk: ^3.11.0-200.1.beta