From 97633a0d7f36cf331015ac367475b366524deb81 Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Mon, 15 Jun 2026 18:42:20 +0200 Subject: [PATCH 1/4] feat(explorer): hide erased instances by default (toggle to reveal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Wissensbasis/CRM explorer listed tombstoned instances (members erased on user request) indistinguishably from live ones. Erasure is signalled by `status: erased` (members) / `status: revoked` (consent) in frontmatter — exactly what Carl's erase_member writes, keeping required keys so the page still parses. - InstanceSummary.erased + shared isErasedFrontmatter() (models.dart). - showErasedProvider (default false) + allInstancesRawProvider/allInstancesProvider split so the filter is a pure view filter (no refetch on toggle). The breadcrumb count + auto-focus now skip tombstones too. - Instances directory: a 'Gelöschte anzeigen' toggle; erased rows render struck-through/dimmed with a 'gelöscht' marker when revealed. No-mock widget + provider tests over a FixtureEscurelClient corpus (one live + one erased member): hidden by default, revealed + marked when toggled. Full kit suite green (89), analyze clean. Co-Authored-By: Claude Opus 4.8 --- .../lib/client/models.dart | 77 ++++++++--- .../lib/crm/crm_providers.dart | 42 ++++-- .../lib/crm/instances_menu.dart | 97 ++++++++++++-- .../test/crm/hide_erased_test.dart | 125 ++++++++++++++++++ 4 files changed, 305 insertions(+), 36 deletions(-) create mode 100644 packages/escurel_explorer_kit/test/crm/hide_erased_test.dart diff --git a/packages/escurel_explorer_kit/lib/client/models.dart b/packages/escurel_explorer_kit/lib/client/models.dart index 453cf42..57df082 100644 --- a/packages/escurel_explorer_kit/lib/client/models.dart +++ b/packages/escurel_explorer_kit/lib/client/models.dart @@ -159,11 +159,30 @@ class SkillSummary { } class InstanceSummary { - const InstanceSummary({required this.id, required this.skill, required this.frontmatter}); + const InstanceSummary({ + required this.id, + required this.skill, + required this.frontmatter, + }); final String id; final String skill; final Map frontmatter; + + /// Whether this instance is a tombstone (erased/revoked on user + /// request). Carl's `erase_member` writes `status: erased` on the member + /// and `status: revoked` on its consent; both keep their required keys so + /// the page still parses. Treated as a deletion marker the explorer hides + /// by default. + bool get erased => isErasedFrontmatter(frontmatter); +} + +/// `true` when a page's frontmatter marks it as a tombstone. Shared by +/// instance summaries and expanded pages so the "hide erased" rule is +/// defined in exactly one place. +bool isErasedFrontmatter(Map frontmatter) { + final status = (frontmatter['status'] as String?)?.trim().toLowerCase(); + return status == 'erased' || status == 'revoked'; } // ── events / inbox (M7) ───────────────────────────────────────── @@ -199,17 +218,17 @@ class Event { bool get isInbox => status == 'inbox'; static Event fromJson(Map j) => Event( - eventId: (j['event_id'] as String?) ?? '', - at: j['at'] as String?, - source: (j['source'] as String?) ?? '', - mime: (j['mime'] as String?) ?? '', - labelSkill: (j['label_skill'] as String?) ?? '', - instancePageId: j['instance_page_id'] as String?, - status: (j['status'] as String?) ?? 'inbox', - title: (j['title'] as String?) ?? '', - body: (j['body'] as String?) ?? '', - provenance: Map.from(j['provenance'] as Map? ?? const {}), - ); + eventId: (j['event_id'] as String?) ?? '', + at: j['at'] as String?, + source: (j['source'] as String?) ?? '', + mime: (j['mime'] as String?) ?? '', + labelSkill: (j['label_skill'] as String?) ?? '', + instancePageId: j['instance_page_id'] as String?, + status: (j['status'] as String?) ?? 'inbox', + title: (j['title'] as String?) ?? '', + body: (j['body'] as String?) ?? '', + provenance: Map.from(j['provenance'] as Map? ?? const {}), + ); } // ── run_stored_query ──────────────────────────────────────────── @@ -269,7 +288,12 @@ class UpdateResult { // ── live mode (session) — stubs until M3 transport decided ────── class Session { - const Session({required this.id, required this.pageId, required this.headVersion, required this.content}); + const Session({ + required this.id, + required this.pageId, + required this.headVersion, + required this.content, + }); final String id; final String pageId; final String headVersion; @@ -295,7 +319,11 @@ class CloseResult { } class AwarenessEvent { - const AwarenessEvent({required this.session, required this.kind, this.payload}); + const AwarenessEvent({ + required this.session, + required this.kind, + this.payload, + }); final String session; final String kind; final Map? payload; @@ -304,21 +332,33 @@ class AwarenessEvent { // ── admin MCP tools — gated by escurel-admin role ─────────────── class LaneSummary { - const LaneSummary({required this.name, required this.backend, required this.tenantsPresent}); + const LaneSummary({ + required this.name, + required this.backend, + required this.tenantsPresent, + }); final String name; final String backend; final int tenantsPresent; } class LaneKey { - const LaneKey({required this.key, required this.sizeBytes, this.lastModified}); + const LaneKey({ + required this.key, + required this.sizeBytes, + this.lastModified, + }); final String key; final int sizeBytes; final DateTime? lastModified; } class LaneBlob { - const LaneBlob({required this.key, required this.bytes, required this.contentType}); + const LaneBlob({ + required this.key, + required this.bytes, + required this.contentType, + }); final String key; final Uint8List bytes; final String contentType; @@ -423,5 +463,6 @@ class AuditDrift { final List markdownNotInDuckdb; final List indexedButNoMarkdown; - bool get isClean => markdownNotInDuckdb.isEmpty && indexedButNoMarkdown.isEmpty; + bool get isClean => + markdownNotInDuckdb.isEmpty && indexedButNoMarkdown.isEmpty; } diff --git a/packages/escurel_explorer_kit/lib/crm/crm_providers.dart b/packages/escurel_explorer_kit/lib/crm/crm_providers.dart index f4b8057..1582db2 100644 --- a/packages/escurel_explorer_kit/lib/crm/crm_providers.dart +++ b/packages/escurel_explorer_kit/lib/crm/crm_providers.dart @@ -11,10 +11,17 @@ import '../client/models.dart'; import '../md/frontmatter.dart'; import '../state/providers.dart'; -/// Every instance in the tenant, flattened across skills (skips the -/// `escurel` meta-skill, which has no instances). Powers the -/// breadcrumb's "Instances N" count and any cross-skill listing. -final allInstancesProvider = FutureProvider>((ref) async { +/// When `false` (the default), erased/revoked tombstones are hidden +/// everywhere the explorer lists instances; flipping it reveals them +/// (rendered struck-through). A pure view filter — no refetch. +final showErasedProvider = StateProvider((ref) => false); + +/// Every instance in the tenant as fetched, flattened across skills (skips +/// the `escurel` meta-skill). Includes tombstones — filtering happens in +/// [allInstancesProvider] so toggling visibility doesn't refetch. +final allInstancesRawProvider = FutureProvider>(( + ref, +) async { final client = ref.watch(escurelClientProvider); final skills = await ref.watch(skillsCatalogueProvider.future); final out = []; @@ -25,6 +32,15 @@ final allInstancesProvider = FutureProvider>((ref) async { return out; }); +/// Every instance in the tenant, flattened across skills, with erased +/// tombstones hidden unless [showErasedProvider] is on. Powers the +/// breadcrumb's "Instances N" count and any cross-skill listing. +final allInstancesProvider = FutureProvider>((ref) async { + final all = await ref.watch(allInstancesRawProvider.future); + if (ref.watch(showErasedProvider)) return all; + return all.where((i) => !i.erased).toList(); +}); + /// The instance the workspace auto-focuses on first load: the engagement /// spine with the richest *processed* event history (the populated /// showcase), falling back to the first spine, then the first instance. @@ -39,8 +55,9 @@ final allInstancesProvider = FutureProvider>((ref) async { final autoFocusTargetProvider = FutureProvider((ref) async { final all = await ref.watch(allInstancesProvider.future); if (all.isEmpty) return null; - final spines = - all.where((i) => i.skill == 'engagement' && i.id.contains('spine')).toList(); + final spines = all + .where((i) => i.skill == 'engagement' && i.id.contains('spine')) + .toList(); if (spines.isEmpty) return all.first.id; final client = ref.watch(escurelClientProvider); var bestId = spines.first.id; @@ -109,7 +126,8 @@ final eventSourceFilterProvider = StateProvider((ref) => null); /// The distinct `label_skill`s across the focused instance's event /// history — the chips the SOURCES filter offers. Sorted, stable. final availableSourcesProvider = Provider>((ref) { - final history = ref.watch(entityEventHistoryProvider).valueOrNull ?? const []; + final history = + ref.watch(entityEventHistoryProvider).valueOrNull ?? const []; final set = {}; for (final e in history) { if (e.labelSkill.isNotEmpty) set.add(e.labelSkill); @@ -154,7 +172,8 @@ final instanceSnapshotsProvider = FutureProvider>((ref) async { final openEventDetailProvider = Provider((ref) { final id = ref.watch(openEventProvider); if (id == null) return null; - final history = ref.watch(entityEventHistoryProvider).valueOrNull ?? const []; + final history = + ref.watch(entityEventHistoryProvider).valueOrNull ?? const []; final inbox = ref.watch(inboxEventsProvider).valueOrNull ?? const []; for (final e in [...history, ...inbox]) { if (e.eventId == id) return e; @@ -172,7 +191,12 @@ final currentNeighboursProvider = FutureProvider>((ref) async { final scenario = ref.watch(scenarioProvider); return ref .watch(escurelClientProvider) - .neighbours(id, direction: LinkDirection.both, asOf: asOf, scenario: scenario); + .neighbours( + id, + direction: LinkDirection.both, + asOf: asOf, + scenario: scenario, + ); }); /// Resolve a typed `[[skill::slug]]` reference to its page id and focus diff --git a/packages/escurel_explorer_kit/lib/crm/instances_menu.dart b/packages/escurel_explorer_kit/lib/crm/instances_menu.dart index 7809400..3caf6d1 100644 --- a/packages/escurel_explorer_kit/lib/crm/instances_menu.dart +++ b/packages/escurel_explorer_kit/lib/crm/instances_menu.dart @@ -19,7 +19,9 @@ class InstancesMenu extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final text = Theme.of(context).textTheme; - final count = ref.watch(allInstancesProvider).maybeWhen(data: (xs) => xs.length, orElse: () => null); + final count = ref + .watch(allInstancesProvider) + .maybeWhen(data: (xs) => xs.length, orElse: () => null); return BreadcrumbMenu( trigger: (open) => Semantics( label: 'instances', @@ -57,13 +59,19 @@ class _InstancesPanel extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final instances = ref.watch(allInstancesProvider); final current = ref.watch(currentPageIdProvider); + final showErased = ref.watch(showErasedProvider); return Column( mainAxisSize: MainAxisSize.min, children: [ MenuHeader( - title: 'Instances · ${instances.maybeWhen(data: (xs) => xs.length, orElse: () => '…')}', + title: + 'Instances · ${instances.maybeWhen(data: (xs) => xs.length, orElse: () => '…')}', subtitle: 'the root directory of instances', ), + _ShowErasedToggle( + value: showErased, + onChanged: (v) => ref.read(showErasedProvider.notifier).state = v, + ), Flexible( child: instances.when( loading: () => const Padding( @@ -85,11 +93,17 @@ class _InstancesPanel extends ConsumerWidget { shrinkWrap: true, children: [ for (final sk in skillIds) ...[ - MenuSectionHeader(label: sk.toUpperCase(), count: groups[sk]!.length), - for (final inst in (groups[sk]!..sort((a, b) => _name(a).compareTo(_name(b))))) + MenuSectionHeader( + label: sk.toUpperCase(), + count: groups[sk]!.length, + ), + for (final inst + in (groups[sk]! + ..sort((a, b) => _name(a).compareTo(_name(b))))) _InstanceRow( instance: inst, selected: inst.id == current, + erased: inst.erased, onTap: () { close(); navigateToInstance(ref, inst.id); @@ -114,14 +128,64 @@ String _slug(String pageId) { return parts.length == 2 ? parts[1] : base; } -String _name(InstanceSummary i) => (i.frontmatter['name'] as String?)?.trim().isNotEmpty == true +String _name(InstanceSummary i) => + (i.frontmatter['name'] as String?)?.trim().isNotEmpty == true ? (i.frontmatter['name'] as String).trim() : _slug(i.id); +/// A compact "show deleted" switch at the top of the directory. Defaults +/// off; flipping it reveals tombstones (struck-through rows). +class _ShowErasedToggle extends StatelessWidget { + const _ShowErasedToggle({required this.value, required this.onChanged}); + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final text = Theme.of(context).textTheme; + return Semantics( + label: 'show-erased-toggle', + toggled: value, + button: true, + onTap: () => onChanged(!value), + excludeSemantics: true, + child: InkWell( + onTap: () => onChanged(!value), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 12, 4), + child: Row( + children: [ + Icon( + value ? Icons.visibility : Icons.visibility_off, + size: 16, + color: kOutline, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Gelöschte anzeigen', + style: text.bodySmall?.copyWith(color: kOnSurfaceVariant), + ), + ), + Switch(value: value, onChanged: onChanged), + ], + ), + ), + ), + ); + } +} + class _InstanceRow extends StatelessWidget { - const _InstanceRow({required this.instance, required this.selected, required this.onTap}); + const _InstanceRow({ + required this.instance, + required this.selected, + required this.onTap, + this.erased = false, + }); final InstanceSummary instance; final bool selected; + final bool erased; final VoidCallback onTap; @override @@ -140,7 +204,10 @@ class _InstanceRow extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Row( children: [ - SkillAvatar(skill: instance.skill, size: 20), + Opacity( + opacity: erased ? 0.55 : 1.0, + child: SkillAvatar(skill: instance.skill, size: 20), + ), const SizedBox(width: 10), Expanded( child: Text( @@ -148,13 +215,25 @@ class _InstanceRow extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: text.bodyMedium?.copyWith( - color: selected ? kPrimary : kOnSurface, + color: erased + ? kOutline + : (selected ? kPrimary : kOnSurface), fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + decoration: erased ? TextDecoration.lineThrough : null, ), ), ), const SizedBox(width: 8), - Text(_slug(instance.id), style: text.labelSmall?.copyWith(color: kOutline)), + if (erased) + Text( + 'gelöscht', + style: text.labelSmall?.copyWith(color: kError), + ) + else + Text( + _slug(instance.id), + style: text.labelSmall?.copyWith(color: kOutline), + ), ], ), ), diff --git a/packages/escurel_explorer_kit/test/crm/hide_erased_test.dart b/packages/escurel_explorer_kit/test/crm/hide_erased_test.dart new file mode 100644 index 0000000..0039949 --- /dev/null +++ b/packages/escurel_explorer_kit/test/crm/hide_erased_test.dart @@ -0,0 +1,125 @@ +// No-mock test for hiding erased (tombstoned) instances. Erasure is +// signalled by `status: erased` (members) / `status: revoked` (consent) in +// frontmatter — exactly what Carl's `erase_member` writes. By default the +// explorer hides them; a toggle reveals them (struck-through). Real +// FixtureEscurelClient over a tiny in-memory corpus. + +@TestOn('vm') +library; + +import 'package:escurel_explorer_kit/client/fixture_escurel_client.dart'; +import 'package:escurel_explorer_kit/client/models.dart'; +import 'package:escurel_explorer_kit/crm/crm_breadcrumb.dart'; +import 'package:escurel_explorer_kit/crm/crm_providers.dart'; +import 'package:escurel_explorer_kit/state/providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _memberSkill = + '---\ntype: skill\nid: community_member\n' + 'description: A member.\n---\n# community_member\n'; +const _alice = + '---\ntype: instance\nskill: community_member\nid: alice\n' + 'name: Alice\ncredential: "whatsapp:111"\n---\n# Alice\n'; +// A tombstoned member, exactly as erase_member leaves it. +const _bob = + '---\ntype: instance\nskill: community_member\nid: bob\n' + 'name: Bob\ncredential: "whatsapp:222"\nstatus: erased\n' + 'erased_at: "2026-06-15T00:00:00Z"\n---\n# Bob\nGelöscht auf Nutzerwunsch.\n'; + +FixtureEscurelClient _client() => FixtureEscurelClient.fromSources( + skillFiles: {'community_member.md': _memberSkill}, + instanceFiles: { + 'community_member__alice.md': _alice, + 'community_member__bob.md': _bob, + }, +); + +void main() { + test('InstanceSummary.erased keys on status erased/revoked', () { + const live = InstanceSummary( + id: 'community_member__alice', + skill: 'community_member', + frontmatter: {'id': 'alice'}, + ); + const erased = InstanceSummary( + id: 'community_member__bob', + skill: 'community_member', + frontmatter: {'id': 'bob', 'status': 'erased'}, + ); + const revoked = InstanceSummary( + id: 'platform_consent__bob', + skill: 'platform_consent', + frontmatter: {'status': 'revoked'}, + ); + expect(live.erased, isFalse); + expect(erased.erased, isTrue); + expect(revoked.erased, isTrue); + }); + + test( + 'allInstancesProvider hides erased by default, reveals when toggled', + () async { + final container = ProviderContainer( + overrides: [escurelClientProvider.overrideWithValue(_client())], + ); + addTearDown(container.dispose); + + final hidden = await container.read(allInstancesProvider.future); + expect(hidden.map((i) => i.id), [ + 'community_member__alice', + ], reason: 'erased bob is filtered out by default'); + + container.read(showErasedProvider.notifier).state = true; + final shown = await container.read(allInstancesProvider.future); + expect( + shown.map((i) => i.id).toSet(), + {'community_member__alice', 'community_member__bob'}, + reason: 'toggling showErased reveals the tombstone', + ); + }, + ); + + testWidgets( + 'Instances menu hides erased rows by default, toggle reveals them', + (tester) async { + tester.view.physicalSize = const Size(1200, 1000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final container = ProviderContainer( + overrides: [escurelClientProvider.overrideWithValue(_client())], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: Scaffold(appBar: CrmBreadcrumb(), body: SizedBox.shrink()), + ), + ), + ); + await tester.pumpAndSettle(); + + // Count reflects only the live instance. + expect(find.text('Instances 1'), findsOneWidget); + + await tester.tap(find.bySemanticsLabel('instances')); + await tester.pumpAndSettle(); + expect(find.bySemanticsLabel('instance-row:alice'), findsOneWidget); + expect(find.bySemanticsLabel('instance-row:bob'), findsNothing); + + // Reveal erased. + await tester.tap(find.bySemanticsLabel('show-erased-toggle')); + await tester.pumpAndSettle(); + expect( + find.bySemanticsLabel('instance-row:bob'), + findsOneWidget, + reason: 'the tombstone appears when revealed', + ); + }, + ); +} From bb6b7dd5f0675d683277e7dbd90b123ee92bf7ba Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Mon, 15 Jun 2026 18:46:08 +0200 Subject: [PATCH 2/4] feat(explorer): breadcrumb history trail with jump-to-depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top breadcrumb showed only the currently-focused entity, so the navigation history (already tracked in navBackStackProvider for the instance-pane Back button) was invisible. Now render the back-stack as clickable ancestor crumbs — `A › B › current` — scrollable so a deep trail never overflows the title row. - navigateToDepth(ref, index): focus stack[index], truncate history before it (preserves the trail left of the click, drops everything right). - CrmBreadcrumb: ancestors as _TrailCrumb (semantics 'crumb:'), the focused entity stays the final 'focused-entity' crumb. Complements (doesn't replace) the existing Back button. No-mock widget test: nav a→b→c renders crumb:a, crumb:b, focused-entity; tapping crumb:a refocuses a and empties the stack. Full kit suite green (90), analyze clean. Co-Authored-By: Claude Opus 4.8 --- .../lib/crm/crm_breadcrumb.dart | 132 ++++++++++++++---- .../lib/state/providers.dart | 54 +++++-- .../test/crm/breadcrumb_trail_test.dart | 76 ++++++++++ 3 files changed, 220 insertions(+), 42 deletions(-) create mode 100644 packages/escurel_explorer_kit/test/crm/breadcrumb_trail_test.dart diff --git a/packages/escurel_explorer_kit/lib/crm/crm_breadcrumb.dart b/packages/escurel_explorer_kit/lib/crm/crm_breadcrumb.dart index a736ba9..f71ad74 100644 --- a/packages/escurel_explorer_kit/lib/crm/crm_breadcrumb.dart +++ b/packages/escurel_explorer_kit/lib/crm/crm_breadcrumb.dart @@ -22,6 +22,7 @@ class CrmBreadcrumb extends ConsumerWidget implements PreferredSizeWidget { Widget build(BuildContext context, WidgetRef ref) { final text = Theme.of(context).textTheme; final focused = ref.watch(currentPageIdProvider); + final trail = ref.watch(navBackStackProvider); return AppBar( automaticallyImplyLeading: false, @@ -37,42 +38,63 @@ class CrmBreadcrumb extends ConsumerWidget implements PreferredSizeWidget { container: true, explicitChildNodes: true, child: RichText( - text: TextSpan(children: [ - TextSpan( - text: 'data zoo', - style: text.titleMedium?.copyWith(color: kOnSurface, fontWeight: FontWeight.w700), - ), - TextSpan( - text: ' / CRM', - style: text.titleMedium?.copyWith(color: kOutline), - ), - ]), + text: TextSpan( + children: [ + TextSpan( + text: 'data zoo', + style: text.titleMedium?.copyWith( + color: kOnSurface, + fontWeight: FontWeight.w700, + ), + ), + TextSpan( + text: ' / CRM', + style: text.titleMedium?.copyWith(color: kOutline), + ), + ], + ), ), ), const SizedBox(width: 16), const InstancesMenu(), - if (focused != null) ...[ - _Sep(), - // Flexible + ellipsis so a long entity label never overflows - // the title row. + if (focused != null) + // The history trail (ancestors as clickable crumbs) + the + // focused entity, scrollable so a deep trail never overflows. Flexible( - child: _Crumb( - label: 'focused-entity', - child: Text( - _entityLabel(focused), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: text.labelLarge?.copyWith(color: kPrimary, fontWeight: FontWeight.w700), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + reverse: true, // keep the focused entity in view when long + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < trail.length; i++) ...[ + _Sep(), + _TrailCrumb( + label: 'crumb:${_slug(trail[i])}', + text: _entityLabel(trail[i]), + onTap: () => navigateToDepth(ref, i), + ), + ], + _Sep(), + _Crumb( + label: 'focused-entity', + child: Text( + _entityLabel(focused), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: text.labelLarge?.copyWith( + color: kPrimary, + fontWeight: FontWeight.w700, + ), + ), + ), + ], ), ), ), - ], ], ), - actions: const [ - ScenarioSwitch(), - SizedBox(width: 16), - ], + actions: const [ScenarioSwitch(), SizedBox(width: 16)], ); } @@ -82,6 +104,50 @@ class CrmBreadcrumb extends ConsumerWidget implements PreferredSizeWidget { final parts = base.split('__'); return parts.length == 2 ? '${parts[0]} · ${parts[1]}' : base; } + + /// `…/engagement__hoffmann-spine.md` → `hoffmann-spine` (semantics key). + static String _slug(String pageId) { + final base = pageId.split('/').last.replaceAll('.md', ''); + final parts = base.split('__'); + return parts.length == 2 ? parts[1] : base; + } +} + +/// A clickable ancestor crumb in the history trail — tapping it jumps focus +/// back to that depth. +class _TrailCrumb extends StatelessWidget { + const _TrailCrumb({ + required this.label, + required this.text, + required this.onTap, + }); + final String label; + final String text; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context).textTheme; + return Semantics( + label: label, + button: true, + onTap: onTap, + excludeSemantics: true, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.labelLarge?.copyWith(color: kOnSurfaceVariant), + ), + ), + ), + ); + } } class _Crumb extends StatelessWidget { @@ -89,14 +155,18 @@ class _Crumb extends StatelessWidget { final String label; final Widget child; @override - Widget build(BuildContext context) => - Semantics(label: label, container: true, explicitChildNodes: true, child: child); + Widget build(BuildContext context) => Semantics( + label: label, + container: true, + explicitChildNodes: true, + child: child, + ); } class _Sep extends StatelessWidget { @override Widget build(BuildContext context) => const Padding( - padding: EdgeInsets.symmetric(horizontal: 10), - child: Icon(Icons.chevron_right, size: 16, color: kOutlineVariant), - ); + padding: EdgeInsets.symmetric(horizontal: 10), + child: Icon(Icons.chevron_right, size: 16, color: kOutlineVariant), + ); } diff --git a/packages/escurel_explorer_kit/lib/state/providers.dart b/packages/escurel_explorer_kit/lib/state/providers.dart index b90caa3..924e725 100644 --- a/packages/escurel_explorer_kit/lib/state/providers.dart +++ b/packages/escurel_explorer_kit/lib/state/providers.dart @@ -38,9 +38,9 @@ final escurelClientProvider = Provider((ref) { // build-time URL baked in (CLAUDE.md: demo runs as one process // alongside `/mcp`). AppMode.http => HttpEscurelClient( - baseUrl: env.baseUrl.isNotEmpty ? env.baseUrl : Uri.base.origin, - bearerToken: env.auth == AuthMode.bearer ? null : null, - ), + baseUrl: env.baseUrl.isNotEmpty ? env.baseUrl : Uri.base.origin, + bearerToken: env.auth == AuthMode.bearer ? null : null, + ), AppMode.fixture => _bootstrapInlineFixture(), }; ref.onDispose(client.close); @@ -78,11 +78,25 @@ void navigateToInstance(WidgetRef ref, String pageId) { bool navigateBack(WidgetRef ref) { final stack = ref.read(navBackStackProvider); if (stack.isEmpty) return false; - ref.read(navBackStackProvider.notifier).state = stack.sublist(0, stack.length - 1); + ref.read(navBackStackProvider.notifier).state = stack.sublist( + 0, + stack.length - 1, + ); ref.read(currentPageIdProvider.notifier).state = stack.last; return true; } +/// Jump focus to a specific depth in the back-stack (a breadcrumb-trail +/// click): focus `stack[index]` and truncate the history to everything +/// before it, so the trail to the left of the clicked crumb is preserved +/// and everything to its right is dropped. No-op for an out-of-range index. +void navigateToDepth(WidgetRef ref, int index) { + final stack = ref.read(navBackStackProvider); + if (index < 0 || index >= stack.length) return; + ref.read(navBackStackProvider.notifier).state = stack.sublist(0, index); + ref.read(currentPageIdProvider.notifier).state = stack[index]; +} + /// Drop the navigation history (used when a fresh search jumps the focus /// to an unrelated instance, so Back doesn't wander a stale trail). void clearNavHistory(WidgetRef ref) { @@ -99,8 +113,9 @@ void clearNavHistory(WidgetRef ref) { Future focusSkill(WidgetRef ref, String skillId) async { if (skillId.isEmpty) return; final scenario = ref.read(scenarioProvider); - final resolved = - await ref.read(escurelClientProvider).resolve('[[$skillId]]', scenario: scenario); + final resolved = await ref + .read(escurelClientProvider) + .resolve('[[$skillId]]', scenario: scenario); if (resolved.exists && resolved.pageId.isNotEmpty) { navigateToInstance(ref, resolved.pageId); } @@ -130,7 +145,10 @@ final skillsCatalogueProvider = FutureProvider>((ref) { }); /// Instances of a given skill, keyed by skill id. -final instancesProvider = FutureProvider.family, String>((ref, skillId) { +final instancesProvider = FutureProvider.family, String>(( + ref, + skillId, +) { return ref.watch(escurelClientProvider).listInstances(skillId); }); @@ -142,7 +160,9 @@ final currentPageProvider = FutureProvider((ref) async { if (id == null) return null; final asOf = ref.watch(asOfStringProvider); final scenario = ref.watch(scenarioProvider); - final page = await ref.watch(escurelClientProvider).expand(id, asOf: asOf, scenario: scenario); + final page = await ref + .watch(escurelClientProvider) + .expand(id, asOf: asOf, scenario: scenario); // A time-cut page comes back with an empty pageId — treat it as "not // focused" so the reader falls back to its empty state. return page.pageId.isEmpty ? null : page; @@ -156,19 +176,31 @@ final currentBacklinksProvider = FutureProvider>((ref) async { final scenario = ref.watch(scenarioProvider); return ref .watch(escurelClientProvider) - .neighbours(id, direction: LinkDirection.incoming, asOf: asOf, scenario: scenario); + .neighbours( + id, + direction: LinkDirection.incoming, + asOf: asOf, + scenario: scenario, + ); }); /// Outgoing links for the current page. The server returns directionless /// edges, so backlinks vs outgoing are two separate `neighbours` calls. -final currentOutgoingLinksProvider = FutureProvider>((ref) async { +final currentOutgoingLinksProvider = FutureProvider>(( + ref, +) async { final id = ref.watch(currentPageIdProvider); if (id == null) return const []; final asOf = ref.watch(asOfStringProvider); final scenario = ref.watch(scenarioProvider); return ref .watch(escurelClientProvider) - .neighbours(id, direction: LinkDirection.outgoing, asOf: asOf, scenario: scenario); + .neighbours( + id, + direction: LinkDirection.outgoing, + asOf: asOf, + scenario: scenario, + ); }); /// The inline boot corpus is intentionally small — two skills + two diff --git a/packages/escurel_explorer_kit/test/crm/breadcrumb_trail_test.dart b/packages/escurel_explorer_kit/test/crm/breadcrumb_trail_test.dart new file mode 100644 index 0000000..bb18208 --- /dev/null +++ b/packages/escurel_explorer_kit/test/crm/breadcrumb_trail_test.dart @@ -0,0 +1,76 @@ +// No-mock test for the breadcrumb history trail. Following links builds a +// back-stack (navBackStackProvider); the breadcrumb renders that stack as +// clickable crumbs (A › B › current), and clicking an ancestor jumps focus +// back to that depth. Complements the instance-pane Back button. + +@TestOn('vm') +library; + +import 'package:escurel_explorer_kit/client/fixture_escurel_client.dart'; +import 'package:escurel_explorer_kit/crm/crm_breadcrumb.dart'; +import 'package:escurel_explorer_kit/state/providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _skill = + '---\ntype: skill\nid: talk\ndescription: A talk.\n---\n# talk\n'; +String _inst(String id) => + '---\ntype: instance\nskill: talk\nid: $id\nname: $id\n---\n# $id\n'; + +FixtureEscurelClient _client() => FixtureEscurelClient.fromSources( + skillFiles: {'talk.md': _skill}, + instanceFiles: { + 'talk__a.md': _inst('a'), + 'talk__b.md': _inst('b'), + 'talk__c.md': _inst('c'), + }, +); + +void main() { + testWidgets('breadcrumb renders the nav history as clickable crumbs', ( + tester, + ) async { + tester.view.physicalSize = const Size(1400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final container = ProviderContainer( + overrides: [escurelClientProvider.overrideWithValue(_client())], + ); + addTearDown(container.dispose); + + // Simulate having navigated a → b → c. + container.read(navBackStackProvider.notifier).state = [ + 'talk__a', + 'talk__b', + ]; + container.read(currentPageIdProvider.notifier).state = 'talk__c'; + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp( + home: Scaffold(appBar: CrmBreadcrumb(), body: SizedBox.shrink()), + ), + ), + ); + await tester.pumpAndSettle(); + + // Ancestors render as clickable crumbs; the current page as the focused crumb. + expect(find.bySemanticsLabel('crumb:a'), findsOneWidget); + expect(find.bySemanticsLabel('crumb:b'), findsOneWidget); + expect(find.bySemanticsLabel('focused-entity'), findsOneWidget); + + // Clicking the first ancestor jumps focus back to that depth. + await tester.tap(find.bySemanticsLabel('crumb:a')); + await tester.pumpAndSettle(); + expect(container.read(currentPageIdProvider), 'talk__a'); + expect( + container.read(navBackStackProvider), + isEmpty, + reason: 'jumping to depth 0 truncates the stack', + ); + }); +} From c3cce1f420df61cd88e24b8fa13271a66710ba82 Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Mon, 15 Jun 2026 18:52:50 +0200 Subject: [PATCH 3/4] feat(explorer): polling auto-refresh (no manual F5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read views were pull-once FutureProviders, so the knowledge base went stale until a manual reload. Add an AutoRefresher that invalidates the read providers on a timer (default ON, 15s) — watched views re-fetch, idle ones stay lazy. An operator toggle (sync icon in the breadcrumb) pauses/resumes; interval is configurable via autoRefreshIntervalProvider. This is the pragmatic v1; an event-driven path over Escurel's existing /ws (the client's awareness() stream) can replace the poll later without changing the provider seam. - auto_refresh.dart: autoRefreshEnabledProvider/IntervalProvider, refreshExplorerData(), AutoRefresher widget (re-arms on toggle/interval change, cancels on dispose). - Wired into CrmWorkspace; toggle in CrmBreadcrumb actions. No-mock widget test drives the fake clock with explicit pumps (never pumpAndSettle — a periodic timer never settles): one interval re-resolves a watched provider; disabling stops it. Full kit suite green (91), analyze clean. Co-Authored-By: Claude Opus 4.8 --- .../lib/crm/auto_refresh.dart | 86 ++++++++++++++++++ .../lib/crm/crm_breadcrumb.dart | 34 ++++++- .../lib/crm/crm_workspace.dart | 46 ++++++---- .../test/crm/auto_refresh_test.dart | 89 +++++++++++++++++++ 4 files changed, 238 insertions(+), 17 deletions(-) create mode 100644 packages/escurel_explorer_kit/lib/crm/auto_refresh.dart create mode 100644 packages/escurel_explorer_kit/test/crm/auto_refresh_test.dart diff --git a/packages/escurel_explorer_kit/lib/crm/auto_refresh.dart b/packages/escurel_explorer_kit/lib/crm/auto_refresh.dart new file mode 100644 index 0000000..77401bf --- /dev/null +++ b/packages/escurel_explorer_kit/lib/crm/auto_refresh.dart @@ -0,0 +1,86 @@ +/// Auto-refresh: keep the explorer's read views current without a manual +/// reload (F5). All read data is served by `FutureProvider`s, which are +/// pull-once + cache; a periodic timer invalidates them so they re-fetch. +/// +/// On by default — the operator browses a live knowledge base. A toggle + +/// interval let an operator pause it (e.g. while reading) or tune the +/// cadence. This is the pragmatic v1; an event-driven path over Escurel's +/// `/ws` (the client's `awareness()` stream) can replace the poll later. +library; + +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../state/providers.dart'; +import 'crm_providers.dart'; + +/// Whether the explorer polls for changes. Default `true`. +final autoRefreshEnabledProvider = StateProvider((ref) => true); + +/// How often to re-fetch when [autoRefreshEnabledProvider] is on. +final autoRefreshIntervalProvider = StateProvider( + (ref) => const Duration(seconds: 15), +); + +/// Invalidate the explorer's read providers so they re-fetch from the +/// backend. Pure cache-busting — the providers that something is watching +/// re-resolve; idle ones stay lazy. Safe to call repeatedly. +void refreshExplorerData(WidgetRef ref) { + ref.invalidate(allInstancesRawProvider); + ref.invalidate(skillsCatalogueProvider); + ref.invalidate(currentPageProvider); + ref.invalidate(currentBacklinksProvider); + ref.invalidate(currentOutgoingLinksProvider); + ref.invalidate(entityEventHistoryProvider); + ref.invalidate(inboxEventsProvider); + ref.invalidate(instanceSnapshotsProvider); +} + +/// Drives [refreshExplorerData] on a timer while mounted. Invisible — wraps +/// the workspace and re-arms whenever the enabled flag or interval changes. +class AutoRefresher extends ConsumerStatefulWidget { + const AutoRefresher({super.key, required this.child}); + final Widget child; + + @override + ConsumerState createState() => _AutoRefresherState(); +} + +class _AutoRefresherState extends ConsumerState { + Timer? _timer; + + void _reconfigure() { + _timer?.cancel(); + _timer = null; + if (!ref.read(autoRefreshEnabledProvider)) return; + final interval = ref.read(autoRefreshIntervalProvider); + _timer = Timer.periodic(interval, (_) { + if (mounted) refreshExplorerData(ref); + }); + } + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _reconfigure(); + }); + } + + @override + Widget build(BuildContext context) { + // Re-arm the timer whenever the operator toggles polling or changes the + // cadence. + ref.listen(autoRefreshEnabledProvider, (_, __) => _reconfigure()); + ref.listen(autoRefreshIntervalProvider, (_, __) => _reconfigure()); + return widget.child; + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } +} diff --git a/packages/escurel_explorer_kit/lib/crm/crm_breadcrumb.dart b/packages/escurel_explorer_kit/lib/crm/crm_breadcrumb.dart index f71ad74..75f1c89 100644 --- a/packages/escurel_explorer_kit/lib/crm/crm_breadcrumb.dart +++ b/packages/escurel_explorer_kit/lib/crm/crm_breadcrumb.dart @@ -8,6 +8,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../state/providers.dart'; import '../theme/app_theme.dart'; +import 'auto_refresh.dart'; import 'instances_menu.dart'; import 'scenario_switch.dart'; import 'skills_menu.dart'; @@ -94,7 +95,11 @@ class CrmBreadcrumb extends ConsumerWidget implements PreferredSizeWidget { ), ], ), - actions: const [ScenarioSwitch(), SizedBox(width: 16)], + actions: const [ + _AutoRefreshToggle(), + ScenarioSwitch(), + SizedBox(width: 16), + ], ); } @@ -163,6 +168,33 @@ class _Crumb extends StatelessWidget { ); } +/// Pause/resume the knowledge-base auto-refresh. On by default; the icon +/// reflects the live state. +class _AutoRefreshToggle extends ConsumerWidget { + const _AutoRefreshToggle(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final on = ref.watch(autoRefreshEnabledProvider); + return Semantics( + label: 'auto-refresh-toggle', + toggled: on, + button: true, + excludeSemantics: true, + child: IconButton( + tooltip: on ? 'Auto-Aktualisierung an' : 'Auto-Aktualisierung aus', + icon: Icon( + on ? Icons.sync : Icons.sync_disabled, + size: 18, + color: on ? kPrimary : kOutline, + ), + onPressed: () => + ref.read(autoRefreshEnabledProvider.notifier).state = !on, + ), + ); + } +} + class _Sep extends StatelessWidget { @override Widget build(BuildContext context) => const Padding( diff --git a/packages/escurel_explorer_kit/lib/crm/crm_workspace.dart b/packages/escurel_explorer_kit/lib/crm/crm_workspace.dart index aab2aca..460f552 100644 --- a/packages/escurel_explorer_kit/lib/crm/crm_workspace.dart +++ b/packages/escurel_explorer_kit/lib/crm/crm_workspace.dart @@ -13,6 +13,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../theme/app_theme.dart'; import '../state/providers.dart'; +import 'auto_refresh.dart'; import 'capture_bar.dart'; import 'crm_breadcrumb.dart'; import 'crm_providers.dart'; @@ -44,17 +45,21 @@ class _CrmWorkspaceState extends ConsumerState { }); } - return const Scaffold( - backgroundColor: kSurface, - appBar: CrmBreadcrumb(), - body: Column( - children: [ - WorkspaceSearchBar(), - Expanded(child: _SplitBody()), - // Time-travel lives in the instance view's STATE OVER TIME - // version markers; no separate bottom scrubber. - CaptureBar(), - ], + // AutoRefresher polls the read providers so the knowledge base stays + // current without a manual reload (default on; operator-toggleable). + return const AutoRefresher( + child: Scaffold( + backgroundColor: kSurface, + appBar: CrmBreadcrumb(), + body: Column( + children: [ + WorkspaceSearchBar(), + Expanded(child: _SplitBody()), + // Time-travel lives in the instance view's STATE OVER TIME + // version markers; no separate bottom scrubber. + CaptureBar(), + ], + ), ), ); } @@ -108,7 +113,8 @@ class _SplitBody extends ConsumerWidget { child: _CollapsibleRegion( label: 'region-events', collapsed: leftCollapsed, - onToggle: () => ref.read(leftCollapsedProvider.notifier).state = !leftCollapsed, + onToggle: () => ref.read(leftCollapsedProvider.notifier).state = + !leftCollapsed, edge: _Edge.right, child: const EventPane(), ), @@ -125,7 +131,9 @@ class _SplitBody extends ConsumerWidget { child: _CollapsibleRegion( label: 'region-instance', collapsed: rightCollapsed, - onToggle: () => ref.read(rightCollapsedProvider.notifier).state = !rightCollapsed, + onToggle: () => + ref.read(rightCollapsedProvider.notifier).state = + !rightCollapsed, edge: _Edge.left, child: const InstancePane(), ), @@ -167,7 +175,9 @@ class _CollapsibleRegion extends StatelessWidget { icon: Icon( collapsed ? (edge == _Edge.right ? Icons.chevron_right : Icons.chevron_left) - : (edge == _Edge.right ? Icons.chevron_left : Icons.chevron_right), + : (edge == _Edge.right + ? Icons.chevron_left + : Icons.chevron_right), ), ), ); @@ -199,7 +209,9 @@ class _CollapsibleRegion extends StatelessWidget { height: 28, child: Center( child: Icon( - edge == _Edge.right ? Icons.chevron_right : Icons.chevron_left, + edge == _Edge.right + ? Icons.chevron_right + : Icons.chevron_left, size: 16, color: kOnSurfaceVariant, ), @@ -221,7 +233,9 @@ class _CollapsibleRegion extends StatelessWidget { SizedBox( height: 28, child: Align( - alignment: edge == _Edge.right ? Alignment.centerRight : Alignment.centerLeft, + alignment: edge == _Edge.right + ? Alignment.centerRight + : Alignment.centerLeft, child: toggle, ), ), diff --git a/packages/escurel_explorer_kit/test/crm/auto_refresh_test.dart b/packages/escurel_explorer_kit/test/crm/auto_refresh_test.dart new file mode 100644 index 0000000..80a3671 --- /dev/null +++ b/packages/escurel_explorer_kit/test/crm/auto_refresh_test.dart @@ -0,0 +1,89 @@ +// No-mock test for polling auto-refresh: a periodic timer invalidates the +// read providers so a watched view re-fetches without a manual reload. +// Disabling the toggle stops the polling. + +@TestOn('vm') +library; + +import 'package:escurel_explorer_kit/client/fixture_escurel_client.dart'; +import 'package:escurel_explorer_kit/crm/auto_refresh.dart'; +import 'package:escurel_explorer_kit/crm/crm_providers.dart'; +import 'package:escurel_explorer_kit/state/providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _skill = + '---\ntype: skill\nid: talk\ndescription: A talk.\n---\n# talk\n'; +const _inst = '---\ntype: instance\nskill: talk\nid: a\nname: A\n---\n# A\n'; + +FixtureEscurelClient _client() => FixtureEscurelClient.fromSources( + skillFiles: {'talk.md': _skill}, + instanceFiles: {'talk__a.md': _inst}, +); + +void main() { + testWidgets('polling re-resolves read providers; disabling stops it', ( + tester, + ) async { + var dataBuilds = 0; + + final container = ProviderContainer( + overrides: [ + escurelClientProvider.overrideWithValue(_client()), + autoRefreshIntervalProvider.overrideWith( + (ref) => const Duration(milliseconds: 50), + ), + ], + ); + addTearDown(container.dispose); + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: AutoRefresher( + child: Consumer( + builder: (c, ref, _) { + final v = ref.watch(allInstancesRawProvider); + if (v.hasValue && !v.isLoading) dataBuilds++; + return const SizedBox.shrink(); + }, + ), + ), + ), + ), + ); + // NB: never pumpAndSettle — the periodic timer keeps the tree busy and + // would time it out. Drive the fake clock with explicit pumps. + await tester.pump(); // first frame: postframe arms the timer, fetch starts + await tester.pump(); // microtask: initial fetch resolves + final afterInitial = dataBuilds; + expect( + afterInitial, + greaterThanOrEqualTo(1), + reason: 'initial fetch resolved', + ); + + // One poll interval → the timer fires → providers re-resolve. + await tester.pump(const Duration(milliseconds: 60)); + await tester.pump(); // re-fetch resolves + expect( + dataBuilds, + greaterThan(afterInitial), + reason: 'polling re-fetched the data', + ); + + // Disable polling → no further re-resolves. + container.read(autoRefreshEnabledProvider.notifier).state = false; + await tester.pump(); + final afterDisable = dataBuilds; + await tester.pump(const Duration(milliseconds: 120)); + await tester.pump(); + expect( + dataBuilds, + afterDisable, + reason: 'disabled polling does not re-fetch', + ); + }); +} From f227bb73aae5edea258d79a25ab4ab5843da8471 Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Mon, 15 Jun 2026 18:53:23 +0200 Subject: [PATCH 4/4] style(explorer): use wildcard params in ref.listen callbacks (analyze clean) Co-Authored-By: Claude Opus 4.8 --- packages/escurel_explorer_kit/lib/crm/auto_refresh.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/escurel_explorer_kit/lib/crm/auto_refresh.dart b/packages/escurel_explorer_kit/lib/crm/auto_refresh.dart index 77401bf..63c0fbf 100644 --- a/packages/escurel_explorer_kit/lib/crm/auto_refresh.dart +++ b/packages/escurel_explorer_kit/lib/crm/auto_refresh.dart @@ -73,8 +73,8 @@ class _AutoRefresherState extends ConsumerState { Widget build(BuildContext context) { // Re-arm the timer whenever the operator toggles polling or changes the // cadence. - ref.listen(autoRefreshEnabledProvider, (_, __) => _reconfigure()); - ref.listen(autoRefreshIntervalProvider, (_, __) => _reconfigure()); + ref.listen(autoRefreshEnabledProvider, (_, _) => _reconfigure()); + ref.listen(autoRefreshIntervalProvider, (_, _) => _reconfigure()); return widget.child; }