feat(studio): per-field ports in flow editor + store-doc scroll fix
Some checks failed
Security / Security check (push) Failing after 1s

Picks up:
- fai_studio_flow_editor 0.9.0: per-field input + output
  ports rendered from ModuleInfo, i18n tooltips, four
  additional canvas patterns ported from jai_client.
- fai_client_sdk 0.18.0: regenerated ModuleField bindings
  for the schema_version 3 / per-field-description proto
  bump in fai/platform.

Wires StudioFlowRunDriver.moduleInfo so the editor can
fetch declared field info per step capability. The hub
maps schema_version 3 manifest fields (inputs/outputs
with description.en/de) through ModuleInfoResponse; this
patch wraps them back into the editor's ModuleSpec /
ModuleField vocabulary so the canvas paints them as
distinct anchors with hover tooltips.

ModuleDetail in hub.dart now carries the same inputs +
outputs lists (with ModuleFieldInfo type + i18n
description map), enabling other Studio surfaces — e.g.
the module-detail sheet — to render bilingual field docs
in a later pass.

Plus: store-doc nested-scroll fix. The module detail
panel's docs section sat inside its own 480px viewport
inside the outer sheet's SingleChildScrollView, producing
the double-scrollbar feel. Setting
physics: NeverScrollableScrollPhysics on the Markdown
widget and dropping the maxHeight lets the docs scroll
as part of the outer sheet — one scrollbar, predictable
mouse-wheel behaviour.

Bumps fai_studio to 0.56.0.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-06-01 22:34:08 +02:00
parent 34d086c29b
commit a698be0b57
5 changed files with 466 additions and 336 deletions

View file

