From 28554207ef4d1a95ce047f16ce3f6d24742d2b38 Mon Sep 17 00:00:00 2001 From: freetlab Date: Sun, 10 May 2026 02:07:08 +0200 Subject: [PATCH] Resumen de fin de partida espectacular --- lib/pantallas/pantalla_fin_partida.dart | 1059 +++++++++++++---- .../pantalla_fin_partida_online.dart | 135 ++- pubspec.yaml | 2 + 3 files changed, 894 insertions(+), 302 deletions(-) diff --git a/lib/pantallas/pantalla_fin_partida.dart b/lib/pantallas/pantalla_fin_partida.dart index 8b1aa8e..d4de3e7 100644 --- a/lib/pantallas/pantalla_fin_partida.dart +++ b/lib/pantallas/pantalla_fin_partida.dart @@ -1,9 +1,16 @@ -import 'package:flutter/material.dart'; +import 'dart:math' as math; + +import 'package:confetti/confetti.dart'; import 'package:farolero/l10n/generated/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:provider/provider.dart'; + import '../estado/estado_juego.dart'; -import '../modelos/palabra.dart'; import '../modelos/gamificacion_usuario.dart'; +import '../modelos/jugador.dart'; +import '../modelos/palabra.dart'; +import '../modelos/partida.dart'; import '../servicios/servicio_historial_partidas.dart'; import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_perfil_usuario.dart'; @@ -22,6 +29,19 @@ class PantallaFinPartida extends StatefulWidget { class _PantallaFinPartidaState extends State { bool _guardada = false; ProgresoGamificacionUsuario? _progreso; + late final ConfettiController _confetti; + + @override + void initState() { + super.initState(); + _confetti = ConfettiController(duration: const Duration(seconds: 3)); + } + + @override + void dispose() { + _confetti.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -31,248 +51,222 @@ class _PantallaFinPartidaState extends State { if (partida == null) return const SizedBox.shrink(); final ganaronJugadores = partida.ganador == 'jugadores'; - final impostores = - partida.jugadores.where((j) => j.esImpostor).toList(); - if (!_guardada && partida.ganador != null) { - _guardada = true; - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (mounted) { - final historial = context.read(); - final perfil = context.read(); - await historial.guardarPartida(partida); - final progreso = await perfil.registrarPartidaCompletada( - victoria: ganaronJugadores, - ); - if (mounted) setState(() => _progreso = progreso); - } - }); - } + final impostores = partida.jugadores.where((j) => j.esImpostor).toList(); + _registrarResultadoSiHaceFalta(context, partida, ganaronJugadores); return Scaffold( + extendBodyBehindAppBar: true, appBar: AppBar( title: Text(l10n.gameOver), automaticallyImplyLeading: false, + backgroundColor: Colors.transparent, + elevation: 0, ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(24), - child: Column( + body: FondoFarolero( + intenso: true, + child: Stack( children: [ - // Ganador - Container( - width: double.infinity, - padding: const EdgeInsets.all(32), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: ganaronJugadores - ? [TemaApp.colorVerde.withValues(alpha: 0.3), TemaApp.colorVerde.withValues(alpha: 0.1)] - : [TemaApp.colorAcento.withValues(alpha: 0.3), TemaApp.colorAcento.withValues(alpha: 0.1)], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: ganaronJugadores - ? TemaApp.colorVerde - : TemaApp.colorAcento, + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: _RecompensaFondoPainter( + color: ganaronJugadores + ? TemaApp.colorVerde + : TemaApp.colorAcento, + ), ), ), - child: Column( - children: [ - Text( - ganaronJugadores ? '๐ŸŽ‰' : '๐ŸŽญ', - style: const TextStyle(fontSize: 64), - ), - const SizedBox(height: 16), - Text( - ganaronJugadores - ? l10n.playersWin - : l10n.impostorsWin, - style: Theme.of(context) - .textTheme - .headlineMedium - ?.copyWith( - color: ganaronJugadores - ? TemaApp.colorVerde - : TemaApp.colorAcento, - ), - textAlign: TextAlign.center, - ), + ), + Align( + alignment: Alignment.topCenter, + child: ConfettiWidget( + confettiController: _confetti, + blastDirectionality: BlastDirectionality.explosive, + emissionFrequency: 0.06, + numberOfParticles: 18, + gravity: 0.22, + colors: const [ + TemaApp.colorDorado, + TemaApp.colorNaranja, + TemaApp.colorAcento, + Color(0xFFFFECBE), ], ), ), - const SizedBox(height: 24), - if (_progreso != null) ...[ - _TarjetaProgresoGamificacion(progreso: _progreso!), - const SizedBox(height: 16), - ], - - // Palabra secreta - Card( - child: Padding( - padding: const EdgeInsets.all(20), + SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 24), child: Column( children: [ - Text(l10n.theSecretWordWas, - style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Text( - partida.palabraSecreta, - style: Theme.of(context) - .textTheme - .headlineLarge - ?.copyWith( - color: TemaApp.colorNaranja, - fontSize: 32, - ), + _HeroResultado( + titulo: + ganaronJugadores ? l10n.playersWin : l10n.impostorsWin, + icono: ganaronJugadores + ? Icons.emoji_events + : Icons.theater_comedy, + color: ganaronJugadores + ? TemaApp.colorVerde + : TemaApp.colorAcento, ), - const SizedBox(height: 4), - Text( - l10n.categoryLabel(BancoPalabras.nombreBonitoCategoria(partida.categoriaReal, l10n)), - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - ), - ), - const SizedBox(height: 16), - - // Impostores - Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - children: [ - Text( - impostores.length == 1 ? l10n.theImpostorWas : l10n.theImpostorsWere, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - ...impostores.map((j) => Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('๐ŸŽญ ', - style: TextStyle(fontSize: 18)), - Text( - j.nombre, - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(color: TemaApp.colorAcento), - ), - if (j.eliminado) ...[ - const SizedBox(width: 8), - const Text('๐Ÿ’€', - style: TextStyle(fontSize: 16)), - ], - ], + const SizedBox(height: 18), + if (_progreso == null) + const _TarjetaRecompensaCargando() + else + _TarjetaProgresoGamificacion(progreso: _progreso!), + const SizedBox(height: 16), + _TarjetaSecreto( + palabra: partida.palabraSecreta, + categoria: BancoPalabras.nombreBonitoCategoria( + partida.categoriaReal, + l10n, ), - )), + ), + const SizedBox(height: 16), + _TarjetaImpostores( + titulo: impostores.length == 1 + ? l10n.theImpostorWas + : l10n.theImpostorsWere, + impostores: impostores, + ), + const SizedBox(height: 16), + if (partida.historialVotaciones.isNotEmpty) + _TarjetaHistorialVotos(partida: partida, l10n: l10n), + const SizedBox(height: 24), + _BotonesFinPartida( + estado: estado, + onPrincipal: () async { + await context.read().desconectar(); + estado.limpiar(); + if (!context.mounted) return; + Navigator.pushAndRemoveUntil( + context, + MaterialPageRoute( + builder: (_) => const PantallaPrincipal(), + ), + (route) => false, + ); + }, + ), + const SizedBox(height: 16), ], ), ), ), - const SizedBox(height: 16), - - // Estadรญsticas de votaciones - if (partida.historialVotaciones.isNotEmpty) - Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.votingHistory, - style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 12), - ...partida.historialVotaciones - .asMap() - .entries - .map((entrada) { - final ronda = entrada.key + 1; - final resultado = entrada.value; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${l10n.roundElimination(ronda, resultado.eliminadoNombre)} ${resultado.eraImpostor ? '๐ŸŽญ' : '๐Ÿ˜‡'}', - style: TextStyle( - fontWeight: FontWeight.bold, - color: resultado.eraImpostor - ? TemaApp.colorVerde - : TemaApp.colorAcento, - ), - ), - ...resultado.votos.entries.map((v) { - final votante = partida.jugadores - .firstWhere((j) => j.id == v.key); - final votado = partida.jugadores - .firstWhere((j) => j.id == v.value); - return Text( - ' ${votante.nombre} โ†’ ${votado.nombre}', - style: Theme.of(context) - .textTheme - .bodyMedium, - ); - }), - ], - ), - ); - }), - ], - ), - ), - ), - const SizedBox(height: 24), - - // Botones - SizedBox( - width: double.infinity, - height: 56, - child: ElevatedButton.icon( - onPressed: () { - estado.revancha(); - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (_) => const PantallaVerPalabra(), - ), - ); - }, - icon: const Icon(Icons.replay), - label: Text(l10n.rematch), - ), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - height: 56, - child: OutlinedButton.icon( - onPressed: () async { - await context.read().desconectar(); - estado.limpiar(); - if (!context.mounted) return; - Navigator.pushAndRemoveUntil( - context, - MaterialPageRoute( - builder: (_) => const PantallaPrincipal(), - ), - (route) => false, - ); - }, - icon: const Icon(Icons.home), - label: Text(l10n.mainMenu), - ), - ), - const SizedBox(height: 16), ], ), ), ); } + + void _registrarResultadoSiHaceFalta( + BuildContext context, + Partida partida, + bool ganaronJugadores, + ) { + if (_guardada || partida.ganador == null) return; + _guardada = true; + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + final historial = context.read(); + final perfil = context.read(); + await historial.guardarPartida(partida); + final progreso = await perfil.registrarPartidaCompletada( + victoria: ganaronJugadores, + ); + if (!mounted) return; + setState(() => _progreso = progreso); + if (progreso.nuevasMedallas.isNotEmpty || progreso.incrementoFuego > 0) { + _confetti.play(); + } + }); + } +} + +class _HeroResultado extends StatelessWidget { + final String titulo; + final IconData icono; + final Color color; + + const _HeroResultado({ + required this.titulo, + required this.icono, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.center, + children: [ + SizedBox( + height: 230, + width: double.infinity, + child: CustomPaint(painter: _RayosRecompensaPainter(color: color)), + ), + Column( + children: [ + Text( + 'RESULTADOS', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: TemaApp.colorDorado, + fontWeight: FontWeight.w900, + letterSpacing: 4, + ), + ).animate().fadeIn(duration: 350.ms).slideY(begin: -0.25), + const SizedBox(height: 12), + Container( + width: 116, + height: 116, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + color.withValues(alpha: 0.55), + TemaApp.colorSuperficie, + Colors.black.withValues(alpha: 0.72), + ], + ), + border: Border.all(color: TemaApp.colorDorado, width: 3), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.72), + blurRadius: 38, + spreadRadius: 4, + ), + BoxShadow( + color: TemaApp.colorDorado.withValues(alpha: 0.32), + blurRadius: 22, + spreadRadius: 1, + ), + ], + ), + child: Icon(icono, size: 64, color: TemaApp.colorDorado), + ) + .animate() + .scale( + begin: const Offset(0.55, 0.55), + duration: 520.ms, + curve: Curves.elasticOut, + ) + .shimmer(delay: 700.ms, duration: 1500.ms), + const SizedBox(height: 14), + Text( + titulo.toUpperCase(), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: color, + fontWeight: FontWeight.w900, + letterSpacing: 1.2, + shadows: [ + Shadow(color: color.withValues(alpha: 0.85), blurRadius: 22), + ], + ), + ).animate().fadeIn(delay: 180.ms).slideY(begin: 0.25), + ], + ), + ], + ); + } } class _TarjetaProgresoGamificacion extends StatelessWidget { @@ -283,50 +277,597 @@ class _TarjetaProgresoGamificacion extends StatelessWidget { @override Widget build(BuildContext context) { final nuevas = progreso.nuevasMedallas; - return Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Tu progreso', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 12), - Row( - children: [ - const Text('๐Ÿ”ฅ', style: TextStyle(fontSize: 24)), - const SizedBox(width: 10), - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: progreso.despues.fuego / 100, - minHeight: 8, - color: TemaApp.colorNaranja, - backgroundColor: Colors.black.withValues(alpha: 0.35), - ), - ), + final antes = progreso.antes.fuego.clamp(0, 100); + final despues = progreso.despues.fuego.clamp(0, 100); + + return _PanelRecompensa( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.local_fire_department, color: TemaApp.colorNaranja), + const SizedBox(width: 8), + Expanded( + child: Text( + 'RECOMPENSAS DE PARTIDA', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: TemaApp.colorDorado, + fontWeight: FontWeight.w900, + letterSpacing: 1, + ), ), - const SizedBox(width: 10), - Text('${progreso.despues.fuego}%'), - ], - ), - const SizedBox(height: 8), - Text( - progreso.incrementoFuego >= 0 - ? '+${progreso.incrementoFuego}% de fuego' - : '${progreso.incrementoFuego}% de fuego', - style: Theme.of(context).textTheme.bodySmall, - ), - if (nuevas.isNotEmpty) ...[ - const SizedBox(height: 12), - Text('Nuevas medallas', - style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: 6), - MedallasCompactasFarolero(ids: nuevas, max: nuevas.length), + ), + _DeltaFuego(valor: progreso.incrementoFuego), ], + ), + const SizedBox(height: 16), + _BarraFuegoPremium(antes: antes, despues: despues), + const SizedBox(height: 16), + if (nuevas.isEmpty) + Text( + 'Sin medallas nuevas esta vez. Segu? acumulando fuego.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: TemaApp.colorTextoSecundario, + ), + ) + else ...[ + Text( + 'NUEVAS MEDALLAS', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: TemaApp.colorDorado, + fontWeight: FontWeight.w800, + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + alignment: WrapAlignment.center, + children: [for (final id in nuevas) _MedallaDesbloqueada(id: id)], + ), ], - ), + ], + ), + ).animate().fadeIn(delay: 250.ms).slideY(begin: 0.12); + } +} + +class _TarjetaRecompensaCargando extends StatelessWidget { + const _TarjetaRecompensaCargando(); + + @override + Widget build(BuildContext context) { + return _PanelRecompensa( + child: Row( + children: [ + const SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 3, + color: TemaApp.colorNaranja, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Calculando recompensas...', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: TemaApp.colorDorado, + ), + ), + ), + ], ), ); } } + +class _PanelRecompensa extends StatelessWidget { + final Widget child; + + const _PanelRecompensa({required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFF111B28).withValues(alpha: 0.96), + const Color(0xFF180D22).withValues(alpha: 0.94), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: TemaApp.colorDorado.withValues(alpha: 0.52)), + boxShadow: [ + BoxShadow( + color: TemaApp.colorNaranja.withValues(alpha: 0.18), + blurRadius: 32, + offset: const Offset(0, 16), + ), + BoxShadow( + color: Colors.black.withValues(alpha: 0.42), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: child, + ); + } +} + +class _DeltaFuego extends StatelessWidget { + final int valor; + + const _DeltaFuego({required this.valor}); + + @override + Widget build(BuildContext context) { + final texto = valor >= 0 ? '+$valor' : '$valor'; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: TemaApp.colorNaranja.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: TemaApp.colorNaranja), + boxShadow: [ + BoxShadow( + color: TemaApp.colorNaranja.withValues(alpha: 0.28), + blurRadius: 18, + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.local_fire_department, + color: TemaApp.colorNaranja, size: 18), + const SizedBox(width: 4), + Text( + texto, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: TemaApp.colorDorado, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + ).animate().scale(delay: 520.ms, duration: 420.ms, curve: Curves.elasticOut); + } +} + +class _BarraFuegoPremium extends StatelessWidget { + final int antes; + final int despues; + + const _BarraFuegoPremium({required this.antes, required this.despues}); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: antes / 100, end: despues / 100), + duration: const Duration(milliseconds: 1300), + curve: Curves.easeOutCubic, + builder: (context, value, _) { + final normalizado = value.clamp(0.0, 1.0).toDouble(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Fuego', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const Spacer(), + Text( + '${(normalizado * 100).round()}%', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: TemaApp.colorDorado, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: Container( + height: 22, + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.42), + border: Border.all( + color: TemaApp.colorDorado.withValues(alpha: 0.34), + ), + ), + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: normalizado, + child: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFFE53935), + TemaApp.colorNaranja, + TemaApp.colorDorado, + Color(0xFFFFECBE), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +class _MedallaDesbloqueada extends StatelessWidget { + final String id; + + const _MedallaDesbloqueada({required this.id}); + + @override + Widget build(BuildContext context) { + final medalla = EstadisticasPerfilUsuario.catalogoMedallas[id]; + if (medalla == null) return const SizedBox.shrink(); + return Container( + width: 94, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.28), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: TemaApp.colorDorado.withValues(alpha: 0.48)), + ), + child: Column( + children: [ + Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 70, + height: 70, + child: CustomPaint( + painter: _MiniBurstPainter(color: TemaApp.colorDorado), + ), + ), + Image.asset( + medalla.assetPath, + width: 58, + height: 58, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => + Text(medalla.emoji, style: const TextStyle(fontSize: 32)), + ), + ], + ), + const SizedBox(height: 6), + Text( + medalla.nombre, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: TemaApp.colorTexto, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ) + .animate() + .scale(duration: 520.ms, curve: Curves.elasticOut) + .shimmer(delay: 650.ms, duration: 1200.ms); + } +} + +class _TarjetaSecreto extends StatelessWidget { + final String palabra; + final String categoria; + + const _TarjetaSecreto({required this.palabra, required this.categoria}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return _PanelRecompensa( + child: Column( + children: [ + Text(l10n.theSecretWordWas, + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + palabra.toUpperCase(), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineLarge?.copyWith( + color: TemaApp.colorNaranja, + fontSize: 36, + fontWeight: FontWeight.w900, + shadows: [ + Shadow( + color: TemaApp.colorNaranja.withValues(alpha: 0.55), + blurRadius: 18, + ), + ], + ), + ), + const SizedBox(height: 4), + Text( + l10n.categoryLabel(categoria), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: TemaApp.colorTextoSecundario, + ), + ), + ], + ), + ).animate().fadeIn(delay: 380.ms).slideY(begin: 0.1); + } +} + +class _TarjetaImpostores extends StatelessWidget { + final String titulo; + final List impostores; + + const _TarjetaImpostores({required this.titulo, required this.impostores}); + + @override + Widget build(BuildContext context) { + return _PanelRecompensa( + child: Column( + children: [ + Text(titulo, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 10), + ...impostores.map( + (j) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.theater_comedy, + size: 20, color: TemaApp.colorAcento), + const SizedBox(width: 8), + Text( + j.nombre, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: TemaApp.colorAcento), + ), + if (j.eliminado) ...[ + const SizedBox(width: 8), + const Icon(Icons.close, + size: 16, color: TemaApp.colorTextoSecundario), + ], + ], + ), + ), + ), + ], + ), + ).animate().fadeIn(delay: 450.ms); + } +} + +class _TarjetaHistorialVotos extends StatelessWidget { + final Partida partida; + final AppLocalizations l10n; + + const _TarjetaHistorialVotos({required this.partida, required this.l10n}); + + @override + Widget build(BuildContext context) { + return _PanelRecompensa( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.votingHistory, + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + ...partida.historialVotaciones.asMap().entries.map((entrada) { + final ronda = entrada.key + 1; + final resultado = entrada.value; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.roundElimination(ronda, resultado.eliminadoNombre), + style: TextStyle( + fontWeight: FontWeight.bold, + color: resultado.eraImpostor + ? TemaApp.colorVerde + : TemaApp.colorAcento, + ), + ), + ...resultado.votos.entries.map((v) { + final votante = + partida.jugadores.firstWhere((j) => j.id == v.key); + final votado = + partida.jugadores.firstWhere((j) => j.id == v.value); + return Text( + ' ${votante.nombre} -> ${votado.nombre}', + style: Theme.of(context).textTheme.bodyMedium, + ); + }), + ], + ), + ); + }), + ], + ), + ); + } +} + +class _BotonesFinPartida extends StatelessWidget { + final EstadoJuego estado; + final VoidCallback onPrincipal; + + const _BotonesFinPartida({required this.estado, required this.onPrincipal}); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + return Column( + children: [ + SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () { + estado.revancha(); + Navigator.pushReplacement( + context, + MaterialPageRoute(builder: (_) => const PantallaVerPalabra()), + ); + }, + icon: const Icon(Icons.replay), + label: Text(l10n.rematch), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + height: 56, + child: OutlinedButton.icon( + onPressed: onPrincipal, + icon: const Icon(Icons.home), + label: Text(l10n.mainMenu), + ), + ), + ], + ); + } +} + +class _RecompensaFondoPainter extends CustomPainter { + final Color color; + + const _RecompensaFondoPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..isAntiAlias = true; + paint.shader = RadialGradient( + colors: [color.withValues(alpha: 0.28), Colors.transparent], + ).createShader( + Rect.fromCircle( + center: Offset(size.width * 0.5, size.height * 0.22), + radius: size.width * 0.72, + ), + ); + canvas.drawCircle( + Offset(size.width * 0.5, size.height * 0.22), + size.width * 0.72, + paint, + ); + paint.shader = null; + paint.color = TemaApp.colorDorado.withValues(alpha: 0.10); + for (var i = 0; i < 34; i++) { + final x = (i * 83 % math.max(size.width, 1)).toDouble(); + final y = (i * 137 % math.max(size.height, 1)).toDouble(); + canvas.drawCircle(Offset(x, y), 1.2 + (i % 4), paint); + } + } + + @override + bool shouldRepaint(covariant _RecompensaFondoPainter oldDelegate) { + return oldDelegate.color != color; + } +} + +class _RayosRecompensaPainter extends CustomPainter { + final Color color; + + const _RayosRecompensaPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height * 0.5); + final paint = Paint()..isAntiAlias = true; + for (var i = 0; i < 28; i++) { + final angle = (math.pi * 2 / 28) * i; + final inner = Offset( + center.dx + math.cos(angle - 0.035) * 34, + center.dy + math.sin(angle - 0.035) * 34, + ); + final outer = Offset( + center.dx + math.cos(angle) * size.width * 0.46, + center.dy + math.sin(angle) * size.width * 0.46, + ); + final inner2 = Offset( + center.dx + math.cos(angle + 0.035) * 34, + center.dy + math.sin(angle + 0.035) * 34, + ); + paint.color = (i.isEven ? TemaApp.colorDorado : color) + .withValues(alpha: i.isEven ? 0.16 : 0.09); + canvas.drawPath( + Path() + ..moveTo(inner.dx, inner.dy) + ..lineTo(outer.dx, outer.dy) + ..lineTo(inner2.dx, inner2.dy) + ..close(), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant _RayosRecompensaPainter oldDelegate) { + return oldDelegate.color != color; + } +} + +class _MiniBurstPainter extends CustomPainter { + final Color color; + + const _MiniBurstPainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final paint = Paint() + ..isAntiAlias = true + ..color = color.withValues(alpha: 0.22) + ..strokeCap = StrokeCap.round; + for (var i = 0; i < 16; i++) { + final angle = math.pi * 2 * i / 16; + paint.strokeWidth = i.isEven ? 3 : 1.4; + canvas.drawLine( + center, + Offset( + center.dx + math.cos(angle) * size.width * 0.48, + center.dy + math.sin(angle) * size.height * 0.48, + ), + paint, + ); + } + paint + ..style = PaintingStyle.fill + ..shader = RadialGradient( + colors: [color.withValues(alpha: 0.38), Colors.transparent], + ).createShader(Rect.fromCircle(center: center, radius: size.width * 0.42)); + canvas.drawCircle(center, size.width * 0.42, paint); + } + + @override + bool shouldRepaint(covariant _MiniBurstPainter oldDelegate) { + return oldDelegate.color != color; + } +} diff --git a/lib/pantallas/pantalla_fin_partida_online.dart b/lib/pantallas/pantalla_fin_partida_online.dart index a12e70b..38bccc1 100644 --- a/lib/pantallas/pantalla_fin_partida_online.dart +++ b/lib/pantallas/pantalla_fin_partida_online.dart @@ -1,5 +1,6 @@ -import 'package:flutter/material.dart'; import 'package:farolero/l10n/generated/app_localizations.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:provider/provider.dart'; import '../modelos/inicio_partida_multijugador.dart'; import '../modelos/gamificacion_usuario.dart'; @@ -290,50 +291,98 @@ class _TarjetaProgresoGamificacion extends StatelessWidget { @override Widget build(BuildContext context) { final nuevas = progreso.nuevasMedallas; - return Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Tu progreso', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 12), - Row( - children: [ - const Text('๐Ÿ”ฅ', style: TextStyle(fontSize: 24)), - const SizedBox(width: 10), - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: progreso.despues.fuego / 100, - minHeight: 8, - color: TemaApp.colorNaranja, - backgroundColor: Colors.black.withValues(alpha: 0.35), - ), - ), - ), - const SizedBox(width: 10), - Text('${progreso.despues.fuego}%'), - ], - ), - const SizedBox(height: 8), - Text( - progreso.incrementoFuego >= 0 - ? '+${progreso.incrementoFuego}% de fuego' - : '${progreso.incrementoFuego}% de fuego', - style: Theme.of(context).textTheme.bodySmall, - ), - if (nuevas.isNotEmpty) ...[ - const SizedBox(height: 12), - Text('Nuevas medallas', - style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: 6), - MedallasCompactasFarolero(ids: nuevas, max: nuevas.length), - ], + return Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFF111B28).withValues(alpha: 0.96), + const Color(0xFF180D22).withValues(alpha: 0.94), ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: TemaApp.colorDorado.withValues(alpha: 0.52)), + boxShadow: [ + BoxShadow( + color: TemaApp.colorNaranja.withValues(alpha: 0.18), + blurRadius: 32, + offset: const Offset(0, 16), + ), + ], ), - ); + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.local_fire_department, + color: TemaApp.colorNaranja), + const SizedBox(width: 8), + Expanded( + child: Text( + 'RECOMPENSAS DE PARTIDA', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: TemaApp.colorDorado, + fontWeight: FontWeight.w900, + letterSpacing: 1, + ), + ), + ), + Text( + progreso.incrementoFuego >= 0 + ? '+${progreso.incrementoFuego}' + : '${progreso.incrementoFuego}', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: TemaApp.colorDorado, + fontWeight: FontWeight.w900, + ), + ), + ], + ), + const SizedBox(height: 12), + TweenAnimationBuilder( + tween: Tween( + begin: progreso.antes.fuego.clamp(0, 100) / 100, + end: progreso.despues.fuego.clamp(0, 100) / 100, + ), + duration: const Duration(milliseconds: 1300), + curve: Curves.easeOutCubic, + builder: (context, value, _) => ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: value.clamp(0.0, 1.0).toDouble(), + minHeight: 18, + backgroundColor: Colors.black.withValues(alpha: 0.35), + valueColor: const AlwaysStoppedAnimation(TemaApp.colorNaranja), + ), + ), + ), + const SizedBox(height: 8), + Text( + '${progreso.despues.fuego}% de fuego', + style: Theme.of(context).textTheme.bodySmall, + ), + if (nuevas.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + 'NUEVAS MEDALLAS', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: TemaApp.colorDorado, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 6), + MedallasCompactasFarolero(ids: nuevas, max: nuevas.length), + ], + ], + ), + ) + .animate() + .fadeIn(duration: 360.ms) + .slideY(begin: 0.12) + .shimmer(delay: 700.ms, duration: 1200.ms); } } diff --git a/pubspec.yaml b/pubspec.yaml index 67a0857..83ec2ad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,8 @@ dependencies: google_fonts: ^6.2.1 nearby_connections: ^4.0.0 permission_handler: ^12.0.1 + flutter_animate: ^4.5.2 + confetti: ^0.8.0 dev_dependencies: flutter_test: