diff --git a/lib/main.dart b/lib/main.dart index 34825ee..8ff478a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,7 +26,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.31.0'; +const String kStudioVersion = '0.31.1'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 4199995..e394999 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -232,82 +232,79 @@ class _StorePageState extends State { ), ); } - // 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 && - _source.isEmpty && - !_installedOnly && - items.any((e) => e.featured); - // Sparse-store nudge: when no filter is set - // and the store carries no federated entries - // yet, surface curated public MCP servers as - // one-click add cards. Hides the moment any - // federated entry shows up — the strip has - // done its job. - final showRecommendedStrip = - _queryCtrl.text.trim().isEmpty && - _category.isEmpty && - _status.isEmpty && - _source.isEmpty && - !_installedOnly && - !raw.any((e) => e.isFederated); - // Editorial hero is browsing-only chrome — - // hides as soon as any filter is set so the - // operator's actual query stays in focus. - final showToday = !_todayDismissed && - _queryCtrl.text.trim().isEmpty && + // Browsing-only chrome — every editorial + // surface hides the moment a filter is set so + // the operator's query stays the focus. + final isBrowsing = _queryCtrl.text.trim().isEmpty && _category.isEmpty && _status.isEmpty && _source.isEmpty && !_installedOnly; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showToday) ...[ - _StoreTodayHero( - story: _todayStory, - locale: _locale, - onDismiss: () => - setState(() => _todayDismissed = true), - onCta: _todayStory.cta == TodayCta.openSettings - ? () => FaiSettingsDialog.show(context) - : null, - ), - const SizedBox(height: FaiSpace.md), - ], - if (showRecommendedStrip) ...[ - _RecommendedSourcesStrip( - adding: _addingSources, - locale: _locale, - onAdd: _addRecommendedSource, - ), - const SizedBox(height: FaiSpace.md), - ], - if (showFeatured) ...[ - _FeaturedStrip( - items: items.where((e) => e.featured).toList(), - locale: _locale, - onTap: _openDetail, - onInstall: _install, - ), - const SizedBox(height: FaiSpace.lg), - ], - Expanded( - child: _StoreGrid( + final hasNoFederation = !raw.any((e) => e.isFederated); + final showToday = !_todayDismissed && isBrowsing; + // Recommended-source quick-adds are inlined in + // the Today footer when both apply, so the + // operator never sees two competing editorial + // cards stacked on top of each other. + final embedRecommended = showToday && hasNoFederation; + // Standalone strip only fires in the corner + // case: Today dismissed AND no federation. + // Featured strip and Today never compete — + // Today is the editorial hero, Featured plays + // second fiddle and only renders once Today is + // out of the way. + final showStandaloneRecommended = + isBrowsing && _todayDismissed && hasNoFederation; + final showFeatured = isBrowsing && + _todayDismissed && + items.any((e) => e.featured); + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showToday) ...[ + _StoreTodayHero( + story: _todayStory, + locale: _locale, + onDismiss: () => + setState(() => _todayDismissed = true), + onCta: _todayStory.cta == TodayCta.openSettings + ? () => FaiSettingsDialog.show(context) + : null, + recommendedSources: embedRecommended + ? _kRecommendedSources + : const <_RecommendedSource>[], + recommendedAdding: _addingSources, + onAddRecommended: _addRecommendedSource, + ), + const SizedBox(height: FaiSpace.lg), + ], + if (showStandaloneRecommended) ...[ + _RecommendedSourcesStrip( + adding: _addingSources, + locale: _locale, + onAdd: _addRecommendedSource, + ), + const SizedBox(height: FaiSpace.lg), + ], + if (showFeatured) ...[ + _FeaturedStrip( + items: items.where((e) => e.featured).toList(), + locale: _locale, + onTap: _openDetail, + onInstall: _install, + ), + const SizedBox(height: FaiSpace.lg), + ], + _StoreGrid( items: items, locale: _locale, installedVersions: _installedVersions, onTap: _openDetail, onInstall: _install, ), - ), - ], + ], + ), ); }, ), @@ -603,8 +600,16 @@ class _StoreGrid extends StatelessWidget { builder: (context, constraints) { const minCardWidth = 360.0; final cols = (constraints.maxWidth / minCardWidth).floor().clamp(1, 4); + // shrinkWrap + NeverScrollable lets the grid sit inside + // the page-level SingleChildScrollView so editorial + // chrome and the grid scroll as one continuous surface + // (App-Store / Play-Store behaviour). Without this, the + // inner grid claims its own scroll viewport and the + // outer Column overflows on small windows. return GridView.builder( padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: cols, mainAxisSpacing: FaiSpace.md, @@ -1972,12 +1977,23 @@ class _StoreTodayHero extends StatelessWidget { /// because navigation targets live in the store-page state, /// not in const story data. final VoidCallback? onCta; + /// Inline one-click "+ Add public source" chips rendered in + /// the hero footer. Empty list hides the row — the parent + /// passes [_kRecommendedSources] only when the store has zero + /// federated entries, so we never duplicate the standalone + /// strip. + final List<_RecommendedSource> recommendedSources; + final Set recommendedAdding; + final void Function(_RecommendedSource)? onAddRecommended; const _StoreTodayHero({ required this.story, required this.locale, required this.onDismiss, required this.onCta, + this.recommendedSources = const <_RecommendedSource>[], + this.recommendedAdding = const {}, + this.onAddRecommended, }); @override @@ -2082,15 +2098,39 @@ class _StoreTodayHero extends StatelessWidget { height: 1.45, ), ), - if (onCta != null) ...[ + if (onCta != null || recommendedSources.isNotEmpty) ...[ const SizedBox(height: FaiSpace.md), - FilledButton.tonalIcon( - onPressed: onCta, - icon: const Icon(Icons.arrow_forward, size: 14), - label: Text(ctaLabel), - style: FilledButton.styleFrom( - visualDensity: VisualDensity.compact, - ), + Wrap( + spacing: FaiSpace.sm, + runSpacing: FaiSpace.xs, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + if (onCta != null) + FilledButton.tonalIcon( + onPressed: onCta, + icon: const Icon(Icons.arrow_forward, size: 14), + label: Text(ctaLabel), + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + ), + ), + for (final s in recommendedSources) + ActionChip( + avatar: recommendedAdding.contains(s.name) + ? const SizedBox( + width: 12, + height: 12, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(s.icon, size: 14), + label: Text('+ ${s.label}'), + onPressed: + recommendedAdding.contains(s.name) || onAddRecommended == null + ? null + : () => onAddRecommended!(s), + ), + ], ), ], ], @@ -2340,10 +2380,11 @@ class _ProvenancePill extends StatelessWidget { icon: Icons.account_tree_outlined, ); default: - return FaiPill( - label: l.storeProvenanceNative, - tone: FaiPillTone.neutral, - ); + // Native is the default: no badge needed. Keeps the + // card visually quiet — only foreign code (mcp / n8n) + // earns the provenance pill, the same way a "verified" + // badge is opt-in in commercial app stores. + return const SizedBox.shrink(); } } } diff --git a/pubspec.yaml b/pubspec.yaml index a45d1fc..016000b 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.31.0 +version: 0.31.1 environment: sdk: ^3.11.0-200.1.beta