fix(approvals): usertest-panel hardening (0.81.0)

Panel findings against the reworked approvals page, fixed in place:

- Never fabricate the request time: ApprovalRecord.createdAt is
  nullable now; a missing created_at omits the line instead of
  rendering DateTime.now() (which drifted on refresh). Guard:
  approvals_origin_test pins the omit-on-null invariant.
- Copyable errors on approve/reject/batch via showChainErrorSnack
  (the hard project rule) — batch surfaces the first real cause.
- Reject requires a reason: ChainInlineHelp strip + confirm disabled
  while empty, no more silent close-and-nothing-happens.
- Batch approve applies the same no-data confirmation as the single
  path, naming how many selected requests carry no show: data.
- Plainer language: glossary "Vorgang (Flow)", history label FRAGE
  (was PROMPT), no-data hint drops developer jargon.
- One-click copy of the run id; history payload pretty-prints like
  the card.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-26 16:05:55 +02:00
parent 28f6fe1a9a
commit f7d7427d91
9 changed files with 411 additions and 122 deletions

View file

@ -28,6 +28,31 @@ lockstep.
local CLI falls back to it. Guard: two new `RunsLoadErrorView` local CLI falls back to it. Guard: two new `RunsLoadErrorView`
widget tests. widget tests.
### Fixed (0.81.0)
Approvals usertest-panel hardening:
- **Never fake the request time.** A missing/unparseable `created_at`
now leaves the "Angefordert" line off the card instead of showing
`now` (which even drifted on every refresh). `ApprovalRecord.createdAt`
is nullable; guard in `approvals_origin_test`.
- **Copyable errors on every path.** Approve/Reject and both batch
actions now route failures through `showChainErrorSnack` instead of
a bare `SnackBar(Text(e))` — the batch path surfaces the first real
cause, not just a count.
- **Reject needs a reason.** The reject dialog leads with a
`ChainInlineHelp` strip and disables the confirm button while the
reason is empty, instead of closing silently with nothing happening.
- **Batch approve respects the no-data guard.** A bulk approve that
includes requests without `show:` data now asks for the same
conscious confirmation the single approve already required, naming
how many are data-less.
- **Plainer language.** The intro and origin glossary "Flow" as
"Vorgang (Flow)", the history detail labels the question "FRAGE"
(not "PROMPT"), and the no-data hint drops developer jargon.
- **Copy the run id in one click**, and the history payload renders
with the same JSON pretty-printing as the card.
### Added (0.80.0) ### Added (0.80.0)
- **Ollama joins the host services.** Ollama-backed store entries - **Ollama joins the host services.** Ollama-backed store entries

View file

