Compare commits

..

29 Commits

Author SHA1 Message Date
ShanaiaBot
2f2c77285a chore: bump version to 1.1.17+22 [ci skip] 2026-05-05 23:10:04 +02:00
f64f36b78f Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 12s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m18s
2026-05-05 23:09:38 +02:00
0772ec526e Eliminar valores innecesarios de la configuración 2026-05-05 23:00:29 +02:00
08235999d3 Mostrar la versión en la app 2026-05-05 22:56:25 +02:00
ShanaiaBot
4510ca10c4 chore: bump version to 1.1.16+21 [ci skip] 2026-05-05 22:48:30 +02:00
031c190d74 Subidas para permitir compilación
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 11s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m20s
2026-05-05 22:48:05 +02:00
1b0ec8dc57 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 12s
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
2026-05-05 22:45:53 +02:00
cfe5d479ff Posible mejora en el multidispositivo 2026-05-05 22:45:51 +02:00
ShanaiaBot
c75e4165f6 chore: bump version to 1.1.15+20 [ci skip] 2026-05-05 21:50:24 +02:00
016333f6c0 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 17s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m26s
2026-05-05 21:49:44 +02:00
6e5e423ab4 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.
2026-05-05 21:49:40 +02:00
ShanaiaBot
be880d416b chore: bump version to 1.1.14+19 [ci skip] 2026-05-05 20:54:39 +02:00
1abdeb2f56 Ahora sí, corregido en teoría
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 10s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m58s
2026-05-05 20:54:13 +02:00
ff01d6c9e6 corrección de errores de compilación, eso espero
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 11s
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
2026-05-05 20:48:23 +02:00
d61e79ec99 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 14s
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
2026-05-05 20:38:25 +02:00
5c9e8b2b9c Reintentos de ejecución de partidas online! 2026-05-05 20:38:13 +02:00
ShanaiaBot
9a2b2edefd chore: bump version to 1.1.13+18 [ci skip] 2026-05-04 22:23:46 +02:00
2dbe505d77 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 12s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m16s
2026-05-04 22:23:20 +02:00
3b0b10ea50 traducciones 2026-05-04 22:23:11 +02:00
ShanaiaBot
6a130acc84 chore: bump version to 1.1.12+17 [ci skip] 2026-05-04 20:58:32 +02:00
00dc3ee5e1 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 11s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m21s
2026-05-04 20:58:05 +02:00
957b42ea0c Gestión de usuarios y avatares en la aplicación. Gestión de traducciones de las palabras. 2026-05-04 20:58:02 +02:00
ShanaiaBot
47b1209668 chore: bump version to 1.1.11+16 [ci skip] 2026-05-04 20:24:24 +02:00
7dd6c7bd74 Mejora flujo de datos en partidas multidispositivos
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 12s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m53s
2026-05-04 20:23:47 +02:00
ShanaiaBot
01b65a3d29 chore: bump version to 1.1.10+15 [ci skip] 2026-05-04 13:58:30 +02:00
841f94e543 Completo y absoluto cambio de diseño
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 23s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m53s
2026-05-04 13:57:55 +02:00
ShanaiaBot
ab0d4dc2ba chore: bump version to 1.1.9+14 [ci skip] 2026-04-27 16:04:31 +02:00
Javier Bautista Fernández
50b050e678 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 10s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m57s
2026-04-27 16:04:10 +02:00
Javier Bautista Fernández
5d3b3ef271 feat: Add eliminarUsuario message type and handle user removal in ServicioNearby 2026-04-27 16:04:03 +02:00
98 changed files with 23403 additions and 695 deletions

View File

@@ -1,4 +1,4 @@
# Skill Registry
# Skill Registry
**Delegator use only.** Any agent that launches sub-agents reads this registry to resolve compact rules, then injects them directly into sub-agent prompts. Sub-agents do NOT read this registry or individual SKILL.md files.
@@ -8,51 +8,69 @@ See `_shared/skill-resolver.md` for the full resolution protocol.
| Trigger | Skill | Path |
|---------|-------|------|
| When creating a pull request, opening a PR, or preparing changes for review | branch-pr | /Users/freetlab/.config/opencode/skills/branch-pr/SKILL.md |
| When creating a GitHub issue, reporting a bug, or requesting a feature | issue-creation | /Users/freetlab/.config/opencode/skills/issue-creation/SKILL.md |
| When user says "judgment day", "judgment-day", "review adversarial", "dual review", "doble review", "juzgar", "que lo juzguen" | judgment-day | /Users/freetlab/.config/opencode/skills/judgment-day/SKILL.md |
| When user asks to create a new skill, add agent instructions, or document patterns for AI | skill-creator | /Users/freetlab/.config/opencode/skills/skill-creator/SKILL.md |
| Go tests, Bubbletea TUI testing | go-testing | C:/Users/jbwhi/.codex/skills/go-testing/SKILL.md |
| Creating a GitHub issue, reporting a bug, or requesting a feature | issue-creation | C:/Users/jbwhi/.codex/skills/issue-creation/SKILL.md |
| Creating a pull request or preparing changes for review | branch-pr | C:/Users/jbwhi/.codex/skills/branch-pr/SKILL.md |
| Adversarial dual review / judgment day | judgment-day | C:/Users/jbwhi/.codex/skills/judgment-day/SKILL.md |
| Creating new AI skills | skill-creator | C:/Users/jbwhi/.codex/skills/skill-creator/SKILL.md |
| Browser automation for localhost/file/current browser tab | browser-use:browser | C:/Users/jbwhi/.codex/plugins/cache/openai-bundled/browser-use/0.1.0-alpha1/skills/browser/SKILL.md |
| Document editing/render verification | documents:documents | C:/Users/jbwhi/.codex/plugins/cache/openai-primary-runtime/documents/26.430.10722/skills/documents/SKILL.md |
| Presentation deck creation/edit/render/export | presentations:Presentations | C:/Users/jbwhi/.codex/plugins/cache/openai-primary-runtime/presentations/26.430.10722/skills/presentations/SKILL.md |
| Spreadsheet creation/edit/analyze/visualize | spreadsheets:Spreadsheets | C:/Users/jbwhi/.codex/plugins/cache/openai-primary-runtime/spreadsheets/26.430.10722/skills/spreadsheets/SKILL.md |
## Compact Rules
Pre-digested rules per skill. Delegators copy matching blocks into sub-agent prompts as `## Project Standards (auto-resolved)`.
### branch-pr
- Every PR MUST link an approved issue — no exceptions
- Every PR MUST have exactly one `type:*` label
- Automated checks must pass before merge is possible
- Branch names must match: `^(feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert)/[a-z0-9._-]+$`
- Conventional commits: `^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9\._-]+\))?!?: .+`
- Commit type determines PR label: feat→type:feature, fix→type:bug, docs→type:docs, refactor→type:refactor, chore→type:chore, style→type:chore, perf→type:feature, test→type:chore, build→type:chore, ci→type:chore, revert→type:bug
- PR body must contain: Closes #N (linked issue), PR type checkbox, Summary, Changes Table, Test Plan
### go-testing
- Use `go test` patterns and Bubbletea `teatest` when touching Go/TUI code.
- Prefer deterministic tests and isolate terminal/model effects.
- Keep tests close to behavior and avoid brittle timing assumptions.
- Not applicable to this Flutter/Dart project unless Go files are introduced.
### issue-creation
- Blank issues are disabled — MUST use a template (bug report or feature request)
- Every issue gets `status:needs-review` automatically on creation
- A maintainer MUST add `status:approved` before any PR can be opened
- Questions go to Discussions, not issues
- Bug report template required fields: Pre-flight Checks, Bug Description, Steps to Reproduce, Expected Behavior, Actual Behavior, Operating System, Agent/Client, Shell
- Feature request template required fields: Pre-flight Checks, Problem Description, Proposed Solution, Affected Area
- Follow issue-first workflow before PR work when a feature/bug needs tracking.
- Capture problem, expected behavior, acceptance criteria, and verification steps.
- Do not create noisy or duplicate issues without checking existing context.
### branch-pr
- Use conventional commit/PR language.
- Never add AI attribution or `Co-Authored-By`.
- Ensure code review summary includes what changed, tests/analyze status, and risks.
### judgment-day
- Launch TWO sub-agents via delegate (async, parallel — never sequential)
- Each agent receives the same target but works independently
- Neither agent knows about the other — no cross-contamination
- Classify warnings as WARNING (real) or WARNING (theoretical)
- If confirmed CRITICALs or real WARNINGs exist → delegate Fix Agent
- After Fix Agent completes → re-launch both judges in parallel
- After 2 fix iterations, if issues remain → escalate to user
- Run two independent blind reviews of the same target.
- Synthesize findings, fix real issues, and re-review until both pass or escalation is needed.
- Keep judges focused on correctness, regressions, and requirement coverage.
### skill-creator
- Create a skill when: pattern is used repeatedly, project-specific conventions differ, complex workflows need steps, decision trees help AI
- Don't create a skill when: documentation exists, pattern is trivial, one-off task
- Skill structure: frontmatter (name, description, triggers, allowed-tools), Critical Rules, When to Use, Patterns, Commands
- Create skills with clear trigger, concise rules, and progressive disclosure.
- Avoid embedding large references in `SKILL.md`; link supporting files instead.
- Include actionable constraints and examples only where they prevent mistakes.
### browser-use:browser
- Use the in-app browser for explicit localhost/file/current-tab inspection.
- Do not substitute shell `open` or generic browsing for explicit Browser Use requests.
- After frontend UI changes, suggest browser testing unless already requested.
### documents:documents
- For `.docx`, render pages to images and visually verify before delivering.
- Iterate layout until verified; do not assume generated document layout is correct.
### presentations:Presentations
- Build decks around a clear narrative and chart-first storytelling.
- Render and critique slides before final export.
### spreadsheets:Spreadsheets
- Use spreadsheet-native formulas/tables/charts when editing `.xlsx`/CSV workflows.
- Recalculate and verify outputs after edits.
## Project Conventions
| File | Path | Notes |
|------|------|-------|
| SPEC.md | /Users/freetlab/Proyectos/farolero/SPEC.md | Existing SDD artifacts with Explore/Propose/Spec/Tasks/Apply/Verify phases |
| .gga | /Users/freetlab/Proyectos/farolero/.gga | Gentleman Guardian Angel config (AI provider, file patterns, rules file) |
| AGENTS.md | c:/Proyectos/gitea/farolero/AGENTS.md | Flutter/Dart rules: Provider, Clean Architecture, flutter_test, analyze before commit, no Co-Authored-By. |
| analysis_options.yaml | c:/Proyectos/gitea/farolero/analysis_options.yaml | Uses `package:flutter_lints/flutter.yaml`. |
| pubspec.yaml | c:/Proyectos/gitea/farolero/pubspec.yaml | Flutter app dependencies and asset declarations. |
Read the convention files listed above for project-specific patterns and rules. All referenced paths have been extracted — no need to read index files to discover more.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -4,8 +4,11 @@ import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import 'estado/estado_juego.dart';
import 'servicios/servicio_historial_partidas.dart';
import 'servicios/servicio_idioma.dart';
import 'servicios/servicio_nearby.dart';
import 'servicios/servicio_perfil_usuario.dart';
import 'tema/componentes_farolero.dart';
import 'tema/tema_app.dart';
import 'pantallas/pantalla_principal.dart';
@@ -35,6 +38,12 @@ class FaroleroApp extends StatelessWidget {
ChangeNotifierProvider(
create: (_) => ServicioIdioma()..cargar(),
),
ChangeNotifierProvider(
create: (_) => ServicioPerfilUsuario()..cargar(),
),
ChangeNotifierProvider(
create: (_) => ServicioHistorialPartidas()..cargar(),
),
ChangeNotifierProvider(
create: (_) => ServicioNearby(),
),
@@ -71,24 +80,42 @@ class PantallaCarga extends StatelessWidget {
if (estado.cargando || estado.banco == null) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('🎭', style: TextStyle(fontSize: 72)),
const SizedBox(height: 24),
Text(
l10n?.appTitle ?? 'Farolero',
style: Theme.of(context).textTheme.headlineLarge,
body: FondoFarolero(
intenso: true,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 34, vertical: 28),
child: Column(
children: [
const Spacer(flex: 2),
const Icon(
Icons.lightbulb,
color: TemaApp.colorNaranja,
size: 86,
),
const SizedBox(height: 18),
const LogoFarolero(size: 58),
const Spacer(flex: 3),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: const LinearProgressIndicator(
minHeight: 8,
color: TemaApp.colorNaranja,
backgroundColor: TemaApp.colorSuperficie,
),
),
const SizedBox(height: 14),
Text(
l10n?.loadingWords ?? 'Cargando palabras...',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: TemaApp.colorDorado,
),
textAlign: TextAlign.center,
),
const Spacer(),
],
),
const SizedBox(height: 16),
const CircularProgressIndicator(color: TemaApp.colorAcento),
const SizedBox(height: 12),
Text(
l10n?.loadingWords ?? 'Cargando palabras...',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
);

View File

@@ -1,47 +1,61 @@
import 'dart:convert';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/services.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
/// Categorías disponibles en el banco de palabras
/// Categorías disponibles en el banco de palabras.
class BancoPalabras {
final Map<String, List<String>> categorias;
final Map<String, String> pistasPorCategoria;
BancoPalabras(this.categorias);
BancoPalabras(this.categorias, {Map<String, String>? pistasPorCategoria})
: pistasPorCategoria = pistasPorCategoria ?? {};
static final Map<String, BancoPalabras> _instancias = {};
static Future<BancoPalabras> cargar({String idioma = 'es'}) async {
if (_instancias.containsKey(idioma)) return _instancias[idioma]!;
// Intentar cargar el banco del idioma solicitado, fallback a castellano
String jsonStr;
try {
final archivo = idioma == 'es'
? 'assets/palabras.json'
: 'assets/palabras_$idioma.json';
jsonStr = await rootBundle.loadString(archivo);
jsonStr = await rootBundle.loadString(
'assets/words/palabras_$idioma.json',
);
} catch (_) {
// Fallback a castellano si no existe el banco para ese idioma
if (idioma != 'es') {
return cargar(idioma: 'es');
try {
final archivoLegacy = idioma == 'es'
? 'assets/palabras.json'
: 'assets/palabras_$idioma.json';
jsonStr = await rootBundle.loadString(archivoLegacy);
} catch (_) {
if (idioma != 'es') return cargar(idioma: 'es');
rethrow;
}
rethrow;
}
final data = json.decode(jsonStr) as Map<String, dynamic>;
final cats = data['categorias'] as Map<String, dynamic>;
final mapa = <String, List<String>>{};
final pistas = <String, String>{};
for (final entrada in cats.entries) {
mapa[entrada.key] = List<String>.from(entrada.value);
final valor = entrada.value;
if (valor is Map<String, dynamic>) {
mapa[entrada.key] = List<String>.from(valor['palabras'] as List);
final pista = valor['pista'];
if (pista is String && pista.isNotEmpty) pistas[entrada.key] = pista;
} else {
mapa[entrada.key] = List<String>.from(valor as List);
}
}
_instancias[idioma] = BancoPalabras(mapa);
_instancias[idioma] = BancoPalabras(mapa, pistasPorCategoria: pistas);
return _instancias[idioma]!;
}
List<String> get nombresCategorias => categorias.keys.toList();
/// Obtiene una palabra aleatoria de la categoría dada (o de todas si es null)
/// Obtiene una palabra aleatoria de la categoría dada (o de todas si es null).
String palabraAleatoria(String? categoria) {
final rng = Random();
if (categoria == null || categoria == 'todas') {
@@ -52,7 +66,7 @@ class BancoPalabras {
return lista[rng.nextInt(lista.length)];
}
/// Devuelve la categoría a la que pertenece una palabra
/// Devuelve la categoría a la que pertenece una palabra.
String? categoriaDepalabra(String palabra) {
for (final entrada in categorias.entries) {
if (entrada.value.contains(palabra)) return entrada.key;
@@ -60,7 +74,10 @@ class BancoPalabras {
return null;
}
/// Devuelve el nombre localizado de la categoría usando AppLocalizations
/// Devuelve la pista localizada de una categoría si el banco la trae.
String? pistaDeCategoria(String categoria) => pistasPorCategoria[categoria];
/// Devuelve el nombre localizado de la categoría usando AppLocalizations.
static String nombreBonitoCategoria(String clave, [AppLocalizations? l10n]) {
if (l10n != null) {
final nombres = {
@@ -78,7 +95,6 @@ class BancoPalabras {
};
return nombres[clave] ?? clave;
}
// Fallback a castellano si no hay l10n
const nombres = {
'todas': 'Todas',
'animales': 'Animales',
@@ -95,3 +111,34 @@ class BancoPalabras {
return nombres[clave] ?? clave;
}
}
class EntradaPalabraTraducida {
final String palabra;
final String pista;
const EntradaPalabraTraducida({required this.palabra, required this.pista});
}
class BancoPalabrasTraducidas {
final Map<String, List<EntradaPalabraTraducida>> categorias;
const BancoPalabrasTraducidas(this.categorias);
static final Map<String, BancoPalabrasTraducidas> _instancias = {};
static Future<BancoPalabrasTraducidas> cargar({String idioma = 'es'}) async {
if (_instancias.containsKey(idioma)) return _instancias[idioma]!;
final banco = await BancoPalabras.cargar(idioma: idioma);
final mapa = <String, List<EntradaPalabraTraducida>>{};
for (final categoria in banco.categorias.entries) {
final pista = banco.pistaDeCategoria(categoria.key) ?? categoria.key;
mapa[categoria.key] = categoria.value
.map((palabra) => EntradaPalabraTraducida(palabra: palabra, pista: pista))
.toList();
}
_instancias[idioma] = BancoPalabrasTraducidas(mapa);
return _instancias[idioma]!;
}
}

View File

@@ -0,0 +1,132 @@
import 'jugador.dart';
import 'partida.dart';
class SnapshotPartidaOnline {
final String? roomId;
final String fase;
final int ronda;
final String categoria;
final String? palabraSecreta;
final String? ganador;
final List<Jugador> jugadores;
final ResultadoVotacion? resultadoActual;
final List<ResultadoVotacion> historialVotaciones;
final List<String> impostores;
final String? mensaje;
const SnapshotPartidaOnline({
required this.roomId,
required this.fase,
required this.ronda,
required this.categoria,
required this.jugadores,
this.palabraSecreta,
this.ganador,
this.resultadoActual,
this.historialVotaciones = const [],
this.impostores = const [],
this.mensaje,
});
factory SnapshotPartidaOnline.desdePartida(
Partida partida, {
String? roomId,
String? fase,
ResultadoVotacion? resultadoActual,
String? mensaje,
bool revelarImpostores = false,
bool revelarPalabra = false,
}) {
return SnapshotPartidaOnline(
roomId: roomId,
fase: fase ?? partida.fase.name,
ronda: partida.rondaActual,
categoria: partida.categoriaReal,
palabraSecreta: revelarPalabra ? partida.palabraSecreta : null,
ganador: partida.ganador,
jugadores: partida.jugadores,
resultadoActual: resultadoActual ??
(partida.historialVotaciones.isEmpty
? null
: partida.historialVotaciones.last),
historialVotaciones: partida.historialVotaciones,
impostores: revelarImpostores
? partida.jugadores
.where((jugador) => jugador.esImpostor)
.map((jugador) => jugador.nombre)
.toList()
: const [],
mensaje: mensaje,
);
}
Map<String, dynamic> toJson() => {
'roomId': roomId,
'fase': fase,
'round': ronda,
'categoria': categoria,
if (palabraSecreta != null) 'palabraSecreta': palabraSecreta,
if (ganador != null) 'ganador': ganador,
'jugadoresTodos': jugadores.map(_jugadorToJson).toList(),
if (resultadoActual != null)
'resultadoActual': _resultadoToJson(resultadoActual!),
'historialVotaciones':
historialVotaciones.map(_resultadoToJson).toList(),
if (impostores.isNotEmpty) 'impostores': impostores,
if (mensaje != null) 'mensaje': mensaje,
};
factory SnapshotPartidaOnline.fromJson(Map<String, dynamic> json) {
final jugadoresData = json['jugadoresTodos'] as List<dynamic>? ?? const [];
final historialData =
json['historialVotaciones'] as List<dynamic>? ?? const [];
final resultadoData = json['resultadoActual'] as Map<String, dynamic>?;
return SnapshotPartidaOnline(
roomId: json['roomId'] as String?,
fase: json['fase'] as String? ?? '',
ronda: (json['round'] as num?)?.toInt() ?? 1,
categoria: json['categoria'] as String? ?? '',
palabraSecreta: json['palabraSecreta'] as String?,
ganador: json['ganador'] as String?,
jugadores: jugadoresData
.map((data) => Jugador.fromJson(data as Map<String, dynamic>))
.toList(),
resultadoActual:
resultadoData == null ? null : _resultadoFromJson(resultadoData),
historialVotaciones: historialData
.map((data) => _resultadoFromJson(data as Map<String, dynamic>))
.toList(),
impostores: (json['impostores'] as List<dynamic>? ?? const [])
.map((nombre) => nombre.toString())
.toList(),
mensaje: json['mensaje'] as String?,
);
}
static Map<String, dynamic> _jugadorToJson(Jugador jugador) => {
'id': jugador.id,
'nombre': jugador.nombre,
'esImpostor': jugador.esImpostor,
'eliminado': jugador.eliminado,
};
static Map<String, dynamic> _resultadoToJson(ResultadoVotacion resultado) => {
'eliminadoId': resultado.eliminadoId,
'eliminadoNombre': resultado.eliminadoNombre,
'eraImpostor': resultado.eraImpostor,
'votos': resultado.votos,
};
static ResultadoVotacion _resultadoFromJson(Map<String, dynamic> json) {
final votos = (json['votos'] as Map<dynamic, dynamic>? ?? const {}).map(
(key, value) => MapEntry(key.toString(), value.toString()),
);
return ResultadoVotacion(
eliminadoId: json['eliminadoId'] as String? ?? '',
eliminadoNombre: json['eliminadoNombre'] as String? ?? '',
eraImpostor: json['eraImpostor'] as bool? ?? false,
votos: votos,
);
}
}

View File

@@ -2,14 +2,18 @@
class Usuario {
final String id;
final String nombre;
final String? nick;
final String? avatar;
final String? foto;
final String? creadoPorClienteId;
final String? clienteIdSeleccionado;
Usuario({
required this.id,
required this.nombre,
this.nick,
this.avatar,
this.foto,
this.creadoPorClienteId,
this.clienteIdSeleccionado,
});
@@ -20,7 +24,9 @@ class Usuario {
Usuario copiar({
String? id,
String? nombre,
String? nick,
String? avatar,
String? foto,
String? creadoPorClienteId,
String? clienteIdSeleccionado,
bool liberarSeleccion = false,
@@ -28,7 +34,9 @@ class Usuario {
return Usuario(
id: id ?? this.id,
nombre: nombre ?? this.nombre,
nick: nick ?? this.nick,
avatar: avatar ?? this.avatar,
foto: foto ?? this.foto,
creadoPorClienteId: creadoPorClienteId ?? this.creadoPorClienteId,
clienteIdSeleccionado: liberarSeleccion
? null
@@ -39,7 +47,9 @@ class Usuario {
Map<String, dynamic> toJson() => {
'id': id,
'nombre': nombre,
if (nick != null) 'nick': nick,
if (avatar != null) 'avatar': avatar,
if (foto != null) 'foto': foto,
if (creadoPorClienteId != null) 'creadoPorClienteId': creadoPorClienteId,
if (clienteIdSeleccionado != null)
'clienteIdSeleccionado': clienteIdSeleccionado,
@@ -48,7 +58,9 @@ class Usuario {
factory Usuario.fromJson(Map<String, dynamic> json) => Usuario(
id: json['id'] as String,
nombre: json['nombre'] as String,
nick: json['nick'] as String?,
avatar: json['avatar'] as String?,
foto: json['foto'] as String?,
creadoPorClienteId: json['creadoPorClienteId'] as String?,
clienteIdSeleccionado: json['clienteIdSeleccionado'] as String?,
);

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import '../servicios/servicio_idioma.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
class PantallaAjustes extends StatefulWidget {
@@ -12,21 +14,35 @@ class PantallaAjustes extends StatefulWidget {
}
class _PantallaAjustesState extends State<PantallaAjustes> {
double _volumen = 0.7;
bool _vibracion = true;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final servicioIdioma = context.watch<ServicioIdioma>();
final perfil = context.watch<ServicioPerfilUsuario>().perfil;
return Scaffold(
appBar: AppBar(title: Text(l10n.settingsTitle)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
body: FondoFarolero(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Card(
child: ListTile(
leading: AvatarFarolero(
texto: perfil.nombre.substring(0, 1).toUpperCase(),
assetPath: perfil.avatarAsset,
size: 48,
),
title: Text(perfil.nombre),
subtitle: Text('@${perfil.nick}'),
trailing: const Icon(Icons.edit),
onTap: () => _editarPerfil(context),
),
),
const SizedBox(height: 12),
// Selector de idioma
Card(
child: Padding(
@@ -65,74 +81,8 @@ class _PantallaAjustesState extends State<PantallaAjustes> {
),
),
const SizedBox(height: 12),
// Volumen de efectos de sonido
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.soundVolume,
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Slider(
value: _volumen,
onChanged: (v) => setState(() => _volumen = v),
activeColor: TemaApp.colorAcento,
inactiveColor: TemaApp.colorTarjeta,
),
],
),
),
),
const SizedBox(height: 12),
// Vibración
Card(
child: SwitchListTile(
title: Text(l10n.vibration),
value: _vibracion,
onChanged: (v) => setState(() => _vibracion = v),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
),
),
const SizedBox(height: 12),
// Acerca de
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.about,
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
_filaInfo(context, l10n.version, '1.0.0'),
const SizedBox(height: 8),
_filaInfo(context, l10n.developer, 'FreeTTimeLab'),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () {
showLicensePage(
context: context,
applicationName: 'Farolero',
applicationVersion: '1.0.0',
);
},
child: Text(l10n.licenses),
),
),
],
),
),
),
const SizedBox(height: 16),
],
),
),
),
);
@@ -160,14 +110,105 @@ class _PantallaAjustesState extends State<PantallaAjustes> {
);
}
Widget _filaInfo(BuildContext context, String etiqueta, String valor) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(etiqueta, style: Theme.of(context).textTheme.bodyMedium),
Text(valor, style: Theme.of(context).textTheme.bodyLarge),
],
Future<void> _editarPerfil(BuildContext context) async {
final servicioPerfil = context.read<ServicioPerfilUsuario>();
final actual = servicioPerfil.perfil;
final nombreController = TextEditingController(text: actual.nombre);
final nickController = TextEditingController(text: actual.nick);
var avatarSeleccionado = actual.avatarAsset;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('Perfil del dispositivo'),
content: SizedBox(
width: 420,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nombreController,
textCapitalization: TextCapitalization.words,
decoration: const InputDecoration(
labelText: 'Nombre',
prefixIcon: Icon(Icons.person),
),
),
const SizedBox(height: 12),
TextField(
controller: nickController,
decoration: const InputDecoration(
labelText: 'Nick',
prefixIcon: Icon(Icons.alternate_email),
),
),
const SizedBox(height: 16),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: ServicioPerfilUsuario.avatares.length,
itemBuilder: (context, index) {
final avatar = ServicioPerfilUsuario.avatares[index];
final seleccionado = avatar == avatarSeleccionado;
return InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () => setDialogState(
() => avatarSeleccionado = avatar,
),
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: seleccionado
? TemaApp.colorNaranja
: Colors.transparent,
width: 3,
),
),
child: AvatarFarolero(
texto: '',
assetPath: avatar,
size: 48,
),
),
);
},
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancelar'),
),
TextButton(
onPressed: () async {
await servicioPerfil.guardar(
nombre: nombreController.text,
nick: nickController.text,
avatarAsset: avatarSeleccionado,
);
if (ctx.mounted) Navigator.pop(ctx);
},
child: const Text('Guardar'),
),
],
),
),
);
nombreController.dispose();
nickController.dispose();
}
String _nombreIdiomaDelSistema() {

View File

@@ -5,9 +5,10 @@ import '../estado/estado_juego.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/palabra.dart';
import '../modelos/partida.dart';
import '../modelos/usuario.dart';
import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_permisos.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_gestor_host.dart';
import 'pantalla_lobby_host.dart';
@@ -123,15 +124,21 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
return;
}
// 2. Seleccionar o crear usuario del pool
// 2. Usar el perfil principal del dispositivo como usuario del host.
final nombre = await _seleccionarUsuarioHost();
if (nombre == null || nombre.trim().isEmpty) return;
// 3. Iniciar host en Nearby
if (!mounted) return;
final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>().perfil;
final nombreSala = '${nombre.trim()} - Farolero';
final ok = await nearby.iniciarHost(nombreSala, nombre.trim());
final ok = await nearby.iniciarHost(
nombreSala,
nombre.trim(),
miNick: perfil.nick,
miAvatar: perfil.avatarAsset,
);
if (!ok) {
if (mounted) {
@@ -236,162 +243,9 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
}
}
/// Muestra diálogo para seleccionar usuario del pool o crear nuevo
/// Devuelve el perfil principal del dispositivo para crear la sala.
Future<String?> _seleccionarUsuarioHost() async {
final l10n = AppLocalizations.of(context)!;
final nearby = context.read<ServicioNearby>();
final usuarios = nearby.usuarios;
// Si hay usuarios en el pool, mostrar selección
if (usuarios.isNotEmpty) {
String? seleccionado;
return showDialog<String>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: Text(l10n.selectYourProfile),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String>(
initialValue: seleccionado,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.person),
hintText: l10n.selectProfile,
border: const OutlineInputBorder(),
),
items: [
// Opción para crear nuevo usuario
DropdownMenuItem<String>(
value: '_new_',
child: Row(
children: [
const Icon(Icons.add, size: 18),
const SizedBox(width: 8),
Text(l10n.createNewUser),
],
),
),
// Usuarios existentes
...usuarios.map((usuario) {
return DropdownMenuItem<String>(
value: usuario.nombre,
child: Row(
children: [
Text(usuario.avatar ?? '👤'),
const SizedBox(width: 8),
Text(usuario.nombre),
],
),
);
}),
],
onChanged: (valor) {
setDialogState(() => seleccionado = valor);
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () {
if (seleccionado == '_new_') {
Navigator.pop(ctx);
_crearNuevoUsuarioHost();
} else if (seleccionado != null) {
Navigator.pop(ctx, seleccionado);
}
},
child: Text(l10n.accept),
),
],
),
),
);
}
// Pool vacío, pedir nombre nuevo
return _pedirNombreHost();
}
/// Crea un nuevo usuario y lo agrega al pool
Future<String?> _crearNuevoUsuarioHost() async {
final controller = TextEditingController();
final l10n = AppLocalizations.of(context)!;
final nearby = context.read<ServicioNearby>();
final nombre = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.createNewUser),
content: TextField(
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
hintText: l10n.yourName,
prefixIcon: const Icon(Icons.person),
),
onSubmitted: (v) => Navigator.pop(ctx, v),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, controller.text),
child: const Text('OK'),
),
],
),
);
if (nombre != null && nombre.trim().isNotEmpty) {
final nuevoUsuario = Usuario(
id: DateTime.now().millisecondsSinceEpoch.toString(),
nombre: nombre.trim(),
);
nearby.agregarUsuario(nuevoUsuario);
return nombre.trim();
}
return null;
}
/// Método original para pedir nombre (usado cuando pool vacío)
Future<String?> _pedirNombreHost() async {
final controller = TextEditingController();
final l10n = AppLocalizations.of(context)!;
return showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.yourName),
content: TextField(
controller: controller,
autofocus: true,
textCapitalization: TextCapitalization.words,
decoration: InputDecoration(
hintText: l10n.yourName,
prefixIcon: const Icon(Icons.person),
),
onSubmitted: (v) => Navigator.pop(ctx, v),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, controller.text),
child: const Text('OK'),
),
],
),
);
return context.read<ServicioPerfilUsuario>().perfil.nombre;
}
@override
@@ -409,11 +263,38 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
return Scaffold(
appBar: AppBar(title: Text(l10n.createGame)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
body: FondoFarolero(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PanelFarolero(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
child: Row(
children: [
const Icon(Icons.groups, color: TemaApp.colorNaranja, size: 42),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'¿Cómo quieres jugar?',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 3),
Text(
l10n.playersRange,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
),
const SizedBox(height: 12),
// Modo de juego
Card(
child: Padding(
@@ -648,6 +529,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
),
const SizedBox(height: 16),
],
),
),
),
);

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_notas.dart';
import 'pantalla_votacion.dart';
@@ -46,7 +47,7 @@ class _PantallaDebateState extends State<PantallaDebate> {
String _formatearTiempo(int segundos) {
final min = segundos ~/ 60;
final seg = segundos % 60;
return '${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}';
return "${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}";
}
void _irAVotacion() {
@@ -75,9 +76,10 @@ class _PantallaDebateState extends State<PantallaDebate> {
title: Text(l10n.debateRound(partida.rondaActual)),
automaticallyImplyLeading: false,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Temporizador
if (tieneTemporizador) ...[
@@ -223,6 +225,7 @@ class _PantallaDebateState extends State<PantallaDebate> {
],
),
],
),
),
),
);

View File

@@ -1,17 +1,34 @@
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/pantallas/pantalla_votacion_cliente.dart';
import 'package:farolero/servicios/servicio_nearby.dart';
import 'package:farolero/tema/tema_app.dart';
import 'package:provider/provider.dart';
/// Pantalla que ve el jugador durante la fase de debate (multidispositivo).
/// El cliente recibe el cambio de fase via Nearby y se navega aquí.
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,
});
@@ -23,10 +40,36 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
Timer? _timer;
int _segundosRestantes = 0;
bool _votacionSolicitada = false;
OnMensajeCallback? _listener;
ServicioNearby? _nearby;
@override
void initState() {
super.initState();
_listener = (endpointId, mensaje) {
if (!mounted || mensaje.tipo != TipoMensaje.fase) return;
final fase = mensaje.datos['fase'] as String?;
if (fase == 'votacion') {
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaVotacionCliente(
jugadores: widget.jugadores,
jugadoresControlados: widget.jugadoresControlados,
partidaId: widget.partidaId,
pistaCategoria: widget.pistaCategoria,
onVotos: _enviarVotos,
),
),
);
}
};
WidgetsBinding.instance.addPostFrameCallback((_) {
final listener = _listener;
if (listener != null && mounted) {
_nearby = context.read<ServicioNearby>();
_nearby!.onMensaje(listener);
}
});
if (widget.tiempoDebateSegundos != null) {
_segundosRestantes = widget.tiempoDebateSegundos!;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
@@ -42,13 +85,35 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
@override
void dispose() {
_timer?.cancel();
final listener = _listener;
if (listener != null) {
_nearby?.removeMensajeListener(listener);
}
super.dispose();
}
void _enviarVotos(Map<String, String> votos) {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId == null) return;
for (final entry in votos.entries) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(
tipo: TipoMensaje.voto,
datos: {
'votanteId': entry.key,
'votadoId': entry.value,
'votoporId': entry.value,
},
),
);
}
}
String _formatearTiempo(int segundos) {
final min = segundos ~/ 60;
final seg = segundos % 60;
return '${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}';
return "${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}";
}
@override
@@ -62,6 +127,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),
@@ -111,6 +205,36 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
],
// Instrucciones
if (widget.primerTurnoNombre != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: TemaApp.colorNaranja.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: TemaApp.colorNaranja.withValues(alpha: 0.65),
),
),
child: Row(
children: [
const Icon(
Icons.record_voice_over,
color: TemaApp.colorNaranja,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Empieza ${widget.primerTurnoNombre} diciendo su palabra.',
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
),
const SizedBox(height: 16),
],
Text(
l10n.debateInstructions,
textAlign: TextAlign.center,
@@ -153,4 +277,10 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
),
);
}
bool get _puedeAbrirNotas {
return widget.partidaId != null &&
widget.jugadores.isNotEmpty &&
widget.jugadoresControlados.isNotEmpty;
}
}

View File

@@ -3,13 +3,22 @@ import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../modelos/palabra.dart';
import '../servicios/servicio_historial_partidas.dart';
import '../servicios/servicio_nearby.dart';
import '../tema/tema_app.dart';
import 'pantalla_principal.dart';
import 'pantalla_ver_palabra.dart';
class PantallaFinPartida extends StatelessWidget {
class PantallaFinPartida extends StatefulWidget {
const PantallaFinPartida({super.key});
@override
State<PantallaFinPartida> createState() => _PantallaFinPartidaState();
}
class _PantallaFinPartidaState extends State<PantallaFinPartida> {
bool _guardada = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -20,6 +29,14 @@ class PantallaFinPartida extends StatelessWidget {
final ganaronJugadores = partida.ganador == 'jugadores';
final impostores =
partida.jugadores.where((j) => j.esImpostor).toList();
if (!_guardada && partida.ganador != null) {
_guardada = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
context.read<ServicioHistorialPartidas>().guardarPartida(partida);
}
});
}
return Scaffold(
appBar: AppBar(
@@ -220,8 +237,10 @@ class PantallaFinPartida extends StatelessWidget {
width: double.infinity,
height: 56,
child: OutlinedButton.icon(
onPressed: () {
onPressed: () async {
await context.read<ServicioNearby>().desconectar();
estado.limpiar();
if (!context.mounted) return;
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(

View File

@@ -0,0 +1,234 @@
import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/palabra.dart';
import '../modelos/snapshot_partida_online.dart';
import '../servicios/servicio_nearby.dart';
import '../tema/tema_app.dart';
import 'pantalla_notas_online.dart';
import 'pantalla_principal.dart';
import 'pantalla_revision_palabra.dart';
class PantallaFinPartidaOnline extends StatelessWidget {
final SnapshotPartidaOnline snapshot;
final List<JugadorInicioPartida> jugadoresControlados;
final String? pistaCategoria;
const PantallaFinPartidaOnline({
super.key,
required this.snapshot,
required this.jugadoresControlados,
this.pistaCategoria,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final ganaronJugadores = snapshot.ganador == 'jugadores';
return Scaffold(
appBar: AppBar(
title: Text(l10n.gameOver),
automaticallyImplyLeading: false,
actions: [
IconButton(
tooltip: l10n.seeYourWord,
icon: const Icon(Icons.visibility),
onPressed: jugadoresControlados.isEmpty
? null
: () => mostrarRevisionPalabraOnline(
context: context,
jugadoresControlados: jugadoresControlados,
pistaCategoria: pistaCategoria,
),
),
IconButton(
tooltip: l10n.notesTitle,
icon: const Icon(Icons.edit_note),
onPressed: snapshot.roomId == null || jugadoresControlados.isEmpty
? null
: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PantallaNotasOnline(
partidaId: snapshot.roomId!,
jugadores: snapshot.jugadores,
autoresControlados: jugadoresControlados,
),
),
),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: ganaronJugadores
? [
TemaApp.colorVerde.withValues(alpha: 0.3),
TemaApp.colorVerde.withValues(alpha: 0.1),
]
: [
TemaApp.colorAcento.withValues(alpha: 0.3),
TemaApp.colorAcento.withValues(alpha: 0.1),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: ganaronJugadores
? TemaApp.colorVerde
: TemaApp.colorAcento,
),
),
child: Column(
children: [
Text(
ganaronJugadores ? '🎉' : '🎭',
style: const TextStyle(fontSize: 64),
),
const SizedBox(height: 16),
Text(
ganaronJugadores ? l10n.playersWin : l10n.impostorsWin,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: ganaronJugadores
? TemaApp.colorVerde
: TemaApp.colorAcento,
),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 24),
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Text(
l10n.theSecretWordWas,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
snapshot.palabraSecreta ?? '?',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
color: TemaApp.colorNaranja,
fontSize: 32,
),
),
const SizedBox(height: 4),
Text(
l10n.categoryLabel(
BancoPalabras.nombreBonitoCategoria(
snapshot.categoria,
l10n,
),
),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
Text(
snapshot.impostores.length == 1
? l10n.theImpostorWas
: l10n.theImpostorsWere,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
...snapshot.impostores.map(
(nombre) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
'🎭 $nombre',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: TemaApp.colorAcento,
),
),
),
),
],
),
),
),
const SizedBox(height: 16),
if (snapshot.historialVotaciones.isNotEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.votingHistory,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
...snapshot.historialVotaciones.asMap().entries.map(
(entrada) {
final ronda = entrada.key + 1;
final resultado = entrada.value;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
l10n.roundElimination(
ronda,
resultado.eliminadoNombre,
),
style: TextStyle(
fontWeight: FontWeight.bold,
color: resultado.eraImpostor
? TemaApp.colorVerde
: TemaApp.colorAcento,
),
),
);
},
),
],
),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton.icon(
onPressed: () async {
await context.read<ServicioNearby>().desconectar();
if (!context.mounted) return;
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (_) => const PantallaPrincipal(),
),
(route) => false,
);
},
icon: const Icon(Icons.home),
label: Text(l10n.mainMenu),
),
),
],
),
),
);
}
}

View File

@@ -1,12 +1,18 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/jugador.dart';
import '../modelos/partida.dart';
import '../modelos/snapshot_partida_online.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';
@@ -22,6 +28,9 @@ class PantallaGestorHost extends StatefulWidget {
class _PantallaGestorHostState extends State<PantallaGestorHost> {
Timer? _timer;
int _segundosRestantes = 0;
bool _hostListo = false;
String? _primerTurnoId;
String? _primerTurnoNombre;
final Map<String, bool> _clientesListos = {};
final Map<String, String> _votosRecibidos = {};
@@ -74,7 +83,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
String _formatearTiempo(int segundos) {
final min = segundos ~/ 60;
final seg = segundos % 60;
return '${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}';
return "${min.toString().padLeft(2, '0')}:${seg.toString().padLeft(2, '0')}";
}
void _avanzarAFase(FaseJuego fase) {
@@ -84,23 +93,33 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
switch (fase) {
case FaseJuego.debate:
estado.iniciarDebate();
nearby.enviarCambioFase('debate');
final primero = _elegirPrimerTurno();
nearby.enviarCambioFase('debate', {
..._snapshot(fase: 'debate').toJson(),
if (primero != null) ...{
'primerTurnoId': primero.id,
'primerTurnoNombre': primero.nombre,
},
if (estado.partida?.config.tiempoDebateSegundos != null)
'tiempoDebateSegundos': estado.partida!.config.tiempoDebateSegundos,
});
_iniciarTemporizador();
break;
case FaseJuego.votacion:
estado.iniciarVotacion();
nearby.enviarCambioFase('votacion');
nearby.enviarCambioFase('votacion', _snapshot(fase: 'votacion').toJson());
_votosRecibidos.clear();
break;
case FaseJuego.resultado:
final resultado = estado.procesarVotacion();
if (resultado != null) {
nearby.enviarResultadoVotacion({
'eliminadoId': resultado.eliminadoId,
'eliminadoNombre': resultado.eliminadoNombre,
'eraImpostor': resultado.eraImpostor,
'votos': resultado.votos,
});
nearby.enviarResultadoVotacion(
_snapshot(
fase: 'resultado',
resultadoActual: resultado,
mensaje: _mensajeSiguienteAccion(estado, resultado),
).toJson(),
);
}
break;
default:
@@ -122,7 +141,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
);
}
final todosListos = _clientesListos.length >= nearby.jugadores.length;
final todosListos =
_hostListo && _clientesListos.length >= nearby.jugadores.length;
final todosVotaron = estado.todosHanVotado();
return Scaffold(
@@ -130,6 +150,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 {
@@ -139,9 +195,10 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
),
],
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildFaseIndicator(context, partida.fase, l10n),
const SizedBox(height: 16),
@@ -163,6 +220,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
todosVotaron,
),
],
),
),
),
);
@@ -178,6 +236,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
(FaseJuego.debate, l10n.debate),
(FaseJuego.votacion, l10n.voting),
(FaseJuego.resultado, l10n.result),
(FaseJuego.adivinanza, l10n.guess),
(FaseJuego.finPartida, l10n.gameOver),
];
return SingleChildScrollView(
@@ -221,11 +281,57 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
return _buildFaseDebate(context, l10n, nearby);
case FaseJuego.votacion:
return _buildFaseVotacion(context, l10n, todosVotaron, nearby);
case FaseJuego.resultado:
return _buildFaseResultado(context, l10n);
case FaseJuego.adivinanza:
return _buildFaseAdivinanza(context, l10n);
case FaseJuego.finPartida:
return _buildFaseFinOnline(context, l10n);
default:
return const Center(child: Text('Fin de la partida'));
}
}
SnapshotPartidaOnline _snapshot({
required String fase,
ResultadoVotacion? resultadoActual,
String? mensaje,
bool revelarFinal = false,
}) {
final estado = context.read<EstadoJuego>();
final nearby = context.read<ServicioNearby>();
final partida = estado.partida!;
return SnapshotPartidaOnline.desdePartida(
partida,
roomId: nearby.roomId,
fase: fase,
resultadoActual: resultadoActual,
mensaje: mensaje,
revelarPalabra: revelarFinal,
revelarImpostores: revelarFinal,
);
}
String _mensajeSiguienteAccion(
EstadoJuego estado,
ResultadoVotacion resultado,
) {
final partida = estado.partida;
if (partida != null && _hayFinTrasVotacion(partida)) {
return 'La partida ha terminado.';
}
if (resultado.eraImpostor) {
return 'El impostor eliminado puede intentar adivinar la palabra.';
}
return 'La partida continúa en la siguiente ronda.';
}
bool _hayFinTrasVotacion(Partida partida) {
final impostoresVivos = partida.impostoresActivos.length;
final jugadoresVivos = partida.jugadoresNormalesActivos.length;
return impostoresVivos == 0 || impostoresVivos >= jugadoresVivos;
}
Widget _buildFaseVerPalabra(
BuildContext context,
AppLocalizations l10n,
@@ -248,7 +354,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
_buildJugadorTile(nearby.miNombre ?? 'Host', true, false),
_buildJugadorTile(nearby.miNombre ?? 'Host', true, _hostListo),
...nearby.jugadores.map(
(j) => _buildJugadorTile(
j.nombre,
@@ -297,13 +403,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) {
@@ -314,10 +421,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(
@@ -328,7 +444,10 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
pistaCategoria: partida.config.pistaImpostor
? partida.categoriaReal
: null,
onTodosVistos: () => Navigator.of(context).pop(),
onTodosVistos: () {
setState(() => _hostListo = true);
Navigator.of(context).pop();
},
),
),
);
@@ -348,11 +467,28 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
palabra: partida.palabraSecreta,
pistaActiva: partida.config.pistaImpostor,
categoria: partida.categoriaReal,
onVisto: () => setState(() => _hostListo = true),
),
),
);
}
Jugador? _elegirPrimerTurno() {
final partida = context.read<EstadoJuego>().partida;
if (partida == null || partida.jugadoresActivos.isEmpty) return null;
if (_primerTurnoId != null) {
return partida.jugadoresActivos.firstWhere(
(j) => j.id == _primerTurnoId,
orElse: () => partida.jugadoresActivos.first,
);
}
final elegido = partida.jugadoresActivos[
Random.secure().nextInt(partida.jugadoresActivos.length)];
_primerTurnoId = elegido.id;
_primerTurnoNombre = elegido.nombre;
return elegido;
}
Widget _buildFaseDebate(
BuildContext context,
AppLocalizations l10n,
@@ -396,6 +532,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
),
const SizedBox(height: 16),
],
_buildPrimerTurno(context),
const SizedBox(height: 16),
Text(
l10n.activePlayers,
style: Theme.of(context).textTheme.titleMedium,
@@ -423,6 +561,31 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
);
}
Widget _buildPrimerTurno(BuildContext context) {
final primero = _elegirPrimerTurno();
final nombre = _primerTurnoNombre ?? primero?.nombre;
if (nombre == null) return const SizedBox.shrink();
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: TemaApp.decoracionPanel(
color: TemaApp.colorNaranja.withValues(alpha: 0.16),
borderColor: TemaApp.colorNaranja.withValues(alpha: 0.7),
),
child: Row(
children: [
const Icon(Icons.record_voice_over, color: TemaApp.colorNaranja),
const SizedBox(width: 12),
Expanded(
child: Text(
'Empieza $nombre diciendo su palabra.',
style: Theme.of(context).textTheme.titleMedium,
),
),
],
),
);
}
Widget _buildFaseVotacion(
BuildContext context,
AppLocalizations l10n,
@@ -522,6 +685,206 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
);
}
Widget _buildFaseResultado(BuildContext context, AppLocalizations l10n) {
final partida = context.watch<EstadoJuego>().partida;
final resultado = partida?.historialVotaciones.isNotEmpty == true
? partida!.historialVotaciones.last
: null;
if (partida == null || resultado == null) {
return const Center(child: Text('Sin resultado'));
}
final conteo = <String, int>{};
for (final votadoId in resultado.votos.values) {
conteo[votadoId] = (conteo[votadoId] ?? 0) + 1;
}
final maxVotos = conteo.values.isEmpty
? 1
: conteo.values.reduce((a, b) => a > b ? a : b);
final ranking = conteo.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.result, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: TemaApp.decoracionPanel(
color: resultado.eraImpostor
? TemaApp.colorVerde.withValues(alpha: 0.18)
: TemaApp.colorAcento.withValues(alpha: 0.18),
borderColor: resultado.eraImpostor
? TemaApp.colorVerde
: TemaApp.colorAcento,
),
child: Column(
children: [
Text(
resultado.eliminadoNombre,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 6),
Text(
resultado.eraImpostor
? l10n.wasImpostor
: l10n.wasInnocent,
style: TextStyle(
color: resultado.eraImpostor
? TemaApp.colorVerde
: TemaApp.colorAcento,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 16),
Text(l10n.votesThisRound,
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
Expanded(
child: ListView(
children: [
...ranking.map((entry) {
final jugador = partida.jugadores.firstWhere(
(j) => j.id == entry.key,
orElse: () => partida.jugadores.first,
);
return _buildBarraResultado(
context,
nombre: jugador.nombre,
votos: entry.value,
maxVotos: maxVotos,
destacado: entry.key == resultado.eliminadoId,
);
}),
const Divider(height: 24),
...resultado.votos.entries.map((entry) {
final votante = partida.jugadores.firstWhere(
(j) => j.id == entry.key,
orElse: () => partida.jugadores.first,
);
final votado = partida.jugadores.firstWhere(
(j) => j.id == entry.value,
orElse: () => partida.jugadores.first,
);
return ListTile(
dense: true,
leading: const Icon(Icons.how_to_vote),
title: Text('${votante.nombre}${votado.nombre}'),
);
}),
],
),
),
],
),
),
);
}
Widget _buildBarraResultado(
BuildContext context, {
required String nombre,
required int votos,
required int maxVotos,
required bool destacado,
}) {
final color = destacado ? TemaApp.colorAcento : TemaApp.colorNaranja;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text(nombre)),
Text('$votos',
style: TextStyle(color: color, fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: (votos / maxVotos).clamp(0.0, 1.0).toDouble(),
minHeight: 10,
backgroundColor: TemaApp.colorSuperficie,
valueColor: AlwaysStoppedAnimation(color),
),
),
],
),
);
}
Widget _buildFaseAdivinanza(BuildContext context, AppLocalizations l10n) {
final partida = context.watch<EstadoJuego>().partida;
final ultimo = partida?.historialVotaciones.isNotEmpty == true
? partida!.historialVotaciones.last
: null;
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.psychology, size: 56, color: TemaApp.colorNaranja),
const SizedBox(height: 16),
Text(
l10n.impostorGuessTitle,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
Text(
ultimo == null
? l10n.impostorCanGuess
: '${ultimo.eliminadoNombre}: ${l10n.impostorCanGuess}',
textAlign: TextAlign.center,
),
],
),
),
);
}
Widget _buildFaseFinOnline(BuildContext context, AppLocalizations l10n) {
final partida = context.watch<EstadoJuego>().partida;
final ganaronJugadores = partida?.ganador == 'jugadores';
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
ganaronJugadores ? '🎉' : '🎭',
style: const TextStyle(fontSize: 64),
),
const SizedBox(height: 16),
Text(
ganaronJugadores ? l10n.playersWin : l10n.impostorsWin,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 12),
Text(
partida == null ? '' : l10n.theWordWas(partida.palabraSecreta),
textAlign: TextAlign.center,
),
],
),
),
);
}
bool _hostYaVoto(BuildContext context) {
final estado = context.read<EstadoJuego>();
final sala = context.read<ServicioNearby>().estadoSala;
@@ -554,6 +917,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);
@@ -638,10 +1005,200 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
label: Text(todosVotaron ? l10n.revealResult : l10n.waitingVoting),
),
);
case FaseJuego.resultado:
return _buildAccionesResultado(context, l10n);
case FaseJuego.adivinanza:
return _buildAccionesAdivinanza(context, l10n);
case FaseJuego.finPartida:
return SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton.icon(
onPressed: () async {
final nearby = context.read<ServicioNearby>();
await nearby.desconectar();
widget.onPartidaFin();
},
icon: const Icon(Icons.home),
label: Text(l10n.mainMenu),
),
);
default:
return const SizedBox.shrink();
}
}
Widget _buildAccionesResultado(BuildContext context, AppLocalizations l10n) {
final estado = context.read<EstadoJuego>();
final partida = estado.partida;
final resultado = partida?.historialVotaciones.isNotEmpty == true
? partida!.historialVotaciones.last
: null;
if (partida == null || resultado == null) return const SizedBox.shrink();
if (_hayFinTrasVotacion(partida)) {
return SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () => _finalizarPartidaOnline(context),
icon: const Icon(Icons.emoji_events),
label: Text(l10n.seeEndResult),
),
);
}
if (resultado.eraImpostor) {
return Column(
children: [
SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton.icon(
onPressed: () => _iniciarAdivinanzaOnline(context),
icon: const Icon(Icons.psychology),
label: Text(l10n.impostorGuessWord),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () => _siguienteRondaOnline(context),
icon: const Icon(Icons.skip_next),
label: Text(l10n.nextRound),
),
),
],
);
}
return SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () => _siguienteRondaOnline(context),
icon: const Icon(Icons.skip_next),
label: Text(l10n.nextRound),
),
);
}
Widget _buildAccionesAdivinanza(BuildContext context, AppLocalizations l10n) {
return Column(
children: [
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () => _resolverAdivinanzaOnline(context),
icon: const Icon(Icons.check_circle),
label: Text(l10n.guess),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 56,
child: OutlinedButton.icon(
onPressed: () => _siguienteRondaOnline(context),
icon: const Icon(Icons.skip_next),
label: Text(l10n.dontGuess),
),
),
],
);
}
Future<void> _finalizarPartidaOnline(BuildContext context) async {
final estado = context.read<EstadoJuego>();
final nearby = context.read<ServicioNearby>();
estado.comprobarFinPartida();
await nearby.enviarFinPartida(
_snapshot(fase: 'finPartida', revelarFinal: true).toJson(),
);
if (mounted) setState(() {});
}
Future<void> _iniciarAdivinanzaOnline(BuildContext context) async {
final estado = context.read<EstadoJuego>();
final nearby = context.read<ServicioNearby>();
estado.iniciarAdivinanza();
await nearby.enviarCambioFase(
'adivinanza',
_snapshot(
fase: 'adivinanza',
mensaje: AppLocalizations.of(context)!.impostorCanGuess,
).toJson(),
);
if (mounted) setState(() {});
}
Future<void> _resolverAdivinanzaOnline(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
final controller = TextEditingController();
final intento = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l10n.impostorGuessTitle),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(hintText: l10n.guessWordHint),
onSubmitted: (value) => Navigator.pop(ctx, value),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () => Navigator.pop(ctx, controller.text),
child: Text(l10n.accept),
),
],
),
);
controller.dispose();
if (!context.mounted) return;
if (intento == null || intento.trim().isEmpty) {
await _siguienteRondaOnline(context);
return;
}
final estado = context.read<EstadoJuego>();
final acierto = estado.intentarAdivinar(intento);
if (acierto) {
final nearby = context.read<ServicioNearby>();
await nearby.enviarFinPartida(
_snapshot(fase: 'finPartida', revelarFinal: true).toJson(),
);
if (mounted) setState(() {});
return;
}
await _siguienteRondaOnline(context);
}
Future<void> _siguienteRondaOnline(BuildContext context) async {
final estado = context.read<EstadoJuego>();
final nearby = context.read<ServicioNearby>();
estado.siguienteRonda();
_primerTurnoId = null;
_primerTurnoNombre = null;
final primero = _elegirPrimerTurno();
await nearby.enviarCambioFase('debate', {
..._snapshot(fase: 'debate').toJson(),
if (primero != null) ...{
'primerTurnoId': primero.id,
'primerTurnoNombre': primero.nombre,
},
if (estado.partida?.config.tiempoDebateSegundos != null)
'tiempoDebateSegundos': estado.partida!.config.tiempoDebateSegundos,
});
_timer?.cancel();
_iniciarTemporizador();
if (mounted) setState(() {});
}
}
class _PantallaRevelarPalabraHost extends StatefulWidget {
@@ -650,6 +1207,7 @@ class _PantallaRevelarPalabraHost extends StatefulWidget {
final String palabra;
final bool pistaActiva;
final String categoria;
final VoidCallback onVisto;
const _PantallaRevelarPalabraHost({
required this.nombre,
@@ -657,6 +1215,7 @@ class _PantallaRevelarPalabraHost extends StatefulWidget {
required this.palabra,
required this.pistaActiva,
required this.categoria,
required this.onVisto,
});
@override
@@ -667,6 +1226,7 @@ class _PantallaRevelarPalabraHost extends StatefulWidget {
class _PantallaRevelarPalabraHostState
extends State<_PantallaRevelarPalabraHost> {
bool _manteniendo = false;
bool _haRevelado = false;
@override
Widget build(BuildContext context) {
@@ -674,10 +1234,12 @@ class _PantallaRevelarPalabraHostState
return Scaffold(
appBar: AppBar(title: Text(widget.nombre)),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
body: FondoFarolero(
intenso: true,
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
@@ -726,17 +1288,12 @@ class _PantallaRevelarPalabraHostState
),
if (!widget.esImpostor) ...[
const SizedBox(height: 12),
Text(
widget.palabra,
style: Theme.of(context).textTheme.headlineLarge
?.copyWith(fontSize: 32, color: Colors.white),
textAlign: TextAlign.center,
),
TarjetaPalabraFarolero(palabra: widget.palabra),
],
if (widget.esImpostor && widget.pistaActiva) ...[
const SizedBox(height: 12),
Text(
'Categoria: ${widget.categoria}',
'Categoría: ${widget.categoria}',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: TemaApp.colorNaranja),
),
@@ -745,7 +1302,7 @@ class _PantallaRevelarPalabraHostState
)
: Column(
children: [
const Text('Candado', style: TextStyle(fontSize: 48)),
const Text('🔒', style: TextStyle(fontSize: 48)),
const SizedBox(height: 16),
Text(
l10n.holdToSeeWord,
@@ -762,7 +1319,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,
@@ -787,7 +1347,25 @@ class _PantallaRevelarPalabraHostState
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _haRevelado
? () {
widget.onVisto();
Navigator.of(context).pop();
}
: null,
icon: const Icon(Icons.check),
label: Text(
_haRevelado ? l10n.iveSeenIt : l10n.tapToSee,
),
),
),
],
),
),
),
),

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../servicios/servicio_historial_partidas.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
class PantallaHistorial extends StatelessWidget {
const PantallaHistorial({super.key});
@override
Widget build(BuildContext context) {
final historial = context.watch<ServicioHistorialPartidas>();
final partidas = historial.partidas;
return Scaffold(
appBar: AppBar(title: const Text('Historial')),
body: FondoFarolero(
child: partidas.isEmpty
? const Center(child: Text('Todavía no hay partidas guardadas.'))
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: partidas.length,
itemBuilder: (context, index) {
final partida = partidas[index];
final ganaronJugadores = partida.ganador == 'jugadores';
final dia =
partida.fecha.day.toString().padLeft(2, '0');
final mes =
partida.fecha.month.toString().padLeft(2, '0');
return Card(
child: ListTile(
leading: CircleAvatar(
backgroundColor: ganaronJugadores
? TemaApp.colorVerde
: TemaApp.colorAcento,
child: Icon(
ganaronJugadores ? Icons.groups : Icons.theater_comedy,
color: Colors.white,
),
),
title: Text(
ganaronJugadores
? 'Ganaron los jugadores'
: 'Ganaron los impostores',
),
subtitle: Text(
'${partida.jugadores} jugadores · ${partida.impostores} impostor(es) · ${partida.rondas} ronda(s)\n${partida.palabra} · ${partida.categoria}',
),
isThreeLine: true,
trailing: Text(
'$dia/$mes',
style: Theme.of(context).textTheme.bodySmall,
),
),
);
},
),
),
);
}
}

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import '../modelos/usuario.dart';
import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
/// Lobby del host. El host es autoridad de sala y también cliente local.
@@ -45,9 +47,10 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
},
),
),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
@@ -128,6 +131,7 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
),
),
],
),
),
),
);
@@ -176,6 +180,7 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
decoration: BoxDecoration(
color: color.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withValues(alpha: 0.55)),
),
child: Row(
children: [
@@ -202,12 +207,17 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
return ListTile(
leading: CircleAvatar(
backgroundColor: seleccionadoPorMi
? TemaApp.colorVerde
: seleccionadoPorOtro
? TemaApp.colorNaranja
: TemaApp.colorTarjeta,
child: Text(usuario.avatar ?? '👤'),
backgroundColor: Colors.transparent,
child: AvatarFarolero(
texto: usuario.nombre.isEmpty ? '?' : usuario.nombre[0],
assetPath: usuario.avatar,
color: seleccionadoPorMi
? TemaApp.colorVerde
: seleccionadoPorOtro
? TemaApp.colorNaranja
: TemaApp.colorAzul,
size: 38,
),
),
title: Text(usuario.nombre),
subtitle: Text(
@@ -260,6 +270,7 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
final l10n = AppLocalizations.of(context)!;
final controller = TextEditingController();
final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>().perfil;
final nombre = await showDialog<String>(
context: context,
@@ -289,7 +300,12 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
);
if (nombre != null && nombre.trim().isNotEmpty) {
await nearby.crearUsuarioSala(nombre.trim(), seleccionar: true);
await nearby.crearUsuarioSala(
nombre.trim(),
seleccionar: true,
nick: perfil.nick,
avatar: perfil.avatarAsset,
);
}
}
}

View File

@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
import 'package:farolero/modelos/jugador.dart';
import 'package:farolero/servicios/servicio_notas.dart';
import 'package:farolero/tema/tema_app.dart';
class PantallaNotasOnline extends StatefulWidget {
final String partidaId;
final List<Jugador> jugadores;
final List<JugadorInicioPartida> autoresControlados;
const PantallaNotasOnline({
super.key,
required this.partidaId,
required this.jugadores,
required this.autoresControlados,
});
@override
State<PantallaNotasOnline> createState() => _PantallaNotasOnlineState();
}
class _PantallaNotasOnlineState extends State<PantallaNotasOnline> {
JugadorInicioPartida? _autor;
final Map<String, TextEditingController> _controladores = {};
final TextEditingController _notaLibreController = TextEditingController();
bool _cargando = false;
List<Jugador> get _jugadoresActivos =>
widget.jugadores.where((jugador) => !jugador.eliminado).toList();
@override
void initState() {
super.initState();
for (final jugador in _jugadoresActivos) {
_controladores[jugador.id] = TextEditingController();
}
if (widget.autoresControlados.length == 1) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _seleccionarAutor(widget.autoresControlados.first);
});
}
}
@override
void dispose() {
for (final controller in _controladores.values) {
controller.dispose();
}
_notaLibreController.dispose();
super.dispose();
}
Future<void> _seleccionarAutor(JugadorInicioPartida autor) async {
setState(() {
_autor = autor;
_cargando = true;
for (final controller in _controladores.values) {
controller.clear();
}
_notaLibreController.clear();
});
final datos = await ServicioNotas.cargarNotasPartida(
partidaId: widget.partidaId,
autorJugadorId: autor.jugadorId,
);
if (!mounted) return;
final notas = datos['notas'] as Map<String, String>;
for (final entry in notas.entries) {
_controladores[entry.key]?.text = entry.value;
}
_notaLibreController.text = datos['notaLibre'] as String;
setState(() => _cargando = false);
}
Future<void> _guardarNotas() async {
final autor = _autor;
if (autor == null) return;
final notas = <String, String>{};
for (final entry in _controladores.entries) {
final texto = entry.value.text.trim();
if (texto.isNotEmpty) notas[entry.key] = texto;
}
await ServicioNotas.guardarNotasPartida(
partidaId: widget.partidaId,
autorJugadorId: autor.jugadorId,
notasPorJugador: notas,
notaLibre: _notaLibreController.text,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return PopScope(
canPop: true,
onPopInvokedWithResult: (didPop, result) => _guardarNotas(),
child: Scaffold(
appBar: AppBar(
title: Text(l10n.notesTitle),
actions: [
if (_autor != null)
IconButton(
icon: const Icon(Icons.save),
onPressed: () async {
await _guardarNotas();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.notesSaved)),
);
},
),
],
),
body: _autor == null ? _buildSelector(context) : _buildNotas(context),
),
);
}
Widget _buildSelector(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.whoAreYou,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
Expanded(
child: ListView(
children: widget.autoresControlados
.map(
(autor) => Card(
child: ListTile(
leading: const Icon(Icons.edit_note),
title: Text(autor.nombre),
onTap: () => _seleccionarAutor(autor),
),
),
)
.toList(),
),
),
],
),
);
}
Widget _buildNotas(BuildContext context) {
if (_cargando) return const Center(child: CircularProgressIndicator());
final l10n = AppLocalizations.of(context)!;
final autor = _autor!;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (widget.autoresControlados.length > 1)
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () async {
await _guardarNotas();
if (!mounted) return;
setState(() => _autor = null);
},
),
Expanded(
child: Text(
l10n.notesOf(autor.nombre),
style: Theme.of(context).textTheme.titleLarge,
),
),
],
),
const SizedBox(height: 16),
Text(
l10n.notesAboutPlayers,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: TemaApp.colorTextoSecundario,
),
),
const SizedBox(height: 8),
..._jugadoresActivos
.where((jugador) => jugador.id != autor.jugadorId)
.map(
(jugador) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
controller: _controladores[jugador.id],
decoration: InputDecoration(
labelText: jugador.nombre,
prefixIcon: const Icon(Icons.person, size: 20),
hintText: l10n.playerNoteHint,
),
maxLines: 2,
minLines: 1,
),
),
),
const SizedBox(height: 16),
Text(
l10n.freeNote,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: TemaApp.colorTextoSecundario,
),
),
const SizedBox(height: 8),
TextField(
controller: _notaLibreController,
decoration: InputDecoration(
hintText: l10n.freeNoteHint,
prefixIcon: const Icon(Icons.note, size: 20),
),
maxLines: 5,
minLines: 3,
),
],
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:farolero/tema/componentes_farolero.dart';
import 'package:farolero/tema/tema_app.dart';
/// Pantalla que ve cada jugador cuando recibe su palabra (modo multidispositivo).
@@ -26,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();
}
@@ -44,8 +49,9 @@ class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
backgroundColor: TemaApp.colorFondo,
body: SafeArea(
body: FondoFarolero(
intenso: true,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -58,15 +64,18 @@ class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
duration: const Duration(milliseconds: 300),
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 24),
decoration: BoxDecoration(
decoration: TemaApp.decoracionPanel(
color: _palabraVisible
? TemaApp.colorAcento
? TemaApp.colorSuperficie
: TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(24),
borderColor: _palabraVisible
? TemaApp.colorNaranja
: TemaApp.colorBorde,
).copyWith(
boxShadow: _palabraVisible
? [
BoxShadow(
color: TemaApp.colorAcento.withValues(alpha: 0.4),
color: TemaApp.colorNaranja.withValues(alpha: 0.32),
blurRadius: 24,
spreadRadius: 2,
),
@@ -83,17 +92,28 @@ class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
size: 32,
),
const SizedBox(height: 16),
Text(
_palabraVisible ? widget.palabra : '???',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: _palabraVisible
? Colors.white
: 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,
),
),
),
],
),
),
@@ -132,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,
@@ -148,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,
@@ -164,6 +186,7 @@ class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
),
),
),
),
);
}
}

View File

