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,
|
||||
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 = '',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]!),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue