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:
+49
-31
@@ -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.
|
**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 |
|
| 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 |
|
| Go tests, Bubbletea TUI testing | go-testing | C:/Users/jbwhi/.codex/skills/go-testing/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 |
|
| Creating a GitHub issue, reporting a bug, or requesting a feature | issue-creation | C:/Users/jbwhi/.codex/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 |
|
| Creating a pull request or preparing changes for review | branch-pr | C:/Users/jbwhi/.codex/skills/branch-pr/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 |
|
| 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
|
## Compact Rules
|
||||||
|
|
||||||
Pre-digested rules per skill. Delegators copy matching blocks into sub-agent prompts as `## Project Standards (auto-resolved)`.
|
Pre-digested rules per skill. Delegators copy matching blocks into sub-agent prompts as `## Project Standards (auto-resolved)`.
|
||||||
|
|
||||||
### branch-pr
|
### go-testing
|
||||||
- Every PR MUST link an approved issue — no exceptions
|
- Use `go test` patterns and Bubbletea `teatest` when touching Go/TUI code.
|
||||||
- Every PR MUST have exactly one `type:*` label
|
- Prefer deterministic tests and isolate terminal/model effects.
|
||||||
- Automated checks must pass before merge is possible
|
- Keep tests close to behavior and avoid brittle timing assumptions.
|
||||||
- Branch names must match: `^(feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert)/[a-z0-9._-]+$`
|
- Not applicable to this Flutter/Dart project unless Go files are introduced.
|
||||||
- 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
|
|
||||||
|
|
||||||
### issue-creation
|
### issue-creation
|
||||||
- Blank issues are disabled — MUST use a template (bug report or feature request)
|
- Follow issue-first workflow before PR work when a feature/bug needs tracking.
|
||||||
- Every issue gets `status:needs-review` automatically on creation
|
- Capture problem, expected behavior, acceptance criteria, and verification steps.
|
||||||
- A maintainer MUST add `status:approved` before any PR can be opened
|
- Do not create noisy or duplicate issues without checking existing context.
|
||||||
- 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
|
### branch-pr
|
||||||
- Feature request template required fields: Pre-flight Checks, Problem Description, Proposed Solution, Affected Area
|
- 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
|
### judgment-day
|
||||||
- Launch TWO sub-agents via delegate (async, parallel — never sequential)
|
- Run two independent blind reviews of the same target.
|
||||||
- Each agent receives the same target but works independently
|
- Synthesize findings, fix real issues, and re-review until both pass or escalation is needed.
|
||||||
- Neither agent knows about the other — no cross-contamination
|
- Keep judges focused on correctness, regressions, and requirement coverage.
|
||||||
- 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
|
|
||||||
|
|
||||||
### skill-creator
|
### skill-creator
|
||||||
- Create a skill when: pattern is used repeatedly, project-specific conventions differ, complex workflows need steps, decision trees help AI
|
- Create skills with clear trigger, concise rules, and progressive disclosure.
|
||||||
- Don't create a skill when: documentation exists, pattern is trivial, one-off task
|
- Avoid embedding large references in `SKILL.md`; link supporting files instead.
|
||||||
- Skill structure: frontmatter (name, description, triggers, allowed-tools), Critical Rules, When to Use, Patterns, Commands
|
- 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
|
## Project Conventions
|
||||||
|
|
||||||
| File | Path | Notes |
|
| File | Path | Notes |
|
||||||
|------|------|-------|
|
|------|------|-------|
|
||||||
| SPEC.md | /Users/freetlab/Proyectos/farolero/SPEC.md | Existing SDD artifacts with Explore/Propose/Spec/Tasks/Apply/Verify phases |
|
| AGENTS.md | c:/Proyectos/gitea/farolero/AGENTS.md | Flutter/Dart rules: Provider, Clean Architecture, flutter_test, analyze before commit, no Co-Authored-By. |
|
||||||
| .gga | /Users/freetlab/Proyectos/farolero/.gga | Gentleman Guardian Angel config (AI provider, file patterns, rules file) |
|
| 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.
|
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.
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:farolero/l10n/generated/app_localizations.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';
|
import 'package:farolero/tema/tema_app.dart';
|
||||||
|
|
||||||
/// Pantalla que ve el jugador durante la fase de debate (multidispositivo).
|
/// 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 {
|
class PantallaDebateCliente extends StatefulWidget {
|
||||||
final int? tiempoDebateSegundos;
|
final int? tiempoDebateSegundos;
|
||||||
final String? primerTurnoNombre;
|
final String? primerTurnoNombre;
|
||||||
|
final String? partidaId;
|
||||||
|
final String? pistaCategoria;
|
||||||
|
final List<Jugador> jugadores;
|
||||||
|
final List<JugadorInicioPartida> jugadoresControlados;
|
||||||
final VoidCallback onSolicitarVotacion;
|
final VoidCallback onSolicitarVotacion;
|
||||||
|
|
||||||
const PantallaDebateCliente({
|
const PantallaDebateCliente({
|
||||||
super.key,
|
super.key,
|
||||||
this.tiempoDebateSegundos,
|
this.tiempoDebateSegundos,
|
||||||
this.primerTurnoNombre,
|
this.primerTurnoNombre,
|
||||||
|
this.partidaId,
|
||||||
|
this.pistaCategoria,
|
||||||
|
this.jugadores = const [],
|
||||||
|
this.jugadoresControlados = const [],
|
||||||
required this.onSolicitarVotacion,
|
required this.onSolicitarVotacion,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -64,6 +76,35 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
|
|||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
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(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import '../modelos/partida.dart';
|
|||||||
import '../servicios/servicio_nearby.dart';
|
import '../servicios/servicio_nearby.dart';
|
||||||
import '../tema/componentes_farolero.dart';
|
import '../tema/componentes_farolero.dart';
|
||||||
import '../tema/tema_app.dart';
|
import '../tema/tema_app.dart';
|
||||||
|
import 'pantalla_notas_online.dart';
|
||||||
|
import 'pantalla_revision_palabra.dart';
|
||||||
import 'pantalla_votacion_cliente.dart';
|
import 'pantalla_votacion_cliente.dart';
|
||||||
import 'pantalla_palabras_cliente.dart';
|
import 'pantalla_palabras_cliente.dart';
|
||||||
|
|
||||||
@@ -145,6 +147,42 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
|||||||
title: Text(l10n.hostGame),
|
title: Text(l10n.hostGame),
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
actions: [
|
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(
|
IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: const Icon(Icons.close),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -316,13 +354,14 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _mostrarPalabraHost(BuildContext context) {
|
List<JugadorInicioPartida> _jugadoresHostControlados(
|
||||||
final estado = context.read<EstadoJuego>();
|
Partida partida,
|
||||||
final sala = context.read<ServicioNearby>().estadoSala;
|
ServicioNearby nearby,
|
||||||
final partida = estado.partida;
|
) {
|
||||||
if (partida == null || sala == null) return;
|
final sala = nearby.estadoSala;
|
||||||
|
if (sala == null) return const [];
|
||||||
|
|
||||||
final jugadoresHost = sala
|
return sala
|
||||||
.usuariosPorCliente(sala.hostClientId)
|
.usuariosPorCliente(sala.hostClientId)
|
||||||
.where((usuario) => partida.jugadores.any((j) => j.id == usuario.id))
|
.where((usuario) => partida.jugadores.any((j) => j.id == usuario.id))
|
||||||
.map((usuario) {
|
.map((usuario) {
|
||||||
@@ -333,10 +372,19 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
|||||||
jugadorId: jugador.id,
|
jugadorId: jugador.id,
|
||||||
nombre: jugador.nombre,
|
nombre: jugador.nombre,
|
||||||
esImpostor: jugador.esImpostor,
|
esImpostor: jugador.esImpostor,
|
||||||
palabra: jugador.palabra,
|
palabra: jugador.palabra ?? partida.palabraSecreta,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.toList();
|
.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) {
|
if (jugadoresHost.length > 1) {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
@@ -759,6 +807,10 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
|
|||||||
builder: (_) => PantallaVotacionCliente(
|
builder: (_) => PantallaVotacionCliente(
|
||||||
jugadores: partida.jugadoresActivos,
|
jugadores: partida.jugadoresActivos,
|
||||||
jugadoresControlados: jugadoresHost,
|
jugadoresControlados: jugadoresHost,
|
||||||
|
partidaId: context.read<ServicioNearby>().roomId,
|
||||||
|
pistaCategoria: partida.config.pistaImpostor
|
||||||
|
? partida.categoriaReal
|
||||||
|
: null,
|
||||||
onVotos: (votos) {
|
onVotos: (votos) {
|
||||||
for (final entry in votos.entries) {
|
for (final entry in votos.entries) {
|
||||||
estado.registrarVoto(entry.key, entry.value);
|
estado.registrarVoto(entry.key, entry.value);
|
||||||
@@ -874,6 +926,7 @@ class _PantallaRevelarPalabraHost extends StatefulWidget {
|
|||||||
class _PantallaRevelarPalabraHostState
|
class _PantallaRevelarPalabraHostState
|
||||||
extends State<_PantallaRevelarPalabraHost> {
|
extends State<_PantallaRevelarPalabraHost> {
|
||||||
bool _manteniendo = false;
|
bool _manteniendo = false;
|
||||||
|
bool _haRevelado = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -966,7 +1019,10 @@ class _PantallaRevelarPalabraHostState
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onLongPressStart: (_) => setState(() => _manteniendo = true),
|
onLongPressStart: (_) => setState(() {
|
||||||
|
_manteniendo = true;
|
||||||
|
_haRevelado = true;
|
||||||
|
}),
|
||||||
onLongPressEnd: (_) => setState(() => _manteniendo = false),
|
onLongPressEnd: (_) => setState(() => _manteniendo = false),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -996,12 +1052,16 @@ class _PantallaRevelarPalabraHostState
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 56,
|
height: 56,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: () {
|
onPressed: _haRevelado
|
||||||
|
? () {
|
||||||
widget.onVisto();
|
widget.onVisto();
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
}
|
||||||
|
: null,
|
||||||
icon: const Icon(Icons.check),
|
icon: const Icon(Icons.check),
|
||||||
label: Text(l10n.iveSeenIt),
|
label: Text(
|
||||||
|
_haRevelado ? l10n.iveSeenIt : l10n.tapToSee,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,10 +27,14 @@ class PantallaPalabraCliente extends StatefulWidget {
|
|||||||
|
|
||||||
class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
|
class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
|
||||||
bool _palabraVisible = false;
|
bool _palabraVisible = false;
|
||||||
|
bool _haRevelado = false;
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
|
||||||
void _togglePalabra() {
|
void _togglePalabra() {
|
||||||
setState(() => _palabraVisible = !_palabraVisible);
|
setState(() {
|
||||||
|
_palabraVisible = !_palabraVisible;
|
||||||
|
if (_palabraVisible) _haRevelado = true;
|
||||||
|
});
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +92,20 @@ class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
|
|||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_palabraVisible
|
if (_palabraVisible && widget.esImpostor)
|
||||||
? TarjetaPalabraFarolero(palabra: widget.palabra)
|
Text(
|
||||||
: const 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,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -137,7 +152,9 @@ class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
_palabraVisible
|
_palabraVisible
|
||||||
? 'Mantén la pantalla oculta. No la enseñes a nadie.'
|
? 'Mantén la pantalla oculta. No la enseñes a nadie.'
|
||||||
: 'Toca para ver tu palabra',
|
: _haRevelado
|
||||||
|
? l10n.seeYourWord
|
||||||
|
: l10n.tapToSee,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: TemaApp.colorTextoSecundario,
|
color: TemaApp.colorTextoSecundario,
|
||||||
@@ -153,11 +170,11 @@ class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 56,
|
height: 56,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: () {
|
onPressed: _haRevelado ? widget.onVisto : null,
|
||||||
widget.onVisto();
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.check),
|
icon: const Icon(Icons.check),
|
||||||
label: Text(l10n.iveSeenIt),
|
label: Text(
|
||||||
|
_haRevelado ? l10n.iveSeenIt : l10n.tapToSee,
|
||||||
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: TemaApp.colorAcento,
|
backgroundColor: TemaApp.colorAcento,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
|||||||
@@ -24,11 +24,14 @@ class PantallaPalabrasCliente extends StatefulWidget {
|
|||||||
class _PantallaPalabrasClienteState extends State<PantallaPalabrasCliente> {
|
class _PantallaPalabrasClienteState extends State<PantallaPalabrasCliente> {
|
||||||
int _indice = 0;
|
int _indice = 0;
|
||||||
bool _visible = false;
|
bool _visible = false;
|
||||||
|
final Set<String> _jugadoresRevelados = {};
|
||||||
|
|
||||||
JugadorInicioPartida get _actual => widget.jugadores[_indice];
|
JugadorInicioPartida get _actual => widget.jugadores[_indice];
|
||||||
bool get _esUltimo => _indice == widget.jugadores.length - 1;
|
bool get _esUltimo => _indice == widget.jugadores.length - 1;
|
||||||
|
bool get _actualRevelado => _jugadoresRevelados.contains(_actual.jugadorId);
|
||||||
|
|
||||||
void _continuar() {
|
void _continuar() {
|
||||||
|
if (!_actualRevelado) return;
|
||||||
if (_esUltimo) {
|
if (_esUltimo) {
|
||||||
widget.onTodosVistos();
|
widget.onTodosVistos();
|
||||||
return;
|
return;
|
||||||
@@ -64,7 +67,10 @@ class _PantallaPalabrasClienteState extends State<PantallaPalabrasCliente> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => setState(() => _visible = !_visible),
|
onTap: () => setState(() {
|
||||||
|
_visible = !_visible;
|
||||||
|
if (_visible) _jugadoresRevelados.add(actual.jugadorId);
|
||||||
|
}),
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -113,14 +119,26 @@ class _PantallaPalabrasClienteState extends State<PantallaPalabrasCliente> {
|
|||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
_actualRevelado
|
||||||
|
? l10n.seeYourWord
|
||||||
|
: l10n.tapToSee,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(color: TemaApp.colorTextoSecundario),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 56,
|
height: 56,
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: _continuar,
|
onPressed: _actualRevelado ? _continuar : null,
|
||||||
icon: Icon(_esUltimo ? Icons.check : Icons.arrow_forward),
|
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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
|
|||||||
String? _palabraRecibida;
|
String? _palabraRecibida;
|
||||||
bool _esImpostor = false;
|
bool _esImpostor = false;
|
||||||
String? _pistaCategoria;
|
String? _pistaCategoria;
|
||||||
|
String? _partidaId;
|
||||||
final List<Jugador> _jugadores = [];
|
final List<Jugador> _jugadores = [];
|
||||||
final List<JugadorInicioPartida> _jugadoresControlados = [];
|
final List<JugadorInicioPartida> _jugadoresControlados = [];
|
||||||
|
|
||||||
@@ -87,8 +88,24 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
|
|||||||
} else {
|
} else {
|
||||||
_palabraRecibida = mensaje.datos['palabra'] as String?;
|
_palabraRecibida = mensaje.datos['palabra'] as String?;
|
||||||
_esImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
|
_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?;
|
_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
|
// Navegar a pantalla de palabra del cliente
|
||||||
if (mounted && (_jugadoresControlados.isNotEmpty || _palabraRecibida != null)) {
|
if (mounted && (_jugadoresControlados.isNotEmpty || _palabraRecibida != null)) {
|
||||||
@@ -160,6 +177,10 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
|
|||||||
datosFase?['tiempoDebateSegundos'] as int?,
|
datosFase?['tiempoDebateSegundos'] as int?,
|
||||||
primerTurnoNombre:
|
primerTurnoNombre:
|
||||||
datosFase?['primerTurnoNombre'] as String?,
|
datosFase?['primerTurnoNombre'] as String?,
|
||||||
|
partidaId: _partidaId ?? context.read<ServicioNearby>().roomId,
|
||||||
|
pistaCategoria: _pistaCategoria,
|
||||||
|
jugadores: List.unmodifiable(_jugadores),
|
||||||
|
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
|
||||||
onSolicitarVotacion: () {
|
onSolicitarVotacion: () {
|
||||||
final nearby = context.read<ServicioNearby>();
|
final nearby = context.read<ServicioNearby>();
|
||||||
if (nearby.hostEndpointId != null) {
|
if (nearby.hostEndpointId != null) {
|
||||||
@@ -182,6 +203,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
|
|||||||
builder: (_) => PantallaVotacionCliente(
|
builder: (_) => PantallaVotacionCliente(
|
||||||
jugadores: _jugadores,
|
jugadores: _jugadores,
|
||||||
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
|
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
|
||||||
|
partidaId: _partidaId ?? context.read<ServicioNearby>().roomId,
|
||||||
|
pistaCategoria: _pistaCategoria,
|
||||||
onVotos: (votos) {
|
onVotos: (votos) {
|
||||||
final nearby = context.read<ServicioNearby>();
|
final nearby = context.read<ServicioNearby>();
|
||||||
if (nearby.hostEndpointId != null) {
|
if (nearby.hostEndpointId != null) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||||
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
|
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
|
||||||
import 'package:farolero/modelos/jugador.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/servicios/servicio_nearby.dart';
|
||||||
import 'package:farolero/tema/tema_app.dart';
|
import 'package:farolero/tema/tema_app.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -12,12 +14,16 @@ import 'package:provider/provider.dart';
|
|||||||
class PantallaVotacionCliente extends StatefulWidget {
|
class PantallaVotacionCliente extends StatefulWidget {
|
||||||
final List<Jugador> jugadores;
|
final List<Jugador> jugadores;
|
||||||
final List<JugadorInicioPartida> jugadoresControlados;
|
final List<JugadorInicioPartida> jugadoresControlados;
|
||||||
|
final String? partidaId;
|
||||||
|
final String? pistaCategoria;
|
||||||
final Function(Map<String, String> votos) onVotos;
|
final Function(Map<String, String> votos) onVotos;
|
||||||
|
|
||||||
const PantallaVotacionCliente({
|
const PantallaVotacionCliente({
|
||||||
super.key,
|
super.key,
|
||||||
required this.jugadores,
|
required this.jugadores,
|
||||||
this.jugadoresControlados = const [],
|
this.jugadoresControlados = const [],
|
||||||
|
this.partidaId,
|
||||||
|
this.pistaCategoria,
|
||||||
required this.onVotos,
|
required this.onVotos,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,6 +81,35 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
|
|||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
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(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
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) {
|
Widget _buildResultado(BuildContext context, Map<String, dynamic> resultado) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final eliminadoId = resultado['eliminadoId'] as String?;
|
final eliminadoId = resultado['eliminadoId'] as String?;
|
||||||
final eliminadoNombre = resultado['eliminadoNombre'] as String? ?? '?';
|
final eliminadoNombre = resultado['eliminadoNombre'] as String? ?? '?';
|
||||||
final eraImpostor = resultado['eraImpostor'] as bool? ?? false;
|
final eraImpostor = resultado['eraImpostor'] as bool? ?? false;
|
||||||
@@ -153,6 +195,35 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
|
|||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
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(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
|||||||
@@ -794,6 +794,7 @@ class ServicioNearby extends ChangeNotifier {
|
|||||||
'esImpostor': esImpostor,
|
'esImpostor': esImpostor,
|
||||||
'categoria': categoria,
|
'categoria': categoria,
|
||||||
'numJugadores': _jugadores.length + 1,
|
'numJugadores': _jugadores.length + 1,
|
||||||
|
'roomId': _roomId,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -819,6 +820,7 @@ class ServicioNearby extends ChangeNotifier {
|
|||||||
if (endpointId == null) continue;
|
if (endpointId == null) continue;
|
||||||
final datos = payload.toJson();
|
final datos = payload.toJson();
|
||||||
datos['jugadoresTodos'] = jugadoresTodos;
|
datos['jugadoresTodos'] = jugadoresTodos;
|
||||||
|
datos['roomId'] = _roomId;
|
||||||
await enviarMensaje(
|
await enviarMensaje(
|
||||||
endpointId,
|
endpointId,
|
||||||
MensajeP2P(tipo: TipoMensaje.partidaInicio, datos: datos),
|
MensajeP2P(tipo: TipoMensaje.partidaInicio, datos: datos),
|
||||||
|
|||||||
@@ -1,42 +1,94 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
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 {
|
class ServicioNotas {
|
||||||
static const _clavePrefix = 'notas_';
|
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(
|
static Future<void> guardarNotas(
|
||||||
String jugadorId,
|
String jugadorId,
|
||||||
Map<String, String> notas,
|
Map<String, String> notas,
|
||||||
String notaLibre,
|
String notaLibre,
|
||||||
) async {
|
) async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
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,
|
'notas': notas,
|
||||||
'notaLibre': notaLibre,
|
'notaLibre': notaLibre,
|
||||||
};
|
};
|
||||||
await prefs.setString('$_clavePrefix$jugadorId', json.encode(datos));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Carga las notas de un jugador
|
static Map<String, dynamic> _decodificar(String? str) {
|
||||||
static Future<Map<String, dynamic>> cargarNotas(String jugadorId) async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final str = prefs.getString('$_clavePrefix$jugadorId');
|
|
||||||
if (str == null) {
|
if (str == null) {
|
||||||
return {'notas': <String, String>{}, 'notaLibre': ''};
|
return {'notas': <String, String>{}, 'notaLibre': ''};
|
||||||
}
|
}
|
||||||
final datos = json.decode(str) as Map<String, dynamic>;
|
final datos = json.decode(str) as Map<String, dynamic>;
|
||||||
return {
|
return {
|
||||||
'notas': Map<String, String>.from(datos['notas'] ?? {}),
|
'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 {
|
static Future<void> limpiarNotas() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
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) {
|
for (final clave in claves) {
|
||||||
await prefs.remove(clave);
|
await prefs.remove(clave);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user