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
This commit is contained in:
@@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user