patchbay_flutter 0.6.0 copy "patchbay_flutter: ^0.6.0" to clipboard
patchbay_flutter: ^0.6.0 copied to clipboard

Optional low-intrusion Flutter bridge for Patchbay: stable UI targets, Semantics observation and actions, navigation, waits and capture.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:patchbay_flutter/patchbay_flutter_host.dart';

import 'example_app.dart';
import 'example_direct_transport.dart';
import 'example_domain.dart';
import 'example_log_source.dart';

const String exampleApplicationId = 'dev.patchbay.example';
const String counterSemanticsId = 'example.counter.value';
const String incrementSemanticsId = 'example.counter.increment';
const String identifierActionSemanticsId = 'example.identifier.action';
const String noteTargetId = 'example.note';
const String cardCaptureTargetId = 'example.card.capture';
const String semanticsBenchmarkCommand = 'example.benchmark.semanticsProbe';

/// Semantics identifier of the anchored-gesture surface (press-hold / drag).
const String gestureSurfaceSemanticsId = 'example.gesture.surface';

/// Semantics identifier of the scrollable list used for fling / drag paths.
const String gestureListSemanticsId = 'example.gesture.list';

/// Semantics identifier of the deliberately covered tap probe: policy allows
/// it, but an opaque non-modal decoration sits on top, so `ui.gesture.tap`
/// must reject it with `uiGestureTargetObscured` instead of tapping through.
const String gestureCoveredSemanticsId = 'example.gesture.covered';

/// Semantics identifier of the nested horizontal scrollable list.
const String gestureNestedListSemanticsId = 'example.gesture.nested';

/// Stable destination IDs the example router exposes to `navigation.*`.
const String homeDestinationId = 'example.home';
const String detailsDestinationId = 'example.details';

/// PB-050-17: the lazy-paging screen `ui.reveal` is built for.
///
/// It sits on its own destination rather than on the home screen so the
/// existing precheck steps keep driving exactly the surfaces they always did.
const String revealDestinationId = 'example.reveal';

/// Semantics identifier anchoring the reveal list's scroll container.
///
/// The anchor wraps the `ListView`, so the scroll semantics node is its
/// descendant — that is the shape `--container` resolves, and the shape the
/// reveal policy sees as `container.identifier`.
const String revealListSemanticsId = 'example.reveal.list';

/// A row several pages down: it is not mounted until reveal drives the list.
const String revealTargetSemanticsId = 'example.reveal.row.far';

/// A row with semantics but no pointer footprint, so a successful reveal
/// reports `reachability: semanticsOnly` and the caller must select the
/// canonical `ui perform tap ... --via semantics` route.
const String revealSemanticsOnlyRowId = 'example.reveal.row.semanticsOnly';

/// Pinned bottom bar. A row that stops under it stays `obstructed`, so reveal
/// has to keep stepping instead of calling a covered row "revealed".
const String revealOverlaySemanticsId = 'example.reveal.overlay';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  final ExampleCounterModel model = ExampleCounterModel();
  final PatchbayUiRegistry registry = PatchbayUiRegistry();
  final ExampleRouter router = ExampleRouter();
  final PatchbayKey noteKey = PatchbayKey.text(
    noteTargetId,
    // `ui.text.set`/`.enter` are `sideEffect: appState` (see the doc comment
    // on `exampleWriteGate`), so they get the same write gate as every other
    // write path in this example.
    operationGates: const <PatchbayUiOperation, Set<String>>{
      PatchbayUiOperation.textSet: <String>{exampleWriteGate},
      PatchbayUiOperation.textEnter: <String>{exampleWriteGate},
    },
    registry: registry,
  );
  final PatchbayKey cardCaptureKey = PatchbayKey.capture(
    cardCaptureTargetId,
    // No `gates:` here on purpose: `ui.capture`/`.capture.diff` are declared
    // `sideEffect: none` / `mode: readOnly` by the shared protocol
    // descriptors, so this target only passes the (always-open) base gate,
    // same as `logs.*`/`blob.*`.
    registry: registry,
  );

  // Host construction is inside a compile-time false release branch. The Key
  // remains the same GlobalKey kind in every mode; only debug registrations
  // and service callbacks are removed from release reachability.
  if (!kReleaseMode) {
    final PatchbayExampleHost host = PatchbayExampleHost(
      model: model,
      registry: registry,
      router: router,
    )..register();

    // 可选的第二条面。默认不启动;只有 --dart-define=patchbay.direct=1 才绑 loopback,
    // 而且仍要操作者显式 adb forward 才能从工作站到达。
    if (ExampleDirectTransport.requested) {
      unawaited(
        ExampleDirectTransport(host: host.service).start().then((
          Object? session,
        ) {
          if (session != null) {
            host.logs.write(
              category: 'transport',
              message: 'direct plane listening',
            );
          }
        }),
      );
    }
  }

  runApp(
    PatchbayExampleApp(
      model: model,
      noteKey: noteKey,
      cardCaptureKey: cardCaptureKey,
      router: router,
    ),
  );
}

final class ExampleCounterModel extends ValueNotifier<int> {
  ExampleCounterModel() : super(0);

  void increment() => value += 1;
}

/// The only consumer adapter in this example.
///
/// It owns the domain descriptor/handler while Patchbay owns transport,
/// identity envelopes, Flutter catalog composition and UI observation.
final class PatchbayExampleHost {
  /// Resolves the single log source before construction: the artifact service
  /// and [logs] must be the same instance, or records written by the app would
  /// never appear in `logs.*`.
  ///
  /// [consumerGate] and [permissions] exist for the same reason [registrar] and
  /// [isAppResumed] do: the composition root is the only place these can be
  /// substituted, and `example_domain_gate_test.dart` has to drive this exact
  /// host with the write gate closed — the state a fresh copy of this example
  /// is in before its author authorizes anything. They default to what the App
  /// actually ships.
  factory PatchbayExampleHost({
    required ExampleCounterModel model,
    required PatchbayUiRegistry registry,
    required ExampleRouter router,
    ExampleLogSource? logs,
    String? appInstanceId,
    PatchbayExtensionRegistrar? registrar,
    bool Function()? isAppResumed,
    PatchbayConsumerGate? consumerGate,
    ExamplePermissionGateway? permissions,
  }) => PatchbayExampleHost._(
    model: model,
    registry: registry,
    router: router,
    logs: logs ?? ExampleLogSource(),
    appInstanceId: appInstanceId,
    registrar: registrar,
    isAppResumed: isAppResumed,
    consumerGate: consumerGate ?? _exampleConsumerGate,
    permissions: permissions,
  );

  PatchbayExampleHost._({
    required ExampleCounterModel model,
    required PatchbayUiRegistry registry,
    required ExampleRouter router,
    required this.logs,
    required PatchbayConsumerGate consumerGate,
    String? appInstanceId,
    PatchbayExtensionRegistrar? registrar,
    bool Function()? isAppResumed,
    ExamplePermissionGateway? permissions,
  }) : _model = model,
       _router = router,
       domain = ExampleDomain(
         counter: model,
         logs: logs,
         permissions: permissions,
       ),
       bridge = PatchbayFlutterBridge(
         gates: PatchbayGateEvaluator(
           baseGate: _allowBaseGate,
           consumerGate: consumerGate,
         ),
         registry: registry,
         isAppResumed: isAppResumed,
         semanticsActionPolicy: _semanticsActionPolicy,
         gesturePolicy: _gesturePolicy,
         revealPolicy: exampleRevealPolicy,
         inspectPolicy: const PatchbayInspectPolicy(
           gates: <String>{exampleWriteGate},
           defaultLease: Duration(minutes: 2),
           maxLease: Duration(minutes: 10),
         ),
         keepAwakeGates: const <String>{exampleWriteGate},
         keepAwakeDelegate: exampleKeepAwake.apply,
         navigationAdapter: PatchbayNavigationAdapter(
           destinations: router.destinations,
           current: router.observe,
           back: router.back,
           backGateIds: const <String>{exampleWriteGate},
         ),
         // Empty on purpose: `ui.capture`/`.capture.diff` are declared
         // `sideEffect: none` in the shared protocol descriptors (see the
         // doc comment on `exampleWriteGate`), so this example treats them
         // as read-only diagnostics, same as `logs.*`/`blob.*` below.
         captureGates: const <String>{},
         rootController: PatchbayRootController.instance,
         artifacts: _artifacts(logs, consumerGate),
       ) {
    _service = PatchbayFlutterServiceHost(
      applicationId: exampleApplicationId,
      appInstanceId: appInstanceId,
      bridge: bridge,
      registrar: registrar,
      domainCatalog: _catalog,
      snapshot: _snapshot,
      domainInvokeWithContext: _invokeWithContext,
      // 审计事件只带参数形状与门结果,不带参数值;把它写进 example 自己的日志源,
      // 于是 `logs.*` 里能看到「谁在什么门下调了什么」,而值仍然不出 App。
      auditSink: _audit,
      onAuditSinkError: _auditFailed,
    );
  }

  final ExampleCounterModel _model;
  final ExampleRouter _router;

  /// Example-authored, already-redacted records served by `logs.*`.
  final ExampleLogSource logs;

  /// Domain commands, job ledger and the simulated device controller.
  final ExampleDomain domain;
  final PatchbayFlutterBridge bridge;

  /// Blob store plus log/blob commands. Injecting it is what turns on
  /// `ui.capture`, `ui.capture.diff`, `blob.metadata` and the `logs.*` family;
  /// without it those commands stay absent from the catalog.
  static PatchbayArtifactService _artifacts(
    ExampleLogSource logs,
    PatchbayConsumerGate consumerGate,
  ) => PatchbayArtifactService(
    blobs: PatchbayMemoryBlobStore(),
    gates: PatchbayGateEvaluator(
      baseGate: _allowBaseGate,
      consumerGate: consumerGate,
    ),
    logs: logs,
    // Empty on purpose: `blob.metadata`, `blob.read`, `logs.query`,
    // `logs.export` and `logs.tail` are all declared
    // `mode: PatchbayCommandMode.readOnly` by `PatchbayArtifactService`
    // itself — they read already-recorded facts, they do not write
    // anything. Gating them behind `exampleWriteGate` would contradict
    // this example's own "read-only diagnostics open by default" story,
    // so `gateIds` stays empty and they only pass the base gate below.
    gateIds: const <String>{},
  );
  late final PatchbayFlutterServiceHost _service;

  /// The one host both transports dispatch into.
  PatchbayFlutterServiceHost get service => _service;

  String get appInstanceId => _service.appInstanceId;

  void register() => _service.register();

  Future<Map<String, Object?>> _catalog() async => <String, Object?>{
    'commands': <Object?>[
      for (final PatchbayCommandDescriptor descriptor in domain.descriptors)
        descriptor.toJson(),
      _semanticsBenchmarkDescriptor.toJson(),
    ],
  };

  Future<Map<String, Object?>> _snapshot() async => <String, Object?>{
    'source': PatchbayFactSource.appRecorded.name,
    'counter': _model.value,
    'navigation': <String, Object?>{
      'destinationId': _router.current,
      'revision': _router.revision,
    },
    'device': <String, Object?>{'value': domain.device.value},
    'keepAwake': <String, Object?>{
      'held': exampleKeepAwake.held,
      'applications': exampleKeepAwake.applications,
    },
  };

  Future<Map<String, Object?>> _invokeWithContext(
    String command,
    Map<String, Object?> arguments,
    String requestId,
    PatchbayInvocationContext context,
  ) => command == semanticsBenchmarkCommand
      ? _benchmarkSemantics(arguments, requestId)
      : domain.invokeWithContext(command, arguments, requestId, context);

  Future<Map<String, Object?>> _benchmarkSemantics(
    Map<String, Object?> arguments,
    String requestId,
  ) async {
    final Object? rawSamples = arguments['samples'];
    if (arguments.keys.any((String key) => key != 'samples') ||
        (rawSamples != null && rawSamples is! int)) {
      return _benchmarkRejected(
        requestId,
        'samples must be the only argument and must be an integer',
      );
    }
    final int sampleRuns = (rawSamples as int?) ?? 12;
    if (sampleRuns < 1 || sampleRuns > 50) {
      return _benchmarkRejected(requestId, 'samples must be between 1 and 50');
    }

    final PatchbayInvocation snapshot = await bridge.semantics.snapshot(
      maxNodes: 10000,
    );
    if (snapshot.admission != PatchbayAdmission.accepted) {
      return _benchmarkStageRejected(requestId, 'snapshot', snapshot);
    }
    final int scannedNodes =
        (snapshot.payload['nodes']! as List<Object?>).length;

    const int warmupRuns = 2;
    for (var run = 0; run < warmupRuns; run += 1) {
      await _benchmarkEndOfFrame();
      await _benchmarkMeasure(bridge.semantics.ensureOwner);
      await _benchmarkIdentifierProbe();
      final _ExampleBenchmarkSample wait = await _benchmarkWaitFrame();
      if (wait.value case final PatchbayInvocation invocation
          when invocation.admission != PatchbayAdmission.accepted) {
        return _benchmarkStageRejected(requestId, 'warmupWait', invocation);
      }
    }

    final List<_ExampleBenchmarkSample> endOfFrame =
        <_ExampleBenchmarkSample>[];
    final List<_ExampleBenchmarkSample> ensureOwner =
        <_ExampleBenchmarkSample>[];
    final List<_ExampleBenchmarkSample> probes = <_ExampleBenchmarkSample>[];
    final List<_ExampleBenchmarkSample> waitFrames =
        <_ExampleBenchmarkSample>[];
    for (var run = 0; run < sampleRuns; run += 1) {
      endOfFrame.add(await _benchmarkEndOfFrame());
      ensureOwner.add(await _benchmarkMeasure(bridge.semantics.ensureOwner));
      final _ExampleBenchmarkSample probe = await _benchmarkIdentifierProbe();
      if (probe.value == null) {
        return _benchmarkRejected(requestId, 'Semantics owner unavailable');
      }
      probes.add(probe);
      final _ExampleBenchmarkSample wait = await _benchmarkWaitFrame();
      if (wait.value case final PatchbayInvocation invocation
          when invocation.admission != PatchbayAdmission.accepted) {
        return _benchmarkStageRejected(requestId, 'uiWait', invocation);
      }
      waitFrames.add(wait);
    }

    return PatchbayInvocation.accepted(
      requestId: requestId,
      payload: <String, Object?>{
        'outcome': 'completed',
        'source': PatchbayFactSource.uiObserved.name,
        'buildMode': kProfileMode
            ? 'profile'
            : kDebugMode
            ? 'debug'
            : 'release',
        'scannedNodes': scannedNodes,
        'warmupRuns': warmupRuns,
        'sampleRuns': sampleRuns,
        'metrics': <String, Object?>{
          'endOfFrame': _exampleBenchmarkSummary(endOfFrame),
          'ensureOwner': _exampleBenchmarkSummary(ensureOwner),
          'identifierProbe': <String, Object?>{
            ..._exampleBenchmarkSummary(probes),
            'matchedNodes': 0,
          },
          'uiWaitAdditionalFrame': _exampleBenchmarkSummary(waitFrames),
        },
        'derived': <String, Object?>{
          'unsatisfiedSemanticsWaitFramesPerPoll':
              _exampleMedianFrames(probes) + _exampleMedianFrames(waitFrames),
          'scanExclusiveEstimateMedianUs':
              (_exampleMedianElapsed(probes) -
                      _exampleMedianElapsed(ensureOwner))
                  .clamp(0, 1 << 31),
        },
      },
    ).toJson();
  }

  Future<_ExampleBenchmarkSample> _benchmarkEndOfFrame() =>
      _benchmarkMeasure(() async {
        SchedulerBinding.instance.scheduleFrame();
        await SchedulerBinding.instance.endOfFrame;
        return null;
      });

  Future<_ExampleBenchmarkSample> _benchmarkIdentifierProbe() =>
      _benchmarkMeasure(
        () =>
            bridge.semantics.observeIdentifier('benchmark.missing.identifier'),
      );

  Future<_ExampleBenchmarkSample> _benchmarkWaitFrame() {
    final int before = bridge.frameRevision;
    return _benchmarkMeasure(
      () => bridge.wait.wait(
        PatchbayUiWaitRequest(
          condition: PatchbayUiWaitCondition.frameRevision,
          timeout: const Duration(seconds: 1),
          revision: before,
        ),
      ),
    );
  }

  static Future<_ExampleBenchmarkSample> _benchmarkMeasure(
    Future<Object?> Function() operation,
  ) async {
    var completed = false;
    var frames = 0;
    void countFrame(Duration _) {
      if (completed) return;
      frames += 1;
      SchedulerBinding.instance.addPostFrameCallback(countFrame);
    }

    SchedulerBinding.instance.addPostFrameCallback(countFrame);
    final Stopwatch elapsed = Stopwatch()..start();
    try {
      final Object? value = await operation();
      return _ExampleBenchmarkSample(
        value: value,
        elapsedUs: elapsed.elapsedMicroseconds,
        frames: frames,
      );
    } finally {
      completed = true;
      elapsed.stop();
    }
  }

  static Map<String, Object?> _benchmarkRejected(
    String requestId,
    String reason,
  ) => PatchbayInvocation.rejected(
    requestId: requestId,
    rejection: PatchbayRejection(
      code: 'benchmarkInvalid',
      details: <String, Object?>{'reason': reason},
    ),
  ).toJson();

  static Map<String, Object?> _benchmarkStageRejected(
    String requestId,
    String stage,
    PatchbayInvocation invocation,
  ) => PatchbayInvocation.rejected(
    requestId: requestId,
    rejection: PatchbayRejection(
      code: 'benchmarkProbeFailed',
      details: <String, Object?>{
        'stage': stage,
        if (invocation.rejection case final PatchbayRejection rejection)
          'cause': rejection.code,
      },
    ),
  ).toJson();

  void _audit(PatchbayAuditEvent event) => logs.write(
    category: 'audit',
    message: event.command,
    fields: <String, Object?>{
      'requestId': event.requestId,
      'gateResult': event.gateResult,
      'parameterShape': event.parameterShape,
      if (event.executionClassification case final String classification)
        'executionClassification': classification,
    },
  );

  // 审计写失败不能把被审计的命令一起拖失败:记一条降级说明,然后继续。
  void _auditFailed(
    Object error,
    StackTrace stackTrace,
    PatchbayAuditEvent event,
  ) => logs.write(
    category: 'audit',
    message: 'audit sink failed',
    level: PatchbayLogLevelWire.warning,
    fields: <String, Object?>{
      'command': event.command,
      'error': error.runtimeType.toString(),
    },
  );

  void dispose() => bridge.dispose();
}

const PatchbayCommandDescriptor _semanticsBenchmarkDescriptor =
    PatchbayCommandDescriptor(
      name: semanticsBenchmarkCommand,
      summary: 'Measure Semantics probe stages in the example App.',
      plane: PatchbayPlane.domain,
      mode: PatchbayCommandMode.immediate,
      sideEffect: PatchbaySideEffect.none,
      factSources: <PatchbayFactSource>{PatchbayFactSource.uiObserved},
      parameters: <PatchbayParameterDescriptor>[
        PatchbayParameterDescriptor(
          name: 'samples',
          type: PatchbayParameterType.integer,
          required: false,
          defaultValue: 12,
        ),
      ],
    );

Map<String, Object?> _exampleBenchmarkSummary(
  List<_ExampleBenchmarkSample> samples,
) => <String, Object?>{
  'medianUs': _exampleMedianElapsed(samples),
  'medianFrames': _exampleMedianFrames(samples),
  'minUs': samples
      .map((_ExampleBenchmarkSample sample) => sample.elapsedUs)
      .reduce((int left, int right) => left < right ? left : right),
  'maxUs': samples
      .map((_ExampleBenchmarkSample sample) => sample.elapsedUs)
      .reduce((int left, int right) => left > right ? left : right),
};

int _exampleMedianElapsed(List<_ExampleBenchmarkSample> samples) =>
    _exampleMedian(samples.map((sample) => sample.elapsedUs));

int _exampleMedianFrames(List<_ExampleBenchmarkSample> samples) =>
    _exampleMedian(samples.map((sample) => sample.frames));

int _exampleMedian(Iterable<int> values) {
  final List<int> sorted = values.toList()..sort();
  return sorted[sorted.length ~/ 2];
}

final class _ExampleBenchmarkSample {
  const _ExampleBenchmarkSample({
    required this.value,
    required this.elapsedUs,
    required this.frames,
  });

  final Object? value;
  final int elapsedUs;
  final int frames;
}

/// `PatchbayBaseGate` is `FutureOr<PatchbayGateDecision> Function()` — no
/// argument reaches it, for any command. It structurally cannot tell a read
/// from a write; that split only exists in *which* operations declare a
/// consumer gate at all (see `exampleWriteGate`'s doc comment). Keeping this
/// `allow()` is what "shortest integration opens read-only diagnostics by
/// default" means in practice: every read-only command this example exposes
/// (`ui.semantics.tree`, `ui.wait`, `navigation.catalog`/`.current`,
/// `ui.keepAwake.status`, `ui.inspect.status`, `ui.capture`/`.capture.diff`,
/// `blob.*`, `logs.*`, `example.permission.status`, `patchbay.job.get`/
/// `.wait`) declares zero consumer gates.
///
/// Note the asymmetry the host enforces since PB-050-25: read-only *domain*
/// commands skip this gate entirely, while a domain **write** always crosses
/// it — even one that declares no consumer gate of its own. A consumer whose
/// base gate is conditional ("reject until the controller is attached") will
/// therefore see domain writes refused inside those windows, which is the
/// point: the base gate is not optional.
FutureOr<PatchbayGateDecision> _allowBaseGate() =>
    const PatchbayGateDecision.allow();

/// Out-of-the-box behavior for this example's one write gate.
///
/// Every write path on both planes declares `exampleWriteGate` (see its doc
/// comment in `example_domain.dart`), so in a fresh copy of this example this
/// function is the entire write policy — UI operators and the six domain
/// write commands alike. The factory-safe shape is: reject with a code +
/// notice a script can act on, and require the host to opt in by name —
/// [factoryDefaultWriteGateDecision] is that reference implementation,
/// unit-tested directly in `example_consumer_test.dart`.
///
/// This example does **not** call it for `exampleWriteGate`, though — it
/// allows that one gate outright. That is a deliberate, disclosed exception,
/// not the recommended default: `tool/example_precheck.sh` drives
/// `ui.tap`/`ui.action`/`ui.gesture.*`/`navigation.go|push|back`/`ui.inspect.select`/
/// `ui.keepAwake.set`/`ui.text.set|enter` plus the domain write chain on a
/// real device and asserts they succeed (AGENTS.md "验证分两段", stage one),
/// and that precheck's pass/fail contract must not change under this task
/// (PB-050-22). Delete the `exampleWriteGate` special case below and every one
/// of those write paths goes back to the factory default (closed) immediately;
/// `example_domain_gate_test.dart` drives exactly that closed state.
FutureOr<PatchbayGateDecision> _exampleConsumerGate(String id) {
  if (id != exampleWriteGate) {
    return PatchbayGateDecision.reject(
      code: 'unknownConsumerGate',
      notice: 'No consumer gate named $id.',
    );
  }
  // 预检需要:见上面的文档注释。真正的出厂默认是下面这行——删掉本函数里
  // 对 exampleWriteGate 的特判,写路径就会立即回落到它:
  //   return factoryDefaultWriteGateDecision(id);
  return const PatchbayGateDecision.allow();
}

/// The factory-safe default for a write gate this example has not
/// authorized: reject, with a code a script can branch on and a notice that
/// says why and what to do about it.
///
/// `_exampleConsumerGate` above does not call this for `exampleWriteGate` —
/// see its doc comment for why — so this function does not fire anywhere in
/// the example as shipped. It is the reference shape a real consumer should
/// keep, exercised directly by `example_consumer_test.dart` rather than
/// through the running app.
PatchbayGateDecision factoryDefaultWriteGateDecision(String gateId) =>
    PatchbayGateDecision.reject(
      code: 'writeGateClosedByDefault',
      notice:
          'This is a factory-safe default: "$gateId" is a write gate and '
          'stays closed until the host authorizes it here.',
    );

/// Executable semantics actions are opt-in per target.
///
/// The increment button and the explicit identifier-action probe are the only
/// actionable nodes. The counter value remains a read-only live region.
PatchbaySemanticsActionDecision _semanticsActionPolicy(
  PatchbaySemanticsTarget target,
  PatchbaySemanticsAction action,
) {
  if (target.identifier == incrementSemanticsId &&
      action == PatchbaySemanticsAction.tap) {
    return const PatchbaySemanticsActionDecision.allow(
      gateIds: <String>{exampleWriteGate},
    );
  }
  if (target.identifier == identifierActionSemanticsId &&
      const <PatchbaySemanticsAction>{
        PatchbaySemanticsAction.focus,
        PatchbaySemanticsAction.scrollDown,
        PatchbaySemanticsAction.setText,
      }.contains(action)) {
    return const PatchbaySemanticsActionDecision.allow(
      gateIds: <String>{exampleWriteGate},
    );
  }
  // PB-050-17: both rows `ui.reveal` can drive to reveal also opt in to a
  // Semantics tap. `reachability: pointer` is a geometry fact, not gesture
  // authorization: the example keeps pointer gestures scoped to the dedicated
  // surfaces below, so the device precheck explicitly selects the canonical
  // Semantics route after reveal. Both rows land on the same `tapIdentifier`
  // seam and therefore the same policy check.
  if ((target.identifier == revealTargetSemanticsId ||
          target.identifier == revealSemanticsOnlyRowId) &&
      action == PatchbaySemanticsAction.tap) {
    return const PatchbaySemanticsActionDecision.allow(
      gateIds: <String>{exampleWriteGate},
    );
  }
  return const PatchbaySemanticsActionDecision.reject(
    rejectionNotice: 'This example does not allow that semantics action.',
  );
}

/// Anchored gestures are allowed on the dedicated surfaces built for them, with
/// budgets small enough that a runaway path is rejected rather than replayed.
PatchbayGestureDecision _gesturePolicy(
  PatchbayGestureTarget target,
  PatchbayGestureKind gesture,
) {
  const Set<String> surfaces = <String>{
    gestureSurfaceSemanticsId,
    gestureListSemanticsId,
    gestureNestedListSemanticsId,
    gestureCoveredSemanticsId,
  };
  if (!surfaces.contains(target.identifier)) {
    return const PatchbayGestureDecision.reject(
      rejectionNotice: 'This example only opens its gesture surfaces.',
    );
  }
  if (gesture == PatchbayGestureKind.fling &&
      target.identifier == gestureSurfaceSemanticsId) {
    return const PatchbayGestureDecision.reject(
      rejectionNotice: 'The press-hold surface does not accept a fling.',
    );
  }
  // 预算只能相对协议上限**收紧**:`maxVelocity` 的单位是「目标宽/高每秒」,上限 20,
  // 不是设备像素每秒。声明一个越界的预算不会被当成"放宽",而是让这条 decision 整体非法——
  // 于是该表面上的每一次手势都按 `uiGestureBudgetExceeded` 拒绝,连不带速度的 press-hold
  // 也一起被拒。这里写 8 就是 20 以内的收紧值。
  return const PatchbayGestureDecision.allow(
    gateIds: <String>{exampleWriteGate},
    maxDurationMs: 4000,
    maxPathPoints: 32,
    maxVelocity: 8,
  );
}

/// PB-050-17: driving a scroll container is authorized per container, and the
/// authorization is re-asked before every single step.
///
/// Two things this example deliberately shows:
///
/// - **Only the reveal list is open.** `container.identifier` is the innermost
///   anchor identifier of the container being driven, so any other scrollable
///   on screen — including the gesture list on the home destination — is
///   refused outright. `ui.reveal` is a write, so a fresh copy of this example
///   opens nothing it did not name here.
/// - **Budgets only ever tighten.** 60 steps / 20 s are well inside the host
///   ceilings (200 / 120 s). A request asking for more is *rejected*, not
///   silently clamped, because a clamped `stepBudgetExceeded` reads exactly
///   like "the list really is that long".
///
/// The declared gate is the same `exampleWriteGate` every other write path
/// crosses, and reveal re-evaluates it once per step. A consumer whose gate is
/// interactive should read the note on that gate before wiring one here: a
/// 40-step reveal would ask 41 times.
PatchbayRevealDecision exampleRevealPolicy(
  PatchbaySemanticsTarget container,
  PatchbayRevealDirection direction,
) {
  if (container.identifier != revealListSemanticsId) {
    return const PatchbayRevealDecision.reject(
      rejectionNotice: 'This example only opens its reveal list.',
    );
  }
  return const PatchbayRevealDecision.allow(
    gateIds: <String>{exampleWriteGate},
    maxSteps: 60,
    maxDurationMs: 20000,
  );
}

/// Records what the host asked the platform to do, without pretending the
/// screen is actually awake.
///
/// A real consumer injects the one platform line here (Android
/// `FLAG_KEEP_SCREEN_ON`, iOS `isIdleTimerDisabled`). The example has no
/// tracked platform directory, so it can only account for the request. The
/// protocol already says `source: appRecorded` — it never claims the screen
/// stayed on — so this stub exercises the accounting and lease paths honestly.
final ExampleKeepAwakeRecorder exampleKeepAwake = ExampleKeepAwakeRecorder();

final class ExampleKeepAwakeRecorder {
  bool held = false;
  int applications = 0;

  void apply(bool enabled) {
    held = enabled;
    applications += 1;
  }
}

/// Two destinations over a real `Navigator`, with a monotonic revision.
final class ExampleRouter {
  final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

  int _revision = 0;
  String _current = homeDestinationId;

  int get revision => _revision;
  String get current => _current;

  PatchbayNavigationObservation observe() => PatchbayNavigationObservation(
    revision: _revision,
    destinationId: _current,
  );

  List<PatchbayNavigationDestination> destinations() =>
      <PatchbayNavigationDestination>[
        PatchbayNavigationDestination(
          id: homeDestinationId,
          summary: 'Counter, note field and gesture surfaces.',
          gateIds: const <String>{exampleWriteGate},
          go: () => _go(homeDestinationId),
        ),
        PatchbayNavigationDestination(
          id: detailsDestinationId,
          summary: 'A second screen used by walkthrough verification.',
          gateIds: const <String>{exampleWriteGate},
          go: () => _go(detailsDestinationId),
          push: () => _push(detailsDestinationId),
        ),
        PatchbayNavigationDestination(
          id: revealDestinationId,
          summary:
              'A lazy-paging list under a pinned bar, for scroll-to-reveal.',
          gateIds: const <String>{exampleWriteGate},
          go: () => _go(revealDestinationId),
          push: () => _push(revealDestinationId),
        ),
      ];

  Future<void> back() async {
    final NavigatorState? navigator = navigatorKey.currentState;
    if (navigator == null || !navigator.canPop()) return;
    navigator.pop();
    _land(homeDestinationId);
  }

  // pushNamed / pushNamedAndRemoveUntil 返回的 Future 要等那条路由被 pop 才完成。
  // await 它等于把一次导航请求挂到用户按返回为止:宿主不回答,CLI 只看到
  // appUnresponsive。所以只发起导航并立即记账,不等待路由结果。
  Future<void> _go(String destination) async {
    final NavigatorState? navigator = navigatorKey.currentState;
    if (navigator == null) return;
    unawaited(
      navigator.pushNamedAndRemoveUntil(
        destination,
        (Route<Object?> route) => false,
      ),
    );
    _land(destination);
  }

  Future<void> _push(String destination) async {
    final NavigatorState? navigator = navigatorKey.currentState;
    if (navigator == null) return;
    unawaited(navigator.pushNamed(destination));
    _land(destination);
  }

  void _land(String destination) {
    _current = destination;
    _revision += 1;
  }
}
0
likes
0
points
436
downloads

Publisher

unverified uploader

Weekly Downloads

Optional low-intrusion Flutter bridge for Patchbay: stable UI targets, Semantics observation and actions, navigation, waits and capture.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

flutter, patchbay

More

Packages that depend on patchbay_flutter