feat(studio): plain-language store, bottom-anchored ask bar (v0.33.0)
The store had three structural problems flagged in user feedback: 1. "Lernkurve zu hoch — alle bridge, debug, alpha, planned, keiner weiß was das heißt." Wire-level enum values bled through into the UI: `bridge`, `debug`, `published`, `alpha`, `planned`. Operators don't share the data model. 2. "Filter / Kategorien sollten oben in die Leiste neben Store zum ausklappen, der Body soll übersichtlicher werden." A chip-row plus category strip plus result-count line ate a full row of viewport on every screen. 3. "Suche passt besser an den unteren Bildschirmrand." The chat-style ask bar belongs at the bottom — Claude / ChatGPT / Slack pattern — not at the top where it competes with the Today hero. Plus: "Es gibt noch ganz viele overflows in den Beschreibungen." Changes: - Plain-language display names. New ARB keys for category labels (Connectors / Sample modules / AI models / Storage / Channels / Authentication / Orchestration / …) and updated status values (stable / experimental / coming soon). Wire ids stay in StoreEntry, RPCs, seed.yaml — only the rendered pills and dropdowns get translated. Helpers `_categoryDisplayName` and `_statusDisplayName` keep the mapping in one place. - Toolbar moves into the AppBar. The new `_CategoryDropdown` hosts the category picker as a popup-menu with localised labels; the existing `_FilterButton` lives next to it; the reload icon stays where it always was. The body no longer carries any chip strip or result-count line. - Bottom-anchored `_AskBar`. The Scaffold body becomes a Column of `Expanded(scrollable content)` plus a pinned composer row at the foot of the viewport, with a top divider matching the chat-input pattern of modern AI assistants. AI answers now render at the top of the scroll (above the Today-Hero) so the operator sees them right after submitting the question at the bottom. - Overflow sweep on descriptions. Today-Hero header row becomes a Wrap (badge + deck flow naturally on narrow windows), the title is bound to 3 lines, the body to 6. Card category text gets `maxLines: 1, ellipsis` in both StoreCard and FeaturedTile. Recommended-source card title gets the same treatment. - Better toast for the "added but zero capabilities" path. When MCP discovery succeeds but returns no tools (typical Streamable-HTTP servers without Mcp-Session-Id support, or servers that need `notifications/initialized`), the toast now explains the situation in plain language and points the operator at Settings → MCP Clients to retry. Old pluralised toast still fires when N > 0. Hub-side `notifications/initialized` for HTTP MCP and Mcp-Session-Id support are out of scope for this commit; tracked as a separate fai_hub follow-up. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
c7869870c4
commit
de22791e86
8 changed files with 434 additions and 143 deletions
|
|
@ -153,10 +153,34 @@ class _StorePageState extends State<StorePage> {
|
|||
appBar: AppBar(
|
||||
title: Text(l.searchGroupStore),
|
||||
actions: [
|
||||
// The DE/EN toggle now lives in the sidebar footer
|
||||
// and flips the entire app's locale. The Store
|
||||
// detail-sheet content follows automatically because
|
||||
// _locale reads from `Localizations.localeOf`.
|
||||
// Category dropdown and Filter button live in the
|
||||
// toolbar so the body stays uncluttered. Both refresh
|
||||
// off the current store snapshot via FutureBuilder.
|
||||
FutureBuilder<List<StoreItem>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
final raw = snap.data ?? const <StoreItem>[];
|
||||
final categories = _categoriesIn(raw);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: FaiSpace.sm),
|
||||
child: _CategoryDropdown(
|
||||
categories: categories,
|
||||
selected: _category,
|
||||
onSelected: (v) {
|
||||
setState(() => _category = v);
|
||||
_runSearch();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: FaiSpace.sm),
|
||||
child: _FilterButton(
|
||||
activeCount: _activeFilterCount(),
|
||||
onPressed: _openFilterDialog,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
tooltip: l.storeReloadTooltip,
|
||||
|
|
@ -165,70 +189,20 @@ class _StorePageState extends State<StorePage> {
|
|||
const SizedBox(width: FaiSpace.sm),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
FaiSpace.xl,
|
||||
FaiSpace.lg,
|
||||
FaiSpace.xl,
|
||||
0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_AskBar(
|
||||
controller: _queryCtrl,
|
||||
thinking: _aiThinking,
|
||||
aiAvailable: _systemAiEnabled,
|
||||
onSubmit: _onAskSubmit,
|
||||
onClear: _clearQuery,
|
||||
onTextChange: _onAskTyping,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
FutureBuilder<List<StoreItem>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
final raw = snap.data ?? const <StoreItem>[];
|
||||
final items = _applyAllFilters(raw);
|
||||
final categories = _categoriesIn(raw);
|
||||
final activeFilters = _activeFilterCount();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_FilterButton(
|
||||
activeCount: activeFilters,
|
||||
onPressed: _openFilterDialog,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.md),
|
||||
if (categories.isNotEmpty)
|
||||
Expanded(
|
||||
child: _CategoryStrip(
|
||||
categories: categories,
|
||||
selected: _category,
|
||||
onSelected: (v) {
|
||||
setState(() => _category = v);
|
||||
_runSearch();
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
const Spacer(),
|
||||
Text(
|
||||
l.storeNResults(items.length),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: FaiSpace.md),
|
||||
Expanded(
|
||||
body: Column(
|
||||
children: [
|
||||
// Scrollable content: editorial chrome + grid. Sits in
|
||||
// an Expanded so the bottom AskBar pins to the foot of
|
||||
// the viewport, mirroring the chat-input pattern of
|
||||
// Claude / ChatGPT / Slack.
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
FaiSpace.xl,
|
||||
FaiSpace.lg,
|
||||
FaiSpace.xl,
|
||||
0,
|
||||
),
|
||||
child: FutureBuilder<List<StoreItem>>(
|
||||
future: _future,
|
||||
builder: (context, snap) {
|
||||
|
|
@ -299,6 +273,18 @@ class _StorePageState extends State<StorePage> {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_aiThinking ||
|
||||
_aiAnswer != null ||
|
||||
_aiError != null) ...[
|
||||
_AiAnswerCard(
|
||||
thinking: _aiThinking,
|
||||
answer: _aiAnswer,
|
||||
matches: _aiMatches ?? const <_AiMatch>[],
|
||||
error: _aiError,
|
||||
onClear: _clearQuery,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
],
|
||||
if (showToday) ...[
|
||||
_StoreTodayHero(
|
||||
story: _todayStory,
|
||||
|
|
@ -333,18 +319,6 @@ class _StorePageState extends State<StorePage> {
|
|||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
],
|
||||
if (_aiThinking ||
|
||||
_aiAnswer != null ||
|
||||
_aiError != null) ...[
|
||||
_AiAnswerCard(
|
||||
thinking: _aiThinking,
|
||||
answer: _aiAnswer,
|
||||
matches: _aiMatches ?? const <_AiMatch>[],
|
||||
error: _aiError,
|
||||
onClear: _clearQuery,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
],
|
||||
_StoreGrid(
|
||||
items: items,
|
||||
locale: _locale,
|
||||
|
|
@ -352,14 +326,39 @@ class _StorePageState extends State<StorePage> {
|
|||
onTap: _openDetail,
|
||||
onInstall: _install,
|
||||
),
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Pinned chat-style ask bar at the foot of the
|
||||
// viewport. Always visible so the operator can switch
|
||||
// from browsing to asking with one click — same shape
|
||||
// as Claude / ChatGPT / Slack composer rows.
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.xl,
|
||||
vertical: FaiSpace.md,
|
||||
),
|
||||
child: _AskBar(
|
||||
controller: _queryCtrl,
|
||||
thinking: _aiThinking,
|
||||
aiAvailable: _systemAiEnabled,
|
||||
onSubmit: _onAskSubmit,
|
||||
onClear: _clearQuery,
|
||||
onTextChange: _onAskTyping,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -532,11 +531,20 @@ class _StorePageState extends State<StorePage> {
|
|||
orElse: () => list.last,
|
||||
);
|
||||
if (!mounted) return;
|
||||
// toolCount == 0 happens when the server registered fine
|
||||
// but discovery returned nothing — typically a transport
|
||||
// mismatch or a server that wants `notifications/initialized`
|
||||
// before listing. We surface the difference so the
|
||||
// operator knows whether to celebrate or to debug.
|
||||
final msg = added.toolCount > 0
|
||||
? l.storeProviderAddedToast(s.label, added.toolCount)
|
||||
: l.storeProviderAddedZeroToast(s.label);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
l.storeProviderAddedToast(s.label, added.toolCount),
|
||||
),
|
||||
content: Text(msg),
|
||||
duration: added.toolCount > 0
|
||||
? const Duration(seconds: 4)
|
||||
: const Duration(seconds: 8),
|
||||
),
|
||||
);
|
||||
_runSearch();
|
||||
|
|
@ -565,6 +573,59 @@ String _sourceForItem(StoreItem i) {
|
|||
return 'federated';
|
||||
}
|
||||
|
||||
/// Translates a wire-level category id (`bridge`, `debug`, …)
|
||||
/// into a label the operator can read. Wire ids stay in the
|
||||
/// data model — only the display side gets localised. Unknown
|
||||
/// ids fall through unchanged so newly-added categories don't
|
||||
/// vanish until we update this map.
|
||||
String _categoryDisplayName(BuildContext ctx, String wire) {
|
||||
final l = AppLocalizations.of(ctx)!;
|
||||
switch (wire) {
|
||||
case '':
|
||||
return '';
|
||||
case 'bridge':
|
||||
return l.storeCategoryBridge;
|
||||
case 'debug':
|
||||
return l.storeCategoryDebug;
|
||||
case 'text':
|
||||
case 'text-processing':
|
||||
return l.storeCategoryText;
|
||||
case 'llm':
|
||||
return l.storeCategoryLlm;
|
||||
case 'system':
|
||||
return l.storeCategorySystem;
|
||||
case 'auth':
|
||||
return l.storeCategoryAuth;
|
||||
case 'storage':
|
||||
return l.storeCategoryStorage;
|
||||
case 'channel':
|
||||
return l.storeCategoryChannel;
|
||||
case 'web':
|
||||
return l.storeCategoryWeb;
|
||||
case 'orchestrator':
|
||||
return l.storeCategoryOrchestrator;
|
||||
default:
|
||||
return wire;
|
||||
}
|
||||
}
|
||||
|
||||
/// Same idea for the `published` / `alpha` / `planned` status
|
||||
/// values. Operators read "stable / experimental / coming
|
||||
/// soon"; the wire keeps the strict enum it has always had.
|
||||
String _statusDisplayName(BuildContext ctx, String wire) {
|
||||
final l = AppLocalizations.of(ctx)!;
|
||||
switch (wire) {
|
||||
case 'published':
|
||||
return l.storeStatusPublished;
|
||||
case 'alpha':
|
||||
return l.storeStatusAlpha;
|
||||
case 'planned':
|
||||
return l.storeStatusPlanned;
|
||||
default:
|
||||
return wire;
|
||||
}
|
||||
}
|
||||
|
||||
/// Multi-line ask-and-search input. Drives the toolbar's
|
||||
/// primary action: substring keyword search on every keystroke
|
||||
/// (cheap, identical to the legacy `_SearchField`) plus an
|
||||
|
|
@ -1056,15 +1117,17 @@ class _AiAnswerCard extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// Horizontally-scrollable category chip strip. Always-on
|
||||
/// "All" chip first, then categories the current result set
|
||||
/// contains. Disappears when the index is empty.
|
||||
class _CategoryStrip extends StatelessWidget {
|
||||
/// Compact category picker that lives in the AppBar's actions
|
||||
/// row. Replaces the older horizontally-scrolling chip strip
|
||||
/// because chips ate a full row of viewport for what is in
|
||||
/// practice a one-click operation; a dropdown is faster to
|
||||
/// reach and never grows past the toolbar.
|
||||
class _CategoryDropdown extends StatelessWidget {
|
||||
final List<String> categories;
|
||||
final String selected;
|
||||
final void Function(String) onSelected;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
const _CategoryStrip({
|
||||
const _CategoryDropdown({
|
||||
required this.categories,
|
||||
required this.selected,
|
||||
required this.onSelected,
|
||||
|
|
@ -1072,25 +1135,53 @@ class _CategoryStrip extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
if (categories.isEmpty) return const SizedBox.shrink();
|
||||
return SizedBox(
|
||||
height: 36,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
_ChoiceChip(
|
||||
label: l.storeStatusAll,
|
||||
selected: selected.isEmpty,
|
||||
onSelected: () => onSelected(''),
|
||||
final activeLabel = selected.isEmpty
|
||||
? l.storeCategoryAll
|
||||
: _categoryDisplayName(context, selected);
|
||||
return PopupMenuButton<String>(
|
||||
tooltip: l.storeCategoryLabel,
|
||||
initialValue: selected,
|
||||
onSelected: onSelected,
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: '',
|
||||
child: Text(l.storeCategoryAll),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
for (final c in categories)
|
||||
PopupMenuItem(
|
||||
value: c,
|
||||
child: Text(_categoryDisplayName(context, c)),
|
||||
),
|
||||
for (final c in categories)
|
||||
_ChoiceChip(
|
||||
label: c,
|
||||
selected: selected == c,
|
||||
onSelected: () => onSelected(c),
|
||||
],
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: FaiSpace.sm,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
border: Border.all(color: theme.colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
size: 14,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 6),
|
||||
Text(activeLabel, style: theme.textTheme.bodySmall),
|
||||
Icon(
|
||||
Icons.arrow_drop_down,
|
||||
size: 16,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -1392,7 +1483,9 @@ class _FeaturedTile extends StatelessWidget {
|
|||
),
|
||||
if (item.category.isNotEmpty)
|
||||
Text(
|
||||
item.category,
|
||||
_categoryDisplayName(context, item.category),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -1424,7 +1517,7 @@ class _FeaturedTile extends StatelessWidget {
|
|||
const SizedBox(width: FaiSpace.xs),
|
||||
if (item.status.isNotEmpty)
|
||||
FaiPill(
|
||||
label: item.status,
|
||||
label: _statusDisplayName(context, item.status),
|
||||
tone: _toneForStatus(item.status),
|
||||
),
|
||||
const Spacer(),
|
||||
|
|
@ -1523,7 +1616,11 @@ class _StoreCard extends StatelessWidget {
|
|||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
item.category.isEmpty ? '—' : item.category,
|
||||
item.category.isEmpty
|
||||
? '—'
|
||||
: _categoryDisplayName(context, item.category),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -1551,7 +1648,7 @@ class _StoreCard extends StatelessWidget {
|
|||
)
|
||||
else if (item.status.isNotEmpty)
|
||||
FaiPill(
|
||||
label: item.status,
|
||||
label: _statusDisplayName(context, item.status),
|
||||
tone: _toneForStatus(item.status),
|
||||
),
|
||||
],
|
||||
|
|
@ -1827,12 +1924,14 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
|
|||
),
|
||||
if (item.status.isNotEmpty)
|
||||
FaiPill(
|
||||
label: item.status,
|
||||
label: _statusDisplayName(
|
||||
context, item.status),
|
||||
tone: _toneForStatus(item.status),
|
||||
),
|
||||
if (item.category.isNotEmpty)
|
||||
FaiPill(
|
||||
label: item.category,
|
||||
label: _categoryDisplayName(
|
||||
context, item.category),
|
||||
tone: FaiPillTone.neutral,
|
||||
),
|
||||
if (item.installed)
|
||||
|
|
@ -2664,13 +2763,15 @@ class _StoreTodayHero extends StatelessWidget {
|
|||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
Wrap(
|
||||
spacing: FaiSpace.sm,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
FaiPill(
|
||||
label: l.storeTodayBadge,
|
||||
tone: FaiPillTone.accent,
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
badge,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
|
|
@ -2679,16 +2780,8 @@ class _StoreTodayHero extends StatelessWidget {
|
|||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
'·',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
Text(
|
||||
l.storeTodayDeck,
|
||||
'· ${l.storeTodayDeck}',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
|
|
@ -2700,6 +2793,8 @@ class _StoreTodayHero extends StatelessWidget {
|
|||
const SizedBox(height: FaiSpace.sm),
|
||||
Text(
|
||||
title,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.15,
|
||||
|
|
@ -2708,6 +2803,8 @@ class _StoreTodayHero extends StatelessWidget {
|
|||
const SizedBox(height: FaiSpace.sm),
|
||||
Text(
|
||||
body,
|
||||
maxLines: 6,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
height: 1.45,
|
||||
|
|
@ -2926,17 +3023,19 @@ class _RecommendedSourceCard extends StatelessWidget {
|
|||
children: [
|
||||
Text(
|
||||
source.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
desc,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue