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:
parent
07432055a8
commit
4b30589962
2 changed files with 156 additions and 24 deletions
|
|
@ -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 = '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]!),
|
||||||
|
],
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue