feat: usertest fixes — hub-truth builtins, calm badge + separate install action, list filter, formal address

- drop the hardcoded builtin set {'debug.echo'}; missing-module
  detection trusts the host capability list (includes real hub
  builtins) — fixes hello's false play button
- split the orange combined chip into a neutral status chip and a
  never-truncated install link
- add a substring filter over the flow list with an honest
  no-match state
- German strings use the formal address throughout; grammar fixes
- analyzer hint says 'chain install --link' (was 'fai …')
- 0.24.0

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-07-17 23:49:17 +02:00
parent ae09a55146
commit 4e7abec24d
4 changed files with 199 additions and 80 deletions

View file

@ -1252,11 +1252,6 @@ class _FlowFile {
/// content contains this exact string.
const String _sampleFlowMarker = 'F∆I sample flow';
/// Built-in capabilities the Hub always provides, even when
/// they aren't in the host-supplied installed list. Treated as
/// satisfied so the "needs N modules" badge never flags them.
const Set<String> _builtinCapabilities = {'debug.echo'};
/// Result of scanning a single flow file: provenance + the
/// capability NAMES (without `@version`) its steps reference.
class _FlowMeta {
@ -1266,15 +1261,16 @@ class _FlowMeta {
static const empty = _FlowMeta(isExample: false, requiredCaps: []);
/// Capabilities this flow needs that are neither installed nor
/// built in. [availableNames] is the set of installed
/// capability NAMES (the part before `@`).
List<String> missingCaps(Set<String> availableNames) => requiredCaps
.where(
(c) =>
!availableNames.contains(c) && !_builtinCapabilities.contains(c),
)
.toList();
/// Capabilities this flow needs that the hub does not provide.
/// [availableNames] is the set of capability NAMES (the part
/// before `@`) from the host's live capability list — which
/// already includes the hub's builtins (e.g. system.approval),
/// so there is no client-side builtin list to drift out of
/// sync with the hub (the old hardcoded {'debug.echo'} made
/// the bundled hello flow look runnable on hubs that don't
/// have the module).
List<String> missingCaps(Set<String> availableNames) =>
requiredCaps.where((c) => !availableNames.contains(c)).toList();
}
/// Caches [_FlowMeta] per file, keyed by path + mtime. Reading
@ -1345,7 +1341,7 @@ String _formatBytes(int bytes) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
class _FileList extends StatelessWidget {
class _FileList extends StatefulWidget {
final Future<List<_FlowFile>> filesFuture;
final String? activeName;
final FlowEditorStrings strings;
@ -1377,9 +1373,20 @@ class _FileList extends StatelessWidget {
required this.onInstallMissing,
});
@override
State<_FileList> createState() => _FileListState();
}
class _FileListState extends State<_FileList> {
/// Case-insensitive substring filter over flow names. First
/// iteration of "find a flow fast" (usertest power-user
/// finding: no search over the flow list at all).
String _filter = '';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final strings = widget.strings;
return Container(
color: theme.colorScheme.surface,
child: Column(
@ -1405,7 +1412,7 @@ class _FileList extends StatelessWidget {
),
),
IconButton(
onPressed: onRefresh,
onPressed: widget.onRefresh,
tooltip: strings.refresh,
icon: const Icon(Icons.refresh, size: 16),
visualDensity: VisualDensity.compact,
@ -1418,6 +1425,43 @@ class _FileList extends StatelessWidget {
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(
FaiSpace.md,
FaiSpace.xs,
FaiSpace.md,
FaiSpace.xs,
),
child: SizedBox(
height: 28,
child: TextField(
onChanged: (v) => setState(() => _filter = v.trim()),
style: theme.textTheme.bodySmall,
decoration: InputDecoration(
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
prefixIcon: const Icon(Icons.search, size: 14),
prefixIconConstraints: const BoxConstraints(
minWidth: 28,
minHeight: 28,
),
hintText: strings.listFilterHint,
hintStyle: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(FaiRadius.sm),
borderSide: BorderSide(
color: theme.colorScheme.outlineVariant,
),
),
),
),
),
),
Expanded(child: _buildBody(context, theme)),
],
),
@ -1425,8 +1469,9 @@ class _FileList extends StatelessWidget {
}
Widget _buildBody(BuildContext context, ThemeData theme) {
final strings = widget.strings;
return FutureBuilder<List<_FlowFile>>(
future: filesFuture,
future: widget.filesFuture,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
@ -1437,8 +1482,8 @@ class _FileList extends StatelessWidget {
child: ChainErrorBox(error: snap.error, isError: true),
);
}
final files = snap.data ?? <_FlowFile>[];
if (files.isEmpty) {
final all = snap.data ?? <_FlowFile>[];
if (all.isEmpty) {
return Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: FaiEmptyState(
@ -1448,15 +1493,33 @@ class _FileList extends StatelessWidget {
),
);
}
final needle = _filter.toLowerCase();
final files = needle.isEmpty
? all
: all
.where((f) => f.name.toLowerCase().contains(needle))
.toList();
if (files.isEmpty) {
// Flows exist, the filter just matches none say that
// instead of pretending the directory is empty.
return Padding(
padding: const EdgeInsets.all(FaiSpace.md),
child: FaiEmptyState(
icon: Icons.search_off_outlined,
title: strings.listFilterNoMatch(_filter),
hint: null,
),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
itemCount: files.length,
itemBuilder: (_, i) {
final f = files[i];
final isActive = f.name == activeName;
final missing = f.meta.missingCaps(installedNames);
final isActive = f.name == widget.activeName;
final missing = f.meta.missingCaps(widget.installedNames);
return InkWell(
onTap: () => onOpen(f),
onTap: () => widget.onOpen(f),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: FaiSpace.md,
@ -1535,9 +1598,10 @@ class _FileList extends StatelessWidget {
_MissingModulesBadge(
missing: missing,
strings: strings,
onInstall: onInstallMissing == null
onInstall: widget.onInstallMissing == null
? null
: () => onInstallMissing!(missing),
: () =>
widget.onInstallMissing!(missing),
),
],
),
@ -1550,7 +1614,7 @@ class _FileList extends StatelessWidget {
// the install badge is the actionable path then.
if (missing.isEmpty)
IconButton(
onPressed: () => onStart(f),
onPressed: () => widget.onStart(f),
tooltip: strings.listStartTooltip,
icon: Icon(
Icons.play_arrow_rounded,
@ -1617,11 +1681,17 @@ class _ExampleBadge extends StatelessWidget {
}
}
/// Warning-toned "needs N modules" chip on flows whose steps
/// reference capabilities that aren't installed. Tooltip lists
/// the exact missing ids. When an install handler is wired, the
/// chip is tappable and one-click installs all missing caps via
/// the host's existing install path.
/// "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.
class _MissingModulesBadge extends StatelessWidget {
final List<String> missing;
final FlowEditorStrings strings;
@ -1635,50 +1705,67 @@ class _MissingModulesBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Amber warning tone consistent with the diagnostic strip's
// warning colour, readable on both light + dark surfaces.
const warn = Color(0xFFEF6C00);
final label = '${strings.flowListNeedsModules(missing.length)}'
'${onInstall != null ? ' · ${strings.flowListInstallMissing}' : ''}';
final chip = Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: warn.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: warn.withValues(alpha: 0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.extension_off_outlined, size: 10, color: warn),
const SizedBox(width: 4),
// Flexible + ellipsis: the chip sits in the narrow flow
// list, where the full label otherwise overflows by a few
// pixels (the tooltip still carries the complete text).
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
const dot = Color(0xFFEF6C00);
final fg = theme.colorScheme.onSurfaceVariant;
final chip = Tooltip(
message: strings.flowListNeedsModulesTooltip(missing.join('\n')),
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: const BoxDecoration(
color: dot,
shape: BoxShape.circle,
),
),
const SizedBox(width: 4),
Text(
strings.flowListNeedsModules(missing.length),
style: theme.textTheme.labelSmall?.copyWith(
color: warn,
color: fg,
fontSize: 10,
letterSpacing: 0.2,
),
),
],
),
),
);
if (onInstall == null) return chip;
return Wrap(
spacing: FaiSpace.xs,
runSpacing: FaiSpace.xs,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
chip,
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,
),
),
),
],
),
);
return Tooltip(
message: strings.flowListNeedsModulesTooltip(missing.join('\n')),
child: onInstall == null
? chip
: InkWell(
onTap: onInstall,
borderRadius: BorderRadius.circular(FaiRadius.sm),
child: chip,
),
),
],
);
}
}