feat(doctor): deep-link findings to the page where they are actioned

Doctor findings used to be dead-end statements — 'approvals
waiting for review' left the operator to find the approvals inbox
on their own. Every finding with a dedicated surface is now one
tap away from it:

- summary tiles: modules -> store, approvals -> approvals inbox,
  audit chain -> audit log (chevron affordance, tooltip + semantics
  button; the services tile stays plain — no dedicated page)
- modules/approvals panel rows link the same way
- the event-log headline opens the audit page next to the
  existing verify button
- host-services empty state gains a 'view the configuration'
  button opening the in-Studio config viewer the hint refers to
- the update banner's release-notes URL is now an underlined,
  clickable link instead of dead text

Tiles and the panel are public callback-driven widgets so the
widget tests pump them without a live hub. New DE+EN link labels.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-18 18:02:04 +02:00
parent c18bb7f357
commit e67e0c9e73
7 changed files with 525 additions and 139 deletions

View file

@ -1184,6 +1184,11 @@
} }
}, },
"doctorSummaryDeclared": "deklariert", "doctorSummaryDeclared": "deklariert",
"doctorLinkStore": "Store öffnen",
"doctorLinkApprovals": "Freigaben öffnen",
"doctorLinkAudit": "Protokoll öffnen",
"doctorLinkConfig": "Konfiguration ansehen",
"doctorLinkReleaseNotes": "Versionshinweise im Browser öffnen",
"doctorModulesPanelSummary": "{n, plural, =1{1 Modul} other{{n} Module}} · {m, plural, =1{1 Fähigkeit} other{{m} Fähigkeiten}}", "doctorModulesPanelSummary": "{n, plural, =1{1 Modul} other{{n} Module}} · {m, plural, =1{1 Fähigkeit} other{{m} Fähigkeiten}}",
"@doctorModulesPanelSummary": { "@doctorModulesPanelSummary": {
"placeholders": { "placeholders": {

View file

@ -1208,6 +1208,11 @@
} }
}, },
"doctorSummaryDeclared": "declared", "doctorSummaryDeclared": "declared",
"doctorLinkStore": "Open the store",
"doctorLinkApprovals": "Open approvals",
"doctorLinkAudit": "Open the audit log",
"doctorLinkConfig": "View the configuration",
"doctorLinkReleaseNotes": "Open the release notes in the browser",
"doctorModulesPanelSummary": "{n, plural, =1{1 module} other{{n} modules}} · {m, plural, =1{1 capability} other{{m} capabilities}}", "doctorModulesPanelSummary": "{n, plural, =1{1 module} other{{n} modules}} · {m, plural, =1{1 capability} other{{m} capabilities}}",
"@doctorModulesPanelSummary": { "@doctorModulesPanelSummary": {
"placeholders": { "placeholders": {

View file

@ -3662,6 +3662,36 @@ abstract class AppLocalizations {
/// **'declared'** /// **'declared'**
String get doctorSummaryDeclared; String get doctorSummaryDeclared;
/// No description provided for @doctorLinkStore.
///
/// In en, this message translates to:
/// **'Open the store'**
String get doctorLinkStore;
/// No description provided for @doctorLinkApprovals.
///
/// In en, this message translates to:
/// **'Open approvals'**
String get doctorLinkApprovals;
/// No description provided for @doctorLinkAudit.
///
/// In en, this message translates to:
/// **'Open the audit log'**
String get doctorLinkAudit;
/// No description provided for @doctorLinkConfig.
///
/// In en, this message translates to:
/// **'View the configuration'**
String get doctorLinkConfig;
/// No description provided for @doctorLinkReleaseNotes.
///
/// In en, this message translates to:
/// **'Open the release notes in the browser'**
String get doctorLinkReleaseNotes;
/// No description provided for @doctorModulesPanelSummary. /// No description provided for @doctorModulesPanelSummary.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -2132,6 +2132,21 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get doctorSummaryDeclared => 'deklariert'; String get doctorSummaryDeclared => 'deklariert';
@override
String get doctorLinkStore => 'Store öffnen';
@override
String get doctorLinkApprovals => 'Freigaben öffnen';
@override
String get doctorLinkAudit => 'Protokoll öffnen';
@override
String get doctorLinkConfig => 'Konfiguration ansehen';
@override
String get doctorLinkReleaseNotes => 'Versionshinweise im Browser öffnen';
@override @override
String doctorModulesPanelSummary(int n, int m) { String doctorModulesPanelSummary(int n, int m) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(

View file

@ -2136,6 +2136,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get doctorSummaryDeclared => 'declared'; String get doctorSummaryDeclared => 'declared';
@override
String get doctorLinkStore => 'Open the store';
@override
String get doctorLinkApprovals => 'Open approvals';
@override
String get doctorLinkAudit => 'Open the audit log';
@override
String get doctorLinkConfig => 'View the configuration';
@override
String get doctorLinkReleaseNotes => 'Open the release notes in the browser';
@override @override
String doctorModulesPanelSummary(int n, int m) { String doctorModulesPanelSummary(int n, int m) {
String _temp0 = intl.Intl.pluralLogic( String _temp0 = intl.Intl.pluralLogic(

View file

@ -7,6 +7,7 @@ import '../data/hub.dart';
import '../data/hub_auth_token.dart'; import '../data/hub_auth_token.dart';
import '../data/system_actions.dart'; import '../data/system_actions.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../main.dart' show StudioShellState;
import '../theme/theme.dart'; import '../theme/theme.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
import '../widgets/widgets.dart'; import '../widgets/widgets.dart';
@ -75,23 +76,49 @@ class _DoctorPageState extends State<DoctorPage> {
final showUpdate = final showUpdate =
s.update.updateAvailable || s.update.updateAvailable ||
(!s.update.manifestReachable && s.update.localVersion.isNotEmpty); (!s.update.manifestReachable && s.update.localVersion.isNotEmpty);
// Findings that have a dedicated page link straight to it
// "approvals waiting for review" must be one tap away from
// the approvals inbox, not a dead-end statement.
final shell = StudioShellState.of(context);
final openStore = shell == null
? null
: () => shell.navigateTo('store');
final openApprovals = shell == null
? null
: () => shell.navigateTo('approvals');
final openAudit = shell == null
? null
: () => shell.navigateTo('audit');
return ListView( return ListView(
padding: const EdgeInsets.all(ChainSpace.xl), padding: const EdgeInsets.all(ChainSpace.xl),
children: [ children: [
if (showUpdate) _UpdateBanner(status: s.update), if (showUpdate) _UpdateBanner(status: s.update),
if (showUpdate) const SizedBox(height: ChainSpace.lg), if (showUpdate) const SizedBox(height: ChainSpace.lg),
_SummaryStrip(snapshot: s), _SummaryStrip(
snapshot: s,
onOpenStore: openStore,
onOpenApprovals: openApprovals,
onOpenAudit: openAudit,
),
const SizedBox(height: ChainSpace.xl), const SizedBox(height: ChainSpace.xl),
_Section( _Section(
title: AppLocalizations.of(context)!.doctorEventLogSection, title: AppLocalizations.of(context)!.doctorEventLogSection,
child: _EventLogPanel(snapshot: s, onRefresh: _refresh), child: _EventLogPanel(
snapshot: s,
onRefresh: _refresh,
onOpenAudit: openAudit,
),
), ),
const SizedBox(height: ChainSpace.lg), const SizedBox(height: ChainSpace.lg),
_Section( _Section(
title: AppLocalizations.of( title: AppLocalizations.of(
context, context,
)!.doctorModulesApprovalsSection, )!.doctorModulesApprovalsSection,
child: _ModulesPanel(snapshot: s), child: DoctorModulesPanel(
snapshot: s,
onOpenStore: openStore,
onOpenApprovals: openApprovals,
),
), ),
const SizedBox(height: ChainSpace.lg), const SizedBox(height: ChainSpace.lg),
_Section( _Section(
@ -150,22 +177,32 @@ class _Section extends StatelessWidget {
class _SummaryStrip extends StatelessWidget { class _SummaryStrip extends StatelessWidget {
final DoctorSnapshot snapshot; final DoctorSnapshot snapshot;
final VoidCallback? onOpenStore;
final VoidCallback? onOpenApprovals;
final VoidCallback? onOpenAudit;
const _SummaryStrip({required this.snapshot}); const _SummaryStrip({
required this.snapshot,
this.onOpenStore,
this.onOpenApprovals,
this.onOpenAudit,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
return Row( return Row(
children: [ children: [
_StatTile( DoctorStatTile(
label: l.doctorSummaryModules, label: l.doctorSummaryModules,
value: snapshot.moduleCount.toString(), value: snapshot.moduleCount.toString(),
subtitle: l.doctorSummaryCapabilities(snapshot.capabilityCount), subtitle: l.doctorSummaryCapabilities(snapshot.capabilityCount),
icon: Icons.extension_outlined, icon: Icons.extension_outlined,
onTap: onOpenStore,
linkLabel: l.doctorLinkStore,
), ),
const SizedBox(width: ChainSpace.md), const SizedBox(width: ChainSpace.md),
_StatTile( DoctorStatTile(
label: l.doctorSummaryApprovals, label: l.doctorSummaryApprovals,
value: snapshot.pendingApprovals.toString(), value: snapshot.pendingApprovals.toString(),
subtitle: l.doctorSummaryPending, subtitle: l.doctorSummaryPending,
@ -173,9 +210,11 @@ class _SummaryStrip extends StatelessWidget {
tone: snapshot.pendingApprovals > 0 tone: snapshot.pendingApprovals > 0
? ChainPillTone.warning ? ChainPillTone.warning
: ChainPillTone.neutral, : ChainPillTone.neutral,
onTap: onOpenApprovals,
linkLabel: l.doctorLinkApprovals,
), ),
const SizedBox(width: ChainSpace.md), const SizedBox(width: ChainSpace.md),
_StatTile( DoctorStatTile(
label: l.doctorSummaryAudit, label: l.doctorSummaryAudit,
value: snapshot.chainHealthy ? '' : '', value: snapshot.chainHealthy ? '' : '',
subtitle: l.doctorSummaryChain( subtitle: l.doctorSummaryChain(
@ -186,9 +225,11 @@ class _SummaryStrip extends StatelessWidget {
tone: snapshot.chainHealthy tone: snapshot.chainHealthy
? ChainPillTone.success ? ChainPillTone.success
: ChainPillTone.danger, : ChainPillTone.danger,
onTap: onOpenAudit,
linkLabel: l.doctorLinkAudit,
), ),
const SizedBox(width: ChainSpace.md), const SizedBox(width: ChainSpace.md),
_StatTile( DoctorStatTile(
label: l.doctorSummaryServices, label: l.doctorSummaryServices,
value: snapshot.services.length.toString(), value: snapshot.services.length.toString(),
subtitle: l.doctorSummaryDeclared, subtitle: l.doctorSummaryDeclared,
@ -199,19 +240,32 @@ class _SummaryStrip extends StatelessWidget {
} }
} }
class _StatTile extends StatelessWidget { /// One summary tile of the doctor page. Public + callback-driven so
/// the deep-link widget test can pump it directly: when [onTap] is
/// set, the whole tile becomes a button (chevron affordance,
/// tooltip + semantics from [linkLabel]) that jumps to the page
/// where the finding can be acted on.
class DoctorStatTile extends StatelessWidget {
final String label; final String label;
final String value; final String value;
final String subtitle; final String subtitle;
final IconData icon; final IconData icon;
final ChainPillTone tone; final ChainPillTone tone;
final VoidCallback? onTap;
const _StatTile({ /// Human-readable tap target ("Open approvals"). Required when
/// [onTap] is set; doubles as tooltip and semantics label.
final String? linkLabel;
const DoctorStatTile({
super.key,
required this.label, required this.label,
required this.value, required this.value,
required this.subtitle, required this.subtitle,
required this.icon, required this.icon,
this.tone = ChainPillTone.neutral, this.tone = ChainPillTone.neutral,
this.onTap,
this.linkLabel,
}); });
@override @override
@ -223,8 +277,7 @@ class _StatTile extends StatelessWidget {
ChainPillTone.danger => theme.colorScheme.error, ChainPillTone.danger => theme.colorScheme.error,
_ => theme.colorScheme.primary, _ => theme.colorScheme.primary,
}; };
return Expanded( Widget card = ChainCard(
child: ChainCard(
accentLeft: accentColor, accentLeft: accentColor,
padding: const EdgeInsets.all(ChainSpace.lg), padding: const EdgeInsets.all(ChainSpace.lg),
child: Column( child: Column(
@ -245,6 +298,14 @@ class _StatTile extends StatelessWidget {
), ),
), ),
), ),
if (onTap != null) ...[
const Spacer(),
Icon(
Icons.chevron_right,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
],
], ],
), ),
const SizedBox(height: ChainSpace.sm), const SizedBox(height: ChainSpace.sm),
@ -264,9 +325,25 @@ class _StatTile extends StatelessWidget {
), ),
], ],
), ),
);
if (onTap != null) {
// ChainCard is a plain Container, so the InkWell needs its own
// transparent Material to paint the hover/ripple feedback.
card = Tooltip(
message: linkLabel ?? '',
waitDuration: const Duration(milliseconds: 400),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(ChainRadius.md),
child: Semantics(button: true, label: linkLabel, child: card),
),
), ),
); );
} }
return Expanded(child: card);
}
} }
class _EventLogPanel extends StatelessWidget { class _EventLogPanel extends StatelessWidget {
@ -277,24 +354,22 @@ class _EventLogPanel extends StatelessWidget {
/// after import / restore without restarting the daemon. /// after import / restore without restarting the daemon.
final VoidCallback onRefresh; final VoidCallback onRefresh;
const _EventLogPanel({required this.snapshot, required this.onRefresh}); /// Jumps to the audit page the finding's detail view (per-event
/// list, forensic exports) lives there.
final VoidCallback? onOpenAudit;
const _EventLogPanel({
required this.snapshot,
required this.onRefresh,
this.onOpenAudit,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final healthy = snapshot.chainHealthy; final healthy = snapshot.chainHealthy;
final l = AppLocalizations.of(context)!; final l = AppLocalizations.of(context)!;
return ChainCard( Widget headline = Column(
child: Row(
children: [
Icon(
healthy ? Icons.verified_outlined : Icons.gpp_bad_outlined,
size: 24,
color: healthy ? ChainColors.success : theme.colorScheme.error,
),
const SizedBox(width: ChainSpace.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@ -317,8 +392,32 @@ class _EventLogPanel extends StatelessWidget {
), ),
), ),
], ],
);
if (onOpenAudit != null) {
headline = Tooltip(
message: l.doctorLinkAudit,
waitDuration: const Duration(milliseconds: 400),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onOpenAudit,
borderRadius: BorderRadius.circular(ChainRadius.sm),
child: Semantics(button: true, label: l.doctorLinkAudit,
child: headline),
), ),
), ),
);
}
return ChainCard(
child: Row(
children: [
Icon(
healthy ? Icons.verified_outlined : Icons.gpp_bad_outlined,
size: 24,
color: healthy ? ChainColors.success : theme.colorScheme.error,
),
const SizedBox(width: ChainSpace.lg),
Expanded(child: headline),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onRefresh, onPressed: onRefresh,
icon: const Icon(Icons.fact_check_outlined, size: 16), icon: const Icon(Icons.fact_check_outlined, size: 16),
@ -834,10 +933,43 @@ String _sourceKindLabel(String kind) {
} }
} }
class _ModulesPanel extends StatelessWidget { /// The modules + approvals findings card. Public + callback-driven
/// so the deep-link widget test can pump it without a hub: each row
/// that has a dedicated page is a tap target ("approvals waiting
/// for review" jumps to the approvals inbox, the modules line to
/// the store).
class DoctorModulesPanel extends StatelessWidget {
final DoctorSnapshot snapshot; final DoctorSnapshot snapshot;
final VoidCallback? onOpenStore;
final VoidCallback? onOpenApprovals;
const _ModulesPanel({required this.snapshot}); const DoctorModulesPanel({
super.key,
required this.snapshot,
this.onOpenStore,
this.onOpenApprovals,
});
Widget _linkRow(
BuildContext context, {
required Widget child,
required VoidCallback? onTap,
required String linkLabel,
}) {
if (onTap == null) return child;
return Tooltip(
message: linkLabel,
waitDuration: const Duration(milliseconds: 400),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(ChainRadius.sm),
child: Semantics(button: true, label: linkLabel, child: child),
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -847,7 +979,11 @@ class _ModulesPanel extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( _linkRow(
context,
onTap: onOpenStore,
linkLabel: l.doctorLinkStore,
child: Row(
children: [ children: [
Icon( Icon(
Icons.extension_outlined, Icons.extension_outlined,
@ -886,10 +1022,23 @@ class _ModulesPanel extends StatelessWidget {
? ChainPillTone.success ? ChainPillTone.success
: ChainPillTone.neutral, : ChainPillTone.neutral,
), ),
if (onOpenStore != null) ...[
const SizedBox(width: ChainSpace.xs),
Icon(
Icons.chevron_right,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
],
], ],
), ),
),
const Divider(height: ChainSpace.xl), const Divider(height: ChainSpace.xl),
Row( _linkRow(
context,
onTap: onOpenApprovals,
linkLabel: l.doctorLinkApprovals,
child: Row(
children: [ children: [
Icon( Icon(
Icons.inbox_outlined, Icons.inbox_outlined,
@ -909,7 +1058,16 @@ class _ModulesPanel extends StatelessWidget {
label: l.doctorApprovalsAttentionPill, label: l.doctorApprovalsAttentionPill,
tone: ChainPillTone.warning, tone: ChainPillTone.warning,
), ),
if (onOpenApprovals != null) ...[
const SizedBox(width: ChainSpace.xs),
Icon(
Icons.chevron_right,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
], ],
],
),
), ),
], ],
), ),
@ -960,6 +1118,21 @@ class _ServicesPanel extends StatelessWidget {
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
// The hint names config.yaml put the file one tap away
// instead of making the operator hunt for it.
if (snapshot.paths.configPath.isNotEmpty)
OutlinedButton.icon(
onPressed: () => showFaiConfigViewer(
context,
path: snapshot.paths.configPath,
title: l.doctorPathConfig,
),
icon: const Icon(Icons.settings_outlined, size: 14),
label: Text(l.doctorLinkConfig),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
], ],
), ),
); );
@ -1085,11 +1258,32 @@ class _UpdateBannerState extends State<_UpdateBanner> {
), ),
if (available && status.releaseNotesUrl != null) ...[ if (available && status.releaseNotesUrl != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( // A URL the operator cannot click is a finding
// without a link open it in the browser.
Tooltip(
message: l.doctorLinkReleaseNotes,
waitDuration: const Duration(milliseconds: 400),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => SystemActions.openInOs(
status.releaseNotesUrl!,
),
child: Semantics(
button: true,
label: l.doctorLinkReleaseNotes,
child: Text(
l.doctorReleaseNotes(status.releaseNotesUrl!), l.doctorReleaseNotes(status.releaseNotesUrl!),
style: ChainTheme.mono( style: ChainTheme.mono(
size: 11, size: 11,
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
).copyWith(
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary,
),
),
),
),
), ),
), ),
], ],

View file

@ -0,0 +1,122 @@
// Doctor deep links findings with a dedicated page must be one
// tap away from it (usertest follow-up: "approvals waiting for
// review" was a dead-end statement). The tiles and panel rows are
// public callback-driven widgets so these tests pump each variant
// without a live hub.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio/data/hub.dart';
import 'package:chain_studio/l10n/app_localizations.dart';
import 'package:chain_studio/pages/doctor.dart';
const _snapshot = DoctorSnapshot(
moduleCount: 2,
capabilityCount: 3,
pendingApprovals: 1,
eventChainTotal: 5,
eventChainVerified: 5,
eventChainTamperedAt: null,
services: [],
update: UpdateStatus(
channel: 'stable',
localVersion: '0.22.0',
latestVersion: '0.22.0',
updateAvailable: false,
manifestReachable: true,
),
paths: DaemonPathsSnapshot(
logPath: '',
dbPath: '',
modulesDir: '',
flowsDir: '',
configPath: '',
pidPath: '',
),
);
Widget _host(Widget child) => MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('de'),
home: Scaffold(body: child),
);
void main() {
testWidgets('a linked stat tile is a labelled button that fires its target', (
tester,
) async {
var opened = false;
await tester.pumpWidget(
_host(
Row(
children: [
DoctorStatTile(
label: 'Freigaben',
value: '1',
subtitle: 'warten auf Prüfung',
icon: Icons.inbox_outlined,
onTap: () => opened = true,
linkLabel: 'Freigaben öffnen',
),
],
),
),
);
await tester.pumpAndSettle();
// Affordance: chevron + semantics button with the link label.
expect(find.byIcon(Icons.chevron_right), findsOneWidget);
expect(
find.bySemanticsLabel(RegExp('Freigaben öffnen')),
findsOneWidget,
);
await tester.tap(find.text('1'));
expect(opened, isTrue);
});
testWidgets('an unlinked stat tile stays a plain card', (tester) async {
await tester.pumpWidget(
_host(
const Row(
children: [
DoctorStatTile(
label: 'Dienste',
value: '0',
subtitle: 'deklariert',
icon: Icons.dns_outlined,
),
],
),
),
);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.chevron_right), findsNothing);
expect(find.byType(InkWell), findsNothing);
});
testWidgets(
'the approvals row links to approvals, the modules row to the store',
(tester) async {
var storeOpened = false;
var approvalsOpened = false;
await tester.pumpWidget(
_host(
DoctorModulesPanel(
snapshot: _snapshot,
onOpenStore: () => storeOpened = true,
onOpenApprovals: () => approvalsOpened = true,
),
),
);
await tester.pumpAndSettle();
// The pending-approvals finding must be tappable ("1 Freigabe
// wartet auf Prüfung" -> approvals inbox).
await tester.tap(find.textContaining('wartet auf Prüfung'));
expect(approvalsOpened, isTrue);
expect(storeOpened, isFalse);
await tester.tap(find.textContaining('2 Module'));
expect(storeOpened, isTrue);
},
);
}