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:
flemming-it 2026-05-08 13:37:35 +02:00
parent c7869870c4
commit de22791e86
8 changed files with 434 additions and 143 deletions

View file

@ -127,10 +127,22 @@
"storeNoMatchesHint": "Filter anpassen oder Suche leeren, um alle Einträge zu sehen.", "storeNoMatchesHint": "Filter anpassen oder Suche leeren, um alle Einträge zu sehen.",
"storeClearFilters": "Filter zurücksetzen", "storeClearFilters": "Filter zurücksetzen",
"storeStatusAll": "Alle", "storeStatusAll": "Alle",
"storeStatusPublished": "Veröffentlicht", "storeStatusPublished": "stabil",
"storeStatusAlpha": "Alpha", "storeStatusAlpha": "experimentell",
"storeStatusPlanned": "Geplant", "storeStatusPlanned": "geplant",
"storeInstalledOnly": "Installiert", "storeInstalledOnly": "Installiert",
"storeCategoryLabel": "Kategorie",
"storeCategoryAll": "Alle Kategorien",
"storeCategoryBridge": "Verbindungen",
"storeCategoryDebug": "Beispiele",
"storeCategoryText": "Text",
"storeCategoryLlm": "KI-Modelle",
"storeCategorySystem": "System",
"storeCategoryAuth": "Authentifizierung",
"storeCategoryStorage": "Speicher",
"storeCategoryChannel": "Kanäle",
"storeCategoryWeb": "Web",
"storeCategoryOrchestrator": "Orchestrierung",
"storeNResults": "{n} Treffer", "storeNResults": "{n} Treffer",
"@storeNResults": { "@storeNResults": {
"placeholders": { "n": { "type": "int" } } "placeholders": { "n": { "type": "int" } }
@ -221,6 +233,10 @@
"n": { "type": "int" } "n": { "type": "int" }
} }
}, },
"storeProviderAddedZeroToast": "{name} ist eingetragen, aber der Server hat noch keine Capabilities geliefert. Öffne Einstellungen → MCP-Clients, um Discovery erneut zu versuchen oder den Server zu prüfen.",
"@storeProviderAddedZeroToast": {
"placeholders": { "name": { "type": "String" } }
},
"storeProviderAddFailed": "{name} konnte nicht hinzugefügt werden: {error}", "storeProviderAddFailed": "{name} konnte nicht hinzugefügt werden: {error}",
"@storeProviderAddFailed": { "@storeProviderAddFailed": {
"placeholders": { "placeholders": {

View file

@ -128,10 +128,22 @@
"storeNoMatchesHint": "Adjust the filters or clear the search to see all entries.", "storeNoMatchesHint": "Adjust the filters or clear the search to see all entries.",
"storeClearFilters": "Clear filters", "storeClearFilters": "Clear filters",
"storeStatusAll": "All", "storeStatusAll": "All",
"storeStatusPublished": "Published", "storeStatusPublished": "stable",
"storeStatusAlpha": "Alpha", "storeStatusAlpha": "experimental",
"storeStatusPlanned": "Planned", "storeStatusPlanned": "coming soon",
"storeInstalledOnly": "Installed", "storeInstalledOnly": "Installed",
"storeCategoryLabel": "Category",
"storeCategoryAll": "All categories",
"storeCategoryBridge": "Connectors",
"storeCategoryDebug": "Sample modules",
"storeCategoryText": "Text",
"storeCategoryLlm": "AI models",
"storeCategorySystem": "System",
"storeCategoryAuth": "Authentication",
"storeCategoryStorage": "Storage",
"storeCategoryChannel": "Channels",
"storeCategoryWeb": "Web",
"storeCategoryOrchestrator": "Orchestration",
"storeNResults": "{n} result{n, plural, =1{} other{s}}", "storeNResults": "{n} result{n, plural, =1{} other{s}}",
"@storeNResults": { "@storeNResults": {
"placeholders": { "n": { "type": "int" } } "placeholders": { "n": { "type": "int" } }
@ -222,6 +234,10 @@
"n": { "type": "int" } "n": { "type": "int" }
} }
}, },
"storeProviderAddedZeroToast": "{name} registered, but the server returned no capabilities yet. Open Settings → MCP Clients to retry discovery or check the server.",
"@storeProviderAddedZeroToast": {
"placeholders": { "name": { "type": "String" } }
},
"storeProviderAddFailed": "Could not add {name}: {error}", "storeProviderAddFailed": "Could not add {name}: {error}",
"@storeProviderAddFailed": { "@storeProviderAddFailed": {
"placeholders": { "placeholders": {

View file

@ -761,19 +761,19 @@ abstract class AppLocalizations {
/// No description provided for @storeStatusPublished. /// No description provided for @storeStatusPublished.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Published'** /// **'stable'**
String get storeStatusPublished; String get storeStatusPublished;
/// No description provided for @storeStatusAlpha. /// No description provided for @storeStatusAlpha.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Alpha'** /// **'experimental'**
String get storeStatusAlpha; String get storeStatusAlpha;
/// No description provided for @storeStatusPlanned. /// No description provided for @storeStatusPlanned.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Planned'** /// **'coming soon'**
String get storeStatusPlanned; String get storeStatusPlanned;
/// No description provided for @storeInstalledOnly. /// No description provided for @storeInstalledOnly.
@ -782,6 +782,78 @@ abstract class AppLocalizations {
/// **'Installed'** /// **'Installed'**
String get storeInstalledOnly; String get storeInstalledOnly;
/// No description provided for @storeCategoryLabel.
///
/// In en, this message translates to:
/// **'Category'**
String get storeCategoryLabel;
/// No description provided for @storeCategoryAll.
///
/// In en, this message translates to:
/// **'All categories'**
String get storeCategoryAll;
/// No description provided for @storeCategoryBridge.
///
/// In en, this message translates to:
/// **'Connectors'**
String get storeCategoryBridge;
/// No description provided for @storeCategoryDebug.
///
/// In en, this message translates to:
/// **'Sample modules'**
String get storeCategoryDebug;
/// No description provided for @storeCategoryText.
///
/// In en, this message translates to:
/// **'Text'**
String get storeCategoryText;
/// No description provided for @storeCategoryLlm.
///
/// In en, this message translates to:
/// **'AI models'**
String get storeCategoryLlm;
/// No description provided for @storeCategorySystem.
///
/// In en, this message translates to:
/// **'System'**
String get storeCategorySystem;
/// No description provided for @storeCategoryAuth.
///
/// In en, this message translates to:
/// **'Authentication'**
String get storeCategoryAuth;
/// No description provided for @storeCategoryStorage.
///
/// In en, this message translates to:
/// **'Storage'**
String get storeCategoryStorage;
/// No description provided for @storeCategoryChannel.
///
/// In en, this message translates to:
/// **'Channels'**
String get storeCategoryChannel;
/// No description provided for @storeCategoryWeb.
///
/// In en, this message translates to:
/// **'Web'**
String get storeCategoryWeb;
/// No description provided for @storeCategoryOrchestrator.
///
/// In en, this message translates to:
/// **'Orchestration'**
String get storeCategoryOrchestrator;
/// No description provided for @storeNResults. /// No description provided for @storeNResults.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -1100,6 +1172,12 @@ abstract class AppLocalizations {
/// **'Added {name} — {n} federated {n, plural, =1{capability} other{capabilities}}.'** /// **'Added {name} — {n} federated {n, plural, =1{capability} other{capabilities}}.'**
String storeProviderAddedToast(String name, int n); String storeProviderAddedToast(String name, int n);
/// No description provided for @storeProviderAddedZeroToast.
///
/// In en, this message translates to:
/// **'{name} registered, but the server returned no capabilities yet. Open Settings → MCP Clients to retry discovery or check the server.'**
String storeProviderAddedZeroToast(String name);
/// No description provided for @storeProviderAddFailed. /// No description provided for @storeProviderAddFailed.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -382,17 +382,53 @@ class AppLocalizationsDe extends AppLocalizations {
String get storeStatusAll => 'Alle'; String get storeStatusAll => 'Alle';
@override @override
String get storeStatusPublished => 'Veröffentlicht'; String get storeStatusPublished => 'stabil';
@override @override
String get storeStatusAlpha => 'Alpha'; String get storeStatusAlpha => 'experimentell';
@override @override
String get storeStatusPlanned => 'Geplant'; String get storeStatusPlanned => 'geplant';
@override @override
String get storeInstalledOnly => 'Installiert'; String get storeInstalledOnly => 'Installiert';
@override
String get storeCategoryLabel => 'Kategorie';
@override
String get storeCategoryAll => 'Alle Kategorien';
@override
String get storeCategoryBridge => 'Verbindungen';
@override
String get storeCategoryDebug => 'Beispiele';
@override
String get storeCategoryText => 'Text';
@override
String get storeCategoryLlm => 'KI-Modelle';
@override
String get storeCategorySystem => 'System';
@override
String get storeCategoryAuth => 'Authentifizierung';
@override
String get storeCategoryStorage => 'Speicher';
@override
String get storeCategoryChannel => 'Kanäle';
@override
String get storeCategoryWeb => 'Web';
@override
String get storeCategoryOrchestrator => 'Orchestrierung';
@override @override
String storeNResults(int n) { String storeNResults(int n) {
return '$n Treffer'; return '$n Treffer';
@ -596,6 +632,11 @@ class AppLocalizationsDe extends AppLocalizations {
return '$name hinzugefügt — $n $_temp0.'; return '$name hinzugefügt — $n $_temp0.';
} }
@override
String storeProviderAddedZeroToast(String name) {
return '$name ist eingetragen, aber der Server hat noch keine Capabilities geliefert. Öffne Einstellungen → MCP-Clients, um Discovery erneut zu versuchen oder den Server zu prüfen.';
}
@override @override
String storeProviderAddFailed(String name, String error) { String storeProviderAddFailed(String name, String error) {
return '$name konnte nicht hinzugefügt werden: $error'; return '$name konnte nicht hinzugefügt werden: $error';

View file

@ -394,17 +394,53 @@ class AppLocalizationsEn extends AppLocalizations {
String get storeStatusAll => 'All'; String get storeStatusAll => 'All';
@override @override
String get storeStatusPublished => 'Published'; String get storeStatusPublished => 'stable';
@override @override
String get storeStatusAlpha => 'Alpha'; String get storeStatusAlpha => 'experimental';
@override @override
String get storeStatusPlanned => 'Planned'; String get storeStatusPlanned => 'coming soon';
@override @override
String get storeInstalledOnly => 'Installed'; String get storeInstalledOnly => 'Installed';
@override
String get storeCategoryLabel => 'Category';
@override
String get storeCategoryAll => 'All categories';
@override
String get storeCategoryBridge => 'Connectors';
@override
String get storeCategoryDebug => 'Sample modules';
@override
String get storeCategoryText => 'Text';
@override
String get storeCategoryLlm => 'AI models';
@override
String get storeCategorySystem => 'System';
@override
String get storeCategoryAuth => 'Authentication';
@override
String get storeCategoryStorage => 'Storage';
@override
String get storeCategoryChannel => 'Channels';
@override
String get storeCategoryWeb => 'Web';
@override
String get storeCategoryOrchestrator => 'Orchestration';
@override @override
String storeNResults(int n) { String storeNResults(int n) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(
@ -613,6 +649,11 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Added $name$n federated $_temp0.'; return 'Added $name$n federated $_temp0.';
} }
@override
String storeProviderAddedZeroToast(String name) {
return '$name registered, but the server returned no capabilities yet. Open Settings → MCP Clients to retry discovery or check the server.';
}
@override @override
String storeProviderAddFailed(String name, String error) { String storeProviderAddFailed(String name, String error) {
return 'Could not add $name: $error'; return 'Could not add $name: $error';

View file

@ -26,7 +26,7 @@ import 'widgets/widgets.dart';
/// Studio's own build version. Bump on every UI commit so the /// Studio's own build version. Bump on every UI commit so the
/// running app self-identifies visible in the sidebar header /// running app self-identifies visible in the sidebar header
/// and quick-glance proof that you're seeing the current build. /// and quick-glance proof that you're seeing the current build.
const String kStudioVersion = '0.32.0'; const String kStudioVersion = '0.33.0';
Future<void> main() async { Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();

View file

@ -153,10 +153,34 @@ class _StorePageState extends State<StorePage> {
appBar: AppBar( appBar: AppBar(
title: Text(l.searchGroupStore), title: Text(l.searchGroupStore),
actions: [ actions: [
// The DE/EN toggle now lives in the sidebar footer // Category dropdown and Filter button live in the
// and flips the entire app's locale. The Store // toolbar so the body stays uncluttered. Both refresh
// detail-sheet content follows automatically because // off the current store snapshot via FutureBuilder.
// _locale reads from `Localizations.localeOf`. 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( IconButton(
icon: const Icon(Icons.refresh, size: 18), icon: const Icon(Icons.refresh, size: 18),
tooltip: l.storeReloadTooltip, tooltip: l.storeReloadTooltip,
@ -165,70 +189,20 @@ class _StorePageState extends State<StorePage> {
const SizedBox(width: FaiSpace.sm), const SizedBox(width: FaiSpace.sm),
], ],
), ),
body: Padding( body: Column(
padding: const EdgeInsets.fromLTRB( children: [
FaiSpace.xl, // Scrollable content: editorial chrome + grid. Sits in
FaiSpace.lg, // an Expanded so the bottom AskBar pins to the foot of
FaiSpace.xl, // the viewport, mirroring the chat-input pattern of
0, // Claude / ChatGPT / Slack.
), Expanded(
child: Column( child: Padding(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.fromLTRB(
children: [ FaiSpace.xl,
_AskBar( FaiSpace.lg,
controller: _queryCtrl, FaiSpace.xl,
thinking: _aiThinking, 0,
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(
child: FutureBuilder<List<StoreItem>>( child: FutureBuilder<List<StoreItem>>(
future: _future, future: _future,
builder: (context, snap) { builder: (context, snap) {
@ -299,6 +273,18 @@ class _StorePageState extends State<StorePage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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) ...[ if (showToday) ...[
_StoreTodayHero( _StoreTodayHero(
story: _todayStory, story: _todayStory,
@ -333,18 +319,6 @@ class _StorePageState extends State<StorePage> {
), ),
const SizedBox(height: FaiSpace.lg), 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( _StoreGrid(
items: items, items: items,
locale: _locale, locale: _locale,
@ -352,14 +326,39 @@ class _StorePageState extends State<StorePage> {
onTap: _openDetail, onTap: _openDetail,
onInstall: _install, 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, orElse: () => list.last,
); );
if (!mounted) return; 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( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(msg),
l.storeProviderAddedToast(s.label, added.toolCount), duration: added.toolCount > 0
), ? const Duration(seconds: 4)
: const Duration(seconds: 8),
), ),
); );
_runSearch(); _runSearch();
@ -565,6 +573,59 @@ String _sourceForItem(StoreItem i) {
return 'federated'; 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 /// Multi-line ask-and-search input. Drives the toolbar's
/// primary action: substring keyword search on every keystroke /// primary action: substring keyword search on every keystroke
/// (cheap, identical to the legacy `_SearchField`) plus an /// (cheap, identical to the legacy `_SearchField`) plus an
@ -1056,15 +1117,17 @@ class _AiAnswerCard extends StatelessWidget {
} }
} }
/// Horizontally-scrollable category chip strip. Always-on /// Compact category picker that lives in the AppBar's actions
/// "All" chip first, then categories the current result set /// row. Replaces the older horizontally-scrolling chip strip
/// contains. Disappears when the index is empty. /// because chips ate a full row of viewport for what is in
class _CategoryStrip extends StatelessWidget { /// 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 List<String> categories;
final String selected; final String selected;
final void Function(String) onSelected; final ValueChanged<String> onSelected;
const _CategoryStrip({ const _CategoryDropdown({
required this.categories, required this.categories,
required this.selected, required this.selected,
required this.onSelected, required this.onSelected,
@ -1072,25 +1135,53 @@ class _CategoryStrip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
if (categories.isEmpty) return const SizedBox.shrink(); final activeLabel = selected.isEmpty
return SizedBox( ? l.storeCategoryAll
height: 36, : _categoryDisplayName(context, selected);
child: ListView( return PopupMenuButton<String>(
scrollDirection: Axis.horizontal, tooltip: l.storeCategoryLabel,
children: [ initialValue: selected,
_ChoiceChip( onSelected: onSelected,
label: l.storeStatusAll, itemBuilder: (_) => [
selected: selected.isEmpty, PopupMenuItem(
onSelected: () => onSelected(''), 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( child: Container(
label: c, padding: const EdgeInsets.symmetric(
selected: selected == c, horizontal: FaiSpace.sm,
onSelected: () => onSelected(c), 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) if (item.category.isNotEmpty)
Text( Text(
item.category, _categoryDisplayName(context, item.category),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -1424,7 +1517,7 @@ class _FeaturedTile extends StatelessWidget {
const SizedBox(width: FaiSpace.xs), const SizedBox(width: FaiSpace.xs),
if (item.status.isNotEmpty) if (item.status.isNotEmpty)
FaiPill( FaiPill(
label: item.status, label: _statusDisplayName(context, item.status),
tone: _toneForStatus(item.status), tone: _toneForStatus(item.status),
), ),
const Spacer(), const Spacer(),
@ -1523,7 +1616,11 @@ class _StoreCard extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
Text( Text(
item.category.isEmpty ? '' : item.category, item.category.isEmpty
? ''
: _categoryDisplayName(context, item.category),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
@ -1551,7 +1648,7 @@ class _StoreCard extends StatelessWidget {
) )
else if (item.status.isNotEmpty) else if (item.status.isNotEmpty)
FaiPill( FaiPill(
label: item.status, label: _statusDisplayName(context, item.status),
tone: _toneForStatus(item.status), tone: _toneForStatus(item.status),
), ),
], ],
@ -1827,12 +1924,14 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
), ),
if (item.status.isNotEmpty) if (item.status.isNotEmpty)
FaiPill( FaiPill(
label: item.status, label: _statusDisplayName(
context, item.status),
tone: _toneForStatus(item.status), tone: _toneForStatus(item.status),
), ),
if (item.category.isNotEmpty) if (item.category.isNotEmpty)
FaiPill( FaiPill(
label: item.category, label: _categoryDisplayName(
context, item.category),
tone: FaiPillTone.neutral, tone: FaiPillTone.neutral,
), ),
if (item.installed) if (item.installed)
@ -2664,13 +2763,15 @@ class _StoreTodayHero extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Wrap(
spacing: FaiSpace.sm,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
FaiPill( FaiPill(
label: l.storeTodayBadge, label: l.storeTodayBadge,
tone: FaiPillTone.accent, tone: FaiPillTone.accent,
), ),
const SizedBox(width: FaiSpace.sm),
Text( Text(
badge, badge,
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
@ -2679,16 +2780,8 @@ class _StoreTodayHero extends StatelessWidget {
fontSize: 10, fontSize: 10,
), ),
), ),
const SizedBox(width: FaiSpace.sm),
Text( Text(
'·', '· ${l.storeTodayDeck}',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: FaiSpace.sm),
Text(
l.storeTodayDeck,
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
@ -2700,6 +2793,8 @@ class _StoreTodayHero extends StatelessWidget {
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
Text( Text(
title, title,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.headlineSmall?.copyWith( style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
height: 1.15, height: 1.15,
@ -2708,6 +2803,8 @@ class _StoreTodayHero extends StatelessWidget {
const SizedBox(height: FaiSpace.sm), const SizedBox(height: FaiSpace.sm),
Text( Text(
body, body,
maxLines: 6,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
height: 1.45, height: 1.45,
@ -2926,17 +3023,19 @@ class _RecommendedSourceCard extends StatelessWidget {
children: [ children: [
Text( Text(
source.label, source.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
Text( Text(
desc, desc,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
], ],
), ),

View file

@ -1,7 +1,7 @@
name: fai_studio name: fai_studio
description: "F∆I Studio — desktop GUI for the F∆I hub" description: "F∆I Studio — desktop GUI for the F∆I hub"
publish_to: 'none' publish_to: 'none'
version: 0.32.0 version: 0.33.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta