Compare commits

...

26 Commits

Author SHA1 Message Date
ShanaiaBot
9a2b2edefd chore: bump version to 1.1.13+18 [ci skip] 2026-05-04 22:23:46 +02:00
2dbe505d77 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 12s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m16s
2026-05-04 22:23:20 +02:00
3b0b10ea50 traducciones 2026-05-04 22:23:11 +02:00
ShanaiaBot
6a130acc84 chore: bump version to 1.1.12+17 [ci skip] 2026-05-04 20:58:32 +02:00
00dc3ee5e1 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 11s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m21s
2026-05-04 20:58:05 +02:00
957b42ea0c Gestión de usuarios y avatares en la aplicación. Gestión de traducciones de las palabras. 2026-05-04 20:58:02 +02:00
ShanaiaBot
47b1209668 chore: bump version to 1.1.11+16 [ci skip] 2026-05-04 20:24:24 +02:00
7dd6c7bd74 Mejora flujo de datos en partidas multidispositivos
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 12s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m53s
2026-05-04 20:23:47 +02:00
ShanaiaBot
01b65a3d29 chore: bump version to 1.1.10+15 [ci skip] 2026-05-04 13:58:30 +02:00
841f94e543 Completo y absoluto cambio de diseño
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 23s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m53s
2026-05-04 13:57:55 +02:00
ShanaiaBot
ab0d4dc2ba chore: bump version to 1.1.9+14 [ci skip] 2026-04-27 16:04:31 +02:00
Javier Bautista Fernández
50b050e678 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 10s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m57s
2026-04-27 16:04:10 +02:00
Javier Bautista Fernández
5d3b3ef271 feat: Add eliminarUsuario message type and handle user removal in ServicioNearby 2026-04-27 16:04:03 +02:00
c8e5cf25c5 Actualizar .gitea/workflows/build.yml
Some checks failed
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
Build & Deploy Farolero / Análisis de código (push) Failing after 13s
2026-04-27 14:43:52 +02:00
d850b66089 Actualizar .gitea/workflows/build.yml 2026-04-27 14:43:36 +02:00
Javier Bautista Fernández
166b89a661 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/farolero
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 4s
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
2026-04-27 14:41:01 +02:00
Javier Bautista Fernández
1cb2260298 chore: Remove PATH from environment variables and add Flutter version check steps 2026-04-27 14:40:43 +02:00
da9bd0cd4a Actualizar .gitea/workflows/build.yml
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 4s
Build & Deploy Farolero / Build APK + AAB release (push) Has been skipped
2026-04-27 14:37:50 +02:00
d600835105 Actualizar .gitea/workflows/build.yml
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Failing after 6s
Build & Deploy Farolero / Build APK + AAB release (push) Has been cancelled
2026-04-27 14:36:44 +02:00
Javier Bautista Fernández
a8d5b0f002 feat: Implement multiplayer game session management
Some checks failed
Build & Deploy Farolero / Análisis de código (push) Has been cancelled
Build & Deploy Farolero / Build APK + AAB release (push) Has been cancelled
- Add models for managing player assignments and game session initialization in `inicio_partida_multijugador.dart`.
- Create a multiplayer room state management system in `sala_multijugador.dart`, including user registration, selection, and session validation.
- Develop a UI screen for displaying player words sequentially in `pantalla_palabras_cliente.dart`.
- Implement unit tests for the multiplayer session management and player assignment logic in `inicio_partida_multijugador_test.dart` and `sala_multijugador_test.dart`.
2026-04-27 14:02:33 +02:00
ShanaiaBot
4a1abd0be0 chore: bump version to 1.1.8+13 [ci skip] 2026-04-24 21:38:01 +02:00
f3dcb99de1 Merge pull request 'fix: boton ver palabra del host ahora funciona' (#3) from feat/host-como-jugador into main
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 10s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m18s
Reviewed-on: #3
2026-04-24 21:37:42 +02:00
FreeTLab
f41fbc7dd9 fix: boton ver palabra del host ahora funciona 2026-04-24 21:34:40 +02:00
ShanaiaBot
e3c502c7df chore: bump version to 1.1.7+12 [ci skip] 2026-04-24 20:04:17 +02:00
3f4ec2d20f Merge pull request 'feat: host como jugador' (#2) from feat/host-como-jugador into main
All checks were successful
Build & Deploy Farolero / Análisis de código (push) Successful in 10s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m51s
Reviewed-on: #2
2026-04-24 20:03:59 +02:00
FreeTLab
1231b32c3c feat: host como jugador 2026-04-24 20:01:54 +02:00
99 changed files with 23311 additions and 765 deletions

View File

@@ -5,7 +5,6 @@ on:
branches: [main]
env:
PATH: /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
jobs:
@@ -14,6 +13,10 @@ jobs:
runs-on: [self-hosted, macos, arm64, flutter]
steps:
- uses: actions/checkout@v4
- name: Verificar Flutter
run: |
which flutter
flutter --version
- name: Obtener dependencias
run: flutter pub get
- name: Generar l10n
@@ -28,6 +31,10 @@ jobs:
if: ${{ gitea.ref == 'refs/heads/main' }}
steps:
- uses: actions/checkout@v4
- name: Verificar Flutter
run: |
which flutter
flutter --version
- name: Fetch completo + Bump versión patch + commit
run: |

2
.gitignore vendored
View File

@@ -48,3 +48,5 @@ build/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.atl/

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import '../modelos/jugador.dart';
import '../modelos/partida.dart';
import '../modelos/palabra.dart';
import '../modelos/sala_multijugador.dart';
import '../servicios/servicio_notas.dart';
/// Estado global del juego gestionado con Provider
@@ -89,6 +90,61 @@ class EstadoJuego extends ChangeNotifier {
notifyListeners();
}
/// Crea una partida multi-dispositivo usando los usuarios seleccionados de la
/// sala como jugadores reales. La identidad de jugador se conserva por id y
/// cada jugador queda asociado al endpoint del cliente que lo controla.
void crearPartidaDesdeSala({
required ConfigPartida config,
required EstadoSalaMultijugador sala,
}) {
if (_banco == null) return;
final usuariosSeleccionados = sala.usuariosSeleccionados;
if (usuariosSeleccionados.length < 3) return;
final palabra = _banco!.palabraAleatoria(config.categoria);
final categoriaReal =
_banco!.categoriaDepalabra(palabra) ?? config.categoria;
final jugadores = usuariosSeleccionados.map((usuario) {
final clienteId = usuario.clienteIdSeleccionado;
final endpointId = clienteId == null
? null
: sala.clientes[clienteId]?.endpointId;
return Jugador(
id: usuario.id,
nombre: usuario.nombre,
endpointId: endpointId,
);
}).toList();
final rng = Random.secure();
final numImpostores = config.numImpostores.clamp(1, jugadores.length ~/ 3);
final impostoresElegidos = <int>{};
while (impostoresElegidos.length < numImpostores) {
impostoresElegidos.add(rng.nextInt(jugadores.length));
}
for (final i in impostoresElegidos) {
jugadores[i].esImpostor = true;
}
for (final jugador in jugadores) {
if (!jugador.esImpostor) {
jugador.palabra = palabra;
}
}
_partida = Partida(
config: config,
jugadores: jugadores,
palabraSecreta: palabra,
categoriaReal: categoriaReal,
);
_votos.clear();
ServicioNotas.limpiarNotas();
notifyListeners();
}
/// Avanza a la fase de debate
void iniciarDebate() {
if (_partida == null) return;

View File

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

View File

@@ -0,0 +1,117 @@
class AsignacionJugador {
final String jugadorId;
final String nombre;
final String clientId;
final String? endpointId;
const AsignacionJugador({
required this.jugadorId,
required this.nombre,
required this.clientId,
required this.endpointId,
});
}
class JugadorInicioPartida {
final String jugadorId;
final String nombre;
final bool esImpostor;
final String? palabra;
const JugadorInicioPartida({
required this.jugadorId,
required this.nombre,
required this.esImpostor,
required this.palabra,
});
Map<String, dynamic> toJson() => {
'jugadorId': jugadorId,
'nombre': nombre,
'esImpostor': esImpostor,
if (palabra != null) 'palabra': palabra,
};
factory JugadorInicioPartida.fromJson(Map<String, dynamic> json) {
return JugadorInicioPartida(
jugadorId: json['jugadorId'] as String,
nombre: json['nombre'] as String,
esImpostor: json['esImpostor'] as bool? ?? false,
palabra: json['palabra'] as String?,
);
}
}
class InicioPartidaCliente {
final String clientId;
final String? endpointId;
final String categoria;
final List<JugadorInicioPartida> jugadores;
const InicioPartidaCliente({
required this.clientId,
required this.endpointId,
required this.categoria,
required this.jugadores,
});
Map<String, dynamic> toJson() => {
'clientId': clientId,
if (endpointId != null) 'endpointId': endpointId,
'categoria': categoria,
'jugadores': jugadores.map((jugador) => jugador.toJson()).toList(),
};
factory InicioPartidaCliente.fromJson(Map<String, dynamic> json) {
return InicioPartidaCliente(
clientId: json['clientId'] as String,
endpointId: json['endpointId'] as String?,
categoria: json['categoria'] as String,
jugadores: (json['jugadores'] as List<dynamic>? ?? [])
.map((jugadorJson) => JugadorInicioPartida.fromJson(
jugadorJson as Map<String, dynamic>,
))
.toList(),
);
}
}
class InicioPartidaMultijugador {
static Map<String, InicioPartidaCliente> crearPayloadsPorCliente({
required List<AsignacionJugador> asignaciones,
required String palabraSecreta,
required String categoria,
required Map<String, bool> impostoresPorJugadorId,
}) {
final payloads = <String, InicioPartidaCliente>{};
for (final asignacion in asignaciones) {
final esImpostor = impostoresPorJugadorId[asignacion.jugadorId] ?? false;
final payloadActual = payloads[asignacion.clientId];
final jugador = JugadorInicioPartida(
jugadorId: asignacion.jugadorId,
nombre: asignacion.nombre,
esImpostor: esImpostor,
palabra: esImpostor ? null : palabraSecreta,
);
if (payloadActual == null) {
payloads[asignacion.clientId] = InicioPartidaCliente(
clientId: asignacion.clientId,
endpointId: asignacion.endpointId,
categoria: categoria,
jugadores: [jugador],
);
} else {
payloads[asignacion.clientId] = InicioPartidaCliente(
clientId: payloadActual.clientId,
endpointId: payloadActual.endpointId,
categoria: payloadActual.categoria,
jugadores: [...payloadActual.jugadores, jugador],
);
}
}
return payloads;
}
}

View File

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

View File

@@ -0,0 +1,294 @@
import 'usuario.dart';
enum FaseSalaMultijugador { lobby, enPartida, finalizada }
class ResultadoOperacionSala {
final bool exitoso;
final String? codigo;
final String? mensaje;
const ResultadoOperacionSala._({
required this.exitoso,
this.codigo,
this.mensaje,
});
const ResultadoOperacionSala.ok([String? mensaje])
: this._(exitoso: true, mensaje: mensaje);
const ResultadoOperacionSala.error(String codigo, [String? mensaje])
: this._(exitoso: false, codigo: codigo, mensaje: mensaje);
Map<String, dynamic> toJson() => {
'exitoso': exitoso,
if (codigo != null) 'codigo': codigo,
if (mensaje != null) 'mensaje': mensaje,
};
}
class ClienteSala {
final String clientId;
final String? endpointId;
final String nombre;
final bool esHost;
final bool conectado;
const ClienteSala({
required this.clientId,
this.endpointId,
required this.nombre,
this.esHost = false,
this.conectado = true,
});
ClienteSala copiar({
String? clientId,
String? endpointId,
String? nombre,
bool? esHost,
bool? conectado,
}) {
return ClienteSala(
clientId: clientId ?? this.clientId,
endpointId: endpointId ?? this.endpointId,
nombre: nombre ?? this.nombre,
esHost: esHost ?? this.esHost,
conectado: conectado ?? this.conectado,
);
}
Map<String, dynamic> toJson() => {
'clientId': clientId,
if (endpointId != null) 'endpointId': endpointId,
'nombre': nombre,
'esHost': esHost,
'conectado': conectado,
};
factory ClienteSala.fromJson(Map<String, dynamic> json) => ClienteSala(
clientId: json['clientId'] as String,
endpointId: json['endpointId'] as String?,
nombre: json['nombre'] as String,
esHost: json['esHost'] as bool? ?? false,
conectado: json['conectado'] as bool? ?? true,
);
}
class EstadoSalaMultijugador {
final String roomId;
final String nombreSala;
final String hostClientId;
FaseSalaMultijugador fase;
final Map<String, ClienteSala> clientes;
final Map<String, Usuario> usuarios;
EstadoSalaMultijugador({
required this.roomId,
required this.nombreSala,
required this.hostClientId,
this.fase = FaseSalaMultijugador.lobby,
Map<String, ClienteSala>? clientes,
Map<String, Usuario>? usuarios,
}) : clientes = clientes ?? {},
usuarios = usuarios ?? {};
factory EstadoSalaMultijugador.crear({
required String roomId,
required String nombreSala,
required String hostClientId,
required String hostNombre,
}) {
final sala = EstadoSalaMultijugador(
roomId: roomId,
nombreSala: nombreSala,
hostClientId: hostClientId,
);
sala.registrarCliente(
ClienteSala(
clientId: hostClientId,
nombre: hostNombre,
esHost: true,
),
);
return sala;
}
List<Usuario> get usuariosSeleccionados =>
usuarios.values.where((usuario) => usuario.estaSeleccionado).toList();
List<Usuario> get usuariosDisponibles =>
usuarios.values.where((usuario) => usuario.estaDisponible).toList();
int get cantidadUsuariosSeleccionados => usuariosSeleccionados.length;
List<Usuario> usuariosPorCliente(String clientId) {
return usuarios.values
.where((usuario) => usuario.clienteIdSeleccionado == clientId)
.toList();
}
ClienteSala? clientePorEndpoint(String endpointId) {
for (final cliente in clientes.values) {
if (cliente.endpointId == endpointId) return cliente;
}
return null;
}
ResultadoOperacionSala registrarCliente(ClienteSala cliente) {
clientes[cliente.clientId] = cliente;
return const ResultadoOperacionSala.ok();
}
ResultadoOperacionSala crearUsuario(Usuario usuario) {
if (fase != FaseSalaMultijugador.lobby) {
return const ResultadoOperacionSala.error('sala_cerrada');
}
if (usuarios.containsKey(usuario.id)) {
return const ResultadoOperacionSala.error('usuario_duplicado');
}
final nombreNormalizado = usuario.nombre.trim().toLowerCase();
final nombreExiste = usuarios.values.any(
(u) => u.nombre.trim().toLowerCase() == nombreNormalizado,
);
if (nombreExiste) {
return const ResultadoOperacionSala.error('nombre_duplicado');
}
usuarios[usuario.id] = usuario;
return const ResultadoOperacionSala.ok();
}
ResultadoOperacionSala seleccionarUsuario({
required String usuarioId,
required String clienteId,
}) {
if (fase != FaseSalaMultijugador.lobby) {
return const ResultadoOperacionSala.error('sala_cerrada');
}
final cliente = clientes[clienteId];
if (cliente == null || !cliente.conectado) {
return const ResultadoOperacionSala.error('cliente_no_disponible');
}
final usuario = usuarios[usuarioId];
if (usuario == null) {
return const ResultadoOperacionSala.error('usuario_no_existe');
}
if (usuario.clienteIdSeleccionado == clienteId) {
return const ResultadoOperacionSala.ok();
}
if (usuario.clienteIdSeleccionado != null) {
return const ResultadoOperacionSala.error('usuario_ya_seleccionado');
}
usuarios[usuarioId] = usuario.copiar(clienteIdSeleccionado: clienteId);
return const ResultadoOperacionSala.ok();
}
ResultadoOperacionSala liberarUsuario({
required String usuarioId,
required String solicitanteClientId,
}) {
if (fase != FaseSalaMultijugador.lobby) {
return const ResultadoOperacionSala.error('sala_cerrada');
}
final usuario = usuarios[usuarioId];
if (usuario == null) {
return const ResultadoOperacionSala.error('usuario_no_existe');
}
final solicitante = clientes[solicitanteClientId];
final puedeLiberar =
usuario.clienteIdSeleccionado == solicitanteClientId ||
(solicitante?.esHost ?? false);
if (!puedeLiberar) {
return const ResultadoOperacionSala.error('usuario_de_otro_cliente');
}
usuarios[usuarioId] = usuario.copiar(liberarSeleccion: true);
return const ResultadoOperacionSala.ok();
}
ResultadoOperacionSala eliminarUsuario({
required String usuarioId,
required String solicitanteClientId,
}) {
final solicitante = clientes[solicitanteClientId];
if (!(solicitante?.esHost ?? false)) {
return const ResultadoOperacionSala.error('solo_host');
}
final usuario = usuarios[usuarioId];
if (usuario == null) {
return const ResultadoOperacionSala.error('usuario_no_existe');
}
if (usuario.estaSeleccionado) {
return const ResultadoOperacionSala.error('usuario_seleccionado');
}
usuarios.remove(usuarioId);
return const ResultadoOperacionSala.ok();
}
void desconectarCliente(String clientId) {
final cliente = clientes[clientId];
if (cliente == null) return;
clientes[clientId] = cliente.copiar(conectado: false);
if (fase != FaseSalaMultijugador.lobby) return;
for (final entry in usuarios.entries.toList()) {
if (entry.value.clienteIdSeleccionado == clientId) {
usuarios[entry.key] = entry.value.copiar(liberarSeleccion: true);
}
}
}
ResultadoOperacionSala validarInicio() {
if (fase != FaseSalaMultijugador.lobby) {
return const ResultadoOperacionSala.error('sala_cerrada');
}
if (cantidadUsuariosSeleccionados < 3) {
return const ResultadoOperacionSala.error('faltan_jugadores');
}
if (usuariosPorCliente(hostClientId).isEmpty) {
return const ResultadoOperacionSala.error('host_sin_usuario');
}
return const ResultadoOperacionSala.ok();
}
ResultadoOperacionSala iniciarPartida() {
final validacion = validarInicio();
if (!validacion.exitoso) return validacion;
fase = FaseSalaMultijugador.enPartida;
return const ResultadoOperacionSala.ok();
}
Map<String, dynamic> toJson() => {
'roomId': roomId,
'nombreSala': nombreSala,
'hostClientId': hostClientId,
'fase': fase.name,
'clientes': clientes.values.map((cliente) => cliente.toJson()).toList(),
'usuarios': usuarios.values.map((usuario) => usuario.toJson()).toList(),
};
factory EstadoSalaMultijugador.fromJson(Map<String, dynamic> json) {
final clientes = <String, ClienteSala>{};
for (final clienteJson in json['clientes'] as List<dynamic>? ?? []) {
final cliente = ClienteSala.fromJson(
clienteJson as Map<String, dynamic>,
);
clientes[cliente.clientId] = cliente;
}
final usuarios = <String, Usuario>{};
for (final usuarioJson in json['usuarios'] as List<dynamic>? ?? []) {
final usuario = Usuario.fromJson(usuarioJson as Map<String, dynamic>);
usuarios[usuario.id] = usuario;
}
return EstadoSalaMultijugador(
roomId: json['roomId'] as String,
nombreSala: json['nombreSala'] as String,
hostClientId: json['hostClientId'] as String,
fase: FaseSalaMultijugador.values.firstWhere(
(fase) => fase.name == json['fase'],
orElse: () => FaseSalaMultijugador.lobby,
),
clientes: clientes,
usuarios: usuarios,
);
}
}

View File

@@ -2,19 +2,66 @@
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.avatar});
Usuario({
required this.id,
required this.nombre,
this.nick,
this.avatar,
this.foto,
this.creadoPorClienteId,
this.clienteIdSeleccionado,
});
bool get estaSeleccionado => clienteIdSeleccionado != null;
bool get estaDisponible => clienteIdSeleccionado == null;
Usuario copiar({
String? id,
String? nombre,
String? nick,
String? avatar,
String? foto,
String? creadoPorClienteId,
String? clienteIdSeleccionado,
bool liberarSeleccion = false,
}) {
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
: (clienteIdSeleccionado ?? this.clienteIdSeleccionado),
);
}
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,
};
factory Usuario.fromJson(Map<String, dynamic> json) => Usuario(
id: json['id'] as String,
nombre: json['nombre'] as String,
nick: json['nick'] as String?,
avatar: json['avatar'] as String?,
foto: json['foto'] as String?,
creadoPorClienteId: json['creadoPorClienteId'] as String?,
clienteIdSeleccionado: json['clienteIdSeleccionado'] as String?,
);
}

View File

@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import '../servicios/servicio_idioma.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
class PantallaAjustes extends StatefulWidget {
@@ -19,14 +21,31 @@ class _PantallaAjustesState extends State<PantallaAjustes> {
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(
@@ -133,6 +152,7 @@ class _PantallaAjustesState extends State<PantallaAjustes> {
),
const SizedBox(height: 16),
],
),
),
),
);
@@ -170,6 +190,107 @@ class _PantallaAjustesState extends State<PantallaAjustes> {
);
}
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() {
final locale = WidgetsBinding.instance.platformDispatcher.locale;
final codigo = locale.countryCode != null && locale.countryCode!.isNotEmpty

View File

@@ -2,11 +2,14 @@ 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/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';
@@ -129,8 +132,14 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
// 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) {
@@ -153,15 +162,21 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
onIniciar: () {
// Cuando el host toca "Iniciar" con suficientes jugadores
final estado = context.read<EstadoJuego>();
final sala = nearby.estadoSala;
if (sala == null) return;
final validacion = sala.iniciarPartida();
if (!validacion.exitoso) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'No se puede iniciar: ${validacion.codigo ?? "sala inválida"}',
),
),
);
return;
}
// Set host local player first (required for host-included game)
estado.setHostJugador(nombre.trim());
final jugadoresMulti = [
nombre.trim(),
...nearby.jugadores.map((j) => j.nombre),
];
estado.crearPartida(
estado.crearPartidaDesdeSala(
config: ConfigPartida(
modoMultimovil: true,
categoria: _categoria,
@@ -169,24 +184,41 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
pistaImpostor: _pistaImpostor,
tiempoDebateSegundos: _tiempoDebate,
),
nombresJugadores: jugadoresMulti,
sala: sala,
);
// Enviar palabras a cada jugador via Nearby
final partida = estado.partida!;
final impostores = <String, bool>{};
for (int i = 0; i < nearby.jugadores.length; i++) {
final jugadorNearby = nearby.jugadores[i];
// El jugador [0] es el host, los de nearby son [1..n]
final jugadorPartida = partida.jugadores[i + 1];
impostores[jugadorNearby.endpointId] =
jugadorPartida.esImpostor;
}
final asignaciones = partida.jugadores.map((jugador) {
final usuarioSala = sala.usuarios[jugador.id];
final clientId = usuarioSala?.clienteIdSeleccionado;
final cliente = clientId == null ? null : sala.clientes[clientId];
return AsignacionJugador(
jugadorId: jugador.id,
nombre: jugador.nombre,
clientId: clientId ?? sala.hostClientId,
endpointId: cliente?.endpointId,
);
}).toList();
final impostores = {
for (final jugador in partida.jugadores)
jugador.id: jugador.esImpostor,
};
final jugadoresTodos = partida.jugadores
.map(
(jugador) => {
'id': jugador.id,
'nombre': jugador.nombre,
'eliminado': jugador.eliminado,
},
)
.toList();
nearby.enviarInicioPartida(
nearby.enviarInicioPartidaMulti(
asignaciones: asignaciones,
palabraSecreta: partida.palabraSecreta,
categoria: _categoria,
impostores: impostores,
impostoresPorJugadorId: impostores,
jugadoresTodos: jugadoresTodos,
);
Navigator.pushReplacement(
@@ -230,7 +262,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String>(
value: seleccionado,
initialValue: seleccionado,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.person),
hintText: l10n.selectProfile,
@@ -254,7 +286,11 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
value: usuario.nombre,
child: Row(
children: [
Text(usuario.avatar ?? '👤'),
AvatarFarolero(
texto: usuario.nombre.substring(0, 1),
assetPath: usuario.avatar,
size: 28,
),
const SizedBox(width: 8),
Text(usuario.nombre),
],
@@ -299,6 +335,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
final controller = TextEditingController();
final l10n = AppLocalizations.of(context)!;
final nearby = context.read<ServicioNearby>();
controller.text = context.read<ServicioPerfilUsuario>().perfil.nombre;
final nombre = await showDialog<String>(
context: context,
@@ -331,6 +368,9 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
final nuevoUsuario = Usuario(
id: DateTime.now().millisecondsSinceEpoch.toString(),
nombre: nombre.trim(),
nick: context.read<ServicioPerfilUsuario>().perfil.nick,
avatar: context.read<ServicioPerfilUsuario>().perfil.avatarAsset,
foto: context.read<ServicioPerfilUsuario>().perfil.avatarAsset,
);
nearby.agregarUsuario(nuevoUsuario);
return nombre.trim();
@@ -341,6 +381,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
/// Método original para pedir nombre (usado cuando pool vacío)
Future<String?> _pedirNombreHost() async {
final controller = TextEditingController();
controller.text = context.read<ServicioPerfilUsuario>().perfil.nombre;
final l10n = AppLocalizations.of(context)!;
return showDialog<String>(
context: context,
@@ -385,11 +426,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(
@@ -624,6 +692,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
),
const SizedBox(height: 16),
],
),
),
),
);

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart';
import '../estado/estado_juego.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_notas.dart';
import 'pantalla_votacion.dart';
@@ -75,9 +76,10 @@ class _PantallaDebateState extends State<PantallaDebate> {
title: Text(l10n.debateRound(partida.rondaActual)),
automaticallyImplyLeading: false,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Temporizador
if (tieneTemporizador) ...[
@@ -223,6 +225,7 @@ class _PantallaDebateState extends State<PantallaDebate> {
],
),
],
),
),
),
);

View File

@@ -7,11 +7,13 @@ import 'package:farolero/tema/tema_app.dart';
/// El cliente recibe el cambio de fase via Nearby y se navega aquí.
class PantallaDebateCliente extends StatefulWidget {
final int? tiempoDebateSegundos;
final String? primerTurnoNombre;
final VoidCallback onSolicitarVotacion;
const PantallaDebateCliente({
super.key,
this.tiempoDebateSegundos,
this.primerTurnoNombre,
required this.onSolicitarVotacion,
});
@@ -111,6 +113,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,

View File

@@ -3,13 +3,21 @@ 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 '../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 +28,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(

View File

@@ -1,12 +1,17 @@
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 '../servicios/servicio_nearby.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_votacion_cliente.dart';
import 'pantalla_palabras_cliente.dart';
class PantallaGestorHost extends StatefulWidget {
final VoidCallback onPartidaFin;
@@ -20,6 +25,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 = {};
@@ -52,8 +60,11 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
setState(() => _clientesListos[endpointId] = true);
} else if (mensaje.tipo == TipoMensaje.voto) {
final votanteId = mensaje.datos['votanteId'] as String?;
final votoId = mensaje.datos['votoporId'] as String?;
final votoId =
mensaje.datos['votadoId'] as String? ??
mensaje.datos['votoporId'] as String?;
if (votanteId != null && votoId != null) {
context.read<EstadoJuego>().registrarVoto(votanteId, votoId);
setState(() => _votosRecibidos[votanteId] = votoId);
}
}
@@ -79,7 +90,15 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
switch (fase) {
case FaseJuego.debate:
estado.iniciarDebate();
nearby.enviarCambioFase('debate');
final primero = _elegirPrimerTurno();
nearby.enviarCambioFase('debate', {
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:
@@ -117,9 +136,9 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
);
}
final numJugadores = partida.jugadores.length + 1;
final todosListos = _clientesListos.length >= numJugadores - 1;
final todosVotaron = _votosRecibidos.length >= numJugadores - 1;
final todosListos =
_hostListo && _clientesListos.length >= nearby.jugadores.length;
final todosVotaron = estado.todosHanVotado();
return Scaffold(
appBar: AppBar(
@@ -135,9 +154,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),
@@ -159,6 +179,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
todosVotaron,
),
],
),
),
),
);
@@ -217,6 +238,8 @@ 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);
default:
return const Center(child: Text('Fin de la partida'));
}
@@ -244,7 +267,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,
@@ -253,6 +276,21 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
),
),
const Spacer(),
// Botón para que el host vea su palabra
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _mostrarPalabraHost(context),
icon: const Icon(Icons.visibility),
label: Text(l10n.seeYourWord),
style: ElevatedButton.styleFrom(
backgroundColor: TemaApp.colorNaranja,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(height: 12),
if (todosListos)
Container(
padding: const EdgeInsets.all(12),
@@ -278,6 +316,82 @@ 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;
final jugadoresHost = sala
.usuariosPorCliente(sala.hostClientId)
.where((usuario) => partida.jugadores.any((j) => j.id == usuario.id))
.map((usuario) {
final jugador = partida.jugadores.firstWhere(
(j) => j.id == usuario.id,
);
return JugadorInicioPartida(
jugadorId: jugador.id,
nombre: jugador.nombre,
esImpostor: jugador.esImpostor,
palabra: jugador.palabra,
);
})
.toList();
if (jugadoresHost.length > 1) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PantallaPalabrasCliente(
jugadores: jugadoresHost,
pistaCategoria: partida.config.pistaImpostor
? partida.categoriaReal
: null,
onTodosVistos: () {
setState(() => _hostListo = true);
Navigator.of(context).pop();
},
),
),
);
return;
}
final hostLocal = jugadoresHost.isNotEmpty
? partida.jugadores.firstWhere((j) => j.id == jugadoresHost.first.jugadorId)
: partida.jugadores.first;
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => _PantallaRevelarPalabraHost(
nombre: hostLocal.nombre,
esImpostor: hostLocal.esImpostor,
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,
@@ -321,6 +435,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,
@@ -348,12 +464,43 @@ 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,
bool todosVotaron,
ServicioNearby nearby,
) {
final estado = context.watch<EstadoJuego>();
final partida = estado.partida!;
final totalVotos = partida.jugadoresActivos.length;
final votosEmitidos = estado.votos.length;
final progreso = totalVotos == 0 ? 0.0 : votosEmitidos / totalVotos;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -371,19 +518,12 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
),
child: Column(
children: [
Text(
l10n.votesProgress(
_votosRecibidos.length,
nearby.jugadores.length + 1,
),
),
Text(l10n.votesProgress(votosEmitidos, totalVotos)),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value:
_votosRecibidos.length /
(nearby.jugadores.length + 1),
value: progreso.clamp(0.0, 1.0).toDouble(),
backgroundColor: TemaApp.colorSuperficie,
valueColor: const AlwaysStoppedAnimation(
TemaApp.colorAcento,
@@ -394,6 +534,19 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
],
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _hostYaVoto(context) ? null : () => _abrirVotacionHost(context),
icon: const Icon(Icons.how_to_vote),
label: Text(
_hostYaVoto(context)
? 'Votos del host registrados'
: 'Votar por los jugadores de este m?vil',
),
),
),
const SizedBox(height: 16),
Text(
l10n.playersVoted,
@@ -402,15 +555,11 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
const SizedBox(height: 8),
Expanded(
child: ListView.builder(
itemCount: nearby.jugadores.length + 1,
itemCount: partida.jugadoresActivos.length,
itemBuilder: (context, index) {
final esHost = index == 0;
final nombre = esHost
? (nearby.miNombre ?? 'Host')
: nearby.jugadores[index - 1].nombre;
final haVotado =
esHost || _votosRecibidos.containsKey(nombre);
return _buildJugadorTile(nombre, esHost, haVotado);
final jugador = partida.jugadoresActivos[index];
final haVotado = estado.votos.containsKey(jugador.id);
return _buildJugadorTile(jugador.nombre, false, haVotado);
},
),
),
@@ -439,6 +588,190 @@ 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),
),
),
],
),
);
}
bool _hostYaVoto(BuildContext context) {
final estado = context.read<EstadoJuego>();
final sala = context.read<ServicioNearby>().estadoSala;
if (sala == null || estado.partida == null) return false;
final hostIds = sala.usuariosPorCliente(sala.hostClientId).map((u) => u.id);
return hostIds.every((id) => estado.votos.containsKey(id));
}
void _abrirVotacionHost(BuildContext context) {
final estado = context.read<EstadoJuego>();
final sala = context.read<ServicioNearby>().estadoSala;
final partida = estado.partida;
if (sala == null || partida == null) return;
final jugadoresHost = sala.usuariosPorCliente(sala.hostClientId)
.where((usuario) => partida.jugadoresActivos.any((j) => j.id == usuario.id))
.map(
(usuario) => JugadorInicioPartida(
jugadorId: usuario.id,
nombre: usuario.nombre,
esImpostor: partida.jugadores.firstWhere((j) => j.id == usuario.id).esImpostor,
palabra: partida.jugadores.firstWhere((j) => j.id == usuario.id).palabra,
),
)
.toList();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PantallaVotacionCliente(
jugadores: partida.jugadoresActivos,
jugadoresControlados: jugadoresHost,
onVotos: (votos) {
for (final entry in votos.entries) {
estado.registrarVoto(entry.key, entry.value);
_votosRecibidos[entry.key] = entry.value;
}
if (mounted) setState(() {});
Navigator.of(context).pop();
},
),
),
);
}
Widget _buildJugadorTile(String nombre, bool esHost, bool listo) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
@@ -451,7 +784,10 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
),
child: Row(
children: [
Text(esHost ? '👑' : '🎭', style: const TextStyle(fontSize: 18)),
Text(
esHost ? 'Host' : 'Cliente',
style: const TextStyle(fontSize: 18),
),
const SizedBox(width: 8),
Expanded(child: Text(nombre)),
if (listo)
@@ -512,3 +848,167 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
}
}
}
class _PantallaRevelarPalabraHost extends StatefulWidget {
final String nombre;
final bool esImpostor;
final String palabra;
final bool pistaActiva;
final String categoria;
final VoidCallback onVisto;
const _PantallaRevelarPalabraHost({
required this.nombre,
required this.esImpostor,
required this.palabra,
required this.pistaActiva,
required this.categoria,
required this.onVisto,
});
@override
State<_PantallaRevelarPalabraHost> createState() =>
_PantallaRevelarPalabraHostState();
}
class _PantallaRevelarPalabraHostState
extends State<_PantallaRevelarPalabraHost> {
bool _manteniendo = false;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(widget.nombre)),
body: FondoFarolero(
intenso: true,
child: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.nombre,
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 32),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: double.infinity,
padding: const EdgeInsets.all(32),
decoration: BoxDecoration(
color: _manteniendo
? (widget.esImpostor
? TemaApp.colorAcento.withValues(alpha: 0.3)
: TemaApp.colorVerde.withValues(alpha: 0.3))
: TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: _manteniendo
? (widget.esImpostor
? TemaApp.colorAcento
: TemaApp.colorVerde)
: Colors.transparent,
width: 2,
),
),
child: _manteniendo
? Column(
children: [
Text(
widget.esImpostor ? 'Impostor' : 'Ciudadano',
style: const TextStyle(fontSize: 48),
),
const SizedBox(height: 16),
Text(
widget.esImpostor
? l10n.youAreImpostor
: l10n.yourWordIs,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(
color: widget.esImpostor
? TemaApp.colorAcento
: TemaApp.colorVerde,
),
),
if (!widget.esImpostor) ...[
const SizedBox(height: 12),
TarjetaPalabraFarolero(palabra: widget.palabra),
],
if (widget.esImpostor && widget.pistaActiva) ...[
const SizedBox(height: 12),
Text(
'Categoría: ${widget.categoria}',
style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: TemaApp.colorNaranja),
),
],
],
)
: Column(
children: [
const Text('🔒', style: TextStyle(fontSize: 48)),
const SizedBox(height: 16),
Text(
l10n.holdToSeeWord,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l10n.makeSureNoOneLooks,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
const SizedBox(height: 24),
GestureDetector(
onLongPressStart: (_) => setState(() => _manteniendo = true),
onLongPressEnd: (_) => setState(() => _manteniendo = false),
child: Container(
width: double.infinity,
height: 64,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: _manteniendo
? [TemaApp.colorNaranja, TemaApp.colorAcento]
: [TemaApp.colorAcento, TemaApp.colorAcento],
),
borderRadius: BorderRadius.circular(16),
),
child: Center(
child: Text(
_manteniendo ? l10n.showingWord : l10n.holdToSee,
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () {
widget.onVisto();
Navigator.of(context).pop();
},
icon: const Icon(Icons.check),
label: Text(l10n.iveSeenIt),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,58 @@
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';
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(
'${partida.fecha.day.toString().padLeft(2, '0')}/${partida.fecha.month.toString().padLeft(2, '0')}',
style: Theme.of(context).textTheme.bodySmall,
),
),
);
},
),
),
);
}
}

