Captures page: the recording checklist with one start/end button
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:
@@ -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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
Executable
+12
@@ -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
|
||||
Reference in New Issue
Block a user