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

@ -33,6 +33,12 @@ class _StorePageState extends State<StorePage> {
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<StorePage> {
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<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 ||
_aiAnswer != null ||
_aiError != null) ...[
@ -496,6 +526,10 @@ class _StorePageState extends State<StorePage> {
/// card and the grid stay coherent.
List<StoreItem> _applyAllFilters(List<StoreItem> 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<String> _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 = <String, List<StoreItem>>{};
final labels = <String, String>{};
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<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]!),
],
],
);
},
);