View File

@@ -4,9 +4,11 @@ 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';
/// Pantalla de lobby del host: muestra QR y lista de jugadores conectados
/// Lobby del host. El host es autoridad de sala y también cliente local.
class PantallaLobbyHost extends StatefulWidget {
final String nombreSala;
final VoidCallback onIniciar;
@@ -23,14 +25,16 @@ class PantallaLobbyHost extends StatefulWidget {
class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
bool _iniciando = false;
String? _perfilSeleccionado;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final nearby = context.watch<ServicioNearby>();
final jugadores = nearby.jugadores;
final totalJugadores = jugadores.length + 1; // +1 host
final sala = nearby.estadoSala;
final usuarios = nearby.usuarios;
final seleccionados = usuarios.where((u) => u.estaSeleccionado).length;
final validacionInicio = sala?.validarInicio();
final puedeIniciar = validacionInicio?.exitoso ?? false;
return Scaffold(
appBar: AppBar(
@@ -43,11 +47,11 @@ 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: [
// QR Code
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -57,174 +61,66 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
child: QrImageView(
data: nearby.generarDatosQR(widget.nombreSala),
version: QrVersions.auto,
size: 180,
size: 160,
backgroundColor: Colors.white,
),
),
const SizedBox(height: 12),
Text(
l10n.scanToJoin,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
// Selección de perfil
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.selectYourProfile,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _perfilSeleccionado,
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
...nearby.usuarios.map((usuario) {
return DropdownMenuItem<String>(
value: usuario.nombre,
child: Row(
children: [
Text(usuario.avatar ?? '👤'),
const SizedBox(width: 8),
Text(usuario.nombre),
],
),
);
}),
],
onChanged: (valor) {
if (valor == '_new_') {
_crearNuevoUsuario(context);
} else {
setState(() => _perfilSeleccionado = valor);
}
},
),
],
),
),
),
Text(l10n.scanToJoin),
const SizedBox(height: 16),
// Lista de jugadores
_buildResumenSala(context, seleccionados, nearby.jugadores.length),
const SizedBox(height: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.connectedPlayers,
style: Theme.of(context).textTheme.titleLarge,
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: totalJugadores >= 3
? TemaApp.colorVerde.withValues(alpha: 0.2)
: TemaApp.colorNaranja.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'$totalJugadores',
style: TextStyle(
color: totalJugadores >= 3
? TemaApp.colorVerde
: TemaApp.colorNaranja,
fontWeight: FontWeight.bold,
Row(
children: [
Expanded(
child: Text(
'Usuarios de la partida',
style: Theme.of(context).textTheme.titleLarge,
),
),
),
IconButton.filledTonal(
onPressed: () => _crearNuevoUsuario(context),
icon: const Icon(Icons.person_add),
),
],
),
const SizedBox(height: 8),
Expanded(
child: usuarios.isEmpty
? Center(child: Text(l10n.waitingForPlayers))
: ListView.builder(
itemCount: usuarios.length,
itemBuilder: (context, index) =>
_buildUsuarioTile(context, usuarios[index]),
),
),
],
),
const SizedBox(height: 12),
// Host (yo)
_buildJugadorTile(
nombre: nearby.miNombre ?? 'Host',
esHost: true,
),
// Jugadores conectados
Expanded(
child: jugadores.isEmpty
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'📱',
style: TextStyle(fontSize: 48),
),
const SizedBox(height: 12),
Text(
l10n.waitingForPlayers,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
)
: ListView.builder(
itemCount: jugadores.length,
itemBuilder: (context, index) {
final j = jugadores[index];
return _buildJugadorTile(nombre: j.nombre);
},
),
),
],
),
),
),
// Botón iniciar
if (totalJugadores < 3)
Text(
l10n.needMorePlayers(3 - totalJugadores),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: TemaApp.colorNaranja),
),
const SizedBox(height: 12),
if (_perfilSeleccionado == null)
if (!puedeIniciar)
Text(
l10n.selectProfile,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: TemaApp.colorNaranja),
_mensajeValidacion(validacionInicio?.codigo),
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: TemaApp.colorNaranja),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed:
totalJugadores >= 3 &&
_perfilSeleccionado != null &&
!_iniciando
onPressed: puedeIniciar && !_iniciando
? () {
setState(() => _iniciando = true);
widget.onIniciar();
@@ -235,50 +131,141 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
),
),
],
),
),
),
);
}
Widget _buildJugadorTile({required String nombre, bool esHost = false}) {
Widget _buildResumenSala(
BuildContext context,
int seleccionados,
int clientesRemotos,
) {
return Row(
children: [
Expanded(
child: _buildStat(
context,
icon: Icons.groups,
label: 'Jugadores seleccionados',
value: '$seleccionados',
ok: seleccionados >= 3,
),
),
const SizedBox(width: 8),
Expanded(
child: _buildStat(
context,
icon: Icons.devices,
label: 'Móviles conectados',
value: '${clientesRemotos + 1}',
ok: true,
),
),
],
);
}
Widget _buildStat(
BuildContext context, {
required IconData icon,
required String label,
required String value,
required bool ok,
}) {
final color = ok ? TemaApp.colorVerde : TemaApp.colorNaranja;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: TemaApp.colorTarjeta,
color: color.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
border: esHost
? Border.all(color: TemaApp.colorAcento.withValues(alpha: 0.5))
: null,
border: Border.all(color: color.withValues(alpha: 0.55)),
),
child: Row(
children: [
Text(esHost ? '👑' : '🎭', style: const TextStyle(fontSize: 20)),
const SizedBox(width: 12),
Icon(icon, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(nombre, style: Theme.of(context).textTheme.titleMedium),
child: Text(label, style: Theme.of(context).textTheme.bodySmall),
),
if (esHost)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: TemaApp.colorAcento.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'HOST',
style: TextStyle(
color: TemaApp.colorAcento,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
Text(
value,
style: TextStyle(color: color, fontWeight: FontWeight.bold),
),
],
),
);
}
Widget _buildUsuarioTile(BuildContext context, Usuario usuario) {
final nearby = context.read<ServicioNearby>();
final miClientId = nearby.miClientId;
final seleccionadoPorMi = usuario.clienteIdSeleccionado == miClientId;
final seleccionadoPorOtro =
usuario.estaSeleccionado && usuario.clienteIdSeleccionado != miClientId;
return ListTile(
leading: CircleAvatar(
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(
seleccionadoPorMi
? 'Seleccionado por este móvil'
: seleccionadoPorOtro
? 'Seleccionado por otro cliente'
: 'Disponible',
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (seleccionadoPorMi)
IconButton(
tooltip: 'Liberar',
icon: const Icon(Icons.close),
onPressed: () => nearby.liberarUsuarioSala(usuario.id),
)
else if (!seleccionadoPorOtro)
IconButton(
tooltip: 'Seleccionar',
icon: const Icon(Icons.check_circle_outline),
onPressed: () => nearby.seleccionarUsuarioSala(usuario.id),
),
if (!usuario.estaSeleccionado)
IconButton(
tooltip: 'Eliminar',
icon: const Icon(Icons.delete_outline, color: TemaApp.colorAcento),
onPressed: () => nearby.eliminarUsuarioSala(usuario.id),
),
],
),
);
}
String _mensajeValidacion(String? codigo) {
switch (codigo) {
case 'faltan_jugadores':
return 'Seleccioná al menos 3 usuarios para iniciar.';
case 'host_sin_usuario':
return 'El móvil servidor debe seleccionar al menos un usuario.';
case 'sala_cerrada':
return 'La sala ya no está en lobby.';
default:
return 'Completá la selección de usuarios para iniciar.';
}
}
Future<void> _crearNuevoUsuario(BuildContext context) async {
final l10n = AppLocalizations.of(context)!;
final controller = TextEditingController();
@@ -312,12 +299,13 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
);
if (nombre != null && nombre.trim().isNotEmpty) {
final nuevoUsuario = Usuario(
id: DateTime.now().millisecondsSinceEpoch.toString(),
nombre: nombre.trim(),
final perfil = context.read<ServicioPerfilUsuario>().perfil;
await nearby.crearUsuarioSala(
nombre.trim(),
seleccionar: true,
nick: perfil.nick,
avatar: perfil.avatarAsset,
);
nearby.agregarUsuario(nuevoUsuario);
setState(() => _perfilSeleccionado = nombre.trim());
}
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:farolero/tema/componentes_farolero.dart';
import 'package:farolero/tema/tema_app.dart';
/// Pantalla que ve cada jugador cuando recibe su palabra (modo multidispositivo).
@@ -44,8 +45,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 +60,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 +88,17 @@ 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,
),
),
_palabraVisible
? TarjetaPalabraFarolero(palabra: widget.palabra)
: const Text(
'???',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: TemaApp.colorTextoSecundario,
),
),
],
),
),
@@ -164,6 +169,7 @@ class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
),
),
),
),
);
}
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
import 'package:farolero/tema/componentes_farolero.dart';
import 'package:farolero/tema/tema_app.dart';
/// Reveal secuencial para clientes que manejan uno o varios jugadores.
class PantallaPalabrasCliente extends StatefulWidget {
final List<JugadorInicioPartida> jugadores;
final String? pistaCategoria;
final VoidCallback onTodosVistos;
const PantallaPalabrasCliente({
super.key,
required this.jugadores,
this.pistaCategoria,
required this.onTodosVistos,
});
@override
State<PantallaPalabrasCliente> createState() => _PantallaPalabrasClienteState();
}
class _PantallaPalabrasClienteState extends State<PantallaPalabrasCliente> {
int _indice = 0;
bool _visible = false;
JugadorInicioPartida get _actual => widget.jugadores[_indice];
bool get _esUltimo => _indice == widget.jugadores.length - 1;
void _continuar() {
if (_esUltimo) {
widget.onTodosVistos();
return;
}
setState(() {
_indice++;
_visible = false;
});
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final actual = _actual;
return Scaffold(
body: FondoFarolero(
intenso: true,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Text(
'Jugador ${_indice + 1} de ${widget.jugadores.length}',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
Text(
actual.nombre,
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
GestureDetector(
onTap: () => setState(() => _visible = !_visible),
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
width: double.infinity,
padding: const EdgeInsets.symmetric(
vertical: 48,
horizontal: 24,
),
decoration: BoxDecoration(
color: _visible ? TemaApp.colorSuperficie : TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _visible ? TemaApp.colorNaranja : TemaApp.colorBorde,
),
),
child: Column(
children: [
Icon(
_visible ? Icons.visibility : Icons.visibility_off,
color: _visible ? Colors.white : TemaApp.colorTextoSecundario,
size: 32,
),
const SizedBox(height: 16),
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,
),
),
],
),
),
),
if (_visible && actual.esImpostor && widget.pistaCategoria != null) ...[
const SizedBox(height: 12),
Text(
l10n.clueIs(widget.pistaCategoria!),
style: const TextStyle(color: TemaApp.colorNaranja),
textAlign: TextAlign.center,
),
],
const Spacer(),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _continuar,
icon: Icon(_esUltimo ? Icons.check : Icons.arrow_forward),
label: Text(_esUltimo ? l10n.iveSeenIt : 'Siguiente jugador'),
),
),
],
),
),
),
),
);
}
}