@ -1152,7 +1152,13 @@ class HubService {
stepId: e.stepId, stepId: e.stepId,
prompt: e.prompt, prompt: e.prompt,
payloadPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview, payloadPreview: e.payloadPreview.isEmpty ? null : e.payloadPreview,
createdAt: DateTime.tryParse(e.createdAt) ?? DateTime.now(), // Never fabricate the request time: a missing / unparseable
// created_at stays null so the card can omit the line
// instead of showing "now" (which would even change on
// every refresh) as if it were the truth.
createdAt: e.createdAt.isEmpty
? null
: DateTime.tryParse(e.createdAt),
expiresAt: e.expiresAt.isEmpty expiresAt: e.expiresAt.isEmpty
? null ? null
: DateTime.tryParse(e.expiresAt), : DateTime.tryParse(e.expiresAt),
@ -1762,7 +1768,11 @@ class ApprovalRecord {
final String stepId; final String stepId;
final String prompt; final String prompt;
final String? payloadPreview; final String? payloadPreview;
final DateTime createdAt;
/// When the approval was requested. Null when the hub sent no
/// parseable timestamp the card omits the line rather than
/// inventing one.
final DateTime? createdAt;
final DateTime? expiresAt; final DateTime? expiresAt;
/// One of: pending / approved / rejected / expired. /// One of: pending / approved / rejected / expired.

View file

@ -1095,13 +1095,20 @@
"approvalsPillRejected": "abgelehnt", "approvalsPillRejected": "abgelehnt",
"approvalsPillExpired": "abgelaufen", "approvalsPillExpired": "abgelaufen",
"approvalsPayloadPreview": "ZU PRÜFENDE DATEN", "approvalsPayloadPreview": "ZU PRÜFENDE DATEN",
"approvalsNoPayload": "Keine Daten zum Prüfen angehängt. Der system.approval-Schritt des Flows bestimmt über sein \"show:\"-Feld, was angezeigt wird — setzen Sie es, um die Daten hinter dieser Entscheidung sichtbar zu machen.", "approvalsNoPayload": "Zu dieser Freigabe wurden keine Daten hinterlegt. Sie entscheiden ohne Detailansicht — fragen Sie im Zweifel die Person, die den Vorgang eingerichtet hat. (Technisch: das Feld \"show:\" des Freigabe-Schritts legt fest, was hier erscheint.)",
"approvalsNoDataConfirmTitle": "Ohne Prüfdaten freigeben?", "approvalsNoDataConfirmTitle": "Ohne Prüfdaten freigeben?",
"approvalsNoDataConfirmBody": "Dieser Flow hat bewusst keine Prüfdaten hinterlegt (kein \"show:\" am Freigabe-Schritt). Sie können trotzdem freigeben — entscheiden dann aber, ohne die Daten hinter dieser Entscheidung gesehen zu haben.", "approvalsNoDataConfirmBody": "Dieser Flow hat bewusst keine Prüfdaten hinterlegt (kein \"show:\" am Freigabe-Schritt). Sie können trotzdem freigeben — entscheiden dann aber, ohne die Daten hinter dieser Entscheidung gesehen zu haben.",
"approvalsNoDataConfirmAction": "Trotzdem freigeben", "approvalsNoDataConfirmAction": "Trotzdem freigeben",
"approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich", "approvalsRequestFallback": "Freigabe für diesen Schritt erforderlich",
"approvalsFlowStepMeta": "Flow: {flow} · Schritt: {step}", "approvalsFlowStepMeta": "Flow: {flow} · Schritt: {step}",
"approvalsIntroHelp": "Hier warten pausierte Flows auf Ihre Entscheidung. Jede Karte zeigt, welcher Flow an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt den Flow fort, Ablehnen stoppt ihn mit Ihrer Begründung.", "approvalsIntroHelp": "Hier warten pausierte Vorgänge (Flows) auf Ihre Entscheidung. Jede Karte zeigt, welcher Vorgang an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt ihn fort, Ablehnen stoppt ihn mit Ihrer Begründung.",
"approvalsRejectDialogHelp": "Die Ablehnung stoppt den Vorgang an diesem Schritt und wird mit Ihrer Begründung im Prüfprotokoll festgehalten.",
"approvalsRejectReasonHelper": "Pflichtfeld — wird im Prüfprotokoll (Audit-Log) festgehalten.",
"approvalsBatchNoDataTitle": "Ohne Prüfdaten freigeben?",
"approvalsBatchNoDataBody": "{count, plural, =1{Von {total} ausgewählten Freigaben hat 1 keine Prüfdaten. Sie geben sie ohne Detailansicht frei.} other{Von {total} ausgewählten Freigaben haben {count} keine Prüfdaten. Sie geben sie ohne Detailansicht frei.}}",
"approvalsCopyRun": "Lauf-Kennung kopieren",
"approvalsRunCopied": "Lauf-Kennung kopiert",
"approvalsUnknownReviewer": "(unbekannt)",
"approvalsOriginLabel": "HERKUNFT", "approvalsOriginLabel": "HERKUNFT",
"approvalsOriginProject": "Projekt", "approvalsOriginProject": "Projekt",
"approvalsOriginRequested": "Angefordert", "approvalsOriginRequested": "Angefordert",
@ -1111,7 +1118,7 @@
"approvalsApproveButton": "Freigeben", "approvalsApproveButton": "Freigeben",
"approvalsRejectButton": "Ablehnen", "approvalsRejectButton": "Ablehnen",
"approvalsRejectDialogTitle": "Freigabe ablehnen", "approvalsRejectDialogTitle": "Freigabe ablehnen",
"approvalsRejectReasonLabel": "Begründung (im Audit-Log festgehalten)", "approvalsRejectReasonLabel": "Begründung",
"approvalsApprovedToast": "freigegeben · {flow} {step}", "approvalsApprovedToast": "freigegeben · {flow} {step}",
"@approvalsApprovedToast": { "@approvalsApprovedToast": {
"placeholders": { "placeholders": {
@ -1155,7 +1162,7 @@
"approvalsDialogDecided": "entschieden", "approvalsDialogDecided": "entschieden",
"approvalsDialogCreated": "erstellt", "approvalsDialogCreated": "erstellt",
"approvalsDialogReason": "Begründung", "approvalsDialogReason": "Begründung",
"approvalsDialogPrompt": "PROMPT", "approvalsDialogPrompt": "FRAGE",
"approvalsExpiresIn": "{minutes}m", "approvalsExpiresIn": "{minutes}m",
"@approvalsExpiresIn": { "@approvalsExpiresIn": {
"placeholders": { "placeholders": {

View file

@ -1113,13 +1113,20 @@
"approvalsPillRejected": "rejected", "approvalsPillRejected": "rejected",
"approvalsPillExpired": "expired", "approvalsPillExpired": "expired",
"approvalsPayloadPreview": "DATA TO REVIEW", "approvalsPayloadPreview": "DATA TO REVIEW",
"approvalsNoPayload": "No data was attached for review. The flow's approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.", "approvalsNoPayload": "No data was attached to this approval. You are deciding without a detail view — when in doubt, ask whoever set up the process. (Technical: the approval step's \"show:\" field controls what appears here.)",
"approvalsNoDataConfirmTitle": "Approve without review data?", "approvalsNoDataConfirmTitle": "Approve without review data?",
"approvalsNoDataConfirmBody": "This flow deliberately attached no review data (no \"show:\" on its approval step). You can still approve — but you would be deciding without seeing the data behind this decision.", "approvalsNoDataConfirmBody": "This flow deliberately attached no review data (no \"show:\" on its approval step). You can still approve — but you would be deciding without seeing the data behind this decision.",
"approvalsNoDataConfirmAction": "Approve anyway", "approvalsNoDataConfirmAction": "Approve anyway",
"approvalsRequestFallback": "Approval required for this step", "approvalsRequestFallback": "Approval required for this step",
"approvalsFlowStepMeta": "Flow: {flow} · Step: {step}", "approvalsFlowStepMeta": "Flow: {flow} · Step: {step}",
"approvalsIntroHelp": "Paused flows waiting for your decision. Each card shows which flow is holding at which step and what data it puts in front of you — Approve resumes the flow, Reject stops it with your reason.", "approvalsIntroHelp": "Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.",
"approvalsRejectDialogHelp": "Rejecting stops the process at this step and is recorded with your reason in the audit trail.",
"approvalsRejectReasonHelper": "Required — recorded in the audit log.",
"approvalsBatchNoDataTitle": "Approve without review data?",
"approvalsBatchNoDataBody": "{count, plural, =1{1 of {total} selected approvals has no review data. You are approving it without a detail view.} other{{count} of {total} selected approvals have no review data. You are approving them without a detail view.}}",
"approvalsCopyRun": "Copy run id",
"approvalsRunCopied": "Run id copied",
"approvalsUnknownReviewer": "(unknown)",
"approvalsOriginLabel": "ORIGIN", "approvalsOriginLabel": "ORIGIN",
"approvalsOriginProject": "Project", "approvalsOriginProject": "Project",
"approvalsOriginRequested": "Requested", "approvalsOriginRequested": "Requested",
@ -1135,7 +1142,7 @@
"approvalsApproveButton": "Approve", "approvalsApproveButton": "Approve",
"approvalsRejectButton": "Reject", "approvalsRejectButton": "Reject",
"approvalsRejectDialogTitle": "Reject approval", "approvalsRejectDialogTitle": "Reject approval",
"approvalsRejectReasonLabel": "Reason (recorded in audit log)", "approvalsRejectReasonLabel": "Reason",
"approvalsApprovedToast": "approved · {flow} {step}", "approvalsApprovedToast": "approved · {flow} {step}",
"@approvalsApprovedToast": { "@approvalsApprovedToast": {
"placeholders": { "placeholders": {
@ -1179,7 +1186,7 @@
"approvalsDialogDecided": "decided", "approvalsDialogDecided": "decided",
"approvalsDialogCreated": "created", "approvalsDialogCreated": "created",
"approvalsDialogReason": "reason", "approvalsDialogReason": "reason",
"approvalsDialogPrompt": "PROMPT", "approvalsDialogPrompt": "QUESTION",
"approvalsExpiresIn": "{minutes}m", "approvalsExpiresIn": "{minutes}m",
"@approvalsExpiresIn": { "@approvalsExpiresIn": {
"placeholders": { "placeholders": {

View file

@ -3521,7 +3521,7 @@ abstract class AppLocalizations {
/// No description provided for @approvalsNoPayload. /// No description provided for @approvalsNoPayload.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'No data was attached for review. The flow\'s approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.'** /// **'No data was attached to this approval. You are deciding without a detail view — when in doubt, ask whoever set up the process. (Technical: the approval step\'s \"show:\" field controls what appears here.)'**
String get approvalsNoPayload; String get approvalsNoPayload;
/// No description provided for @approvalsNoDataConfirmTitle. /// No description provided for @approvalsNoDataConfirmTitle.
@ -3557,9 +3557,51 @@ abstract class AppLocalizations {
/// No description provided for @approvalsIntroHelp. /// No description provided for @approvalsIntroHelp.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Paused flows waiting for your decision. Each card shows which flow is holding at which step and what data it puts in front of you — Approve resumes the flow, Reject stops it with your reason.'** /// **'Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.'**
String get approvalsIntroHelp; String get approvalsIntroHelp;
/// No description provided for @approvalsRejectDialogHelp.
///
/// In en, this message translates to:
/// **'Rejecting stops the process at this step and is recorded with your reason in the audit trail.'**
String get approvalsRejectDialogHelp;
/// No description provided for @approvalsRejectReasonHelper.
///
/// In en, this message translates to:
/// **'Required — recorded in the audit log.'**
String get approvalsRejectReasonHelper;
/// No description provided for @approvalsBatchNoDataTitle.
///
/// In en, this message translates to:
/// **'Approve without review data?'**
String get approvalsBatchNoDataTitle;
/// No description provided for @approvalsBatchNoDataBody.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 of {total} selected approvals has no review data. You are approving it without a detail view.} other{{count} of {total} selected approvals have no review data. You are approving them without a detail view.}}'**
String approvalsBatchNoDataBody(num count, Object total);
/// No description provided for @approvalsCopyRun.
///
/// In en, this message translates to:
/// **'Copy run id'**
String get approvalsCopyRun;
/// No description provided for @approvalsRunCopied.
///
/// In en, this message translates to:
/// **'Run id copied'**
String get approvalsRunCopied;
/// No description provided for @approvalsUnknownReviewer.
///
/// In en, this message translates to:
/// **'(unknown)'**
String get approvalsUnknownReviewer;
/// No description provided for @approvalsOriginLabel. /// No description provided for @approvalsOriginLabel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -3617,7 +3659,7 @@ abstract class AppLocalizations {
/// No description provided for @approvalsRejectReasonLabel. /// No description provided for @approvalsRejectReasonLabel.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Reason (recorded in audit log)'** /// **'Reason'**
String get approvalsRejectReasonLabel; String get approvalsRejectReasonLabel;
/// No description provided for @approvalsApprovedToast. /// No description provided for @approvalsApprovedToast.
@ -3677,7 +3719,7 @@ abstract class AppLocalizations {
/// No description provided for @approvalsDialogPrompt. /// No description provided for @approvalsDialogPrompt.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'PROMPT'** /// **'QUESTION'**
String get approvalsDialogPrompt; String get approvalsDialogPrompt;
/// No description provided for @approvalsExpiresIn. /// No description provided for @approvalsExpiresIn.

View file

@ -2037,7 +2037,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get approvalsNoPayload => String get approvalsNoPayload =>
'Keine Daten zum Prüfen angehängt. Der system.approval-Schritt des Flows bestimmt über sein \"show:\"-Feld, was angezeigt wird — setzen Sie es, um die Daten hinter dieser Entscheidung sichtbar zu machen.'; 'Zu dieser Freigabe wurden keine Daten hinterlegt. Sie entscheiden ohne Detailansicht — fragen Sie im Zweifel die Person, die den Vorgang eingerichtet hat. (Technisch: das Feld \"show:\" des Freigabe-Schritts legt fest, was hier erscheint.)';
@override @override
String get approvalsNoDataConfirmTitle => 'Ohne Prüfdaten freigeben?'; String get approvalsNoDataConfirmTitle => 'Ohne Prüfdaten freigeben?';
@ -2060,7 +2060,40 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get approvalsIntroHelp => String get approvalsIntroHelp =>
'Hier warten pausierte Flows auf Ihre Entscheidung. Jede Karte zeigt, welcher Flow an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt den Flow fort, Ablehnen stoppt ihn mit Ihrer Begründung.'; 'Hier warten pausierte Vorgänge (Flows) auf Ihre Entscheidung. Jede Karte zeigt, welcher Vorgang an welchem Schritt hält und welche Daten er Ihnen vorlegt — Freigeben setzt ihn fort, Ablehnen stoppt ihn mit Ihrer Begründung.';
@override
String get approvalsRejectDialogHelp =>
'Die Ablehnung stoppt den Vorgang an diesem Schritt und wird mit Ihrer Begründung im Prüfprotokoll festgehalten.';
@override
String get approvalsRejectReasonHelper =>
'Pflichtfeld — wird im Prüfprotokoll (Audit-Log) festgehalten.';
@override
String get approvalsBatchNoDataTitle => 'Ohne Prüfdaten freigeben?';
@override
String approvalsBatchNoDataBody(num count, Object total) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'Von $total ausgewählten Freigaben haben $count keine Prüfdaten. Sie geben sie ohne Detailansicht frei.',
one:
'Von $total ausgewählten Freigaben hat 1 keine Prüfdaten. Sie geben sie ohne Detailansicht frei.',
);
return '$_temp0';
}
@override
String get approvalsCopyRun => 'Lauf-Kennung kopieren';
@override
String get approvalsRunCopied => 'Lauf-Kennung kopiert';
@override
String get approvalsUnknownReviewer => '(unbekannt)';
@override @override
String get approvalsOriginLabel => 'HERKUNFT'; String get approvalsOriginLabel => 'HERKUNFT';
@ -2090,8 +2123,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get approvalsRejectDialogTitle => 'Freigabe ablehnen'; String get approvalsRejectDialogTitle => 'Freigabe ablehnen';
@override @override
String get approvalsRejectReasonLabel => String get approvalsRejectReasonLabel => 'Begründung';
'Begründung (im Audit-Log festgehalten)';
@override @override
String approvalsApprovedToast(String flow, String step) { String approvalsApprovedToast(String flow, String step) {
@ -2130,7 +2162,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get approvalsDialogReason => 'Begründung'; String get approvalsDialogReason => 'Begründung';
@override @override
String get approvalsDialogPrompt => 'PROMPT'; String get approvalsDialogPrompt => 'FRAGE';
@override @override
String approvalsExpiresIn(int minutes) { String approvalsExpiresIn(int minutes) {

View file

@ -2042,7 +2042,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get approvalsNoPayload => String get approvalsNoPayload =>
'No data was attached for review. The flow\'s approval step chooses what to show via its \"show:\" field — set it to surface the data behind this decision.'; 'No data was attached to this approval. You are deciding without a detail view — when in doubt, ask whoever set up the process. (Technical: the approval step\'s \"show:\" field controls what appears here.)';
@override @override
String get approvalsNoDataConfirmTitle => 'Approve without review data?'; String get approvalsNoDataConfirmTitle => 'Approve without review data?';
@ -2064,7 +2064,40 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get approvalsIntroHelp => String get approvalsIntroHelp =>
'Paused flows waiting for your decision. Each card shows which flow is holding at which step and what data it puts in front of you — Approve resumes the flow, Reject stops it with your reason.'; 'Paused processes (flows) waiting for your decision. Each card shows which process is holding at which step and what data it puts in front of you — Approve resumes it, Reject stops it with your reason.';
@override
String get approvalsRejectDialogHelp =>
'Rejecting stops the process at this step and is recorded with your reason in the audit trail.';
@override
String get approvalsRejectReasonHelper =>
'Required — recorded in the audit log.';
@override
String get approvalsBatchNoDataTitle => 'Approve without review data?';
@override
String approvalsBatchNoDataBody(num count, Object total) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'$count of $total selected approvals have no review data. You are approving them without a detail view.',
one:
'1 of $total selected approvals has no review data. You are approving it without a detail view.',
);
return '$_temp0';
}
@override
String get approvalsCopyRun => 'Copy run id';
@override
String get approvalsRunCopied => 'Run id copied';
@override
String get approvalsUnknownReviewer => '(unknown)';
@override @override
String get approvalsOriginLabel => 'ORIGIN'; String get approvalsOriginLabel => 'ORIGIN';
@ -2094,7 +2127,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get approvalsRejectDialogTitle => 'Reject approval'; String get approvalsRejectDialogTitle => 'Reject approval';
@override @override
String get approvalsRejectReasonLabel => 'Reason (recorded in audit log)'; String get approvalsRejectReasonLabel => 'Reason';
@override @override
String approvalsApprovedToast(String flow, String step) { String approvalsApprovedToast(String flow, String step) {
@ -2133,7 +2166,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get approvalsDialogReason => 'reason'; String get approvalsDialogReason => 'reason';
@override @override
String get approvalsDialogPrompt => 'PROMPT'; String get approvalsDialogPrompt => 'QUESTION';
@override @override
String approvalsExpiresIn(int minutes) { String approvalsExpiresIn(int minutes) {

View file

@ -2,7 +2,9 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../data/error_presentation.dart';
import '../data/hub.dart'; import '../data/hub.dart';
import '../data/workspace.dart'; import '../data/workspace.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@ -129,7 +131,9 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
_toast(l.approvalsApprovedToast(a.flowName, a.stepId)); _toast(l.approvalsApprovedToast(a.flowName, a.stepId));
_refresh(); _refresh();
} catch (e) { } catch (e) {
_toast(l.approvalsApproveFailed(e.toString())); if (!mounted) return;
// Copyable error never a bare SnackBar(Text(e)).
showChainErrorSnack(context, 'approvals.approve', e);
} }
} }
@ -142,7 +146,8 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
_toast(l.approvalsRejectedToast(a.flowName, a.stepId)); _toast(l.approvalsRejectedToast(a.flowName, a.stepId));
_refresh(); _refresh();
} catch (e) { } catch (e) {
_toast(l.approvalsRejectFailed(e.toString())); if (!mounted) return;
showChainErrorSnack(context, 'approvals.reject', e);
} }
} }
@ -169,25 +174,54 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
/// drains as items succeed. /// drains as items succeed.
Future<void> _batchApprove(List<ApprovalRecord> picked) async { Future<void> _batchApprove(List<ApprovalRecord> picked) async {
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
// Same conscious-confirmation guard as the single approve, but for
// the whole batch: a one-click bulk approve must not silently
// sweep through the data-less requests the single path stops on.
final noData = picked.where((a) => a.payloadPreview == null).length;
if (noData > 0) {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.approvalsBatchNoDataTitle),
content: Text(l.approvalsBatchNoDataBody(noData, picked.length)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l.buttonCancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l.approvalsNoDataConfirmAction),
),
],
),
);
if (confirmed != true) return;
}
if (!mounted) return;
setState(() => _batchInFlight = true); setState(() => _batchInFlight = true);
var ok = 0; var ok = 0;
var failed = 0; var failed = 0;
Object? firstError;
for (final a in picked) { for (final a in picked) {
try { try {
await HubService.instance.approve(a.id, _reviewer); await HubService.instance.approve(a.id, _reviewer);
ok += 1; ok += 1;
_selectedIds.remove(a.id); _selectedIds.remove(a.id);
} catch (_) { } catch (e) {
failed += 1; failed += 1;
firstError ??= e;
} }
} }
if (!mounted) return; if (!mounted) return;
setState(() => _batchInFlight = false); setState(() => _batchInFlight = false);
_toast( if (failed == 0) {
failed == 0 _toast(l.approvalsBatchApproveDoneToast(ok));
? l.approvalsBatchApproveDoneToast(ok) } else {
: l.approvalsBatchPartialFailure(ok, failed), // Partial failure: surface the copyable cause, not just a count.
); // The failed items stay selected so they remain visible.
showChainErrorSnack(context, 'approvals.batchApprove', firstError!);
}
_refresh(); _refresh();
} }
@ -198,22 +232,24 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
setState(() => _batchInFlight = true); setState(() => _batchInFlight = true);
var ok = 0; var ok = 0;
var failed = 0; var failed = 0;
Object? firstError;
for (final a in picked) { for (final a in picked) {
try { try {
await HubService.instance.reject(a.id, _reviewer, reason); await HubService.instance.reject(a.id, _reviewer, reason);
ok += 1; ok += 1;
_selectedIds.remove(a.id); _selectedIds.remove(a.id);
} catch (_) { } catch (e) {
failed += 1; failed += 1;
firstError ??= e;
} }
} }
if (!mounted) return; if (!mounted) return;
setState(() => _batchInFlight = false); setState(() => _batchInFlight = false);
_toast( if (failed == 0) {
failed == 0 _toast(l.approvalsBatchRejectDoneToast(ok));
? l.approvalsBatchRejectDoneToast(ok) } else {
: l.approvalsBatchPartialFailure(ok, failed), showChainErrorSnack(context, 'approvals.batchReject', firstError!);
); }
_refresh(); _refresh();
} }
@ -229,26 +265,49 @@ class _ApprovalsPageState extends State<ApprovalsPage> {
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: Text(l.approvalsRejectDialogTitle), title: Text(l.approvalsRejectDialogTitle),
content: TextField( content: Column(
controller: controller, mainAxisSize: MainAxisSize.min,
autofocus: true, crossAxisAlignment: CrossAxisAlignment.stretch,
decoration: InputDecoration( children: [
labelText: l.approvalsRejectReasonLabel, // Explain the consequence at the point of input, and make
border: const OutlineInputBorder(), // the reason a real required field: the button stays
), // disabled while it is empty instead of the dialog closing
// silently and nothing happening (usertest finding).
ChainInlineHelp(text: l.approvalsRejectDialogHelp),
const SizedBox(height: ChainSpace.md),
TextField(
controller: controller,
autofocus: true,
minLines: 1,
maxLines: 3,
decoration: InputDecoration(
labelText: l.approvalsRejectReasonLabel,
helperText: l.approvalsRejectReasonHelper,
border: const OutlineInputBorder(),
),
),
],
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, null), onPressed: () => Navigator.pop(ctx, null),
child: Text(l.buttonCancel), child: Text(l.buttonCancel),
), ),
FilledButton( ValueListenableBuilder<TextEditingValue>(
onPressed: () => Navigator.pop(ctx, controller.text), valueListenable: controller,
style: FilledButton.styleFrom( builder: (ctx, value, _) {
backgroundColor: Theme.of(ctx).colorScheme.error, final empty = value.text.trim().isEmpty;
foregroundColor: Theme.of(ctx).colorScheme.onError, return FilledButton(
), onPressed: empty
child: Text(l.approvalsRejectButton), ? null
: () => Navigator.pop(ctx, controller.text.trim()),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
foregroundColor: Theme.of(ctx).colorScheme.onError,
),
child: Text(l.approvalsRejectButton),
);
},
), ),
], ],
), ),
@ -687,23 +746,58 @@ class _ApprovalCard extends StatelessWidget {
style: metaStyle, style: metaStyle,
), ),
), ),
originLine( if (approval.createdAt != null)
Icons.schedule, originLine(
Text( Icons.schedule,
'${l.approvalsOriginRequested}: ' Text(
'${_formatTimestamp(approval.createdAt.toLocal())}', '${l.approvalsOriginRequested}: '
style: metaStyle, '${_formatTimestamp(approval.createdAt!.toLocal())}',
style: metaStyle,
),
), ),
),
if (approval.flowExecution != null) if (approval.flowExecution != null)
originLine( originLine(
Icons.tag_outlined, Icons.tag_outlined,
SelectableText( Row(
'${l.approvalsOriginRun}: ${approval.flowExecution}', children: [
style: ChainTheme.mono( Expanded(
size: 11, child: SelectableText(
color: theme.colorScheme.onSurfaceVariant, '${l.approvalsOriginRun}: ${approval.flowExecution}',
), style: ChainTheme.mono(
size: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
// One-click copy so the run id can be pasted into the
// Audit page's search to reach the event timeline
// (a filtered deeplink is a larger shell change see
// the backlog).
Tooltip(
message: l.approvalsCopyRun,
child: InkResponse(
radius: 16,
onTap: () async {
await Clipboard.setData(
ClipboardData(text: approval.flowExecution!),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l.approvalsRunCopied)),
);
}
},
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(
Icons.content_copy,
size: 13,
color: theme.colorScheme.onSurfaceVariant,
),
),
),
),
],
), ),
), ),
const SizedBox(height: ChainSpace.md), const SizedBox(height: ChainSpace.md),
@ -733,7 +827,7 @@ class _ApprovalCard extends StatelessWidget {
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
child: SelectableText( child: SelectableText(
_prettyPreview(approval.payloadPreview!), _prettyJson(approval.payloadPreview!),
style: ChainTheme.mono( style: ChainTheme.mono(
size: 11, size: 11,
color: theme.colorScheme.onSurface, color: theme.colorScheme.onSurface,
@ -813,16 +907,6 @@ class _ApprovalCard extends StatelessWidget {
); );
} }
String _prettyPreview(String raw) {
if (raw.isEmpty) return raw;
try {
final dynamic parsed = const JsonDecoder().convert(raw);
return const JsonEncoder.withIndent(' ').convert(parsed);
} catch (_) {
return raw;
}
}
String _expiresInLabel(BuildContext context, DateTime t) { String _expiresInLabel(BuildContext context, DateTime t) {
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
final remaining = t.difference(DateTime.now()); final remaining = t.difference(DateTime.now());
@ -955,13 +1039,14 @@ class _HistoryDialog extends StatelessWidget {
theme, theme,
AppLocalizations.of(context)!.approvalsDialogDecided, AppLocalizations.of(context)!.approvalsDialogDecided,
'${_formatTimestamp(record.decidedAt!.toLocal())} ' '${_formatTimestamp(record.decidedAt!.toLocal())} '
'· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}', '· ${record.decidedBy.isEmpty ? AppLocalizations.of(context)!.approvalsUnknownReviewer : record.decidedBy}',
),
if (record.createdAt != null)
_kv(
theme,
AppLocalizations.of(context)!.approvalsDialogCreated,
_formatTimestamp(record.createdAt!.toLocal()),
), ),
_kv(
theme,
AppLocalizations.of(context)!.approvalsDialogCreated,
_formatTimestamp(record.createdAt.toLocal()),
),
if (record.project.isNotEmpty) if (record.project.isNotEmpty)
_kv( _kv(
theme, theme,
@ -1015,7 +1100,7 @@ class _HistoryDialog extends StatelessWidget {
border: Border.all(color: theme.colorScheme.outlineVariant), border: Border.all(color: theme.colorScheme.outlineVariant),
), ),
child: SelectableText( child: SelectableText(
record.payloadPreview!, _prettyJson(record.payloadPreview!),
style: ChainTheme.mono(size: 11), style: ChainTheme.mono(size: 11),
), ),
), ),
@ -1064,6 +1149,19 @@ class _HistoryDialog extends StatelessWidget {
} }
} }
/// Pretty-print a JSON payload preview; returns the raw string
/// unchanged when it is empty or not valid JSON. Shared by the
/// pending card + the history dialog so both render identically.
String _prettyJson(String raw) {
if (raw.isEmpty) return raw;
try {
final dynamic parsed = const JsonDecoder().convert(raw);
return const JsonEncoder.withIndent(' ').convert(parsed);
} catch (_) {
return raw;
}
}
/// Locale-unambiguous YYYY-MM-DD HH:mm:ss formatter shared by /// Locale-unambiguous YYYY-MM-DD HH:mm:ss formatter shared by
/// the history row + dialog. Always renders in the operator's /// the history row + dialog. Always renders in the operator's
/// local time zone ISO timestamps from the wire still appear /// local time zone ISO timestamps from the wire still appear

