fix(studio): store overflow + visual clutter (v0.31.1)
Two issues from the user feedback after v0.31.0:
1. RenderFlex overflow in the store body. The inner Column
wrapped the editorial chrome (Today, Recommended,
Featured) plus an Expanded grid; on small windows the
chrome alone exceeded the available height and the grid
got squeezed past zero, painting the yellow-and-black
stripe.
Fix: wrap the inner Column in a SingleChildScrollView so
chrome and grid scroll as one continuous surface
(App-Store / Play-Store behaviour). The grid switches to
`shrinkWrap: true` + `NeverScrollableScrollPhysics()` so
it doesn't try to claim its own viewport.
2. "Der Store ist zu unaufgeräumt, ich fühle mich
erschlagen." — three editorial cards stacked above the
filter row + grid was too much.
Visual cleanup:
- The recommended-source quick-add chips are now inlined
into the Today hero's footer (same row as the CTA).
The standalone `_RecommendedSourcesStrip` only fires in
the corner case where Today is dismissed AND no
federation exists yet.
- The Featured strip no longer competes with Today —
it only renders once Today has been dismissed. Today
plays the editorial-hero role; Featured is the
fallback editorial surface.
- Provenance pill ("native") is dropped on native cards.
Native is the default and doesn't earn a badge — only
foreign code (mcp / n8n) gets the provenance pill, the
same way "verified" is opt-in in commercial app stores.
Net: at most one editorial card above the grid at any time.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
ce97300a12
commit
b0f09260e1
3 changed files with 121 additions and 80 deletions
|
|
@ -232,82 +232,79 @@ class _StorePageState extends State<StorePage> {
|
|||
),
|
||||
);
|
||||
}
|
||||
// Featured strip is only useful in the
|
||||
// browse-the-whole-thing case. Once the
|
||||
// operator has typed a query or picked a
|
||||
// filter, the result list itself is the
|
||||
// answer they want — featured chrome would
|
||||
// just push it down.
|
||||
final showFeatured = _queryCtrl.text.trim().isEmpty &&
|
||||
_category.isEmpty &&
|
||||
_status.isEmpty &&
|
||||
_source.isEmpty &&
|
||||
!_installedOnly &&
|
||||
items.any((e) => e.featured);
|
||||
// Sparse-store nudge: when no filter is set
|
||||
// and the store carries no federated entries
|
||||
// yet, surface curated public MCP servers as
|
||||
// one-click add cards. Hides the moment any
|
||||
// federated entry shows up — the strip has
|
||||
// done its job.
|
||||
final showRecommendedStrip =
|
||||
_queryCtrl.text.trim().isEmpty &&
|
||||
_category.isEmpty &&
|
||||
_status.isEmpty &&
|
||||
_source.isEmpty &&
|
||||
!_installedOnly &&
|
||||
!raw.any((e) => e.isFederated);
|
||||
// Editorial hero is browsing-only chrome —
|
||||
// hides as soon as any filter is set so the
|
||||
// operator's actual query stays in focus.
|
||||
final showToday = !_todayDismissed &&
|
||||
_queryCtrl.text.trim().isEmpty &&
|
||||
// Browsing-only chrome — every editorial
|
||||
// surface hides the moment a filter is set so
|
||||
// the operator's query stays the focus.
|
||||
final isBrowsing = _queryCtrl.text.trim().isEmpty &&
|
||||
_category.isEmpty &&
|
||||
_status.isEmpty &&
|
||||
_source.isEmpty &&
|
||||
!_installedOnly;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showToday) ...[
|
||||
_StoreTodayHero(
|
||||
story: _todayStory,
|
||||
locale: _locale,
|
||||
onDismiss: () =>
|
||||
setState(() => _todayDismissed = true),
|
||||
onCta: _todayStory.cta == TodayCta.openSettings
|
||||
? () => FaiSettingsDialog.show(context)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
],
|
||||
if (showRecommendedStrip) ...[
|
||||
_RecommendedSourcesStrip(
|
||||
adding: _addingSources,
|
||||
locale: _locale,
|
||||
onAdd: _addRecommendedSource,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
],
|
||||
if (showFeatured) ...[
|
||||
_FeaturedStrip(
|
||||
items: items.where((e) => e.featured).toList(),
|
||||
locale: _locale,
|
||||
onTap: _openDetail,
|
||||
onInstall: _install,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
],
|
||||
Expanded(
|
||||
child: _StoreGrid(
|
||||
final hasNoFederation = !raw.any((e) => e.isFederated);
|
||||
final showToday = !_todayDismissed && isBrowsing;
|
||||
// Recommended-source quick-adds are inlined in
|
||||
// the Today footer when both apply, so the
|
||||
// operator never sees two competing editorial
|
||||
// cards stacked on top of each other.
|
||||
final embedRecommended = showToday && hasNoFederation;
|
||||
// Standalone strip only fires in the corner
|
||||
// case: Today dismissed AND no federation.
|
||||
// Featured strip and Today never compete —
|
||||
// Today is the editorial hero, Featured plays
|
||||
// second fiddle and only renders once Today is
|
||||
// out of the way.
|
||||
final showStandaloneRecommended =
|
||||
isBrowsing && _todayDismissed && hasNoFederation;
|
||||
final showFeatured = isBrowsing &&
|
||||
_todayDismissed &&
|
||||
items.any((e) => e.featured);
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showToday) ...[
|
||||
_StoreTodayHero(
|
||||
story: _todayStory,
|
||||
locale: _locale,
|
||||
onDismiss: () =>
|
||||
setState(() => _todayDismissed = true),
|
||||
onCta: _todayStory.cta == TodayCta.openSettings
|
||||
? () => FaiSettingsDialog.show(context)
|
||||
: null,
|
||||
recommendedSources: embedRecommended
|
||||
? _kRecommendedSources
|
||||
: const <_RecommendedSource>[],
|
||||
recommendedAdding: _addingSources,
|
||||
onAddRecommended: _addRecommendedSource,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
],
|
||||
if (showStandaloneRecommended) ...[
|
||||
_RecommendedSourcesStrip(
|
||||
adding: _addingSources,
|
||||
locale: _locale,
|
||||
onAdd: _addRecommendedSource,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
],
|
||||
if (showFeatured) ...[
|
||||
_FeaturedStrip(
|
||||
items: items.where((e) => e.featured).toList(),
|
||||
locale: _locale,
|
||||
onTap: _openDetail,
|
||||
onInstall: _install,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
],
|
||||
_StoreGrid(
|
||||
items: items,
|
||||
locale: _locale,
|
||||
installedVersions: _installedVersions,
|
||||
onTap: _openDetail,
|
||||
onInstall: _install,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -603,8 +600,16 @@ class _StoreGrid extends StatelessWidget {
|
|||
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: FaiSpace.md,
|
||||
|
|
@ -1972,12 +1977,23 @@ class _StoreTodayHero extends StatelessWidget {
|
|||
/// because navigation targets live in the store-page state,
|
||||
/// not in const story data.
|
||||
final VoidCallback? onCta;
|
||||
/// Inline one-click "+ Add public source" chips rendered in
|
||||
/// the hero footer. Empty list hides the row — the parent
|
||||
/// passes [_kRecommendedSources] only when the store has zero
|
||||
/// federated entries, so we never duplicate the standalone
|
||||
/// strip.
|
||||
final List<_RecommendedSource> recommendedSources;
|
||||
final Set<String> recommendedAdding;
|
||||
final void Function(_RecommendedSource)? onAddRecommended;
|
||||
|
||||
const _StoreTodayHero({
|
||||
required this.story,
|
||||
required this.locale,
|
||||
required this.onDismiss,
|
||||
required this.onCta,
|
||||
this.recommendedSources = const <_RecommendedSource>[],
|
||||
this.recommendedAdding = const <String>{},
|
||||
this.onAddRecommended,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -2082,15 +2098,39 @@ class _StoreTodayHero extends StatelessWidget {
|
|||
height: 1.45,
|
||||
),
|
||||
),
|
||||
if (onCta != null) ...[
|
||||
if (onCta != null || recommendedSources.isNotEmpty) ...[
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onCta,
|
||||
icon: const Icon(Icons.arrow_forward, size: 14),
|
||||
label: Text(ctaLabel),
|
||||
style: FilledButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
Wrap(
|
||||
spacing: FaiSpace.sm,
|
||||
runSpacing: FaiSpace.xs,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
if (onCta != null)
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onCta,
|
||||
icon: const Icon(Icons.arrow_forward, size: 14),
|
||||
label: Text(ctaLabel),
|
||||
style: FilledButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
),
|
||||
for (final s in recommendedSources)
|
||||
ActionChip(
|
||||
avatar: recommendedAdding.contains(s.name)
|
||||
? const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child:
|
||||
CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(s.icon, size: 14),
|
||||
label: Text('+ ${s.label}'),
|
||||
onPressed:
|
||||
recommendedAdding.contains(s.name) || onAddRecommended == null
|
||||
? null
|
||||
: () => onAddRecommended!(s),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
|
|
@ -2340,10 +2380,11 @@ class _ProvenancePill extends StatelessWidget {
|
|||
icon: Icons.account_tree_outlined,
|
||||
);
|
||||
default:
|
||||
return FaiPill(
|
||||
label: l.storeProvenanceNative,
|
||||
tone: FaiPillTone.neutral,
|
||||
);
|
||||
// Native is the default: no badge needed. Keeps the
|
||||
// card visually quiet — only foreign code (mcp / n8n)
|
||||
// earns the provenance pill, the same way a "verified"
|
||||
// badge is opt-in in commercial app stores.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue