diff --git a/.atl/skill-registry.md b/.atl/skill-registry.md index 0281765..6e88acd 100644 --- a/.atl/skill-registry.md +++ b/.atl/skill-registry.md @@ -1,4 +1,4 @@ -# Skill Registry +# Skill Registry **Delegator use only.** Any agent that launches sub-agents reads this registry to resolve compact rules, then injects them directly into sub-agent prompts. Sub-agents do NOT read this registry or individual SKILL.md files. @@ -8,51 +8,69 @@ See `_shared/skill-resolver.md` for the full resolution protocol. | Trigger | Skill | Path | |---------|-------|------| -| When creating a pull request, opening a PR, or preparing changes for review | branch-pr | /Users/freetlab/.config/opencode/skills/branch-pr/SKILL.md | -| When creating a GitHub issue, reporting a bug, or requesting a feature | issue-creation | /Users/freetlab/.config/opencode/skills/issue-creation/SKILL.md | -| When user says "judgment day", "judgment-day", "review adversarial", "dual review", "doble review", "juzgar", "que lo juzguen" | judgment-day | /Users/freetlab/.config/opencode/skills/judgment-day/SKILL.md | -| When user asks to create a new skill, add agent instructions, or document patterns for AI | skill-creator | /Users/freetlab/.config/opencode/skills/skill-creator/SKILL.md | +| Go tests, Bubbletea TUI testing | go-testing | C:/Users/jbwhi/.codex/skills/go-testing/SKILL.md | +| Creating a GitHub issue, reporting a bug, or requesting a feature | issue-creation | C:/Users/jbwhi/.codex/skills/issue-creation/SKILL.md | +| Creating a pull request or preparing changes for review | branch-pr | C:/Users/jbwhi/.codex/skills/branch-pr/SKILL.md | +| Adversarial dual review / judgment day | judgment-day | C:/Users/jbwhi/.codex/skills/judgment-day/SKILL.md | +| Creating new AI skills | skill-creator | C:/Users/jbwhi/.codex/skills/skill-creator/SKILL.md | +| Browser automation for localhost/file/current browser tab | browser-use:browser | C:/Users/jbwhi/.codex/plugins/cache/openai-bundled/browser-use/0.1.0-alpha1/skills/browser/SKILL.md | +| Document editing/render verification | documents:documents | C:/Users/jbwhi/.codex/plugins/cache/openai-primary-runtime/documents/26.430.10722/skills/documents/SKILL.md | +| Presentation deck creation/edit/render/export | presentations:Presentations | C:/Users/jbwhi/.codex/plugins/cache/openai-primary-runtime/presentations/26.430.10722/skills/presentations/SKILL.md | +| Spreadsheet creation/edit/analyze/visualize | spreadsheets:Spreadsheets | C:/Users/jbwhi/.codex/plugins/cache/openai-primary-runtime/spreadsheets/26.430.10722/skills/spreadsheets/SKILL.md | ## Compact Rules Pre-digested rules per skill. Delegators copy matching blocks into sub-agent prompts as `## Project Standards (auto-resolved)`. -### branch-pr -- Every PR MUST link an approved issue — no exceptions -- Every PR MUST have exactly one `type:*` label -- Automated checks must pass before merge is possible -- Branch names must match: `^(feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert)/[a-z0-9._-]+$` -- Conventional commits: `^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9\._-]+\))?!?: .+` -- Commit type determines PR label: feat→type:feature, fix→type:bug, docs→type:docs, refactor→type:refactor, chore→type:chore, style→type:chore, perf→type:feature, test→type:chore, build→type:chore, ci→type:chore, revert→type:bug -- PR body must contain: Closes #N (linked issue), PR type checkbox, Summary, Changes Table, Test Plan +### go-testing +- Use `go test` patterns and Bubbletea `teatest` when touching Go/TUI code. +- Prefer deterministic tests and isolate terminal/model effects. +- Keep tests close to behavior and avoid brittle timing assumptions. +- Not applicable to this Flutter/Dart project unless Go files are introduced. ### issue-creation -- Blank issues are disabled — MUST use a template (bug report or feature request) -- Every issue gets `status:needs-review` automatically on creation -- A maintainer MUST add `status:approved` before any PR can be opened -- Questions go to Discussions, not issues -- Bug report template required fields: Pre-flight Checks, Bug Description, Steps to Reproduce, Expected Behavior, Actual Behavior, Operating System, Agent/Client, Shell -- Feature request template required fields: Pre-flight Checks, Problem Description, Proposed Solution, Affected Area +- Follow issue-first workflow before PR work when a feature/bug needs tracking. +- Capture problem, expected behavior, acceptance criteria, and verification steps. +- Do not create noisy or duplicate issues without checking existing context. + +### branch-pr +- Use conventional commit/PR language. +- Never add AI attribution or `Co-Authored-By`. +- Ensure code review summary includes what changed, tests/analyze status, and risks. ### judgment-day -- Launch TWO sub-agents via delegate (async, parallel — never sequential) -- Each agent receives the same target but works independently -- Neither agent knows about the other — no cross-contamination -- Classify warnings as WARNING (real) or WARNING (theoretical) -- If confirmed CRITICALs or real WARNINGs exist → delegate Fix Agent -- After Fix Agent completes → re-launch both judges in parallel -- After 2 fix iterations, if issues remain → escalate to user +- Run two independent blind reviews of the same target. +- Synthesize findings, fix real issues, and re-review until both pass or escalation is needed. +- Keep judges focused on correctness, regressions, and requirement coverage. ### skill-creator -- Create a skill when: pattern is used repeatedly, project-specific conventions differ, complex workflows need steps, decision trees help AI -- Don't create a skill when: documentation exists, pattern is trivial, one-off task -- Skill structure: frontmatter (name, description, triggers, allowed-tools), Critical Rules, When to Use, Patterns, Commands +- Create skills with clear trigger, concise rules, and progressive disclosure. +- Avoid embedding large references in `SKILL.md`; link supporting files instead. +- Include actionable constraints and examples only where they prevent mistakes. + +### browser-use:browser +- Use the in-app browser for explicit localhost/file/current-tab inspection. +- Do not substitute shell `open` or generic browsing for explicit Browser Use requests. +- After frontend UI changes, suggest browser testing unless already requested. + +### documents:documents +- For `.docx`, render pages to images and visually verify before delivering. +- Iterate layout until verified; do not assume generated document layout is correct. + +### presentations:Presentations +- Build decks around a clear narrative and chart-first storytelling. +- Render and critique slides before final export. + +### spreadsheets:Spreadsheets +- Use spreadsheet-native formulas/tables/charts when editing `.xlsx`/CSV workflows. +- Recalculate and verify outputs after edits. ## Project Conventions | File | Path | Notes | |------|------|-------| -| SPEC.md | /Users/freetlab/Proyectos/farolero/SPEC.md | Existing SDD artifacts with Explore/Propose/Spec/Tasks/Apply/Verify phases | -| .gga | /Users/freetlab/Proyectos/farolero/.gga | Gentleman Guardian Angel config (AI provider, file patterns, rules file) | +| AGENTS.md | c:/Proyectos/gitea/farolero/AGENTS.md | Flutter/Dart rules: Provider, Clean Architecture, flutter_test, analyze before commit, no Co-Authored-By. | +| analysis_options.yaml | c:/Proyectos/gitea/farolero/analysis_options.yaml | Uses `package:flutter_lints/flutter.yaml`. | +| pubspec.yaml | c:/Proyectos/gitea/farolero/pubspec.yaml | Flutter app dependencies and asset declarations. | Read the convention files listed above for project-specific patterns and rules. All referenced paths have been extracted — no need to read index files to discover more. diff --git a/lib/pantallas/pantalla_debate_cliente.dart b/lib/pantallas/pantalla_debate_cliente.dart index 92d5bc8..9400f4f 100644 --- a/lib/pantallas/pantalla_debate_cliente.dart +++ b/lib/pantallas/pantalla_debate_cliente.dart @@ -1,6 +1,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:farolero/l10n/generated/app_localizations.dart'; +import 'package:farolero/modelos/inicio_partida_multijugador.dart'; +import 'package:farolero/modelos/jugador.dart'; +import 'package:farolero/pantallas/pantalla_notas_online.dart'; +import 'package:farolero/pantallas/pantalla_revision_palabra.dart'; import 'package:farolero/tema/tema_app.dart'; /// Pantalla que ve el jugador durante la fase de debate (multidispositivo). @@ -8,12 +12,20 @@ import 'package:farolero/tema/tema_app.dart'; class PantallaDebateCliente extends StatefulWidget { final int? tiempoDebateSegundos; final String? primerTurnoNombre; + final String? partidaId; + final String? pistaCategoria; + final List jugadores; + final List jugadoresControlados; final VoidCallback onSolicitarVotacion; const PantallaDebateCliente({ super.key, this.tiempoDebateSegundos, this.primerTurnoNombre, + this.partidaId, + this.pistaCategoria, + this.jugadores = const [], + this.jugadoresControlados = const [], required this.onSolicitarVotacion, }); @@ -64,6 +76,35 @@ class _PantallaDebateClienteState extends State { automaticallyImplyLeading: false, backgroundColor: Colors.transparent, elevation: 0, + actions: [ + IconButton( + tooltip: l10n.seeYourWord, + icon: const Icon(Icons.visibility), + onPressed: widget.jugadoresControlados.isEmpty + ? null + : () => mostrarRevisionPalabraOnline( + context: context, + jugadoresControlados: widget.jugadoresControlados, + pistaCategoria: widget.pistaCategoria, + ), + ), + IconButton( + tooltip: l10n.notesTitle, + icon: const Icon(Icons.edit_note), + onPressed: _puedeAbrirNotas + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PantallaNotasOnline( + partidaId: widget.partidaId!, + jugadores: widget.jugadores, + autoresControlados: widget.jugadoresControlados, + ), + ), + ) + : null, + ), + ], ), body: Padding( padding: const EdgeInsets.all(24), @@ -185,4 +226,10 @@ class _PantallaDebateClienteState extends State { ), ); } + + bool get _puedeAbrirNotas { + return widget.partidaId != null && + widget.jugadores.isNotEmpty && + widget.jugadoresControlados.isNotEmpty; + } } diff --git a/lib/pantallas/pantalla_gestor_host.dart b/lib/pantallas/pantalla_gestor_host.dart index acb7272..cdcca96 100644 --- a/lib/pantallas/pantalla_gestor_host.dart +++ b/lib/pantallas/pantalla_gestor_host.dart @@ -10,6 +10,8 @@ import '../modelos/partida.dart'; import '../servicios/servicio_nearby.dart'; import '../tema/componentes_farolero.dart'; import '../tema/tema_app.dart'; +import 'pantalla_notas_online.dart'; +import 'pantalla_revision_palabra.dart'; import 'pantalla_votacion_cliente.dart'; import 'pantalla_palabras_cliente.dart'; @@ -145,6 +147,42 @@ class _PantallaGestorHostState extends State { title: Text(l10n.hostGame), automaticallyImplyLeading: false, actions: [ + IconButton( + tooltip: l10n.seeYourWord, + icon: const Icon(Icons.visibility), + onPressed: partida.fase.index <= FaseJuego.verPalabra.index + ? null + : () => mostrarRevisionPalabraOnline( + context: context, + jugadoresControlados: _jugadoresHostControlados( + partida, + nearby, + ), + pistaCategoria: partida.config.pistaImpostor + ? partida.categoriaReal + : null, + ), + ), + IconButton( + tooltip: l10n.notesTitle, + icon: const Icon(Icons.edit_note), + onPressed: partida.fase.index < FaseJuego.debate.index || + nearby.roomId == null + ? null + : () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PantallaNotasOnline( + partidaId: nearby.roomId!, + jugadores: partida.jugadoresActivos, + autoresControlados: _jugadoresHostControlados( + partida, + nearby, + ), + ), + ), + ), + ), IconButton( icon: const Icon(Icons.close), onPressed: () async { @@ -316,13 +354,14 @@ class _PantallaGestorHostState extends State { ); } - void _mostrarPalabraHost(BuildContext context) { - final estado = context.read(); - final sala = context.read().estadoSala; - final partida = estado.partida; - if (partida == null || sala == null) return; + List _jugadoresHostControlados( + Partida partida, + ServicioNearby nearby, + ) { + final sala = nearby.estadoSala; + if (sala == null) return const []; - final jugadoresHost = sala + return sala .usuariosPorCliente(sala.hostClientId) .where((usuario) => partida.jugadores.any((j) => j.id == usuario.id)) .map((usuario) { @@ -333,10 +372,19 @@ class _PantallaGestorHostState extends State { jugadorId: jugador.id, nombre: jugador.nombre, esImpostor: jugador.esImpostor, - palabra: jugador.palabra, + palabra: jugador.palabra ?? partida.palabraSecreta, ); }) .toList(); + } + + void _mostrarPalabraHost(BuildContext context) { + final estado = context.read(); + final nearby = context.read(); + final partida = estado.partida; + if (partida == null) return; + + final jugadoresHost = _jugadoresHostControlados(partida, nearby); if (jugadoresHost.length > 1) { Navigator.push( @@ -759,6 +807,10 @@ class _PantallaGestorHostState extends State { builder: (_) => PantallaVotacionCliente( jugadores: partida.jugadoresActivos, jugadoresControlados: jugadoresHost, + partidaId: context.read().roomId, + pistaCategoria: partida.config.pistaImpostor + ? partida.categoriaReal + : null, onVotos: (votos) { for (final entry in votos.entries) { estado.registrarVoto(entry.key, entry.value); @@ -874,6 +926,7 @@ class _PantallaRevelarPalabraHost extends StatefulWidget { class _PantallaRevelarPalabraHostState extends State<_PantallaRevelarPalabraHost> { bool _manteniendo = false; + bool _haRevelado = false; @override Widget build(BuildContext context) { @@ -966,7 +1019,10 @@ class _PantallaRevelarPalabraHostState ), const SizedBox(height: 24), GestureDetector( - onLongPressStart: (_) => setState(() => _manteniendo = true), + onLongPressStart: (_) => setState(() { + _manteniendo = true; + _haRevelado = true; + }), onLongPressEnd: (_) => setState(() => _manteniendo = false), child: Container( width: double.infinity, @@ -996,12 +1052,16 @@ class _PantallaRevelarPalabraHostState width: double.infinity, height: 56, child: ElevatedButton.icon( - onPressed: () { - widget.onVisto(); - Navigator.of(context).pop(); - }, + onPressed: _haRevelado + ? () { + widget.onVisto(); + Navigator.of(context).pop(); + } + : null, icon: const Icon(Icons.check), - label: Text(l10n.iveSeenIt), + label: Text( + _haRevelado ? l10n.iveSeenIt : l10n.tapToSee, + ), ), ), ], diff --git a/lib/pantallas/pantalla_notas_online.dart b/lib/pantallas/pantalla_notas_online.dart new file mode 100644 index 0000000..8ecffea --- /dev/null +++ b/lib/pantallas/pantalla_notas_online.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; +import 'package:farolero/l10n/generated/app_localizations.dart'; +import 'package:farolero/modelos/inicio_partida_multijugador.dart'; +import 'package:farolero/modelos/jugador.dart'; +import 'package:farolero/servicios/servicio_notas.dart'; +import 'package:farolero/tema/tema_app.dart'; + +class PantallaNotasOnline extends StatefulWidget { + final String partidaId; + final List jugadores; + final List autoresControlados; + + const PantallaNotasOnline({ + super.key, + required this.partidaId, + required this.jugadores, + required this.autoresControlados, + }); + + @override + State createState() => _PantallaNotasOnlineState(); +} + +class _PantallaNotasOnlineState extends State { + JugadorInicioPartida? _autor; + final Map _controladores = {}; + final TextEditingController _notaLibreController = TextEditingController(); + bool _cargando = false; + + List get _jugadoresActivos => + widget.jugadores.where((jugador) => !jugador.eliminado).toList(); + + @override + void initState() { + super.initState(); + for (final jugador in _jugadoresActivos) { + _controladores[jugador.id] = TextEditingController(); + } + if (widget.autoresControlados.length == 1) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _seleccionarAutor(widget.autoresControlados.first); + }); + } + } + + @override + void dispose() { + for (final controller in _controladores.values) { + controller.dispose(); + } + _notaLibreController.dispose(); + super.dispose(); + } + + Future _seleccionarAutor(JugadorInicioPartida autor) async { + setState(() { + _autor = autor; + _cargando = true; + for (final controller in _controladores.values) { + controller.clear(); + } + _notaLibreController.clear(); + }); + + final datos = await ServicioNotas.cargarNotasPartida( + partidaId: widget.partidaId, + autorJugadorId: autor.jugadorId, + ); + if (!mounted) return; + final notas = datos['notas'] as Map; + for (final entry in notas.entries) { + _controladores[entry.key]?.text = entry.value; + } + _notaLibreController.text = datos['notaLibre'] as String; + setState(() => _cargando = false); + } + + Future _guardarNotas() async { + final autor = _autor; + if (autor == null) return; + final notas = {}; + for (final entry in _controladores.entries) { + final texto = entry.value.text.trim(); + if (texto.isNotEmpty) notas[entry.key] = texto; + } + await ServicioNotas.guardarNotasPartida( + partidaId: widget.partidaId, + autorJugadorId: autor.jugadorId, + notasPorJugador: notas, + notaLibre: _notaLibreController.text, + ); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return PopScope( + canPop: true, + onPopInvokedWithResult: (_, __) => _guardarNotas(), + child: Scaffold( + appBar: AppBar( + title: Text(l10n.notesTitle), + actions: [ + if (_autor != null) + IconButton( + icon: const Icon(Icons.save), + onPressed: () async { + await _guardarNotas(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.notesSaved)), + ); + }, + ), + ], + ), + body: _autor == null ? _buildSelector(context) : _buildNotas(context), + ), + ); + } + + Widget _buildSelector(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.of(context)!.whoAreYou, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + Expanded( + child: ListView( + children: widget.autoresControlados + .map( + (autor) => Card( + child: ListTile( + leading: const Icon(Icons.edit_note), + title: Text(autor.nombre), + onTap: () => _seleccionarAutor(autor), + ), + ), + ) + .toList(), + ), + ), + ], + ), + ); + } + + Widget _buildNotas(BuildContext context) { + if (_cargando) return const Center(child: CircularProgressIndicator()); + final l10n = AppLocalizations.of(context)!; + final autor = _autor!; + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (widget.autoresControlados.length > 1) + IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () async { + await _guardarNotas(); + if (!mounted) return; + setState(() => _autor = null); + }, + ), + Expanded( + child: Text( + l10n.notesOf(autor.nombre), + style: Theme.of(context).textTheme.titleLarge, + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + l10n.notesAboutPlayers, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: TemaApp.colorTextoSecundario, + ), + ), + const SizedBox(height: 8), + ..._jugadoresActivos + .where((jugador) => jugador.id != autor.jugadorId) + .map( + (jugador) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: TextField( + controller: _controladores[jugador.id], + decoration: InputDecoration( + labelText: jugador.nombre, + prefixIcon: const Icon(Icons.person, size: 20), + hintText: l10n.playerNoteHint, + ), + maxLines: 2, + minLines: 1, + ), + ), + ), + const SizedBox(height: 16), + Text( + l10n.freeNote, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: TemaApp.colorTextoSecundario, + ), + ), + const SizedBox(height: 8), + TextField( + controller: _notaLibreController, + decoration: InputDecoration( + hintText: l10n.freeNoteHint, + prefixIcon: const Icon(Icons.note, size: 20), + ), + maxLines: 5, + minLines: 3, + ), + ], + ), + ); + } +} diff --git a/lib/pantallas/pantalla_palabra_cliente.dart b/lib/pantallas/pantalla_palabra_cliente.dart index 8b1bb08..ca4ae72 100644 --- a/lib/pantallas/pantalla_palabra_cliente.dart +++ b/lib/pantallas/pantalla_palabra_cliente.dart @@ -27,10 +27,14 @@ class PantallaPalabraCliente extends StatefulWidget { class _PantallaPalabraClienteState extends State { bool _palabraVisible = false; + bool _haRevelado = false; Timer? _timer; void _togglePalabra() { - setState(() => _palabraVisible = !_palabraVisible); + setState(() { + _palabraVisible = !_palabraVisible; + if (_palabraVisible) _haRevelado = true; + }); _timer?.cancel(); } @@ -88,17 +92,28 @@ class _PantallaPalabraClienteState extends State { size: 32, ), const SizedBox(height: 16), - _palabraVisible - ? TarjetaPalabraFarolero(palabra: widget.palabra) - : const Text( - '???', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.bold, - color: TemaApp.colorTextoSecundario, - ), - ), + if (_palabraVisible && widget.esImpostor) + Text( + l10n.youAreImpostor, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: TemaApp.colorDorado, + ), + ) + else if (_palabraVisible) + TarjetaPalabraFarolero(palabra: widget.palabra) + else + const Text( + '???', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: TemaApp.colorTextoSecundario, + ), + ), ], ), ), @@ -137,7 +152,9 @@ class _PantallaPalabraClienteState extends State { child: Text( _palabraVisible ? 'Mantén la pantalla oculta. No la enseñes a nadie.' - : 'Toca para ver tu palabra', + : _haRevelado + ? l10n.seeYourWord + : l10n.tapToSee, textAlign: TextAlign.center, style: TextStyle( color: TemaApp.colorTextoSecundario, @@ -153,11 +170,11 @@ class _PantallaPalabraClienteState extends State { width: double.infinity, height: 56, child: ElevatedButton.icon( - onPressed: () { - widget.onVisto(); - }, + onPressed: _haRevelado ? widget.onVisto : null, icon: const Icon(Icons.check), - label: Text(l10n.iveSeenIt), + label: Text( + _haRevelado ? l10n.iveSeenIt : l10n.tapToSee, + ), style: ElevatedButton.styleFrom( backgroundColor: TemaApp.colorAcento, foregroundColor: Colors.white, diff --git a/lib/pantallas/pantalla_palabras_cliente.dart b/lib/pantallas/pantalla_palabras_cliente.dart index 37fbb76..105f26f 100644 --- a/lib/pantallas/pantalla_palabras_cliente.dart +++ b/lib/pantallas/pantalla_palabras_cliente.dart @@ -24,11 +24,14 @@ class PantallaPalabrasCliente extends StatefulWidget { class _PantallaPalabrasClienteState extends State { int _indice = 0; bool _visible = false; + final Set _jugadoresRevelados = {}; JugadorInicioPartida get _actual => widget.jugadores[_indice]; bool get _esUltimo => _indice == widget.jugadores.length - 1; + bool get _actualRevelado => _jugadoresRevelados.contains(_actual.jugadorId); void _continuar() { + if (!_actualRevelado) return; if (_esUltimo) { widget.onTodosVistos(); return; @@ -64,7 +67,10 @@ class _PantallaPalabrasClienteState extends State { ), const SizedBox(height: 24), GestureDetector( - onTap: () => setState(() => _visible = !_visible), + onTap: () => setState(() { + _visible = !_visible; + if (_visible) _jugadoresRevelados.add(actual.jugadorId); + }), child: AnimatedContainer( duration: const Duration(milliseconds: 250), width: double.infinity, @@ -113,14 +119,26 @@ class _PantallaPalabrasClienteState extends State { textAlign: TextAlign.center, ), ], + const SizedBox(height: 12), + Text( + _actualRevelado + ? l10n.seeYourWord + : l10n.tapToSee, + textAlign: TextAlign.center, + style: TextStyle(color: TemaApp.colorTextoSecundario), + ), const Spacer(), SizedBox( width: double.infinity, height: 56, child: ElevatedButton.icon( - onPressed: _continuar, + onPressed: _actualRevelado ? _continuar : null, icon: Icon(_esUltimo ? Icons.check : Icons.arrow_forward), - label: Text(_esUltimo ? l10n.iveSeenIt : 'Siguiente jugador'), + label: Text( + _actualRevelado + ? (_esUltimo ? l10n.iveSeenIt : l10n.next) + : l10n.tapToSee, + ), ), ), ], diff --git a/lib/pantallas/pantalla_revision_palabra.dart b/lib/pantallas/pantalla_revision_palabra.dart new file mode 100644 index 0000000..385cc72 --- /dev/null +++ b/lib/pantallas/pantalla_revision_palabra.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:farolero/l10n/generated/app_localizations.dart'; +import 'package:farolero/modelos/inicio_partida_multijugador.dart'; +import 'package:farolero/tema/componentes_farolero.dart'; +import 'package:farolero/tema/tema_app.dart'; + +Future mostrarRevisionPalabraOnline({ + required BuildContext context, + required List jugadoresControlados, + String? pistaCategoria, +}) async { + if (jugadoresControlados.isEmpty) return; + + final jugador = jugadoresControlados.length == 1 + ? jugadoresControlados.first + : await showModalBottomSheet( + context: context, + backgroundColor: TemaApp.colorSuperficie, + builder: (sheetContext) => SafeArea( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.all(16), + children: [ + Text( + AppLocalizations.of(sheetContext)!.seeYourWord, + style: Theme.of(sheetContext).textTheme.titleLarge, + ), + const SizedBox(height: 12), + ...jugadoresControlados.map( + (jugador) => Card( + color: TemaApp.colorTarjeta, + child: ListTile( + leading: const Icon(Icons.visibility), + title: Text(jugador.nombre), + onTap: () => Navigator.pop(sheetContext, jugador), + ), + ), + ), + ], + ), + ), + ); + + if (jugador == null || !context.mounted) return; + await showDialog( + context: context, + builder: (dialogContext) => _DialogoRevisionPalabra( + jugador: jugador, + pistaCategoria: pistaCategoria, + ), + ); +} + +class _DialogoRevisionPalabra extends StatelessWidget { + final JugadorInicioPartida jugador; + final String? pistaCategoria; + + const _DialogoRevisionPalabra({ + required this.jugador, + required this.pistaCategoria, + }); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Dialog( + backgroundColor: TemaApp.colorSuperficie, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + jugador.nombre, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: TemaApp.decoracionPanel( + color: TemaApp.colorTarjeta, + borderColor: jugador.esImpostor + ? TemaApp.colorAcento + : TemaApp.colorNaranja, + ), + child: Column( + children: [ + Icon( + jugador.esImpostor ? Icons.theater_comedy : Icons.key, + color: jugador.esImpostor + ? TemaApp.colorAcento + : TemaApp.colorNaranja, + size: 36, + ), + const SizedBox(height: 16), + if (jugador.esImpostor) + Text( + l10n.youAreImpostor, + style: Theme.of(context).textTheme.headlineSmall, + textAlign: TextAlign.center, + ) + else + TarjetaPalabraFarolero(palabra: jugador.palabra ?? ''), + if (jugador.esImpostor && pistaCategoria != null) ...[ + const SizedBox(height: 12), + Text( + l10n.clueIs(pistaCategoria!), + style: const TextStyle(color: TemaApp.colorNaranja), + textAlign: TextAlign.center, + ), + ], + ], + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => Navigator.pop(context), + icon: const Icon(Icons.check), + label: Text(l10n.back), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pantallas/pantalla_unirse.dart b/lib/pantallas/pantalla_unirse.dart index 1fa5e50..b781a34 100644 --- a/lib/pantallas/pantalla_unirse.dart +++ b/lib/pantallas/pantalla_unirse.dart @@ -39,6 +39,7 @@ class _PantallaUnirseState extends State { String? _palabraRecibida; bool _esImpostor = false; String? _pistaCategoria; + String? _partidaId; final List _jugadores = []; final List _jugadoresControlados = []; @@ -87,8 +88,24 @@ class _PantallaUnirseState extends State { } else { _palabraRecibida = mensaje.datos['palabra'] as String?; _esImpostor = mensaje.datos['esImpostor'] as bool? ?? false; + if (_palabraRecibida != null) { + _jugadoresControlados.add( + JugadorInicioPartida( + jugadorId: nearby.miClientId ?? '_legacy', + nombre: _nombreController.text.trim().isEmpty + ? 'Jugador' + : _nombreController.text.trim(), + esImpostor: _esImpostor, + palabra: _palabraRecibida, + ), + ); + } } _pistaCategoria = mensaje.datos['categoria'] as String?; + _partidaId = (mensaje.datos['roomId'] as String?) ?? + nearby.roomId ?? + (mensaje.datos['clientId'] as String?) ?? + DateTime.now().microsecondsSinceEpoch.toString(); }); // Navegar a pantalla de palabra del cliente if (mounted && (_jugadoresControlados.isNotEmpty || _palabraRecibida != null)) { @@ -160,6 +177,10 @@ class _PantallaUnirseState extends State { datosFase?['tiempoDebateSegundos'] as int?, primerTurnoNombre: datosFase?['primerTurnoNombre'] as String?, + partidaId: _partidaId ?? context.read().roomId, + pistaCategoria: _pistaCategoria, + jugadores: List.unmodifiable(_jugadores), + jugadoresControlados: List.unmodifiable(_jugadoresControlados), onSolicitarVotacion: () { final nearby = context.read(); if (nearby.hostEndpointId != null) { @@ -182,6 +203,8 @@ class _PantallaUnirseState extends State { builder: (_) => PantallaVotacionCliente( jugadores: _jugadores, jugadoresControlados: List.unmodifiable(_jugadoresControlados), + partidaId: _partidaId ?? context.read().roomId, + pistaCategoria: _pistaCategoria, onVotos: (votos) { final nearby = context.read(); if (nearby.hostEndpointId != null) { diff --git a/lib/pantallas/pantalla_votacion_cliente.dart b/lib/pantallas/pantalla_votacion_cliente.dart index baf088d..0ce3820 100644 --- a/lib/pantallas/pantalla_votacion_cliente.dart +++ b/lib/pantallas/pantalla_votacion_cliente.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/modelos/inicio_partida_multijugador.dart'; import 'package:farolero/modelos/jugador.dart'; +import 'package:farolero/pantallas/pantalla_notas_online.dart'; +import 'package:farolero/pantallas/pantalla_revision_palabra.dart'; import 'package:farolero/servicios/servicio_nearby.dart'; import 'package:farolero/tema/tema_app.dart'; import 'package:provider/provider.dart'; @@ -12,12 +14,16 @@ import 'package:provider/provider.dart'; class PantallaVotacionCliente extends StatefulWidget { final List jugadores; final List jugadoresControlados; + final String? partidaId; + final String? pistaCategoria; final Function(Map votos) onVotos; const PantallaVotacionCliente({ super.key, required this.jugadores, this.jugadoresControlados = const [], + this.partidaId, + this.pistaCategoria, required this.onVotos, }); @@ -75,6 +81,35 @@ class _PantallaVotacionClienteState extends State { automaticallyImplyLeading: false, backgroundColor: Colors.transparent, elevation: 0, + actions: [ + IconButton( + tooltip: l10n.seeYourWord, + icon: const Icon(Icons.visibility), + onPressed: widget.jugadoresControlados.isEmpty + ? null + : () => mostrarRevisionPalabraOnline( + context: context, + jugadoresControlados: widget.jugadoresControlados, + pistaCategoria: widget.pistaCategoria, + ), + ), + IconButton( + tooltip: l10n.notesTitle, + icon: const Icon(Icons.edit_note), + onPressed: _puedeAbrirNotas + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PantallaNotasOnline( + partidaId: widget.partidaId!, + jugadores: widget.jugadores, + autoresControlados: widget.jugadoresControlados, + ), + ), + ) + : null, + ), + ], ), body: Padding( padding: const EdgeInsets.all(16), @@ -127,7 +162,14 @@ class _PantallaVotacionClienteState extends State { ); } + bool get _puedeAbrirNotas { + return widget.partidaId != null && + widget.jugadores.isNotEmpty && + widget.jugadoresControlados.isNotEmpty; + } + Widget _buildResultado(BuildContext context, Map resultado) { + final l10n = AppLocalizations.of(context)!; final eliminadoId = resultado['eliminadoId'] as String?; final eliminadoNombre = resultado['eliminadoNombre'] as String? ?? '?'; final eraImpostor = resultado['eraImpostor'] as bool? ?? false; @@ -153,6 +195,35 @@ class _PantallaVotacionClienteState extends State { automaticallyImplyLeading: false, backgroundColor: Colors.transparent, elevation: 0, + actions: [ + IconButton( + tooltip: l10n.seeYourWord, + icon: const Icon(Icons.visibility), + onPressed: widget.jugadoresControlados.isEmpty + ? null + : () => mostrarRevisionPalabraOnline( + context: context, + jugadoresControlados: widget.jugadoresControlados, + pistaCategoria: widget.pistaCategoria, + ), + ), + IconButton( + tooltip: l10n.notesTitle, + icon: const Icon(Icons.edit_note), + onPressed: _puedeAbrirNotas + ? () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => PantallaNotasOnline( + partidaId: widget.partidaId!, + jugadores: widget.jugadores, + autoresControlados: widget.jugadoresControlados, + ), + ), + ) + : null, + ), + ], ), body: Padding( padding: const EdgeInsets.all(24), diff --git a/lib/servicios/servicio_nearby.dart b/lib/servicios/servicio_nearby.dart index 27a8280..4af5625 100644 --- a/lib/servicios/servicio_nearby.dart +++ b/lib/servicios/servicio_nearby.dart @@ -794,6 +794,7 @@ class ServicioNearby extends ChangeNotifier { 'esImpostor': esImpostor, 'categoria': categoria, 'numJugadores': _jugadores.length + 1, + 'roomId': _roomId, }, ), ); @@ -819,6 +820,7 @@ class ServicioNearby extends ChangeNotifier { if (endpointId == null) continue; final datos = payload.toJson(); datos['jugadoresTodos'] = jugadoresTodos; + datos['roomId'] = _roomId; await enviarMensaje( endpointId, MensajeP2P(tipo: TipoMensaje.partidaInicio, datos: datos), diff --git a/lib/servicios/servicio_notas.dart b/lib/servicios/servicio_notas.dart index 9c9be24..62880ee 100644 --- a/lib/servicios/servicio_notas.dart +++ b/lib/servicios/servicio_notas.dart @@ -1,42 +1,94 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; -/// Servicio para persistir las notas de los jugadores localmente +/// Servicio para persistir las notas de los jugadores localmente. class ServicioNotas { static const _clavePrefix = 'notas_'; + static const _clavePartidaPrefix = 'notas_partida_'; - /// Guarda las notas de un jugador para una partida + /// Guarda las notas de un jugador para una partida local/legacy. static Future guardarNotas( String jugadorId, Map notas, String notaLibre, ) async { final prefs = await SharedPreferences.getInstance(); - final datos = { + await prefs.setString( + '$_clavePrefix$jugadorId', + json.encode(_serializar(notas, notaLibre)), + ); + } + + /// Carga las notas de un jugador en modo local/legacy. + static Future> cargarNotas(String jugadorId) async { + final prefs = await SharedPreferences.getInstance(); + return _decodificar(prefs.getString('$_clavePrefix$jugadorId')); + } + + /// Guarda notas privadas scoped por partida y jugador autor. + /// + /// Esto evita que una partida online contamine otra aunque se reutilicen + /// nombres o ids visibles de jugador. + static Future guardarNotasPartida({ + required String partidaId, + required String autorJugadorId, + required Map notasPorJugador, + required String notaLibre, + }) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _claveNotasPartida(partidaId, autorJugadorId), + json.encode(_serializar(notasPorJugador, notaLibre)), + ); + } + + /// Carga notas privadas scoped por partida y jugador autor. + static Future> cargarNotasPartida({ + required String partidaId, + required String autorJugadorId, + }) async { + final prefs = await SharedPreferences.getInstance(); + return _decodificar( + prefs.getString(_claveNotasPartida(partidaId, autorJugadorId)), + ); + } + + static String _claveNotasPartida(String partidaId, String autorJugadorId) { + return '$_clavePartidaPrefix${_normalizarClave(partidaId)}_${_normalizarClave(autorJugadorId)}'; + } + + static String _normalizarClave(String valor) { + return base64Url.encode(utf8.encode(valor)); + } + + static Map _serializar( + Map notas, + String notaLibre, + ) { + return { 'notas': notas, 'notaLibre': notaLibre, }; - await prefs.setString('$_clavePrefix$jugadorId', json.encode(datos)); } - /// Carga las notas de un jugador - static Future> cargarNotas(String jugadorId) async { - final prefs = await SharedPreferences.getInstance(); - final str = prefs.getString('$_clavePrefix$jugadorId'); + static Map _decodificar(String? str) { if (str == null) { return {'notas': {}, 'notaLibre': ''}; } final datos = json.decode(str) as Map; return { 'notas': Map.from(datos['notas'] ?? {}), - 'notaLibre': datos['notaLibre'] ?? '', + 'notaLibre': datos['notaLibre'] as String? ?? '', }; } - /// Limpia todas las notas (al iniciar nueva partida) + /// Limpia todas las notas (al iniciar nueva partida local o reset manual). static Future limpiarNotas() async { final prefs = await SharedPreferences.getInstance(); - final claves = prefs.getKeys().where((k) => k.startsWith(_clavePrefix)); + final claves = prefs + .getKeys() + .where((k) => k.startsWith(_clavePrefix)) + .toList(); for (final clave in claves) { await prefs.remove(clave); } diff --git a/test/servicio_notas_test.dart b/test/servicio_notas_test.dart new file mode 100644 index 0000000..d06d366 --- /dev/null +++ b/test/servicio_notas_test.dart @@ -0,0 +1,57 @@ +import 'package:farolero/servicios/servicio_notas.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('guarda y carga notas scoped por partida y autor', () async { + await ServicioNotas.guardarNotasPartida( + partidaId: 'sala-1', + autorJugadorId: 'jugador-a', + notasPorJugador: {'jugador-b': 'Dijo perro demasiado rápido'}, + notaLibre: 'Sospecho de B', + ); + + final datos = await ServicioNotas.cargarNotasPartida( + partidaId: 'sala-1', + autorJugadorId: 'jugador-a', + ); + + expect(datos['notas'], {'jugador-b': 'Dijo perro demasiado rápido'}); + expect(datos['notaLibre'], 'Sospecho de B'); + }); + + test('no mezcla notas entre partidas ni autores', () async { + await ServicioNotas.guardarNotasPartida( + partidaId: 'sala-1', + autorJugadorId: 'jugador-a', + notasPorJugador: {'jugador-b': 'nota sala 1'}, + notaLibre: 'libre 1', + ); + await ServicioNotas.guardarNotasPartida( + partidaId: 'sala-2', + autorJugadorId: 'jugador-a', + notasPorJugador: {'jugador-b': 'nota sala 2'}, + notaLibre: 'libre 2', + ); + + final otraPartida = await ServicioNotas.cargarNotasPartida( + partidaId: 'sala-2', + autorJugadorId: 'jugador-a', + ); + final otroAutor = await ServicioNotas.cargarNotasPartida( + partidaId: 'sala-1', + autorJugadorId: 'jugador-c', + ); + + expect(otraPartida['notas'], {'jugador-b': 'nota sala 2'}); + expect(otraPartida['notaLibre'], 'libre 2'); + expect(otroAutor['notas'], isEmpty); + expect(otroAutor['notaLibre'], isEmpty); + }); +}