App structure: a swipe-in menu shell; pages/, link/, actions/; Settings
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 13s
CI / Debug APK (push) Successful in 27s

The shell (shell.dart): a drawer from the left edge or each page's menu button.
Dash, Signals, Captures; Tools (Camera, Link test, Orin power options), the
rarely used ones, after pirate-station-aide; Settings (gateway address, dark/bright,
build-time theme and landing). Landing view at build time: YUKART_HOME=dash|signals,
signals for now. Dash, Signals and Captures stay alive while hidden; Captures has its
own navigator, so a run survives switching views. Back goes inside Captures, then to
the landing view, then out. The mock banner sits over every page.

main.dart is startup only; the old home screen is pages/signals.dart; dialogs and
sheets moved to actions/; the protocol client, USB binding and Reading to link/.
lib/README.md catalogues it.

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-14 14:08:56 +09:00
parent 3aeb09d61f
commit 4cf7a786d6
22 changed files with 941 additions and 579 deletions
+467
View File
@@ -0,0 +1,467 @@
// 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 '../link/boundary.dart';
import '../shell_scope.dart';
import '../theme/palette.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(leading: const MenuButton(), 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(leading: const MenuButton(), 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 p = AppTheme.of(context);
final a = run.currentAction;
// The big button by what pressing it does: start (good), stop a running span
// (caution: something is in progress), a point mark (accent), skip on (subdued).
final (Color color, IconData icon) = run.stepDone
? (p.subdued, Icons.skip_next)
: a!.point
? (p.accent, Icons.flag)
: run.inSpan
? (p.caution, Icons.stop)
: (p.good, 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: p.onFill(color),
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 ? AppTheme.of(context).good : AppTheme.of(context).foreground,
),
);
}
/// 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 ? ('✓', AppTheme.of(context).good) : ('✗ ${ack.detail}', AppTheme.of(context).alarm))
: b.pending.any((p) => p.id == last.id)
? ('… unacked', AppTheme.of(context).caution)
: ('?', 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();
},
),
],
);
},
);
}
}