3aeb09d61f
background, foreground, subdued, accent (emphasis without danger), and the state gradient good → caution → alarm; surface and onFill derived. smart-serow's four-key themes still load (highlight = alarm; the new roles fall back). Material's scheme follows: buttons in accent instead of the alarm colour. Link state, mock banner, signal tiles, raw log, checklist button and marks, camera page all take palette roles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DDwfd6Ki8UfG4hK6W5YpJz
467 lines
15 KiB
Dart
467 lines
15 KiB
Dart
// 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';
|
||
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(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 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();
|
||
},
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|