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:
parent
ae09a55146
commit
4e7abec24d
4 changed files with 199 additions and 80 deletions
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,13 +28,14 @@ class FlowEditorStrings {
|
|||
String get emptyTitle => _t('No flow open', 'Kein Flow geöffnet');
|
||||
String get emptyBody => _t(
|
||||
'Pick one from the left, or click New flow to start a fresh one.',
|
||||
'Wähle links einen aus oder klicke Neuer Flow um einen neuen anzulegen.',
|
||||
'Wählen Sie links einen Flow aus oder klicken Sie auf „Neuer Flow", '
|
||||
'um einen neuen anzulegen.',
|
||||
);
|
||||
String get listEmptyTitle =>
|
||||
_t('No saved flows', 'Keine gespeicherten Flows');
|
||||
String get listEmptyBody => _t(
|
||||
'Click New flow to scaffold one from a template.',
|
||||
'Klicke Neuer Flow um aus einer Vorlage zu starten.',
|
||||
'Klicken Sie auf „Neuer Flow", um aus einer Vorlage zu starten.',
|
||||
);
|
||||
String get discardTitle =>
|
||||
_t('Discard unsaved changes?', 'Ungespeicherte Änderungen verwerfen?');
|
||||
|
|
@ -95,14 +96,15 @@ class FlowEditorStrings {
|
|||
String get graphEmptyTitle => _t('No steps yet', 'Noch keine Schritte');
|
||||
String get graphEmptyBody => _t(
|
||||
'Click Add step to drop the first capability onto the canvas.',
|
||||
'Klicke Schritt hinzufügen um die erste Capability auf die Fläche zu setzen.',
|
||||
'Klicken Sie auf „Schritt hinzufügen", um die erste Capability auf '
|
||||
'die Fläche zu setzen.',
|
||||
);
|
||||
|
||||
// Properties panel.
|
||||
String get propPanelTitle => _t('Step details', 'Schritt-Details');
|
||||
String get propNoSelection => _t(
|
||||
'Click a step on the canvas to edit it.',
|
||||
'Klicke einen Schritt auf der Fläche an, um ihn zu bearbeiten.',
|
||||
'Klicken Sie einen Schritt auf der Fläche an, um ihn zu bearbeiten.',
|
||||
);
|
||||
String get propStepId => _t('ID', 'ID');
|
||||
String get propCapability => _t('Capability', 'Capability');
|
||||
|
|
@ -142,11 +144,12 @@ class FlowEditorStrings {
|
|||
String get runStart => _t('Start run', 'Lauf starten');
|
||||
String get runUnsavedBanner => _t(
|
||||
'You have unsaved changes — save first to run the latest version.',
|
||||
'Du hast ungespeicherte Änderungen — bitte zuerst speichern, um die neueste Version auszuführen.',
|
||||
'Sie haben ungespeicherte Änderungen — bitte zuerst speichern, um die '
|
||||
'neueste Version auszuführen.',
|
||||
);
|
||||
String get runNoFlow => _t(
|
||||
'Open a flow from the left to run it.',
|
||||
'Öffne links einen Flow um ihn auszuführen.',
|
||||
'Öffnen Sie links einen Flow, um ihn auszuführen.',
|
||||
);
|
||||
String get runOutputs => _t('Outputs', 'Ausgaben');
|
||||
String get runChooseFile => _t('Choose file…', 'Datei wählen…');
|
||||
|
|
@ -253,15 +256,15 @@ class FlowEditorStrings {
|
|||
);
|
||||
String analyzerUnknownCapTypo(String cap, String suggestion) => _t(
|
||||
'Unknown capability "$cap". Did you mean "$suggestion"?',
|
||||
'Unbekannte Capability "$cap". Meintest du "$suggestion"?',
|
||||
'Unbekannte Capability "$cap". Meinten Sie "$suggestion"?',
|
||||
);
|
||||
String analyzerUnknownCapNotInStore(String cap) => _t(
|
||||
'Unknown capability "$cap". '
|
||||
'Not in the store — install locally with '
|
||||
'`fai install --link <path>` or check the spelling.',
|
||||
'`chain install --link <path>` or check the spelling.',
|
||||
'Unbekannte Capability "$cap". '
|
||||
'Nicht im Store — lokal mit '
|
||||
'`fai install --link <pfad>` installieren oder Tippfehler prüfen.',
|
||||
'`chain install --link <pfad>` installieren oder Tippfehler prüfen.',
|
||||
);
|
||||
String analyzerInputKind() => _t('input', 'Eingabe');
|
||||
String analyzerOutputKind() => _t('output', 'Ausgabe');
|
||||
|
|
@ -280,8 +283,8 @@ class FlowEditorStrings {
|
|||
'Mitgelieferter Beispiel-Flow.',
|
||||
);
|
||||
String flowListNeedsModules(int n) => _t(
|
||||
n == 1 ? 'needs 1 module' : 'needs $n modules',
|
||||
n == 1 ? 'braucht 1 Modul' : 'braucht $n Module',
|
||||
n == 1 ? '1 module missing' : '$n modules missing',
|
||||
n == 1 ? '1 Modul fehlt' : '$n Module fehlen',
|
||||
);
|
||||
String flowListNeedsModulesTooltip(String caps) => _t(
|
||||
'This flow needs capabilities that are not installed:\n$caps\n'
|
||||
|
|
@ -291,6 +294,13 @@ class FlowEditorStrings {
|
|||
);
|
||||
String get flowListInstallMissing => _t('Install', 'Installieren');
|
||||
|
||||
// Flow-list filter (first iteration: plain substring match).
|
||||
String get listFilterHint => _t('Filter flows…', 'Flows filtern…');
|
||||
String listFilterNoMatch(String query) => _t(
|
||||
'No flow matches "$query".',
|
||||
'Kein Flow passt zu „$query".',
|
||||
);
|
||||
|
||||
// Quick-fix button labels.
|
||||
String fixInstallCap(String cap) => _t('Install $cap', '$cap installieren');
|
||||
String fixAddSource(String cap) =>
|
||||
|
|
@ -379,7 +389,7 @@ class AnalyzerStrings {
|
|||
static String _enUnknownCapNotInStore(String cap) =>
|
||||
'Unknown capability "$cap". '
|
||||
'Not in the store — install locally with '
|
||||
'`fai install --link <path>` or check the spelling.';
|
||||
'`chain install --link <path>` or check the spelling.';
|
||||
static String _enUnknownType(String kind, String value, String validList) =>
|
||||
'Unknown $kind type "$value". Use one of: $validList.';
|
||||
static String _enInputKind() => 'input';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue