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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
229
lib/pantallas/pantalla_notas_online.dart
Normal file
229
lib/pantallas/pantalla_notas_online.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
133
lib/pantallas/pantalla_revision_palabra.dart
Normal file
133
lib/pantallas/pantalla_revision_palabra.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
57
test/servicio_notas_test.dart
Normal file
57
test/servicio_notas_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user