View File

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

View File

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

View File

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

View File

@@ -3,11 +3,15 @@ import 'package:mobile_scanner/mobile_scanner.dart';
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/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';
@@ -36,12 +40,17 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
bool _esImpostor = false;
String? _pistaCategoria;
final List<Jugador> _jugadores = [];
final List<JugadorInicioPartida> _jugadoresControlados = [];
@override
void initState() {
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();
});
}
@@ -51,13 +60,38 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
nearby.onMensaje((endpointId, mensaje) {
if (mensaje.tipo == TipoMensaje.partidaInicio) {
// El host ha iniciado la partida — nos ha enviado nuestra palabra
final jugadoresData = mensaje.datos['jugadores'] as List<dynamic>?;
final jugadoresTodosData =
mensaje.datos['jugadoresTodos'] as List<dynamic>?;
setState(() {
_palabraRecibida = mensaje.datos['palabra'] as String?;
_esImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
_jugadoresControlados
..clear()
..addAll(
(jugadoresData ?? []).map(
(json) => JugadorInicioPartida.fromJson(
json as Map<String, dynamic>,
),
),
);
_jugadores
..clear()
..addAll(
(jugadoresTodosData ?? []).map(
(json) => Jugador.fromJson(json as Map<String, dynamic>),
),
);
if (_jugadoresControlados.isNotEmpty) {
final primero = _jugadoresControlados.first;
_palabraRecibida = primero.palabra;
_esImpostor = primero.esImpostor;
} else {
_palabraRecibida = mensaje.datos['palabra'] as String?;
_esImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
}
_pistaCategoria = mensaje.datos['categoria'] as String?;
});
// Navegar a pantalla de palabra del cliente
if (mounted && _palabraRecibida != null) {
if (mounted && (_jugadoresControlados.isNotEmpty || _palabraRecibida != null)) {
_navegarAPalabra();
}
} else if (mensaje.tipo == TipoMensaje.fase) {
@@ -71,6 +105,28 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
}
void _navegarAPalabra() {
if (_jugadoresControlados.isNotEmpty) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PantallaPalabrasCliente(
jugadores: List.unmodifiable(_jugadoresControlados),
pistaCategoria: _pistaCategoria,
onTodosVistos: () {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(tipo: TipoMensaje.listo, datos: {}),
);
}
Navigator.of(context).pop();
},
),
),
);
return;
}
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PantallaPalabraCliente(
@@ -96,10 +152,14 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
void _navegarSegunFase(String fase) {
switch (fase) {
case 'debate':
final datosFase = context.read<ServicioNearby>().datosPartida;
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => PantallaDebateCliente(
tiempoDebateSegundos: null,
tiempoDebateSegundos:
datosFase?['tiempoDebateSegundos'] as int?,
primerTurnoNombre:
datosFase?['primerTurnoNombre'] as String?,
onSolicitarVotacion: () {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {
@@ -121,18 +181,24 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
MaterialPageRoute(
builder: (_) => PantallaVotacionCliente(
jugadores: _jugadores,
onVoto: (votoporId) {
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
onVotos: (votos) {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(
tipo: TipoMensaje.voto,
datos: {'votoporId': votoporId},
),
);
for (final entry in votos.entries) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(
tipo: TipoMensaje.voto,
datos: {
'votanteId': entry.key,
'votadoId': entry.value,
'votoporId': entry.value,
},
),
);
}
}
Navigator.of(context).pop();
},
),
),
@@ -266,14 +332,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,
@@ -316,6 +387,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
),
),
),
),
);
}
@@ -342,9 +414,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) ...[
@@ -438,6 +511,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
],
),
),
),
);
}
@@ -553,9 +627,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
@@ -605,19 +680,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
),
const Divider(),
// Usuarios existentes
...usuarios.map(
(usuario) => ListTile(
leading: Text(
usuario.avatar ?? '👤',
style: const TextStyle(fontSize: 24),
),
title: Text(usuario.nombre),
onTap: () {
// Seleccionar usuario - enviar al host
_enviarUsuarioAlHost(usuario);
},
),
),
...usuarios.map(_buildUsuarioSalaTile),
],
),
),
@@ -632,6 +695,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
),
],
],
),
),
),
);
@@ -642,6 +706,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
final l10n = AppLocalizations.of(context)!;
final controller = TextEditingController();
final nearby = context.read<ServicioNearby>();
controller.text = context.read<ServicioPerfilUsuario>().perfil.nombre;
final nombre = await showDialog<String>(
context: context,
@@ -671,34 +736,63 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
);
if (nombre != null && nombre.trim().isNotEmpty) {
final nuevoUsuario = Usuario(
id: DateTime.now().millisecondsSinceEpoch.toString(),
nombre: nombre.trim(),
final perfil = context.read<ServicioPerfilUsuario>().perfil;
await nearby.crearUsuarioSala(
nombre.trim(),
seleccionar: true,
nick: perfil.nick,
avatar: perfil.avatarAsset,
);
// Agregar localmente
nearby.agregarUsuario(nuevoUsuario);
// Enviar al host
_enviarUsuarioAlHost(nuevoUsuario);
}
}
/// Envía el usuario seleccionado/creado al host
/// Env?a el usuario seleccionado/creado al host
void _enviarUsuarioAlHost(Usuario usuario) {
final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) {
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(
tipo: TipoMensaje.usuarioNuevo,
datos: {'usuario': usuario.toJson()},
),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('${usuario.nombre} seleccionado')));
}
nearby.seleccionarUsuarioSala(usuario.id);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('${usuario.nombre} seleccionado')));
}
Widget _buildUsuarioSalaTile(Usuario usuario) {
final nearby = context.read<ServicioNearby>();
final miClientId = nearby.miClientId;
final seleccionadoPorMi = usuario.clienteIdSeleccionado == miClientId;
final seleccionadoPorOtro =
usuario.estaSeleccionado && usuario.clienteIdSeleccionado != miClientId;
return ListTile(
leading: Text(
usuario.avatar ?? '??',
style: const TextStyle(fontSize: 24),
),
title: Text(usuario.nombre),
subtitle: Text(
seleccionadoPorMi
? 'Seleccionado por este m?vil'
: seleccionadoPorOtro
? 'No disponible'
: 'Disponible',
),
trailing: seleccionadoPorMi
? IconButton(
icon: const Icon(Icons.close),
onPressed: () => nearby.liberarUsuarioSala(usuario.id),
)
: null,
enabled: !seleccionadoPorOtro,
onTap: seleccionadoPorOtro
? null
: () {
if (seleccionadoPorMi) {
nearby.liberarUsuarioSala(usuario.id);
} else {
_enviarUsuarioAlHost(usuario);
}
},
);
}
// ==================== HELPERS ====================
Widget _buildError(String msg) {

View File

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

View File

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

View File

@@ -1,18 +1,24 @@
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_nearby.dart';
import 'package:farolero/tema/tema_app.dart';
import 'package:provider/provider.dart';
/// Pantalla de votación para el cliente (multidispositivo).
/// El cliente recibe fase=votacion y ve esta pantalla para elegir a quién votar.
/// Pantalla de votación para cliente multidispositivo.
/// Un cliente puede manejar uno o varios jugadores, por eso se recoge un voto
/// por cada jugador controlado activo.
class PantallaVotacionCliente extends StatefulWidget {
final List<Jugador> jugadores;
final Function(String votoporId) onVoto;
final List<JugadorInicioPartida> jugadoresControlados;
final Function(Map<String, String> votos) onVotos;
const PantallaVotacionCliente({
super.key,
required this.jugadores,
required this.onVoto,
this.jugadoresControlados = const [],
required this.onVotos,
});
@override
@@ -20,11 +26,47 @@ class PantallaVotacionCliente extends StatefulWidget {
}
class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
String? _votoSeleccionado;
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;
bool get _votacionCompleta {
if (_votantes.isEmpty) return _votosPorVotante.containsKey('_legacy');
return _votantes.every((votante) => _votosPorVotante[votante.jugadorId] != null);
}
@override
void initState() {
super.initState();
_listener = (endpointId, mensaje) {
if (mensaje.tipo != TipoMensaje.votacionResultado || !mounted) return;
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,
@@ -45,56 +87,31 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
),
const SizedBox(height: 8),
Text(
l10n.selectOnePlayer,
_modoMultiVotante
? 'Emití un voto por cada jugador que manejás.'
: l10n.selectOnePlayer,
style: TextStyle(color: TemaApp.colorTextoSecundario),
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: widget.jugadores.length,
itemBuilder: (context, index) {
final jugador = widget.jugadores[index];
final selected = _votoSeleccionado == jugador.id;
return Card(
color: selected
? TemaApp.colorAcento.withValues(alpha: 0.3)
: TemaApp.colorTarjeta,
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: selected
? TemaApp.colorAcento
: TemaApp.colorAcento.withValues(alpha: 0.3),
child: Text(
'${index + 1}',
style: TextStyle(
color: selected
? Colors.white
: TemaApp.colorTexto,
),
),
),
title: Text(jugador.nombre),
trailing: selected
? const Icon(Icons.check_circle,
color: TemaApp.colorAcento)
: null,
onTap: () {
setState(() => _votoSeleccionado = jugador.id);
child: _votantes.isEmpty
? _buildSelectorLegacy()
: ListView.builder(
itemCount: _votantes.length,
itemBuilder: (context, index) {
final votante = _votantes[index];
return _buildSelectorParaVotante(context, votante);
},
),
);
},
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: _votoSeleccionado == null
? null
: () => widget.onVoto(_votoSeleccionado!),
onPressed: _votacionCompleta
? () => widget.onVotos(Map.unmodifiable(_votosPorVotante))
: null,
icon: const Icon(Icons.how_to_vote),
label: Text(l10n.votar),
style: ElevatedButton.styleFrom(
@@ -109,4 +126,218 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
),
);
}
Widget _buildResultado(BuildContext context, Map<String, dynamic> resultado) {
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,
),
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,
itemBuilder: (context, index) {
final jugador = widget.jugadores[index];
final selected = _votosPorVotante['_legacy'] == jugador.id;
return _buildJugadorVotable(
jugador: jugador,
index: index,
selected: selected,
onTap: () => setState(() => _votosPorVotante['_legacy'] = jugador.id),
);
},
);
}
Widget _buildSelectorParaVotante(
BuildContext context,
JugadorInicioPartida votante,
) {
return Card(
color: TemaApp.colorSuperficie,
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Voto de ${votante.nombre}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
...widget.jugadores.asMap().entries.map((entry) {
final jugador = entry.value;
final selected = _votosPorVotante[votante.jugadorId] == jugador.id;
return _buildJugadorVotable(
jugador: jugador,
index: entry.key,
selected: selected,
onTap: () => setState(
() => _votosPorVotante[votante.jugadorId] = jugador.id,
),
);
}),
],
),
),
);
}
Widget _buildJugadorVotable({
required Jugador jugador,
required int index,
required bool selected,
required VoidCallback onTap,
}) {
return Card(
color: selected
? TemaApp.colorAcento.withValues(alpha: 0.3)
: TemaApp.colorTarjeta,
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: CircleAvatar(
backgroundColor: selected
? TemaApp.colorAcento
: TemaApp.colorAcento.withValues(alpha: 0.3),
child: Text(
'${index + 1}',
style: TextStyle(
color: selected ? Colors.white : TemaApp.colorTexto,
),
),
),
title: Text(jugador.nombre),
trailing: selected
? const Icon(Icons.check_circle, color: TemaApp.colorAcento)
: null,
onTap: onTap,
),
);
}
}

