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:
parent
c18bb7f357
commit
e67e0c9e73
7 changed files with 525 additions and 139 deletions
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,49 +277,72 @@ 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(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Row(
|
||||||
Row(
|
children: [
|
||||||
children: [
|
Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant),
|
||||||
Icon(icon, size: 14, color: theme.colorScheme.onSurfaceVariant),
|
const SizedBox(width: ChainSpace.xs),
|
||||||
const SizedBox(width: ChainSpace.xs),
|
Flexible(
|
||||||
Flexible(
|
child: Text(
|
||||||
child: Text(
|
label.toUpperCase(),
|
||||||
label.toUpperCase(),
|
overflow: TextOverflow.ellipsis,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
style: theme.textTheme.labelSmall?.copyWith(
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
letterSpacing: 0.5,
|
||||||
letterSpacing: 0.5,
|
fontSize: 10,
|
||||||
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(
|
const SizedBox(height: 2),
|
||||||
value,
|
Text(
|
||||||
style: theme.textTheme.displaySmall?.copyWith(
|
subtitle,
|
||||||
color: accentColor,
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
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.
|
/// 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)!;
|
||||||
|
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(
|
return ChainCard(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -293,32 +417,7 @@ class _EventLogPanel extends StatelessWidget {
|
||||||
color: healthy ? ChainColors.success : theme.colorScheme.error,
|
color: healthy ? ChainColors.success : theme.colorScheme.error,
|
||||||
),
|
),
|
||||||
const SizedBox(width: ChainSpace.lg),
|
const SizedBox(width: ChainSpace.lg),
|
||||||
Expanded(
|
Expanded(child: headline),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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,69 +979,95 @@ class _ModulesPanel extends StatelessWidget {
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
_linkRow(
|
||||||
children: [
|
context,
|
||||||
Icon(
|
onTap: onOpenStore,
|
||||||
Icons.extension_outlined,
|
linkLabel: l.doctorLinkStore,
|
||||||
size: 18,
|
child: Row(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
children: [
|
||||||
),
|
Icon(
|
||||||
const SizedBox(width: ChainSpace.sm),
|
Icons.extension_outlined,
|
||||||
Expanded(
|
size: 18,
|
||||||
child: Column(
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: ChainSpace.sm),
|
||||||
ChainPill(
|
Expanded(
|
||||||
label: snapshot.moduleCount > 0
|
child: Column(
|
||||||
? l.doctorPillLoaded
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
: l.doctorPillEmpty,
|
children: [
|
||||||
tone: snapshot.moduleCount > 0
|
Text(
|
||||||
? ChainPillTone.success
|
l.doctorModulesPanelSummary(
|
||||||
: ChainPillTone.neutral,
|
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),
|
const Divider(height: ChainSpace.xl),
|
||||||
Row(
|
_linkRow(
|
||||||
children: [
|
context,
|
||||||
Icon(
|
onTap: onOpenApprovals,
|
||||||
Icons.inbox_outlined,
|
linkLabel: l.doctorLinkApprovals,
|
||||||
size: 18,
|
child: Row(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
children: [
|
||||||
),
|
Icon(
|
||||||
const SizedBox(width: ChainSpace.sm),
|
Icons.inbox_outlined,
|
||||||
Text(
|
size: 18,
|
||||||
snapshot.pendingApprovals == 0
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
? l.doctorApprovalsNone
|
|
||||||
: l.doctorApprovalsCount(snapshot.pendingApprovals),
|
|
||||||
style: theme.textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
if (snapshot.pendingApprovals > 0)
|
|
||||||
ChainPill(
|
|
||||||
label: l.doctorApprovalsAttentionPill,
|
|
||||||
tone: ChainPillTone.warning,
|
|
||||||
),
|
),
|
||||||
],
|
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,
|
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
|
||||||
l.doctorReleaseNotes(status.releaseNotesUrl!),
|
// without a link — open it in the browser.
|
||||||
style: ChainTheme.mono(
|
Tooltip(
|
||||||
size: 11,
|
message: l.doctorLinkReleaseNotes,
|
||||||
color: theme.colorScheme.primary,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
122
test/doctor_deeplinks_test.dart
Normal file
122
test/doctor_deeplinks_test.dart
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue