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",
"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": {
"placeholders": {

View file

@ -1208,6 +1208,11 @@
}
},
"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": {
"placeholders": {

View file

@ -3662,6 +3662,36 @@ abstract class AppLocalizations {
/// **'declared'**
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.
///
/// In en, this message translates to:

View file

@ -2132,6 +2132,21 @@ class AppLocalizationsDe extends AppLocalizations {
@override
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
String doctorModulesPanelSummary(int n, int m) {
String _temp0 = intl.Intl.pluralLogic(

View file

@ -2136,6 +2136,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override
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
String doctorModulesPanelSummary(int n, int m) {
String _temp0 = intl.Intl.pluralLogic(

View file

@ -7,6 +7,7 @@ import '../data/hub.dart';
import '../data/hub_auth_token.dart';
import '../data/system_actions.dart';
import '../l10n/app_localizations.dart';
import '../main.dart' show StudioShellState;
import '../theme/theme.dart';
import '../theme/tokens.dart';
import '../widgets/widgets.dart';
@ -75,23 +76,49 @@ class _DoctorPageState extends State<DoctorPage> {
final showUpdate =
s.update.updateAvailable ||
(!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(
padding: const EdgeInsets.all(ChainSpace.xl),
children: [
if (showUpdate) _UpdateBanner(status: s.update),
if (showUpdate) const SizedBox(height: ChainSpace.lg),
_SummaryStrip(snapshot: s),
_SummaryStrip(
snapshot: s,
onOpenStore: openStore,
onOpenApprovals: openApprovals,
onOpenAudit: openAudit,
),
const SizedBox(height: ChainSpace.xl),
_Section(
title: AppLocalizations.of(context)!.doctorEventLogSection,
child: _EventLogPanel(snapshot: s, onRefresh: _refresh),
child: _EventLogPanel(
snapshot: s,
onRefresh: _refresh,
onOpenAudit: openAudit,
),
),
const SizedBox(height: ChainSpace.lg),
_Section(
title: AppLocalizations.of(
context,
)!.doctorModulesApprovalsSection,
child: _ModulesPanel(snapshot: s),
child: DoctorModulesPanel(
snapshot: s,
onOpenStore: openStore,
onOpenApprovals: openApprovals,
),
),
const SizedBox(height: ChainSpace.lg),
_Section(
@ -150,22 +177,32 @@ class _Section extends StatelessWidget {
class _SummaryStrip extends StatelessWidget {
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
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
return Row(
children: [
_StatTile(
DoctorStatTile(
label: l.doctorSummaryModules,
value: snapshot.moduleCount.toString(),
subtitle: l.doctorSummaryCapabilities(snapshot.capabilityCount),
icon: Icons.extension_outlined,
onTap: onOpenStore,
linkLabel: l.doctorLinkStore,
),
const SizedBox(width: ChainSpace.md),
_StatTile(
DoctorStatTile(
label: l.doctorSummaryApprovals,
value: snapshot.pendingApprovals.toString(),
subtitle: l.doctorSummaryPending,
@ -173,9 +210,11 @@ class _SummaryStrip extends StatelessWidget {
tone: snapshot.pendingApprovals > 0
? ChainPillTone.warning
: ChainPillTone.neutral,
onTap: onOpenApprovals,
linkLabel: l.doctorLinkApprovals,
),
const SizedBox(width: ChainSpace.md),
_StatTile(
DoctorStatTile(
label: l.doctorSummaryAudit,
value: snapshot.chainHealthy ? '' : '',
subtitle: l.doctorSummaryChain(
@ -186,9 +225,11 @@ class _SummaryStrip extends StatelessWidget {
tone: snapshot.chainHealthy
? ChainPillTone.success
: ChainPillTone.danger,
onTap: onOpenAudit,
linkLabel: l.doctorLinkAudit,
),
const SizedBox(width: ChainSpace.md),
_StatTile(
DoctorStatTile(
label: l.doctorSummaryServices,
value: snapshot.services.length.toString(),
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 value;
final String subtitle;
final IconData icon;
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.value,
required this.subtitle,
required this.icon,
this.tone = ChainPillTone.neutral,
this.onTap,
this.linkLabel,
});
@override
@ -223,49 +277,72 @@ class _StatTile extends StatelessWidget {
ChainPillTone.danger => theme.colorScheme.error,
_ => theme.colorScheme.primary,
};
return Expanded(
child: ChainCard(
accentLeft: accentColor,
padding: const EdgeInsets.all(ChainSpace.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: ChainSpace.xs),
Flexible(
child: Text(
label.toUpperCase(),
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
fontSize: 10,
),
Widget card = ChainCard(
accentLeft: accentColor,
padding: const EdgeInsets.all(ChainSpace.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: ChainSpace.xs),
Flexible(
child: Text(
label.toUpperCase(),
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.5,
fontSize: 10,
),
),
),
if (onTap != null) ...[
const Spacer(),
Icon(
Icons.chevron_right,
size: 16,
color: theme.colorScheme.onSurfaceVariant,
),
],
],
),
const SizedBox(height: ChainSpace.sm),
Text(
value,
style: theme.textTheme.displaySmall?.copyWith(
color: accentColor,
fontWeight: FontWeight.w700,
),
const SizedBox(height: ChainSpace.sm),
Text(
value,
style: theme.textTheme.displaySmall?.copyWith(
color: accentColor,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 2),
Text(
subtitle,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
);
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);
}
}
@ -277,13 +354,60 @@ class _EventLogPanel extends StatelessWidget {
/// after import / restore without restarting the daemon.
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
Widget build(BuildContext context) {
final theme = Theme.of(context);
final healthy = snapshot.chainHealthy;
final l = AppLocalizations.of(context)!;
Widget headline = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
healthy ? l.doctorChainIntact : l.doctorChainTampered,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
healthy
? l.doctorChainIntactDetail(snapshot.eventChainTotal)
: l.doctorChainTamperedDetail(
snapshot.eventChainVerified,
snapshot.eventChainTotal,
snapshot.eventChainTamperedAt ?? '',
),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
);
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: [
@ -293,32 +417,7 @@ class _EventLogPanel extends StatelessWidget {
color: healthy ? ChainColors.success : theme.colorScheme.error,
),
const SizedBox(width: ChainSpace.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
healthy ? l.doctorChainIntact : l.doctorChainTampered,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
healthy
? l.doctorChainIntactDetail(snapshot.eventChainTotal)
: l.doctorChainTamperedDetail(
snapshot.eventChainVerified,
snapshot.eventChainTotal,
snapshot.eventChainTamperedAt ?? '',
),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
Expanded(child: headline),
OutlinedButton.icon(
onPressed: onRefresh,
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 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
Widget build(BuildContext context) {
@ -847,69 +979,95 @@ class _ModulesPanel extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.extension_outlined,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.doctorModulesPanelSummary(
snapshot.moduleCount,
snapshot.capabilityCount,
),
style: theme.textTheme.bodyMedium,
),
if (snapshot.capabilitiesBySource.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
_sourceBreakdown(snapshot.capabilitiesBySource),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
_linkRow(
context,
onTap: onOpenStore,
linkLabel: l.doctorLinkStore,
child: Row(
children: [
Icon(
Icons.extension_outlined,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
),
ChainPill(
label: snapshot.moduleCount > 0
? l.doctorPillLoaded
: l.doctorPillEmpty,
tone: snapshot.moduleCount > 0
? ChainPillTone.success
: ChainPillTone.neutral,
),
],
const SizedBox(width: ChainSpace.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l.doctorModulesPanelSummary(
snapshot.moduleCount,
snapshot.capabilityCount,
),
style: theme.textTheme.bodyMedium,
),
if (snapshot.capabilitiesBySource.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
_sourceBreakdown(snapshot.capabilitiesBySource),
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
ChainPill(
label: snapshot.moduleCount > 0
? l.doctorPillLoaded
: l.doctorPillEmpty,
tone: snapshot.moduleCount > 0
? ChainPillTone.success
: 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),
Row(
children: [
Icon(
Icons.inbox_outlined,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: ChainSpace.sm),
Text(
snapshot.pendingApprovals == 0
? l.doctorApprovalsNone
: l.doctorApprovalsCount(snapshot.pendingApprovals),
style: theme.textTheme.bodyMedium,
),
const Spacer(),
if (snapshot.pendingApprovals > 0)
ChainPill(
label: l.doctorApprovalsAttentionPill,
tone: ChainPillTone.warning,
_linkRow(
context,
onTap: onOpenApprovals,
linkLabel: l.doctorLinkApprovals,
child: Row(
children: [
Icon(
Icons.inbox_outlined,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
],
const SizedBox(width: ChainSpace.sm),
Text(
snapshot.pendingApprovals == 0
? l.doctorApprovalsNone
: l.doctorApprovalsCount(snapshot.pendingApprovals),
style: theme.textTheme.bodyMedium,
),
const Spacer(),
if (snapshot.pendingApprovals > 0)
ChainPill(
label: l.doctorApprovalsAttentionPill,
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,
),
),
// 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) ...[
const SizedBox(height: 4),
Text(
l.doctorReleaseNotes(status.releaseNotesUrl!),
style: ChainTheme.mono(
size: 11,
color: theme.colorScheme.primary,
// 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!),
style: ChainTheme.mono(
size: 11,
color: theme.colorScheme.primary,
).copyWith(
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary,
),
),
),
),
),
),
],