Compare commits
29 Commits
c8e5cf25c5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f2c77285a | ||
| f64f36b78f | |||
| 0772ec526e | |||
| 08235999d3 | |||
|
|
4510ca10c4 | ||
| 031c190d74 | |||
| 1b0ec8dc57 | |||
| cfe5d479ff | |||
|
|
c75e4165f6 | ||
| 016333f6c0 | |||
| 6e5e423ab4 | |||
|
|
be880d416b | ||
| 1abdeb2f56 | |||
| ff01d6c9e6 | |||
| d61e79ec99 | |||
| 5c9e8b2b9c | |||
|
|
9a2b2edefd | ||
| 2dbe505d77 | |||
| 3b0b10ea50 | |||
|
|
6a130acc84 | ||
| 00dc3ee5e1 | |||
| 957b42ea0c | |||
|
|
47b1209668 | ||
| 7dd6c7bd74 | |||
|
|
01b65a3d29 | ||
| 841f94e543 | |||
|
|
ab0d4dc2ba | ||
|
|
50b050e678 | ||
|
|
5d3b3ef271 |
@@ -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.
|
||||
|
||||
BIN
assets/avatars/avatar_01.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
assets/avatars/avatar_02.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/avatars/avatar_03.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/avatars/avatar_04.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
assets/avatars/avatar_05.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
assets/avatars/avatar_06.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
assets/avatars/avatar_07.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/avatars/avatar_08.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/avatars/avatar_09.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
assets/avatars/avatar_10.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/avatars/avatar_11.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/avatars/avatar_12.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
assets/avatars/avatar_13.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/avatars/avatar_14.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
assets/avatars/avatar_15.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/avatars/avatar_16.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
assets/avatars/avatar_17.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
assets/avatars/avatar_18.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
assets/avatars/avatar_19.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/avatars/avatar_20.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
assets/avatars/avatar_21.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/avatars/avatar_22.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/avatars/avatar_23.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
assets/avatars/avatar_24.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
assets/avatars/avatar_25.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
assets/avatars/avatar_26.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
assets/avatars/avatar_27.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
assets/avatars/avatar_28.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
assets/avatars/avatar_29.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/avatars/avatar_30.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/avatars/capybara_01.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/avatars/capybara_02.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/avatars/capybara_03.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_04.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_05.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_06.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/avatars/capybara_07.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_08.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/avatars/capybara_09.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_10.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_11.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_12.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
1056
assets/words/palabras_ar.json
Normal file
1056
assets/words/palabras_ca.json
Normal file
1056
assets/words/palabras_de.json
Normal file
1056
assets/words/palabras_en.json
Normal file
1056
assets/words/palabras_es.json
Normal file
1056
assets/words/palabras_eu.json
Normal file
1056
assets/words/palabras_fr.json
Normal file
1056
assets/words/palabras_hi.json
Normal file
1056
assets/words/palabras_it.json
Normal file
1056
assets/words/palabras_ja.json
Normal file
1056
assets/words/palabras_ko.json
Normal file
1056
assets/words/palabras_nl.json
Normal file
1056
assets/words/palabras_pl.json
Normal file
1056
assets/words/palabras_pt.json
Normal file
1056
assets/words/palabras_ru.json
Normal file
1056
assets/words/palabras_tr.json
Normal file
1056
assets/words/palabras_zh.json
Normal file
1056
assets/words/palabras_zh_TW.json
Normal file
BIN
docs/prototipos/propotipo inicial.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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]!;
|
||||
}
|
||||
}
|
||||
|
||||
132
lib/modelos/snapshot_partida_online.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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?,
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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> {
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
234
lib/pantallas/pantalla_fin_partida_online.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
62
lib/pantallas/pantalla_historial.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
229
lib/pantallas/pantalla_notas_online.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
|
||||
import 'package:farolero/modelos/jugador.dart';
|
||||
import 'package:farolero/servicios/servicio_notas.dart';
|
||||
import 'package:farolero/tema/tema_app.dart';
|
||||
|
||||
class PantallaNotasOnline extends StatefulWidget {
|
||||
final String partidaId;
|
||||
final List<Jugador> jugadores;
|
||||
final List<JugadorInicioPartida> autoresControlados;
|
||||
|
||||
const PantallaNotasOnline({
|
||||
super.key,
|
||||
required this.partidaId,
|
||||
required this.jugadores,
|
||||
required this.autoresControlados,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PantallaNotasOnline> createState() => _PantallaNotasOnlineState();
|
||||
}
|
||||
|
||||
class _PantallaNotasOnlineState extends State<PantallaNotasOnline> {
|
||||
JugadorInicioPartida? _autor;
|
||||
final Map<String, TextEditingController> _controladores = {};
|
||||
final TextEditingController _notaLibreController = TextEditingController();
|
||||
bool _cargando = false;
|
||||
|
||||
List<Jugador> get _jugadoresActivos =>
|
||||
widget.jugadores.where((jugador) => !jugador.eliminado).toList();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
for (final jugador in _jugadoresActivos) {
|
||||
_controladores[jugador.id] = TextEditingController();
|
||||
}
|
||||
if (widget.autoresControlados.length == 1) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _seleccionarAutor(widget.autoresControlados.first);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final controller in _controladores.values) {
|
||||
controller.dispose();
|
||||
}
|
||||
_notaLibreController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _seleccionarAutor(JugadorInicioPartida autor) async {
|
||||
setState(() {
|
||||
_autor = autor;
|
||||
_cargando = true;
|
||||
for (final controller in _controladores.values) {
|
||||
controller.clear();
|
||||
}
|
||||
_notaLibreController.clear();
|
||||
});
|
||||
|
||||
final datos = await ServicioNotas.cargarNotasPartida(
|
||||
partidaId: widget.partidaId,
|
||||
autorJugadorId: autor.jugadorId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final notas = datos['notas'] as Map<String, String>;
|
||||
for (final entry in notas.entries) {
|
||||
_controladores[entry.key]?.text = entry.value;
|
||||
}
|
||||
_notaLibreController.text = datos['notaLibre'] as String;
|
||||
setState(() => _cargando = false);
|
||||
}
|
||||
|
||||
Future<void> _guardarNotas() async {
|
||||
final autor = _autor;
|
||||
if (autor == null) return;
|
||||
final notas = <String, String>{};
|
||||
for (final entry in _controladores.entries) {
|
||||
final texto = entry.value.text.trim();
|
||||
if (texto.isNotEmpty) notas[entry.key] = texto;
|
||||
}
|
||||
await ServicioNotas.guardarNotasPartida(
|
||||
partidaId: widget.partidaId,
|
||||
autorJugadorId: autor.jugadorId,
|
||||
notasPorJugador: notas,
|
||||
notaLibre: _notaLibreController.text,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
onPopInvokedWithResult: (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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
351
lib/pantallas/pantalla_resultado_online.dart
Normal 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'),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
133
lib/pantallas/pantalla_revision_palabra.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
|
||||
import 'package:farolero/tema/componentes_farolero.dart';
|
||||
import 'package:farolero/tema/tema_app.dart';
|
||||
|
||||
Future<void> mostrarRevisionPalabraOnline({
|
||||
required BuildContext context,
|
||||
required List<JugadorInicioPartida> jugadoresControlados,
|
||||
String? pistaCategoria,
|
||||
}) async {
|
||||
if (jugadoresControlados.isEmpty) return;
|
||||
|
||||
final jugador = jugadoresControlados.length == 1
|
||||
? jugadoresControlados.first
|
||||
: await showModalBottomSheet<JugadorInicioPartida>(
|
||||
context: context,
|
||||
backgroundColor: TemaApp.colorSuperficie,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(sheetContext)!.seeYourWord,
|
||||
style: Theme.of(sheetContext).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...jugadoresControlados.map(
|
||||
(jugador) => Card(
|
||||
color: TemaApp.colorTarjeta,
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.visibility),
|
||||
title: Text(jugador.nombre),
|
||||
onTap: () => Navigator.pop(sheetContext, jugador),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (jugador == null || !context.mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => _DialogoRevisionPalabra(
|
||||
jugador: jugador,
|
||||
pistaCategoria: pistaCategoria,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _DialogoRevisionPalabra extends StatelessWidget {
|
||||
final JugadorInicioPartida jugador;
|
||||
final String? pistaCategoria;
|
||||
|
||||
const _DialogoRevisionPalabra({
|
||||
required this.jugador,
|
||||
required this.pistaCategoria,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: TemaApp.colorSuperficie,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
jugador.nombre,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: TemaApp.decoracionPanel(
|
||||
color: TemaApp.colorTarjeta,
|
||||
borderColor: jugador.esImpostor
|
||||
? TemaApp.colorAcento
|
||||
: TemaApp.colorNaranja,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
jugador.esImpostor ? Icons.theater_comedy : Icons.key,
|
||||
color: jugador.esImpostor
|
||||
? TemaApp.colorAcento
|
||||
: TemaApp.colorNaranja,
|
||||
size: 36,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (jugador.esImpostor)
|
||||
Text(
|
||||
l10n.youAreImpostor,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
else
|
||||
TarjetaPalabraFarolero(palabra: jugador.palabra ?? ''),
|
||||
if (jugador.esImpostor && pistaCategoria != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
l10n.clueIs(pistaCategoria!),
|
||||
style: const TextStyle(color: TemaApp.colorNaranja),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.check),
|
||||
label: Text(l10n.back),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
115
lib/servicios/servicio_historial_partidas.dart
Normal 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()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
104
lib/servicios/servicio_perfil_usuario.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
437
lib/tema/componentes_farolero.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
|
||||
57
test/servicio_notas_test.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:farolero/servicios/servicio_notas.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
test('guarda y carga notas scoped por partida y autor', () async {
|
||||
await ServicioNotas.guardarNotasPartida(
|
||||
partidaId: 'sala-1',
|
||||
autorJugadorId: 'jugador-a',
|
||||
notasPorJugador: {'jugador-b': 'Dijo perro demasiado rápido'},
|
||||
notaLibre: 'Sospecho de B',
|
||||
);
|
||||
|
||||
final datos = await ServicioNotas.cargarNotasPartida(
|
||||
partidaId: 'sala-1',
|
||||
autorJugadorId: 'jugador-a',
|
||||
);
|
||||
|
||||
expect(datos['notas'], {'jugador-b': 'Dijo perro demasiado rápido'});
|
||||
expect(datos['notaLibre'], 'Sospecho de B');
|
||||
});
|
||||
|
||||
test('no mezcla notas entre partidas ni autores', () async {
|
||||
await ServicioNotas.guardarNotasPartida(
|
||||
partidaId: 'sala-1',
|
||||
autorJugadorId: 'jugador-a',
|
||||
notasPorJugador: {'jugador-b': 'nota sala 1'},
|
||||
notaLibre: 'libre 1',
|
||||
);
|
||||
await ServicioNotas.guardarNotasPartida(
|
||||
partidaId: 'sala-2',
|
||||
autorJugadorId: 'jugador-a',
|
||||
notasPorJugador: {'jugador-b': 'nota sala 2'},
|
||||
notaLibre: 'libre 2',
|
||||
);
|
||||
|
||||
final otraPartida = await ServicioNotas.cargarNotasPartida(
|
||||
partidaId: 'sala-2',
|
||||
autorJugadorId: 'jugador-a',
|
||||
);
|
||||
final otroAutor = await ServicioNotas.cargarNotasPartida(
|
||||
partidaId: 'sala-1',
|
||||
autorJugadorId: 'jugador-c',
|
||||
);
|
||||
|
||||
expect(otraPartida['notas'], {'jugador-b': 'nota sala 2'});
|
||||
expect(otraPartida['notaLibre'], 'libre 2');
|
||||
expect(otroAutor['notas'], isEmpty);
|
||||
expect(otroAutor['notaLibre'], isEmpty);
|
||||
});
|
||||
}
|
||||
70
test/snapshot_partida_online_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
216
tools/generate_word_banks.ps1
Normal 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"
|
||||