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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-09 17:18:40 +02:00
parent 07432055a8
commit 4b30589962
2 changed files with 156 additions and 24 deletions

View file

@ -787,6 +787,8 @@ class HubService {
provider: e.provider, provider: e.provider,
sourceKind: e.sourceKind, sourceKind: e.sourceKind,
source: e.source, source: e.source,
canonicalCategory: e.canonicalCategory,
canonicalCategoryLabel: e.canonicalCategoryLabel,
), ),
) )
.toList(); .toList();
@ -1800,6 +1802,20 @@ class StoreItem {
/// label and group modules by where they came from. /// label and group modules by where they came from.
final String source; 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'; bool get isFederated => kind == 'federated';
const StoreItem({ const StoreItem({
@ -1825,5 +1841,7 @@ class StoreItem {
required this.provider, required this.provider,
this.sourceKind = '', this.sourceKind = '',
this.source = '', this.source = '',
this.canonicalCategory = '',
this.canonicalCategoryLabel = '',
}); });
} }

View file

@ -33,6 +33,12 @@ class _StorePageState extends State<StorePage> {
String _category = ''; String _category = '';
String _status = ''; 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 /// Source filter: '' (all), 'native', 'mcp', 'n8n'. Applied
/// client-side after the hub returns results the search RPC /// client-side after the hub returns results the search RPC
/// has no source field. /// has no source field.
@ -355,6 +361,30 @@ class _StorePageState extends State<StorePage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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<bool>(
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 || if (_aiThinking ||
_aiAnswer != null || _aiAnswer != null ||
_aiError != null) ...[ _aiError != null) ...[
@ -496,6 +526,10 @@ class _StorePageState extends State<StorePage> {
/// card and the grid stay coherent. /// card and the grid stay coherent.
List<StoreItem> _applyAllFilters(List<StoreItem> items) { List<StoreItem> _applyAllFilters(List<StoreItem> items) {
var out = _applySourceFilter(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; final ai = _aiMatchedNames;
if (ai != null) { if (ai != null) {
out = out.where((e) => ai.contains(e.name)).toList(); out = out.where((e) => ai.contains(e.name)).toList();
@ -1529,36 +1563,116 @@ class _StoreGrid extends StatelessWidget {
required this.onInstall, 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<String> _order = [
'documents',
'text-language',
'data',
'ai-llm',
'web-api',
'analysis-domain',
'examples-dev',
'studio-themes',
'other',
];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
const minCardWidth = 360.0; const minCardWidth = 360.0;
final cols = (constraints.maxWidth / minCardWidth).floor().clamp(1, 4); final cols = (constraints.maxWidth / minCardWidth).floor().clamp(1, 4);
// shrinkWrap + NeverScrollable lets the grid sit inside
// the page-level SingleChildScrollView so editorial // Group the flat result set by canonical category so the
// chrome and the grid scroll as one continuous surface // store reads like an app store a labelled section per
// (App-Store / Play-Store behaviour). Without this, the // category instead of one jumbled grid. Falls back to the
// inner grid claims its own scroll viewport and the // raw `category` label for a pre-0.21 hub that sends no
// outer Column overflows on small windows. // canonical slug.
return GridView.builder( final groups = <String, List<StoreItem>>{};
padding: EdgeInsets.zero, final labels = <String, String>{};
shrinkWrap: true, for (final it in items) {
physics: const NeverScrollableScrollPhysics(), final slug = it.canonicalCategory.isNotEmpty
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( ? it.canonicalCategory
crossAxisCount: cols, : (it.category.isNotEmpty ? it.category : 'other');
mainAxisSpacing: ChainSpace.md, groups.putIfAbsent(slug, () => []).add(it);
crossAxisSpacing: ChainSpace.md, labels[slug] = it.canonicalCategoryLabel.isNotEmpty
mainAxisExtent: 168, ? it.canonicalCategoryLabel
), : (it.category.isNotEmpty ? it.category : 'Other');
itemCount: items.length, }
itemBuilder: (context, i) => _StoreCard( final slugs = groups.keys.toList()
item: items[i], ..sort((a, b) {
locale: locale, final ia = _order.indexOf(a);
installedVersion: installedVersions[items[i].name], final ib = _order.indexOf(b);
onTap: () => onTap(items[i]), // Unknown slugs sort after the known order, alphabetically.
onInstall: () => onInstall(items[i]), 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<StoreItem> 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]!),
],
],
); );
}, },
); );