patchbay_flutter 0.4.1
patchbay_flutter: ^0.4.1 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:patchbay_flutter/patchbay_flutter.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 noteTargetId = 'example.note';
const String cardCaptureTargetId = 'example.card.capture';
/// 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 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';
/// The single consumer gate this example declares. Everything the host may
/// execute passes it, so an unknown gate ID stays a rejection rather than a
/// silently allowed write.
const String exampleWriteGate = 'example.uiWrite';
void main() {
WidgetsFlutterBinding.ensureInitialized();
final ExampleCounterModel model = ExampleCounterModel();
final PatchbayUiRegistry registry = PatchbayUiRegistry();
final ExampleRouter router = ExampleRouter();
final PatchbayKey noteKey = PatchbayKey.text(
noteTargetId,
registry: registry,
);
final PatchbayKey cardCaptureKey = PatchbayKey.capture(
cardCaptureTargetId,
gates: const <String>{exampleWriteGate},
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.*`.
factory PatchbayExampleHost({
required ExampleCounterModel model,
required PatchbayUiRegistry registry,
required ExampleRouter router,
ExampleLogSource? logs,
String? appInstanceId,
PatchbayExtensionRegistrar? registrar,
bool Function()? isAppResumed,
}) => PatchbayExampleHost._(
model: model,
registry: registry,
router: router,
logs: logs ?? ExampleLogSource(),
appInstanceId: appInstanceId,
registrar: registrar,
isAppResumed: isAppResumed,
);
PatchbayExampleHost._({
required ExampleCounterModel model,
required PatchbayUiRegistry registry,
required ExampleRouter router,
required this.logs,
String? appInstanceId,
PatchbayExtensionRegistrar? registrar,
bool Function()? isAppResumed,
}) : _model = model,
_router = router,
domain = ExampleDomain(counter: model, logs: logs),
bridge = PatchbayFlutterBridge(
gates: const PatchbayGateEvaluator(
baseGate: _allowBaseGate,
consumerGate: _exampleConsumerGate,
),
registry: registry,
isAppResumed: isAppResumed,
semanticsActionPolicy: _semanticsActionPolicy,
gesturePolicy: _gesturePolicy,
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},
),
captureGates: const <String>{exampleWriteGate},
rootController: PatchbayRootController.instance,
artifacts: _artifacts(logs),
) {
_service = PatchbayFlutterServiceHost(
applicationId: exampleApplicationId,
appInstanceId: appInstanceId,
bridge: bridge,
registrar: registrar,
domainCatalog: _catalog,
snapshot: _snapshot,
domainInvoke: _invoke,
// 审计事件只带参数形状与门结果,不带参数值;把它写进 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) =>
PatchbayArtifactService(
blobs: PatchbayMemoryBlobStore(),
gates: const PatchbayGateEvaluator(
baseGate: _allowBaseGate,
consumerGate: _exampleConsumerGate,
),
logs: logs,
gateIds: const <String>{exampleWriteGate},
);
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(),
],
};
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?>> _invoke(
String command,
Map<String, Object?> arguments,
String requestId,
) => domain.invoke(command, arguments, requestId);
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();
}
FutureOr<PatchbayGateDecision> _allowBaseGate() =>
const PatchbayGateDecision.allow();
/// Only the one gate this example declares is allowed; anything else stays a
/// typed rejection so a policy that names an unknown gate cannot write.
FutureOr<PatchbayGateDecision> _exampleConsumerGate(String id) =>
id == exampleWriteGate
? const PatchbayGateDecision.allow()
: PatchbayGateDecision.reject(
code: 'unknownConsumerGate',
notice: 'No consumer gate named $id.',
);
/// Executable semantics actions are opt-in per target.
///
/// The increment button is the only actionable node. The counter value is a
/// read-only live region: allowing a tap there would let a caller "press" a
/// label and read the result as a domain effect.
PatchbaySemanticsActionDecision _semanticsActionPolicy(
PatchbaySemanticsTarget target,
PatchbaySemanticsAction action,
) {
if (target.identifier == incrementSemanticsId &&
action == PatchbaySemanticsAction.tap) {
return const PatchbaySemanticsActionDecision.allow(
gateIds: <String>{exampleWriteGate},
);
}
return const PatchbaySemanticsActionDecision.reject(
rejectionNotice: 'Only the increment button accepts semantics actions.',
);
}
/// Anchored gestures are allowed on the two 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,
};
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,
);
}
/// 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),
),
];
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;
}
}
final class PatchbayExampleApp extends StatefulWidget {
const PatchbayExampleApp({
required this.model,
required this.noteKey,
required this.cardCaptureKey,
required this.router,
super.key,
});
final ExampleCounterModel model;
final PatchbayKey noteKey;
final PatchbayKey cardCaptureKey;
final ExampleRouter router;
@override
State<PatchbayExampleApp> createState() => _PatchbayExampleAppState();
}
final class _PatchbayExampleAppState extends State<PatchbayExampleApp> {
final TextEditingController _noteController = TextEditingController();
@override
void dispose() {
_noteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => PatchbayRoot(
child: MaterialApp(
navigatorKey: widget.router.navigatorKey,
initialRoute: homeDestinationId,
routes: <String, WidgetBuilder>{
homeDestinationId: (BuildContext context) => _ExampleHomeScreen(
model: widget.model,
noteKey: widget.noteKey,
cardCaptureKey: widget.cardCaptureKey,
noteController: _noteController,
),
detailsDestinationId: (BuildContext context) =>
const _ExampleDetailsScreen(),
},
),
);
}
final class _ExampleHomeScreen extends StatelessWidget {
const _ExampleHomeScreen({
required this.model,
required this.noteKey,
required this.cardCaptureKey,
required this.noteController,
});
final ExampleCounterModel model;
final PatchbayKey noteKey;
final PatchbayKey cardCaptureKey;
final TextEditingController noteController;
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Patchbay example')),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
ValueListenableBuilder<int>(
valueListenable: model,
builder: (BuildContext context, int count, Widget? child) =>
Semantics(
identifier: counterSemanticsId,
label: 'Counter value',
value: '$count',
liveRegion: true,
child: Text('Count: $count'),
),
),
const SizedBox(height: 16),
// identifier 与 tap 动作必须落在同一个语义节点上,而且该 identifier 只能命中
// 一个节点:
// - 只包一层 Semantics(identifier:) 时,按钮自己的节点才带 tap 动作,
// identifier 命中的那个节点 actions 为空 → uiSemanticsActionUnavailable;
// - 用 MergeSemantics 合并时,identifier 会同时出现在合并节点和子节点上,
// 活体清单核对报 manifestSemanticsIdentifierAmbiguous(matchCount 2)。
// 所以由外层节点自己声明动作,并排除子树语义。两条路径都是真机预检发现的。
Semantics(
identifier: incrementSemanticsId,
button: true,
onTap: model.increment,
child: ExcludeSemantics(
child: ElevatedButton(
onPressed: model.increment,
child: const Text('Increment'),
),
),
),
const SizedBox(height: 16),
TextField(
key: noteKey,
controller: noteController,
decoration: const InputDecoration(labelText: 'Debug note'),
),
const SizedBox(height: 16),
RepaintBoundary(
key: cardCaptureKey,
child: const _ExampleGestureSurface(),
),
const SizedBox(height: 16),
const Expanded(child: _ExampleGestureList()),
],
),
),
);
}
/// Press-hold / drag target. It reports what it observed so a CLI-driven
/// gesture can be verified from the App side instead of from a screenshot.
final class _ExampleGestureSurface extends StatefulWidget {
const _ExampleGestureSurface();
@override
State<_ExampleGestureSurface> createState() => _ExampleGestureSurfaceState();
}
final class _ExampleGestureSurfaceState extends State<_ExampleGestureSurface> {
String _observed = 'none';
@override
Widget build(BuildContext context) => Semantics(
identifier: gestureSurfaceSemanticsId,
label: 'Gesture surface',
value: _observed,
child: GestureDetector(
onLongPress: () => setState(() => _observed = 'longPress'),
onPanUpdate: (DragUpdateDetails details) =>
setState(() => _observed = 'pan'),
child: Container(
height: 96,
alignment: Alignment.center,
color: Theme.of(context).colorScheme.secondaryContainer,
child: Text('gesture surface: $_observed'),
),
),
);
}
/// Scrollable list for fling and multi-segment drag paths, including a nested
/// horizontal scrollable for nested gesture isolation testing.
final class _ExampleGestureList extends StatelessWidget {
const _ExampleGestureList();
@override
Widget build(BuildContext context) => Semantics(
identifier: gestureListSemanticsId,
label: 'Gesture list',
child: ListView.builder(
itemCount: 60,
itemBuilder: (BuildContext context, int index) {
if (index == 2) {
return SizedBox(
height: 96,
child: Semantics(
identifier: gestureNestedListSemanticsId,
container: true,
label: 'Nested horizontal list',
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 20,
itemBuilder: (BuildContext context, int hIndex) => Container(
width: 96,
margin: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text('item $hIndex'),
),
),
),
);
}
return ListTile(dense: true, title: Text('row $index'));
},
),
);
}
final class _ExampleDetailsScreen extends StatelessWidget {
const _ExampleDetailsScreen();
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Details')),
body: Center(
child: Semantics(
identifier: 'example.details.body',
child: Text('Second destination'),
),
),
);
}