// 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: // [ #] start | end a span (the one button, pressed twice) // [ #] a point // [ #] abort a span left open (skip, redo, another step) // skip | redo the step was skipped / is being done again // checklist v 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 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 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 steps; factory Checklist.fromJson(Map j) { final steps = []; for (final s in j['sections'] as List) { final section = (s as Map)['title'] as String; for (final st in s['steps'] as List) { final m = st as Map; 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)], 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 _announce() { if (_announced) return const []; _announced = true; return ['checklist ${checklist.id} v${checklist.version}']; } List 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 _leave() => inSpan ? [..._announce(), '${current.id} $actionName abort'] : const []; List 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 redo() { final out = [..._leave(), ..._announce(), '${current.id} redo']; _goto(step); return out; } List 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> 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), ]; } /// 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>( 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(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 createState() => _ChecklistPageState(); } class _ChecklistPageState extends State { 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 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(); }, ), ], ); }, ); } }