Implementado:

No se puede marcar “vista” sin revelar la palabra antes.
Se puede volver a ver la palabra durante debate/votación/resultado.
Notas online privadas por partida y jugador.
Tests añadidos para notas scoped.
Ajusté roomId en el payload de inicio para que las notas no se mezclen entre partidas.
This commit is contained in:
2026-05-05 21:49:40 +02:00
parent 1abdeb2f56
commit 6e5e423ab4
12 changed files with 802 additions and 75 deletions

View File

@@ -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.

View File

@@ -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<Jugador> jugadores;
final List<JugadorInicioPartida> 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<PantallaDebateCliente> {
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<PantallaDebateCliente> {
),
);
}
bool get _puedeAbrirNotas {
return widget.partidaId != null &&
widget.jugadores.isNotEmpty &&
widget.jugadoresControlados.isNotEmpty;
}
}

View File

@@ -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<PantallaGestorHost> {
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<PantallaGestorHost> {
);
}
void _mostrarPalabraHost(BuildContext context) {
final estado = context.read<EstadoJuego>();
final sala = context.read<ServicioNearby>().estadoSala;
final partida = estado.partida;
if (partida == null || sala == null) return;
List<JugadorInicioPartida> _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<PantallaGestorHost> {
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<EstadoJuego>();
final nearby = context.read<ServicioNearby>();
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<PantallaGestorHost> {
builder: (_) => PantallaVotacionCliente(
jugadores: partida.jugadoresActivos,
jugadoresControlados: jugadoresHost,
partidaId: context.read<ServicioNearby>().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,
),
),
),
],

View File

@@ -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<Jugador> jugadores;
final List<JugadorInicioPartida> autoresControlados;
const PantallaNotasOnline({
super.key,
required this.partidaId,
required this.jugadores,
required this.autoresControlados,
});
@override
State<PantallaNotasOnline> createState() => _PantallaNotasOnlineState();
}
class _PantallaNotasOnlineState extends State<PantallaNotasOnline> {
JugadorInicioPartida? _autor;
final Map<String, TextEditingController> _controladores = {};
final TextEditingController _notaLibreController = TextEditingController();
bool _cargando = false;
List<Jugador> 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<void> _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<String, String>;
for (final entry in notas.entries) {
_controladores[entry.key]?.text = entry.value;
}
_notaLibreController.text = datos['notaLibre'] as String;
setState(() => _cargando = false);
}
Future<void> _guardarNotas() async {
final autor = _autor;
if (autor == null) return;
final notas = <String, String>{};
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,
),
],
),
);
}
}

View File

@@ -27,10 +27,14 @@ class PantallaPalabraCliente extends StatefulWidget {
class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
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<PantallaPalabraCliente> {
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<PantallaPalabraCliente> {
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<PantallaPalabraCliente> {
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,

View File

@@ -24,11 +24,14 @@ class PantallaPalabrasCliente extends StatefulWidget {
class _PantallaPalabrasClienteState extends State<PantallaPalabrasCliente> {
int _indice = 0;
bool _visible = false;
final Set<String> _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<PantallaPalabrasCliente> {
),
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<PantallaPalabrasCliente> {
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,
),
),
),
],

View File

@@ -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<void> mostrarRevisionPalabraOnline({
required BuildContext context,
required List<JugadorInicioPartida> jugadoresControlados,
String? pistaCategoria,
}) async {
if (jugadoresControlados.isEmpty) return;
final jugador = jugadoresControlados.length == 1
? jugadoresControlados.first
: await showModalBottomSheet<JugadorInicioPartida>(
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<void>(
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),
),
),
],
),
),
);
}
}

View File

@@ -39,6 +39,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
String? _palabraRecibida;
bool _esImpostor = false;
String? _pistaCategoria;
String? _partidaId;
final List<Jugador> _jugadores = [];
final List<JugadorInicioPartida> _jugadoresControlados = [];
@@ -87,8 +88,24 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
} 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<PantallaUnirse> {
datosFase?['tiempoDebateSegundos'] as int?,
primerTurnoNombre:
datosFase?['primerTurnoNombre'] as String?,
partidaId: _partidaId ?? context.read<ServicioNearby>().roomId,
pistaCategoria: _pistaCategoria,
jugadores: List.unmodifiable(_jugadores),
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
onSolicitarVotacion: () {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {
@@ -182,6 +203,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
builder: (_) => PantallaVotacionCliente(
jugadores: _jugadores,
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
partidaId: _partidaId ?? context.read<ServicioNearby>().roomId,
pistaCategoria: _pistaCategoria,
onVotos: (votos) {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {

View File

@@ -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<Jugador> jugadores;
final List<JugadorInicioPartida> jugadoresControlados;
final String? partidaId;
final String? pistaCategoria;
final Function(Map<String, String> 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<PantallaVotacionCliente> {
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<PantallaVotacionCliente> {
);
}
bool get _puedeAbrirNotas {
return widget.partidaId != null &&
widget.jugadores.isNotEmpty &&
widget.jugadoresControlados.isNotEmpty;
}
Widget _buildResultado(BuildContext context, Map<String, dynamic> 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<PantallaVotacionCliente> {
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),

View File

@@ -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),

View File

@@ -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<void> guardarNotas(
String jugadorId,
Map<String, String> 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<Map<String, dynamic>> 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<void> guardarNotasPartida({
required String partidaId,
required String autorJugadorId,
required Map<String, String> 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<Map<String, dynamic>> 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<String, Object> _serializar(
Map<String, String> notas,
String notaLibre,
) {
return {
'notas': notas,
'notaLibre': notaLibre,
};
await prefs.setString('$_clavePrefix$jugadorId', json.encode(datos));
}
/// Carga las notas de un jugador
static Future<Map<String, dynamic>> cargarNotas(String jugadorId) async {
final prefs = await SharedPreferences.getInstance();
final str = prefs.getString('$_clavePrefix$jugadorId');
static Map<String, dynamic> _decodificar(String? str) {
if (str == null) {
return {'notas': <String, String>{}, 'notaLibre': ''};
}
final datos = json.decode(str) as Map<String, dynamic>;
return {
'notas': Map<String, String>.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<void> 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);
}

View File

@@ -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);
});
}