import 'dart:async'; import 'dart:convert'; import 'teddy_audio.dart'; import 'hint_panel.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; class RoomPage extends StatefulWidget { const RoomPage({super.key, this.client}); final http.Client? client; @override State createState() => _RoomPageState(); } class _RoomPageState extends State { static final base = (Uri.base.scheme == 'http' || Uri.base.scheme == 'https') ? Uri.base.origin : 'http://127.0.0.1:18777'; late final http.Client client; Timer? timer; Map? room; bool pending = false, polling = false; String? error; String notice = 'Connecting to the isolated room controller...'; String profile = 'standard'; int party = 6, tab = 0, serial = 0, generation = 0; final Map selections = {}; int? selectionWave; Map? trace; String speechStatus = 'Teddy audio not enabled (captions available)'; bool speechPolling = false; final audio = TeddyAudio(); String? readyAudioUrl; Future refreshSpeech() async { if (speechPolling) return; speechPolling = true; var status = 'Teddy audio not enabled. Start lab.py start-room --speech.'; try { final response = await client.get(Uri.parse('$base/speech/health')).timeout(const Duration(seconds: 2)); if (response.statusCode == 200) { final data = jsonDecode(response.body); final teddy = room?['teddy']; final valid = error == null && data['output'] == 'browser' && data['run_id'] == room?['run_id'] && data['cue_id'] != null && data['cue_id'] == teddy?['current']?['id'] && teddy?['muted'] != true && teddy?['online'] == true && (teddy?['takeover'] != true || (teddy?['current']?['key'] as String? ?? '').startsWith('gm-')); readyAudioUrl = valid && data['audio_url'] is String ? '$base/speech${data['audio_url']}' : null; audio.sync(readyAudioUrl); status = 'Teddy: ${data['status']} · ${data['voice']} — ${data['message']}'; } else { readyAudioUrl=null; audio.sync(null); } } catch (_) { readyAudioUrl=null; audio.sync(null); /* Speech is optional; room controls stay available. */ } if (mounted) setState(() => speechStatus = status); speechPolling = false; } @override void initState() { super.initState(); client = widget.client ?? http.Client(); refresh(); refreshSpeech(); timer = Timer.periodic(const Duration(seconds: 1), (_) { refresh(); refreshSpeech(); }); } @override void dispose() { timer?.cancel(); audio.dispose(); client.close(); super.dispose(); } Future refresh() async { if (pending || polling) return; polling = true; final gen = generation; try { final response = await client.get(Uri.parse('$base/room/state')).timeout(const Duration(seconds: 6)); if (response.statusCode != 200) throw Exception(); final data = jsonDecode(response.body); if (data['state'] is! Map) throw Exception(); if (mounted && gen == generation) setState(() { if (room == null || error != null) notice = 'Connected. The room timer runs after briefing; Pause freezes gameplay.'; room = Map.from(data['state']); error = null; }); } catch (_) { if (mounted && gen == generation) setState(() => error = 'Room simulator disconnected. Start lab.py start-room; controls are disabled.'); } finally { polling = false; } } Future act(String action, [String entity = '', dynamic value]) async { if (pending || room == null || error != null) return; generation++; setState(() => pending = true); try { final response = await client.post(Uri.parse('$base/room/command'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'command_id': 'gm-${DateTime.now().microsecondsSinceEpoch}-${serial++}', 'run_id': room!['run_id'], 'expected_revision': room!['revision'], 'expires_at_ms': DateTime.now().millisecondsSinceEpoch + 10000, 'input': {'action': action, 'entity': entity, 'value': value}, })).timeout(const Duration(seconds: 7)); if (response.statusCode != 200) throw Exception(); final data = jsonDecode(response.body); if (data['state'] is! Map) throw Exception(); if (mounted) setState(() { room = Map.from(data['state']); notice = '${data['accepted'] == true ? 'Accepted' : 'Rejected'}: ${data['message']}'; if (room?['teddy']?['current'] == null) audio.sync(null); }); } catch (_) { if (mounted) setState(() => error = 'Delivery unconfirmed. Refresh the room state before retrying.'); } finally { if (mounted) setState(() => pending = false); } } Future confirmReset() async { final confirmed = await showDialog(context: context, builder: (context) => AlertDialog( title: const Text('Reset this simulated run?'), content: const Text('Clears tokens, wins, charge, calibration and launch readiness. No physical room is affected. The current process trace retains the reset.'), actions: [TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('Reset simulation'))])); if (confirmed == true) await act('reset', '', {'confirmed': true}); } Future withReason(String action, String entity) async { final controller = TextEditingController(); final reason = await showDialog(context: context, builder: (context) => AlertDialog( title: Text(action == 'deliver' ? 'Confirm GM hand-delivery' : 'Finish as assisted rescue'), content: TextField(controller: controller, autofocus: true, decoration: const InputDecoration(labelText: 'Staff name and reason')), actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), FilledButton(onPressed: () { if (controller.text.trim().isNotEmpty) Navigator.pop(context, controller.text.trim()); }, child: const Text('Record intervention'))])); controller.dispose(); if (reason != null) await act(action, entity, action == 'deliver' ? {'hand': true, 'reason': reason} : reason); } Future loadTrace() async { try { final response = await client.get(Uri.parse('$base/room/trace')).timeout(const Duration(seconds: 6)); if (response.statusCode != 200) throw Exception(); final data = jsonDecode(response.body); if (mounted) setState(() => trace = Map.from(data)); } catch (_) { if (mounted) setState(() => notice = 'Could not fetch trace.'); } } bool get enabled => room != null && error == null && !pending; List get stations => room?['stations'] ?? []; String get phase => room?['phase'] ?? 'UNKNOWN'; String get lifecycle => room?['lifecycle'] ?? 'UNKNOWN'; Widget button(String label, String action, {String entity = '', dynamic value, bool allow = true}) => OutlinedButton(onPressed: enabled && allow ? () => act(action, entity, value) : null, child: Text(label)); Widget panel(String title, List children, {Color? color}) => SizedBox(width: double.infinity, child: Card( color: color, child: Padding(padding: const EdgeInsets.all(18), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), const SizedBox(height: 12), ...children])))); Widget wrap(List children) => Wrap(spacing: 8, runSpacing: 8, children: children); Widget fact(String text) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Text(text)); Widget badge(String text, [Color color = Colors.cyan]) => Chip( label: Text(text), side: BorderSide(color: color.withValues(alpha: .5))); String blocker() { if ((room?['hold'] ?? '') != '') return room!['hold']; switch (phase) { case 'BRIEFING': return 'Start the run, then finish briefing to enable the discovery puzzles.'; case 'POWER_RESTORE': return 'Find the junction box and key, remove the lock, then raise the large switch. Teddy is awake; cabinets are off.'; case 'BOOTING': return 'Power restored. All arcade cabinets are running their four-second startup check.'; case 'ARCADE': return 'Find six tokens, authorize their cabinets, and achieve six distinct first-state wins.'; case 'BOSS': return 'All six wins are latched. Start and defeat The Dark Machine.'; case 'REVEAL': return 'Passage release is requested. ACK is not an open sensor. Confirm opening and advance 300 ms.'; case 'CELLS': return 'Confirm charged bays open, remove each cell, and install it in its matching socket. Advance 2.5 seconds for stability.'; case 'CALIBRATION_TUTORIAL': return 'Complete two readback/Ready/Execute practices before scored waves.'; case 'CALIBRATION': return 'Match ship targets, simulate stable Ready at each active station, then Execute.'; case 'LAUNCH_ARM': return 'Arm all six stations sequentially, acknowledge each operator group, then navigator Commit.'; case 'COUNTDOWN': return 'Five-second countdown. Removing a cell or losing a required station cancels launch.'; case 'DEBRIEF': return 'Run finished. Review the trace or reset the simulation.'; default: return 'Waiting for a room snapshot.'; } } Widget navigation() => ListView(children: [ const Padding(padding: EdgeInsets.all(20), child: Text('ROOM OPERATIONS', style: TextStyle(fontWeight: FontWeight.bold))), for (final entry in ['Room Flow','Cabinets','Ship & Cells','Calibration & Launch','Teddy','Diagnostics'].asMap().entries) ListTile(selected: tab == entry.key, leading: Icon([Icons.account_tree,Icons.sports_esports,Icons.battery_charging_full,Icons.rocket_launch,Icons.record_voice_over,Icons.monitor_heart][entry.key]), title: Text(entry.value), onTap: () { setState(() => tab=entry.key); if (MediaQuery.sizeOf(context).width < 900) Navigator.pop(context); }), ]); Widget overview() => Column(children: [ panel('What happens next', [ fact(blocker()), wrap([ button('Start run', 'start_run', allow: lifecycle == 'READY'), button('Briefing complete', 'briefing_done', allow: lifecycle == 'RUNNING' && phase == 'BRIEFING'), button('Flip power switch up', 'power_switch', value: true, allow: lifecycle == 'RUNNING' && phase == 'POWER_RESTORE'), button('Start boss', 'boss_start', allow: lifecycle == 'RUNNING' && room?['boss'] == 'READY'), button('Simulate boss victory', 'boss_win', allow: lifecycle == 'RUNNING' && room?['boss'] == 'PLAYING'), button('Observe passage open', 'passage', value: true, allow: phase == 'REVEAL'), ]), ]), panel('Room flow · conceptual layout', [ const Text('Discovery arcade → Dark Machine → hidden ship → calibration → launch. This is a flow diagram, not a measured floor plan.'), const SizedBox(height: 12), wrap([ badge('Puzzles ${(room?['puzzles'] as List? ?? []).where((p) => p['solved'] == true).length}/7'), badge('First wins ${stations.where((s) => s['won'] == true).length}/6'), badge('Boss ${room?['boss']}'), badge('Passage ${room?['passage_open'] == true ? 'OPEN' : 'UNCONFIRMED'}'), badge('Ship power ${room?['ship_power'] == true ? 'STABLE' : 'WAITING'}'), badge('Launches ${room?['launches']}'), ]), ]), panel('Discovery puzzles and rewards', [ const Text('These are the supplied placeholder puzzle contracts. Solve and physical delivery are separate observations.'), const SizedBox(height: 12), ...((room?['puzzles'] as List?) ?? []).map((p) { const labels = {'P01':'Restore claw controls','P02A':'Claw stage A','P02B':'Claw stage B', 'P03':'Record and sleeve','P04':'Service-panel symbols','P05':'Ticket values','P06':'Poster overlay'}; final effects = (room?['effects'] as List? ?? []).where((e) => e['target'] == p['token']).toList(); return Padding(padding: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('${p['id']} · ${labels[p['id']]} ${p['token']}', style: const TextStyle(fontWeight: FontWeight.bold)), Text(p['delivered'] == true ? 'Delivered' : p['solved'] == true ? (p['token'] == '' ? 'Solved' : 'Solved · reward pending') : 'Unsolved'), wrap([ button('Simulate solve', 'solve', entity: p['id'], allow: phase == 'ARCADE' && p['solved'] != true), if (p['token'] != '') button('Observe delivery', 'deliver', entity: p['token'], allow: p['solved'] == true && p['delivered'] != true), if (effects.isNotEmpty) button('ACK only / jam test', 'ack', entity: effects.first['id'], allow: effects.first['status'] == 'REQUESTED'), if (p['solved'] == true && p['token'] != '' && p['delivered'] != true) TextButton(onPressed: enabled ? () => withReason('deliver', p['token']) : null, child: const Text('GM hand-delivery...')), ]), ])); }), ]), ]); Widget cabinets() => LayoutBuilder(builder: (context, constraints) { final width = constraints.maxWidth > 1000 ? (constraints.maxWidth - 24) / 3 : constraints.maxWidth > 650 ? (constraints.maxWidth - 16) / 2 : constraints.maxWidth; return wrap(stations.map((s) => SizedBox(width: width, child: panel( '${s['id']} · ${s['game'].toString().replaceAll('_', ' ')}', [ wrap([badge(s['status'], s['won'] == true ? Colors.green : Colors.cyan), badge(s['online'] == true ? 'ONLINE' : 'OFFLINE', s['online'] == true ? Colors.green : Colors.red)]), if (s['status'] == 'BOOTING') ...[const LinearProgressIndicator(), const Text('ROM check / startup - four seconds')], if (s['status'] == 'TOKEN_WAIT') const Text('ATTRACT MODE - insert token'), fact('Token ${s['token']} · ${s['authorized'] == true ? 'authorized for retries' : 'not authorized'}'), fact('Objective ${s['progress']} / ${s['target']} · attempt ${s['attempt']} · losses ${s['losses']}'), LinearProgressIndicator(value: (s['charge_ms'] as num) / 30000), fact('${s['cell']} charge ${((s['charge_ms'] as num) / 300).round()}% · calibration passes ${s['passes']}'), wrap([ button('Scan ${s['token']}', 'token', entity: s['id'], value: {'token':s['token'],'age_ms':0}, allow: s['status'] == 'TOKEN_WAIT'), button('Wrong token', 'token', entity: s['id'], value: {'token':'WRONG','age_ms':0}, allow: s['status'] == 'TOKEN_WAIT'), button('Start attempt', 'start', entity: s['id'], allow: s['status'] == 'READY'), button('Simulate target reached', 'win', entity: s['id'], value: {'attempt':s['attempt'],'progress':s['target']}, allow: s['status'] == 'PLAYING'), button('Simulate loss', 'lose', entity: s['id'], value: {'attempt':s['attempt']}, allow: s['status'] == 'PLAYING'), button(s['online'] == true ? 'Disconnect' : 'Reconnect', 'online', entity:s['id'], value:s['online'] != true), ]), ]))).toList()); }); Widget ship() { final locations = Map.from(room?['locations'] ?? {}); final faults = room?['cell_faults'] as List? ?? []; return Column(children: [ panel('Passage and ship power', [ fact('Passage: ${room?['passage_open'] == true ? 'observed open' : 'unconfirmed'} · ship power: ${room?['ship_power']}'), if (faults.isNotEmpty) Text(faults.join('\n'), style: const TextStyle(color: Colors.amber)), ]), ...stations.map((s) { final bay = 'BAY${s['cell'].toString().substring(4)}'; final here = locations.entries.where((e) => e.value == s['cell']).map((e) => e.key).join(', '); return panel('${s['cell']} → ${s['socket']}', [ fact('Location: ${here.isEmpty ? 'carried / unobserved' : here} · bay open: ${s['bay_open']}'), wrap([ button('Observe bay open', 'bay', entity:s['id'], allow: room?['passage_open'] == true && s['charge_ms'] == 30000 && s['bay_open'] != true), button('Remove from bay', 'cell', entity:bay, value:{'cell':s['cell'],'present':false}, allow: s['bay_open'] == true && locations[bay] == s['cell']), button('Insert matching socket', 'cell', entity:s['socket'], value:{'cell':s['cell'],'present':true}, allow: room?['passage_open'] == true && locations[s['socket']] != s['cell']), button('Remove from socket', 'cell', entity:s['socket'], value:{'cell':s['cell'],'present':false}, allow: locations[s['socket']] == s['cell']), ]), ]); }), ]); } Widget calibration() { final wave = room?['wave'] as Map?; if (wave?['id'] != selectionWave) { selectionWave = wave?['id']; selections.clear(); } final targets = Map.from(wave?['targets'] ?? {}); const ranges = {'cabinet_01':[0,3],'cabinet_02':[1,5],'cabinet_03':[0,7], 'cabinet_04':[1,3],'cabinet_05':[1,4],'cabinet_06':[1,4]}; final hold = {'guided':1000,'standard':2000,'challenge':3000}[room?['profile']] ?? 2000; return Column(children: [ panel('Navigator and operator practice', [ fact('Party ${room?['party']} · ${room?['operators']} operator groups · practices ${room?['tutorial']}/2'), fact(blocker()), if (wave != null) ...[ fact('Challenge ${wave['id']} · active-time deadline ${wave['deadline'] ?? 'untimed practice'} ms'), ...targets.entries.map((entry) { final range = ranges[entry.key]!; final selected = selections[entry.key] ?? range.first; return Padding(padding: const EdgeInsets.only(bottom: 14), child: wrap([ SizedBox(width: 230, child: Text('${entry.key}\nShip target: ${entry.value}\nReadback: ${wave['ready'][entry.key] ?? 'waiting'}')), DropdownButton(value:selected, items:[for (int i = range.first; i <= range.last; i++) DropdownMenuItem(value:i, child:Text('Selection $i'))], onChanged:enabled ? (v) => setState(() => selections[entry.key] = v!) : null), button('Simulate held Ready', 'ready', entity:entry.key, value:{'challenge':wave['id'],'selection':selected,'stable_ms':hold}), button('Revoke Ready', 'revoke', entity:entry.key, value:wave['id']), ])); }), button('Navigator Execute', 'execute', value:wave['id']), ], const SizedBox(height: 12), wrap(stations.map((s) => badge('${s['id']}: ${s['passes']} passes')).toList()), ]), panel('Launch barrier', [ fact('Epoch ${room?['launch_epoch']} · Armed ${(room?['armed'] as List? ?? []).length}/6'), wrap(stations.map((s) => button('Arm ${s['id']}', 'arm', entity:s['id'], value:room?['launch_epoch'], allow:phase == 'LAUNCH_ARM' && !(room?['armed'] as List? ?? []).contains(s['id']))).toList()), const SizedBox(height: 12), wrap([for (int i=0; i < (room?['operators'] ?? 1); i++) button('Group ${i+1} ready', 'group_ready', value:{'epoch':room?['launch_epoch'],'group':i}, allow:phase == 'LAUNCH_ARM' && !(room?['groups'] as List? ?? []).contains(i))]), fact('One operator can arm several stations in sequence. No six-person simultaneous button press is required.'), button('Navigator Commit', 'commit', value:room?['launch_epoch'], allow:phase == 'LAUNCH_ARM'), if (phase == 'COUNTDOWN') button('Advance five-second countdown', 'advance', value:5000), ]), ]); } Widget teddy() { final t = room?['teddy'] as Map? ?? {}; return panel('Teddy Ruxpin · hints and speech', [ wrap([badge(t['mode'] ?? 'DORMANT'), badge(t['online'] == true ? 'ONLINE' : 'FAULT'), badge(t['muted'] == true ? 'MUTED' : 'CAPTIONS + OPTIONAL PIPER'), if (t['takeover'] == true) badge('GM TAKEOVER')]), Text(speechStatus), Text(audio.status), OutlinedButton(onPressed: () async { if (audio.enabled) { setState(() => audio.disable()); } else { await audio.enable(); if (mounted) { setState(() {}); await refreshSpeech(); } } }, child: Text(audio.enabled ? 'Disable audio on this device' : 'Enable audio and test speaker')), Wrap(spacing:8,runSpacing:8,children:[ OutlinedButton(onPressed:() async { await audio.enable(); if(mounted) setState(() {}); },child:const Text('Test speaker tone')), FilledButton(onPressed:readyAudioUrl == null ? null : () { audio.playNow(readyAudioUrl!); setState(() {}); },child:const Text('Play current hint here')), ]), if (t['takeover'] == true) const Text('GM takeover: automatic narration is paused. Say now and Replay still work.'), if (t['muted'] == true || t['online'] != true) const Text('Teddy is muted or offline. Unmute/reconnect before sending hints.'), const Text('With browser speech enabled, sound plays on each device that opts in. Keep the page open. Listen before advancing virtual time: advancing may stop or replace the cue. Doll movement remains simulated.'), const SizedBox(height: 18), SelectableText(t['transcript'] == '' ? 'Waiting for a cue...' : t['transcript'] ?? '', style: const TextStyle(fontSize: 22)), const SizedBox(height: 18), fact('Current cue: ${t['current']?['key'] ?? 'none'} · Hint level: ${t['hint_level'] ?? 0}'), wrap([ button('Quick phase hint now', 'hint', allow:['ARCADE','CALIBRATION','CALIBRATION_TUTORIAL'].contains(phase) && t['online'] == true && t['muted'] != true), button(t['muted'] == true ? 'Unmute' : 'Mute', 'teddy', value:'mute'), button('Stop cue', 'teddy', value:'stop'), button('Replay transcript cue', 'teddy', value:'replay'), button(t['online'] == true ? 'Simulate Teddy failure' : 'Reconnect Teddy', 'teddy', value:'offline'), button(t['takeover'] == true ? 'Release to autopilot' : 'GM takeover', 'teddy', value:t['takeover'] == true ? 'release' : 'takeover'), ]), const SizedBox(height: 18), const Text('Queued cues'), ...((t['queue'] as List?) ?? []).map((c) => ListTile(title:Text(c['key']), subtitle:Text(c['transcript']))), HintPanel(key:const ValueKey('hint-library'),room:room!,client:client,base:base,canSpeak:enabled && t['online'] == true && t['muted'] != true, onSpeak:(text)=>act('speak','',{'text':text})), ]); } Widget diagnostics() => panel('Diagnostics and replay trace', [ const Text('Fast-forward the simulation clock for testing:'), wrap([ button('+ 1 second','advance',value:1000), button('+ 3 seconds','advance',value:3000), button('+ 30 seconds','advance',value:30000), button('+ 2 minutes','advance',value:120000), ]), fact('Run ${room?['run_id']} · revision ${room?['revision']}'), const Text('In-memory simulation trace. Save it before closing the controller. Durable recovery and physical output replay are not implemented.'), wrap([ OutlinedButton(onPressed: enabled ? loadTrace : null, child: const Text('Refresh trace')), OutlinedButton(onPressed: trace == null ? null : () async { await Clipboard.setData(ClipboardData(text:const JsonEncoder.withIndent(' ').convert(trace))); if (mounted) setState(() => notice = 'Trace copied. Save it as a JSON file for replay.'); }, child: const Text('Copy trace JSON')), ]), if (trace != null) fact('Trace events: ${(trace!['trace'] as List).length} · final SHA-256: ${trace!['final_hash']}'), ...((room?['effects'] as List?) ?? []).map((e) => ListTile( title:Text('${e['kind']} · ${e['target']}'), subtitle:Text('${e['status']} — ${e['id']}'), trailing:button('Simulate ACK', 'ack', entity:e['id'], allow:e['status']=='REQUESTED'))), if (trace != null) ...((trace!['trace'] as List).reversed.take(20)).map((e) => ListTile( leading:Icon(e['accepted'] == true ? Icons.check_circle_outline : Icons.block), title:Text('${e['request']['input']['action']} ${e['request']['input']['entity']}'), subtitle:Text(e['message']))), ]); @override Widget build(BuildContext context) { final remaining = ((room?['limit_ms'] ?? 3600000) - (room?['active_ms'] ?? 0)) ~/ 1000; return Scaffold( appBar: AppBar(title: const Text('Escape Gallery / Glitch: the Dark Machine')), drawer: Drawer(child: SafeArea(child: navigation())), body: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ if (MediaQuery.sizeOf(context).width >= 900) SizedBox(width: 230, child: navigation()), Expanded(child: Center(child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 1450), child: ListView(padding: const EdgeInsets.all(20), children: [ const Text('LAB SIMULATION • LIVE TIMER AFTER BRIEFING • NO GPIO', style: TextStyle(color: Colors.amber, fontWeight: FontWeight.bold)), const SizedBox(height: 12), wrap([badge(lifecycle),badge(phase),badge('${room?['profile'] ?? 'standard'} / ${room?['party'] ?? 6} players'),badge('Remaining ${remaining ~/ 60}:${(remaining % 60).toString().padLeft(2,'0')}'), badge(room?['assisted'] == true ? 'ASSISTED' : 'UNASSISTED'), if (room?['outcome'] != null) badge(room!['outcome'],Colors.green)]), if (lifecycle == 'READY') wrap([ DropdownButton(value:profile,items:['guided','standard','challenge'].map((p) => DropdownMenuItem(value:p,child:Text(p))).toList(),onChanged:enabled ? (v)=>setState(()=>profile=v!):null), DropdownButton(value:party,items:[for(int i=1;i<=8;i++) DropdownMenuItem(value:i,child:Text('$i players'))], onChanged:enabled ? (v)=>setState(()=>party=v!):null), button('Apply profile', 'prepare', value:{'profile':profile,'party':party,'seed':42}), ]), wrap([ button('Pause','pause',allow:lifecycle=='RUNNING'), button('Resume','resume',allow:['PAUSED','RECOVERY_HOLD'].contains(lifecycle)), button('Extend 5 minutes','extend',value:5), TextButton(onPressed:enabled ? ()=>withReason('finish_assisted',''):null,child:const Text('Finish assisted...')), TextButton(onPressed:enabled ? confirmReset:null,child:const Text('Reset simulation...')), ]), const SizedBox(height: 10), Text(error ?? notice,style:TextStyle(color:error==null ? Colors.white70:Colors.orange)), const Divider(height: 30), const SizedBox(height: 16), if (room == null) const LinearProgressIndicator() else [overview,cabinets,ship,calibration,teddy,diagnostics][tab](), ])))), ]), ); } }