View file

@ -5,6 +5,10 @@
// the Dart mapping layer; this pins that it reaches the card, next // the Dart mapping layer; this pins that it reaches the card, next
// to the rest of the origin facts and the plain-language intro. // to the rest of the origin facts and the plain-language intro.
// //
// It also pins the honesty invariant from the usertest: a missing
// created_at must NOT be rendered as a fabricated "now" the line
// is simply omitted, exactly like the run id on a legacy hub.
//
// Runs against the scriptable FakeHubService never a real hub. // Runs against the scriptable FakeHubService never a real hub.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -31,46 +35,57 @@ String _allText(WidgetTester tester) {
return buf.toString(); return buf.toString();
} }
ApprovalRecord _pending({DateTime? createdAt, String? runId = 'run-abc123'}) {
return ApprovalRecord(
id: 'apr-1',
flowName: 'classify-and-file',
stepId: 'review',
prompt: 'Bitte die Klassifikation prüfen',
payloadPreview: '{"label":"Rechnung"}',
createdAt: createdAt,
expiresAt: null,
status: 'pending',
decidedAt: null,
decidedBy: '',
reason: '',
project: 'lbs',
flowExecution: runId,
);
}
Future<String> _renderApprovals(
WidgetTester tester,
List<ApprovalRecord> approvals,
) async {
final fake = installFakeHub();
fake.approvals = approvals;
SharedPreferences.setMockInitialValues({});
tester.view.physicalSize = const Size(1280, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
const StudioApp(
initialThemeMode: ThemeModeValue.dark,
initialLocale: Locale('de'),
),
);
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byKey(const ValueKey('sidebar-item-approvals')));
for (var i = 0; i < 12; i++) {
await tester.pump(const Duration(milliseconds: 200));
}
final text = _allText(tester);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(minutes: 1));
return text;
}
void main() { void main() {
testWidgets('a pending approval card shows its full origin and an ' testWidgets('a pending approval card shows its full origin and an '
'intro explaining approve / reject', (tester) async { 'intro explaining approve / reject', (tester) async {
final fake = installFakeHub(); final text = await _renderApprovals(tester, [
fake.approvals = [ _pending(createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0)),
ApprovalRecord( ]);
id: 'apr-1',
flowName: 'classify-and-file',
stepId: 'review',
prompt: 'Bitte die Klassifikation prüfen',
payloadPreview: '{"label":"Rechnung"}',
createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0),
expiresAt: null,
status: 'pending',
decidedAt: null,
decidedBy: '',
reason: '',
project: 'lbs',
flowExecution: 'run-abc123',
),
];
SharedPreferences.setMockInitialValues({});
tester.view.physicalSize = const Size(1280, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
const StudioApp(
initialThemeMode: ThemeModeValue.dark,
initialLocale: Locale('de'),
),
);
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byKey(const ValueKey('sidebar-item-approvals')));
for (var i = 0; i < 12; i++) {
await tester.pump(const Duration(milliseconds: 200));
}
final text = _allText(tester);
// The human question (headline). // The human question (headline).
expect(text, contains('Bitte die Klassifikation prüfen')); expect(text, contains('Bitte die Klassifikation prüfen'));
// The origin block every fact a reviewer needs to place it. // The origin block every fact a reviewer needs to place it.
@ -82,9 +97,29 @@ void main() {
// The run id the field that used to be dropped at the mapping. // The run id the field that used to be dropped at the mapping.
expect(text, contains('Lauf: run-abc123')); expect(text, contains('Lauf: run-abc123'));
// The intro strip says what this inbox is and what the actions do. // The intro strip says what this inbox is and what the actions do.
expect(text, contains('pausierte Flows')); expect(text, contains('pausierte'));
});
await tester.pumpWidget(const SizedBox.shrink()); testWidgets('a missing requested-at time is omitted, never faked as '
await tester.pump(const Duration(minutes: 1)); '"now"', (tester) async {
// created_at unknown: the honest behaviour is to drop the line,
// not to invent a timestamp (which would even drift on refresh).
final text = await _renderApprovals(tester, [_pending(createdAt: null)]);
// The rest of the origin still renders.
expect(text, contains('HERKUNFT'));
expect(text, contains('Flow: classify-and-file'));
expect(text, contains('Lauf: run-abc123'));
// But no requested-at line at all.
expect(text, isNot(contains('Angefordert:')));
});
testWidgets('a legacy approval without a run id omits the run line', (
tester,
) async {
final text = await _renderApprovals(tester, [
_pending(createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0), runId: null),
]);
expect(text, contains('HERKUNFT'));
expect(text, isNot(contains('Lauf:')));
}); });
} }