@ -31,11 +31,13 @@ class _StorePageState extends State<StorePage> {
final _queryCtrl = TextEditingController();
String _category = '';
String _status = '';
/// Source filter: '' (all), 'native', 'mcp', 'n8n'. Applied
/// client-side after the hub returns results the search RPC
/// has no source field.
String _source = '';
bool _installedOnly = false;
/// "Show installable modules only". On by default: roughly
/// 2/3 of the seed-index entries currently carry status
/// `planned` (no bundle to install), and pre-filter polish
@ -43,20 +45,24 @@ class _StorePageState extends State<StorePage> {
/// install half the cards. Planned modules are one chip-flip
/// away in the filter dialog.
bool _installableOnly = true;
/// Tracks per-recommended-source add buttons to disable them
/// while the request is in flight.
final Set<String> _addingSources = <String>{};
/// Whether the operator dismissed the editorial hero this
/// session. Not persisted the next Studio launch shows it
/// again so a release-bumped story has a chance to be seen.
bool _todayDismissed = false;
/// The carousel of stories rendered in the hero. When an
/// operator-accepted story exists at `~/.fai/today/active.yaml`,
/// it is the only entry (carousel collapses to a single
/// slide). Otherwise the curated fallback list rotates.
late final List<TodayStoryData> _todayStories = (() {
final loaded =
TodayStoryLoader.loadOrFallback(_kFallbackTodayStories.first);
final loaded = TodayStoryLoader.loadOrFallback(
_kFallbackTodayStories.first,
);
// When the loader fell back to the const, it returned the
// first entry of our list surface the whole list so the
// operator can browse. Otherwise the loader produced a real
@ -66,6 +72,7 @@ class _StorePageState extends State<StorePage> {
}
return <TodayStoryData>[loaded];
})();
/// Carousel index. Wraps around modulo `_todayStories.length`.
int _todayIndex = 0;
late Future<List<StoreItem>> _future;
@ -82,11 +89,13 @@ class _StorePageState extends State<StorePage> {
List<_AiMatch>? _aiMatches;
Set<String>? _aiMatchedNames;
String? _aiError;
/// Debounce-Timer für Such-Filterung beim Tippen. Ohne
/// Debounce flackert "Keine Treffer" zwischen Buchstaben
/// (für 2-3 Buchstaben matched z.B. nichts, das nächste
/// dann doch). 250ms ist comfortable für Tippen.
Timer? _typingDebounce;
/// Whether the operator has a System-AI configured. Drives
/// the Ask-bar hint copy and disables the AI path when off.
bool _systemAiEnabled = false;
@ -151,8 +160,10 @@ class _StorePageState extends State<StorePage> {
// An already-installed item counts as "installable" for
// this filter the user can still update / uninstall it.
out = out
.where((e) =>
e.installed || e.status == 'published' || e.status == 'alpha')
.where(
(e) =>
e.installed || e.status == 'published' || e.status == 'alpha',
)
.toList();
}
return out;
@ -292,7 +303,8 @@ class _StorePageState extends State<StorePage> {
// 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 &&
final isBrowsing =
_queryCtrl.text.trim().isEmpty &&
_category.isEmpty &&
_status.isEmpty &&
_source.isEmpty &&
@ -312,7 +324,8 @@ class _StorePageState extends State<StorePage> {
// out of the way.
final showStandaloneRecommended =
isBrowsing && _todayDismissed && hasNoFederation;
final showFeatured = isBrowsing &&
final showFeatured =
isBrowsing &&
_todayDismissed &&
items.any((e) => e.featured);
return SingleChildScrollView(
@ -334,28 +347,32 @@ class _StorePageState extends State<StorePage> {
if (showToday) ...[
_StoreTodayHero(
story:
_todayStories[_todayIndex % _todayStories.length],
_todayStories[_todayIndex %
_todayStories.length],
locale: _locale,
currentIndex: _todayIndex,
totalCount: _todayStories.length,
onPrev: _todayStories.length > 1
? () => setState(() {
_todayIndex = (_todayIndex -
1 +
_todayStories.length) %
_todayStories.length;
})
_todayIndex =
(_todayIndex -
1 +
_todayStories.length) %
_todayStories.length;
})
: null,
onNext: _todayStories.length > 1
? () => setState(() {
_todayIndex = (_todayIndex + 1) %
_todayStories.length;
})
_todayIndex =
(_todayIndex + 1) %
_todayStories.length;
})
: null,
onDismiss: () =>
setState(() => _todayDismissed = true),
onCta: _todayStories[
_todayIndex % _todayStories.length]
onCta:
_todayStories[_todayIndex %
_todayStories.length]
.cta ==
TodayCta.openSettings
? () => FaiSettingsDialog.show(context)
@ -538,13 +555,15 @@ class _StorePageState extends State<StorePage> {
try {
final all = await HubService.instance.searchStore(limit: 200);
final modules = all
.map((s) => {
'name': s.name,
'tagline': _locale == 'de' && s.taglineDe.isNotEmpty
? s.taglineDe
: s.taglineEn,
'category': s.category,
})
.map(
(s) => {
'name': s.name,
'tagline': _locale == 'de' && s.taglineDe.isNotEmpty
? s.taglineDe
: s.taglineEn,
'category': s.category,
},
)
.toList();
final prompt = _buildAiSearchPrompt(query, modules, _locale);
final result = await HubService.instance.askAi(prompt);
@ -794,12 +813,14 @@ class _AskBar extends StatelessWidget {
// can write multi-line questions naturally.
child: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.meta,
LogicalKeyboardKey.enter):
const _SubmitAskIntent(),
LogicalKeySet(LogicalKeyboardKey.control,
LogicalKeyboardKey.enter):
const _SubmitAskIntent(),
LogicalKeySet(
LogicalKeyboardKey.meta,
LogicalKeyboardKey.enter,
): const _SubmitAskIntent(),
LogicalKeySet(
LogicalKeyboardKey.control,
LogicalKeyboardKey.enter,
): const _SubmitAskIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
@ -822,10 +843,10 @@ class _AskBar extends StatelessWidget {
// Cmd/Ctrl+Enter.
decoration: InputDecoration(
isCollapsed: true,
contentPadding:
const EdgeInsets.symmetric(vertical: 6),
hintText:
aiAvailable ? l.storeAskHint : l.storeSearchHint,
contentPadding: const EdgeInsets.symmetric(vertical: 6),
hintText: aiAvailable
? l.storeAskHint
: l.storeSearchHint,
hintMaxLines: 2,
hintStyle: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
@ -857,9 +878,7 @@ class _AskBar extends StatelessWidget {
aiAvailable ? Icons.auto_awesome : Icons.search,
size: 14,
),
label: Text(
thinking ? l.storeAskAiThinking : l.storeAskSubmit,
),
label: Text(thinking ? l.storeAskAiThinking : l.storeAskSubmit),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
@ -883,10 +902,7 @@ class _FilterButton extends StatelessWidget {
final int activeCount;
final VoidCallback onPressed;
const _FilterButton({
required this.activeCount,
required this.onPressed,
});
const _FilterButton({required this.activeCount, required this.onPressed});
@override
Widget build(BuildContext context) {
@ -902,10 +918,7 @@ class _FilterButton extends StatelessWidget {
if (activeCount > 0) ...[
const SizedBox(width: FaiSpace.xs),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(8),
@ -921,9 +934,7 @@ class _FilterButton extends StatelessWidget {
],
],
),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
style: OutlinedButton.styleFrom(visualDensity: VisualDensity.compact),
);
}
}
@ -976,30 +987,32 @@ class _FilterDialogState extends State<_FilterDialog> {
final theme = Theme.of(context);
final l = AppLocalizations.of(context)!;
Widget header(String text) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
text.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
padding: const EdgeInsets.only(bottom: 6),
child: Text(
text.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
);
Widget chipRow(
List<({String value, String label})> items,
String selected,
void Function(String) onSelect,
) => Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: [
for (final i in items)
_ChoiceChip(
label: i.label,
selected: selected == i.value,
onSelected: () => onSelect(i.value),
),
);
Widget chipRow(List<({String value, String label})> items, String selected,
void Function(String) onSelect) =>
Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
children: [
for (final i in items)
_ChoiceChip(
label: i.label,
selected: selected == i.value,
onSelected: () => onSelect(i.value),
),
],
);
],
);
return AlertDialog(
title: Text(l.storeFilterLabel),
shape: RoundedRectangleBorder(
@ -1279,10 +1292,7 @@ class _CategoryDropdown extends StatelessWidget {
initialValue: selected,
onSelected: onSelected,
itemBuilder: (_) => [
PopupMenuItem(
value: '',
child: Text(l.storeCategoryAll),
),
PopupMenuItem(value: '', child: Text(l.storeCategoryAll)),
const PopupMenuDivider(),
for (final c in categories)
PopupMenuItem(
@ -1419,6 +1429,7 @@ class _ChoiceChip extends StatelessWidget {
class _StoreGrid extends StatelessWidget {
final List<StoreItem> items;
final String locale;
/// Map of installed module name installed version. Cards
/// compare against `bestVersion` to render an "Update" badge
/// when the store offers something newer.
@ -1594,8 +1605,9 @@ class _FeaturedTile extends StatelessWidget {
children: [
CircleAvatar(
radius: 24,
backgroundColor:
theme.colorScheme.primary.withValues(alpha: 0.18),
backgroundColor: theme.colorScheme.primary.withValues(
alpha: 0.18,
),
child: Icon(
_iconForCategory(item.category),
size: 26,
@ -1720,6 +1732,7 @@ class _FeaturedTile extends StatelessWidget {
class _StoreCard extends StatelessWidget {
final StoreItem item;
final String locale;
/// Installed version for this name, when the operator has it.
/// `null` for not-installed entries.
final String? installedVersion;
@ -1761,148 +1774,149 @@ class _StoreCard extends StatelessWidget {
return Opacity(
opacity: notInstallable ? 0.6 : 1.0,
child: Material(
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: InkWell(
onTap: onTap,
color: theme.colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor:
theme.colorScheme.primary.withValues(alpha: 0.12),
child: Icon(
_iconForCategory(item.category),
size: 18,
color: theme.colorScheme.primary,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(FaiRadius.md),
child: Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(
radius: 18,
backgroundColor: theme.colorScheme.primary.withValues(
alpha: 0.12,
),
child: Icon(
_iconForCategory(item.category),
size: 18,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
const SizedBox(width: FaiSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
item.category.isEmpty
? ''
: _categoryDisplayName(context, item.category),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
Text(
item.category.isEmpty
? ''
: _categoryDisplayName(context, item.category),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
if (_hasUpdate)
Tooltip(
message: l.storeUpdateTooltip(
installedVersion ?? '?',
item.bestVersion,
),
],
),
),
if (_hasUpdate)
Tooltip(
message: l.storeUpdateTooltip(
installedVersion ?? '?',
item.bestVersion,
child: FaiPill(
label: l.storePillUpdate,
tone: FaiPillTone.warning,
icon: Icons.system_update_alt,
),
)
else if (item.installed)
FaiPill(
label: l.storePillInstalled,
tone: FaiPillTone.success,
icon: Icons.check,
)
else if (item.status.isNotEmpty)
FaiPill(
label: _statusDisplayName(context, item.status),
tone: _toneForStatus(item.status),
),
child: FaiPill(
label: l.storePillUpdate,
tone: FaiPillTone.warning,
icon: Icons.system_update_alt,
),
)
else if (item.installed)
FaiPill(
label: l.storePillInstalled,
tone: FaiPillTone.success,
icon: Icons.check,
)
else if (item.status.isNotEmpty)
FaiPill(
label: _statusDisplayName(context, item.status),
tone: _toneForStatus(item.status),
),
],
),
const SizedBox(height: FaiSpace.sm),
Expanded(
child: Text(
tagline.isEmpty ? l.storeNoTagline : tagline,
style: theme.textTheme.bodySmall,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
if (item.bestVersion.isNotEmpty) ...[
Tooltip(
message: item.isFederated
? l.storeAdvisoryVersionTooltip
: '',
child: FaiPill(
label: item.isFederated
? '~v${item.bestVersion}'
: 'v${item.bestVersion}',
tone: FaiPillTone.neutral,
monospace: true,
),
),
const SizedBox(width: FaiSpace.xs),
],
_ProvenancePill(item: item),
const Spacer(),
if (_hasUpdate)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.system_update_alt, size: 14),
label: Text(l.storeUpdateButton(item.bestVersion)),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
const SizedBox(height: FaiSpace.sm),
Expanded(
child: Text(
tagline.isEmpty ? l.storeNoTagline : tagline,
style: theme.textTheme.bodySmall,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: FaiSpace.sm),
Row(
children: [
if (item.bestVersion.isNotEmpty) ...[
Tooltip(
message: item.isFederated
? l.storeAdvisoryVersionTooltip
: '',
child: FaiPill(
label: item.isFederated
? '~v${item.bestVersion}'
: 'v${item.bestVersion}',
tone: FaiPillTone.neutral,
monospace: true,
),
),
)
else if (item.installed)
TextButton.icon(
onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14),
label: Text(l.buttonDetails),
)
else if (installable)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.download, size: 14),
label: Text(l.buttonInstall),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
const SizedBox(width: FaiSpace.xs),
],
_ProvenancePill(item: item),
const Spacer(),
if (_hasUpdate)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.system_update_alt, size: 14),
label: Text(l.storeUpdateButton(item.bestVersion)),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
)
else if (item.installed)
TextButton.icon(
onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14),
label: Text(l.buttonDetails),
)
else if (installable)
FilledButton.icon(
onPressed: onInstall,
icon: const Icon(Icons.download, size: 14),
label: Text(l.buttonInstall),
style: FilledButton.styleFrom(
visualDensity: VisualDensity.compact,
),
)
else
OutlinedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14),
label: Text(l.buttonDetails),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
)
else
OutlinedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.info_outline, size: 14),
label: Text(l.buttonDetails),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
],
],
),
],
),
),
),
),
),
);
}
}
@ -1944,6 +1958,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
String get _locale => Localizations.localeOf(context).languageCode;
bool _busy = false;
String? _toast;
/// `null` while the initial probe is in flight; non-null once
/// `readInstalledModuleDocs` has returned. The probe is cheap
/// a single file stat on disk so we run it eagerly when the
@ -1951,6 +1966,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
/// behind a "Load docs" click.
({String state, String text, String sourcePath})? _docsResult;
bool _docsProbed = false;
/// Live module-info for installed entries: declared
/// permissions and on-disk directory. Pulled lazily so
/// not-installed entries don't run an unnecessary RPC. Stays
@ -1980,22 +1996,25 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
Future<void> _probeInstalledDocs() async {
final locale = Localizations.localeOf(context).languageCode;
try {
final r = await HubService.instance
.readInstalledModuleDocs(widget.item.name, locale: locale);
final r = await HubService.instance.readInstalledModuleDocs(
widget.item.name,
locale: locale,
);
if (!mounted) return;
setState(() => _docsResult = r);
} catch (_) {
// Treat probe failures as "no docs available" the section
// simply hides, the rest of the sheet still works.
if (!mounted) return;
setState(() => _docsResult = (state: 'no_docs', text: '', sourcePath: ''));
setState(
() => _docsResult = (state: 'no_docs', text: '', sourcePath: ''),
);
}
}
Future<void> _loadModuleDetail() async {
try {
final detail =
await HubService.instance.moduleInfo(widget.item.name);
final detail = await HubService.instance.moduleInfo(widget.item.name);
if (!mounted) return;
setState(() => _moduleDetail = detail);
} catch (_) {
@ -2009,7 +2028,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
_toast = null;
});
try {
final r = await HubService.instance.installModule(source: widget.item.name);
final r = await HubService.instance.installModule(
source: widget.item.name,
);
if (!mounted) return;
final l = AppLocalizations.of(context)!;
setState(() {
@ -2033,8 +2054,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
// more than one is installed side-by-side. Otherwise the
// hub picks "highest version" and the operator can be
// surprised by what disappeared.
final versions =
await HubService.instance.installedVersions(widget.item.name);
final versions = await HubService.instance.installedVersions(
widget.item.name,
);
if (!mounted) return;
String? targetVersion;
if (versions.length > 1) {
@ -2189,13 +2211,17 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
if (item.status.isNotEmpty)
FaiPill(
label: _statusDisplayName(
context, item.status),
context,
item.status,
),
tone: _toneForStatus(item.status),
),
if (item.category.isNotEmpty)
FaiPill(
label: _categoryDisplayName(
context, item.category),
context,
item.category,
),
tone: FaiPillTone.neutral,
),
if (item.installed)
@ -2322,8 +2348,7 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
Icon(
_iconForPermission(p),
size: 14,
color:
theme.colorScheme.onSurfaceVariant,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: FaiSpace.sm),
SelectableText(
@ -2360,9 +2385,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
itemCount: item.screenshotUrls.length,
separatorBuilder: (_, _) =>
const SizedBox(width: FaiSpace.sm),
itemBuilder: (context, i) => _Screenshot(
url: item.screenshotUrls[i],
),
itemBuilder: (context, i) =>
_Screenshot(url: item.screenshotUrls[i]),
),
),
const SizedBox(height: FaiSpace.lg),
@ -2424,16 +2448,18 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
? const SizedBox(
width: 14,
height: 14,
child:
CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.delete_outline, size: 16),
label: Text(l.buttonUninstall),
style: OutlinedButton.styleFrom(
foregroundColor: theme.colorScheme.error,
side: BorderSide(
color: theme.colorScheme.error
.withValues(alpha: 0.5),
color: theme.colorScheme.error.withValues(
alpha: 0.5,
),
),
),
),
@ -2444,8 +2470,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
? const SizedBox(
width: 14,
height: 14,
child:
CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.download, size: 16),
label: Text(
@ -2480,10 +2507,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> {
Expanded(
child: Text(
l.storeNotInstallableInline(item.status),
style:
theme.textTheme.bodySmall?.copyWith(
color:
theme.colorScheme.onSurfaceVariant,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
@ -2676,9 +2701,13 @@ class _DocsPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// No maxHeight + NeverScrollableScrollPhysics so the docs
// section participates in the outer sheet's scroll instead
// of opening its own viewport. The previous nested-scroll
// form trapped the reader on a 480 px window with two
// scrollbars fighting for the wheel.
return Container(
width: double.infinity,
constraints: const BoxConstraints(maxHeight: 480),
padding: const EdgeInsets.all(FaiSpace.md),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
@ -2688,6 +2717,7 @@ class _DocsPanel extends StatelessWidget {
child: Markdown(
data: text,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
selectable: true,
onTapLink: (text, href, title) async {
if (href == null || href.isEmpty) return;
@ -2926,6 +2956,7 @@ const List<TodayStoryData> _kFallbackTodayStories = <TodayStoryData>[
class _StoreTodayHero extends StatelessWidget {
final TodayStoryData story;
final String locale;
/// Carousel position (0-based) and total slide count. Drives
/// the dot indicator and the visibility of the prev/next
/// arrows. When `totalCount == 1` the chrome collapses to a
@ -2936,10 +2967,12 @@ class _StoreTodayHero extends StatelessWidget {
final VoidCallback? onPrev;
final VoidCallback? onNext;
final VoidCallback onDismiss;
/// CTA action null hides the button. Wired by the parent
/// 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
@ -3011,11 +3044,7 @@ class _StoreTodayHero extends StatelessWidget {
color: theme.colorScheme.primary.withValues(alpha: 0.14),
shape: BoxShape.circle,
),
child: Icon(
story.icon,
size: 24,
color: theme.colorScheme.primary,
),
child: Icon(story.icon, size: 24, color: theme.colorScheme.primary),
),
const SizedBox(width: FaiSpace.lg),
Expanded(
@ -3027,10 +3056,7 @@ class _StoreTodayHero extends StatelessWidget {
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
FaiPill(
label: l.storeTodayBadge,
tone: FaiPillTone.accent,
),
FaiPill(label: l.storeTodayBadge, tone: FaiPillTone.accent),
Text(
badge,
style: theme.textTheme.labelSmall?.copyWith(
@ -3091,15 +3117,17 @@ class _StoreTodayHero extends StatelessWidget {
? const SizedBox(
width: 12,
height: 12,
child:
CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: Icon(s.icon, size: 14),
label: Text('+ ${s.label}'),
onPressed:
recommendedAdding.contains(s.name) || onAddRecommended == null
? null
: () => onAddRecommended!(s),
recommendedAdding.contains(s.name) ||
onAddRecommended == null
? null
: () => onAddRecommended!(s),
),
],
),
@ -3131,9 +3159,7 @@ class _StoreTodayHero extends StatelessWidget {
onPressed: onPrev,
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@ -3141,9 +3167,7 @@ class _StoreTodayHero extends StatelessWidget {
Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(
horizontal: 2,
),
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == currentIndex
@ -3178,11 +3202,14 @@ class _RecommendedSource {
/// the prefix of every synthetic store entry the server emits
/// (`mcp.<name>.<tool>`).
final String name;
/// Display label shown on the card.
final String label;
final IconData icon;
/// HTTPS Streamable-HTTP endpoint. No auth required.
final String endpoint;
/// Bilingual description used as the MCP-client `description`
/// field keeps the audit log in the operator's locale.
final String descriptionEn;