import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; /// Null means outside the current context; all parallel unfinished branches qualify. String? hintReason(Map hint, Map room) { final phase = room['phase']; if (hint['phase'] != 'ALL' && hint['phase'] != phase) return null; final target = hint['target']; if (target == 'room') return hint['phase'] == 'ALL' ? 'General room hint' : 'Current phase: $phase'; final isPuzzle = (target as String).startsWith('P'); final items = (room[isPuzzle ? 'puzzles' : 'stations'] as List?) ?? []; final matches = items.where((p) => p['id'] == target); if (matches.isEmpty) return null; final item = matches.first; final complete = item[isPuzzle ? 'solved' : 'won'] == true; if (hint['condition'] == 'complete' && !complete) return null; if (hint['condition'] == 'incomplete') { if (complete || phase != 'ARCADE') return null; bool flag(String id, String field) => ((room['puzzles'] as List?) ?? []).any((p) => p['id'] == id && p[field] == true); if (target == 'P02A' && !flag('P01','solved')) return null; if (target == 'P02B' && !flag('P02A','delivered')) return null; } return '$target: ${complete ? 'completed' : 'unfinished'}'; } class HintPanel extends StatefulWidget { const HintPanel({super.key, required this.room, required this.client, required this.base, required this.onSpeak, required this.canSpeak}); final Map room; final http.Client client; final String base; final Future Function(String text) onSpeak; final bool canSpeak; @override State createState() => _HintPanelState(); } class _HintPanelState extends State { List> hints = []; int revision = 0; bool loading = false, suggested = true; String category = 'All categories', query = '', message = ''; final draft = TextEditingController(); @override void initState() { super.initState(); load(); } @override void dispose() { draft.dispose(); super.dispose(); } Future load() async { if (loading) return; setState(() => loading = true); try { final response = await widget.client.get(Uri.parse('${widget.base}/hints')).timeout(const Duration(seconds:5)); final body = jsonDecode(response.body); if (response.statusCode != 200 || body['hints'] is! List) throw Exception('Hint library unavailable. Restart the updated lab gateway.'); if (mounted) setState(() { hints = (body['hints'] as List).map((h)=>Map.from(h)).toList(); revision = body['revision']; message = ''; }); } catch (e) { if (mounted) setState(()=>message=e.toString()); } finally { if (mounted) setState(()=>loading=false); } } Future edit([Map? original, String? initialText]) async { final title = TextEditingController(text:original?['title'] ?? ''); final text = TextEditingController(text:original?['text'] ?? initialText ?? ''); final tags = TextEditingController(text:((original?['categories'] as List?) ?? ['General']).join(', ')); var phase = original?['phase'] ?? 'ALL'; var target = original?['target'] ?? 'room'; var condition = original?['condition'] ?? 'any'; var level = original?['level'] ?? 1; var error = ''; var saving = false; var editRevision = revision; await showDialog(context:context,builder:(context)=>StatefulBuilder(builder:(context,update) { Widget pick(String label, dynamic value, List values, void Function(dynamic) change) => DropdownButtonFormField(initialValue:value,isExpanded:true, decoration:InputDecoration(labelText:label), items:values.map((v)=>DropdownMenuItem(value:v,child:Text('$v'))).toList(), onChanged:saving ? null : (v)=>update(()=>change(v))); return AlertDialog( title:Text(original == null ? 'Create saved hint' : 'Edit saved hint'), content:SizedBox(width:580,child:SingleChildScrollView(child:Column(mainAxisSize:MainAxisSize.min,children:[ TextField(controller:title,maxLength:100,decoration:const InputDecoration(labelText:'Hint title')), TextField(controller:text,maxLength:1500,minLines:3,maxLines:6,decoration:const InputDecoration(labelText:'What Teddy should say')), TextField(controller:tags,decoration:const InputDecoration(labelText:'Categories, separated by commas')), pick('Room phase',phase,['ALL','BRIEFING','ARCADE','BOSS','REVEAL','CELLS','CALIBRATION_TUTORIAL','CALIBRATION','LAUNCH_ARM','COUNTDOWN','DEBRIEF'],(v)=>phase=v), pick('Puzzle or cabinet',target,['room','P01','P02A','P02B','P03','P04','P05','P06',...List.generate(6,(i)=>'cabinet_0${i+1}')],(v)=>target=v), pick('Show when target is',condition,['any','incomplete','complete'],(v)=>condition=v), pick('Hint strength: 1 subtle, 2 direct, 3 solution',level,[1,2,3],(v)=>level=v), if (error.isNotEmpty) Text(error,style:TextStyle(color:Theme.of(context).colorScheme.error)), ]))), actions:[ TextButton(onPressed:saving?null:()=>Navigator.pop(context),child:const Text('Cancel')), FilledButton(onPressed:saving?null:() async { update(()=>saving=true); try { final result=await widget.client.post(Uri.parse('${widget.base}/hints'), headers:{'Content-Type':'application/json'}, body:jsonEncode({'revision':editRevision,'hint':{ 'id':original?['id'] ?? '', 'title':title.text,'text':text.text, 'categories':tags.text.split(',').map((s)=>s.trim()).where((s)=>s.isNotEmpty).toList(), 'phase':phase,'target':target,'condition':condition,'level':level, }})).timeout(const Duration(seconds:6)); final body=jsonDecode(result.body); if (result.statusCode != 200) { if (result.statusCode == 409) { await load(); editRevision=revision; throw Exception('Library changed on another device. Your draft is kept. Review it, then Save again to apply your version.'); } throw Exception(body['error'] ?? 'Save failed'); } if (mounted) setState(() { hints=(body['hints'] as List).map((h)=>Map.from(h)).toList(); revision=body['revision']; message='Hint saved on the host PC.'; }); if (context.mounted) Navigator.pop(context); } catch(e) { if(context.mounted) update(()=>error=e.toString()); } finally { if(context.mounted) update(()=>saving=false); } },child:Text(saving?'Saving...':'Save hint')), ], ); })); // Controllers are released after the dialog route's closing animation. await Future.delayed(const Duration(milliseconds:250)); title.dispose(); text.dispose(); tags.dispose(); } @override Widget build(BuildContext context) { final categories = {'All categories',...hints.expand((h)=>(h['categories'] as List).cast())}.toList()..sort(); final shown = hints.where((h) { if(suggested && hintReason(h,widget.room)==null) return false; if(category!='All categories' && !(h['categories'] as List).contains(category)) return false; return '${h['title']} ${h['text']} ${h['categories']}'.toLowerCase().contains(query.toLowerCase()); }).toList()..sort((a,b)=>(a['level'] as int).compareTo(b['level'] as int)); return Column(crossAxisAlignment:CrossAxisAlignment.start,children:[ const SizedBox(height:24), Text('Hint library',style:Theme.of(context).textTheme.titleLarge), Text('Room phase: ${widget.room['phase']}. Suggestions include all eligible parallel puzzles. These are suggestions; the GM chooses what to say.'), Wrap(spacing:10,runSpacing:8,children:[ FilterChip(label:const Text('Relevant now'),selected:suggested,onSelected:(v)=>setState(()=>suggested=v)), OutlinedButton(onPressed:()=>edit(),child:const Text('New saved hint')), TextButton(onPressed:loading?null:load,child:const Text('Reload library')), ]), DropdownButton(isExpanded:true,value:categories.contains(category)?category:'All categories', items:categories.map((c)=>DropdownMenuItem(value:c,child:Text(c))).toList(), onChanged:(v)=>setState(()=>category=v!)), TextField(decoration:const InputDecoration(labelText:'Search hints'),onChanged:(v)=>setState(()=>query=v)), if(message.isNotEmpty) Text(message), if(loading) const LinearProgressIndicator(), if(!loading && shown.isEmpty) const Text('No matching hints. Turn off Relevant now to browse the whole library.'), ...shown.map((h)=>Card(child:Padding(padding:const EdgeInsets.all(12),child:Column(crossAxisAlignment:CrossAxisAlignment.start,children:[ Text(h['title'],style:Theme.of(context).textTheme.titleMedium), Text('${(h['categories'] as List).join(' / ')} · Level ${h['level']} · ${hintReason(h,widget.room) ?? 'Outside current context'}'), Text(h['text']), Wrap(spacing:8,children:[ FilledButton(onPressed:widget.canSpeak?()=>widget.onSpeak(h['text']):null,child:const Text('Say now')), TextButton(onPressed:()=>edit(h),child:const Text('Edit')), ]), ])))), const SizedBox(height:16), TextField(controller:draft,minLines:2,maxLines:5,maxLength:1500, decoration:const InputDecoration(labelText:'Write a one-off hint',hintText:'Type exactly what Teddy should say')), Wrap(spacing:8,children:[ FilledButton(onPressed:widget.canSpeak?() { if(draft.text.trim().isNotEmpty) widget.onSpeak(draft.text.trim()); }:null,child:const Text('Say typed hint now')), OutlinedButton(onPressed:()=>edit(null,draft.text),child:const Text('Save typed hint...')), ]), ]); } }