@@ -1,6 +1,7 @@
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';
/// Reveal secuencial para clientes que manejan uno o varios jugadores.
@@ -23,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;
@@ -44,8 +48,9 @@ class _PantallaPalabrasClienteState extends State<PantallaPalabrasCliente> {
final actual = _actual;
return Scaffold(
backgroundColor: TemaApp.colorFondo,
body: SafeArea(
body: FondoFarolero(
intenso: true,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
@@ -62,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,
@@ -71,8 +79,11 @@ class _PantallaPalabrasClienteState extends State<PantallaPalabrasCliente> {
horizontal: 24,
),
decoration: BoxDecoration(
color: _visible ? TemaApp.colorAcento : TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(24),
color: _visible ? TemaApp.colorSuperficie : TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _visible ? TemaApp.colorNaranja : TemaApp.colorBorde,
),
),
child: Column(
children: [
@@ -82,21 +93,20 @@ class _PantallaPalabrasClienteState extends State<PantallaPalabrasCliente> {
size: 32,
),
const SizedBox(height: 16),
Text(
_visible
? (actual.esImpostor
? l10n.youAreImpostor
: actual.palabra ?? '')
: '???',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: _visible
? Colors.white
: TemaApp.colorTextoSecundario,
if (_visible && !actual.esImpostor)
TarjetaPalabraFarolero(palabra: actual.palabra ?? '')
else
Text(
_visible ? l10n.youAreImpostor : '???',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: _visible
? TemaApp.colorDorado
: TemaApp.colorTextoSecundario,
),
),
),
],
),
),
@@ -109,20 +119,33 @@ class _PantallaPalabrasClienteState extends State<PantallaPalabrasCliente> {
textAlign: TextAlign.center,
),
],
const SizedBox(height: 12),
Text(
_actualRevelado
? l10n.seeYourWord
: l10n.tapToSee,
textAlign: TextAlign.center,
style: TextStyle(color: TemaApp.colorTextoSecundario),
),
const Spacer(),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _continuar,
onPressed: _actualRevelado ? _continuar : null,
icon: Icon(_esUltimo ? Icons.check : Icons.arrow_forward),
label: Text(_esUltimo ? l10n.iveSeenIt : 'Siguiente jugador'),
label: Text(
_actualRevelado
? (_esUltimo ? l10n.iveSeenIt : l10n.next)
: l10n.tapToSee,
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -1,8 +1,12 @@
import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_ajustes.dart';
import 'pantalla_crear_partida.dart';
import 'pantalla_historial.dart';
import 'pantalla_reglas.dart';
import 'pantalla_unirse.dart';
@@ -12,136 +16,166 @@ class PantallaPrincipal extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final perfil = context.watch<ServicioPerfilUsuario>().perfil;
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
colors: [TemaApp.colorAcento, TemaApp.colorNaranja],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
body: FondoFarolero(
intenso: true,
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 18),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
children: [
Row(
children: [
AvatarFarolero(
texto: perfil.nombre.substring(0, 1).toUpperCase(),
assetPath: perfil.avatarAsset,
size: 48,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
perfil.nombre,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'@${perfil.nick}',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 3),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: 0.68,
minHeight: 4,
color: TemaApp.colorPurpura,
backgroundColor: Colors.black.withValues(alpha: 0.45),
),
),
],
),
),
IconButton.filledTonal(
tooltip: l10n.settings,
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaAjustes(),
),
);
},
icon: const Icon(Icons.settings),
),
],
),
boxShadow: [
BoxShadow(
color: TemaApp.colorAcento.withValues(alpha: 0.4),
blurRadius: 30,
spreadRadius: 5,
const SizedBox(height: 38),
const LogoFarolero(size: 70),
const SizedBox(height: 12),
Text(
l10n.subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: TemaApp.colorTexto,
fontSize: 15,
),
],
),
child: const Center(
child: Text(
'🎭',
style: TextStyle(fontSize: 56),
),
),
),
const SizedBox(height: 24),
// Título
Text(
l10n.appTitle,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontSize: 36,
letterSpacing: 1.2,
),
),
const SizedBox(height: 8),
Text(
l10n.subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 16,
),
),
const SizedBox(height: 48),
// Botones
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaCrearPartida(),
const SizedBox(height: 54),
BotonFarolero(
texto: 'Jugar',
icono: Icons.play_arrow,
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaCrearPartida(),
),
);
},
),
const SizedBox(height: 12),
BotonFarolero.secundario(
texto: l10n.joinGame,
icono: Icons.bolt,
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaUnirse(),
),
);
},
),
const SizedBox(height: 12),
BotonFarolero.oscuro(
texto: l10n.howToPlay,
icono: Icons.question_mark,
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaReglas(),
),
);
},
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: AccesoFarolero(
etiqueta: 'Historial',
icono: Icons.history,
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaHistorial(),
),
);
},
),
),
);
},
icon: const Text('🎮', style: TextStyle(fontSize: 20)),
label: Text(l10n.createGame),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaUnirse(),
const SizedBox(width: 8),
Expanded(
child: AccesoFarolero(
etiqueta: 'Logros',
icono: Icons.emoji_events,
onPressed: () {},
),
),
);
},
icon: const Text('📱', style: TextStyle(fontSize: 20)),
label: Text(l10n.joinGame),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaReglas(),
const SizedBox(width: 8),
Expanded(
child: AccesoFarolero(
etiqueta: 'Ranking',
icono: Icons.bar_chart,
onPressed: () {},
),
),
);
},
icon: const Text('📖', style: TextStyle(fontSize: 20)),
label: Text(l10n.howToPlay),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const PantallaAjustes(),
const SizedBox(width: 8),
Expanded(
child: AccesoFarolero(
etiqueta: 'Tienda',
icono: Icons.storefront,
onPressed: () {},
),
),
);
},
icon: const Icon(Icons.settings, size: 20),
label: Text(l10n.settings),
),
],
),
const SizedBox(height: 28),
Text(
l10n.playersRange,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 48),
Text(
l10n.playersRange,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 12,
),
),
],
),
),
),
),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
class PantallaReglas extends StatelessWidget {
@@ -11,56 +12,117 @@ class PantallaReglas extends StatelessWidget {
return Scaffold(
appBar: AppBar(title: Text(l10n.rulesTitle)),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
body: FondoFarolero(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_seccion(context, l10n.rulesWhatIsTitle, l10n.rulesWhatIsBody),
_seccion(context, l10n.rulesHowToPlayTitle, l10n.rulesHowToPlayBody),
_seccion(context, l10n.rulesWhoWinsTitle, l10n.rulesWhoWinsBody),
_seccion(context, l10n.rulesTipsPlayersTitle, l10n.rulesTipsPlayersBody),
_seccion(context, l10n.rulesTipsImpostorTitle, l10n.rulesTipsImpostorBody),
_seccion(context, l10n.rulesModesTitle, l10n.rulesModesBody),
_seccion(
context,
1,
Icons.person_search,
l10n.rulesWhatIsTitle,
l10n.rulesWhatIsBody,
),
_seccion(
context,
2,
Icons.chat_bubble,
l10n.rulesHowToPlayTitle,
l10n.rulesHowToPlayBody,
),
_seccion(
context,
3,
Icons.how_to_vote,
l10n.rulesWhoWinsTitle,
l10n.rulesWhoWinsBody,
),
_seccion(
context,
4,
Icons.lightbulb,
l10n.rulesTipsPlayersTitle,
l10n.rulesTipsPlayersBody,
),
_seccion(
context,
5,
Icons.theater_comedy,
l10n.rulesTipsImpostorTitle,
l10n.rulesTipsImpostorBody,
),
_seccion(
context,
6,
Icons.devices,
l10n.rulesModesTitle,
l10n.rulesModesBody,
),
_ejemplo(context, l10n.rulesExampleTitle, l10n.rulesExampleBody),
const SizedBox(height: 32),
],
),
),
);
}
Widget _seccion(BuildContext context, String titulo, String contenido) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(titulo,
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text(contenido,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
height: 1.5,
)),
],
),
),
),
);
}
Widget _seccion(
BuildContext context,
int numero,
IconData icono,
String titulo,
String contenido,
) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: PanelFarolero(
padding: const EdgeInsets.all(12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 54,
height: 54,
decoration: BoxDecoration(
color: TemaApp.colorNaranja.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: TemaApp.colorNaranja),
),
child: Icon(icono, color: TemaApp.colorNaranja, size: 30),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$numero. ${titulo.toUpperCase()}',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(contenido,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
height: 1.5,
)),
],
),
),
],
),
),
);
}
Widget _ejemplo(BuildContext context, String titulo, String contenido) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Card(
child: PanelFarolero(
color: TemaApp.colorNaranja.withValues(alpha: 0.15),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
borderColor: TemaApp.colorNaranja,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(titulo,
@@ -73,7 +135,6 @@ class PantallaReglas extends StatelessWidget {
height: 1.5,
)),
],
),
),
),
);

View File

@@ -3,6 +3,7 @@ import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../modelos/partida.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_adivinanza.dart';
import 'pantalla_debate.dart';
@@ -62,10 +63,11 @@ class _PantallaResultadoState extends State<PantallaResultado>
title: Text(l10n.result),
automaticallyImplyLeading: false,
),
body: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: Column(
body: FondoFarolero(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Animación de suspense
@@ -128,41 +130,7 @@ class _PantallaResultadoState extends State<PantallaResultado>
),
const SizedBox(height: 24),
// Detalle de votos
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.votesThisRound,
style: Theme.of(context)
.textTheme
.titleMedium),
const SizedBox(height: 8),
...widget.resultado.votos.entries.map((e) {
final votante = partida?.jugadores
.firstWhere((j) => j.id == e.key);
final votado = partida?.jugadores
.firstWhere((j) => j.id == e.value);
return Padding(
padding:
const EdgeInsets.symmetric(vertical: 2),
child: Text(
'${votante?.nombre ?? '?'}${votado?.nombre ?? '?'}',
style: TextStyle(
color: e.value ==
widget.resultado.eliminadoId
? TemaApp.colorAcento
: TemaApp.colorTextoSecundario,
),
),
);
}),
],
),
),
),
_buildDetalleVotos(context, partida, l10n),
const SizedBox(height: 24),
// Acciones
@@ -172,12 +140,146 @@ class _PantallaResultadoState extends State<PantallaResultado>
),
],
],
),
),
),
),
);
}
Widget _buildDetalleVotos(
BuildContext context,
Partida? partida,
AppLocalizations l10n,
) {
final jugadores = {
for (final jugador in partida?.jugadores ?? []) jugador.id: jugador,
};
final conteo = <String, int>{};
for (final votadoId in widget.resultado.votos.values) {
conteo[votadoId] = (conteo[votadoId] ?? 0) + 1;
}
final maxVotos = conteo.values.isEmpty
? 1
: conteo.values.reduce((a, b) => a > b ? a : b);
final ranking = conteo.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.bar_chart, color: TemaApp.colorNaranja),
const SizedBox(width: 8),
Text(
l10n.votesThisRound,
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 12),
...ranking.map((entry) {
final jugador = jugadores[entry.key];
final eliminado = entry.key == widget.resultado.eliminadoId;
return _buildBarraVotos(
context,
nombre: jugador?.nombre ?? '?',
votos: entry.value,
total: maxVotos,
destacado: eliminado,
);
}),
const Divider(height: 24),
...widget.resultado.votos.entries.map((entry) {
final votante = jugadores[entry.key]?.nombre ?? '?';
final votado = jugadores[entry.value]?.nombre ?? '?';
final fueAlEliminado =
entry.value == widget.resultado.eliminadoId;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(
fueAlEliminado
? Icons.how_to_vote
: Icons.arrow_forward,
size: 18,
color: fueAlEliminado
? TemaApp.colorAcento
: TemaApp.colorTextoSecundario,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'$votante$votado',
style: TextStyle(
color: fueAlEliminado
? TemaApp.colorTexto
: TemaApp.colorTextoSecundario,
fontWeight: fueAlEliminado
? FontWeight.bold
: FontWeight.normal,
),
),
),
],
),
);
}),
],
),
),
);
}
Widget _buildBarraVotos(
BuildContext context, {
required String nombre,
required int votos,
required int total,
required bool destacado,
}) {
final color = destacado ? TemaApp.colorAcento : TemaApp.colorNaranja;
final proporcion = total == 0 ? 0.0 : votos / total;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
nombre,
style: TextStyle(
fontWeight: destacado ? FontWeight.bold : FontWeight.w600,
),
),
),
Text(
'$votos',
style: TextStyle(color: color, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: proporcion.clamp(0.0, 1.0).toDouble(),
minHeight: 10,
backgroundColor: TemaApp.colorSuperficie,
valueColor: AlwaysStoppedAnimation(color),
),
),
],
),
);
}
Widget _construirBotones(BuildContext context, EstadoJuego estado) {
final l10n = AppLocalizations.of(context)!;
final partida = estado.partida;

View File

@@ -0,0 +1,351 @@
import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/partida.dart';
import '../modelos/snapshot_partida_online.dart';
import '../servicios/servicio_nearby.dart';
import '../tema/tema_app.dart';
import 'pantalla_debate_cliente.dart';
import 'pantalla_fin_partida_online.dart';
import 'pantalla_notas_online.dart';
import 'pantalla_revision_palabra.dart';
import 'pantalla_votacion_cliente.dart';
import 'package:provider/provider.dart';
class PantallaResultadoOnline extends StatefulWidget {
final SnapshotPartidaOnline snapshot;
final List<JugadorInicioPartida> jugadoresControlados;
final String? pistaCategoria;
const PantallaResultadoOnline({
super.key,
required this.snapshot,
required this.jugadoresControlados,
this.pistaCategoria,
});
@override
State<PantallaResultadoOnline> createState() => _PantallaResultadoOnlineState();
}
class _PantallaResultadoOnlineState extends State<PantallaResultadoOnline> {
OnMensajeCallback? _listener;
ServicioNearby? _nearby;
late SnapshotPartidaOnline _snapshot;
@override
void initState() {
super.initState();
_snapshot = widget.snapshot;
_listener = (endpointId, mensaje) {
if (!mounted) return;
if (mensaje.tipo == TipoMensaje.partidaFin) {
_abrirFin(mensaje.datos);
return;
}
if (mensaje.tipo == TipoMensaje.votacionResultado) {
setState(() => _snapshot = SnapshotPartidaOnline.fromJson(mensaje.datos));
return;
}
if (mensaje.tipo != TipoMensaje.fase) return;
final fase = mensaje.datos['fase'] as String?;
if (fase == 'debate') {
_abrirDebate(mensaje.datos);
} else if (fase == 'votacion') {
_abrirVotacion(mensaje.datos);
} else if (fase == 'adivinanza' || fase == 'resultado') {
setState(() => _snapshot = SnapshotPartidaOnline.fromJson(mensaje.datos));
} else if (fase == 'finPartida') {
_abrirFin(mensaje.datos);
}
};
WidgetsBinding.instance.addPostFrameCallback((_) {
final listener = _listener;
if (listener != null && mounted) {
_nearby = context.read<ServicioNearby>();
_nearby!.onMensaje(listener);
}
});
}
@override
void dispose() {
final listener = _listener;
if (listener != null) {
_nearby?.removeMensajeListener(listener);
}
super.dispose();
}
void _abrirDebate(Map<String, dynamic> datos) {
final snapshot = SnapshotPartidaOnline.fromJson(datos);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaDebateCliente(
tiempoDebateSegundos: datos['tiempoDebateSegundos'] as int?,
primerTurnoNombre: datos['primerTurnoNombre'] as String?,
partidaId: snapshot.roomId,
pistaCategoria: widget.pistaCategoria,
jugadores: snapshot.jugadores,
jugadoresControlados: widget.jugadoresControlados,
onSolicitarVotacion: _solicitarVotacion,
),
),
);
}
void _abrirVotacion(Map<String, dynamic> datos) {
final snapshot = SnapshotPartidaOnline.fromJson(datos);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaVotacionCliente(
jugadores: snapshot.jugadores,
jugadoresControlados: widget.jugadoresControlados,
partidaId: snapshot.roomId,
pistaCategoria: widget.pistaCategoria,
onVotos: _enviarVotos,
),
),
);
}
void _abrirFin(Map<String, dynamic> datos) {
final snapshot = SnapshotPartidaOnline.fromJson(datos);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaFinPartidaOnline(
snapshot: snapshot,
jugadoresControlados: widget.jugadoresControlados,
pistaCategoria: widget.pistaCategoria,
),
),
);
}
void _solicitarVotacion() {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId == null) return;
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(
tipo: TipoMensaje.ping,
datos: {'solicitoVotacion': true},
),
);
}
void _enviarVotos(Map<String, String> votos) {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId == null) return;
for (final entry in votos.entries) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(
tipo: TipoMensaje.voto,
datos: {
'votanteId': entry.key,
'votadoId': entry.value,
'votoporId': entry.value,
},
),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final resultado = _snapshot.resultadoActual;
return Scaffold(
backgroundColor: TemaApp.colorFondo,
appBar: AppBar(
title: Text(_snapshot.fase == 'adivinanza'
? l10n.impostorGuessTitle
: l10n.result),
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
elevation: 0,
actions: _acciones(context, l10n),
),
body: Padding(
padding: const EdgeInsets.all(24),
child: resultado == null
? _buildEsperaAdivinanza(context, l10n)
: _buildResultado(context, l10n, resultado),
),
);
}
List<Widget> _acciones(BuildContext context, AppLocalizations l10n) => [
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: _snapshot.roomId == null || widget.jugadoresControlados.isEmpty
? null
: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PantallaNotasOnline(
partidaId: _snapshot.roomId!,
jugadores: _snapshot.jugadores,
autoresControlados: widget.jugadoresControlados,
),
),
),
),
];
Widget _buildEsperaAdivinanza(
BuildContext context,
AppLocalizations l10n,
) {
return Center(
child: Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.hourglass_top, size: 48),
const SizedBox(height: 16),
Text(
_snapshot.mensaje ?? l10n.impostorCanGuess,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Text(
l10n.waitingForHost,
textAlign: TextAlign.center,
style: TextStyle(color: TemaApp.colorTextoSecundario),
),
],
),
),
),
);
}
Widget _buildResultado(
BuildContext context,
AppLocalizations l10n,
ResultadoVotacion resultado,
) {
final conteo = <String, int>{};
for (final votadoId in resultado.votos.values) {
conteo[votadoId] = (conteo[votadoId] ?? 0) + 1;
}
final maxVotos = conteo.values.isEmpty
? 1
: conteo.values.reduce((a, b) => a > b ? a : b);
final ranking = conteo.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final jugadores = {for (final jugador in _snapshot.jugadores) jugador.id: jugador};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: TemaApp.decoracionPanel(
color: resultado.eraImpostor
? TemaApp.colorVerde.withValues(alpha: 0.18)
: TemaApp.colorAcento.withValues(alpha: 0.18),
borderColor:
resultado.eraImpostor ? TemaApp.colorVerde : TemaApp.colorAcento,
),
child: Column(
children: [
Text(
resultado.eliminadoNombre,
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
resultado.eraImpostor ? l10n.wasImpostor : l10n.wasInnocent,
style: TextStyle(
color: resultado.eraImpostor
? TemaApp.colorVerde
: TemaApp.colorAcento,
fontWeight: FontWeight.bold,
),
),
if (_snapshot.mensaje != null) ...[
const SizedBox(height: 12),
Text(_snapshot.mensaje!, textAlign: TextAlign.center),
],
],
),
),
const SizedBox(height: 20),
Text(l10n.votesThisRound, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
Expanded(
child: ListView(
children: [
...ranking.map((entry) {
final jugador = jugadores[entry.key];
final destacado = entry.key == resultado.eliminadoId;
final color = destacado ? TemaApp.colorAcento : TemaApp.colorNaranja;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text(jugador?.nombre ?? '?')),
Text(
'${entry.value}',
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: (entry.value / maxVotos).clamp(0.0, 1.0).toDouble(),
minHeight: 10,
backgroundColor: TemaApp.colorSuperficie,
valueColor: AlwaysStoppedAnimation(color),
),
),
],
),
);
}),
const Divider(height: 24),
...resultado.votos.entries.map((entry) {
final votante = jugadores[entry.key]?.nombre ?? '?';
final votado = jugadores[entry.value]?.nombre ?? '?';
return ListTile(
dense: true,
leading: const Icon(Icons.how_to_vote),
title: Text('$votante$votado'),
);
}),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
import 'package:farolero/tema/componentes_farolero.dart';
import 'package:farolero/tema/tema_app.dart';
Future<void> mostrarRevisionPalabraOnline({
required BuildContext context,
required List<JugadorInicioPartida> jugadoresControlados,
String? pistaCategoria,
}) async {
if (jugadoresControlados.isEmpty) return;
final jugador = jugadoresControlados.length == 1
? jugadoresControlados.first
: await showModalBottomSheet<JugadorInicioPartida>(
context: context,
backgroundColor: TemaApp.colorSuperficie,
builder: (sheetContext) => SafeArea(
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.all(16),
children: [
Text(
AppLocalizations.of(sheetContext)!.seeYourWord,
style: Theme.of(sheetContext).textTheme.titleLarge,
),
const SizedBox(height: 12),
...jugadoresControlados.map(
(jugador) => Card(
color: TemaApp.colorTarjeta,
child: ListTile(
leading: const Icon(Icons.visibility),
title: Text(jugador.nombre),
onTap: () => Navigator.pop(sheetContext, jugador),
),
),
),
],
),
),
);
if (jugador == null || !context.mounted) return;
await showDialog<void>(
context: context,
builder: (dialogContext) => _DialogoRevisionPalabra(
jugador: jugador,
pistaCategoria: pistaCategoria,
),
);
}
class _DialogoRevisionPalabra extends StatelessWidget {
final JugadorInicioPartida jugador;
final String? pistaCategoria;
const _DialogoRevisionPalabra({
required this.jugador,
required this.pistaCategoria,
});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Dialog(
backgroundColor: TemaApp.colorSuperficie,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
jugador.nombre,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: TemaApp.decoracionPanel(
color: TemaApp.colorTarjeta,
borderColor: jugador.esImpostor
? TemaApp.colorAcento
: TemaApp.colorNaranja,
),
child: Column(
children: [
Icon(
jugador.esImpostor ? Icons.theater_comedy : Icons.key,
color: jugador.esImpostor
? TemaApp.colorAcento
: TemaApp.colorNaranja,
size: 36,
),
const SizedBox(height: 16),
if (jugador.esImpostor)
Text(
l10n.youAreImpostor,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
)
else
TarjetaPalabraFarolero(palabra: jugador.palabra ?? ''),
if (jugador.esImpostor && pistaCategoria != null) ...[
const SizedBox(height: 12),
Text(
l10n.clueIs(pistaCategoria!),
style: const TextStyle(color: TemaApp.colorNaranja),
textAlign: TextAlign.center,
),
],
],
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.check),
label: Text(l10n.back),
),
),
],
),
),
);
}
}

View File

@@ -4,14 +4,19 @@ import 'package:provider/provider.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import '../modelos/jugador.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/snapshot_partida_online.dart';
import '../modelos/usuario.dart';
import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_permisos.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_palabra_cliente.dart';
import 'pantalla_palabras_cliente.dart';
import 'pantalla_debate_cliente.dart';
import 'pantalla_votacion_cliente.dart';
import 'pantalla_resultado_online.dart';
import 'pantalla_fin_partida_online.dart';
/// Pantalla para unirse a una partida multidispositivo.
/// Flujo: nombre → discovery automático (lista de salas) → fallback QR
@@ -37,6 +42,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
String? _palabraRecibida;
bool _esImpostor = false;
String? _pistaCategoria;
String? _partidaId;
final List<Jugador> _jugadores = [];
final List<JugadorInicioPartida> _jugadoresControlados = [];
@@ -45,6 +51,10 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
super.initState();
// Registrar listener ANTES del primer build
WidgetsBinding.instance.addPostFrameCallback((_) {
final perfil = context.read<ServicioPerfilUsuario>().perfil;
if (_nombreController.text.isEmpty) {
_nombreController.text = perfil.nombre;
}
_registrarListenerPartida();
});
}
@@ -52,6 +62,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
void _registrarListenerPartida() {
final nearby = context.read<ServicioNearby>();
nearby.onMensaje((endpointId, mensaje) {
if (!mounted) return;
if (mensaje.tipo == TipoMensaje.partidaInicio) {
// El host ha iniciado la partida — nos ha enviado nuestra palabra
final jugadoresData = mensaje.datos['jugadores'] as List<dynamic>?;
@@ -81,19 +92,41 @@ 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)) {
_navegarAPalabra();
}
} else if (mensaje.tipo == TipoMensaje.fase) {
// El host cambia de fase — navegar a la pantalla correspondiente
final fase = mensaje.datos['fase'] as String?;
_actualizarSnapshotSiExiste(mensaje.datos);
if (mounted && fase != null) {
_navegarSegunFase(fase);
_navegarSegunFase(fase, mensaje.datos);
}
} else if (mensaje.tipo == TipoMensaje.votacionResultado) {
_actualizarSnapshotSiExiste(mensaje.datos);
if (mounted) _navegarResultado(mensaje.datos);
} else if (mensaje.tipo == TipoMensaje.partidaFin) {
_actualizarSnapshotSiExiste(mensaje.datos);
if (mounted) _navegarFinPartida(mensaje.datos);
}
});
}
@@ -143,13 +176,39 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
);
}
void _navegarSegunFase(String fase) {
void _actualizarSnapshotSiExiste(Map<String, dynamic> datos) {
final jugadoresTodosData = datos['jugadoresTodos'] as List<dynamic>?;
if (jugadoresTodosData == null) return;
setState(() {
_jugadores
..clear()
..addAll(
jugadoresTodosData.map(
(json) => Jugador.fromJson(json as Map<String, dynamic>),
),
);
_partidaId = (datos['roomId'] as String?) ??
_partidaId ??
context.read<ServicioNearby>().roomId;
_pistaCategoria = (datos['categoria'] as String?) ?? _pistaCategoria;
});
}
void _navegarSegunFase(String fase, [Map<String, dynamic>? datos]) {
switch (fase) {
case 'debate':
final datosFase = datos ?? context.read<ServicioNearby>().datosPartida;
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaDebateCliente(
tiempoDebateSegundos: null,
tiempoDebateSegundos:
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) {
@@ -172,6 +231,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) {
@@ -189,15 +250,48 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
);
}
}
Navigator.of(context).pop();
},
),
),
);
break;
case 'resultado':
case 'adivinanza':
_navegarResultado(datos ?? context.read<ServicioNearby>().datosPartida);
break;
case 'finPartida':
_navegarFinPartida(datos ?? context.read<ServicioNearby>().datosPartida);
break;
}
}
void _navegarResultado(Map<String, dynamic>? datos) {
if (datos == null) return;
final snapshot = SnapshotPartidaOnline.fromJson(datos);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaResultadoOnline(
snapshot: snapshot,
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
pistaCategoria: _pistaCategoria,
),
),
);
}
void _navegarFinPartida(Map<String, dynamic>? datos) {
if (datos == null) return;
final snapshot = SnapshotPartidaOnline.fromJson(datos);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaFinPartidaOnline(
snapshot: snapshot,
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
pistaCategoria: _pistaCategoria,
),
),
);
}
@override
void dispose() {
_nombreController.dispose();
@@ -323,14 +417,19 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
Widget _buildFormularioNombre(BuildContext context, AppLocalizations l10n) {
return Scaffold(
appBar: AppBar(title: Text(l10n.joinGameTitle)),
body: Padding(
padding: const EdgeInsets.all(32),
child: Form(
body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(32),
child: Form(
key: _formKey,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('📱', style: TextStyle(fontSize: 64)),
const Icon(
Icons.bluetooth_searching,
color: TemaApp.colorAzul,
size: 70,
),
const SizedBox(height: 24),
Text(
l10n.joinGameTitle,
@@ -373,6 +472,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
),
),
),
),
);
}
@@ -399,9 +499,10 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
},
),
),
body: Padding(
padding: const EdgeInsets.all(24),
child: Column(
body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Estado
if (_conectando) ...[
@@ -495,6 +596,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
],
),
),
),
);
}
@@ -610,9 +712,10 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
},
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
body: FondoFarolero(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Estado de conexión
@@ -677,6 +780,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
),
],
],
),
),
),
);
@@ -687,6 +791,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
final l10n = AppLocalizations.of(context)!;
final controller = TextEditingController();
final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>().perfil;
controller.text = perfil.nombre;
final nombre = await showDialog<String>(
context: context,
@@ -716,7 +822,12 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
);
if (nombre != null && nombre.trim().isNotEmpty) {
await nearby.crearUsuarioSala(nombre.trim(), seleccionar: true);
await nearby.crearUsuarioSala(
nombre.trim(),
seleccionar: true,
nick: perfil.nick,
avatar: perfil.avatarAsset,
);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../modelos/palabra.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_debate.dart';
@@ -31,9 +32,10 @@ class _PantallaVerPalabraState extends State<PantallaVerPalabra> {
title: Text(l10n.seeYourWord),
automaticallyImplyLeading: false,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(
l10n.eachPlayerMustSee,
@@ -109,6 +111,7 @@ class _PantallaVerPalabraState extends State<PantallaVerPalabra> {
),
),
],
),
),
),
);
@@ -169,10 +172,12 @@ class _PantallaRevelarPalabraState extends State<_PantallaRevelarPalabra> {
return Scaffold(
appBar: AppBar(title: Text(widget.nombre)),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
body: FondoFarolero(
intenso: true,
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
@@ -225,17 +230,7 @@ class _PantallaRevelarPalabraState extends State<_PantallaRevelarPalabra> {
),
if (!widget.esImpostor) ...[
const SizedBox(height: 12),
Text(
widget.palabra,
style: Theme.of(context)
.textTheme
.headlineLarge
?.copyWith(
fontSize: 32,
color: Colors.white,
),
textAlign: TextAlign.center,
),
TarjetaPalabraFarolero(palabra: widget.palabra),
],
if (widget.esImpostor && widget.pistaActiva) ...[
const SizedBox(height: 12),
@@ -324,6 +319,7 @@ class _PantallaRevelarPalabraState extends State<_PantallaRevelarPalabra> {
),
),
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_resultado.dart';
@@ -58,9 +59,10 @@ class _PantallaVotacionState extends State<PantallaVotacion> {
title: Text(l10n.voting),
automaticallyImplyLeading: false,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Progreso de votos
Container(
@@ -161,6 +163,7 @@ class _PantallaVotacionState extends State<PantallaVotacion> {
),
),
],
),
),
),
);
@@ -174,10 +177,11 @@ class _PantallaVotacionState extends State<PantallaVotacion> {
title: Text(l10n.votingComplete),
automaticallyImplyLeading: false,
),
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
body: FondoFarolero(
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('🗳️', style: TextStyle(fontSize: 64)),
@@ -213,6 +217,7 @@ class _PantallaVotacionState extends State<PantallaVotacion> {
),
),
],
),
),
),
),

View File

@@ -1,8 +1,14 @@
import 'package:flutter/material.dart';
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/modelos/snapshot_partida_online.dart';
import 'package:farolero/pantallas/pantalla_notas_online.dart';
import 'package:farolero/pantallas/pantalla_revision_palabra.dart';
import 'package:farolero/pantallas/pantalla_resultado_online.dart';
import 'package:farolero/servicios/servicio_nearby.dart';
import 'package:farolero/tema/tema_app.dart';
import 'package:provider/provider.dart';
/// Pantalla de votación para cliente multidispositivo.
/// Un cliente puede manejar uno o varios jugadores, por eso se recoge un voto
@@ -10,12 +16,16 @@ import 'package:farolero/tema/tema_app.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,
});
@@ -25,6 +35,9 @@ class PantallaVotacionCliente extends StatefulWidget {
class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
final Map<String, String> _votosPorVotante = {};
Map<String, dynamic>? _resultado;
OnMensajeCallback? _listener;
ServicioNearby? _nearby;
List<JugadorInicioPartida> get _votantes => widget.jugadoresControlados;
bool get _modoMultiVotante => _votantes.length > 1;
@@ -33,9 +46,48 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
return _votantes.every((votante) => _votosPorVotante[votante.jugadorId] != null);
}
@override
void initState() {
super.initState();
_listener = (endpointId, mensaje) {
if (mensaje.tipo != TipoMensaje.votacionResultado || !mounted) return;
if (mensaje.datos.containsKey('jugadoresTodos')) {
final snapshot = SnapshotPartidaOnline.fromJson(mensaje.datos);
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaResultadoOnline(
snapshot: snapshot,
jugadoresControlados: widget.jugadoresControlados,
pistaCategoria: widget.pistaCategoria,
),
),
);
} else {
setState(() => _resultado = mensaje.datos);
}
};
WidgetsBinding.instance.addPostFrameCallback((_) {
final listener = _listener;
if (listener != null && mounted) {
_nearby = context.read<ServicioNearby>();
_nearby!.onMensaje(listener);
}
});
}
@override
void dispose() {
final listener = _listener;
if (listener != null) {
_nearby?.removeMensajeListener(listener);
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (_resultado != null) return _buildResultado(context, _resultado!);
return Scaffold(
backgroundColor: TemaApp.colorFondo,
@@ -44,6 +96,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),
@@ -96,6 +177,173 @@ 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;
final votosRaw = resultado['votos'] as Map<dynamic, dynamic>? ?? {};
final votos = votosRaw.map(
(key, value) => MapEntry(key.toString(), value.toString()),
);
final jugadores = {for (final jugador in widget.jugadores) jugador.id: jugador};
final conteo = <String, int>{};
for (final votadoId in votos.values) {
conteo[votadoId] = (conteo[votadoId] ?? 0) + 1;
}
final maxVotos = conteo.values.isEmpty
? 1
: conteo.values.reduce((a, b) => a > b ? a : b);
final ranking = conteo.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
return Scaffold(
backgroundColor: TemaApp.colorFondo,
appBar: AppBar(
title: const Text('Resultado'),
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),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: eraImpostor
? TemaApp.colorVerde.withValues(alpha: 0.18)
: TemaApp.colorAcento.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: eraImpostor ? TemaApp.colorVerde : TemaApp.colorAcento,
),
),
child: Column(
children: [
Text(
eliminadoNombre,
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
eraImpostor ? 'Era impostor' : 'Era inocente',
style: TextStyle(
color: eraImpostor
? TemaApp.colorVerde
: TemaApp.colorAcento,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(height: 20),
Text(
'Detalle de votos',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Expanded(
child: ListView(
children: [
...ranking.map((entry) {
final jugador = jugadores[entry.key];
final destacado = entry.key == eliminadoId;
final color = destacado
? TemaApp.colorAcento
: TemaApp.colorNaranja;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(child: Text(jugador?.nombre ?? '?')),
Text(
'${entry.value}',
style: TextStyle(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
value: (entry.value / maxVotos)
.clamp(0.0, 1.0)
.toDouble(),
minHeight: 10,
backgroundColor: TemaApp.colorSuperficie,
valueColor: AlwaysStoppedAnimation(color),
),
),
],
),
);
}),
const Divider(height: 24),
...votos.entries.map((entry) {
final votante = jugadores[entry.key]?.nombre ?? '?';
final votado = jugadores[entry.value]?.nombre ?? '?';
return ListTile(
dense: true,
leading: const Icon(Icons.how_to_vote),
title: Text('$votante$votado'),
);
}),
],
),
),
],
),
),
);
}
Widget _buildSelectorLegacy() {
return ListView.builder(
itemCount: widget.jugadores.length,

View File

@@ -0,0 +1,115 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/partida.dart';
class ResultadoPartidaGuardado {
final String id;
final DateTime fecha;
final bool modoMultimovil;
final int jugadores;
final int impostores;
final int rondas;
final String ganador;
final String palabra;
final String categoria;
const ResultadoPartidaGuardado({
required this.id,
required this.fecha,
required this.modoMultimovil,
required this.jugadores,
required this.impostores,
required this.rondas,
required this.ganador,
required this.palabra,
required this.categoria,
});
factory ResultadoPartidaGuardado.desdePartida(Partida partida) {
return ResultadoPartidaGuardado(
id: DateTime.now().microsecondsSinceEpoch.toString(),
fecha: DateTime.now(),
modoMultimovil: partida.config.modoMultimovil,
jugadores: partida.jugadores.length,
impostores: partida.impostoresTotales,
rondas: partida.rondaActual,
ganador: partida.ganador ?? 'sin_resultado',
palabra: partida.palabraSecreta,
categoria: partida.categoriaReal,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'fecha': fecha.toIso8601String(),
'modoMultimovil': modoMultimovil,
'jugadores': jugadores,
'impostores': impostores,
'rondas': rondas,
'ganador': ganador,
'palabra': palabra,
'categoria': categoria,
};
factory ResultadoPartidaGuardado.fromJson(Map<String, dynamic> json) {
return ResultadoPartidaGuardado(
id: json['id'] as String,
fecha: DateTime.parse(json['fecha'] as String),
modoMultimovil: json['modoMultimovil'] as bool? ?? false,
jugadores: json['jugadores'] as int? ?? 0,
impostores: json['impostores'] as int? ?? 0,
rondas: json['rondas'] as int? ?? 0,
ganador: json['ganador'] as String? ?? 'sin_resultado',
palabra: json['palabra'] as String? ?? '',
categoria: json['categoria'] as String? ?? '',
);
}
}
class ServicioHistorialPartidas extends ChangeNotifier {
static const _clave = 'historial.partidas';
final List<ResultadoPartidaGuardado> _partidas = [];
bool _cargado = false;
List<ResultadoPartidaGuardado> get partidas => List.unmodifiable(_partidas);
bool get cargado => _cargado;
Future<void> cargar() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_clave);
_partidas.clear();
if (raw != null) {
final lista = json.decode(raw) as List<dynamic>;
_partidas.addAll(
lista.map((e) => ResultadoPartidaGuardado.fromJson(e as Map<String, dynamic>)),
);
}
_cargado = true;
notifyListeners();
}
Future<void> guardarPartida(Partida partida) async {
if (partida.ganador == null) return;
_partidas.insert(0, ResultadoPartidaGuardado.desdePartida(partida));
if (_partidas.length > 100) _partidas.removeRange(100, _partidas.length);
await _persistir();
notifyListeners();
}
Future<void> limpiar() async {
_partidas.clear();
await _persistir();
notifyListeners();
}
Future<void> _persistir() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_clave,
json.encode(_partidas.map((p) => p.toJson()).toList()),
);
}
}

View File

