Compare commits

...

21 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
96 changed files with 23133 additions and 777 deletions

View File

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

2
.gitignore vendored
View File

@@ -48,3 +48,5 @@ build/
.flutter-plugins .flutter-plugins
.flutter-plugins-dependencies .flutter-plugins-dependencies
.packages .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/jugador.dart';
import '../modelos/partida.dart'; import '../modelos/partida.dart';
import '../modelos/palabra.dart'; import '../modelos/palabra.dart';
import '../modelos/sala_multijugador.dart';
import '../servicios/servicio_notas.dart'; import '../servicios/servicio_notas.dart';
/// Estado global del juego gestionado con Provider /// Estado global del juego gestionado con Provider
@@ -89,6 +90,61 @@ class EstadoJuego extends ChangeNotifier {
notifyListeners(); 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 /// Avanza a la fase de debate
void iniciarDebate() { void iniciarDebate() {
if (_partida == null) return; 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:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'estado/estado_juego.dart'; import 'estado/estado_juego.dart';
import 'servicios/servicio_historial_partidas.dart';
import 'servicios/servicio_idioma.dart'; import 'servicios/servicio_idioma.dart';
import 'servicios/servicio_nearby.dart'; import 'servicios/servicio_nearby.dart';
import 'servicios/servicio_perfil_usuario.dart';
import 'tema/componentes_farolero.dart';
import 'tema/tema_app.dart'; import 'tema/tema_app.dart';
import 'pantallas/pantalla_principal.dart'; import 'pantallas/pantalla_principal.dart';
@@ -35,6 +38,12 @@ class FaroleroApp extends StatelessWidget {
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) => ServicioIdioma()..cargar(), create: (_) => ServicioIdioma()..cargar(),
), ),
ChangeNotifierProvider(
create: (_) => ServicioPerfilUsuario()..cargar(),
),
ChangeNotifierProvider(
create: (_) => ServicioHistorialPartidas()..cargar(),
),
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) => ServicioNearby(), create: (_) => ServicioNearby(),
), ),
@@ -71,26 +80,44 @@ class PantallaCarga extends StatelessWidget {
if (estado.cargando || estado.banco == null) { if (estado.cargando || estado.banco == null) {
return Scaffold( return Scaffold(
body: Center( body: FondoFarolero(
intenso: true,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 34, vertical: 28),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text('🎭', style: TextStyle(fontSize: 72)), const Spacer(flex: 2),
const SizedBox(height: 24), const Icon(
Text( Icons.lightbulb,
l10n?.appTitle ?? 'Farolero', color: TemaApp.colorNaranja,
style: Theme.of(context).textTheme.headlineLarge, size: 86,
), ),
const SizedBox(height: 16), const SizedBox(height: 18),
const CircularProgressIndicator(color: TemaApp.colorAcento), const LogoFarolero(size: 58),
const SizedBox(height: 12), 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( Text(
l10n?.loadingWords ?? 'Cargando palabras...', l10n?.loadingWords ?? 'Cargando palabras...',
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: TemaApp.colorDorado,
), ),
textAlign: TextAlign.center,
),
const Spacer(),
], ],
), ),
), ),
),
),
); );
} }

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 'dart:math';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:farolero/l10n/generated/app_localizations.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 { class BancoPalabras {
final Map<String, List<String>> categorias; 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 final Map<String, BancoPalabras> _instancias = {};
static Future<BancoPalabras> cargar({String idioma = 'es'}) async { static Future<BancoPalabras> cargar({String idioma = 'es'}) async {
if (_instancias.containsKey(idioma)) return _instancias[idioma]!; if (_instancias.containsKey(idioma)) return _instancias[idioma]!;
// Intentar cargar el banco del idioma solicitado, fallback a castellano
String jsonStr; String jsonStr;
try { try {
final archivo = idioma == 'es' jsonStr = await rootBundle.loadString(
'assets/words/palabras_$idioma.json',
);
} catch (_) {
try {
final archivoLegacy = idioma == 'es'
? 'assets/palabras.json' ? 'assets/palabras.json'
: 'assets/palabras_$idioma.json'; : 'assets/palabras_$idioma.json';
jsonStr = await rootBundle.loadString(archivo); jsonStr = await rootBundle.loadString(archivoLegacy);
} catch (_) { } catch (_) {
// Fallback a castellano si no existe el banco para ese idioma if (idioma != 'es') return cargar(idioma: 'es');
if (idioma != 'es') {
return cargar(idioma: 'es');
}
rethrow; rethrow;
} }
}
final data = json.decode(jsonStr) as Map<String, dynamic>; final data = json.decode(jsonStr) as Map<String, dynamic>;
final cats = data['categorias'] as Map<String, dynamic>; final cats = data['categorias'] as Map<String, dynamic>;
final mapa = <String, List<String>>{}; final mapa = <String, List<String>>{};
final pistas = <String, String>{};
for (final entrada in cats.entries) { 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]!; return _instancias[idioma]!;
} }
List<String> get nombresCategorias => categorias.keys.toList(); 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) { String palabraAleatoria(String? categoria) {
final rng = Random(); final rng = Random();
if (categoria == null || categoria == 'todas') { if (categoria == null || categoria == 'todas') {
@@ -52,7 +66,7 @@ class BancoPalabras {
return lista[rng.nextInt(lista.length)]; 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) { String? categoriaDepalabra(String palabra) {
for (final entrada in categorias.entries) { for (final entrada in categorias.entries) {
if (entrada.value.contains(palabra)) return entrada.key; if (entrada.value.contains(palabra)) return entrada.key;
@@ -60,7 +74,10 @@ class BancoPalabras {
return null; 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]) { static String nombreBonitoCategoria(String clave, [AppLocalizations? l10n]) {
if (l10n != null) { if (l10n != null) {
final nombres = { final nombres = {
@@ -78,7 +95,6 @@ class BancoPalabras {
}; };
return nombres[clave] ?? clave; return nombres[clave] ?? clave;
} }
// Fallback a castellano si no hay l10n
const nombres = { const nombres = {
'todas': 'Todas', 'todas': 'Todas',
'animales': 'Animales', 'animales': 'Animales',
@@ -95,3 +111,34 @@ class BancoPalabras {
return nombres[clave] ?? clave; 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 { class Usuario {
final String id; final String id;
final String nombre; final String nombre;
final String? nick;
final String? avatar; 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() => { Map<String, dynamic> toJson() => {
'id': id, 'id': id,
'nombre': nombre, 'nombre': nombre,
if (nick != null) 'nick': nick,
if (avatar != null) 'avatar': avatar, 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( factory Usuario.fromJson(Map<String, dynamic> json) => Usuario(
id: json['id'] as String, id: json['id'] as String,
nombre: json['nombre'] as String, nombre: json['nombre'] as String,
nick: json['nick'] as String?,
avatar: json['avatar'] 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:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../servicios/servicio_idioma.dart'; import '../servicios/servicio_idioma.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
class PantallaAjustes extends StatefulWidget { class PantallaAjustes extends StatefulWidget {
@@ -19,14 +21,31 @@ class _PantallaAjustesState extends State<PantallaAjustes> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final servicioIdioma = context.watch<ServicioIdioma>(); final servicioIdioma = context.watch<ServicioIdioma>();
final perfil = context.watch<ServicioPerfilUsuario>().perfil;
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(l10n.settingsTitle)), appBar: AppBar(title: Text(l10n.settingsTitle)),
body: SingleChildScrollView( body: FondoFarolero(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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 // Selector de idioma
Card( Card(
child: Padding( child: Padding(
@@ -135,6 +154,7 @@ class _PantallaAjustesState extends State<PantallaAjustes> {
], ],
), ),
), ),
),
); );
} }
@@ -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() { String _nombreIdiomaDelSistema() {
final locale = WidgetsBinding.instance.platformDispatcher.locale; final locale = WidgetsBinding.instance.platformDispatcher.locale;
final codigo = locale.countryCode != null && locale.countryCode!.isNotEmpty 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:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../estado/estado_juego.dart'; import '../estado/estado_juego.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/palabra.dart'; import '../modelos/palabra.dart';
import '../modelos/partida.dart'; import '../modelos/partida.dart';
import '../modelos/usuario.dart'; import '../modelos/usuario.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_permisos.dart'; import '../servicios/servicio_permisos.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_gestor_host.dart'; import 'pantalla_gestor_host.dart';
import 'pantalla_lobby_host.dart'; import 'pantalla_lobby_host.dart';
@@ -129,8 +132,14 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
// 3. Iniciar host en Nearby // 3. Iniciar host en Nearby
if (!mounted) return; if (!mounted) return;
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>().perfil;
final nombreSala = '${nombre.trim()} - Farolero'; 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 (!ok) {
if (mounted) { if (mounted) {
@@ -153,15 +162,21 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
onIniciar: () { onIniciar: () {
// Cuando el host toca "Iniciar" con suficientes jugadores // Cuando el host toca "Iniciar" con suficientes jugadores
final estado = context.read<EstadoJuego>(); 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.crearPartidaDesdeSala(
estado.setHostJugador(nombre.trim());
final jugadoresMulti = [
nombre.trim(),
...nearby.jugadores.map((j) => j.nombre),
];
estado.crearPartida(
config: ConfigPartida( config: ConfigPartida(
modoMultimovil: true, modoMultimovil: true,
categoria: _categoria, categoria: _categoria,
@@ -169,24 +184,41 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
pistaImpostor: _pistaImpostor, pistaImpostor: _pistaImpostor,
tiempoDebateSegundos: _tiempoDebate, tiempoDebateSegundos: _tiempoDebate,
), ),
nombresJugadores: jugadoresMulti, sala: sala,
); );
// Enviar palabras a cada jugador via Nearby
final partida = estado.partida!; final partida = estado.partida!;
final impostores = <String, bool>{}; final asignaciones = partida.jugadores.map((jugador) {
for (int i = 0; i < nearby.jugadores.length; i++) { final usuarioSala = sala.usuarios[jugador.id];
final jugadorNearby = nearby.jugadores[i]; final clientId = usuarioSala?.clienteIdSeleccionado;
// El jugador [0] es el host, los de nearby son [1..n] final cliente = clientId == null ? null : sala.clientes[clientId];
final jugadorPartida = partida.jugadores[i + 1]; return AsignacionJugador(
impostores[jugadorNearby.endpointId] = jugadorId: jugador.id,
jugadorPartida.esImpostor; 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, palabraSecreta: partida.palabraSecreta,
categoria: _categoria, categoria: _categoria,
impostores: impostores, impostoresPorJugadorId: impostores,
jugadoresTodos: jugadoresTodos,
); );
Navigator.pushReplacement( Navigator.pushReplacement(
@@ -254,7 +286,11 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
value: usuario.nombre, value: usuario.nombre,
child: Row( child: Row(
children: [ children: [
Text(usuario.avatar ?? '👤'), AvatarFarolero(
texto: usuario.nombre.substring(0, 1),
assetPath: usuario.avatar,
size: 28,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(usuario.nombre), Text(usuario.nombre),
], ],
@@ -299,6 +335,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
final controller = TextEditingController(); final controller = TextEditingController();
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
controller.text = context.read<ServicioPerfilUsuario>().perfil.nombre;
final nombre = await showDialog<String>( final nombre = await showDialog<String>(
context: context, context: context,
@@ -331,6 +368,9 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
final nuevoUsuario = Usuario( final nuevoUsuario = Usuario(
id: DateTime.now().millisecondsSinceEpoch.toString(), id: DateTime.now().millisecondsSinceEpoch.toString(),
nombre: nombre.trim(), nombre: nombre.trim(),
nick: context.read<ServicioPerfilUsuario>().perfil.nick,
avatar: context.read<ServicioPerfilUsuario>().perfil.avatarAsset,
foto: context.read<ServicioPerfilUsuario>().perfil.avatarAsset,
); );
nearby.agregarUsuario(nuevoUsuario); nearby.agregarUsuario(nuevoUsuario);
return nombre.trim(); return nombre.trim();
@@ -341,6 +381,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
/// Método original para pedir nombre (usado cuando pool vacío) /// Método original para pedir nombre (usado cuando pool vacío)
Future<String?> _pedirNombreHost() async { Future<String?> _pedirNombreHost() async {
final controller = TextEditingController(); final controller = TextEditingController();
controller.text = context.read<ServicioPerfilUsuario>().perfil.nombre;
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
return showDialog<String>( return showDialog<String>(
context: context, context: context,
@@ -385,11 +426,38 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(l10n.createGame)), appBar: AppBar(title: Text(l10n.createGame)),
body: SingleChildScrollView( body: FondoFarolero(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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 // Modo de juego
Card( Card(
child: Padding( child: Padding(
@@ -626,6 +694,7 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
], ],
), ),
), ),
),
); );
} }
} }

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../estado/estado_juego.dart'; import '../estado/estado_juego.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_notas.dart'; import 'pantalla_notas.dart';
import 'pantalla_votacion.dart'; import 'pantalla_votacion.dart';
@@ -75,7 +76,8 @@ class _PantallaDebateState extends State<PantallaDebate> {
title: Text(l10n.debateRound(partida.rondaActual)), title: Text(l10n.debateRound(partida.rondaActual)),
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
), ),
body: Padding( body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
@@ -225,6 +227,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í. /// El cliente recibe el cambio de fase via Nearby y se navega aquí.
class PantallaDebateCliente extends StatefulWidget { class PantallaDebateCliente extends StatefulWidget {
final int? tiempoDebateSegundos; final int? tiempoDebateSegundos;
final String? primerTurnoNombre;
final VoidCallback onSolicitarVotacion; final VoidCallback onSolicitarVotacion;
const PantallaDebateCliente({ const PantallaDebateCliente({
super.key, super.key,
this.tiempoDebateSegundos, this.tiempoDebateSegundos,
this.primerTurnoNombre,
required this.onSolicitarVotacion, required this.onSolicitarVotacion,
}); });
@@ -111,6 +113,36 @@ class _PantallaDebateClienteState extends State<PantallaDebateCliente> {
], ],
// Instrucciones // 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( Text(
l10n.debateInstructions, l10n.debateInstructions,
textAlign: TextAlign.center, textAlign: TextAlign.center,

View File

@@ -3,13 +3,21 @@ import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../estado/estado_juego.dart'; import '../estado/estado_juego.dart';
import '../modelos/palabra.dart'; import '../modelos/palabra.dart';
import '../servicios/servicio_historial_partidas.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_principal.dart'; import 'pantalla_principal.dart';
import 'pantalla_ver_palabra.dart'; import 'pantalla_ver_palabra.dart';
class PantallaFinPartida extends StatelessWidget { class PantallaFinPartida extends StatefulWidget {
const PantallaFinPartida({super.key}); const PantallaFinPartida({super.key});
@override
State<PantallaFinPartida> createState() => _PantallaFinPartidaState();
}
class _PantallaFinPartidaState extends State<PantallaFinPartida> {
bool _guardada = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
@@ -20,6 +28,14 @@ class PantallaFinPartida extends StatelessWidget {
final ganaronJugadores = partida.ganador == 'jugadores'; final ganaronJugadores = partida.ganador == 'jugadores';
final impostores = final impostores =
partida.jugadores.where((j) => j.esImpostor).toList(); 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( return Scaffold(
appBar: AppBar( appBar: AppBar(

View File

@@ -1,11 +1,17 @@
import 'dart:async'; import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../estado/estado_juego.dart'; import '../estado/estado_juego.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/jugador.dart';
import '../modelos/partida.dart'; import '../modelos/partida.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_votacion_cliente.dart';
import 'pantalla_palabras_cliente.dart';
class PantallaGestorHost extends StatefulWidget { class PantallaGestorHost extends StatefulWidget {
final VoidCallback onPartidaFin; final VoidCallback onPartidaFin;
@@ -19,6 +25,9 @@ class PantallaGestorHost extends StatefulWidget {
class _PantallaGestorHostState extends State<PantallaGestorHost> { class _PantallaGestorHostState extends State<PantallaGestorHost> {
Timer? _timer; Timer? _timer;
int _segundosRestantes = 0; int _segundosRestantes = 0;
bool _hostListo = false;
String? _primerTurnoId;
String? _primerTurnoNombre;
final Map<String, bool> _clientesListos = {}; final Map<String, bool> _clientesListos = {};
final Map<String, String> _votosRecibidos = {}; final Map<String, String> _votosRecibidos = {};
@@ -51,8 +60,11 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
setState(() => _clientesListos[endpointId] = true); setState(() => _clientesListos[endpointId] = true);
} else if (mensaje.tipo == TipoMensaje.voto) { } else if (mensaje.tipo == TipoMensaje.voto) {
final votanteId = mensaje.datos['votanteId'] as String?; 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) { if (votanteId != null && votoId != null) {
context.read<EstadoJuego>().registrarVoto(votanteId, votoId);
setState(() => _votosRecibidos[votanteId] = votoId); setState(() => _votosRecibidos[votanteId] = votoId);
} }
} }
@@ -78,7 +90,15 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
switch (fase) { switch (fase) {
case FaseJuego.debate: case FaseJuego.debate:
estado.iniciarDebate(); 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(); _iniciarTemporizador();
break; break;
case FaseJuego.votacion: case FaseJuego.votacion:
@@ -116,9 +136,9 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
); );
} }
final numJugadores = partida.jugadores.length + 1; final todosListos =
final todosListos = _clientesListos.length >= numJugadores - 1; _hostListo && _clientesListos.length >= nearby.jugadores.length;
final todosVotaron = _votosRecibidos.length >= numJugadores - 1; final todosVotaron = estado.todosHanVotado();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -134,7 +154,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
), ),
], ],
), ),
body: Padding( body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
@@ -160,6 +181,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
], ],
), ),
), ),
),
); );
} }
@@ -216,6 +238,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
return _buildFaseDebate(context, l10n, nearby); return _buildFaseDebate(context, l10n, nearby);
case FaseJuego.votacion: case FaseJuego.votacion:
return _buildFaseVotacion(context, l10n, todosVotaron, nearby); return _buildFaseVotacion(context, l10n, todosVotaron, nearby);
case FaseJuego.resultado:
return _buildFaseResultado(context, l10n);
default: default:
return const Center(child: Text('Fin de la partida')); return const Center(child: Text('Fin de la partida'));
} }
@@ -243,7 +267,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildJugadorTile(nearby.miNombre ?? 'Host', true, false), _buildJugadorTile(nearby.miNombre ?? 'Host', true, _hostListo),
...nearby.jugadores.map( ...nearby.jugadores.map(
(j) => _buildJugadorTile( (j) => _buildJugadorTile(
j.nombre, j.nombre,
@@ -294,14 +318,48 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
void _mostrarPalabraHost(BuildContext context) { void _mostrarPalabraHost(BuildContext context) {
final estado = context.read<EstadoJuego>(); final estado = context.read<EstadoJuego>();
final sala = context.read<ServicioNearby>().estadoSala;
final partida = estado.partida; final partida = estado.partida;
if (partida == null) return; if (partida == null || sala == null) return;
// Buscar el jugador host local final jugadoresHost = sala
final hostLocal = partida.jugadores.firstWhere( .usuariosPorCliente(sala.hostClientId)
(j) => j.nombre == context.read<ServicioNearby>().miNombre, .where((usuario) => partida.jugadores.any((j) => j.id == usuario.id))
orElse: () => partida.jugadores.first, .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( Navigator.push(
context, context,
@@ -312,11 +370,28 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
palabra: partida.palabraSecreta, palabra: partida.palabraSecreta,
pistaActiva: partida.config.pistaImpostor, pistaActiva: partida.config.pistaImpostor,
categoria: partida.categoriaReal, 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( Widget _buildFaseDebate(
BuildContext context, BuildContext context,
AppLocalizations l10n, AppLocalizations l10n,
@@ -360,6 +435,8 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
_buildPrimerTurno(context),
const SizedBox(height: 16),
Text( Text(
l10n.activePlayers, l10n.activePlayers,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
@@ -387,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( Widget _buildFaseVotacion(
BuildContext context, BuildContext context,
AppLocalizations l10n, AppLocalizations l10n,
bool todosVotaron, bool todosVotaron,
ServicioNearby nearby, 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( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -410,19 +518,12 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
), ),
child: Column( child: Column(
children: [ children: [
Text( Text(l10n.votesProgress(votosEmitidos, totalVotos)),
l10n.votesProgress(
_votosRecibidos.length,
nearby.jugadores.length + 1,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: value: progreso.clamp(0.0, 1.0).toDouble(),
_votosRecibidos.length /
(nearby.jugadores.length + 1),
backgroundColor: TemaApp.colorSuperficie, backgroundColor: TemaApp.colorSuperficie,
valueColor: const AlwaysStoppedAnimation( valueColor: const AlwaysStoppedAnimation(
TemaApp.colorAcento, TemaApp.colorAcento,
@@ -433,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), const SizedBox(height: 16),
Text( Text(
l10n.playersVoted, l10n.playersVoted,
@@ -441,15 +555,11 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
const SizedBox(height: 8), const SizedBox(height: 8),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: nearby.jugadores.length + 1, itemCount: partida.jugadoresActivos.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final esHost = index == 0; final jugador = partida.jugadoresActivos[index];
final nombre = esHost final haVotado = estado.votos.containsKey(jugador.id);
? (nearby.miNombre ?? 'Host') return _buildJugadorTile(jugador.nombre, false, haVotado);
: nearby.jugadores[index - 1].nombre;
final haVotado =
esHost || _votosRecibidos.containsKey(nombre);
return _buildJugadorTile(nombre, esHost, haVotado);
}, },
), ),
), ),
@@ -478,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) { Widget _buildJugadorTile(String nombre, bool esHost, bool listo) {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
@@ -561,6 +855,7 @@ class _PantallaRevelarPalabraHost extends StatefulWidget {
final String palabra; final String palabra;
final bool pistaActiva; final bool pistaActiva;
final String categoria; final String categoria;
final VoidCallback onVisto;
const _PantallaRevelarPalabraHost({ const _PantallaRevelarPalabraHost({
required this.nombre, required this.nombre,
@@ -568,6 +863,7 @@ class _PantallaRevelarPalabraHost extends StatefulWidget {
required this.palabra, required this.palabra,
required this.pistaActiva, required this.pistaActiva,
required this.categoria, required this.categoria,
required this.onVisto,
}); });
@override @override
@@ -585,7 +881,9 @@ class _PantallaRevelarPalabraHostState
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(widget.nombre)), appBar: AppBar(title: Text(widget.nombre)),
body: Center( body: FondoFarolero(
intenso: true,
child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
child: Column( child: Column(
@@ -637,17 +935,12 @@ class _PantallaRevelarPalabraHostState
), ),
if (!widget.esImpostor) ...[ if (!widget.esImpostor) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( TarjetaPalabraFarolero(palabra: widget.palabra),
widget.palabra,
style: Theme.of(context).textTheme.headlineLarge
?.copyWith(fontSize: 32, color: Colors.white),
textAlign: TextAlign.center,
),
], ],
if (widget.esImpostor && widget.pistaActiva) ...[ if (widget.esImpostor && widget.pistaActiva) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'Categoria: ${widget.categoria}', 'Categoría: ${widget.categoria}',
style: Theme.of(context).textTheme.bodyLarge style: Theme.of(context).textTheme.bodyLarge
?.copyWith(color: TemaApp.colorNaranja), ?.copyWith(color: TemaApp.colorNaranja),
), ),
@@ -656,7 +949,7 @@ class _PantallaRevelarPalabraHostState
) )
: Column( : Column(
children: [ children: [
const Text('Candado', style: TextStyle(fontSize: 48)), const Text('🔒', style: TextStyle(fontSize: 48)),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
l10n.holdToSeeWord, l10n.holdToSeeWord,
@@ -698,10 +991,24 @@ class _PantallaRevelarPalabraHostState
), ),
), ),
), ),
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 'package:farolero/l10n/generated/app_localizations.dart';
import '../modelos/usuario.dart'; import '../modelos/usuario.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.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 { class PantallaLobbyHost extends StatefulWidget {
final String nombreSala; final String nombreSala;
final VoidCallback onIniciar; final VoidCallback onIniciar;
@@ -23,14 +25,16 @@ class PantallaLobbyHost extends StatefulWidget {
class _PantallaLobbyHostState extends State<PantallaLobbyHost> { class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
bool _iniciando = false; bool _iniciando = false;
String? _perfilSeleccionado;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final nearby = context.watch<ServicioNearby>(); final nearby = context.watch<ServicioNearby>();
final jugadores = nearby.jugadores; final sala = nearby.estadoSala;
final totalJugadores = jugadores.length + 1; // +1 host final usuarios = nearby.usuarios;
final seleccionados = usuarios.where((u) => u.estaSeleccionado).length;
final validacionInicio = sala?.validarInicio();
final puedeIniciar = validacionInicio?.exitoso ?? false;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -43,11 +47,11 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
}, },
), ),
), ),
body: Padding( body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
children: [ children: [
// QR Code
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -57,174 +61,66 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
child: QrImageView( child: QrImageView(
data: nearby.generarDatosQR(widget.nombreSala), data: nearby.generarDatosQR(widget.nombreSala),
version: QrVersions.auto, version: QrVersions.auto,
size: 180, size: 160,
backgroundColor: Colors.white, backgroundColor: Colors.white,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(l10n.scanToJoin),
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>(
initialValue: _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);
}
},
),
],
),
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildResumenSala(context, seleccionados, nearby.jugadores.length),
// Lista de jugadores const SizedBox(height: 12),
Expanded( Expanded(
child: Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
Text( Expanded(
l10n.connectedPlayers, child: Text(
'Usuarios de la partida',
style: Theme.of(context).textTheme.titleLarge, 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,
),
), ),
IconButton.filledTonal(
onPressed: () => _crearNuevoUsuario(context),
icon: const Icon(Icons.person_add),
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
// Host (yo)
_buildJugadorTile(
nombre: nearby.miNombre ?? 'Host',
esHost: true,
),
// Jugadores conectados
Expanded( Expanded(
child: jugadores.isEmpty child: usuarios.isEmpty
? Center( ? Center(child: Text(l10n.waitingForPlayers))
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( : ListView.builder(
itemCount: jugadores.length, itemCount: usuarios.length,
itemBuilder: (context, index) { itemBuilder: (context, index) =>
final j = jugadores[index]; _buildUsuarioTile(context, usuarios[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), const SizedBox(height: 12),
if (_perfilSeleccionado == null) if (!puedeIniciar)
Text( Text(
l10n.selectProfile, _mensajeValidacion(validacionInicio?.codigo),
style: Theme.of( style: Theme.of(context)
context, .textTheme
).textTheme.bodyMedium?.copyWith(color: TemaApp.colorNaranja), .bodyMedium
?.copyWith(color: TemaApp.colorNaranja),
textAlign: TextAlign.center,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: onPressed: puedeIniciar && !_iniciando
totalJugadores >= 3 &&
_perfilSeleccionado != null &&
!_iniciando
? () { ? () {
setState(() => _iniciando = true); setState(() => _iniciando = true);
widget.onIniciar(); widget.onIniciar();
@@ -237,48 +133,139 @@ 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( return Container(
margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: TemaApp.colorTarjeta, color: color.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: esHost border: Border.all(color: color.withValues(alpha: 0.55)),
? Border.all(color: TemaApp.colorAcento.withValues(alpha: 0.5))
: null,
), ),
child: Row( child: Row(
children: [ children: [
Text(esHost ? '👑' : '🎭', style: const TextStyle(fontSize: 20)), Icon(icon, color: color),
const SizedBox(width: 12), const SizedBox(width: 8),
Expanded( 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 { Future<void> _crearNuevoUsuario(BuildContext context) async {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final controller = TextEditingController(); final controller = TextEditingController();
@@ -312,12 +299,13 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
); );
if (nombre != null && nombre.trim().isNotEmpty) { if (nombre != null && nombre.trim().isNotEmpty) {
final nuevoUsuario = Usuario( final perfil = context.read<ServicioPerfilUsuario>().perfil;
id: DateTime.now().millisecondsSinceEpoch.toString(), await nearby.crearUsuarioSala(
nombre: nombre.trim(), 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 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:farolero/tema/componentes_farolero.dart';
import 'package:farolero/tema/tema_app.dart'; import 'package:farolero/tema/tema_app.dart';
/// Pantalla que ve cada jugador cuando recibe su palabra (modo multidispositivo). /// 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)!; final l10n = AppLocalizations.of(context)!;
return Scaffold( return Scaffold(
backgroundColor: TemaApp.colorFondo, body: FondoFarolero(
body: SafeArea( intenso: true,
child: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
@@ -58,15 +60,18 @@ class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 24), padding: const EdgeInsets.symmetric(vertical: 48, horizontal: 24),
decoration: BoxDecoration( decoration: TemaApp.decoracionPanel(
color: _palabraVisible color: _palabraVisible
? TemaApp.colorAcento ? TemaApp.colorSuperficie
: TemaApp.colorTarjeta, : TemaApp.colorTarjeta,
borderRadius: BorderRadius.circular(24), borderColor: _palabraVisible
? TemaApp.colorNaranja
: TemaApp.colorBorde,
).copyWith(
boxShadow: _palabraVisible boxShadow: _palabraVisible
? [ ? [
BoxShadow( BoxShadow(
color: TemaApp.colorAcento.withValues(alpha: 0.4), color: TemaApp.colorNaranja.withValues(alpha: 0.32),
blurRadius: 24, blurRadius: 24,
spreadRadius: 2, spreadRadius: 2,
), ),
@@ -83,15 +88,15 @@ class _PantallaPalabraClienteState extends State<PantallaPalabraCliente> {
size: 32, size: 32,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( _palabraVisible
_palabraVisible ? widget.palabra : '???', ? TarjetaPalabraFarolero(palabra: widget.palabra)
: const Text(
'???',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: _palabraVisible color: TemaApp.colorTextoSecundario,
? Colors.white
: 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:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.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 '../tema/tema_app.dart';
import 'pantalla_ajustes.dart'; import 'pantalla_ajustes.dart';
import 'pantalla_crear_partida.dart'; import 'pantalla_crear_partida.dart';
import 'pantalla_historial.dart';
import 'pantalla_reglas.dart'; import 'pantalla_reglas.dart';
import 'pantalla_unirse.dart'; import 'pantalla_unirse.dart';
@@ -12,115 +16,54 @@ class PantallaPrincipal extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final perfil = context.watch<ServicioPerfilUsuario>().perfil;
return Scaffold( return Scaffold(
body: SafeArea( body: FondoFarolero(
intenso: true,
child: SafeArea(
child: Center( child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 18),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Logo Row(
Container( children: [
width: 120, AvatarFarolero(
height: 120, texto: perfil.nombre.substring(0, 1).toUpperCase(),
decoration: BoxDecoration( assetPath: perfil.avatarAsset,
shape: BoxShape.circle, size: 48,
gradient: const LinearGradient( ),
colors: [TemaApp.colorAcento, TemaApp.colorNaranja], const SizedBox(width: 10),
begin: Alignment.topLeft, Expanded(
end: Alignment.bottomRight, 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),
), ),
boxShadow: [
BoxShadow(
color: TemaApp.colorAcento.withValues(alpha: 0.4),
blurRadius: 30,
spreadRadius: 5,
), ),
], ],
), ),
child: const Center(
child: Text(
'🎭',
style: TextStyle(fontSize: 56),
), ),
), IconButton.filledTonal(
), tooltip: l10n.settings,
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(),
),
);
},
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(),
),
);
},
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(),
),
);
},
icon: const Text('📖', style: TextStyle(fontSize: 20)),
label: Text(l10n.howToPlay),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () { onPressed: () {
Navigator.push( Navigator.push(
context, context,
@@ -129,20 +72,111 @@ class PantallaPrincipal extends StatelessWidget {
), ),
); );
}, },
icon: const Icon(Icons.settings, size: 20), icon: const Icon(Icons.settings),
label: Text(l10n.settings),
), ),
],
), ),
const SizedBox(height: 48), const SizedBox(height: 38),
const LogoFarolero(size: 70),
const SizedBox(height: 12),
Text( Text(
l10n.playersRange, l10n.subtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontSize: 12, color: TemaApp.colorTexto,
fontSize: 15,
),
),
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(),
),
);
},
),
),
const SizedBox(width: 8),
Expanded(
child: AccesoFarolero(
etiqueta: 'Logros',
icono: Icons.emoji_events,
onPressed: () {},
),
),
const SizedBox(width: 8),
Expanded(
child: AccesoFarolero(
etiqueta: 'Ranking',
icono: Icons.bar_chart,
onPressed: () {},
),
),
const SizedBox(width: 8),
Expanded(
child: AccesoFarolero(
etiqueta: 'Tienda',
icono: Icons.storefront,
onPressed: () {},
), ),
), ),
], ],
), ),
const SizedBox(height: 28),
Text(
l10n.playersRange,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
),
), ),
), ),
), ),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
class PantallaReglas extends StatelessWidget { class PantallaReglas extends StatelessWidget {
@@ -11,36 +12,96 @@ class PantallaReglas extends StatelessWidget {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(l10n.rulesTitle)), appBar: AppBar(title: Text(l10n.rulesTitle)),
body: SingleChildScrollView( body: FondoFarolero(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_seccion(context, l10n.rulesWhatIsTitle, l10n.rulesWhatIsBody), _seccion(
_seccion(context, l10n.rulesHowToPlayTitle, l10n.rulesHowToPlayBody), context,
_seccion(context, l10n.rulesWhoWinsTitle, l10n.rulesWhoWinsBody), 1,
_seccion(context, l10n.rulesTipsPlayersTitle, l10n.rulesTipsPlayersBody), Icons.person_search,
_seccion(context, l10n.rulesTipsImpostorTitle, l10n.rulesTipsImpostorBody), l10n.rulesWhatIsTitle,
_seccion(context, l10n.rulesModesTitle, l10n.rulesModesBody), 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), _ejemplo(context, l10n.rulesExampleTitle, l10n.rulesExampleBody),
const SizedBox(height: 32), const SizedBox(height: 32),
], ],
), ),
), ),
),
); );
} }
Widget _seccion(BuildContext context, String titulo, String contenido) { Widget _seccion(
BuildContext context,
int numero,
IconData icono,
String titulo,
String contenido,
) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
child: Card( child: PanelFarolero(
child: Padding( padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(16), 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(titulo, Text(
style: Theme.of(context).textTheme.titleLarge), '$numero. ${titulo.toUpperCase()}',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(contenido, Text(contenido,
style: Theme.of(context).textTheme.bodyLarge?.copyWith( style: Theme.of(context).textTheme.bodyLarge?.copyWith(
@@ -49,6 +110,8 @@ class PantallaReglas extends StatelessWidget {
], ],
), ),
), ),
],
),
), ),
); );
} }
@@ -56,10 +119,9 @@ class PantallaReglas extends StatelessWidget {
Widget _ejemplo(BuildContext context, String titulo, String contenido) { Widget _ejemplo(BuildContext context, String titulo, String contenido) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 16),
child: Card( child: PanelFarolero(
color: TemaApp.colorNaranja.withValues(alpha: 0.15), color: TemaApp.colorNaranja.withValues(alpha: 0.15),
child: Padding( borderColor: TemaApp.colorNaranja,
padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -75,7 +137,6 @@ class PantallaReglas extends StatelessWidget {
], ],
), ),
), ),
),
); );
} }
} }

View File

@@ -3,6 +3,7 @@ import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../estado/estado_juego.dart'; import '../estado/estado_juego.dart';
import '../modelos/partida.dart'; import '../modelos/partida.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_adivinanza.dart'; import 'pantalla_adivinanza.dart';
import 'pantalla_debate.dart'; import 'pantalla_debate.dart';
@@ -62,7 +63,8 @@ class _PantallaResultadoState extends State<PantallaResultado>
title: Text(l10n.result), title: Text(l10n.result),
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
), ),
body: Center( body: FondoFarolero(
child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
child: Column( child: Column(
@@ -128,41 +130,7 @@ class _PantallaResultadoState extends State<PantallaResultado>
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// Detalle de votos _buildDetalleVotos(context, partida, l10n),
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,
),
),
);
}),
],
),
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
// Acciones // Acciones
@@ -175,9 +143,143 @@ 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) { Widget _construirBotones(BuildContext context, EstadoJuego estado) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final partida = estado.partida; final partida = estado.partida;

View File

@@ -3,11 +3,15 @@ import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import '../modelos/jugador.dart'; import '../modelos/jugador.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/usuario.dart'; import '../modelos/usuario.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_permisos.dart'; import '../servicios/servicio_permisos.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_palabra_cliente.dart'; import 'pantalla_palabra_cliente.dart';
import 'pantalla_palabras_cliente.dart';
import 'pantalla_debate_cliente.dart'; import 'pantalla_debate_cliente.dart';
import 'pantalla_votacion_cliente.dart'; import 'pantalla_votacion_cliente.dart';
@@ -36,12 +40,17 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
bool _esImpostor = false; bool _esImpostor = false;
String? _pistaCategoria; String? _pistaCategoria;
final List<Jugador> _jugadores = []; final List<Jugador> _jugadores = [];
final List<JugadorInicioPartida> _jugadoresControlados = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Registrar listener ANTES del primer build // Registrar listener ANTES del primer build
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
final perfil = context.read<ServicioPerfilUsuario>().perfil;
if (_nombreController.text.isEmpty) {
_nombreController.text = perfil.nombre;
}
_registrarListenerPartida(); _registrarListenerPartida();
}); });
} }
@@ -51,13 +60,38 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
nearby.onMensaje((endpointId, mensaje) { nearby.onMensaje((endpointId, mensaje) {
if (mensaje.tipo == TipoMensaje.partidaInicio) { if (mensaje.tipo == TipoMensaje.partidaInicio) {
// El host ha iniciado la partida — nos ha enviado nuestra palabra // 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(() { setState(() {
_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?; _palabraRecibida = mensaje.datos['palabra'] as String?;
_esImpostor = mensaje.datos['esImpostor'] as bool? ?? false; _esImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
}
_pistaCategoria = mensaje.datos['categoria'] as String?; _pistaCategoria = mensaje.datos['categoria'] as String?;
}); });
// Navegar a pantalla de palabra del cliente // Navegar a pantalla de palabra del cliente
if (mounted && _palabraRecibida != null) { if (mounted && (_jugadoresControlados.isNotEmpty || _palabraRecibida != null)) {
_navegarAPalabra(); _navegarAPalabra();
} }
} else if (mensaje.tipo == TipoMensaje.fase) { } else if (mensaje.tipo == TipoMensaje.fase) {
@@ -71,6 +105,28 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
} }
void _navegarAPalabra() { 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( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => PantallaPalabraCliente( builder: (_) => PantallaPalabraCliente(
@@ -96,10 +152,14 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
void _navegarSegunFase(String fase) { void _navegarSegunFase(String fase) {
switch (fase) { switch (fase) {
case 'debate': case 'debate':
final datosFase = context.read<ServicioNearby>().datosPartida;
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => PantallaDebateCliente( builder: (_) => PantallaDebateCliente(
tiempoDebateSegundos: null, tiempoDebateSegundos:
datosFase?['tiempoDebateSegundos'] as int?,
primerTurnoNombre:
datosFase?['primerTurnoNombre'] as String?,
onSolicitarVotacion: () { onSolicitarVotacion: () {
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) { if (nearby.hostEndpointId != null) {
@@ -121,18 +181,24 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
MaterialPageRoute( MaterialPageRoute(
builder: (_) => PantallaVotacionCliente( builder: (_) => PantallaVotacionCliente(
jugadores: _jugadores, jugadores: _jugadores,
onVoto: (votoporId) { jugadoresControlados: List.unmodifiable(_jugadoresControlados),
onVotos: (votos) {
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) { if (nearby.hostEndpointId != null) {
for (final entry in votos.entries) {
nearby.enviarMensaje( nearby.enviarMensaje(
nearby.hostEndpointId!, nearby.hostEndpointId!,
MensajeP2P( MensajeP2P(
tipo: TipoMensaje.voto, tipo: TipoMensaje.voto,
datos: {'votoporId': votoporId}, 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) { Widget _buildFormularioNombre(BuildContext context, AppLocalizations l10n) {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(l10n.joinGameTitle)), appBar: AppBar(title: Text(l10n.joinGameTitle)),
body: Padding( body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Text('📱', style: TextStyle(fontSize: 64)), const Icon(
Icons.bluetooth_searching,
color: TemaApp.colorAzul,
size: 70,
),
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
l10n.joinGameTitle, l10n.joinGameTitle,
@@ -316,6 +387,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
), ),
), ),
), ),
),
); );
} }
@@ -342,7 +414,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
}, },
), ),
), ),
body: Padding( body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
children: [ children: [
@@ -438,6 +511,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
], ],
), ),
), ),
),
); );
} }
@@ -553,7 +627,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
}, },
), ),
), ),
body: SingleChildScrollView( body: FondoFarolero(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -605,19 +680,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
), ),
const Divider(), const Divider(),
// Usuarios existentes // Usuarios existentes
...usuarios.map( ...usuarios.map(_buildUsuarioSalaTile),
(usuario) => ListTile(
leading: Text(
usuario.avatar ?? '👤',
style: const TextStyle(fontSize: 24),
),
title: Text(usuario.nombre),
onTap: () {
// Seleccionar usuario - enviar al host
_enviarUsuarioAlHost(usuario);
},
),
),
], ],
), ),
), ),
@@ -634,6 +697,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
], ],
), ),
), ),
),
); );
} }
@@ -642,6 +706,7 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final controller = TextEditingController(); final controller = TextEditingController();
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
controller.text = context.read<ServicioPerfilUsuario>().perfil.nombre;
final nombre = await showDialog<String>( final nombre = await showDialog<String>(
context: context, context: context,
@@ -671,34 +736,63 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
); );
if (nombre != null && nombre.trim().isNotEmpty) { if (nombre != null && nombre.trim().isNotEmpty) {
final nuevoUsuario = Usuario( final perfil = context.read<ServicioPerfilUsuario>().perfil;
id: DateTime.now().millisecondsSinceEpoch.toString(), await nearby.crearUsuarioSala(
nombre: nombre.trim(), 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) { void _enviarUsuarioAlHost(Usuario usuario) {
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
if (nearby.hostEndpointId != null) { nearby.seleccionarUsuarioSala(usuario.id);
nearby.enviarMensaje(
nearby.hostEndpointId!,
MensajeP2P(
tipo: TipoMensaje.usuarioNuevo,
datos: {'usuario': usuario.toJson()},
),
);
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text('${usuario.nombre} seleccionado'))); ).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 ==================== // ==================== HELPERS ====================
Widget _buildError(String msg) { Widget _buildError(String msg) {

View File

@@ -3,6 +3,7 @@ import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../estado/estado_juego.dart'; import '../estado/estado_juego.dart';
import '../modelos/palabra.dart'; import '../modelos/palabra.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_debate.dart'; import 'pantalla_debate.dart';
@@ -31,7 +32,8 @@ class _PantallaVerPalabraState extends State<PantallaVerPalabra> {
title: Text(l10n.seeYourWord), title: Text(l10n.seeYourWord),
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
), ),
body: Padding( body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
@@ -111,6 +113,7 @@ class _PantallaVerPalabraState extends State<PantallaVerPalabra> {
], ],
), ),
), ),
),
); );
} }
@@ -169,7 +172,9 @@ class _PantallaRevelarPalabraState extends State<_PantallaRevelarPalabra> {
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(widget.nombre)), appBar: AppBar(title: Text(widget.nombre)),
body: Center( body: FondoFarolero(
intenso: true,
child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
child: Column( child: Column(
@@ -225,17 +230,7 @@ class _PantallaRevelarPalabraState extends State<_PantallaRevelarPalabra> {
), ),
if (!widget.esImpostor) ...[ if (!widget.esImpostor) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( TarjetaPalabraFarolero(palabra: widget.palabra),
widget.palabra,
style: Theme.of(context)
.textTheme
.headlineLarge
?.copyWith(
fontSize: 32,
color: Colors.white,
),
textAlign: TextAlign.center,
),
], ],
if (widget.esImpostor && widget.pistaActiva) ...[ if (widget.esImpostor && widget.pistaActiva) ...[
const SizedBox(height: 12), 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:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../estado/estado_juego.dart'; import '../estado/estado_juego.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_resultado.dart'; import 'pantalla_resultado.dart';
@@ -58,7 +59,8 @@ class _PantallaVotacionState extends State<PantallaVotacion> {
title: Text(l10n.voting), title: Text(l10n.voting),
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
), ),
body: Padding( body: FondoFarolero(
child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
children: [ children: [
@@ -163,6 +165,7 @@ class _PantallaVotacionState extends State<PantallaVotacion> {
], ],
), ),
), ),
),
); );
} }
@@ -174,7 +177,8 @@ class _PantallaVotacionState extends State<PantallaVotacion> {
title: Text(l10n.votingComplete), title: Text(l10n.votingComplete),
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
), ),
body: Center( body: FondoFarolero(
child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
child: Column( child: Column(
@@ -216,6 +220,7 @@ class _PantallaVotacionState extends State<PantallaVotacion> {
), ),
), ),
), ),
),
); );
} }
} }

View File

@@ -1,18 +1,24 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
import 'package:farolero/modelos/jugador.dart'; import 'package:farolero/modelos/jugador.dart';
import 'package:farolero/servicios/servicio_nearby.dart';
import 'package:farolero/tema/tema_app.dart'; import 'package:farolero/tema/tema_app.dart';
import 'package:provider/provider.dart';
/// Pantalla de votación para el cliente (multidispositivo). /// Pantalla de votación para cliente multidispositivo.
/// El cliente recibe fase=votacion y ve esta pantalla para elegir a quién votar. /// Un cliente puede manejar uno o varios jugadores, por eso se recoge un voto
/// por cada jugador controlado activo.
class PantallaVotacionCliente extends StatefulWidget { class PantallaVotacionCliente extends StatefulWidget {
final List<Jugador> jugadores; final List<Jugador> jugadores;
final Function(String votoporId) onVoto; final List<JugadorInicioPartida> jugadoresControlados;
final Function(Map<String, String> votos) onVotos;
const PantallaVotacionCliente({ const PantallaVotacionCliente({
super.key, super.key,
required this.jugadores, required this.jugadores,
required this.onVoto, this.jugadoresControlados = const [],
required this.onVotos,
}); });
@override @override
@@ -20,11 +26,47 @@ class PantallaVotacionCliente extends StatefulWidget {
} }
class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
if (_resultado != null) return _buildResultado(context, _resultado!);
return Scaffold( return Scaffold(
backgroundColor: TemaApp.colorFondo, backgroundColor: TemaApp.colorFondo,
@@ -45,45 +87,20 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
l10n.selectOnePlayer, _modoMultiVotante
? 'Emití un voto por cada jugador que manejás.'
: l10n.selectOnePlayer,
style: TextStyle(color: TemaApp.colorTextoSecundario), style: TextStyle(color: TemaApp.colorTextoSecundario),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Expanded( Expanded(
child: ListView.builder( child: _votantes.isEmpty
itemCount: widget.jugadores.length, ? _buildSelectorLegacy()
: ListView.builder(
itemCount: _votantes.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final jugador = widget.jugadores[index]; final votante = _votantes[index];
final selected = _votoSeleccionado == jugador.id; return _buildSelectorParaVotante(context, votante);
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);
},
),
);
}, },
), ),
), ),
@@ -92,9 +109,9 @@ class _PantallaVotacionClienteState extends State<PantallaVotacionCliente> {
width: double.infinity, width: double.infinity,
height: 56, height: 56,
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: _votoSeleccionado == null onPressed: _votacionCompleta
? null ? () => widget.onVotos(Map.unmodifiable(_votosPorVotante))
: () => widget.onVoto(_votoSeleccionado!), : null,
icon: const Icon(Icons.how_to_vote), icon: const Icon(Icons.how_to_vote),
label: Text(l10n.votar), label: Text(l10n.votar),
style: ElevatedButton.styleFrom( 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:flutter/foundation.dart';
import 'package:nearby_connections/nearby_connections.dart'; import 'package:nearby_connections/nearby_connections.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/sala_multijugador.dart';
import '../modelos/usuario.dart'; import '../modelos/usuario.dart';
/// Tipos de mensajes en el protocolo P2P /// Tipos de mensajes en el protocolo P2P.
enum TipoMensaje { enum TipoMensaje {
salaInfo, salaInfo,
partidaInicio, partidaInicio,
@@ -15,12 +17,20 @@ enum TipoMensaje {
listo, listo,
ping, ping,
jugadorDesconectado, jugadorDesconectado,
clienteRegistrado,
estadoSala,
crearUsuario,
seleccionarUsuario,
liberarUsuario,
eliminarUsuario,
errorOperacion,
usuarioNuevo, usuarioNuevo,
// Compatibilidad con versiones previas del protocolo.
usuarioEliminado, usuarioEliminado,
usuariosActualizados, usuariosActualizados,
} }
/// Mensaje del protocolo P2P entre dispositivos /// Mensaje del protocolo P2P entre dispositivos.
class MensajeP2P { class MensajeP2P {
final TipoMensaje tipo; final TipoMensaje tipo;
final Map<String, dynamic> datos; final Map<String, dynamic> datos;
@@ -40,7 +50,8 @@ class MensajeP2P {
Uint8List toBytes() => Uint8List.fromList(utf8.encode(toJson())); 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 { class JugadorConectado {
final String endpointId; final String endpointId;
final String nombre; final String nombre;
@@ -53,13 +64,13 @@ class JugadorConectado {
}); });
} }
/// Callback para mensajes recibidos
typedef OnMensajeCallback = typedef OnMensajeCallback =
void Function(String endpointId, MensajeP2P mensaje); void Function(String endpointId, MensajeP2P mensaje);
/// Servicio para conexiones P2P usando Google Nearby Connections API. /// Servicio para conexiones P2P usando Google Nearby Connections API.
class ServicioNearby extends ChangeNotifier { class ServicioNearby extends ChangeNotifier {
static const _serviceId = 'es.freetimelab.farolero'; static const _serviceId = 'es.freetimelab.farolero';
static const _hostClientId = 'host';
bool _esHost = false; bool _esHost = false;
bool _conectado = false; bool _conectado = false;
@@ -67,23 +78,21 @@ class ServicioNearby extends ChangeNotifier {
bool _anunciando = false; bool _anunciando = false;
String? _miEndpointId; String? _miEndpointId;
String? _hostEndpointId; String? _hostEndpointId;
String? _roomId;
String? _miClientId;
String? _nombreSala; String? _nombreSala;
String? _miNombre; String? _miNombre;
final Map<String, JugadorConectado> _jugadores = {}; final Map<String, JugadorConectado> _jugadores = {};
final List<OnMensajeCallback> _listeners = []; 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; String? _palabraRecibida;
bool? _soyImpostor; bool? _soyImpostor;
String? _faseActual; String? _faseActual;
Map<String, dynamic>? _datosPartida; Map<String, dynamic>? _datosPartida;
EstadoSalaMultijugador? _estadoSala;
// Pool de usuarios para modo multi-dispositivo
final Map<String, Usuario> _usuariosPool = {};
bool get esHost => _esHost; bool get esHost => _esHost;
bool get conectado => _conectado; bool get conectado => _conectado;
@@ -91,27 +100,35 @@ class ServicioNearby extends ChangeNotifier {
bool get anunciando => _anunciando; bool get anunciando => _anunciando;
String? get miEndpointId => _miEndpointId; String? get miEndpointId => _miEndpointId;
String? get hostEndpointId => _hostEndpointId; String? get hostEndpointId => _hostEndpointId;
String? get roomId => _roomId;
String? get miClientId => _miClientId;
String? get nombreSala => _nombreSala; String? get nombreSala => _nombreSala;
String? get miNombre => _miNombre; String? get miNombre => _miNombre;
String? get palabraRecibida => _palabraRecibida; String? get palabraRecibida => _palabraRecibida;
bool? get soyImpostor => _soyImpostor; bool? get soyImpostor => _soyImpostor;
String? get faseActual => _faseActual; String? get faseActual => _faseActual;
Map<String, dynamic>? get datosPartida => _datosPartida; Map<String, dynamic>? get datosPartida => _datosPartida;
EstadoSalaMultijugador? get estadoSala => _estadoSala;
List<JugadorConectado> get jugadores => _jugadores.values.toList(); List<JugadorConectado> get jugadores => _jugadores.values.toList();
int get numJugadoresConectados => _jugadores.length; int get numJugadoresConectados => _jugadores.length;
Map<String, String> get hostsEncontrados => Map<String, String> get hostsEncontrados =>
Map.unmodifiable(_hostsEncontrados); Map.unmodifiable(_hostsEncontrados);
/// Pool de usuarios disponibles para seleccionar en modo multi-dispositivo List<Usuario> get usuarios =>
List<Usuario> get usuarios => _usuariosPool.values.toList(); (_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) { void onMensaje(OnMensajeCallback callback) {
_listeners.add(callback); _listeners.add(callback);
} }
/// Elimina un listener
void removeMensajeListener(OnMensajeCallback callback) { void removeMensajeListener(OnMensajeCallback callback) {
_listeners.remove(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) { void agregarUsuario(Usuario usuario) {
_usuariosPool[usuario.id] = usuario; _usuariosPool[usuario.id] = usuario;
_estadoSala?.usuarios[usuario.id] = usuario;
notifyListeners(); notifyListeners();
} }
/// Elimina un usuario del pool
void eliminarUsuario(String usuarioId) { void eliminarUsuario(String usuarioId) {
_usuariosPool.remove(usuarioId); _usuariosPool.remove(usuarioId);
_estadoSala?.usuarios.remove(usuarioId);
notifyListeners(); notifyListeners();
} }
/// Obtiene un usuario por su ID
Usuario? getUsuario(String usuarioId) { 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) { void sincronizarUsuarios(List<Usuario> usuarios) {
_usuariosPool.clear(); _usuariosPool.clear();
for (final usuario in usuarios) { for (final usuario in usuarios) {
@@ -150,12 +165,38 @@ class ServicioNearby extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Obtiene el jugador local del host (él mismo como participante) void _sincronizarSala(EstadoSalaMultijugador sala) {
/// Retorna un JugadorConectado con endpointId null porque es local _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() { JugadorConectado? getJugadorLocal() {
if (_miNombre == null) return null; if (_miNombre == null) return null;
return JugadorConectado( return JugadorConectado(
endpointId: _miEndpointId ?? '', // vacío indica que es el host local endpointId: _miEndpointId ?? '',
nombre: _miNombre!, nombre: _miNombre!,
listo: true, listo: true,
); );
@@ -163,10 +204,37 @@ class ServicioNearby extends ChangeNotifier {
// ==================== HOST ==================== // ==================== HOST ====================
/// Inicia como host (anunciando el endpoint) Future<bool> iniciarHost(
Future<bool> iniciarHost(String nombreSala, String miNombre) async { String nombreSala,
String miNombre, {
String? miNick,
String? miAvatar,
}) async {
_nombreSala = nombreSala; _nombreSala = nombreSala;
_miNombre = miNombre; _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 { try {
final resultado = await Nearby().startAdvertising( final resultado = await Nearby().startAdvertising(
@@ -194,7 +262,6 @@ class ServicioNearby extends ChangeNotifier {
// ==================== CLIENTE ==================== // ==================== CLIENTE ====================
/// Busca hosts disponibles
Future<bool> buscarHosts(String miNombre) async { Future<bool> buscarHosts(String miNombre) async {
_miNombre = miNombre; _miNombre = miNombre;
@@ -219,7 +286,6 @@ class ServicioNearby extends ChangeNotifier {
} }
} }
/// Conecta a un host específico
Future<bool> conectarAHost(String endpointId, String miNombre) async { Future<bool> conectarAHost(String endpointId, String miNombre) async {
try { try {
await Nearby().requestConnection( await Nearby().requestConnection(
@@ -239,8 +305,7 @@ class ServicioNearby extends ChangeNotifier {
// ==================== CALLBACKS NEARBY ==================== // ==================== CALLBACKS NEARBY ====================
void _onConexionIniciada(String endpointId, ConnectionInfo info) { void _onConexionIniciada(String endpointId, ConnectionInfo info) {
debugPrint('Conexión iniciada con $endpointId: ${info.endpointName}'); debugPrint('Conexion iniciada con $endpointId: ${info.endpointName}');
// Auto-aceptar conexiones
Nearby().acceptConnection( Nearby().acceptConnection(
endpointId, endpointId,
onPayLoadRecieved: _onPayloadRecibido, onPayLoadRecieved: _onPayloadRecibido,
@@ -249,16 +314,13 @@ class ServicioNearby extends ChangeNotifier {
} }
void _onResultadoConexion(String endpointId, Status status) { void _onResultadoConexion(String endpointId, Status status) {
debugPrint('Resultado conexión $endpointId: $status'); debugPrint('Resultado conexion $endpointId: $status');
if (status == Status.CONNECTED) { if (status == Status.CONNECTED) {
if (_esHost) { if (_esHost) {
// Host: esperar mensaje 'unirse' del cliente
debugPrint('Cliente conectado: $endpointId'); debugPrint('Cliente conectado: $endpointId');
} else { } else {
// Cliente: conectado al host
_hostEndpointId = endpointId; _hostEndpointId = endpointId;
_conectado = true; _conectado = true;
// Enviar mensaje de unirse
enviarMensaje( enviarMensaje(
endpointId, endpointId,
MensajeP2P( MensajeP2P(
@@ -269,16 +331,20 @@ class ServicioNearby extends ChangeNotifier {
} }
notifyListeners(); notifyListeners();
} else { } else {
debugPrint('Conexión fallida con $endpointId'); debugPrint('Conexion fallida con $endpointId');
} }
} }
void _onDesconexion(String endpointId) { void _onDesconexion(String endpointId) {
debugPrint('Desconexión: $endpointId'); debugPrint('Desconexion: $endpointId');
if (_esHost) { if (_esHost) {
final jugador = _jugadores.remove(endpointId); final jugador = _jugadores.remove(endpointId);
final cliente = _estadoSala?.clientePorEndpoint(endpointId);
if (cliente != null) {
_estadoSala?.desconectarCliente(cliente.clientId);
_broadcastEstadoSala();
}
if (jugador != null) { if (jugador != null) {
// Notificar a todos que se desconectó
enviarATodos( enviarATodos(
MensajeP2P( MensajeP2P(
tipo: TipoMensaje.jugadorDesconectado, tipo: TipoMensaje.jugadorDesconectado,
@@ -287,7 +353,6 @@ class ServicioNearby extends ChangeNotifier {
); );
} }
} else { } else {
// Cliente perdió conexión con host
_conectado = false; _conectado = false;
_hostEndpointId = null; _hostEndpointId = null;
} }
@@ -312,7 +377,6 @@ class ServicioNearby extends ChangeNotifier {
} }
} }
/// Para el discovery sin desconectar
Future<void> pararBusqueda() async { Future<void> pararBusqueda() async {
try { try {
await Nearby().stopDiscovery(); await Nearby().stopDiscovery();
@@ -334,9 +398,7 @@ class ServicioNearby extends ChangeNotifier {
} }
} }
void _onPayloadUpdate(String endpointId, PayloadTransferUpdate update) { void _onPayloadUpdate(String endpointId, PayloadTransferUpdate update) {}
// No necesitamos trackear progreso para bytes pequeños
}
// ==================== PROCESAMIENTO DE MENSAJES ==================== // ==================== PROCESAMIENTO DE MENSAJES ====================
@@ -355,33 +417,11 @@ class ServicioNearby extends ChangeNotifier {
void _procesarMensajeHost(String endpointId, MensajeP2P mensaje) { void _procesarMensajeHost(String endpointId, MensajeP2P mensaje) {
switch (mensaje.tipo) { switch (mensaje.tipo) {
case TipoMensaje.unirse: case TipoMensaje.unirse:
final nombre = mensaje.datos['nombre'] as String? ?? 'Jugador'; _registrarClienteRemoto(endpointId, mensaje);
_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();
break; break;
case TipoMensaje.voto: case TipoMensaje.voto:
// Propagar al flujo de juego
_notificarMensaje(endpointId, mensaje); _notificarMensaje(endpointId, mensaje);
break; break;
case TipoMensaje.listo: case TipoMensaje.listo:
final jugador = _jugadores[endpointId]; final jugador = _jugadores[endpointId];
if (jugador != null) { if (jugador != null) {
@@ -389,33 +429,69 @@ class ServicioNearby extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
break; 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: case TipoMensaje.usuarioNuevo:
_handleUsuarioNuevo(mensaje); _handleUsuarioNuevo(mensaje);
break; break;
case TipoMensaje.usuariosActualizados: case TipoMensaje.usuariosActualizados:
_handleUsuariosActualizados(mensaje); _handleUsuariosActualizados(mensaje);
break; break;
default: default:
break; 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) { void _handleUsuarioNuevo(MensajeP2P mensaje) {
final usuarioJson = mensaje.datos['usuario'] as Map<String, dynamic>?; final usuarioJson = mensaje.datos['usuario'] as Map<String, dynamic>?;
if (usuarioJson != null) { if (usuarioJson != null) {
final nuevoUsuario = Usuario.fromJson(usuarioJson); final nuevoUsuario = Usuario.fromJson(usuarioJson);
_usuariosPool[nuevoUsuario.id] = nuevoUsuario; _usuariosPool[nuevoUsuario.id] = nuevoUsuario;
// Propagar a todos los clientes _estadoSala?.usuarios[nuevoUsuario.id] = nuevoUsuario;
if (_esHost) { if (_esHost) {
enviarATodos( _broadcastEstadoSala();
MensajeP2P(
tipo: TipoMensaje.usuarioNuevo,
datos: {'usuario': usuarioJson},
),
);
} }
notifyListeners(); 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) { void _procesarMensajeCliente(String endpointId, MensajeP2P mensaje) {
switch (mensaje.tipo) { switch (mensaje.tipo) {
case TipoMensaje.salaInfo: case TipoMensaje.salaInfo:
_datosPartida = mensaje.datos; _datosPartida = mensaje.datos;
// Sincronizar pool de usuarios si viene en el mensaje
final usuariosData = mensaje.datos['usuarios'] as List<dynamic>?; final usuariosData = mensaje.datos['usuarios'] as List<dynamic>?;
if (usuariosData != null) { if (usuariosData != null) {
_usuariosPool.clear(); _usuariosPool.clear();
@@ -448,42 +600,54 @@ class ServicioNearby extends ChangeNotifier {
} }
notifyListeners(); notifyListeners();
break; 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: case TipoMensaje.partidaInicio:
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?; _palabraRecibida = mensaje.datos['palabra'] as String?;
_soyImpostor = mensaje.datos['esImpostor'] as bool? ?? false; _soyImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
}
_datosPartida = mensaje.datos; _datosPartida = mensaje.datos;
notifyListeners(); notifyListeners();
break; break;
case TipoMensaje.fase: case TipoMensaje.fase:
_faseActual = mensaje.datos['fase'] as String?; _faseActual = mensaje.datos['fase'] as String?;
_datosPartida = mensaje.datos; _datosPartida = mensaje.datos;
notifyListeners(); notifyListeners();
break; break;
case TipoMensaje.votacionResultado: case TipoMensaje.votacionResultado:
_datosPartida = mensaje.datos;
notifyListeners();
break;
case TipoMensaje.partidaFin: case TipoMensaje.partidaFin:
case TipoMensaje.errorOperacion:
_datosPartida = mensaje.datos; _datosPartida = mensaje.datos;
notifyListeners(); notifyListeners();
break; break;
case TipoMensaje.jugadorDesconectado: case TipoMensaje.jugadorDesconectado:
notifyListeners(); notifyListeners();
break; break;
default: default:
break; break;
} }
} }
// ==================== ENVÍO ==================== // ==================== ENVIO ====================
/// Envía un mensaje a un dispositivo específico
Future<void> enviarMensaje(String endpointId, MensajeP2P mensaje) async { Future<void> enviarMensaje(String endpointId, MensajeP2P mensaje) async {
try { try {
await Nearby().sendBytesPayload(endpointId, mensaje.toBytes()); 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 { Future<void> enviarATodos(MensajeP2P mensaje) async {
for (final id in _jugadores.keys) { for (final id in _jugadores.keys) {
await enviarMensaje(id, mensaje); 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: ACCIONES DE JUEGO ====================
/// Host envía inicio de partida con la palabra de cada jugador
Future<void> enviarInicioPartida({ Future<void> enviarInicioPartida({
required String palabraSecreta, required String palabraSecreta,
required String categoria, required String categoria,
required Map<String, bool> impostores, // endpointId -> esImpostor required Map<String, bool> impostores,
}) async { }) async {
for (final entry in _jugadores.entries) { for (final entry in _jugadores.entries) {
final esImpostor = impostores[entry.key] ?? false; final esImpostor = impostores[entry.key] ?? false;
@@ -517,14 +782,39 @@ class ServicioNearby extends ChangeNotifier {
'palabra': esImpostor ? null : palabraSecreta, 'palabra': esImpostor ? null : palabraSecreta,
'esImpostor': esImpostor, 'esImpostor': esImpostor,
'categoria': categoria, '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( Future<void> enviarCambioFase(
String fase, [ String fase, [
Map<String, dynamic>? extra, Map<String, dynamic>? extra,
@@ -533,14 +823,12 @@ class ServicioNearby extends ChangeNotifier {
await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos)); await enviarATodos(MensajeP2P(tipo: TipoMensaje.fase, datos: datos));
} }
/// Host envía resultado de votación
Future<void> enviarResultadoVotacion(Map<String, dynamic> resultado) async { Future<void> enviarResultadoVotacion(Map<String, dynamic> resultado) async {
await enviarATodos( await enviarATodos(
MensajeP2P(tipo: TipoMensaje.votacionResultado, datos: resultado), MensajeP2P(tipo: TipoMensaje.votacionResultado, datos: resultado),
); );
} }
/// Host envía fin de partida
Future<void> enviarFinPartida(Map<String, dynamic> resultado) async { Future<void> enviarFinPartida(Map<String, dynamic> resultado) async {
await enviarATodos( await enviarATodos(
MensajeP2P(tipo: TipoMensaje.partidaFin, datos: resultado), MensajeP2P(tipo: TipoMensaje.partidaFin, datos: resultado),
@@ -549,7 +837,6 @@ class ServicioNearby extends ChangeNotifier {
// ==================== LIMPIEZA ==================== // ==================== LIMPIEZA ====================
/// Desconecta y limpia todo
Future<void> desconectar() async { Future<void> desconectar() async {
try { try {
await Nearby().stopAllEndpoints(); await Nearby().stopAllEndpoints();
@@ -565,27 +852,30 @@ class ServicioNearby extends ChangeNotifier {
_anunciando = false; _anunciando = false;
_miEndpointId = null; _miEndpointId = null;
_hostEndpointId = null; _hostEndpointId = null;
_roomId = null;
_miClientId = null;
_nombreSala = null; _nombreSala = null;
_miNombre = null; _miNombre = null;
_palabraRecibida = null; _palabraRecibida = null;
_soyImpostor = null; _soyImpostor = null;
_faseActual = null; _faseActual = null;
_datosPartida = null; _datosPartida = null;
_estadoSala = null;
_jugadores.clear(); _jugadores.clear();
_hostsEncontrados.clear(); _hostsEncontrados.clear();
_usuariosPool.clear();
notifyListeners(); notifyListeners();
} }
/// Genera los datos para el código QR de conexión
String generarDatosQR(String nombreSala) { String generarDatosQR(String nombreSala) {
return json.encode({ return json.encode({
'app': 'farolero', 'app': 'farolero',
'sala': nombreSala, 'sala': nombreSala,
'host': _miNombre, 'host': _miNombre,
'roomId': _roomId,
}); });
} }
/// Parsea datos de QR escaneado
static Map<String, dynamic>? parsearQR(String datos) { static Map<String, dynamic>? parsearQR(String datos) {
try { try {
final mapa = json.decode(datos) as Map<String, dynamic>; 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'; import 'package:google_fonts/google_fonts.dart';
class TemaApp { class TemaApp {
static const colorFondo = Color(0xFF121212); static const colorFondo = Color(0xFF05080D);
static const colorSuperficie = Color(0xFF1E1E1E); static const colorFondoAzul = Color(0xFF0A1520);
static const colorTarjeta = Color(0xFF2A2A2A); static const colorSuperficie = Color(0xFF0D151C);
static const colorAcento = Color(0xFFE53935); // Rojo impostor static const colorTarjeta = Color(0xFF121B23);
static const colorAcentoClaro = Color(0xFFFF6F61); static const colorBorde = Color(0xFF263947);
static const colorNaranja = Color(0xFFFF9800); static const colorAcento = Color(0xFFC02824);
static const colorVerde = Color(0xFF4CAF50); 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 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() { static ThemeData obtenerTema() {
final base = ThemeData.dark(useMaterial3: true);
final cuerpo = GoogleFonts.robotoCondensedTextTheme(base.textTheme);
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: Brightness.dark,
scaffoldBackgroundColor: colorFondo, scaffoldBackgroundColor: colorFondo,
colorScheme: const ColorScheme.dark( colorScheme: const ColorScheme.dark(
primary: colorAcento, primary: colorNaranja,
secondary: colorNaranja, secondary: colorNaranja,
surface: colorSuperficie, surface: colorSuperficie,
error: colorAcento, error: colorAcento,
onPrimary: Colors.black,
onSurface: colorTexto,
), ),
textTheme: GoogleFonts.poppinsTextTheme( textTheme: cuerpo.copyWith(
const TextTheme(
headlineLarge: TextStyle( headlineLarge: TextStyle(
fontFamily: GoogleFonts.oswald().fontFamily,
color: colorTexto, color: colorTexto,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 28, fontSize: 30,
letterSpacing: 0,
), ),
headlineMedium: TextStyle( headlineMedium: TextStyle(
fontFamily: GoogleFonts.oswald().fontFamily,
color: colorTexto, color: colorTexto,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 22, fontSize: 22,
letterSpacing: 0,
), ),
titleLarge: TextStyle( titleLarge: TextStyle(
color: colorTexto, fontFamily: GoogleFonts.oswald().fontFamily,
fontWeight: FontWeight.w600, color: colorDorado,
fontWeight: FontWeight.w700,
fontSize: 18, fontSize: 18,
letterSpacing: 0,
), ),
titleMedium: TextStyle( titleMedium: TextStyle(
fontFamily: GoogleFonts.oswald().fontFamily,
color: colorTexto, color: colorTexto,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w600,
fontSize: 16, fontSize: 16,
letterSpacing: 0,
), ),
bodyLarge: TextStyle(color: colorTexto, fontSize: 16), bodyLarge: const TextStyle(color: colorTexto, fontSize: 16),
bodyMedium: TextStyle(color: colorTextoSecundario, fontSize: 14), bodyMedium: const TextStyle(color: colorTextoSecundario, fontSize: 14),
), bodySmall: const TextStyle(color: colorTextoSecundario, fontSize: 12),
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: colorTarjeta, color: colorTarjeta.withValues(alpha: 0.82),
elevation: 4, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: const BorderSide(color: colorBorde),
),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: colorAcento, backgroundColor: colorNaranja,
foregroundColor: colorTexto, foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), disabledBackgroundColor: colorTarjeta,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), disabledForegroundColor: colorTextoSecundario,
textStyle: GoogleFonts.poppins( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 15),
fontWeight: FontWeight.w600, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: GoogleFonts.oswald(
fontWeight: FontWeight.w700,
fontSize: 16, fontSize: 16,
letterSpacing: 0,
), ),
), ),
), ),
outlinedButtonTheme: OutlinedButtonThemeData( outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: colorTexto, foregroundColor: colorTexto,
side: const BorderSide(color: colorAcento), side: const BorderSide(color: colorBorde),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 15),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
textStyle: GoogleFonts.oswald(
fontWeight: FontWeight.w700,
fontSize: 16,
letterSpacing: 0,
),
), ),
), ),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: colorTarjeta, fillColor: const Color(0xFF0B1117),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none, borderSide: const BorderSide(color: colorBorde),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: colorBorde),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: colorAcento), borderSide: const BorderSide(color: colorNaranja),
), ),
labelStyle: const TextStyle(color: colorTextoSecundario), labelStyle: const TextStyle(color: colorTextoSecundario),
hintStyle: const TextStyle(color: colorTextoSecundario), hintStyle: const TextStyle(color: colorTextoSecundario),
), ),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: colorFondo, backgroundColor: colorFondo,
foregroundColor: colorTexto, foregroundColor: colorNaranja,
centerTitle: true,
elevation: 0, elevation: 0,
titleTextStyle: GoogleFonts.poppins( titleTextStyle: GoogleFonts.oswald(
color: colorTexto, color: colorDorado,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 20, 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( switchTheme: SwitchThemeData(
thumbColor: WidgetStateProperty.resolveWith((states) { thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) return colorAcento; if (states.contains(WidgetState.selected)) return colorNaranja;
return colorTextoSecundario; return colorTextoSecundario;
}), }),
trackColor: WidgetStateProperty.resolveWith((states) { trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.selected)) { if (states.contains(WidgetState.selected)) {
return colorAcento.withValues(alpha: 0.5); return colorNaranja.withValues(alpha: 0.5);
} }
return colorTarjeta; 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 name: farolero
description: "Farolero — Juego de deducción social. ¿Quién finge saber?" description: "Farolero — Juego de deducción social. ¿Quién finge saber?"
publish_to: 'none' publish_to: 'none'
version: 1.1.7+12 version: 1.1.13+18
environment: environment:
sdk: ^3.11.1 sdk: ^3.11.1
@@ -32,3 +32,5 @@ flutter:
- assets/palabras.json - assets/palabras.json
- assets/palabras_en.json - assets/palabras_en.json
- assets/palabras_fr.json - assets/palabras_fr.json
- assets/words/
- assets/avatars/

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

@@ -4,6 +4,6 @@ import 'package:farolero/main.dart';
void main() { void main() {
testWidgets('App carga correctamente', (WidgetTester tester) async { testWidgets('App carga correctamente', (WidgetTester tester) async {
await tester.pumpWidget(const FaroleroApp()); 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"