From 4b30589962dd409ecf807ef0d5f9c9b8b5bcb800 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 9 Jul 2026 17:18:40 +0200 Subject: [PATCH] feat(store): group grid by canonical category + Modules/Studio segment The store grid was a flat jumble. It now renders labelled sections per canonical category (App-Store style, fixed order, with counts), and a top 'Modules | Studio & Themes' segment splits flow modules from Studio plugins/themes. StoreItem carries the hub's canonical_category(+label); falls back to the raw category label for a pre-0.21 hub. Signed-off-by: flemming-it --- lib/data/hub.dart | 18 +++++ lib/pages/store.dart | 162 ++++++++++++++++++++++++++++++++++++------- 2 files changed, 156 insertions(+), 24 deletions(-) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index df4b7fc..a10f431 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -787,6 +787,8 @@ class HubService { provider: e.provider, sourceKind: e.sourceKind, source: e.source, + canonicalCategory: e.canonicalCategory, + canonicalCategoryLabel: e.canonicalCategoryLabel, ), ) .toList(); @@ -1800,6 +1802,20 @@ class StoreItem { /// label and group modules by where they came from. final String source; + /// Normalized category slug from the hub (e.g. "data", + /// "studio-themes"). The whole catalogue is mapped onto a fixed + /// taxonomy by capability namespace, so different stores' free-form + /// labels collapse into one bucket. Studio groups + filters by this, + /// not [category]. Empty from a pre-0.21 hub → fall back to [category]. + final String canonicalCategory; + + /// Human-readable label for [canonicalCategory] (e.g. "Data & Formats"). + final String canonicalCategoryLabel; + + /// True iff this entry is a Studio plugin/theme rather than a flow + /// module — drives the "Modules | Studio & Themes" store segment. + bool get isStudioPlugin => canonicalCategory == 'studio-themes'; + bool get isFederated => kind == 'federated'; const StoreItem({ @@ -1825,5 +1841,7 @@ class StoreItem { required this.provider, this.sourceKind = '', this.source = '', + this.canonicalCategory = '', + this.canonicalCategoryLabel = '', }); } diff --git a/lib/pages/store.dart b/lib/pages/store.dart index d21a7bf..4d38484 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -33,6 +33,12 @@ class _StorePageState extends State { String _category = ''; String _status = ''; + /// Store segment: `false` shows flow Modules, `true` shows Studio + /// plugins + themes. A theme extends the GUI, a module runs in a + /// flow — mixing them in one grid was a big part of the clutter, so + /// they live under a top segment toggle instead. + bool _showStudio = false; + /// Source filter: '' (all), 'native', 'mcp', 'n8n'. Applied /// client-side after the hub returns results — the search RPC /// has no source field. @@ -355,6 +361,30 @@ class _StorePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Modules vs Studio plugins/themes — a theme + // extends the GUI, a module runs in a flow. + Padding( + padding: + const EdgeInsets.only(bottom: ChainSpace.md), + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: false, + label: Text('Module'), + icon: Icon(Icons.extension_outlined, size: 16), + ), + ButtonSegment( + value: true, + label: Text('Studio & Themes'), + icon: Icon(Icons.palette_outlined, size: 16), + ), + ], + selected: {_showStudio}, + showSelectedIcon: false, + onSelectionChanged: (s) => + setState(() => _showStudio = s.first), + ), + ), if (_aiThinking || _aiAnswer != null || _aiError != null) ...[ @@ -496,6 +526,10 @@ class _StorePageState extends State { /// card and the grid stay coherent. List _applyAllFilters(List items) { var out = _applySourceFilter(items); + // Store segment: flow modules vs Studio plugins/themes. Federated + // entries (MCP/n8n bridges) are never Studio plugins, so they stay + // in the Modules segment. + out = out.where((e) => e.isStudioPlugin == _showStudio).toList(); final ai = _aiMatchedNames; if (ai != null) { out = out.where((e) => ai.contains(e.name)).toList(); @@ -1529,36 +1563,116 @@ class _StoreGrid extends StatelessWidget { required this.onInstall, }); + /// Fixed display order of the canonical categories (mirrors the + /// hub's `Category::all_ordered`) — flow-module categories first, + /// Studio + Other last, so the grouped store reads top-to-bottom + /// like an app store's category rows. + static const List _order = [ + 'documents', + 'text-language', + 'data', + 'ai-llm', + 'web-api', + 'analysis-domain', + 'examples-dev', + 'studio-themes', + 'other', + ]; + @override Widget build(BuildContext context) { return LayoutBuilder( 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: ChainSpace.md, - crossAxisSpacing: ChainSpace.md, - mainAxisExtent: 168, - ), - itemCount: items.length, - itemBuilder: (context, i) => _StoreCard( - item: items[i], - locale: locale, - installedVersion: installedVersions[items[i].name], - onTap: () => onTap(items[i]), - onInstall: () => onInstall(items[i]), - ), + + // Group the flat result set by canonical category so the + // store reads like an app store — a labelled section per + // category instead of one jumbled grid. Falls back to the + // raw `category` label for a pre-0.21 hub that sends no + // canonical slug. + final groups = >{}; + final labels = {}; + for (final it in items) { + final slug = it.canonicalCategory.isNotEmpty + ? it.canonicalCategory + : (it.category.isNotEmpty ? it.category : 'other'); + groups.putIfAbsent(slug, () => []).add(it); + labels[slug] = it.canonicalCategoryLabel.isNotEmpty + ? it.canonicalCategoryLabel + : (it.category.isNotEmpty ? it.category : 'Other'); + } + final slugs = groups.keys.toList() + ..sort((a, b) { + final ia = _order.indexOf(a); + final ib = _order.indexOf(b); + // Unknown slugs sort after the known order, alphabetically. + if (ia == -1 && ib == -1) return a.compareTo(b); + if (ia == -1) return 1; + if (ib == -1) return -1; + return ia.compareTo(ib); + }); + + Widget grid(List gi) => GridView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + mainAxisSpacing: ChainSpace.md, + crossAxisSpacing: ChainSpace.md, + mainAxisExtent: 168, + ), + itemCount: gi.length, + itemBuilder: (context, i) => _StoreCard( + item: gi[i], + locale: locale, + installedVersion: installedVersions[gi[i].name], + onTap: () => onTap(gi[i]), + onInstall: () => onInstall(gi[i]), + ), + ); + + // A single category (e.g. the store is already filtered to + // one) renders without a redundant header. + if (slugs.length <= 1) { + return grid(items); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final slug in slugs) ...[ + Padding( + padding: const EdgeInsets.only( + top: ChainSpace.lg, + bottom: ChainSpace.sm, + ), + child: Row( + children: [ + Text( + labels[slug] ?? slug, + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(width: ChainSpace.sm), + Text( + '${groups[slug]!.length}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + grid(groups[slug]!), + ], + ], ); }, );