Files
mikkeli 4cf7a786d6
/ crosscheck (push) Successful in 1s
CI / Analyze & Test (push) Successful in 13s
CI / Debug APK (push) Successful in 27s
App structure: a swipe-in menu shell; pages/, link/, actions/; Settings
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
2026-10-14 14:08:56 +09:00

468 lines
15 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
},
),
],
);
},
);
}
}