170 lines
9.9 KiB
Dart
170 lines
9.9 KiB
Dart
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<String,dynamic> hint, Map<String,dynamic> 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<String,dynamic> room;
|
|
final http.Client client;
|
|
final String base;
|
|
final Future<void> Function(String text) onSpeak;
|
|
final bool canSpeak;
|
|
@override
|
|
State<HintPanel> createState() => _HintPanelState();
|
|
}
|
|
class _HintPanelState extends State<HintPanel> {
|
|
List<Map<String,dynamic>> 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<void> 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<String,dynamic>.from(h)).toList();
|
|
revision = body['revision']; message = '';
|
|
});
|
|
} catch (e) { if (mounted) setState(()=>message=e.toString()); }
|
|
finally { if (mounted) setState(()=>loading=false); }
|
|
}
|
|
Future<void> edit([Map<String,dynamic>? 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<void>(context:context,builder:(context)=>StatefulBuilder(builder:(context,update) {
|
|
Widget pick(String label, dynamic value, List<dynamic> values, void Function(dynamic) change) =>
|
|
DropdownButtonFormField<dynamic>(initialValue:value,isExpanded:true,
|
|
decoration:InputDecoration(labelText:label),
|
|
items:values.map((v)=>DropdownMenuItem<dynamic>(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<String,dynamic>.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<void>.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<String>())}.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<String>(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...')),
|
|
]),
|
|
]);
|
|
}
|
|
}
|