Added chatterbox connections
This commit is contained in:
1 parent
41ef12f954
commit
e58ceeb192
58 files changed
+8931
-8
No files matched your search
@@ -0,0 +1,169 @@
|
||||
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...')),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user