diff --git a/lib/data/hub.dart b/lib/data/hub.dart index a0d9f70..5179608 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -390,6 +390,7 @@ class HubService { bestVersion: e.bestVersion, status: e.status, installed: e.installed, + featured: e.featured, ), ) .toList(); @@ -1060,6 +1061,10 @@ class StoreItem { /// "published", "alpha", "planned", or empty when unknown. final String status; final bool installed; + /// True iff the store-index marks this entry as editorially + /// featured (curated quarterly in the bundled seed.yaml). + /// Drives the "Featured" strip at the top of the Store page. + final bool featured; const StoreItem({ required this.name, @@ -1076,5 +1081,6 @@ class StoreItem { required this.bestVersion, required this.status, required this.installed, + required this.featured, }); } diff --git a/lib/main.dart b/lib/main.dart index b277829..1ad543c 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.19.0'; +const String kStudioVersion = '0.20.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -110,6 +110,7 @@ class StudioShell extends StatefulWidget { class _StudioShellState extends State { int _selectedIndex = 0; bool? _connected; // null = unknown, polled on a timer + String? _activeChannel; // local / dev / beta / production Timer? _healthPoll; static const _pages = <_NavPage>[ @@ -165,6 +166,20 @@ class _StudioShellState extends State { final ok = await HubService.instance.healthy(); if (!mounted) return; if (_connected != ok) setState(() => _connected = ok); + // Refresh the active channel pointer alongside the health + // probe — cheap, and keeps the sidebar pill honest if the + // operator switched channels from the CLI. + if (ok) { + try { + final snap = await HubService.instance.channelStatus(); + if (!mounted) return; + if (_activeChannel != snap.active) { + setState(() => _activeChannel = snap.active); + } + } catch (_) { + // Best-effort; pill simply doesn't update this tick. + } + } } @override @@ -223,6 +238,7 @@ class _StudioShellState extends State { pages: _pages, connected: _connected, endpointLabel: HubService.instance.endpointLabel, + activeChannel: _activeChannel, ), Container( width: 1, @@ -274,6 +290,10 @@ class _Sidebar extends StatelessWidget { final List<_NavPage> pages; final bool? connected; final String endpointLabel; + /// Active channel name from the running daemon + /// (`local`/`dev`/`beta`/`production`). Null while the + /// initial fetch is in flight or the hub is unreachable. + final String? activeChannel; const _Sidebar({ required this.selectedIndex, @@ -281,6 +301,7 @@ class _Sidebar extends StatelessWidget { required this.pages, required this.connected, required this.endpointLabel, + required this.activeChannel, }); @override @@ -319,6 +340,13 @@ class _Sidebar extends StatelessWidget { label: endpointLabel, ), ), + if (activeChannel != null && activeChannel!.isNotEmpty) ...[ + const SizedBox(height: FaiSpace.sm), + Padding( + padding: const EdgeInsets.symmetric(horizontal: FaiSpace.lg), + child: _ChannelPill(channel: activeChannel!), + ), + ], const SizedBox(height: FaiSpace.xxl), // Destinations. Expanded( @@ -370,6 +398,87 @@ class _Sidebar extends StatelessWidget { } } +/// Active-channel indicator. Click to open Settings (where the +/// operator can switch). Color-coded so production deployments +/// look visibly different from local-dev — the kind of thing +/// you want to see before clicking "Clear log". +class _ChannelPill extends StatelessWidget { + final String channel; + const _ChannelPill({required this.channel}); + + Color _accent(ColorScheme cs) { + switch (channel) { + case 'production': + return cs.error; + case 'beta': + return FaiColors.warning; + case 'dev': + return cs.primary; + case 'local': + default: + return cs.onSurfaceVariant; + } + } + + IconData get _icon { + switch (channel) { + case 'production': + return Icons.shield_outlined; + case 'beta': + return Icons.science_outlined; + case 'dev': + return Icons.bolt_outlined; + case 'local': + default: + return Icons.home_work_outlined; + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final accent = _accent(theme.colorScheme); + return Tooltip( + message: + 'Active channel — click to switch in Settings (⌘;).\n' + 'production = stable · beta = pre-release · dev = rolling · local = workspace', + child: Material( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(FaiRadius.sm), + child: InkWell( + borderRadius: BorderRadius.circular(FaiRadius.sm), + onTap: () => FaiSettingsDialog.show(context), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: FaiSpace.sm, + vertical: 4, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FaiRadius.sm), + border: Border.all(color: accent.withValues(alpha: 0.4)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_icon, size: 11, color: accent), + const SizedBox(width: 4), + Text( + channel, + style: FaiTheme.mono( + size: 10, + weight: FontWeight.w600, + color: accent, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + class _ConnectionPill extends StatelessWidget { final bool? connected; final String label; diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 228a0db..cb3896c 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -198,12 +198,39 @@ class _StorePageState extends State { ), ); } - return _StoreGrid( - items: items, - locale: _locale, - installedVersions: _installedVersions, - onTap: _openDetail, - onInstall: _install, + // Featured strip is only useful in the + // browse-the-whole-thing case. Once the + // operator has typed a query or picked a + // filter, the result list itself is the + // answer they want — featured chrome would + // just push it down. + final showFeatured = _queryCtrl.text.trim().isEmpty && + _category.isEmpty && + _status.isEmpty && + !_installedOnly && + items.any((e) => e.featured); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showFeatured) ...[ + _FeaturedStrip( + items: items.where((e) => e.featured).toList(), + locale: _locale, + onTap: _openDetail, + onInstall: _install, + ), + const SizedBox(height: FaiSpace.lg), + ], + Expanded( + child: _StoreGrid( + items: items, + locale: _locale, + installedVersions: _installedVersions, + onTap: _openDetail, + onInstall: _install, + ), + ), + ], ); }, ), @@ -458,6 +485,196 @@ class _StoreGrid extends StatelessWidget { } } +/// Horizontally-scrolling band of editorial picks, rendered +/// only when the operator hasn't filtered the result set. +/// Each tile is a hero version of the regular store card — +/// bigger icon, gradient backdrop, prominent install button. +/// Curated quarterly via the bundled `seed.yaml` `featured:` +/// list so the choice can be reviewed in code review. +class _FeaturedStrip extends StatelessWidget { + final List items; + final String locale; + final ValueChanged onTap; + final ValueChanged onInstall; + + const _FeaturedStrip({ + required this.items, + required this.locale, + required this.onTap, + required this.onInstall, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.star, size: 14, color: FaiColors.success), + const SizedBox(width: 6), + Text( + 'FEATURED', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ), + const SizedBox(width: FaiSpace.sm), + Text( + 'curated picks · click for details', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: FaiSpace.sm), + SizedBox( + height: 152, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(width: FaiSpace.md), + itemBuilder: (context, i) { + final item = items[i]; + return SizedBox( + width: 320, + child: _FeaturedTile( + item: item, + locale: locale, + onTap: () => onTap(item), + onInstall: () => onInstall(item), + ), + ); + }, + ), + ), + ], + ); + } +} + +class _FeaturedTile extends StatelessWidget { + final StoreItem item; + final String locale; + final VoidCallback onTap; + final VoidCallback onInstall; + + const _FeaturedTile({ + required this.item, + required this.locale, + required this.onTap, + required this.onInstall, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + 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: Container( + padding: const EdgeInsets.all(FaiSpace.md), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(FaiRadius.md), + gradient: LinearGradient( + colors: [ + theme.colorScheme.primary.withValues(alpha: 0.10), + theme.colorScheme.surfaceContainer.withValues(alpha: 0.0), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + radius: 20, + backgroundColor: + theme.colorScheme.primary.withValues(alpha: 0.16), + child: Icon( + _iconForCategory(item.category), + size: 22, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(width: FaiSpace.sm), + Expanded( + child: Text( + item.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon(Icons.star, size: 14, color: FaiColors.success), + ], + ), + const SizedBox(height: FaiSpace.sm), + Expanded( + child: Text( + tagline.isEmpty ? '(no tagline)' : tagline, + style: theme.textTheme.bodyMedium, + 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 SizedBox(width: FaiSpace.xs), + if (item.status.isNotEmpty) + FaiPill( + label: item.status, + tone: _toneForStatus(item.status), + ), + const Spacer(), + if (item.installed) + const FaiPill( + label: 'installed', + tone: FaiPillTone.success, + icon: Icons.check, + ) + else if (installable) + FilledButton.icon( + onPressed: onInstall, + icon: const Icon(Icons.download, size: 14), + label: const Text('Install'), + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + class _StoreCard extends StatelessWidget { final StoreItem item; final String locale; diff --git a/pubspec.lock b/pubspec.lock index 43c18da..cb3b3a5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -79,7 +79,7 @@ packages: path: "../fai_dart_sdk" relative: true source: path - version: "0.10.0" + version: "0.10.1" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4e20142..d103423 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.19.0 +version: 0.20.0 environment: sdk: ^3.11.0-200.1.beta