Captures page: the recording checklist with one start/end button
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 10s
CI / Debug APK (push) Successful in 24s

Steps come from a bundled copy of contracts checklists/ (assets/contracts/,
refreshed by tool/sync-contracts.sh), not from Dart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
This commit is contained in:
2026-10-13 11:12:44 +09:00
parent 4a32a801f0
commit bf8a99fb23
7 changed files with 793 additions and 4 deletions
+1
View File
@@ -5,6 +5,7 @@ on:
branches: [ master ]
paths:
- 'lib/**'
- 'assets/**'
- 'test/**'
- 'android/**'
- 'pubspec.yaml'
@@ -0,0 +1,226 @@
{
"id": "z27ag-captures",
"version": 1,
"title": "Z27AG decoding captures",
"about": "docs/impl/plans/Z27AG-CAPTURES.md",
"mark_format": "<step> <action>[ #<n>] start|end for a span, <step> <action>[ #<n>] for a point",
"sections": [
{
"title": "Parked, ignition on, engine off",
"steps": [
{
"id": "1",
"title": "Baseline",
"do": "Touch nothing.",
"finds": "the resting value of everything",
"actions": [{ "label": "baseline", "hold_s": 20 }]
},
{
"id": "2",
"title": "Accelerator",
"do": "Slowly to the floor over 5 s, hold 3 s, slowly back. Start as the foot moves, end when it is off.",
"finds": "pedal position, with no rpm to confuse it",
"actions": [{ "label": "accel" }]
},
{
"id": "3",
"title": "Brake",
"do": "Start as you press, hold 3 s, end as you release.",
"finds": "brake switch, maybe pressure",
"actions": [{ "label": "brake", "hold_s": 3, "repeat": 3 }]
},
{
"id": "4",
"title": "Clutch",
"do": "Start as you press, hold 3 s, end as you release.",
"finds": "clutch switch",
"actions": [{ "label": "clutch", "hold_s": 3, "repeat": 3 }]
},
{
"id": "5",
"title": "Steering",
"do": "Centre, full left, full right, back to centre, slowly.",
"finds": "steering angle",
"actions": [{ "label": "steer" }]
},
{
"id": "6",
"title": "Gears",
"do": "Start when the gear is in, end as you take it out. Clutch down throughout.",
"finds": "reverse (likely as the reverse light); no other gearbox signal expected",
"actions": [
{ "label": "gear 1", "hold_s": 3 },
{ "label": "gear 2", "hold_s": 3 },
{ "label": "gear 3", "hold_s": 3 },
{ "label": "gear 4", "hold_s": 3 },
{ "label": "gear 5", "hold_s": 3 },
{ "label": "gear R", "hold_s": 3 },
{ "label": "neutral", "hold_s": 3 }
]
},
{
"id": "7",
"title": "Handbrake",
"do": "Start as you pull it up, end as you release it.",
"finds": "handbrake switch",
"actions": [{ "label": "handbrake", "hold_s": 3, "repeat": 2 }]
},
{
"id": "8",
"title": "Indicators",
"do": "Start when switched on, end when off.",
"finds": "indicator states",
"actions": [
{ "label": "left", "hold_s": 5 },
{ "label": "right", "hold_s": 5 },
{ "label": "hazard", "hold_s": 5 }
]
},
{
"id": "9",
"title": "Lights",
"do": "Start when switched on, end when off.",
"finds": "light states",
"actions": [
{ "label": "side lights", "hold_s": 3 },
{ "label": "dipped", "hold_s": 3 },
{ "label": "main beam", "hold_s": 3 },
{ "label": "flash", "point": true },
{ "label": "front fogs", "hold_s": 3 },
{ "label": "rear fog", "hold_s": 3 }
]
},
{
"id": "10",
"title": "Doors",
"do": "Start as it opens, end when it is shut. Skip what the car does not have.",
"finds": "door switches",
"actions": [
{ "label": "driver door" },
{ "label": "passenger door" },
{ "label": "rear left door" },
{ "label": "rear right door" },
{ "label": "boot" },
{ "label": "bonnet" }
]
},
{
"id": "11",
"title": "Wipers",
"do": "Start when switched on, end when off.",
"finds": "wiper states",
"actions": [
{ "label": "wipers int", "hold_s": 5 },
{ "label": "wipers low", "hold_s": 5 },
{ "label": "wipers high", "hold_s": 5 },
{ "label": "washer" }
]
},
{
"id": "12",
"title": "A/C and blower",
"do": "A/C: start on, end off. Blower: start as you turn it up, end when it is back down.",
"finds": "A/C request, blower",
"actions": [
{ "label": "ac", "hold_s": 5 },
{ "label": "blower" }
]
},
{
"id": "13",
"title": "Seatbelt",
"do": "Start as it clicks in, end as it comes out.",
"finds": "belt switch",
"actions": [{ "label": "belt", "hold_s": 3 }]
}
]
},
{
"title": "Engine running, parked",
"steps": [
{
"id": "14",
"title": "Engine start",
"do": "Cold start if possible: mark as the starter turns, then idle 60 s.",
"finds": "starting, coolant and oil warming, idle control",
"actions": [
{ "label": "engine start", "point": true },
{ "label": "idle", "hold_s": 60 }
]
},
{
"id": "15",
"title": "Revs",
"do": "Start when the revs are steady at the target, end as you leave it.",
"finds": "engine speed against OBD rpm, throttle against pedal",
"actions": [
{ "label": "rpm 1500", "hold_s": 5 },
{ "label": "rpm 2000", "hold_s": 5 },
{ "label": "rpm 3000", "hold_s": 5 }
]
},
{
"id": "16",
"title": "Blips",
"do": "Three sharp blips of the throttle.",
"finds": "response timing of throttle, rpm, pressure",
"actions": [{ "label": "blips" }]
},
{
"id": "17",
"title": "A/C under load",
"do": "A/C on for 20 s, then off.",
"finds": "compressor clutch, idle-up",
"actions": [{ "label": "ac", "hold_s": 20 }]
},
{
"id": "18",
"title": "Radiator fan",
"do": "Idle until the fan cuts in, if it will; mark when you hear it.",
"finds": "fan state, coolant threshold",
"actions": [{ "label": "fan", "point": true }]
}
]
},
{
"title": "Driving (a passenger presses, or skip the marks)",
"steps": [
{
"id": "19",
"title": "Steady speeds",
"do": "Where safe, hold each speed 10 s.",
"finds": "road and wheel speeds against OBD and GNSS speed",
"actions": [
{ "label": "steady 20", "hold_s": 10 },
{ "label": "steady 40", "hold_s": 10 },
{ "label": "steady 60", "hold_s": 10 }
]
},
{
"id": "20",
"title": "Stops",
"do": "Start as you brake, end when stopped.",
"finds": "brake pressure, deceleration",
"actions": [
{ "label": "gentle stop" },
{ "label": "firm stop" }
]
},
{
"id": "21",
"title": "Corners",
"do": "A few corners each way.",
"finds": "yaw, lateral g, wheel speed differences",
"actions": [{ "label": "corners" }]
},
{
"id": "22",
"title": "Through the box",
"do": "Each gear in turn.",
"finds": "gear from rpm / speed",
"actions": [{ "label": "gears" }]
}
]
}
]
}
+462
View File
@@ -0,0 +1,462 @@
// Capture checklists: step through a recording plan with one button per action.
//
// The steps are data, from contracts `checklists/` (bundled as an asset under
// assets/contracts/, an exact copy; tool/sync-contracts.sh refreshes it). Nothing here
// knows what a step is about: it only turns presses into marks.
//
// Mark text, as the analysis reads it back from events.ndjson:
// <step> <action>[ #<n>] start | end a span (the one button, pressed twice)
// <step> <action>[ #<n>] a point
// <step> <action>[ #<n>] abort a span left open (skip, redo, another step)
// <step> skip | <step> redo the step was skipped / is being done again
// checklist <id> v<version> once, before this page's first mark
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'boundary.dart';
const checklistAssets = 'assets/contracts/checklists/';
class ChecklistAction {
ChecklistAction(this.label, {this.holdS, this.repeat = 1, this.point = false});
final String label;
/// Suggested length of the span, shown as a target while it runs.
final int? holdS;
final int repeat;
final bool point;
factory ChecklistAction.fromJson(Map<String, dynamic> j) => ChecklistAction(
j['label'] as String,
holdS: j['hold_s'] as int?,
repeat: j['repeat'] as int? ?? 1,
point: j['point'] == true,
);
}
class ChecklistStep {
ChecklistStep(this.id, this.title, this.doText, this.finds, this.actions, this.section);
final String id;
final String title;
final String doText;
final String finds;
final List<ChecklistAction> actions;
final String section;
}
class Checklist {
Checklist(this.id, this.version, this.title, this.steps);
final String id;
final int version;
final String title;
final List<ChecklistStep> steps;
factory Checklist.fromJson(Map<String, dynamic> j) {
final steps = <ChecklistStep>[];
for (final s in j['sections'] as List) {
final section = (s as Map<String, dynamic>)['title'] as String;
for (final st in s['steps'] as List) {
final m = st as Map<String, dynamic>;
steps.add(ChecklistStep(
m['id'] as String,
m['title'] as String,
m['do'] as String? ?? '',
m['finds'] as String? ?? '',
[for (final a in m['actions'] as List) ChecklistAction.fromJson(a as Map<String, dynamic>)],
section,
));
}
}
return Checklist(j['id'] as String, j['version'] as int, j['title'] as String, steps);
}
}
/// Where the one button is in a checklist, and the mark each press sends. No I/O:
/// every method returns the marks to send, in order.
class ChecklistRun {
ChecklistRun(this.checklist);
final Checklist checklist;
int step = 0;
int action = 0;
int rep = 0;
DateTime? spanStarted;
bool _announced = false;
ChecklistStep get current => checklist.steps[step];
bool get inSpan => spanStarted != null;
bool get stepDone => action >= current.actions.length;
bool get finished => step >= checklist.steps.length - 1 && stepDone;
ChecklistAction? get currentAction => stepDone ? null : current.actions[action];
/// "brake #2" when the action repeats, "brake" when it does not.
String get actionName {
final a = currentAction!;
return a.repeat > 1 ? '${a.label} #${rep + 1}' : a.label;
}
/// What the button will do next, for its label.
String get nextLabel {
if (stepDone) return finished ? 'All done' : 'Next step';
final a = currentAction!;
if (a.point) return 'Mark $actionName';
return inSpan ? 'End $actionName' : 'Start $actionName';
}
List<String> _announce() {
if (_announced) return const [];
_announced = true;
return ['checklist ${checklist.id} v${checklist.version}'];
}
List<String> press([DateTime? now]) {
if (stepDone) {
if (!finished) _goto(step + 1);
return const [];
}
final out = _announce();
final a = currentAction!;
final name = '${current.id} $actionName';
if (a.point) {
_advance();
return [...out, name];
}
if (!inSpan) {
spanStarted = now ?? DateTime.now();
return [...out, '$name start'];
}
spanStarted = null;
_advance();
return [...out, '$name end'];
}
void _advance() {
if (rep + 1 < currentAction!.repeat) {
rep++;
} else {
rep = 0;
action++;
}
}
List<String> _leave() => inSpan ? [..._announce(), '${current.id} $actionName abort'] : const [];
List<String> skip() {
final out = [..._leave(), ..._announce(), '${current.id} skip'];
if (step < checklist.steps.length - 1) {
_goto(step + 1);
} else {
_goto(step);
action = current.actions.length;
}
return out;
}
List<String> redo() {
final out = [..._leave(), ..._announce(), '${current.id} redo'];
_goto(step);
return out;
}
List<String> select(int i) {
if (i == step) return const [];
final out = _leave();
_goto(i);
return out;
}
void _goto(int i) {
step = i;
action = 0;
rep = 0;
spanStarted = null;
}
}
Future<List<Checklist>> loadChecklists() async {
final manifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final paths = manifest.listAssets().where((p) => p.startsWith(checklistAssets) && p.endsWith('.json')).toList()..sort();
return [
for (final p in paths) Checklist.fromJson(jsonDecode(await rootBundle.loadString(p)) as Map<String, dynamic>),
];
}
/// Lists the bundled checklists; opens the only one directly.
class ChecklistsPage extends StatelessWidget {
const ChecklistsPage({super.key, required this.boundary});
final Boundary boundary;
@override
Widget build(BuildContext context) {
return FutureBuilder<List<Checklist>>(
future: loadChecklists(),
builder: (context, snap) {
if (snap.hasError) {
return Scaffold(appBar: AppBar(title: const Text('Captures')), body: Center(child: Text('${snap.error}')));
}
final lists = snap.data;
if (lists == null) return const Scaffold(body: Center(child: CircularProgressIndicator()));
if (lists.length == 1) return ChecklistPage(boundary: boundary, checklist: lists.single);
return Scaffold(
appBar: AppBar(title: const Text('Captures')),
body: ListView(
children: [
if (lists.isEmpty) const ListTile(title: Text('No checklists in this build')),
for (final c in lists)
ListTile(
title: Text(c.title),
subtitle: Text('${c.id} v${c.version} · ${c.steps.length} steps'),
onTap: () => Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => ChecklistPage(boundary: boundary, checklist: c)),
),
),
],
),
);
},
);
}
}
class _Sent {
_Sent(this.id, this.text);
final String id;
final String text;
}
class ChecklistPage extends StatefulWidget {
const ChecklistPage({super.key, required this.boundary, required this.checklist});
final Boundary boundary;
final Checklist checklist;
@override
State<ChecklistPage> createState() => _ChecklistPageState();
}
class _ChecklistPageState extends State<ChecklistPage> {
late final run = ChecklistRun(widget.checklist);
final sent = <_Sent>[];
Timer? _tick;
@override
void initState() {
super.initState();
widget.boundary.addListener(_changed);
}
@override
void dispose() {
_tick?.cancel();
widget.boundary.removeListener(_changed);
super.dispose();
}
void _changed() => setState(() {});
void _send(List<String> marks) {
for (final m in marks) {
sent.insert(0, _Sent(widget.boundary.command('mark', {'text': m}), m));
}
if (sent.length > 20) sent.removeRange(20, sent.length);
// The span timer only needs to tick while a span is open.
_tick?.cancel();
if (run.inSpan) _tick = Timer.periodic(const Duration(milliseconds: 100), (_) => setState(() {}));
setState(() {});
}
@override
Widget build(BuildContext context) {
final steps = StepList(run: run, onSelect: (i) => _send(run.select(i)));
return Scaffold(
appBar: AppBar(
title: Text(widget.checklist.title),
actions: [
Builder(
builder: (context) => MediaQuery.of(context).size.width > 800
? const SizedBox.shrink()
: IconButton(
icon: const Icon(Icons.list),
tooltip: 'Steps',
onPressed: () => Scaffold.of(context).openEndDrawer(),
),
),
],
),
endDrawer: MediaQuery.of(context).size.width > 800 ? null : Drawer(child: SafeArea(child: steps)),
body: SafeArea(
child: LayoutBuilder(
builder: (context, c) {
final panel = _panel(context);
if (c.maxWidth <= 800) return panel;
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(width: 300, child: steps),
const VerticalDivider(width: 1),
Expanded(child: panel),
],
);
},
),
),
);
}
Widget _panel(BuildContext context) {
final s = run.current;
final theme = Theme.of(context);
final a = run.currentAction;
final (Color color, IconData icon) = run.stepDone
? (Colors.blueGrey, Icons.skip_next)
: a!.point
? (Colors.blue, Icons.flag)
: run.inSpan
? (Colors.red.shade700, Icons.stop)
: (Colors.green.shade700, Icons.play_arrow);
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(s.section, style: theme.textTheme.labelLarge?.copyWith(color: theme.colorScheme.outline)),
Text('${s.id}. ${s.title}', style: theme.textTheme.headlineMedium),
const SizedBox(height: 8),
Text(s.doText, style: theme.textTheme.titleLarge),
if (s.finds.isNotEmpty)
Text('Finds: ${s.finds}', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.outline)),
const SizedBox(height: 8),
Wrap(
spacing: 6,
runSpacing: 6,
children: [for (var i = 0; i < s.actions.length; i++) _actionChip(s, i)],
),
const SizedBox(height: 12),
Expanded(
child: FilledButton(
style: FilledButton.styleFrom(
backgroundColor: color,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
onPressed: run.finished ? null : () => _send(run.press()),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 72),
Text(run.nextLabel, textAlign: TextAlign.center, style: const TextStyle(fontSize: 40, fontWeight: FontWeight.w700)),
if (run.inSpan) _spanTimer(a!),
if (!run.inSpan && a != null && a.holdS != null && !a.point)
Text('hold about ${a.holdS} s', style: const TextStyle(fontSize: 22)),
],
),
),
),
const SizedBox(height: 12),
Row(
children: [
OutlinedButton.icon(onPressed: () => _send(run.redo()), icon: const Icon(Icons.replay), label: const Text('Redo step')),
const SizedBox(width: 12),
OutlinedButton.icon(
onPressed: run.finished ? null : () => _send(run.skip()),
icon: const Icon(Icons.redo),
label: const Text('Skip step'),
),
const SizedBox(width: 16),
Expanded(child: _lastMark(context)),
],
),
],
),
);
}
Widget _actionChip(ChecklistStep s, int i) {
final a = s.actions[i];
final done = i < run.action;
final now = i == run.action;
final reps = a.repeat > 1 ? ' ×${a.repeat}' : '';
return Chip(
avatar: Icon(done ? Icons.check : (a.point ? Icons.flag_outlined : Icons.timelapse), size: 18),
label: Text('${a.label}$reps'),
backgroundColor: now ? Theme.of(context).colorScheme.primaryContainer : null,
);
}
Widget _spanTimer(ChecklistAction a) {
final secs = DateTime.now().difference(run.spanStarted!).inMilliseconds / 1000;
final reached = a.holdS != null && secs >= a.holdS!;
return Text(
a.holdS == null ? '${secs.toStringAsFixed(1)} s' : '${secs.toStringAsFixed(1)} s / ${a.holdS} s',
style: TextStyle(
fontSize: 32,
fontFeatures: const [FontFeature.tabularFigures()],
color: reached ? Colors.lightGreenAccent : Colors.white,
),
);
}
/// The latest mark and whether the recorder took it: an unseen ✗ means marks are going nowhere.
Widget _lastMark(BuildContext context) {
if (sent.isEmpty) {
return Text('No marks sent yet', textAlign: TextAlign.end, style: TextStyle(color: Theme.of(context).colorScheme.outline));
}
final b = widget.boundary;
final last = sent.first;
final pending = b.pending.length;
final ack = b.acks.where((x) => x.id == last.id).firstOrNull;
final (String status, Color? color) = ack != null
? (ack.ok ? ('✓', null) : ('✗ ${ack.detail}', Colors.redAccent))
: b.pending.any((p) => p.id == last.id)
? ('… unacked', Colors.orange)
: ('?', null);
return Text(
'"${last.text}" $status${pending > 1 ? ' · $pending waiting' : ''}',
textAlign: TextAlign.end,
overflow: TextOverflow.ellipsis,
maxLines: 2,
style: TextStyle(color: color, fontSize: 16),
);
}
}
class StepList extends StatelessWidget {
const StepList({super.key, required this.run, required this.onSelect});
final ChecklistRun run;
final void Function(int) onSelect;
@override
Widget build(BuildContext context) {
final steps = run.checklist.steps;
return ListView.builder(
itemCount: steps.length,
itemBuilder: (context, i) {
final s = steps[i];
final header = i == 0 || steps[i - 1].section != s.section;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (header)
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Text(s.section, style: Theme.of(context).textTheme.labelLarge),
),
ListTile(
dense: true,
selected: i == run.step,
leading: Text(s.id, style: const TextStyle(fontSize: 18)),
title: Text(s.title),
onTap: () {
onSelect(i);
if (Scaffold.maybeOf(context)?.isEndDrawerOpen == true) Navigator.of(context).pop();
},
),
],
);
},
);
}
}
+10
View File
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'boundary.dart';
import 'camview.dart';
import 'checklist.dart';
import 'linktest.dart';
import 'net.dart';
@@ -250,6 +251,15 @@ class LinkBar extends StatelessWidget {
icon: const Icon(Icons.videocam),
label: const Text('Camera'),
),
TextButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChecklistsPage(boundary: b),
),
),
icon: const Icon(Icons.checklist),
label: const Text('Captures'),
),
],
),
),
+3 -4
View File
@@ -57,10 +57,9 @@ flutter:
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# Exact copies of contracts files (tool/sync-contracts.sh); crosscheck verifies them.
assets:
- assets/contracts/checklists/
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
+79
View File
@@ -0,0 +1,79 @@
import 'dart:convert';
import 'dart:io';
import 'package:display/checklist.dart';
import 'package:flutter_test/flutter_test.dart';
Checklist sample() => Checklist.fromJson({
'id': 'demo',
'version': 2,
'title': 'Demo',
'sections': [
{
'title': 'A',
'steps': [
{
'id': '1',
'title': 'Brake',
'actions': [
{'label': 'brake', 'repeat': 2},
{'label': 'flash', 'point': true},
],
},
{
'id': '2',
'title': 'Gears',
'actions': [
{'label': 'gear 1'},
],
},
],
},
],
});
void main() {
test('one button: announce once, start/end spans, repeats numbered, points single', () {
final r = ChecklistRun(sample());
expect(r.nextLabel, 'Start brake #1');
expect(r.press(), ['checklist demo v2', '1 brake #1 start']);
expect(r.nextLabel, 'End brake #1');
expect(r.press(), ['1 brake #1 end']);
expect(r.press(), ['1 brake #2 start']);
expect(r.press(), ['1 brake #2 end']);
expect(r.nextLabel, 'Mark flash');
expect(r.press(), ['1 flash']);
expect(r.nextLabel, 'Next step');
expect(r.press(), isEmpty);
expect(r.current.id, '2');
expect(r.press(), ['2 gear 1 start']);
expect(r.press(), ['2 gear 1 end']);
expect(r.finished, isTrue);
expect(r.nextLabel, 'All done');
});
test('leaving an open span aborts it', () {
final r = ChecklistRun(sample());
r.press();
expect(r.redo(), ['1 brake #1 abort', '1 redo']);
expect(r.inSpan, isFalse);
expect(r.nextLabel, 'Start brake #1');
r.press();
expect(r.select(1), ['1 brake #1 abort']);
expect(r.current.id, '2');
expect(r.skip(), ['2 skip']);
expect(r.finished, isTrue);
});
test('the bundled checklists parse, with unique step ids', () {
final dir = Directory('assets/contracts/checklists');
final files = dir.listSync().whereType<File>().where((f) => f.path.endsWith('.json')).toList();
expect(files, isNotEmpty);
for (final f in files) {
final c = Checklist.fromJson(jsonDecode(f.readAsStringSync()) as Map<String, dynamic>);
final ids = c.steps.map((s) => s.id).toList();
expect(ids.toSet().length, ids.length, reason: f.path);
expect(c.steps.every((s) => s.actions.isNotEmpty), isTrue, reason: f.path);
}
});
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Refresh the bundled copies of contracts files under assets/contracts/ from a
# contracts checkout (default: ../contracts). The copies must stay byte-identical:
# crosscheck fails the build otherwise. New files are added by copying them in once.
set -eu
here=$(cd "$(dirname "$0")/.." && pwd)
src=$(cd "${1:-$here/../contracts}" && pwd)
cd "$here"
find assets/contracts -type f | while read -r f; do
cp "$src/${f#assets/contracts/}" "$f"
echo "synced $f"
done