feat(studio): per-field ports in flow editor + store-doc scroll fix
Some checks failed
Security / Security check (push) Failing after 1s
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:
parent
34d086c29b
commit
a698be0b57
5 changed files with 466 additions and 336 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue