Files
farolero/lib/pantallas/pantalla_fin_partida_online.dart
T
FreeTLab 863690168c feat(multidispositivo): reconnection, impostor awareness and protocol fixes
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.
2026-07-25 20:36:32 +02:00

274 lines
9.6 KiB
Dart

import 'package:confetti/confetti.dart';
import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../modelos/gamificacion_usuario.dart';
import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/jugador.dart';
import '../modelos/palabra.dart';
import '../modelos/snapshot_partida_online.dart';
import '../servicios/servicio_historial_partidas.dart';
import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/componentes_resultado_farolero.dart';
import '../tema/tema_app.dart';
import 'pantalla_notas_online.dart';
import 'pantalla_principal.dart';
import 'pantalla_revision_palabra.dart';
class PantallaFinPartidaOnline extends StatefulWidget {
final SnapshotPartidaOnline snapshot;
final List<JugadorInicioPartida> jugadoresControlados;
final String? pistaImpostor;
const PantallaFinPartidaOnline({
super.key,
required this.snapshot,
required this.jugadoresControlados,
this.pistaImpostor,
});
@override
State<PantallaFinPartidaOnline> createState() =>
_PantallaFinPartidaOnlineState();
}
class _PantallaFinPartidaOnlineState extends State<PantallaFinPartidaOnline> {
bool _guardada = false;
ProgresoGamificacionUsuario? _progreso;
late final ConfettiController _confetti;
@override
void initState() {
super.initState();
_confetti = ConfettiController(duration: const Duration(seconds: 5));
Future.delayed(const Duration(milliseconds: 450), () {
if (mounted) _confetti.play();
});
}
@override
void dispose() {
_confetti.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final snapshot = widget.snapshot;
final ganaronJugadores = snapshot.ganador == 'jugadores';
final color = ganaronJugadores ? TemaApp.colorVerde : TemaApp.colorAcento;
_registrarResultadoSiHaceFalta(context, snapshot);
return Scaffold(
extendBodyBehindAppBar: true,
backgroundColor: const Color(0xFF05070D),
appBar: AppBar(
title: Text(l10n.gameOver),
automaticallyImplyLeading: false,
backgroundColor: Colors.transparent,
elevation: 0,
actions: _acciones(context, l10n, snapshot),
),
body: FondoFarolero(
intenso: true,
child: Stack(
children: [
Positioned.fill(
child: IgnorePointer(
child: CustomPaint(
painter: EscenarioFinPartidaFaroleroPainter(color: color),
),
),
),
Align(
alignment: Alignment.topCenter,
child: ConfettiWidget(
confettiController: _confetti,
blastDirectionality: BlastDirectionality.explosive,
emissionFrequency: 0.09,
numberOfParticles: 28,
gravity: 0.18,
colors: const [
TemaApp.colorDorado,
TemaApp.colorNaranja,
TemaApp.colorAcento,
Color(0xFFFFECBE),
],
),
),
Positioned.fill(
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.10),
Colors.black.withValues(alpha: 0.52),
],
stops: const [0.0, 0.54, 1.0],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
),
),
SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 72, 20, 28),
child: Column(
children: [
HeroFinalPartidaFarolero(
encabezado: l10n.gameOver,
titulo:
ganaronJugadores ? l10n.playersWin : l10n.impostorsWin,
icono: ganaronJugadores
? Icons.emoji_events
: Icons.theater_comedy,
color: color,
),
const SizedBox(height: 12),
if (_progreso == null)
const TarjetaRecompensaCargandoPremium()
else
TarjetaProgresoGamificacionPremium(progreso: _progreso!),
const SizedBox(height: 18),
if (snapshot.palabraSecreta != null)
TarjetaSecretoPremium(
palabra: snapshot.palabraSecreta!,
categoria: BancoPalabras.nombreBonitoCategoria(
snapshot.categoria,
l10n,
),
),
const SizedBox(height: 18),
TarjetaImpostoresPremium(
titulo: snapshot.impostores.length == 1
? l10n.theImpostorWas
: l10n.theImpostorsWere,
impostores: _impostores(snapshot),
),
const SizedBox(height: 18),
if (snapshot.historialVotaciones.isNotEmpty)
TarjetaHistorialVotosPremium(
historial: snapshot.historialVotaciones,
jugadores: snapshot.jugadores,
),
const SizedBox(height: 24),
BotonFarolero.oscuro(
texto: l10n.mainMenu,
icono: Icons.home,
assetIconPath: 'assets/ui/generated/actions/action_result_trophy.webp',
onPressed: () async {
await context.read<ServicioNearby>().desconectar();
if (!context.mounted) return;
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (_) => const PantallaPrincipal(),
),
(route) => false,
);
},
),
const SizedBox(height: 16),
],
),
),
),
],
),
),
);
}
List<Widget> _acciones(
BuildContext context,
AppLocalizations l10n,
SnapshotPartidaOnline snapshot,
) {
return [
IconButton(
tooltip: l10n.seeYourWord,
icon: IconoFarolero(Icons.visibility),
onPressed: widget.jugadoresControlados.isEmpty
? null
: () => mostrarRevisionPalabraOnline(
context: context,
jugadoresControlados: widget.jugadoresControlados,
pistaImpostor: widget.pistaImpostor,
),
),
IconButton(
tooltip: l10n.notesTitle,
icon: IconoFarolero(Icons.edit_note),
onPressed: snapshot.roomId == null || widget.jugadoresControlados.isEmpty
? null
: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PantallaNotasOnline(
partidaId: snapshot.roomId!,
jugadores: snapshot.jugadores,
autoresControlados: widget.jugadoresControlados,
),
),
),
),
];
}
void _registrarResultadoSiHaceFalta(
BuildContext context,
SnapshotPartidaOnline snapshot,
) {
if (_guardada || snapshot.ganador == null) return;
_guardada = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
final historial = context.read<ServicioHistorialPartidas>();
final perfil = context.read<ServicioPerfilUsuario>();
await historial.guardarSnapshotOnline(snapshot);
final progreso = await perfil.registrarPartidaCompletada(
victoria: _perfilLocalGano(snapshot, widget.jugadoresControlados),
comoImpostor: widget.jugadoresControlados.any((j) => j.esImpostor),
victoriaComoImpostor: snapshot.ganador == 'impostores' &&
widget.jugadoresControlados.any((j) => j.esImpostor),
);
if (!mounted) return;
setState(() => _progreso = progreso);
if (progreso.nuevasMedallas.isNotEmpty || progreso.incrementoFuego > 0) {
_confetti.play();
}
});
}
bool _perfilLocalGano(
SnapshotPartidaOnline snapshot,
List<JugadorInicioPartida> jugadoresControlados,
) {
if (jugadoresControlados.isEmpty) return snapshot.ganador == 'jugadores';
final ganaronImpostores = snapshot.ganador == 'impostores';
return jugadoresControlados.any(
(jugador) => jugador.esImpostor ? ganaronImpostores : !ganaronImpostores,
);
}
List<Jugador> _impostores(SnapshotPartidaOnline snapshot) {
final porNombre = {for (final jugador in snapshot.jugadores) jugador.nombre: jugador};
return snapshot.impostores
.map(
(nombre) =>
porNombre[nombre] ??
Jugador(id: nombre, nombre: nombre, esImpostor: true),
)
.toList();
}
}