Fixes
- SnapshotPartidaOnline no longer serializes esImpostor unless the game is
over. Every phase change was broadcasting the impostor roles in clear to
all clients.
- The impostor clue travels in its own field and only when the game enables
it. It used to be sent as the category key ("todas" when no category was
picked) and was overwritten by each phase snapshot.
- Eliminated players can no longer vote nor be voted for, on both the client
screen and the host listener.
- Nearby listeners are removed on dispose; votes are no longer dispatched
twice; votacionResultado no longer triggers two navigations.
- _jugadoresHostControlados handed the secret word to the host's own
impostors in the payload.
- Impostor cap unified as maxImpostoresPara(n) for both modes, and the
silent clamp in multi-device now reports the effective number.
- palabraAleatoria uses Random.secure.
Impostors know each other
- ConfigPartida.impostoresSeConocen, on by default. companerosImpostores is
nullable: null means nothing to show, an empty list means "you are the
only impostor".
Reconnection
- IdentidadDispositivo persists a stable device id; the host uses it as the
clientId so a phone that drops and returns is the same client. Falls back
to the endpointId for clients that do not send one.
- Usuario.absorbidoDe records the original owner when the host takes over a
disconnected player, and they are handed back automatically on return.
- New solicitarResync/resync messages: the host replies with the current
phase plus that client's own players, and the client jumps straight to the
running phase.
- Readiness is keyed by clientId instead of endpointId, and a disconnected
client no longer blocks the round.
- The retry gives up after three minutes, only reconnects to the host it
belonged to, and is cancelled correctly while shutting down.
Per-word clue support
- The word bank loader accepts both the old list-of-strings format and the
new one carrying a clue per word, falling back to the category clue.
Not verified on real devices: Nearby reconnection timing still needs two
Android phones.
735 lines
26 KiB
Dart
735 lines
26 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:farolero/l10n/generated/app_localizations.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../estado/estado_juego.dart';
|
|
import '../modelos/inicio_partida_multijugador.dart';
|
|
import '../modelos/palabra.dart';
|
|
import '../modelos/partida.dart';
|
|
import '../servicios/servicio_nearby.dart';
|
|
import '../servicios/servicio_permisos.dart';
|
|
import '../servicios/servicio_perfil_usuario.dart';
|
|
import '../tema/componentes_farolero.dart';
|
|
import '../tema/tema_app.dart';
|
|
import 'pantalla_gestor_host.dart';
|
|
import 'pantalla_lobby_host.dart';
|
|
import 'pantalla_principal.dart';
|
|
import 'pantalla_ver_palabra.dart';
|
|
|
|
class PantallaCrearPartida extends StatefulWidget {
|
|
final bool modoInicial;
|
|
final bool bloquearModo;
|
|
|
|
const PantallaCrearPartida({
|
|
super.key,
|
|
this.modoInicial = false,
|
|
this.bloquearModo = false,
|
|
});
|
|
|
|
@override
|
|
State<PantallaCrearPartida> createState() => _PantallaCrearPartidaState();
|
|
}
|
|
|
|
class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
|
|
bool _modoMultimovil = false;
|
|
String _categoria = 'todas';
|
|
int _numImpostores = 1;
|
|
bool _pistaImpostor = false;
|
|
bool _impostoresSeConocen = true;
|
|
int? _tiempoDebate;
|
|
final List<String> _jugadores = [];
|
|
final _controladorNombre = TextEditingController();
|
|
bool _agregandoPerfilPendiente = false;
|
|
|
|
final _opcionesTiempo = <int?>[null, 60, 120, 180, 300];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_modoMultimovil = widget.modoInicial;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _agregarPerfilLocalSiNecesario();
|
|
});
|
|
}
|
|
|
|
void _agregarPerfilLocalSiNecesario() {
|
|
if (_modoMultimovil) return;
|
|
final servicioPerfil = context.read<ServicioPerfilUsuario>();
|
|
if (!servicioPerfil.cargado) return;
|
|
final perfil = servicioPerfil.perfil;
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final nombre = perfil.nombre.trim().isEmpty
|
|
? l10n.defaultPlayerName
|
|
: perfil.nombre.trim();
|
|
if (_jugadores.contains(nombre)) return;
|
|
setState(() {
|
|
_jugadores.insert(0, nombre);
|
|
});
|
|
}
|
|
|
|
/// En multidispositivo el número real de jugadores se conoce en el lobby, así
|
|
/// que aquí se permite el tope global; al iniciar se ajusta y se avisa.
|
|
int get _maxImpostores => _modoMultimovil
|
|
? 4
|
|
: EstadoJuego.maxImpostoresPara(_jugadores.length);
|
|
|
|
List<String> _etiquetasTiempo(AppLocalizations l10n) => [
|
|
l10n.noLimit,
|
|
l10n.oneMin,
|
|
l10n.twoMin,
|
|
l10n.threeMin,
|
|
l10n.fiveMin,
|
|
];
|
|
|
|
void _agregarJugador() {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final nombre = _controladorNombre.text.trim();
|
|
if (nombre.isEmpty) return;
|
|
if (_jugadores.contains(nombre)) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l10n.playerAlreadyExists)));
|
|
return;
|
|
}
|
|
if (_jugadores.length >= 20) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l10n.maxPlayersReached)));
|
|
return;
|
|
}
|
|
setState(() {
|
|
_jugadores.add(nombre);
|
|
_controladorNombre.clear();
|
|
if (_numImpostores > _maxImpostores) {
|
|
_numImpostores = _maxImpostores;
|
|
}
|
|
});
|
|
}
|
|
|
|
void _eliminarJugador(int index) {
|
|
final perfil = context.read<ServicioPerfilUsuario>().perfil;
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final nombrePerfil = perfil.nombre.trim().isEmpty
|
|
? l10n.defaultPlayerName
|
|
: perfil.nombre.trim();
|
|
if (index == 0 && _jugadores[index] == nombrePerfil) return;
|
|
setState(() {
|
|
_jugadores.removeAt(index);
|
|
if (_numImpostores > _maxImpostores && _maxImpostores > 0) {
|
|
_numImpostores = _maxImpostores;
|
|
}
|
|
});
|
|
}
|
|
|
|
void _iniciarPartida() {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
|
|
if (_modoMultimovil) {
|
|
_iniciarPartidaMulti();
|
|
return;
|
|
}
|
|
|
|
if (_jugadores.length < 3) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text(l10n.minPlayersRequired)));
|
|
return;
|
|
}
|
|
|
|
final estado = context.read<EstadoJuego>();
|
|
estado.crearPartida(
|
|
config: ConfigPartida(
|
|
modoMultimovil: false,
|
|
categoria: _categoria,
|
|
numImpostores: _numImpostores,
|
|
pistaImpostor: _pistaImpostor,
|
|
tiempoDebateSegundos: _tiempoDebate,
|
|
impostoresSeConocen: _impostoresSeConocen,
|
|
),
|
|
nombresJugadores: _jugadores,
|
|
);
|
|
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => const PantallaVerPalabra()),
|
|
);
|
|
}
|
|
|
|
Future<void> _iniciarPartidaMulti() async {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
// 1. Pedir permisos automáticamente
|
|
final permisosOk = await ServicioPermisos.solicitarPermisosNearby(context);
|
|
if (!permisosOk) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(l10n.bluetoothLocationPermissionsShort),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 2. Usar el perfil principal del dispositivo como usuario del host.
|
|
final nombre = await _seleccionarUsuarioHost();
|
|
if (nombre == null || nombre.trim().isEmpty) return;
|
|
|
|
// 3. Iniciar host en Nearby
|
|
if (!mounted) return;
|
|
final nearby = context.read<ServicioNearby>();
|
|
final servicioPerfil = context.read<ServicioPerfilUsuario>();
|
|
final perfil = servicioPerfil.perfil;
|
|
final gamificacion = servicioPerfil.resumenGamificacion;
|
|
final nombreSala = '${nombre.trim()} - Farolero';
|
|
final ok = await nearby.iniciarHost(
|
|
nombreSala,
|
|
nombre.trim(),
|
|
miNick: perfil.nick,
|
|
miAvatar: perfil.avatarAsset,
|
|
miFuego: gamificacion.fuego,
|
|
miMedallas: gamificacion.medallas,
|
|
);
|
|
|
|
if (!ok) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(l10n.couldNotCreateRoom),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 4. Navegar al lobby con QR
|
|
if (mounted) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => PantallaLobbyHost(
|
|
nombreSala: nombreSala,
|
|
onIniciar: () {
|
|
// Cuando el host toca "Iniciar" con suficientes jugadores
|
|
final estado = context.read<EstadoJuego>();
|
|
final sala = nearby.estadoSala;
|
|
if (sala == null) return;
|
|
final validacion = sala.iniciarPartida();
|
|
if (!validacion.exitoso) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
l10n.cannotStartWithReason(
|
|
validacion.codigo ?? l10n.invalidRoom,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
estado.crearPartidaDesdeSala(
|
|
config: ConfigPartida(
|
|
modoMultimovil: true,
|
|
categoria: _categoria,
|
|
numImpostores: _numImpostores,
|
|
pistaImpostor: _pistaImpostor,
|
|
tiempoDebateSegundos: _tiempoDebate,
|
|
impostoresSeConocen: _impostoresSeConocen,
|
|
),
|
|
sala: sala,
|
|
);
|
|
|
|
final partida = estado.partida!;
|
|
// El tope de impostores depende de cuántos jugadores hay, y eso
|
|
// solo se sabe aquí. Si se recorta, hay que decirlo.
|
|
if (partida.impostoresTotales < _numImpostores) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
l10n.impostorsAdjusted(partida.impostoresTotales),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
final asignaciones = partida.jugadores.map((jugador) {
|
|
final usuarioSala = sala.usuarios[jugador.id];
|
|
final clientId = usuarioSala?.clienteIdSeleccionado;
|
|
final cliente = clientId == null ? null : sala.clientes[clientId];
|
|
return AsignacionJugador(
|
|
jugadorId: jugador.id,
|
|
nombre: jugador.nombre,
|
|
clientId: clientId ?? sala.hostClientId,
|
|
endpointId: cliente?.endpointId,
|
|
);
|
|
}).toList();
|
|
final impostores = {
|
|
for (final jugador in partida.jugadores)
|
|
jugador.id: jugador.esImpostor,
|
|
};
|
|
final jugadoresTodos = partida.jugadores
|
|
.map(
|
|
(jugador) => {
|
|
'id': jugador.id,
|
|
'nombre': jugador.nombre,
|
|
'eliminado': jugador.eliminado,
|
|
},
|
|
)
|
|
.toList();
|
|
|
|
nearby.enviarInicioPartidaMulti(
|
|
asignaciones: asignaciones,
|
|
palabraSecreta: partida.palabraSecreta,
|
|
categoria: partida.categoriaReal,
|
|
impostoresPorJugadorId: impostores,
|
|
jugadoresTodos: jugadoresTodos,
|
|
impostoresSeConocen: _impostoresSeConocen,
|
|
// La pista solo sale del host si la partida la tiene activada.
|
|
pistaImpostor: _pistaImpostor ? partida.pistaImpostor : null,
|
|
);
|
|
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => PantallaGestorHost(
|
|
onPartidaFin: () {
|
|
estado.limpiar();
|
|
Navigator.pushReplacement(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => const PantallaPrincipal(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Devuelve el perfil principal del dispositivo para crear la sala.
|
|
Future<String?> _seleccionarUsuarioHost() async {
|
|
return context.read<ServicioPerfilUsuario>().perfil.nombre;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controladorNombre.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final estado = context.watch<EstadoJuego>();
|
|
final servicioPerfil = context.watch<ServicioPerfilUsuario>();
|
|
final categorias = ['todas', ...?estado.banco?.nombresCategorias];
|
|
final etiquetas = _etiquetasTiempo(l10n);
|
|
final nombrePerfilActual = servicioPerfil.perfil.nombre.trim().isEmpty
|
|
? l10n.defaultPlayerName
|
|
: servicioPerfil.perfil.nombre.trim();
|
|
if (!_modoMultimovil &&
|
|
servicioPerfil.cargado &&
|
|
!_agregandoPerfilPendiente &&
|
|
!_jugadores.contains(nombrePerfilActual)) {
|
|
_agregandoPerfilPendiente = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_agregandoPerfilPendiente = false;
|
|
if (mounted) _agregarPerfilLocalSiNecesario();
|
|
});
|
|
}
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(l10n.createGame)),
|
|
body: FondoFarolero(
|
|
intenso: true,
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 28),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_CrearPartidaHeader(
|
|
titulo: l10n.createGame,
|
|
subtitulo: l10n.playersRange,
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (!widget.bloquearModo) ...[
|
|
// Modo de juego
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 28),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.gameMode,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 12),
|
|
SegmentedButton<bool>(
|
|
segments: [
|
|
ButtonSegment(
|
|
value: false,
|
|
label: Text(l10n.singleDevice),
|
|
icon: IconoFarolero(Icons.phone_android),
|
|
),
|
|
ButtonSegment(
|
|
value: true,
|
|
label: Text(l10n.multiDevice),
|
|
icon: IconoFarolero(Icons.devices),
|
|
),
|
|
],
|
|
selected: {_modoMultimovil},
|
|
onSelectionChanged: (valor) {
|
|
setState(() {
|
|
_modoMultimovil = valor.first;
|
|
if (_numImpostores > _maxImpostores) {
|
|
_numImpostores = _maxImpostores;
|
|
}
|
|
});
|
|
_agregarPerfilLocalSiNecesario();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
] else ...[
|
|
PanelFarolero(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
child: Row(
|
|
children: [
|
|
IconoFarolero(
|
|
_modoMultimovil ? Icons.devices : Icons.phone_android,
|
|
color: TemaApp.colorNaranja,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
_modoMultimovil
|
|
? l10n.multiDeviceGameLabel
|
|
: l10n.singleDeviceGameLabel,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
// CategorÃÂa
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 28),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.category,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: DropdownButtonFormField<String>(
|
|
initialValue: _categoria,
|
|
decoration: InputDecoration(
|
|
prefixIcon: Padding(
|
|
padding: EdgeInsets.all(10),
|
|
child: Image.asset(
|
|
'assets/ui/generated/actions/action_category_cards.webp',
|
|
width: 24,
|
|
height: 24,
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
),
|
|
items: categorias.map((c) {
|
|
return DropdownMenuItem(
|
|
value: c,
|
|
child: Text(
|
|
BancoPalabras.nombreBonitoCategoria(c, l10n),
|
|
),
|
|
);
|
|
}).toList(),
|
|
onChanged: (v) => setState(() => _categoria = v!),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
if (!_modoMultimovil) ...[
|
|
// Jugadores
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 28),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
l10n.playersCount(_jugadores.length),
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
Text(
|
|
l10n.playersRangeHint,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _controladorNombre,
|
|
decoration: InputDecoration(
|
|
hintText: l10n.playerNameHint,
|
|
prefixIcon: Padding(
|
|
padding: EdgeInsets.all(10),
|
|
child: Image.asset(
|
|
'assets/ui/generated/actions/action_add_player.webp',
|
|
width: 24,
|
|
height: 24,
|
|
fit: BoxFit.contain,
|
|
),
|
|
),
|
|
),
|
|
textCapitalization: TextCapitalization.words,
|
|
onSubmitted: (_) => _agregarJugador(),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton.filled(
|
|
onPressed: _agregarJugador,
|
|
icon: IconoFarolero(Icons.add),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
..._jugadores.asMap().entries.map((e) {
|
|
final perfil = servicioPerfil.perfil;
|
|
final nombrePerfil = perfil.nombre.trim().isEmpty
|
|
? l10n.defaultPlayerName
|
|
: perfil.nombre.trim();
|
|
final inicialPerfil = nombrePerfil.isEmpty
|
|
? '?'
|
|
: nombrePerfil.substring(0, 1).toUpperCase();
|
|
final esPerfilLocal =
|
|
e.key == 0 && e.value == nombrePerfil;
|
|
return ListTile(
|
|
minLeadingWidth: 62,
|
|
leading: esPerfilLocal
|
|
? SizedBox(
|
|
width: 62,
|
|
height: 62,
|
|
child: AvatarFarolero(
|
|
texto: inicialPerfil,
|
|
assetPath: perfil.avatarAsset,
|
|
size: 52,
|
|
),
|
|
)
|
|
: CircleAvatar(
|
|
backgroundColor: TemaApp.colorTarjeta,
|
|
child: Text(
|
|
'${e.key + 1}',
|
|
style: const TextStyle(color: TemaApp.colorTexto),
|
|
),
|
|
),
|
|
title: Text(e.value),
|
|
subtitle: esPerfilLocal
|
|
? Text(l10n.mainDeviceUser)
|
|
: null,
|
|
trailing: esPerfilLocal
|
|
? IconoFarolero(Icons.lock, color: TemaApp.colorDorado)
|
|
: IconButton(
|
|
icon: IconoFarolero(
|
|
Icons.close,
|
|
color: TemaApp.colorAcento,
|
|
),
|
|
onPressed: () => _eliminarJugador(e.key),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
|
|
// Configuración de partida
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 28),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.configuration,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Número de impostores
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(l10n.impostors),
|
|
Row(
|
|
children: [
|
|
IconButton(
|
|
onPressed: _numImpostores > 1
|
|
? () => setState(() => _numImpostores--)
|
|
: null,
|
|
icon: IconoFarolero(Icons.remove_circle_outline),
|
|
),
|
|
Text(
|
|
'$_numImpostores',
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
IconButton(
|
|
onPressed: _numImpostores < _maxImpostores
|
|
? () => setState(() => _numImpostores++)
|
|
: null,
|
|
icon: IconoFarolero(Icons.add_circle_outline),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
|
|
// Pista para impostor
|
|
SwitchListTile(
|
|
title: Text(l10n.impostorClue),
|
|
subtitle: Text(l10n.impostorClueDescription),
|
|
value: _pistaImpostor,
|
|
onChanged: (v) => setState(() => _pistaImpostor = v),
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
|
|
// Los impostores se reconocen entre ellos
|
|
SwitchListTile(
|
|
title: Text(l10n.impostorsKnowEachOther),
|
|
subtitle: Text(
|
|
_numImpostores > 1
|
|
? l10n.impostorsKnowEachOtherDescription
|
|
: l10n.impostorsKnowEachOtherNeedsTwo,
|
|
),
|
|
value: _impostoresSeConocen,
|
|
onChanged: _numImpostores > 1
|
|
? (v) => setState(() => _impostoresSeConocen = v)
|
|
: null,
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
|
|
// Temporizador
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(l10n.debateTime),
|
|
DropdownButton<int?>(
|
|
value: _tiempoDebate,
|
|
items: List.generate(
|
|
_opcionesTiempo.length,
|
|
(i) => DropdownMenuItem(
|
|
value: _opcionesTiempo[i],
|
|
child: Text(etiquetas[i]),
|
|
),
|
|
),
|
|
onChanged: (v) => setState(() => _tiempoDebate = v),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Botón iniciar
|
|
BotonFarolero(
|
|
texto: l10n.startGame,
|
|
icono: Icons.play_arrow,
|
|
assetIconPath: 'assets/ui/generated/actions/action_create_game.webp',
|
|
onPressed: (_modoMultimovil || _jugadores.length >= 3)
|
|
? _iniciarPartida
|
|
: null,
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CrearPartidaHeader extends StatelessWidget {
|
|
final String titulo;
|
|
final String subtitulo;
|
|
|
|
const _CrearPartidaHeader({
|
|
required this.titulo,
|
|
required this.subtitulo,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final ancho = constraints.maxWidth.clamp(320.0, 720.0).toDouble();
|
|
return Center(
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(maxWidth: ancho),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
AspectRatio(
|
|
aspectRatio: 2,
|
|
child: Image.asset(
|
|
'assets/ui/generated/create_game/create_game_header_art.webp',
|
|
fit: BoxFit.contain,
|
|
opacity: const AlwaysStoppedAnimation(0.96),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
titulo,
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
color: TemaApp.colorDorado,
|
|
fontWeight: FontWeight.w900,
|
|
shadows: [
|
|
Shadow(
|
|
color: TemaApp.colorNaranja.withValues(alpha: 0.55),
|
|
blurRadius: 16,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
subtitulo,
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
color: TemaApp.colorTextoSecundario,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|