6e5e423ab4
No se puede marcar “vista” sin revelar la palabra antes. Se puede volver a ver la palabra durante debate/votación/resultado. Notas online privadas por partida y jugador. Tests añadidos para notas scoped. Ajusté roomId en el payload de inicio para que las notas no se mezclen entre partidas.
836 lines
26 KiB
Dart
836 lines
26 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:farolero/l10n/generated/app_localizations.dart';
|
|
import '../modelos/jugador.dart';
|
|
import '../modelos/inicio_partida_multijugador.dart';
|
|
import '../modelos/usuario.dart';
|
|
import '../servicios/servicio_nearby.dart';
|
|
import '../servicios/servicio_permisos.dart';
|
|
import '../servicios/servicio_perfil_usuario.dart';
|
|
import '../tema/componentes_farolero.dart';
|
|
import '../tema/tema_app.dart';
|
|
import 'pantalla_palabra_cliente.dart';
|
|
import 'pantalla_palabras_cliente.dart';
|
|
import 'pantalla_debate_cliente.dart';
|
|
import 'pantalla_votacion_cliente.dart';
|
|
|
|
/// Pantalla para unirse a una partida multidispositivo.
|
|
/// Flujo: nombre → discovery automático (lista de salas) → fallback QR
|
|
class PantallaUnirse extends StatefulWidget {
|
|
const PantallaUnirse({super.key});
|
|
|
|
@override
|
|
State<PantallaUnirse> createState() => _PantallaUnirseState();
|
|
}
|
|
|
|
class _PantallaUnirseState extends State<PantallaUnirse> {
|
|
final _nombreController = TextEditingController();
|
|
final _formKey = GlobalKey<FormState>();
|
|
|
|
// Estados de la pantalla
|
|
bool _buscando = false;
|
|
bool _escaneandoQR = false;
|
|
bool _conectando = false;
|
|
String? _error;
|
|
String? _salaSeleccionada;
|
|
|
|
// Estado del juego recibido del host
|
|
String? _palabraRecibida;
|
|
bool _esImpostor = false;
|
|
String? _pistaCategoria;
|
|
String? _partidaId;
|
|
final List<Jugador> _jugadores = [];
|
|
final List<JugadorInicioPartida> _jugadoresControlados = [];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Registrar listener ANTES del primer build
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final perfil = context.read<ServicioPerfilUsuario>().perfil;
|
|
if (_nombreController.text.isEmpty) {
|
|
_nombreController.text = perfil.nombre;
|
|
}
|
|
_registrarListenerPartida();
|
|
});
|
|
}
|
|
|
|
void _registrarListenerPartida() {
|
|
final nearby = context.read<ServicioNearby>();
|
|
nearby.onMensaje((endpointId, mensaje) {
|
|
if (mensaje.tipo == TipoMensaje.partidaInicio) {
|
|
// El host ha iniciado la partida — nos ha enviado nuestra palabra
|
|
final jugadoresData = mensaje.datos['jugadores'] as List<dynamic>?;
|
|
final jugadoresTodosData =
|
|
mensaje.datos['jugadoresTodos'] as List<dynamic>?;
|
|
setState(() {
|
|
_jugadoresControlados
|
|
..clear()
|
|
..addAll(
|
|
(jugadoresData ?? []).map(
|
|
(json) => JugadorInicioPartida.fromJson(
|
|
json as Map<String, dynamic>,
|
|
),
|
|
),
|
|
);
|
|
_jugadores
|
|
..clear()
|
|
..addAll(
|
|
(jugadoresTodosData ?? []).map(
|
|
(json) => Jugador.fromJson(json as Map<String, dynamic>),
|
|
),
|
|
);
|
|
if (_jugadoresControlados.isNotEmpty) {
|
|
final primero = _jugadoresControlados.first;
|
|
_palabraRecibida = primero.palabra;
|
|
_esImpostor = primero.esImpostor;
|
|
} else {
|
|
_palabraRecibida = mensaje.datos['palabra'] as String?;
|
|
_esImpostor = mensaje.datos['esImpostor'] as bool? ?? false;
|
|
if (_palabraRecibida != null) {
|
|
_jugadoresControlados.add(
|
|
JugadorInicioPartida(
|
|
jugadorId: nearby.miClientId ?? '_legacy',
|
|
nombre: _nombreController.text.trim().isEmpty
|
|
? 'Jugador'
|
|
: _nombreController.text.trim(),
|
|
esImpostor: _esImpostor,
|
|
palabra: _palabraRecibida,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
_pistaCategoria = mensaje.datos['categoria'] as String?;
|
|
_partidaId = (mensaje.datos['roomId'] as String?) ??
|
|
nearby.roomId ??
|
|
(mensaje.datos['clientId'] as String?) ??
|
|
DateTime.now().microsecondsSinceEpoch.toString();
|
|
});
|
|
// Navegar a pantalla de palabra del cliente
|
|
if (mounted && (_jugadoresControlados.isNotEmpty || _palabraRecibida != null)) {
|
|
_navegarAPalabra();
|
|
}
|
|
} else if (mensaje.tipo == TipoMensaje.fase) {
|
|
// El host cambia de fase — navegar a la pantalla correspondiente
|
|
final fase = mensaje.datos['fase'] as String?;
|
|
if (mounted && fase != null) {
|
|
_navegarSegunFase(fase);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
void _navegarAPalabra() {
|
|
if (_jugadoresControlados.isNotEmpty) {
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => PantallaPalabrasCliente(
|
|
jugadores: List.unmodifiable(_jugadoresControlados),
|
|
pistaCategoria: _pistaCategoria,
|
|
onTodosVistos: () {
|
|
final nearby = context.read<ServicioNearby>();
|
|
if (nearby.hostEndpointId != null) {
|
|
nearby.enviarMensaje(
|
|
nearby.hostEndpointId!,
|
|
MensajeP2P(tipo: TipoMensaje.listo, datos: {}),
|
|
);
|
|
}
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (_) => PantallaPalabraCliente(
|
|
palabra: _palabraRecibida ?? '',
|
|
esImpostor: _esImpostor,
|
|
pistaCategoria: _pistaCategoria,
|
|
onVisto: () {
|
|
// Enviar "listo" al host y volver a la espera
|
|
final nearby = context.read<ServicioNearby>();
|
|
if (nearby.hostEndpointId != null) {
|
|
nearby.enviarMensaje(
|
|
nearby.hostEndpointId!,
|
|
MensajeP2P(tipo: TipoMensaje.listo, datos: {}),
|
|
);
|
|
}
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _navegarSegunFase(String fase) {
|
|
switch (fase) {
|
|
case 'debate':
|
|
final datosFase = context.read<ServicioNearby>().datosPartida;
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(
|
|
builder: (_) => PantallaDebateCliente(
|
|
tiempoDebateSegundos:
|
|
datosFase?['tiempoDebateSegundos'] as int?,
|
|
primerTurnoNombre:
|
|
datosFase?['primerTurnoNombre'] as String?,
|
|
partidaId: _partidaId ?? context.read<ServicioNearby>().roomId,
|
|
pistaCategoria: _pistaCategoria,
|
|
jugadores: List.unmodifiable(_jugadores),
|
|
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
|
|
onSolicitarVotacion: () {
|
|
final nearby = context.read<ServicioNearby>();
|
|
if (nearby.hostEndpointId != null) {
|
|
nearby.enviarMensaje(
|
|
nearby.hostEndpointId!,
|
|
MensajeP2P(
|
|
tipo: TipoMensaje.ping,
|
|
datos: {'solicitoVotacion': true},
|
|
),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
break;
|
|
case 'votacion':
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute(
|
|
builder: (_) => PantallaVotacionCliente(
|
|
jugadores: _jugadores,
|
|
jugadoresControlados: List.unmodifiable(_jugadoresControlados),
|
|
partidaId: _partidaId ?? context.read<ServicioNearby>().roomId,
|
|
pistaCategoria: _pistaCategoria,
|
|
onVotos: (votos) {
|
|
final nearby = context.read<ServicioNearby>();
|
|
if (nearby.hostEndpointId != null) {
|
|
for (final entry in votos.entries) {
|
|
nearby.enviarMensaje(
|
|
nearby.hostEndpointId!,
|
|
MensajeP2P(
|
|
tipo: TipoMensaje.voto,
|
|
datos: {
|
|
'votanteId': entry.key,
|
|
'votadoId': entry.value,
|
|
'votoporId': entry.value,
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
),
|
|
),
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nombreController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// Paso 1: validar nombre, pedir permisos e iniciar discovery
|
|
Future<void> _iniciarBusqueda() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
// Solicitar permisos automáticamente
|
|
final permisosOk = await ServicioPermisos.solicitarPermisosNearby(context);
|
|
if (!permisosOk) {
|
|
setState(() {
|
|
_error =
|
|
'Se necesitan permisos de Bluetooth y ubicación para buscar partidas.';
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!mounted) return;
|
|
final nearby = context.read<ServicioNearby>();
|
|
final ok = await nearby.buscarHosts(_nombreController.text.trim());
|
|
|
|
if (ok) {
|
|
setState(() {
|
|
_buscando = true;
|
|
_error = null;
|
|
});
|
|
} else {
|
|
setState(() {
|
|
_error =
|
|
'No se pudo iniciar la búsqueda. Verifica Bluetooth y ubicación.';
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Conectar a un host de la lista
|
|
Future<void> _conectarAHost(String endpointId, String nombreHost) async {
|
|
setState(() {
|
|
_conectando = true;
|
|
_salaSeleccionada = nombreHost;
|
|
});
|
|
|
|
final nearby = context.read<ServicioNearby>();
|
|
// Parar discovery antes de conectar
|
|
await nearby.pararBusqueda();
|
|
final ok = await nearby.conectarAHost(
|
|
endpointId,
|
|
_nombreController.text.trim(),
|
|
);
|
|
|
|
if (!ok && mounted) {
|
|
setState(() {
|
|
_conectando = false;
|
|
_error = 'No se pudo conectar a $nombreHost';
|
|
});
|
|
// Reiniciar búsqueda
|
|
_iniciarBusqueda();
|
|
}
|
|
}
|
|
|
|
/// Fallback: escanear QR
|
|
void _abrirEscaner() {
|
|
setState(() {
|
|
_escaneandoQR = true;
|
|
_error = null;
|
|
});
|
|
}
|
|
|
|
Future<void> _onQRDetectado(BarcodeCapture capture) async {
|
|
if (_conectando) return;
|
|
|
|
for (final barcode in capture.barcodes) {
|
|
final valor = barcode.rawValue;
|
|
if (valor == null) continue;
|
|
|
|
final datos = ServicioNearby.parsearQR(valor);
|
|
if (datos != null) {
|
|
setState(() {
|
|
_escaneandoQR = false;
|
|
_conectando = true;
|
|
_salaSeleccionada =
|
|
datos['host'] as String? ?? datos['sala'] as String? ?? 'Sala';
|
|
});
|
|
|
|
// Iniciar búsqueda para que Nearby encuentre al host
|
|
final nearby = context.read<ServicioNearby>();
|
|
if (!nearby.buscando) {
|
|
await nearby.buscarHosts(_nombreController.text.trim());
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final nearby = context.watch<ServicioNearby>();
|
|
|
|
// Si estamos conectados → pantalla de espera
|
|
if (nearby.conectado && !nearby.esHost) {
|
|
return _buildPantallaEspera(context, l10n);
|
|
}
|
|
|
|
// Si escaneando QR
|
|
if (_escaneandoQR) {
|
|
return _buildEscaner(context, l10n);
|
|
}
|
|
|
|
// Si buscando hosts o conectando
|
|
if (_buscando || _conectando) {
|
|
return _buildDiscovery(context, l10n, nearby);
|
|
}
|
|
|
|
// Formulario nombre
|
|
return _buildFormularioNombre(context, l10n);
|
|
}
|
|
|
|
// ==================== PASO 1: NOMBRE ====================
|
|
|
|
Widget _buildFormularioNombre(BuildContext context, AppLocalizations l10n) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: Text(l10n.joinGameTitle)),
|
|
body: FondoFarolero(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(
|
|
Icons.bluetooth_searching,
|
|
color: TemaApp.colorAzul,
|
|
size: 70,
|
|
),
|
|
const SizedBox(height: 24),
|
|
Text(
|
|
l10n.joinGameTitle,
|
|
style: Theme.of(context).textTheme.headlineMedium,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.enterNameToSearch,
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 32),
|
|
TextFormField(
|
|
controller: _nombreController,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.yourName,
|
|
prefixIcon: const Icon(Icons.person),
|
|
),
|
|
validator: (v) {
|
|
if (v == null || v.trim().isEmpty) return l10n.nameRequired;
|
|
return null;
|
|
},
|
|
textCapitalization: TextCapitalization.words,
|
|
onFieldSubmitted: (_) => _iniciarBusqueda(),
|
|
),
|
|
const SizedBox(height: 24),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
onPressed: _iniciarBusqueda,
|
|
icon: const Icon(Icons.search),
|
|
label: Text(l10n.searchGames),
|
|
),
|
|
),
|
|
if (_error != null) ...[
|
|
const SizedBox(height: 16),
|
|
_buildError(_error!),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ==================== PASO 2: DISCOVERY ====================
|
|
|
|
Widget _buildDiscovery(
|
|
BuildContext context,
|
|
AppLocalizations l10n,
|
|
ServicioNearby nearby,
|
|
) {
|
|
final hosts = nearby.hostsEncontrados;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(l10n.joinGameTitle),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () async {
|
|
await nearby.pararBusqueda();
|
|
setState(() {
|
|
_buscando = false;
|
|
_conectando = false;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
body: FondoFarolero(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
children: [
|
|
// Estado
|
|
if (_conectando) ...[
|
|
const CircularProgressIndicator(color: TemaApp.colorAcento),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'${l10n.connectingTo} ${_salaSeleccionada ?? ""}...',
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
const SizedBox(height: 24),
|
|
] else ...[
|
|
// Buscando
|
|
Row(
|
|
children: [
|
|
const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: TemaApp.colorNaranja,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
l10n.searchingGames,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
|
|
// Lista de hosts encontrados
|
|
Expanded(
|
|
child: hosts.isEmpty && !_conectando
|
|
? Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Text('📡', style: TextStyle(fontSize: 48)),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
l10n.noGamesFound,
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.noGamesFoundHint,
|
|
style: Theme.of(context).textTheme.bodyMedium
|
|
?.copyWith(color: Colors.grey),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: ListView.builder(
|
|
itemCount: hosts.length,
|
|
itemBuilder: (context, index) {
|
|
final entry = hosts.entries.elementAt(index);
|
|
return _buildHostTile(entry.key, entry.value);
|
|
},
|
|
),
|
|
),
|
|
|
|
if (_error != null) ...[
|
|
_buildError(_error!),
|
|
const SizedBox(height: 12),
|
|
],
|
|
|
|
// Fallback: escanear QR
|
|
if (!_conectando) ...[
|
|
const Divider(),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.orScanQR,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: Colors.grey),
|
|
),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: OutlinedButton.icon(
|
|
onPressed: _abrirEscaner,
|
|
icon: const Icon(Icons.qr_code_scanner),
|
|
label: Text(l10n.scanQR),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHostTile(String endpointId, String nombre) {
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
child: Material(
|
|
color: TemaApp.colorTarjeta,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(12),
|
|
onTap: _conectando ? null : () => _conectarAHost(endpointId, nombre),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
|
child: Row(
|
|
children: [
|
|
const Text('🎭', style: TextStyle(fontSize: 28)),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
nombre,
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
Text(
|
|
'Toca para unirte',
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Icon(
|
|
Icons.arrow_forward_ios,
|
|
size: 16,
|
|
color: Colors.grey,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ==================== ESCÁNER QR ====================
|
|
|
|
Widget _buildEscaner(BuildContext context, AppLocalizations l10n) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(l10n.scanQR),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () => setState(() => _escaneandoQR = false),
|
|
),
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
MobileScanner(onDetect: _onQRDetectado),
|
|
Positioned(
|
|
bottom: 0,
|
|
left: 0,
|
|
right: 0,
|
|
child: Container(
|
|
padding: const EdgeInsets.all(24),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
Colors.transparent,
|
|
Colors.black.withValues(alpha: 0.8),
|
|
],
|
|
),
|
|
),
|
|
child: Text(
|
|
l10n.scanHostQR,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleLarge?.copyWith(color: Colors.white),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ==================== ESPERA ====================
|
|
|
|
Widget _buildPantallaEspera(BuildContext context, AppLocalizations l10n) {
|
|
final nearby = context.watch<ServicioNearby>();
|
|
final usuarios = nearby.usuarios;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(_salaSeleccionada ?? l10n.joinGameTitle),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () async {
|
|
final nearby = context.read<ServicioNearby>();
|
|
await nearby.desconectar();
|
|
if (context.mounted) {
|
|
setState(() {
|
|
_buscando = false;
|
|
_conectando = false;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
body: FondoFarolero(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
// Estado de conexión
|
|
const Text('✅', style: TextStyle(fontSize: 64)),
|
|
const SizedBox(height: 24),
|
|
Text(
|
|
l10n.connectedWaiting,
|
|
style: Theme.of(context).textTheme.headlineMedium,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'${l10n.yourName}: ${_nombreController.text}',
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
const SizedBox(height: 32),
|
|
const CircularProgressIndicator(color: TemaApp.colorNaranja),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
l10n.waitingForHost,
|
|
style: Theme.of(context).textTheme.bodyMedium,
|
|
),
|
|
const SizedBox(height: 32),
|
|
|
|
// Pool de usuarios disponibles (tarea 3.4)
|
|
if (usuarios.isNotEmpty) ...[
|
|
const Divider(),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
l10n.availableProfiles,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
children: [
|
|
// Opción crear nuevo usuario (tarea 3.5)
|
|
ListTile(
|
|
leading: const Icon(
|
|
Icons.add,
|
|
color: TemaApp.colorAcento,
|
|
),
|
|
title: Text(l10n.createNewUser),
|
|
onTap: () => _crearNuevoUsuario(context),
|
|
),
|
|
const Divider(),
|
|
// Usuarios existentes
|
|
...usuarios.map(_buildUsuarioSalaTile),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
] else ...[
|
|
const SizedBox(height: 16),
|
|
// Si no hay usuarios, permitir crear uno
|
|
OutlinedButton.icon(
|
|
onPressed: () => _crearNuevoUsuario(context),
|
|
icon: const Icon(Icons.person_add),
|
|
label: Text(l10n.createNewUser),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Crea un nuevo usuario y lo envía al host
|
|
Future<void> _crearNuevoUsuario(BuildContext context) async {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final controller = TextEditingController();
|
|
final nearby = context.read<ServicioNearby>();
|
|
final perfil = context.read<ServicioPerfilUsuario>().perfil;
|
|
controller.text = perfil.nombre;
|
|
|
|
final nombre = await showDialog<String>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(l10n.createNewUser),
|
|
content: TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
textCapitalization: TextCapitalization.words,
|
|
decoration: InputDecoration(
|
|
hintText: l10n.yourName,
|
|
prefixIcon: const Icon(Icons.person),
|
|
),
|
|
onSubmitted: (v) => Navigator.pop(ctx, v),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
child: Text(l10n.cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, controller.text),
|
|
child: Text(l10n.accept),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (nombre != null && nombre.trim().isNotEmpty) {
|
|
await nearby.crearUsuarioSala(
|
|
nombre.trim(),
|
|
seleccionar: true,
|
|
nick: perfil.nick,
|
|
avatar: perfil.avatarAsset,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Env?a el usuario seleccionado/creado al host
|
|
void _enviarUsuarioAlHost(Usuario usuario) {
|
|
final nearby = context.read<ServicioNearby>();
|
|
nearby.seleccionarUsuarioSala(usuario.id);
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('${usuario.nombre} seleccionado')));
|
|
}
|
|
|
|
Widget _buildUsuarioSalaTile(Usuario usuario) {
|
|
final nearby = context.read<ServicioNearby>();
|
|
final miClientId = nearby.miClientId;
|
|
final seleccionadoPorMi = usuario.clienteIdSeleccionado == miClientId;
|
|
final seleccionadoPorOtro =
|
|
usuario.estaSeleccionado && usuario.clienteIdSeleccionado != miClientId;
|
|
|
|
return ListTile(
|
|
leading: Text(
|
|
usuario.avatar ?? '??',
|
|
style: const TextStyle(fontSize: 24),
|
|
),
|
|
title: Text(usuario.nombre),
|
|
subtitle: Text(
|
|
seleccionadoPorMi
|
|
? 'Seleccionado por este m?vil'
|
|
: seleccionadoPorOtro
|
|
? 'No disponible'
|
|
: 'Disponible',
|
|
),
|
|
trailing: seleccionadoPorMi
|
|
? IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => nearby.liberarUsuarioSala(usuario.id),
|
|
)
|
|
: null,
|
|
enabled: !seleccionadoPorOtro,
|
|
onTap: seleccionadoPorOtro
|
|
? null
|
|
: () {
|
|
if (seleccionadoPorMi) {
|
|
nearby.liberarUsuarioSala(usuario.id);
|
|
} else {
|
|
_enviarUsuarioAlHost(usuario);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
// ==================== HELPERS ====================
|
|
|
|
Widget _buildError(String msg) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: TemaApp.colorAcento.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
msg,
|
|
style: const TextStyle(color: TemaApp.colorAcento),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
);
|
|
}
|
|
}
|