Compare commits
21 Commits
f3dcb99de1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a2b2edefd | ||
| 2dbe505d77 | |||
| 3b0b10ea50 | |||
|
|
6a130acc84 | ||
| 00dc3ee5e1 | |||
| 957b42ea0c | |||
|
|
47b1209668 | ||
| 7dd6c7bd74 | |||
|
|
01b65a3d29 | ||
| 841f94e543 | |||
|
|
ab0d4dc2ba | ||
|
|
50b050e678 | ||
|
|
5d3b3ef271 | ||
| c8e5cf25c5 | |||
| d850b66089 | |||
|
|
166b89a661 | ||
|
|
1cb2260298 | ||
| da9bd0cd4a | |||
| d600835105 | |||
|
|
a8d5b0f002 | ||
|
|
4a1abd0be0 |
@@ -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
@@ -48,3 +48,5 @@ build/
|
|||||||
.flutter-plugins
|
.flutter-plugins
|
||||||
.flutter-plugins-dependencies
|
.flutter-plugins-dependencies
|
||||||
.packages
|
.packages
|
||||||
|
|
||||||
|
.atl/
|
||||||
|
|||||||
BIN
assets/avatars/avatar_01.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
assets/avatars/avatar_02.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/avatars/avatar_03.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/avatars/avatar_04.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
assets/avatars/avatar_05.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
assets/avatars/avatar_06.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
assets/avatars/avatar_07.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/avatars/avatar_08.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/avatars/avatar_09.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
assets/avatars/avatar_10.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/avatars/avatar_11.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/avatars/avatar_12.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
assets/avatars/avatar_13.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
assets/avatars/avatar_14.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
assets/avatars/avatar_15.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/avatars/avatar_16.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
assets/avatars/avatar_17.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
assets/avatars/avatar_18.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
assets/avatars/avatar_19.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/avatars/avatar_20.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
assets/avatars/avatar_21.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
BIN
assets/avatars/avatar_22.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/avatars/avatar_23.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
assets/avatars/avatar_24.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
assets/avatars/avatar_25.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
assets/avatars/avatar_26.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
assets/avatars/avatar_27.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
assets/avatars/avatar_28.png
Normal file
|
After Width: | Height: | Size: 8.8 KiB |
BIN
assets/avatars/avatar_29.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/avatars/avatar_30.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
assets/avatars/capybara_01.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/avatars/capybara_02.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/avatars/capybara_03.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_04.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_05.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_06.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/avatars/capybara_07.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_08.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
assets/avatars/capybara_09.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_10.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_11.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/avatars/capybara_12.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
1056
assets/words/palabras_ar.json
Normal file
1056
assets/words/palabras_ca.json
Normal file
1056
assets/words/palabras_de.json
Normal file
1056
assets/words/palabras_en.json
Normal file
1056
assets/words/palabras_es.json
Normal file
1056
assets/words/palabras_eu.json
Normal file
1056
assets/words/palabras_fr.json
Normal file
1056
assets/words/palabras_hi.json
Normal file
1056
assets/words/palabras_it.json
Normal file
1056
assets/words/palabras_ja.json
Normal file
1056
assets/words/palabras_ko.json
Normal file
1056
assets/words/palabras_nl.json
Normal file
1056
assets/words/palabras_pl.json
Normal file
1056
assets/words/palabras_pt.json
Normal file
1056
assets/words/palabras_ru.json
Normal file
1056
assets/words/palabras_tr.json
Normal file
1056
assets/words/palabras_zh.json
Normal file
1056
assets/words/palabras_zh_TW.json
Normal file
BIN
docs/prototipos/propotipo inicial.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
117
lib/modelos/inicio_partida_multijugador.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
294
lib/modelos/sala_multijugador.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
58
lib/pantallas/pantalla_historial.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
133
lib/pantallas/pantalla_palabras_cliente.dart
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:farolero/l10n/generated/app_localizations.dart';
|
||||||
|
import 'package:farolero/modelos/inicio_partida_multijugador.dart';
|
||||||
|
import 'package:farolero/tema/componentes_farolero.dart';
|
||||||
|
import 'package:farolero/tema/tema_app.dart';
|
||||||
|
|
||||||
|
/// 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'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
115
lib/servicios/servicio_historial_partidas.dart
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../modelos/partida.dart';
|
||||||
|
|
||||||
|
class ResultadoPartidaGuardado {
|
||||||
|
final String id;
|
||||||
|
final DateTime fecha;
|
||||||
|
final bool modoMultimovil;
|
||||||
|
final int jugadores;
|
||||||
|
final int impostores;
|
||||||
|
final int rondas;
|
||||||
|
final String ganador;
|
||||||
|
final String palabra;
|
||||||
|
final String categoria;
|
||||||
|
|
||||||
|
const ResultadoPartidaGuardado({
|
||||||
|
required this.id,
|
||||||
|
required this.fecha,
|
||||||
|
required this.modoMultimovil,
|
||||||
|
required this.jugadores,
|
||||||
|
required this.impostores,
|
||||||
|
required this.rondas,
|
||||||
|
required this.ganador,
|
||||||
|
required this.palabra,
|
||||||
|
required this.categoria,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ResultadoPartidaGuardado.desdePartida(Partida partida) {
|
||||||
|
return ResultadoPartidaGuardado(
|
||||||
|
id: DateTime.now().microsecondsSinceEpoch.toString(),
|
||||||
|
fecha: DateTime.now(),
|
||||||
|
modoMultimovil: partida.config.modoMultimovil,
|
||||||
|
jugadores: partida.jugadores.length,
|
||||||
|
impostores: partida.impostoresTotales,
|
||||||
|
rondas: partida.rondaActual,
|
||||||
|
ganador: partida.ganador ?? 'sin_resultado',
|
||||||
|
palabra: partida.palabraSecreta,
|
||||||
|
categoria: partida.categoriaReal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'fecha': fecha.toIso8601String(),
|
||||||
|
'modoMultimovil': modoMultimovil,
|
||||||
|
'jugadores': jugadores,
|
||||||
|
'impostores': impostores,
|
||||||
|
'rondas': rondas,
|
||||||
|
'ganador': ganador,
|
||||||
|
'palabra': palabra,
|
||||||
|
'categoria': categoria,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory ResultadoPartidaGuardado.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ResultadoPartidaGuardado(
|
||||||
|
id: json['id'] as String,
|
||||||
|
fecha: DateTime.parse(json['fecha'] as String),
|
||||||
|
modoMultimovil: json['modoMultimovil'] as bool? ?? false,
|
||||||
|
jugadores: json['jugadores'] as int? ?? 0,
|
||||||
|
impostores: json['impostores'] as int? ?? 0,
|
||||||
|
rondas: json['rondas'] as int? ?? 0,
|
||||||
|
ganador: json['ganador'] as String? ?? 'sin_resultado',
|
||||||
|
palabra: json['palabra'] as String? ?? '',
|
||||||
|
categoria: json['categoria'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServicioHistorialPartidas extends ChangeNotifier {
|
||||||
|
static const _clave = 'historial.partidas';
|
||||||
|
final List<ResultadoPartidaGuardado> _partidas = [];
|
||||||
|
bool _cargado = false;
|
||||||
|
|
||||||
|
List<ResultadoPartidaGuardado> get partidas => List.unmodifiable(_partidas);
|
||||||
|
bool get cargado => _cargado;
|
||||||
|
|
||||||
|
Future<void> cargar() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final raw = prefs.getString(_clave);
|
||||||
|
_partidas.clear();
|
||||||
|
if (raw != null) {
|
||||||
|
final lista = json.decode(raw) as List<dynamic>;
|
||||||
|
_partidas.addAll(
|
||||||
|
lista.map((e) => ResultadoPartidaGuardado.fromJson(e as Map<String, dynamic>)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_cargado = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> guardarPartida(Partida partida) async {
|
||||||
|
if (partida.ganador == null) return;
|
||||||
|
_partidas.insert(0, ResultadoPartidaGuardado.desdePartida(partida));
|
||||||
|
if (_partidas.length > 100) _partidas.removeRange(100, _partidas.length);
|
||||||
|
await _persistir();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> limpiar() async {
|
||||||
|
_partidas.clear();
|
||||||
|
await _persistir();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistir() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(
|
||||||
|
_clave,
|
||||||
|
json.encode(_partidas.map((p) => p.toJson()).toList()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
|||||||
104
lib/servicios/servicio_perfil_usuario.dart
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class PerfilUsuario {
|
||||||
|
final String nombre;
|
||||||
|
final String nick;
|
||||||
|
final String avatarAsset;
|
||||||
|
|
||||||
|
const PerfilUsuario({
|
||||||
|
required this.nombre,
|
||||||
|
required this.nick,
|
||||||
|
required this.avatarAsset,
|
||||||
|
});
|
||||||
|
|
||||||
|
PerfilUsuario copiar({String? nombre, String? nick, String? avatarAsset}) {
|
||||||
|
return PerfilUsuario(
|
||||||
|
nombre: nombre ?? this.nombre,
|
||||||
|
nick: nick ?? this.nick,
|
||||||
|
avatarAsset: avatarAsset ?? this.avatarAsset,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ServicioPerfilUsuario extends ChangeNotifier {
|
||||||
|
static const _claveNombre = 'perfil.nombre';
|
||||||
|
static const _claveNick = 'perfil.nick';
|
||||||
|
static const _claveAvatar = 'perfil.avatar';
|
||||||
|
|
||||||
|
static const avatares = [
|
||||||
|
'assets/avatars/avatar_01.png',
|
||||||
|
'assets/avatars/avatar_02.png',
|
||||||
|
'assets/avatars/avatar_03.png',
|
||||||
|
'assets/avatars/avatar_04.png',
|
||||||
|
'assets/avatars/avatar_05.png',
|
||||||
|
'assets/avatars/avatar_06.png',
|
||||||
|
'assets/avatars/avatar_07.png',
|
||||||
|
'assets/avatars/avatar_08.png',
|
||||||
|
'assets/avatars/avatar_09.png',
|
||||||
|
'assets/avatars/avatar_10.png',
|
||||||
|
'assets/avatars/avatar_11.png',
|
||||||
|
'assets/avatars/avatar_12.png',
|
||||||
|
'assets/avatars/avatar_13.png',
|
||||||
|
'assets/avatars/avatar_14.png',
|
||||||
|
'assets/avatars/avatar_15.png',
|
||||||
|
'assets/avatars/avatar_16.png',
|
||||||
|
'assets/avatars/avatar_17.png',
|
||||||
|
'assets/avatars/avatar_18.png',
|
||||||
|
'assets/avatars/avatar_19.png',
|
||||||
|
'assets/avatars/avatar_20.png',
|
||||||
|
'assets/avatars/avatar_21.png',
|
||||||
|
'assets/avatars/avatar_22.png',
|
||||||
|
'assets/avatars/avatar_23.png',
|
||||||
|
'assets/avatars/avatar_24.png',
|
||||||
|
'assets/avatars/avatar_25.png',
|
||||||
|
'assets/avatars/avatar_26.png',
|
||||||
|
'assets/avatars/avatar_27.png',
|
||||||
|
'assets/avatars/avatar_28.png',
|
||||||
|
'assets/avatars/avatar_29.png',
|
||||||
|
'assets/avatars/avatar_30.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
PerfilUsuario _perfil = const PerfilUsuario(
|
||||||
|
nombre: 'Jugador',
|
||||||
|
nick: 'farolero',
|
||||||
|
avatarAsset: 'assets/avatars/avatar_01.png',
|
||||||
|
);
|
||||||
|
bool _cargado = false;
|
||||||
|
|
||||||
|
PerfilUsuario get perfil => _perfil;
|
||||||
|
bool get cargado => _cargado;
|
||||||
|
|
||||||
|
Future<void> cargar() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_perfil = PerfilUsuario(
|
||||||
|
nombre: prefs.getString(_claveNombre) ?? _perfil.nombre,
|
||||||
|
nick: prefs.getString(_claveNick) ?? _perfil.nick,
|
||||||
|
avatarAsset: prefs.getString(_claveAvatar) ?? _perfil.avatarAsset,
|
||||||
|
);
|
||||||
|
_cargado = true;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> guardar({
|
||||||
|
required String nombre,
|
||||||
|
required String nick,
|
||||||
|
required String avatarAsset,
|
||||||
|
}) async {
|
||||||
|
final nombreLimpio = nombre.trim().isEmpty ? 'Jugador' : nombre.trim();
|
||||||
|
final nickLimpio = nick.trim().isEmpty ? 'farolero' : nick.trim();
|
||||||
|
final avatarSeguro = avatares.contains(avatarAsset)
|
||||||
|
? avatarAsset
|
||||||
|
: avatares.first;
|
||||||
|
_perfil = PerfilUsuario(
|
||||||
|
nombre: nombreLimpio,
|
||||||
|
nick: nickLimpio,
|
||||||
|
avatarAsset: avatarSeguro,
|
||||||
|
);
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_claveNombre, _perfil.nombre);
|
||||||
|
await prefs.setString(_claveNick, _perfil.nick);
|
||||||
|
await prefs.setString(_claveAvatar, _perfil.avatarAsset);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
437
lib/tema/componentes_farolero.dart
Normal file
@@ -0,0 +1,437 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
|
import 'tema_app.dart';
|
||||||
|
|
||||||
|
class FondoFarolero extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
final bool intenso;
|
||||||
|
|
||||||
|
const FondoFarolero({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.intenso = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(gradient: TemaApp.gradienteFondo),
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _FondoFaroleroPainter(intenso: intenso),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PanelFarolero extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
final EdgeInsetsGeometry padding;
|
||||||
|
final EdgeInsetsGeometry? margin;
|
||||||
|
final Color? color;
|
||||||
|
final Color? borderColor;
|
||||||
|
|
||||||
|
const PanelFarolero({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.padding = const EdgeInsets.all(16),
|
||||||
|
this.margin,
|
||||||
|
this.color,
|
||||||
|
this.borderColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: margin,
|
||||||
|
padding: padding,
|
||||||
|
decoration: TemaApp.decoracionPanel(color: color, borderColor: borderColor),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogoFarolero extends StatelessWidget {
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const LogoFarolero({super.key, this.size = 64});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
child: Icon(
|
||||||
|
Icons.lightbulb,
|
||||||
|
color: TemaApp.colorDorado.withValues(alpha: 0.32),
|
||||||
|
size: size * 0.82,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'FAROLERO',
|
||||||
|
style: GoogleFonts.bangers(
|
||||||
|
fontSize: size,
|
||||||
|
color: TemaApp.colorNaranja,
|
||||||
|
letterSpacing: 0,
|
||||||
|
shadows: const [
|
||||||
|
Shadow(offset: Offset(3, 4), blurRadius: 0, color: Color(0xFF5E1205)),
|
||||||
|
Shadow(offset: Offset(0, 0), blurRadius: 16, color: Color(0xFFFFC247)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BotonFarolero extends StatelessWidget {
|
||||||
|
final String texto;
|
||||||
|
final IconData icono;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final LinearGradient gradient;
|
||||||
|
final Color foreground;
|
||||||
|
|
||||||
|
const BotonFarolero({
|
||||||
|
super.key,
|
||||||
|
required this.texto,
|
||||||
|
required this.icono,
|
||||||
|
required this.onPressed,
|
||||||
|
this.gradient = TemaApp.gradientePrimario,
|
||||||
|
this.foreground = Colors.black,
|
||||||
|
});
|
||||||
|
|
||||||
|
const BotonFarolero.secundario({
|
||||||
|
super.key,
|
||||||
|
required this.texto,
|
||||||
|
required this.icono,
|
||||||
|
required this.onPressed,
|
||||||
|
}) : gradient = const LinearGradient(
|
||||||
|
colors: [TemaApp.colorPurpura, Color(0xFF2B1736)],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
),
|
||||||
|
foreground = Colors.white;
|
||||||
|
|
||||||
|
const BotonFarolero.oscuro({
|
||||||
|
super.key,
|
||||||
|
required this.texto,
|
||||||
|
required this.icono,
|
||||||
|
required this.onPressed,
|
||||||
|
}) : gradient = const LinearGradient(
|
||||||
|
colors: [Color(0xFF151F27), Color(0xFF090E13)],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
),
|
||||||
|
foreground = TemaApp.colorTexto;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final habilitado = onPressed != null;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: onPressed,
|
||||||
|
child: Ink(
|
||||||
|
height: 54,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: habilitado
|
||||||
|
? gradient
|
||||||
|
: const LinearGradient(
|
||||||
|
colors: [TemaApp.colorTarjeta, TemaApp.colorSuperficie],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: habilitado
|
||||||
|
? TemaApp.colorDorado.withValues(alpha: 0.74)
|
||||||
|
: TemaApp.colorBorde,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.34),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 58,
|
||||||
|
child: Icon(icono, color: foreground, size: 28),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
texto.toUpperCase(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: foreground,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 58),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccesoFarolero extends StatelessWidget {
|
||||||
|
final String etiqueta;
|
||||||
|
final IconData icono;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
const AccesoFarolero({
|
||||||
|
super.key,
|
||||||
|
required this.etiqueta,
|
||||||
|
required this.icono,
|
||||||
|
required this.onPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: onPressed,
|
||||||
|
child: Ink(
|
||||||
|
height: 66,
|
||||||
|
decoration: TemaApp.decoracionPanel(color: TemaApp.colorSuperficie),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icono, color: TemaApp.colorNaranja, size: 22),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Text(
|
||||||
|
etiqueta.toUpperCase(),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: TemaApp.colorDorado,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TarjetaPalabraFarolero extends StatelessWidget {
|
||||||
|
final String palabra;
|
||||||
|
|
||||||
|
const TarjetaPalabraFarolero({super.key, required this.palabra});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 28),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFC48642),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: const Color(0xFF6B3519), width: 2),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: TemaApp.colorNaranja.withValues(alpha: 0.28),
|
||||||
|
blurRadius: 24,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
palabra.toUpperCase(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.oswald(
|
||||||
|
color: const Color(0xFF1B0C05),
|
||||||
|
fontSize: 42,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
letterSpacing: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AvatarFarolero extends StatelessWidget {
|
||||||
|
final String texto;
|
||||||
|
final String? assetPath;
|
||||||
|
final Color color;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const AvatarFarolero({
|
||||||
|
super.key,
|
||||||
|
required this.texto,
|
||||||
|
this.assetPath,
|
||||||
|
this.color = TemaApp.colorNaranja,
|
||||||
|
this.size = 40,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: RadialGradient(
|
||||||
|
colors: [color.withValues(alpha: 0.9), TemaApp.colorSuperficie],
|
||||||
|
),
|
||||||
|
border: Border.all(color: TemaApp.colorDorado, width: 2),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: assetPath == null
|
||||||
|
? Text(
|
||||||
|
texto,
|
||||||
|
style: TextStyle(
|
||||||
|
color: TemaApp.colorTexto,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: size * 0.36,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ClipOval(
|
||||||
|
child: Image.asset(
|
||||||
|
assetPath!,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FondoFaroleroPainter extends CustomPainter {
|
||||||
|
final bool intenso;
|
||||||
|
|
||||||
|
const _FondoFaroleroPainter({required this.intenso});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()..isAntiAlias = true;
|
||||||
|
final alto = size.height;
|
||||||
|
final ancho = size.width;
|
||||||
|
|
||||||
|
paint.color = const Color(0xFF152845).withValues(alpha: intenso ? 0.34 : 0.22);
|
||||||
|
canvas.drawCircle(Offset(ancho * 0.78, alto * 0.16), 18, paint);
|
||||||
|
|
||||||
|
paint.color = const Color(0xFF07101A).withValues(alpha: 0.82);
|
||||||
|
final colinas = Path()
|
||||||
|
..moveTo(0, alto * 0.34)
|
||||||
|
..quadraticBezierTo(ancho * 0.28, alto * 0.21, ancho * 0.55, alto * 0.33)
|
||||||
|
..quadraticBezierTo(ancho * 0.82, alto * 0.43, ancho, alto * 0.26)
|
||||||
|
..lineTo(ancho, alto)
|
||||||
|
..lineTo(0, alto)
|
||||||
|
..close();
|
||||||
|
canvas.drawPath(colinas, paint);
|
||||||
|
|
||||||
|
_dibujarCasas(canvas, size, paint);
|
||||||
|
_dibujarFarol(canvas, size, paint);
|
||||||
|
|
||||||
|
paint.shader = RadialGradient(
|
||||||
|
colors: [
|
||||||
|
TemaApp.colorNaranja.withValues(alpha: intenso ? 0.26 : 0.16),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
).createShader(Rect.fromCircle(center: Offset(ancho * 0.52, alto * 0.36), radius: 160));
|
||||||
|
canvas.drawCircle(Offset(ancho * 0.52, alto * 0.36), 160, paint);
|
||||||
|
paint.shader = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _dibujarCasas(Canvas canvas, Size size, Paint paint) {
|
||||||
|
final alto = size.height;
|
||||||
|
final ancho = size.width;
|
||||||
|
paint.color = const Color(0xFF020407).withValues(alpha: 0.72);
|
||||||
|
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
final w = ancho * (0.16 + i * 0.018);
|
||||||
|
final h = alto * (0.18 + (i % 2) * 0.05);
|
||||||
|
final x = -30 + i * ancho * 0.24;
|
||||||
|
final y = alto * (0.72 - i * 0.02);
|
||||||
|
final casa = Rect.fromLTWH(x, y - h, w, h);
|
||||||
|
canvas.drawRect(casa, paint);
|
||||||
|
final tejado = Path()
|
||||||
|
..moveTo(x - 8, y - h)
|
||||||
|
..lineTo(x + w * 0.48, y - h - 38)
|
||||||
|
..lineTo(x + w + 8, y - h)
|
||||||
|
..close();
|
||||||
|
canvas.drawPath(tejado, paint);
|
||||||
|
|
||||||
|
final ventana = Paint()
|
||||||
|
..color = TemaApp.colorNaranja.withValues(alpha: 0.38)
|
||||||
|
..isAntiAlias = true;
|
||||||
|
for (var j = 0; j < 2; j++) {
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(
|
||||||
|
Rect.fromLTWH(x + 18 + j * 34, y - h + 36, 12, 22),
|
||||||
|
const Radius.circular(2),
|
||||||
|
),
|
||||||
|
ventana,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _dibujarFarol(Canvas canvas, Size size, Paint paint) {
|
||||||
|
final alto = size.height;
|
||||||
|
final ancho = size.width;
|
||||||
|
final centro = Offset(ancho * 0.5, alto * 0.28);
|
||||||
|
final glow = Paint()
|
||||||
|
..shader = RadialGradient(
|
||||||
|
colors: [
|
||||||
|
TemaApp.colorNaranja.withValues(alpha: 0.44),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
).createShader(Rect.fromCircle(center: centro, radius: 92));
|
||||||
|
canvas.drawCircle(centro, 92, glow);
|
||||||
|
|
||||||
|
paint
|
||||||
|
..shader = null
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 3
|
||||||
|
..color = const Color(0xFF050507).withValues(alpha: 0.82);
|
||||||
|
canvas.drawArc(
|
||||||
|
Rect.fromCircle(center: centro.translate(0, -16), radius: 35),
|
||||||
|
math.pi,
|
||||||
|
math.pi,
|
||||||
|
false,
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
paint.style = PaintingStyle.fill;
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(
|
||||||
|
Rect.fromCenter(center: centro, width: 38, height: 54),
|
||||||
|
const Radius.circular(5),
|
||||||
|
),
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
paint.color = TemaApp.colorNaranja.withValues(alpha: 0.82);
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(
|
||||||
|
Rect.fromCenter(center: centro, width: 21, height: 34),
|
||||||
|
const Radius.circular(4),
|
||||||
|
),
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _FondoFaroleroPainter oldDelegate) {
|
||||||
|
return oldDelegate.intenso != intenso;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,114 +2,212 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
77
test/modelos/inicio_partida_multijugador_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
156
test/modelos/sala_multijugador_test.dart
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
216
tools/generate_word_banks.ps1
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
param(
|
||||||
|
[string]$OutputDir = 'assets/words',
|
||||||
|
[int]$BatchSize = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$langMap = [ordered]@{
|
||||||
|
ar = 'ar'
|
||||||
|
ca = 'ca'
|
||||||
|
de = 'de'
|
||||||
|
en = 'en'
|
||||||
|
es = 'es'
|
||||||
|
eu = 'eu'
|
||||||
|
fr = 'fr'
|
||||||
|
hi = 'hi'
|
||||||
|
it = 'it'
|
||||||
|
ja = 'ja'
|
||||||
|
ko = 'ko'
|
||||||
|
nl = 'nl'
|
||||||
|
pl = 'pl'
|
||||||
|
pt = 'pt'
|
||||||
|
ru = 'ru'
|
||||||
|
tr = 'tr'
|
||||||
|
zh = 'zh-CN'
|
||||||
|
zh_TW = 'zh-TW'
|
||||||
|
}
|
||||||
|
|
||||||
|
$categoryKeyMap = [ordered]@{
|
||||||
|
animales = 'categoryAnimals'
|
||||||
|
comida = 'categoryFood'
|
||||||
|
paises = 'categoryCountries'
|
||||||
|
deportes = 'categorySports'
|
||||||
|
profesiones = 'categoryProfessions'
|
||||||
|
objetos = 'categoryObjects'
|
||||||
|
lugares = 'categoryPlaces'
|
||||||
|
peliculas = 'categoryMovies'
|
||||||
|
musica = 'categoryMusic'
|
||||||
|
tecnologia = 'categoryTechnology'
|
||||||
|
}
|
||||||
|
|
||||||
|
$contextMap = [ordered]@{
|
||||||
|
animales = 'animal'
|
||||||
|
comida = 'food'
|
||||||
|
paises = 'country'
|
||||||
|
deportes = 'sport'
|
||||||
|
profesiones = 'profession'
|
||||||
|
objetos = 'object'
|
||||||
|
lugares = 'place'
|
||||||
|
peliculas = 'movie'
|
||||||
|
musica = 'music'
|
||||||
|
tecnologia = 'technology'
|
||||||
|
}
|
||||||
|
|
||||||
|
$utf8Strict = [System.Text.UTF8Encoding]::new($false, $true)
|
||||||
|
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
|
||||||
|
|
||||||
|
function Read-Utf8Json([string]$path) {
|
||||||
|
$text = $utf8Strict.GetString([System.IO.File]::ReadAllBytes((Resolve-Path $path))).TrimStart([char]0xFEFF)
|
||||||
|
return $text | ConvertFrom-Json
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-Utf8Json([object]$obj, [string]$path) {
|
||||||
|
$full = Join-Path (Get-Location) $path
|
||||||
|
$dir = Split-Path -Parent $full
|
||||||
|
if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force $dir | Out-Null }
|
||||||
|
[System.IO.File]::WriteAllText($full, ($obj | ConvertTo-Json -Depth 10) + "`n", $utf8NoBom)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Strip-Context([string]$value) {
|
||||||
|
$clean = $value.Trim()
|
||||||
|
if ($clean -match '^\s*[^::]{1,30}\s*[::]\s*') {
|
||||||
|
return ($clean -replace '^\s*[^::]{1,30}\s*[::]\s*', '').Trim()
|
||||||
|
}
|
||||||
|
return $clean
|
||||||
|
}
|
||||||
|
|
||||||
|
function Translate-Batch([string[]]$terms, [string]$target) {
|
||||||
|
if ($terms.Count -eq 0) { return @() }
|
||||||
|
$numbered = New-Object System.Collections.Generic.List[string]
|
||||||
|
for ($i = 0; $i -lt $terms.Count; $i++) {
|
||||||
|
$numbered.Add("[$i] $($terms[$i])")
|
||||||
|
}
|
||||||
|
$query = [uri]::EscapeDataString(($numbered -join "`n"))
|
||||||
|
$url = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=es&tl=$target&dt=t&q=$query"
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Invoke-RestMethod -Uri $url -TimeoutSec 45
|
||||||
|
$translated = (($response[0] | ForEach-Object { $_[0] }) -join '')
|
||||||
|
$matches = [regex]::Matches(
|
||||||
|
$translated,
|
||||||
|
'(?s)\[(\d+)\]\s*(.*?)(?=\s*\[\d+\]\s*|$)'
|
||||||
|
)
|
||||||
|
if ($matches.Count -eq $terms.Count) {
|
||||||
|
$out = New-Object string[] $terms.Count
|
||||||
|
foreach ($match in $matches) {
|
||||||
|
$index = [int]$match.Groups[1].Value
|
||||||
|
if ($index -ge 0 -and $index -lt $terms.Count) {
|
||||||
|
$out[$index] = Strip-Context $match.Groups[2].Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (($out | Where-Object { $_ -eq $null -or $_.Trim().Length -eq 0 }).Count -eq 0) {
|
||||||
|
return $out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Start-Sleep -Milliseconds 250
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = New-Object System.Collections.Generic.List[string]
|
||||||
|
foreach ($term in $terms) {
|
||||||
|
$queryOne = [uri]::EscapeDataString($term)
|
||||||
|
$urlOne = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=es&tl=$target&dt=t&q=$queryOne"
|
||||||
|
$translatedOne = $null
|
||||||
|
for ($attempt = 1; $attempt -le 4; $attempt++) {
|
||||||
|
try {
|
||||||
|
$responseOne = Invoke-RestMethod -Uri $urlOne -TimeoutSec 45
|
||||||
|
$translatedOne = (($responseOne[0] | ForEach-Object { $_[0] }) -join '')
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
if ($attempt -eq 4) { throw }
|
||||||
|
Start-Sleep -Milliseconds (250 * $attempt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$out.Add((Strip-Context $translatedOne))
|
||||||
|
Start-Sleep -Milliseconds 35
|
||||||
|
}
|
||||||
|
return $out.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceEs = Read-Utf8Json 'assets/palabras.json'
|
||||||
|
$sourceEn = Read-Utf8Json 'assets/palabras_en.json'
|
||||||
|
$sourceFr = Read-Utf8Json 'assets/palabras_fr.json'
|
||||||
|
|
||||||
|
$arbByLang = @{}
|
||||||
|
foreach ($lang in $langMap.Keys) {
|
||||||
|
$arbByLang[$lang] = Read-Utf8Json ("lib/l10n/app_{0}.arb" -f $lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-LanguageBank([string]$lang) {
|
||||||
|
$bank = [ordered]@{
|
||||||
|
version = 2
|
||||||
|
idioma = $lang
|
||||||
|
categorias = [ordered]@{}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($category in $categoryKeyMap.Keys) {
|
||||||
|
$labelKey = $categoryKeyMap[$category]
|
||||||
|
$words = switch ($lang) {
|
||||||
|
'es' { @($sourceEs.categorias.$category) }
|
||||||
|
'en' { @($sourceEn.categorias.$category) }
|
||||||
|
'fr' { @($sourceFr.categorias.$category) }
|
||||||
|
default { @() }
|
||||||
|
}
|
||||||
|
|
||||||
|
$bank.categorias[$category] = [ordered]@{
|
||||||
|
pista = [string]$arbByLang[$lang].$labelKey
|
||||||
|
palabras = @($words | ForEach-Object { [string]$_ })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $bank
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($lang in @('es', 'en', 'fr')) {
|
||||||
|
Write-Utf8Json (New-LanguageBank $lang) (Join-Path $OutputDir "palabras_$lang.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
$targets = @($langMap.Keys | Where-Object { $_ -notin @('es', 'en', 'fr') })
|
||||||
|
foreach ($lang in $targets) {
|
||||||
|
Write-Host "Generating $lang..."
|
||||||
|
$bank = New-LanguageBank $lang
|
||||||
|
$targetCode = $langMap[$lang]
|
||||||
|
|
||||||
|
foreach ($category in $categoryKeyMap.Keys) {
|
||||||
|
$spanishWords = @($sourceEs.categorias.$category)
|
||||||
|
$context = $contextMap[$category]
|
||||||
|
$translatedWords = New-Object System.Collections.Generic.List[string]
|
||||||
|
|
||||||
|
for ($offset = 0; $offset -lt $spanishWords.Count; $offset += $BatchSize) {
|
||||||
|
$last = [Math]::Min($offset + $BatchSize - 1, $spanishWords.Count - 1)
|
||||||
|
$terms = New-Object System.Collections.Generic.List[string]
|
||||||
|
for ($index = $offset; $index -le $last; $index++) {
|
||||||
|
$terms.Add("${context}: $($spanishWords[$index])")
|
||||||
|
}
|
||||||
|
$translated = @(Translate-Batch $terms.ToArray() $targetCode)
|
||||||
|
foreach ($word in $translated) { $translatedWords.Add($word) }
|
||||||
|
Start-Sleep -Milliseconds 70
|
||||||
|
}
|
||||||
|
|
||||||
|
$bank.categorias[$category].palabras = $translatedWords.ToArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Utf8Json $bank (Join-Path $OutputDir "palabras_$lang.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host 'Validating UTF-8 and sample accents...'
|
||||||
|
foreach ($lang in $langMap.Keys) {
|
||||||
|
$file = Join-Path $OutputDir "palabras_$lang.json"
|
||||||
|
$bytes = [System.IO.File]::ReadAllBytes((Resolve-Path $file))
|
||||||
|
$null = $utf8Strict.GetString($bytes)
|
||||||
|
if ($bytes.Length -ge 3 -and $bytes[0] -eq 239 -and $bytes[1] -eq 187 -and $bytes[2] -eq 191) {
|
||||||
|
throw "Unexpected UTF-8 BOM in $file"
|
||||||
|
}
|
||||||
|
$bank = Read-Utf8Json $file
|
||||||
|
foreach ($category in $categoryKeyMap.Keys) {
|
||||||
|
$expected = @($sourceEs.categorias.$category).Count
|
||||||
|
$actual = @($bank.categorias.$category.palabras).Count
|
||||||
|
if ($actual -ne $expected) { throw "Word count mismatch in $file / $category. Expected $expected, got $actual" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$esBank = Read-Utf8Json (Join-Path $OutputDir 'palabras_es.json')
|
||||||
|
$leon = @($esBank.categorias.animales.palabras) | Where-Object { $_ -eq 'León' } | Select-Object -First 1
|
||||||
|
if ($leon -ne 'León') { throw 'León accent validation failed' }
|
||||||
|
Write-Host "OK: generated split word banks in $OutputDir"
|
||||||