@@ -22,8 +22,10 @@ enum TipoMensaje {
crearUsuario,
seleccionarUsuario,
liberarUsuario,
eliminarUsuario,
errorOperacion,
usuarioNuevo,
// Compatibilidad con versiones previas del protocolo.
usuarioEliminado,
usuariosActualizados,
}
@@ -202,7 +204,21 @@ class ServicioNearby extends ChangeNotifier {
// ==================== HOST ====================
Future<bool> iniciarHost(String nombreSala, String miNombre) async {
Future<bool> iniciarHost(
String nombreSala,
String miNombre, {
String? miNick,
String? miAvatar,
}) async {
if (_conectado ||
_anunciando ||
_buscando ||
_estadoSala != null ||
_jugadores.isNotEmpty) {
await desconectar();
await Future<void>.delayed(const Duration(milliseconds: 250));
}
_nombreSala = nombreSala;
_miNombre = miNombre;
_roomId = DateTime.now().microsecondsSinceEpoch.toString();
@@ -220,6 +236,9 @@ class ServicioNearby extends ChangeNotifier {
final usuarioHost = Usuario(
id: 'u-${_roomId!}-host',
nombre: miNombre,
nick: miNick,
avatar: miAvatar,
foto: miAvatar,
creadoPorClienteId: _hostClientId,
clienteIdSeleccionado: _hostClientId,
);
@@ -243,9 +262,11 @@ class ServicioNearby extends ChangeNotifier {
notifyListeners();
return true;
}
await desconectar();
return false;
} catch (e) {
debugPrint('Error iniciando host: $e');
await desconectar();
return false;
}
}
@@ -429,6 +450,7 @@ class ServicioNearby extends ChangeNotifier {
_handleLiberarUsuario(endpointId, mensaje);
break;
case TipoMensaje.eliminarUsuario:
case TipoMensaje.usuarioEliminado:
_handleEliminarUsuario(endpointId, mensaje);
break;
case TipoMensaje.usuarioNuevo:
@@ -651,13 +673,21 @@ class ServicioNearby extends ChangeNotifier {
}
}
Future<void> crearUsuarioSala(String nombre, {bool seleccionar = true}) async {
Future<void> crearUsuarioSala(
String nombre, {
bool seleccionar = true,
String? nick,
String? avatar,
}) async {
final nombreLimpio = nombre.trim();
if (nombreLimpio.isEmpty) return;
final clientId = _miClientId;
final usuario = Usuario(
id: 'u-${DateTime.now().microsecondsSinceEpoch}',
nombre: nombreLimpio,
nick: nick,
avatar: avatar,
foto: avatar,
creadoPorClienteId: clientId,
);
if (_esHost && _estadoSala != null && clientId != null) {
@@ -764,6 +794,7 @@ class ServicioNearby extends ChangeNotifier {
'esImpostor': esImpostor,
'categoria': categoria,
'numJugadores': _jugadores.length + 1,
'roomId': _roomId,
},
),
);
@@ -789,6 +820,7 @@ class ServicioNearby extends ChangeNotifier {
if (endpointId == null) continue;
final datos = payload.toJson();
datos['jugadoresTodos'] = jugadoresTodos;
datos['roomId'] = _roomId;
await enviarMensaje(
endpointId,
MensajeP2P(tipo: TipoMensaje.partidaInicio, datos: datos),

View File

@@ -1,42 +1,94 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// Servicio para persistir las notas de los jugadores localmente
/// Servicio para persistir las notas de los jugadores localmente.
class ServicioNotas {
static const _clavePrefix = 'notas_';
static const _clavePartidaPrefix = 'notas_partida_';
/// Guarda las notas de un jugador para una partida
/// Guarda las notas de un jugador para una partida local/legacy.
static Future<void> guardarNotas(
String jugadorId,
Map<String, String> notas,
String notaLibre,
) async {
final prefs = await SharedPreferences.getInstance();
final datos = {
await prefs.setString(
'$_clavePrefix$jugadorId',
json.encode(_serializar(notas, notaLibre)),
);
}
/// Carga las notas de un jugador en modo local/legacy.
static Future<Map<String, dynamic>> cargarNotas(String jugadorId) async {
final prefs = await SharedPreferences.getInstance();
return _decodificar(prefs.getString('$_clavePrefix$jugadorId'));
}
/// Guarda notas privadas scoped por partida y jugador autor.
///
/// Esto evita que una partida online contamine otra aunque se reutilicen
/// nombres o ids visibles de jugador.
static Future<void> guardarNotasPartida({
required String partidaId,
required String autorJugadorId,
required Map<String, String> notasPorJugador,
required String notaLibre,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_claveNotasPartida(partidaId, autorJugadorId),
json.encode(_serializar(notasPorJugador, notaLibre)),
);
}
/// Carga notas privadas scoped por partida y jugador autor.
static Future<Map<String, dynamic>> cargarNotasPartida({
required String partidaId,
required String autorJugadorId,
}) async {
final prefs = await SharedPreferences.getInstance();
return _decodificar(
prefs.getString(_claveNotasPartida(partidaId, autorJugadorId)),
);
}
static String _claveNotasPartida(String partidaId, String autorJugadorId) {
return '$_clavePartidaPrefix${_normalizarClave(partidaId)}_${_normalizarClave(autorJugadorId)}';
}
static String _normalizarClave(String valor) {
return base64Url.encode(utf8.encode(valor));
}
static Map<String, Object> _serializar(
Map<String, String> notas,
String notaLibre,
) {
return {
'notas': notas,
'notaLibre': notaLibre,
};
await prefs.setString('$_clavePrefix$jugadorId', json.encode(datos));
}
/// Carga las notas de un jugador
static Future<Map<String, dynamic>> cargarNotas(String jugadorId) async {
final prefs = await SharedPreferences.getInstance();
final str = prefs.getString('$_clavePrefix$jugadorId');
static Map<String, dynamic> _decodificar(String? str) {
if (str == null) {
return {'notas': <String, String>{}, 'notaLibre': ''};
}
final datos = json.decode(str) as Map<String, dynamic>;
return {
'notas': Map<String, String>.from(datos['notas'] ?? {}),
'notaLibre': datos['notaLibre'] ?? '',
'notaLibre': datos['notaLibre'] as String? ?? '',
};
}
/// Limpia todas las notas (al iniciar nueva partida)
/// Limpia todas las notas (al iniciar nueva partida local o reset manual).
static Future<void> limpiarNotas() async {
final prefs = await SharedPreferences.getInstance();
final claves = prefs.getKeys().where((k) => k.startsWith(_clavePrefix));
final claves = prefs
.getKeys()
.where((k) => k.startsWith(_clavePrefix))
.toList();
for (final clave in claves) {
await prefs.remove(clave);
}

View File

@@ -0,0 +1,104 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class PerfilUsuario {
final String nombre;
final String nick;
final String avatarAsset;
const PerfilUsuario({
required this.nombre,
required this.nick,
required this.avatarAsset,
});
PerfilUsuario copiar({String? nombre, String? nick, String? avatarAsset}) {
return PerfilUsuario(
nombre: nombre ?? this.nombre,
nick: nick ?? this.nick,
avatarAsset: avatarAsset ?? this.avatarAsset,
);
}
}
class ServicioPerfilUsuario extends ChangeNotifier {
static const _claveNombre = 'perfil.nombre';
static const _claveNick = 'perfil.nick';
static const _claveAvatar = 'perfil.avatar';
static const avatares = [
'assets/avatars/avatar_01.png',
'assets/avatars/avatar_02.png',
'assets/avatars/avatar_03.png',
'assets/avatars/avatar_04.png',
'assets/avatars/avatar_05.png',
'assets/avatars/avatar_06.png',
'assets/avatars/avatar_07.png',
'assets/avatars/avatar_08.png',
'assets/avatars/avatar_09.png',
'assets/avatars/avatar_10.png',
'assets/avatars/avatar_11.png',
'assets/avatars/avatar_12.png',
'assets/avatars/avatar_13.png',
'assets/avatars/avatar_14.png',
'assets/avatars/avatar_15.png',
'assets/avatars/avatar_16.png',
'assets/avatars/avatar_17.png',
'assets/avatars/avatar_18.png',
'assets/avatars/avatar_19.png',
'assets/avatars/avatar_20.png',
'assets/avatars/avatar_21.png',
'assets/avatars/avatar_22.png',
'assets/avatars/avatar_23.png',
'assets/avatars/avatar_24.png',
'assets/avatars/avatar_25.png',
'assets/avatars/avatar_26.png',
'assets/avatars/avatar_27.png',
'assets/avatars/avatar_28.png',
'assets/avatars/avatar_29.png',
'assets/avatars/avatar_30.png',
];
PerfilUsuario _perfil = const PerfilUsuario(
nombre: 'Jugador',
nick: 'farolero',
avatarAsset: 'assets/avatars/avatar_01.png',
);
bool _cargado = false;
PerfilUsuario get perfil => _perfil;
bool get cargado => _cargado;
Future<void> cargar() async {
final prefs = await SharedPreferences.getInstance();
_perfil = PerfilUsuario(
nombre: prefs.getString(_claveNombre) ?? _perfil.nombre,
nick: prefs.getString(_claveNick) ?? _perfil.nick,
avatarAsset: prefs.getString(_claveAvatar) ?? _perfil.avatarAsset,
);
_cargado = true;
notifyListeners();
}
Future<void> guardar({
required String nombre,
required String nick,
required String avatarAsset,
}) async {
final nombreLimpio = nombre.trim().isEmpty ? 'Jugador' : nombre.trim();
final nickLimpio = nick.trim().isEmpty ? 'farolero' : nick.trim();
final avatarSeguro = avatares.contains(avatarAsset)
? avatarAsset
: avatares.first;
_perfil = PerfilUsuario(
nombre: nombreLimpio,
nick: nickLimpio,
avatarAsset: avatarSeguro,
);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_claveNombre, _perfil.nombre);
await prefs.setString(_claveNick, _perfil.nick);
await prefs.setString(_claveAvatar, _perfil.avatarAsset);
notifyListeners();
}
}

View File

@@ -0,0 +1,437 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'tema_app.dart';
class FondoFarolero extends StatelessWidget {
final Widget child;
final bool intenso;
const FondoFarolero({
super.key,
required this.child,
this.intenso = false,
});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(gradient: TemaApp.gradienteFondo),
child: CustomPaint(
painter: _FondoFaroleroPainter(intenso: intenso),
child: child,
),
);
}
}
class PanelFarolero extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? margin;
final Color? color;
final Color? borderColor;
const PanelFarolero({
super.key,
required this.child,
this.padding = const EdgeInsets.all(16),
this.margin,
this.color,
this.borderColor,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: margin,
padding: padding,
decoration: TemaApp.decoracionPanel(color: color, borderColor: borderColor),
child: child,
);
}
}
class LogoFarolero extends StatelessWidget {
final double size;
const LogoFarolero({super.key, this.size = 64});
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
Positioned(
top: 0,
child: Icon(
Icons.lightbulb,
color: TemaApp.colorDorado.withValues(alpha: 0.32),
size: size * 0.82,
),
),
Text(
'FAROLERO',
style: GoogleFonts.bangers(
fontSize: size,
color: TemaApp.colorNaranja,
letterSpacing: 0,
shadows: const [
Shadow(offset: Offset(3, 4), blurRadius: 0, color: Color(0xFF5E1205)),
Shadow(offset: Offset(0, 0), blurRadius: 16, color: Color(0xFFFFC247)),
],
),
),
],
);
}
}
class BotonFarolero extends StatelessWidget {
final String texto;
final IconData icono;
final VoidCallback? onPressed;
final LinearGradient gradient;
final Color foreground;
const BotonFarolero({
super.key,
required this.texto,
required this.icono,
required this.onPressed,
this.gradient = TemaApp.gradientePrimario,
this.foreground = Colors.black,
});
const BotonFarolero.secundario({
super.key,
required this.texto,
required this.icono,
required this.onPressed,
}) : gradient = const LinearGradient(
colors: [TemaApp.colorPurpura, Color(0xFF2B1736)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
foreground = Colors.white;
const BotonFarolero.oscuro({
super.key,
required this.texto,
required this.icono,
required this.onPressed,
}) : gradient = const LinearGradient(
colors: [Color(0xFF151F27), Color(0xFF090E13)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
foreground = TemaApp.colorTexto;
@override
Widget build(BuildContext context) {
final habilitado = onPressed != null;
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onPressed,
child: Ink(
height: 54,
decoration: BoxDecoration(
gradient: habilitado
? gradient
: const LinearGradient(
colors: [TemaApp.colorTarjeta, TemaApp.colorSuperficie],
),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: habilitado
? TemaApp.colorDorado.withValues(alpha: 0.74)
: TemaApp.colorBorde,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.34),
blurRadius: 14,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
SizedBox(
width: 58,
child: Icon(icono, color: foreground, size: 28),
),
Expanded(
child: Text(
texto.toUpperCase(),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: foreground,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(width: 58),
],
),
),
),
);
}
}
class AccesoFarolero extends StatelessWidget {
final String etiqueta;
final IconData icono;
final VoidCallback onPressed;
const AccesoFarolero({
super.key,
required this.etiqueta,
required this.icono,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onPressed,
child: Ink(
height: 66,
decoration: TemaApp.decoracionPanel(color: TemaApp.colorSuperficie),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icono, color: TemaApp.colorNaranja, size: 22),
const SizedBox(height: 5),
Text(
etiqueta.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: TemaApp.colorDorado,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
);
}
}
class TarjetaPalabraFarolero extends StatelessWidget {
final String palabra;
const TarjetaPalabraFarolero({super.key, required this.palabra});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 28),
decoration: BoxDecoration(
color: const Color(0xFFC48642),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF6B3519), width: 2),
boxShadow: [
BoxShadow(
color: TemaApp.colorNaranja.withValues(alpha: 0.28),
blurRadius: 24,
),
],
),
child: Text(
palabra.toUpperCase(),
textAlign: TextAlign.center,
style: GoogleFonts.oswald(
color: const Color(0xFF1B0C05),
fontSize: 42,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
);
}
}
class AvatarFarolero extends StatelessWidget {
final String texto;
final String? assetPath;
final Color color;
final double size;
const AvatarFarolero({
super.key,
required this.texto,
this.assetPath,
this.color = TemaApp.colorNaranja,
this.size = 40,
});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [color.withValues(alpha: 0.9), TemaApp.colorSuperficie],
),
border: Border.all(color: TemaApp.colorDorado, width: 2),
),
child: Center(
child: assetPath == null
? Text(
texto,
style: TextStyle(
color: TemaApp.colorTexto,
fontWeight: FontWeight.bold,
fontSize: size * 0.36,
),
)
: ClipOval(
child: Image.asset(
assetPath!,
width: size,
height: size,
fit: BoxFit.cover,
),
),
),
);
}
}
class _FondoFaroleroPainter extends CustomPainter {
final bool intenso;
const _FondoFaroleroPainter({required this.intenso});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..isAntiAlias = true;
final alto = size.height;
final ancho = size.width;
paint.color = const Color(0xFF152845).withValues(alpha: intenso ? 0.34 : 0.22);
canvas.drawCircle(Offset(ancho * 0.78, alto * 0.16), 18, paint);
paint.color = const Color(0xFF07101A).withValues(alpha: 0.82);
final colinas = Path()
..moveTo(0, alto * 0.34)
..quadraticBezierTo(ancho * 0.28, alto * 0.21, ancho * 0.55, alto * 0.33)
..quadraticBezierTo(ancho * 0.82, alto * 0.43, ancho, alto * 0.26)
..lineTo(ancho, alto)
..lineTo(0, alto)
..close();
canvas.drawPath(colinas, paint);
_dibujarCasas(canvas, size, paint);
_dibujarFarol(canvas, size, paint);
paint.shader = RadialGradient(
colors: [
TemaApp.colorNaranja.withValues(alpha: intenso ? 0.26 : 0.16),
Colors.transparent,
],
).createShader(Rect.fromCircle(center: Offset(ancho * 0.52, alto * 0.36), radius: 160));
canvas.drawCircle(Offset(ancho * 0.52, alto * 0.36), 160, paint);
paint.shader = null;
}
void _dibujarCasas(Canvas canvas, Size size, Paint paint) {
final alto = size.height;
final ancho = size.width;
paint.color = const Color(0xFF020407).withValues(alpha: 0.72);
for (var i = 0; i < 5; i++) {
final w = ancho * (0.16 + i * 0.018);
final h = alto * (0.18 + (i % 2) * 0.05);
final x = -30 + i * ancho * 0.24;
final y = alto * (0.72 - i * 0.02);
final casa = Rect.fromLTWH(x, y - h, w, h);
canvas.drawRect(casa, paint);
final tejado = Path()
..moveTo(x - 8, y - h)
..lineTo(x + w * 0.48, y - h - 38)
..lineTo(x + w + 8, y - h)
..close();
canvas.drawPath(tejado, paint);
final ventana = Paint()
..color = TemaApp.colorNaranja.withValues(alpha: 0.38)
..isAntiAlias = true;
for (var j = 0; j < 2; j++) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(x + 18 + j * 34, y - h + 36, 12, 22),
const Radius.circular(2),
),
ventana,
);
}
}
}
void _dibujarFarol(Canvas canvas, Size size, Paint paint) {
final alto = size.height;
final ancho = size.width;
final centro = Offset(ancho * 0.5, alto * 0.28);
final glow = Paint()
..shader = RadialGradient(
colors: [
TemaApp.colorNaranja.withValues(alpha: 0.44),
Colors.transparent,
],
).createShader(Rect.fromCircle(center: centro, radius: 92));
canvas.drawCircle(centro, 92, glow);
paint
..shader = null
..style = PaintingStyle.stroke
..strokeWidth = 3
..color = const Color(0xFF050507).withValues(alpha: 0.82);
canvas.drawArc(
Rect.fromCircle(center: centro.translate(0, -16), radius: 35),
math.pi,
math.pi,
false,
paint,
);
paint.style = PaintingStyle.fill;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromCenter(center: centro, width: 38, height: 54),
const Radius.circular(5),
),
paint,
);
paint.color = TemaApp.colorNaranja.withValues(alpha: 0.82);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromCenter(center: centro, width: 21, height: 34),
const Radius.circular(4),
),
paint,
);
}
@override
bool shouldRepaint(covariant _FondoFaroleroPainter oldDelegate) {
return oldDelegate.intenso != intenso;
}
}

View File