View File

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

View File

@@ -1,9 +1,11 @@
import 'dart:convert';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:nearby_connections/nearby_connections.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/sala_multijugador.dart';
import '../modelos/usuario.dart';
/// Tipos de mensajes en el protocolo P2P
/// Tipos de mensajes en el protocolo P2P.
enum TipoMensaje {
salaInfo,
partidaInicio,
@@ -15,12 +17,20 @@ enum TipoMensaje {
listo,
ping,
jugadorDesconectado,
clienteRegistrado,
estadoSala,
crearUsuario,
seleccionarUsuario,
liberarUsuario,
eliminarUsuario,
errorOperacion,
usuarioNuevo,
// Compatibilidad con versiones previas del protocolo.
usuarioEliminado,
usuariosActualizados,
}
/// Mensaje del protocolo P2P entre dispositivos
/// Mensaje del protocolo P2P entre dispositivos.
class MensajeP2P {
final TipoMensaje tipo;
final Map<String, dynamic> datos;
@@ -40,7 +50,8 @@ class MensajeP2P {
Uint8List toBytes() => Uint8List.fromList(utf8.encode(toJson()));
}
/// Info de un jugador conectado
/// Info de un dispositivo conectado. El nombre identifica al cliente/dispositivo,
/// no necesariamente a un jugador de la partida.
class JugadorConectado {
final String endpointId;
final String nombre;
@@ -53,13 +64,13 @@ class JugadorConectado {
});
}
/// Callback para mensajes recibidos
typedef OnMensajeCallback =
void Function(String endpointId, MensajeP2P mensaje);
/// Servicio para conexiones P2P usando Google Nearby Connections API.
class ServicioNearby extends ChangeNotifier {
static const _serviceId = 'es.freetimelab.farolero';
static const _hostClientId = 'host';
bool _esHost = false;
bool _conectado = false;
@@ -67,23 +78,21 @@ class ServicioNearby extends ChangeNotifier {
bool _anunciando = false;
String? _miEndpointId;
String? _hostEndpointId;
String? _roomId;
String? _miClientId;
String? _nombreSala;
String? _miNombre;
final Map<String, JugadorConectado> _jugadores = {};
final List<OnMensajeCallback> _listeners = [];
final Map<String, String> _hostsEncontrados = {};
final Map<String, Usuario> _usuariosPool = {};
// Hosts descubiertos (para discovery automático)
final Map<String, String> _hostsEncontrados = {}; // endpointId -> nombre
// Estado para clientes
String? _palabraRecibida;
bool? _soyImpostor;
String? _faseActual;
Map<String, dynamic>? _datosPartida;
// Pool de usuarios para modo multi-dispositivo
final Map<String, Usuario> _usuariosPool = {};
EstadoSalaMultijugador? _estadoSala;
bool get esHost => _esHost;
bool get conectado => _conectado;
@@ -91,27 +100,35 @@ class ServicioNearby extends ChangeNotifier {
bool get anunciando => _anunciando;
String? get miEndpointId => _miEndpointId;
String? get hostEndpointId => _hostEndpointId;
String? get roomId => _roomId;
String? get miClientId => _miClientId;
String? get nombreSala => _nombreSala;
String? get miNombre => _miNombre;
String? get palabraRecibida => _palabraRecibida;
bool? get soyImpostor => _soyImpostor;
String? get faseActual => _faseActual;
Map<String, dynamic>? get datosPartida => _datosPartida;
EstadoSalaMultijugador? get estadoSala => _estadoSala;
List<JugadorConectado> get jugadores => _jugadores.values.toList();
int get numJugadoresConectados => _jugadores.length;
Map<String, String> get hostsEncontrados =>
Map.unmodifiable(_hostsEncontrados);
/// Pool de usuarios disponibles para seleccionar en modo multi-dispositivo
List<Usuario> get usuarios => _usuariosPool.values.toList();
List<Usuario> get usuarios =>
(_estadoSala?.usuarios.values ?? _usuariosPool.values).toList();
List<Usuario> get misUsuariosSeleccionados {
final clientId = _miClientId;
final sala = _estadoSala;
if (clientId == null || sala == null) return [];
return sala.usuariosPorCliente(clientId);
}
/// Registra un listener de mensajes
void onMensaje(OnMensajeCallback callback) {
_listeners.add(callback);
}
/// Elimina un listener
void removeMensajeListener(OnMensajeCallback callback) {
_listeners.remove(callback);
}
@@ -122,26 +139,24 @@ class ServicioNearby extends ChangeNotifier {
}
}
// ==================== USER POOL ====================
// ==================== USER POOL / SALA ====================
/// Agrega un usuario al pool de usuarios
void agregarUsuario(Usuario usuario) {
_usuariosPool[usuario.id] = usuario;
_estadoSala?.usuarios[usuario.id] = usuario;
notifyListeners();
}
/// Elimina un usuario del pool
void eliminarUsuario(String usuarioId) {
_usuariosPool.remove(usuarioId);
_estadoSala?.usuarios.remove(usuarioId);
notifyListeners();
}
/// Obtiene un usuario por su ID
Usuario? getUsuario(String usuarioId) {
return _usuariosPool[usuarioId];
return _estadoSala?.usuarios[usuarioId] ?? _usuariosPool[usuarioId];
}
/// Sincroniza el pool de usuarios con una lista
void sincronizarUsuarios(List<Usuario> usuarios) {
_usuariosPool.clear();
for (final usuario in usuarios) {
@@ -150,12 +165,38 @@ class ServicioNearby extends ChangeNotifier {
notifyListeners();
}
/// Obtiene el jugador local del host (él mismo como participante)
/// Retorna un JugadorConectado con endpointId null porque es local
void _sincronizarSala(EstadoSalaMultijugador sala) {
_estadoSala = sala;
_roomId = sala.roomId;
_usuariosPool
..clear()
..addEntries(sala.usuarios.entries);
}
void _sincronizarPoolDesdeSala() {
final sala = _estadoSala;
if (sala == null) return;
_usuariosPool
..clear()
..addEntries(sala.usuarios.entries);
}
Future<void> _broadcastEstadoSala() async {
final sala = _estadoSala;
if (sala == null) return;
_sincronizarPoolDesdeSala();
if (_esHost) {
await enviarATodos(
MensajeP2P(tipo: TipoMensaje.estadoSala, datos: sala.toJson()),
);
}
notifyListeners();
}
JugadorConectado? getJugadorLocal() {
if (_miNombre == null) return null;
return JugadorConectado(
endpointId: _miEndpointId ?? '', // vacío indica que es el host local
endpointId: _miEndpointId ?? '',
nombre: _miNombre!,
listo: true,
);
@@ -163,10 +204,37 @@ class ServicioNearby extends ChangeNotifier {
// ==================== HOST ====================
/// Inicia como host (anunciando el endpoint)
Future<bool> iniciarHost(String nombreSala, String miNombre) async {
Future<bool> iniciarHost(
String nombreSala,
String miNombre, {
String? miNick,
String? miAvatar,
}) async {
_nombreSala = nombreSala;
_miNombre = miNombre;
_roomId = DateTime.now().microsecondsSinceEpoch.toString();
_miClientId = _hostClientId;
_estadoSala = EstadoSalaMultijugador.crear(
roomId: _roomId!,
nombreSala: nombreSala,
hostClientId: _hostClientId,
hostNombre: miNombre,
);
// Compatibilidad con el flujo actual: el nombre con el que se crea la sala
// arranca como usuario seleccionado por el host. Luego puede crear/seleccionar
// más usuarios en el lobby.
final usuarioHost = Usuario(
id: 'u-${_roomId!}-host',
nombre: miNombre,
nick: miNick,
avatar: miAvatar,
foto: miAvatar,
creadoPorClienteId: _hostClientId,
clienteIdSeleccionado: _hostClientId,
);
_estadoSala!.crearUsuario(usuarioHost);
_sincronizarPoolDesdeSala();
try {
final resultado = await Nearby().startAdvertising(
@@ -194,7 +262,6 @@ class ServicioNearby extends ChangeNotifier {
// ==================== CLIENTE ====================
/// Busca hosts disponibles
Future<bool> buscarHosts(String miNombre) async {
_miNombre = miNombre;
@@ -219,7 +286,6 @@ class ServicioNearby extends ChangeNotifier {
}
}
/// Conecta a un host específico
Future<bool> conectarAHost(String endpointId, String miNombre) async {
try {
await Nearby().requestConnection(
@@ -239,8 +305,7 @@ class ServicioNearby extends ChangeNotifier {
// ==================== CALLBACKS NEARBY ====================
void _onConexionIniciada(String endpointId, ConnectionInfo info) {
debugPrint('Conexión iniciada con $endpointId: ${info.endpointName}');
// Auto-aceptar conexiones
debugPrint('Conexion iniciada con $endpointId: ${info.endpointName}');
Nearby().acceptConnection(
endpointId,
onPayLoadRecieved: _onPayloadRecibido,
@@ -249,16 +314,13 @@ class ServicioNearby extends ChangeNotifier {
}
void _onResultadoConexion(String endpointId, Status status) {
debugPrint('Resultado conexión $endpointId: $status');
debugPrint('Resultado conexion $endpointId: $status');
if (status == Status.CONNECTED) {
if (_esHost) {
// Host: esperar mensaje 'unirse' del cliente
debugPrint('Cliente conectado: $endpointId');
} else {
// Cliente: conectado al host
_hostEndpointId = endpointId;
_conectado = true;
// Enviar mensaje de unirse
enviarMensaje(
endpointId,
MensajeP2P(
@@ -269,16 +331,20 @@ class ServicioNearby extends ChangeNotifier {
}
notifyListeners();
} else {
debugPrint('Conexión fallida con $endpointId');
debugPrint('Conexion fallida con $endpointId');
}
}
void _onDesconexion(String endpointId) {
debugPrint('Desconexión: $endpointId');
debugPrint('Desconexion: $endpointId');
if (_esHost) {
final jugador = _jugadores.remove(endpointId);
final cliente = _estadoSala?.clientePorEndpoint(endpointId);
if (cliente != null) {
_estadoSala?.desconectarCliente(cliente.clientId);
_broadcastEstadoSala();
}
if (jugador != null) {
// Notificar a todos que se desconectó
enviarATodos(
MensajeP2P(
tipo: TipoMensaje.jugadorDesconectado,
@@ -287,7 +353,6 @@ class ServicioNearby extends ChangeNotifier {
);
}
} else {
// Cliente perdió conexión con host
_conectado = false;
_hostEndpointId = null;
}
@@ -312,7 +377,6 @@ class ServicioNearby extends ChangeNotifier {
}
}
/// Para el discovery sin desconectar
Future<void> pararBusqueda() async {
try {
await Nearby().stopDiscovery();
@@ -334,9 +398,7 @@ class ServicioNearby extends ChangeNotifier {
}
}
void _onPayloadUpdate(String endpointId, PayloadTransferUpdate update) {
// No necesitamos trackear progreso para bytes pequeños
}
void _onPayloadUpdate(String endpointId, PayloadTransferUpdate update) {}
// ==================== PROCESAMIENTO DE MENSAJES ====================
@@ -355,33 +417,11 @@ class ServicioNearby extends ChangeNotifier {
void _procesarMensajeHost(String endpointId, MensajeP2P mensaje) {
switch (mensaje.tipo) {
case TipoMensaje.unirse:
final nombre = mensaje.datos['nombre'] as String? ?? 'Jugador';
_jugadores[endpointId] = JugadorConectado(
endpointId: endpointId,
nombre: nombre,
);
// Enviar info de sala al nuevo jugador (incluye pool de usuarios)
enviarMensaje(
endpointId,
MensajeP2P(
tipo: TipoMensaje.salaInfo,
datos: {
'sala': _nombreSala,
'jugadores': _jugadores.values
.map((j) => {'nombre': j.nombre, 'endpointId': j.endpointId})
.toList(),
'usuarios': _usuariosPool.values.map((u) => u.toJson()).toList(),
},
),
);
notifyListeners();
_registrarClienteRemoto(endpointId, mensaje);
break;
case TipoMensaje.voto:
// Propagar al flujo de juego
_notificarMensaje(endpointId, mensaje);
break;
case TipoMensaje.listo:
final jugador = _jugadores[endpointId];
if (jugador != null) {
@@ -389,33 +429,69 @@ class ServicioNearby extends ChangeNotifier {
notifyListeners();
}
break;
case TipoMensaje.crearUsuario:
_handleCrearUsuario(endpointId, mensaje);
break;
case TipoMensaje.seleccionarUsuario:
_handleSeleccionarUsuario(endpointId, mensaje);
break;
case TipoMensaje.liberarUsuario:
_handleLiberarUsuario(endpointId, mensaje);
break;
case TipoMensaje.eliminarUsuario:
case TipoMensaje.usuarioEliminado:
_handleEliminarUsuario(endpointId, mensaje);
break;
case TipoMensaje.usuarioNuevo:
_handleUsuarioNuevo(mensaje);
break;
case TipoMensaje.usuariosActualizados:
_handleUsuariosActualizados(mensaje);
break;
default:
break;
}
}
void _registrarClienteRemoto(String endpointId, MensajeP2P mensaje) {
final nombre = mensaje.datos['nombre'] as String? ?? 'Jugador';
final clientId = endpointId;
_jugadores[endpointId] = JugadorConectado(
endpointId: endpointId,
nombre: nombre,
);
_estadoSala?.registrarCliente(
ClienteSala(clientId: clientId, endpointId: endpointId, nombre: nombre),
);
enviarMensaje(
endpointId,
MensajeP2P(
tipo: TipoMensaje.clienteRegistrado,
datos: {
'clientId': clientId,
'sala': _nombreSala,
'roomId': _roomId,
'jugadores': _jugadores.values
.map((j) => {'nombre': j.nombre, 'endpointId': j.endpointId})
.toList(),
'usuarios': _usuariosPool.values.map((u) => u.toJson()).toList(),
if (_estadoSala != null) 'estadoSala': _estadoSala!.toJson(),
},
),
);
_broadcastEstadoSala();
notifyListeners();
}
void _handleUsuarioNuevo(MensajeP2P mensaje) {
final usuarioJson = mensaje.datos['usuario'] as Map<String, dynamic>?;
if (usuarioJson != null) {
final nuevoUsuario = Usuario.fromJson(usuarioJson);
_usuariosPool[nuevoUsuario.id] = nuevoUsuario;
// Propagar a todos los clientes
_estadoSala?.usuarios[nuevoUsuario.id] = nuevoUsuario;
if (_esHost) {
enviarATodos(
MensajeP2P(
tipo: TipoMensaje.usuarioNuevo,
datos: {'usuario': usuarioJson},
),
);
_broadcastEstadoSala();
}
notifyListeners();
}
@@ -433,11 +509,87 @@ class ServicioNearby extends ChangeNotifier {
}
}
String _clientIdParaEndpoint(String endpointId, MensajeP2P mensaje) {
return mensaje.datos['clientId'] as String? ??
_estadoSala?.clientePorEndpoint(endpointId)?.clientId ??
endpointId;
}
Future<void> _enviarErrorOperacion(
String endpointId,
ResultadoOperacionSala resultado,
) async {
if (resultado.exitoso) return;
await enviarMensaje(
endpointId,
MensajeP2P(tipo: TipoMensaje.errorOperacion, datos: resultado.toJson()),
);
}
void _handleCrearUsuario(String endpointId, MensajeP2P mensaje) {
final sala = _estadoSala;
final usuarioJson = mensaje.datos['usuario'] as Map<String, dynamic>?;
if (sala == null || usuarioJson == null) return;
final clientId = _clientIdParaEndpoint(endpointId, mensaje);
final usuario = Usuario.fromJson(usuarioJson).copiar(
creadoPorClienteId: clientId,
liberarSeleccion: true,
);
final resultadoCrear = sala.crearUsuario(usuario);
if (!resultadoCrear.exitoso) {
_enviarErrorOperacion(endpointId, resultadoCrear);
return;
}
if (mensaje.datos['seleccionar'] == true) {
final resultadoSeleccion = sala.seleccionarUsuario(
usuarioId: usuario.id,
clienteId: clientId,
);
_enviarErrorOperacion(endpointId, resultadoSeleccion);
}
_broadcastEstadoSala();
}
void _handleSeleccionarUsuario(String endpointId, MensajeP2P mensaje) {
final sala = _estadoSala;
final usuarioId = mensaje.datos['usuarioId'] as String?;
if (sala == null || usuarioId == null) return;
final resultado = sala.seleccionarUsuario(
usuarioId: usuarioId,
clienteId: _clientIdParaEndpoint(endpointId, mensaje),
);
_enviarErrorOperacion(endpointId, resultado);
_broadcastEstadoSala();
}
void _handleLiberarUsuario(String endpointId, MensajeP2P mensaje) {
final sala = _estadoSala;
final usuarioId = mensaje.datos['usuarioId'] as String?;
if (sala == null || usuarioId == null) return;
final resultado = sala.liberarUsuario(
usuarioId: usuarioId,
solicitanteClientId: _clientIdParaEndpoint(endpointId, mensaje),
);
_enviarErrorOperacion(endpointId, resultado);
_broadcastEstadoSala();
}
void _handleEliminarUsuario(String endpointId, MensajeP2P mensaje) {
final sala = _estadoSala;
final usuarioId = mensaje.datos['usuarioId'] as String?;
if (sala == null || usuarioId == null) return;
final resultado = sala.eliminarUsuario(
usuarioId: usuarioId,
solicitanteClientId: _clientIdParaEndpoint(endpointId, mensaje),
);
_enviarErrorOperacion(endpointId, resultado);
_broadcastEstadoSala();
}
void _procesarMensajeCliente(String endpointId, MensajeP2P mensaje) {
switch (mensaje.tipo) {
case TipoMensaje.salaInfo:
_datosPartida = mensaje.datos;
// Sincronizar pool de usuarios si viene en el mensaje
final usuariosData = mensaje.datos['usuarios'] as List<dynamic>?;
if (usuariosData != null) {
_usuariosPool.clear();
@@ -448,42 +600,54 @@ class ServicioNearby extends ChangeNotifier {
}
notifyListeners();
break;
case TipoMensaje.clienteRegistrado:
_miClientId = mensaje.datos['clientId'] as String?;
_datosPartida = mensaje.datos;
final estadoSalaJson =
mensaje.datos['estadoSala'] as Map<String, dynamic>?;
if (estadoSalaJson != null) {
_sincronizarSala(EstadoSalaMultijugador.fromJson(estadoSalaJson));
}
notifyListeners();
break;
case TipoMensaje.estadoSala:
_sincronizarSala(EstadoSalaMultijugador.fromJson(mensaje.datos));
notifyListeners();
break;
case TipoMensaje.partidaInicio:
_palabraRecibida = mensaje.datos['palabra'] as String?;
_soyImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
final jugadoresInicio = mensaje.datos['jugadores'] as List<dynamic>?;
if (jugadoresInicio != null && jugadoresInicio.isNotEmpty) {
final primerJugador = jugadoresInicio.first as Map<String, dynamic>;
_palabraRecibida = primerJugador['palabra'] as String?;
_soyImpostor = primerJugador['esImpostor'] as bool? ?? false;
} else {
_palabraRecibida = mensaje.datos['palabra'] as String?;
_soyImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
}
_datosPartida = mensaje.datos;
notifyListeners();
break;
case TipoMensaje.fase:
_faseActual = mensaje.datos['fase'] as String?;
_datosPartida = mensaje.datos;
notifyListeners();
break;
case TipoMensaje.votacionResultado:
_datosPartida = mensaje.datos;
notifyListeners();
break;
case TipoMensaje.partidaFin:
case TipoMensaje.errorOperacion:
_datosPartida = mensaje.datos;
notifyListeners();
break;
case TipoMensaje.jugadorDesconectado:
notifyListeners();
break;
default:
break;
}
}
// ==================== ENVÍO ====================
// ==================== ENVIO ====================
/// Envía un mensaje a un dispositivo específico
Future<void> enviarMensaje(String endpointId, MensajeP2P mensaje) async {
try {
await Nearby().sendBytesPayload(endpointId, mensaje.toBytes());
@@ -492,20 +656,121 @@ class ServicioNearby extends ChangeNotifier {
}
}
/// Envía un mensaje a todos los dispositivos conectados (solo host)
Future<void> enviarATodos(MensajeP2P mensaje) async {
for (final id in _jugadores.keys) {
await enviarMensaje(id, mensaje);
}
}
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) {
final resultado = _estadoSala!.crearUsuario(usuario);
if (resultado.exitoso && seleccionar) {
_estadoSala!.seleccionarUsuario(
usuarioId: usuario.id,
clienteId: clientId,
);
}
await _broadcastEstadoSala();
return;
}
final hostId = _hostEndpointId;
if (hostId == null) return;
await enviarMensaje(
hostId,
MensajeP2P(
tipo: TipoMensaje.crearUsuario,
datos: {
'clientId': clientId,
'seleccionar': seleccionar,
'usuario': usuario.toJson(),
},
),
);
}
Future<void> seleccionarUsuarioSala(String usuarioId) async {
final clientId = _miClientId;
if (_esHost && _estadoSala != null && clientId != null) {
_estadoSala!.seleccionarUsuario(usuarioId: usuarioId, clienteId: clientId);
await _broadcastEstadoSala();
return;
}
final hostId = _hostEndpointId;
if (hostId == null) return;
await enviarMensaje(
hostId,
MensajeP2P(
tipo: TipoMensaje.seleccionarUsuario,
datos: {'clientId': clientId, 'usuarioId': usuarioId},
),
);
}
Future<void> liberarUsuarioSala(String usuarioId) async {
final clientId = _miClientId;
if (_esHost && _estadoSala != null && clientId != null) {
_estadoSala!.liberarUsuario(
usuarioId: usuarioId,
solicitanteClientId: clientId,
);
await _broadcastEstadoSala();
return;
}
final hostId = _hostEndpointId;
if (hostId == null) return;
await enviarMensaje(
hostId,
MensajeP2P(
tipo: TipoMensaje.liberarUsuario,
datos: {'clientId': clientId, 'usuarioId': usuarioId},
),
);
}
Future<void> eliminarUsuarioSala(String usuarioId) async {
final clientId = _miClientId;
if (_esHost && _estadoSala != null && clientId != null) {
_estadoSala!.eliminarUsuario(
usuarioId: usuarioId,
solicitanteClientId: clientId,
);
await _broadcastEstadoSala();
return;
}
final hostId = _hostEndpointId;
if (hostId == null) return;
await enviarMensaje(
hostId,
MensajeP2P(
tipo: TipoMensaje.eliminarUsuario,
datos: {'clientId': clientId, 'usuarioId': usuarioId},
),
);
}
// ==================== HOST: ACCIONES DE JUEGO ====================
/// Host envía inicio de partida con la palabra de cada jugador
Future<void> enviarInicioPartida({
required String palabraSecreta,
required String categoria,
required Map<String, bool> impostores, // endpointId -> esImpostor
required Map<String, bool> impostores,
}) async {
for (final entry in _jugadores.entries) {
final esImpostor = impostores[entry.key] ?? false;
@@ -517,14 +782,39 @@ class ServicioNearby extends ChangeNotifier {
'palabra': esImpostor ? null : palabraSecreta,
'esImpostor': esImpostor,
'categoria': categoria,
'numJugadores': _jugadores.length + 1, // +1 por el host
'numJugadores': _jugadores.length + 1,
},
),
);
}
}
/// Host envía cambio de fase
Future<void> enviarInicioPartidaMulti({
required List<AsignacionJugador> asignaciones,
required String palabraSecreta,
required String categoria,
required Map<String, bool> impostoresPorJugadorId,
required List<Map<String, dynamic>> jugadoresTodos,
}) async {
final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente(
asignaciones: asignaciones,
palabraSecreta: palabraSecreta,
categoria: categoria,
impostoresPorJugadorId: impostoresPorJugadorId,
);
for (final payload in payloads.values) {
final endpointId = payload.endpointId;
if (endpointId == null) continue;
final datos = payload.toJson();
datos['jugadoresTodos'] = jugadoresTodos;
await enviarMensaje(
endpointId,
MensajeP2P(tipo: TipoMensaje.partidaInicio, datos: datos),
);
}
}
Future<void> enviarCambioFase(
String fase, [
Map<String, dynamic>? extra,
@@ -533,14 +823,12 @@ class ServicioNearby extends ChangeNotifier {
await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos));
}
/// Host envía resultado de votación
Future<void> enviarResultadoVotacion(Map<String, dynamic> resultado) async {
await enviarATodos(
MensajeP2P(tipo: TipoMensaje.votacionResultado, datos: resultado),
);
}
/// Host envía fin de partida
Future<void> enviarFinPartida(Map<String, dynamic> resultado) async {
await enviarATodos(
MensajeP2P(tipo: TipoMensaje.partidaFin, datos: resultado),
@@ -549,7 +837,6 @@ class ServicioNearby extends ChangeNotifier {
// ==================== LIMPIEZA ====================
/// Desconecta y limpia todo
Future<void> desconectar() async {
try {
await Nearby().stopAllEndpoints();
@@ -565,27 +852,30 @@ class ServicioNearby extends ChangeNotifier {
_anunciando = false;
_miEndpointId = null;
_hostEndpointId = null;
_roomId = null;
_miClientId = null;
_nombreSala = null;
_miNombre = null;
_palabraRecibida = null;
_soyImpostor = null;
_faseActual = null;
_datosPartida = null;
_estadoSala = null;
_jugadores.clear();
_hostsEncontrados.clear();
_usuariosPool.clear();
notifyListeners();
}
/// Genera los datos para el código QR de conexión
String generarDatosQR(String nombreSala) {
return json.encode({
'app': 'farolero',
'sala': nombreSala,
'host': _miNombre,
'roomId': _roomId,
});
}
/// Parsea datos de QR escaneado
static Map<String, dynamic>? parsearQR(String datos) {
try {
final mapa = json.decode(datos) as Map<String, dynamic>;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:farolero/estado/estado_juego.dart';
import 'package:farolero/modelos/palabra.dart';
import 'package:farolero/modelos/partida.dart';
void main() {

View File

@@ -1,4 +1,3 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:farolero/estado/estado_juego.dart';

View File

@@ -0,0 +1,77 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
void main() {
group('InicioPartidaMultijugador', () {
test('agrupa varios jugadores controlados por el mismo cliente', () {
final asignaciones = [
const AsignacionJugador(
jugadorId: 'ana',
nombre: 'Ana',
clientId: 'host',
endpointId: null,
),
const AsignacionJugador(
jugadorId: 'juan',
nombre: 'Juan',
clientId: 'host',
endpointId: null,
),
const AsignacionJugador(
jugadorId: 'sofia',
nombre: 'Sofía',
clientId: 'cliente-sofia',
endpointId: 'endpoint-2',
),
];
final impostores = {'juan': true};
final payloads = InicioPartidaMultijugador.crearPayloadsPorCliente(
asignaciones: asignaciones,
palabraSecreta: 'mate',
categoria: 'objetos',
impostoresPorJugadorId: impostores,
);
expect(payloads['host']?.jugadores.map((j) => j.jugadorId), [
'ana',
'juan',
]);
expect(payloads['host']?.jugadores.first.palabra, 'mate');
expect(payloads['host']?.jugadores.last.esImpostor, isTrue);
expect(payloads['host']?.jugadores.last.palabra, isNull);
expect(payloads['cliente-sofia']?.endpointId, 'endpoint-2');
expect(payloads['cliente-sofia']?.jugadores.single.nombre, 'Sofía');
});
test('restaura payload completo desde json', () {
final payload = InicioPartidaCliente(
clientId: 'cliente-sofia',
endpointId: 'endpoint-2',
categoria: 'todas',
jugadores: const [
JugadorInicioPartida(
jugadorId: 'sofia',
nombre: 'Sofía',
esImpostor: false,
palabra: 'luna',
),
JugadorInicioPartida(
jugadorId: 'helena',
nombre: 'Helena',
esImpostor: true,
palabra: null,
),
],
);
final restaurado = InicioPartidaCliente.fromJson(payload.toJson());
expect(restaurado.clientId, 'cliente-sofia');
expect(restaurado.endpointId, 'endpoint-2');
expect(restaurado.jugadores.length, 2);
expect(restaurado.jugadores.first.palabra, 'luna');
expect(restaurado.jugadores.last.esImpostor, isTrue);
});
});
}

View File

@@ -0,0 +1,156 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:farolero/modelos/sala_multijugador.dart';
import 'package:farolero/modelos/usuario.dart';
void main() {
group('EstadoSalaMultijugador', () {
late EstadoSalaMultijugador sala;
setUp(() {
sala = EstadoSalaMultijugador.crear(
roomId: 'room-1',
nombreSala: 'Ana - Farolero',
hostClientId: 'host',
hostNombre: 'Ana',
);
});
test('registra al host como cliente conectado de la sala', () {
expect(sala.hostClientId, 'host');
expect(sala.clientes['host']?.esHost, isTrue);
expect(sala.clientes['host']?.conectado, isTrue);
});
test('permite seleccionar varios usuarios para un mismo cliente', () {
sala.crearUsuario(Usuario(id: 'ana', nombre: 'Ana'));
sala.crearUsuario(Usuario(id: 'juan', nombre: 'Juan'));
final seleccionAna = sala.seleccionarUsuario(
usuarioId: 'ana',
clienteId: 'host',
);
final seleccionJuan = sala.seleccionarUsuario(
usuarioId: 'juan',
clienteId: 'host',
);
expect(seleccionAna.exitoso, isTrue);
expect(seleccionJuan.exitoso, isTrue);
expect(sala.usuariosPorCliente('host').map((u) => u.nombre), [
'Ana',
'Juan',
]);
});
test('impide que dos clientes seleccionen el mismo usuario', () {
sala.registrarCliente(
const ClienteSala(
clientId: 'cliente-jorge',
endpointId: 'endpoint-1',
nombre: 'Jorge',
),
);
sala.crearUsuario(Usuario(id: 'sofia', nombre: 'Sofía'));
final primeraSeleccion = sala.seleccionarUsuario(
usuarioId: 'sofia',
clienteId: 'host',
);
final segundaSeleccion = sala.seleccionarUsuario(
usuarioId: 'sofia',
clienteId: 'cliente-jorge',
);
expect(primeraSeleccion.exitoso, isTrue);
expect(segundaSeleccion.exitoso, isFalse);
expect(segundaSeleccion.codigo, 'usuario_ya_seleccionado');
expect(sala.usuarios['sofia']?.clienteIdSeleccionado, 'host');
});
test('solo permite eliminar usuarios no seleccionados y por el host', () {
sala.registrarCliente(
const ClienteSala(
clientId: 'cliente-jorge',
endpointId: 'endpoint-1',
nombre: 'Jorge',
),
);
sala.crearUsuario(Usuario(id: 'ana', nombre: 'Ana'));
sala.crearUsuario(Usuario(id: 'javier', nombre: 'Javier'));
sala.seleccionarUsuario(usuarioId: 'ana', clienteId: 'host');
final clienteElimina = sala.eliminarUsuario(
usuarioId: 'javier',
solicitanteClientId: 'cliente-jorge',
);
final hostEliminaSeleccionado = sala.eliminarUsuario(
usuarioId: 'ana',
solicitanteClientId: 'host',
);
final hostEliminaLibre = sala.eliminarUsuario(
usuarioId: 'javier',
solicitanteClientId: 'host',
);
expect(clienteElimina.exitoso, isFalse);
expect(clienteElimina.codigo, 'solo_host');
expect(hostEliminaSeleccionado.exitoso, isFalse);
expect(hostEliminaSeleccionado.codigo, 'usuario_seleccionado');
expect(hostEliminaLibre.exitoso, isTrue);
expect(sala.usuarios.containsKey('javier'), isFalse);
});
test('valida inicio con minimo tres usuarios y host seleccionado', () {
sala
..crearUsuario(Usuario(id: 'ana', nombre: 'Ana'))
..crearUsuario(Usuario(id: 'juan', nombre: 'Juan'))
..crearUsuario(Usuario(id: 'sofia', nombre: 'Sofía'));
expect(sala.validarInicio().exitoso, isFalse);
expect(sala.validarInicio().codigo, 'faltan_jugadores');
sala
..seleccionarUsuario(usuarioId: 'ana', clienteId: 'host')
..seleccionarUsuario(usuarioId: 'juan', clienteId: 'host')
..seleccionarUsuario(usuarioId: 'sofia', clienteId: 'host');
expect(sala.validarInicio().exitoso, isTrue);
expect(sala.iniciarPartida().exitoso, isTrue);
expect(sala.fase, FaseSalaMultijugador.enPartida);
});
test('libera usuarios de un cliente desconectado durante lobby', () {
sala.registrarCliente(
const ClienteSala(
clientId: 'cliente-sofia',
endpointId: 'endpoint-2',
nombre: 'Sofía',
),
);
sala
..crearUsuario(Usuario(id: 'sofia', nombre: 'Sofía'))
..crearUsuario(Usuario(id: 'helena', nombre: 'Helena'))
..seleccionarUsuario(usuarioId: 'sofia', clienteId: 'cliente-sofia')
..seleccionarUsuario(usuarioId: 'helena', clienteId: 'cliente-sofia');
sala.desconectarCliente('cliente-sofia');
expect(sala.clientes['cliente-sofia']?.conectado, isFalse);
expect(sala.usuarios['sofia']?.estaDisponible, isTrue);
expect(sala.usuarios['helena']?.estaDisponible, isTrue);
});
test('serializa y restaura clientes y usuarios seleccionados', () {
sala
..crearUsuario(Usuario(id: 'ana', nombre: 'Ana'))
..seleccionarUsuario(usuarioId: 'ana', clienteId: 'host');
final restaurada = EstadoSalaMultijugador.fromJson(sala.toJson());
expect(restaurada.roomId, 'room-1');
expect(restaurada.clientes['host']?.esHost, isTrue);
expect(restaurada.usuarios['ana']?.clienteIdSeleccionado, 'host');
expect(restaurada.usuariosSeleccionados.single.nombre, 'Ana');
});
});
}

View File

@@ -1,4 +1,3 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:farolero/modelos/usuario.dart';
import 'package:farolero/servicios/servicio_nearby.dart';

View File

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

View File

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