feat: honest install badge — classify store resolvability before the click

Split each flow's missing capabilities into store-resolvable ones
(amber chip + Install link, which now installs only those) and a
quiet 'not in store' chip whose tooltip explains the three recovery
paths in place: local install via chain install --link, adding the
providing store, or configuring the MCP/n8n integration. The
analyzer's not-in-store message names the same three paths (EN+DE).
Previously the list's Install action covered every missing
capability and could end in the hub's 'no store entry' error.

Also replace a private-looking capability example name in a doc
comment and test with a neutral placeholder. (0.25.0)

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-07-22 13:42:11 +02:00
parent 7d6a575cae
commit ab97e5e834
8 changed files with 340 additions and 105 deletions

View file

@ -0,0 +1,154 @@
// Flow-list badge for flows whose steps reference capabilities
// the hub doesn't provide. Split out of flow_editor_page.dart so
// the resolvability states (installable from a store vs. not in
// any store) stay unit-testable.
import 'package:flutter/material.dart';
import '../l10n.dart';
import '../tokens.dart';
/// Split a flow's missing capabilities into the ones a configured
/// store can actually install and the ones no store resolves.
///
/// [storeNames] is the host-supplied set of bare capability names
/// the store can install (already filtered to installable entries
/// published/alpha, native). An empty set therefore means
/// "nothing is store-installable", not "unknown": the honest
/// state without store data is the not-in-store explanation, and
/// the local-install / add-store / configure-integration paths
/// remain available.
({List<String> installable, List<String> notInStore}) splitMissingCaps(
List<String> missing,
Set<String> storeNames,
) {
final installable = <String>[];
final notInStore = <String>[];
for (final cap in missing) {
final bare = cap.split('@').first;
(storeNames.contains(bare) ? installable : notInStore).add(cap);
}
return (installable: installable, notInStore: notInStore);
}
/// "N modules missing" status on flows whose steps reference
/// capabilities the hub doesn't provide. Status and action are
/// SEPARATE elements (usertest: the combined orange chip made
/// the whole list read like an error wall and ellipsized the
/// action word):
/// - status = quiet neutral chip with a small amber dot a
/// note, not an alarm; warning orange stays reserved for real
/// failures.
/// - action = its own "Install" link that is never truncated;
/// both live in the row's Wrap, so tight widths wrap to a
/// second line instead of cutting text mid-word.
///
/// The install action only covers [installable] capabilities a
/// configured store resolves. [notInStore] capabilities get their
/// own quiet chip whose tooltip explains the three recovery paths
/// (local install / add store / configure integration) BEFORE any
/// click, instead of an install button that would end in the
/// hub's "no store entry" error.
class MissingModulesBadge extends StatelessWidget {
final List<String> installable;
final List<String> notInStore;
final FlowEditorStrings strings;
final VoidCallback? onInstall;
const MissingModulesBadge({
super.key,
required this.installable,
required this.notInStore,
required this.strings,
required this.onInstall,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final children = <Widget>[
if (installable.isNotEmpty)
_chip(
theme,
dot: const Color(0xFFEF6C00),
label: strings.flowListNeedsModules(installable.length),
tooltip: strings.flowListNeedsModulesTooltip(
installable.join('\n'),
),
),
if (installable.isNotEmpty && onInstall != null)
InkWell(
onTap: onInstall,
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
child: Text(
strings.flowListInstallMissing,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
),
),
),
if (notInStore.isNotEmpty)
_chip(
theme,
// Muted dot: this state has no one-click fix, so it
// must not borrow the actionable chip's amber.
dot: theme.colorScheme.outline,
label: strings.flowListNotInStore(notInStore.length),
tooltip: strings.flowListNotInStoreTooltip(notInStore.join('\n')),
),
];
if (children.length == 1) return children.single;
return Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: children,
);
}
Widget _chip(
ThemeData theme, {
required Color dot,
required String label,
required String tooltip,
}) {
final fg = theme.colorScheme.onSurfaceVariant;
return Tooltip(
message: tooltip,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: dot, shape: BoxShape.circle),
),
const SizedBox(width: 4),
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: fg,
fontSize: 10,
letterSpacing: 0.2,
),
),
],
),
),
);
}
}