@@ -2,114 +2,212 @@ import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class TemaApp {
static const colorFondo = Color(0xFF121212);
static const colorSuperficie = Color(0xFF1E1E1E);
static const colorTarjeta = Color(0xFF2A2A2A);
static const colorAcento = Color(0xFFE53935); // Rojo impostor
static const colorAcentoClaro = Color(0xFFFF6F61);
static const colorNaranja = Color(0xFFFF9800);
static const colorVerde = Color(0xFF4CAF50);
static const colorFondo = Color(0xFF05080D);
static const colorFondoAzul = Color(0xFF0A1520);
static const colorSuperficie = Color(0xFF0D151C);
static const colorTarjeta = Color(0xFF121B23);
static const colorBorde = Color(0xFF263947);
static const colorAcento = Color(0xFFC02824);
static const colorAcentoClaro = Color(0xFFF06A1A);
static const colorNaranja = Color(0xFFF49A13);
static const colorDorado = Color(0xFFFFCE55);
static const colorPurpura = Color(0xFF65306E);
static const colorAzul = Color(0xFF235BCE);
static const colorVerde = Color(0xFF61B944);
static const colorTexto = Color(0xFFFFFFFF);
static const colorTextoSecundario = Color(0xFFB0B0B0);
static const colorTextoSecundario = Color(0xFFC2B9AA);
static const gradientePrimario = LinearGradient(
colors: [Color(0xFFFFB11A), Color(0xFFE87A08)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
static const gradientePeligro = LinearGradient(
colors: [Color(0xFFC02824), Color(0xFF741112)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
static const gradienteFondo = LinearGradient(
colors: [Color(0xFF09131E), Color(0xFF030507)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
static ThemeData obtenerTema() {
final base = ThemeData.dark(useMaterial3: true);
final cuerpo = GoogleFonts.robotoCondensedTextTheme(base.textTheme);
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: colorFondo,
colorScheme: const ColorScheme.dark(
primary: colorAcento,
primary: colorNaranja,
secondary: colorNaranja,
surface: colorSuperficie,
error: colorAcento,
onPrimary: Colors.black,
onSurface: colorTexto,
),
textTheme: GoogleFonts.poppinsTextTheme(
const TextTheme(
textTheme: cuerpo.copyWith(
headlineLarge: TextStyle(
fontFamily: GoogleFonts.oswald().fontFamily,
color: colorTexto,
fontWeight: FontWeight.bold,
fontSize: 28,
fontSize: 30,
letterSpacing: 0,
),
headlineMedium: TextStyle(
fontFamily: GoogleFonts.oswald().fontFamily,
color: colorTexto,
fontWeight: FontWeight.bold,
fontSize: 22,
letterSpacing: 0,
),
titleLarge: TextStyle(
color: colorTexto,
fontWeight: FontWeight.w600,
fontFamily: GoogleFonts.oswald().fontFamily,
color: colorDorado,
fontWeight: FontWeight.w700,
fontSize: 18,
letterSpacing: 0,
),
titleMedium: TextStyle(
fontFamily: GoogleFonts.oswald().fontFamily,
color: colorTexto,
fontWeight: FontWeight.w500,
fontWeight: FontWeight.w600,
fontSize: 16,
letterSpacing: 0,
),
bodyLarge: TextStyle(color: colorTexto, fontSize: 16),
bodyMedium: TextStyle(color: colorTextoSecundario, fontSize: 14),
),
bodyLarge: const TextStyle(color: colorTexto, fontSize: 16),
bodyMedium: const TextStyle(color: colorTextoSecundario, fontSize: 14),
bodySmall: const TextStyle(color: colorTextoSecundario, fontSize: 12),
),
cardTheme: CardThemeData(
color: colorTarjeta,
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
color: colorTarjeta.withValues(alpha: 0.82),
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: const BorderSide(color: colorBorde),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: colorAcento,
foregroundColor: colorTexto,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
textStyle: GoogleFonts.poppins(
fontWeight: FontWeight.w600,
backgroundColor: colorNaranja,
foregroundColor: Colors.black,
disabledBackgroundColor: colorTarjeta,
disabledForegroundColor: colorTextoSecundario,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: GoogleFonts.oswald(
fontWeight: FontWeight.w700,
fontSize: 16,
letterSpacing: 0,
),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: colorTexto,
side: const BorderSide(color: colorAcento),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
side: const BorderSide(color: colorBorde),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: GoogleFonts.oswald(
fontWeight: FontWeight.w700,
fontSize: 16,
letterSpacing: 0,
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: colorTarjeta,
fillColor: const Color(0xFF0B1117),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: colorBorde),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: colorBorde),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: colorAcento),
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: colorNaranja),
),
labelStyle: const TextStyle(color: colorTextoSecundario),
hintStyle: const TextStyle(color: colorTextoSecundario),
),
appBarTheme: AppBarTheme(
backgroundColor: colorFondo,
foregroundColor: colorTexto,
foregroundColor: colorNaranja,
centerTitle: true,
elevation: 0,
titleTextStyle: GoogleFonts.poppins(
color: colorTexto,
titleTextStyle: GoogleFonts.oswald(
color: colorDorado,
fontWeight: FontWeight.bold,
fontSize: 20,
letterSpacing: 0,
),
),
dividerTheme: const DividerThemeData(color: colorBorde, thickness: 1),
listTileTheme: const ListTileThemeData(
iconColor: colorNaranja,
textColor: colorTexto,
subtitleTextStyle: TextStyle(color: colorTextoSecundario),
),
segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return colorNaranja;
return colorSuperficie;
}),
foregroundColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return Colors.black;
return colorTexto;
}),
side: const WidgetStatePropertyAll(BorderSide(color: colorBorde)),
shape: WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
),
),
switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return colorAcento;
if (states.contains(WidgetState.selected)) return colorNaranja;
return colorTextoSecundario;
}),
trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) {
return colorAcento.withValues(alpha: 0.5);
return colorNaranja.withValues(alpha: 0.5);
}
return colorTarjeta;
}),
),
snackBarTheme: SnackBarThemeData(
backgroundColor: colorTarjeta,
contentTextStyle: cuerpo.bodyMedium?.copyWith(color: colorTexto),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
);
}
static BoxDecoration decoracionPanel({
Color? color,
Color? borderColor,
}) {
return BoxDecoration(
color: color ?? colorTarjeta.withValues(alpha: 0.84),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: borderColor ?? colorBorde),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.35),
blurRadius: 18,
offset: const Offset(0, 10),
),
],
);
}
}

View File

@@ -1,7 +1,7 @@
name: farolero
description: "Farolero — Juego de deducción social. ¿Quién finge saber?"
publish_to: 'none'
version: 1.1.8+13
version: 1.1.17+22
environment:
sdk: ^3.11.1
@@ -32,3 +32,5 @@ flutter:
- assets/palabras.json
- assets/palabras_en.json
- assets/palabras_fr.json
- assets/words/
- assets/avatars/

View File

@@ -0,0 +1,57 @@
import 'package:farolero/servicios/servicio_notas.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('guarda y carga notas scoped por partida y autor', () async {
await ServicioNotas.guardarNotasPartida(
partidaId: 'sala-1',
autorJugadorId: 'jugador-a',
notasPorJugador: {'jugador-b': 'Dijo perro demasiado rápido'},
notaLibre: 'Sospecho de B',
);
final datos = await ServicioNotas.cargarNotasPartida(
partidaId: 'sala-1',
autorJugadorId: 'jugador-a',
);
expect(datos['notas'], {'jugador-b': 'Dijo perro demasiado rápido'});
expect(datos['notaLibre'], 'Sospecho de B');
});
test('no mezcla notas entre partidas ni autores', () async {
await ServicioNotas.guardarNotasPartida(
partidaId: 'sala-1',
autorJugadorId: 'jugador-a',
notasPorJugador: {'jugador-b': 'nota sala 1'},
notaLibre: 'libre 1',
);
await ServicioNotas.guardarNotasPartida(
partidaId: 'sala-2',
autorJugadorId: 'jugador-a',
notasPorJugador: {'jugador-b': 'nota sala 2'},
notaLibre: 'libre 2',
);
final otraPartida = await ServicioNotas.cargarNotasPartida(
partidaId: 'sala-2',
autorJugadorId: 'jugador-a',
);
final otroAutor = await ServicioNotas.cargarNotasPartida(
partidaId: 'sala-1',
autorJugadorId: 'jugador-c',
);
expect(otraPartida['notas'], {'jugador-b': 'nota sala 2'});
expect(otraPartida['notaLibre'], 'libre 2');
expect(otroAutor['notas'], isEmpty);
expect(otroAutor['notaLibre'], isEmpty);
});
}

View File

@@ -0,0 +1,70 @@
import 'package:farolero/modelos/jugador.dart';
import 'package:farolero/modelos/partida.dart';
import 'package:farolero/modelos/snapshot_partida_online.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('serializa resultado online preservando acentos y caracteres especiales',
() {
final partida = Partida(
config: const ConfigPartida(categoria: 'animales'),
jugadores: [
Jugador(id: 'j1', nombre: 'León', esImpostor: true, eliminado: true),
Jugador(id: 'j2', nombre: 'María'),
Jugador(id: 'j3', nombre: 'Óscar'),
],
palabraSecreta: 'Camión',
categoriaReal: 'Animales fantásticos',
fase: FaseJuego.resultado,
historialVotaciones: const [
ResultadoVotacion(
eliminadoId: 'j1',
eliminadoNombre: 'León',
eraImpostor: true,
votos: {'j2': 'j1', 'j3': 'j1'},
),
],
);
final snapshot = SnapshotPartidaOnline.desdePartida(
partida,
roomId: 'sala-áéíóú',
revelarPalabra: true,
revelarImpostores: true,
);
final reparsed = SnapshotPartidaOnline.fromJson(snapshot.toJson());
expect(reparsed.roomId, 'sala-áéíóú');
expect(reparsed.categoria, 'Animales fantásticos');
expect(reparsed.palabraSecreta, 'Camión');
expect(reparsed.jugadores.map((jugador) => jugador.nombre), [
'León',
'María',
'Óscar',
]);
expect(reparsed.resultadoActual?.eliminadoNombre, 'León');
expect(reparsed.historialVotaciones.single.votos, {'j2': 'j1', 'j3': 'j1'});
expect(reparsed.impostores, ['León']);
});
test('no revela palabra ni impostores salvo que el host lo indique', () {
final partida = Partida(
config: const ConfigPartida(categoria: 'lugares'),
jugadores: [
Jugador(id: 'j1', nombre: 'Ana', esImpostor: true),
Jugador(id: 'j2', nombre: 'Beto'),
Jugador(id: 'j3', nombre: 'Carla'),
],
palabraSecreta: 'Biblioteca',
categoriaReal: 'Lugares',
fase: FaseJuego.debate,
);
final datos = SnapshotPartidaOnline.desdePartida(partida).toJson();
expect(datos.containsKey('palabraSecreta'), isFalse);
expect(datos.containsKey('impostores'), isFalse);
expect(datos['jugadoresTodos'], hasLength(3));
});
}

View File

@@ -4,6 +4,6 @@ import 'package:farolero/main.dart';
void main() {
testWidgets('App carga correctamente', (WidgetTester tester) async {
await tester.pumpWidget(const FaroleroApp());
expect(find.text('El Impostor'), findsOneWidget);
expect(find.text('FAROLERO'), findsOneWidget);
});
}

View File

@@ -0,0 +1,216 @@
param(
[string]$OutputDir = 'assets/words',
[int]$BatchSize = 100
)
$ErrorActionPreference = 'Stop'
$langMap = [ordered]@{
ar = 'ar'
ca = 'ca'
de = 'de'
en = 'en'
es = 'es'
eu = 'eu'
fr = 'fr'
hi = 'hi'
it = 'it'
ja = 'ja'
ko = 'ko'
nl = 'nl'
pl = 'pl'
pt = 'pt'
ru = 'ru'
tr = 'tr'
zh = 'zh-CN'
zh_TW = 'zh-TW'
}
$categoryKeyMap = [ordered]@{
animales = 'categoryAnimals'
comida = 'categoryFood'
paises = 'categoryCountries'
deportes = 'categorySports'
profesiones = 'categoryProfessions'
objetos = 'categoryObjects'
lugares = 'categoryPlaces'
peliculas = 'categoryMovies'
musica = 'categoryMusic'
tecnologia = 'categoryTechnology'
}
$contextMap = [ordered]@{
animales = 'animal'
comida = 'food'
paises = 'country'
deportes = 'sport'
profesiones = 'profession'
objetos = 'object'
lugares = 'place'
peliculas = 'movie'
musica = 'music'
tecnologia = 'technology'
}
$utf8Strict = [System.Text.UTF8Encoding]::new($false, $true)
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
function Read-Utf8Json([string]$path) {
$text = $utf8Strict.GetString([System.IO.File]::ReadAllBytes((Resolve-Path $path))).TrimStart([char]0xFEFF)
return $text | ConvertFrom-Json
}
function Write-Utf8Json([object]$obj, [string]$path) {
$full = Join-Path (Get-Location) $path
$dir = Split-Path -Parent $full
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force $dir | Out-Null }
[System.IO.File]::WriteAllText($full, ($obj | ConvertTo-Json -Depth 10) + "`n", $utf8NoBom)
}
function Strip-Context([string]$value) {
$clean = $value.Trim()
if ($clean -match '^\s*[^:]{1,30}\s*[:]\s*') {
return ($clean -replace '^\s*[^:]{1,30}\s*[:]\s*', '').Trim()
}
return $clean
}
function Translate-Batch([string[]]$terms, [string]$target) {
if ($terms.Count -eq 0) { return @() }
$numbered = New-Object System.Collections.Generic.List[string]
for ($i = 0; $i -lt $terms.Count; $i++) {
$numbered.Add("[$i] $($terms[$i])")
}
$query = [uri]::EscapeDataString(($numbered -join "`n"))
$url = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=es&tl=$target&dt=t&q=$query"
try {
$response = Invoke-RestMethod -Uri $url -TimeoutSec 45
$translated = (($response[0] | ForEach-Object { $_[0] }) -join '')
$matches = [regex]::Matches(
$translated,
'(?s)\[(\d+)\]\s*(.*?)(?=\s*\[\d+\]\s*|$)'
)
if ($matches.Count -eq $terms.Count) {
$out = New-Object string[] $terms.Count
foreach ($match in $matches) {
$index = [int]$match.Groups[1].Value
if ($index -ge 0 -and $index -lt $terms.Count) {
$out[$index] = Strip-Context $match.Groups[2].Value
}
}
if (($out | Where-Object { $_ -eq $null -or $_.Trim().Length -eq 0 }).Count -eq 0) {
return $out
}
}
} catch {
Start-Sleep -Milliseconds 250
}
$out = New-Object System.Collections.Generic.List[string]
foreach ($term in $terms) {
$queryOne = [uri]::EscapeDataString($term)
$urlOne = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=es&tl=$target&dt=t&q=$queryOne"
$translatedOne = $null
for ($attempt = 1; $attempt -le 4; $attempt++) {
try {
$responseOne = Invoke-RestMethod -Uri $urlOne -TimeoutSec 45
$translatedOne = (($responseOne[0] | ForEach-Object { $_[0] }) -join '')
break
} catch {
if ($attempt -eq 4) { throw }
Start-Sleep -Milliseconds (250 * $attempt)
}
}
$out.Add((Strip-Context $translatedOne))
Start-Sleep -Milliseconds 35
}
return $out.ToArray()
}
$sourceEs = Read-Utf8Json 'assets/palabras.json'
$sourceEn = Read-Utf8Json 'assets/palabras_en.json'
$sourceFr = Read-Utf8Json 'assets/palabras_fr.json'
$arbByLang = @{}
foreach ($lang in $langMap.Keys) {
$arbByLang[$lang] = Read-Utf8Json ("lib/l10n/app_{0}.arb" -f $lang)
}
function New-LanguageBank([string]$lang) {
$bank = [ordered]@{
version = 2
idioma = $lang
categorias = [ordered]@{}
}
foreach ($category in $categoryKeyMap.Keys) {
$labelKey = $categoryKeyMap[$category]
$words = switch ($lang) {
'es' { @($sourceEs.categorias.$category) }
'en' { @($sourceEn.categorias.$category) }
'fr' { @($sourceFr.categorias.$category) }
default { @() }
}
$bank.categorias[$category] = [ordered]@{
pista = [string]$arbByLang[$lang].$labelKey
palabras = @($words | ForEach-Object { [string]$_ })
}
}
return $bank
}
foreach ($lang in @('es', 'en', 'fr')) {
Write-Utf8Json (New-LanguageBank $lang) (Join-Path $OutputDir "palabras_$lang.json")
}
$targets = @($langMap.Keys | Where-Object { $_ -notin @('es', 'en', 'fr') })
foreach ($lang in $targets) {
Write-Host "Generating $lang..."
$bank = New-LanguageBank $lang
$targetCode = $langMap[$lang]
foreach ($category in $categoryKeyMap.Keys) {
$spanishWords = @($sourceEs.categorias.$category)
$context = $contextMap[$category]
$translatedWords = New-Object System.Collections.Generic.List[string]
for ($offset = 0; $offset -lt $spanishWords.Count; $offset += $BatchSize) {
$last = [Math]::Min($offset + $BatchSize - 1, $spanishWords.Count - 1)
$terms = New-Object System.Collections.Generic.List[string]
for ($index = $offset; $index -le $last; $index++) {
$terms.Add("${context}: $($spanishWords[$index])")
}
$translated = @(Translate-Batch $terms.ToArray() $targetCode)
foreach ($word in $translated) { $translatedWords.Add($word) }
Start-Sleep -Milliseconds 70
}
$bank.categorias[$category].palabras = $translatedWords.ToArray()
}
Write-Utf8Json $bank (Join-Path $OutputDir "palabras_$lang.json")
}
Write-Host 'Validating UTF-8 and sample accents...'
foreach ($lang in $langMap.Keys) {
$file = Join-Path $OutputDir "palabras_$lang.json"
$bytes = [System.IO.File]::ReadAllBytes((Resolve-Path $file))
$null = $utf8Strict.GetString($bytes)
if ($bytes.Length -ge 3 -and $bytes[0] -eq 239 -and $bytes[1] -eq 187 -and $bytes[2] -eq 191) {
throw "Unexpected UTF-8 BOM in $file"
}
$bank = Read-Utf8Json $file
foreach ($category in $categoryKeyMap.Keys) {
$expected = @($sourceEs.categorias.$category).Count
$actual = @($bank.categorias.$category.palabras).Count
if ($actual -ne $expected) { throw "Word count mismatch in $file / $category. Expected $expected, got $actual" }
}
}
$esBank = Read-Utf8Json (Join-Path $OutputDir 'palabras_es.json')
$leon = @($esBank.categorias.animales.palabras) | Where-Object { $_ -eq 'León' } | Select-Object -First 1
if ($leon -ne 'León') { throw 'León accent validation failed' }
Write-Host "OK: generated split word banks in $OutputDir"