diff --git a/assets/medals/brasa.png b/assets/medals/brasa.png new file mode 100644 index 0000000..3095d39 Binary files /dev/null and b/assets/medals/brasa.png differ diff --git a/assets/medals/cincuenta_victorias.png b/assets/medals/cincuenta_victorias.png new file mode 100644 index 0000000..d8e3775 Binary files /dev/null and b/assets/medals/cincuenta_victorias.png differ diff --git a/assets/medals/diez_victorias.png b/assets/medals/diez_victorias.png new file mode 100644 index 0000000..e8b75f5 Binary files /dev/null and b/assets/medals/diez_victorias.png differ diff --git a/assets/medals/habitual.png b/assets/medals/habitual.png new file mode 100644 index 0000000..5d18c66 Binary files /dev/null and b/assets/medals/habitual.png differ diff --git a/assets/medals/impostor_habitual.png b/assets/medals/impostor_habitual.png new file mode 100644 index 0000000..60c138c Binary files /dev/null and b/assets/medals/impostor_habitual.png differ diff --git a/assets/medals/incandescente.png b/assets/medals/incandescente.png new file mode 100644 index 0000000..4531183 Binary files /dev/null and b/assets/medals/incandescente.png differ diff --git a/assets/medals/leyenda.png b/assets/medals/leyenda.png new file mode 100644 index 0000000..71d5e31 Binary files /dev/null and b/assets/medals/leyenda.png differ diff --git a/assets/medals/llama_fuerte.png b/assets/medals/llama_fuerte.png new file mode 100644 index 0000000..bcf402c Binary files /dev/null and b/assets/medals/llama_fuerte.png differ diff --git a/assets/medals/llama_suave.png b/assets/medals/llama_suave.png new file mode 100644 index 0000000..978c189 Binary files /dev/null and b/assets/medals/llama_suave.png differ diff --git a/assets/medals/lobo_faroles.png b/assets/medals/lobo_faroles.png new file mode 100644 index 0000000..80100fd Binary files /dev/null and b/assets/medals/lobo_faroles.png differ diff --git a/assets/medals/novato.png b/assets/medals/novato.png new file mode 100644 index 0000000..81a2fd8 Binary files /dev/null and b/assets/medals/novato.png differ diff --git a/assets/medals/primer_engano.png b/assets/medals/primer_engano.png new file mode 100644 index 0000000..7929695 Binary files /dev/null and b/assets/medals/primer_engano.png differ diff --git a/assets/medals/primera_victoria.png b/assets/medals/primera_victoria.png new file mode 100644 index 0000000..6296787 Binary files /dev/null and b/assets/medals/primera_victoria.png differ diff --git a/assets/medals/veinticinco_victorias.png b/assets/medals/veinticinco_victorias.png new file mode 100644 index 0000000..f4ce62a Binary files /dev/null and b/assets/medals/veinticinco_victorias.png differ diff --git a/assets/medals/veterano.png b/assets/medals/veterano.png new file mode 100644 index 0000000..d55ed6f Binary files /dev/null and b/assets/medals/veterano.png differ diff --git a/lib/modelos/gamificacion_usuario.dart b/lib/modelos/gamificacion_usuario.dart new file mode 100644 index 0000000..b9b471e --- /dev/null +++ b/lib/modelos/gamificacion_usuario.dart @@ -0,0 +1,228 @@ +class MedallaUsuario { + final String id; + final String emoji; + final String assetPath; + final String nombre; + final String descripcion; + + const MedallaUsuario({ + required this.id, + required this.emoji, + required this.assetPath, + required this.nombre, + required this.descripcion, + }); +} + +class ResumenGamificacionUsuario { + final int fuego; + final List medallas; + + const ResumenGamificacionUsuario({ + required this.fuego, + required this.medallas, + }); + + Map toJson() => { + 'fuego': fuego, + 'medallas': medallas, + }; + + factory ResumenGamificacionUsuario.fromJson(Map json) { + return ResumenGamificacionUsuario( + fuego: (json['fuego'] as num?)?.toInt() ?? 0, + medallas: (json['medallas'] as List? ?? const []) + .map((valor) => valor.toString()) + .toList(), + ); + } +} + +class ProgresoGamificacionUsuario { + final EstadisticasPerfilUsuario antes; + final EstadisticasPerfilUsuario despues; + final List nuevasMedallas; + + const ProgresoGamificacionUsuario({ + required this.antes, + required this.despues, + required this.nuevasMedallas, + }); + + int get incrementoFuego => despues.fuego - antes.fuego; +} + +class EstadisticasPerfilUsuario { + final int partidasJugadas; + final int partidasGanadas; + final int partidasPerdidas; + final int partidasComoImpostor; + final int victoriasComoImpostor; + final String? fechaUltimaPartidaIso; + final int partidasHoy; + final int subidaFuegoHoy; + final int fuego; + + const EstadisticasPerfilUsuario({ + this.partidasJugadas = 0, + this.partidasGanadas = 0, + this.partidasPerdidas = 0, + this.partidasComoImpostor = 0, + this.victoriasComoImpostor = 0, + this.fechaUltimaPartidaIso, + this.partidasHoy = 0, + this.subidaFuegoHoy = 0, + this.fuego = 0, + }); + + static const catalogoMedallas = { + 'novato': MedallaUsuario(id: 'novato', emoji: '🎲', assetPath: 'assets/medals/novato.png', nombre: 'Novato', descripcion: 'Jugó su primera partida.'), + 'habitual': MedallaUsuario(id: 'habitual', emoji: '🧭', assetPath: 'assets/medals/habitual.png', nombre: 'Habitual', descripcion: 'Jugó 10 partidas.'), + 'veterano': MedallaUsuario(id: 'veterano', emoji: '🏛️', assetPath: 'assets/medals/veterano.png', nombre: 'Veterano', descripcion: 'Jugó 50 partidas.'), + 'leyenda': MedallaUsuario(id: 'leyenda', emoji: '👑', assetPath: 'assets/medals/leyenda.png', nombre: 'Leyenda', descripcion: 'Jugó 100 partidas.'), + 'primera_victoria': MedallaUsuario(id: 'primera_victoria', emoji: '🥉', assetPath: 'assets/medals/primera_victoria.png', nombre: 'Primera victoria', descripcion: 'Ganó una partida.'), + 'diez_victorias': MedallaUsuario(id: 'diez_victorias', emoji: '🥈', assetPath: 'assets/medals/diez_victorias.png', nombre: 'Diez victorias', descripcion: 'Ganó 10 partidas.'), + 'veinticinco_victorias': MedallaUsuario(id: 'veinticinco_victorias', emoji: '🥇', assetPath: 'assets/medals/veinticinco_victorias.png', nombre: 'Veinticinco victorias', descripcion: 'Ganó 25 partidas.'), + 'cincuenta_victorias': MedallaUsuario(id: 'cincuenta_victorias', emoji: '💎', assetPath: 'assets/medals/cincuenta_victorias.png', nombre: 'Cincuenta victorias', descripcion: 'Ganó 50 partidas.'), + 'primer_engano': MedallaUsuario(id: 'primer_engano', emoji: '🎭', assetPath: 'assets/medals/primer_engano.png', nombre: 'Primer engaño', descripcion: 'Ganó como impostor.'), + 'impostor_habitual': MedallaUsuario(id: 'impostor_habitual', emoji: '🃏', assetPath: 'assets/medals/impostor_habitual.png', nombre: 'Impostor habitual', descripcion: 'Ganó 5 partidas como impostor.'), + 'lobo_faroles': MedallaUsuario(id: 'lobo_faroles', emoji: '🐺', assetPath: 'assets/medals/lobo_faroles.png', nombre: 'Lobo entre faroles', descripcion: 'Ganó 15 partidas como impostor.'), + 'brasa': MedallaUsuario(id: 'brasa', emoji: '♨️', assetPath: 'assets/medals/brasa.png', nombre: 'Brasa', descripcion: 'Mantiene algo de fuego reciente.'), + 'llama_suave': MedallaUsuario(id: 'llama_suave', emoji: '🔥', assetPath: 'assets/medals/llama_suave.png', nombre: 'Llama suave', descripcion: 'Está jugando con cierta asiduidad.'), + 'llama_fuerte': MedallaUsuario(id: 'llama_fuerte', emoji: '🔥', assetPath: 'assets/medals/llama_fuerte.png', nombre: 'Llama fuerte', descripcion: 'Tiene una asiduidad alta.'), + 'incandescente': MedallaUsuario(id: 'incandescente', emoji: '🌋', assetPath: 'assets/medals/incandescente.png', nombre: 'Incandescente', descripcion: 'Tiene el fuego al máximo.'), + }; + + EstadisticasPerfilUsuario copiar({ + int? partidasJugadas, + int? partidasGanadas, + int? partidasPerdidas, + int? partidasComoImpostor, + int? victoriasComoImpostor, + String? fechaUltimaPartidaIso, + bool limpiarFechaUltimaPartida = false, + int? partidasHoy, + int? subidaFuegoHoy, + int? fuego, + }) { + return EstadisticasPerfilUsuario( + partidasJugadas: partidasJugadas ?? this.partidasJugadas, + partidasGanadas: partidasGanadas ?? this.partidasGanadas, + partidasPerdidas: partidasPerdidas ?? this.partidasPerdidas, + partidasComoImpostor: partidasComoImpostor ?? this.partidasComoImpostor, + victoriasComoImpostor: victoriasComoImpostor ?? this.victoriasComoImpostor, + fechaUltimaPartidaIso: limpiarFechaUltimaPartida ? null : (fechaUltimaPartidaIso ?? this.fechaUltimaPartidaIso), + partidasHoy: partidasHoy ?? this.partidasHoy, + subidaFuegoHoy: subidaFuegoHoy ?? this.subidaFuegoHoy, + fuego: (fuego ?? this.fuego).clamp(0, 100).toInt(), + ); + } + + EstadisticasPerfilUsuario registrarPartida({ + required bool victoria, + bool comoImpostor = false, + bool victoriaComoImpostor = false, + DateTime? fecha, + }) { + final momento = fecha ?? DateTime.now(); + final normalizada = DateTime(momento.year, momento.month, momento.day); + final anterior = _aplicarPasoDeDias(normalizada); + final ganancia = anterior._gananciaFuegoSiguientePartida(); + return anterior.copiar( + partidasJugadas: anterior.partidasJugadas + 1, + partidasGanadas: anterior.partidasGanadas + (victoria ? 1 : 0), + partidasPerdidas: anterior.partidasPerdidas + (victoria ? 0 : 1), + partidasComoImpostor: anterior.partidasComoImpostor + (comoImpostor ? 1 : 0), + victoriasComoImpostor: anterior.victoriasComoImpostor + (victoriaComoImpostor ? 1 : 0), + fechaUltimaPartidaIso: normalizada.toIso8601String(), + partidasHoy: anterior.partidasHoy + 1, + subidaFuegoHoy: anterior.subidaFuegoHoy + ganancia, + fuego: anterior.fuego + ganancia, + ); + } + + EstadisticasPerfilUsuario _aplicarPasoDeDias(DateTime hoy) { + final ultima = fechaUltimaPartidaIso == null ? null : DateTime.tryParse(fechaUltimaPartidaIso!); + if (ultima == null) return copiar(partidasHoy: 0, subidaFuegoHoy: 0); + final ultimoDia = DateTime(ultima.year, ultima.month, ultima.day); + final diferenciaDias = hoy.difference(ultimoDia).inDays; + if (diferenciaDias <= 0) return this; + final diasSinJugar = diferenciaDias - 1; + var fuegoActual = fuego; + for (var i = 1; i <= diasSinJugar; i++) { + fuegoActual -= i == 1 ? 3 : (i == 2 ? 5 : 7); + } + return copiar(partidasHoy: 0, subidaFuegoHoy: 0, fuego: fuegoActual); + } + + int _gananciaFuegoSiguientePartida() { + final numeroPartidaDelDia = partidasHoy + 1; + final base = switch (numeroPartidaDelDia) { 1 => 6, 2 => 5, 3 => 4, 4 => 3, 5 => 2, _ => 1 }; + final restanteHoy = (25 - subidaFuegoHoy).clamp(0, 25).toInt(); + return base.clamp(0, restanteHoy).toInt(); + } + + List get medallas { + final resultado = []; + if (partidasJugadas >= 1) resultado.add('novato'); + if (partidasJugadas >= 10) resultado.add('habitual'); + if (partidasJugadas >= 50) resultado.add('veterano'); + if (partidasJugadas >= 100) resultado.add('leyenda'); + if (partidasGanadas >= 1) resultado.add('primera_victoria'); + if (partidasGanadas >= 10) resultado.add('diez_victorias'); + if (partidasGanadas >= 25) resultado.add('veinticinco_victorias'); + if (partidasGanadas >= 50) resultado.add('cincuenta_victorias'); + if (victoriasComoImpostor >= 1) resultado.add('primer_engano'); + if (victoriasComoImpostor >= 5) resultado.add('impostor_habitual'); + if (victoriasComoImpostor >= 15) resultado.add('lobo_faroles'); + if (fuego >= 100) { + resultado.add('incandescente'); + } else if (fuego >= 50) { + resultado.add('llama_fuerte'); + } else if (fuego >= 25) { + resultado.add('llama_suave'); + } else if (fuego > 0) { + resultado.add('brasa'); + } + return resultado; + } + + List get medallasPrincipales { + final ids = medallas; + const prioridad = [ + 'incandescente', 'llama_fuerte', 'llama_suave', 'brasa', + 'leyenda', 'veterano', 'habitual', 'novato', + 'cincuenta_victorias', 'veinticinco_victorias', 'diez_victorias', 'primera_victoria', + 'lobo_faroles', 'impostor_habitual', 'primer_engano', + ]; + return prioridad.where(ids.contains).take(3).toList(); + } + + ResumenGamificacionUsuario get resumen => ResumenGamificacionUsuario(fuego: fuego, medallas: medallasPrincipales); + + Map toJson() => { + 'partidasJugadas': partidasJugadas, + 'partidasGanadas': partidasGanadas, + 'partidasPerdidas': partidasPerdidas, + 'partidasComoImpostor': partidasComoImpostor, + 'victoriasComoImpostor': victoriasComoImpostor, + if (fechaUltimaPartidaIso != null) 'fechaUltimaPartidaIso': fechaUltimaPartidaIso, + 'partidasHoy': partidasHoy, + 'subidaFuegoHoy': subidaFuegoHoy, + 'fuego': fuego, + }; + + factory EstadisticasPerfilUsuario.fromJson(Map json) { + return EstadisticasPerfilUsuario( + partidasJugadas: (json['partidasJugadas'] as num?)?.toInt() ?? 0, + partidasGanadas: (json['partidasGanadas'] as num?)?.toInt() ?? 0, + partidasPerdidas: (json['partidasPerdidas'] as num?)?.toInt() ?? 0, + partidasComoImpostor: (json['partidasComoImpostor'] as num?)?.toInt() ?? 0, + victoriasComoImpostor: (json['victoriasComoImpostor'] as num?)?.toInt() ?? 0, + fechaUltimaPartidaIso: json['fechaUltimaPartidaIso'] as String?, + partidasHoy: (json['partidasHoy'] as num?)?.toInt() ?? 0, + subidaFuegoHoy: (json['subidaFuegoHoy'] as num?)?.toInt() ?? 0, + fuego: (json['fuego'] as num?)?.toInt() ?? 0, + ); + } +} diff --git a/lib/modelos/usuario.dart b/lib/modelos/usuario.dart index f658c98..90c70f7 100644 --- a/lib/modelos/usuario.dart +++ b/lib/modelos/usuario.dart @@ -7,6 +7,8 @@ class Usuario { final String? foto; final String? creadoPorClienteId; final String? clienteIdSeleccionado; + final int fuego; + final List medallas; Usuario({ required this.id, @@ -16,6 +18,8 @@ class Usuario { this.foto, this.creadoPorClienteId, this.clienteIdSeleccionado, + this.fuego = 0, + this.medallas = const [], }); bool get estaSeleccionado => clienteIdSeleccionado != null; @@ -29,6 +33,8 @@ class Usuario { String? foto, String? creadoPorClienteId, String? clienteIdSeleccionado, + int? fuego, + List? medallas, bool liberarSeleccion = false, }) { return Usuario( @@ -41,6 +47,8 @@ class Usuario { clienteIdSeleccionado: liberarSeleccion ? null : (clienteIdSeleccionado ?? this.clienteIdSeleccionado), + fuego: fuego ?? this.fuego, + medallas: medallas ?? this.medallas, ); } @@ -53,6 +61,8 @@ class Usuario { if (creadoPorClienteId != null) 'creadoPorClienteId': creadoPorClienteId, if (clienteIdSeleccionado != null) 'clienteIdSeleccionado': clienteIdSeleccionado, + if (fuego > 0) 'fuego': fuego, + if (medallas.isNotEmpty) 'medallas': medallas, }; factory Usuario.fromJson(Map json) => Usuario( @@ -63,5 +73,9 @@ class Usuario { foto: json['foto'] as String?, creadoPorClienteId: json['creadoPorClienteId'] as String?, clienteIdSeleccionado: json['clienteIdSeleccionado'] as String?, + fuego: (json['fuego'] as num?)?.toInt() ?? 0, + medallas: (json['medallas'] as List? ?? const []) + .map((valor) => valor.toString()) + .toList(), ); } diff --git a/lib/pantallas/pantalla_crear_partida.dart b/lib/pantallas/pantalla_crear_partida.dart index 4323b8e..334fb12 100644 --- a/lib/pantallas/pantalla_crear_partida.dart +++ b/lib/pantallas/pantalla_crear_partida.dart @@ -145,13 +145,17 @@ class _PantallaCrearPartidaState extends State { // 3. Iniciar host en Nearby if (!mounted) return; final nearby = context.read(); - final perfil = context.read().perfil; + final servicioPerfil = context.read(); + 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) { diff --git a/lib/pantallas/pantalla_fin_partida.dart b/lib/pantallas/pantalla_fin_partida.dart index 4ac1193..8b1aa8e 100644 --- a/lib/pantallas/pantalla_fin_partida.dart +++ b/lib/pantallas/pantalla_fin_partida.dart @@ -3,8 +3,11 @@ import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:provider/provider.dart'; import '../estado/estado_juego.dart'; import '../modelos/palabra.dart'; +import '../modelos/gamificacion_usuario.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/tema_app.dart'; import 'pantalla_principal.dart'; import 'pantalla_ver_palabra.dart'; @@ -18,6 +21,7 @@ class PantallaFinPartida extends StatefulWidget { class _PantallaFinPartidaState extends State { bool _guardada = false; + ProgresoGamificacionUsuario? _progreso; @override Widget build(BuildContext context) { @@ -31,9 +35,15 @@ class _PantallaFinPartidaState extends State { partida.jugadores.where((j) => j.esImpostor).toList(); if (!_guardada && partida.ganador != null) { _guardada = true; - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) async { if (mounted) { - context.read().guardarPartida(partida); + final historial = context.read(); + final perfil = context.read(); + await historial.guardarPartida(partida); + final progreso = await perfil.registrarPartidaCompletada( + victoria: ganaronJugadores, + ); + if (mounted) setState(() => _progreso = progreso); } }); } @@ -91,6 +101,10 @@ class _PantallaFinPartidaState extends State { ), ), const SizedBox(height: 24), + if (_progreso != null) ...[ + _TarjetaProgresoGamificacion(progreso: _progreso!), + const SizedBox(height: 16), + ], // Palabra secreta Card( @@ -260,3 +274,59 @@ class _PantallaFinPartidaState extends State { ); } } + +class _TarjetaProgresoGamificacion extends StatelessWidget { + final ProgresoGamificacionUsuario progreso; + + const _TarjetaProgresoGamificacion({required this.progreso}); + + @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), + ], + ], + ), + ), + ); + } +} diff --git a/lib/pantallas/pantalla_fin_partida_online.dart b/lib/pantallas/pantalla_fin_partida_online.dart index a502f20..a12e70b 100644 --- a/lib/pantallas/pantalla_fin_partida_online.dart +++ b/lib/pantallas/pantalla_fin_partida_online.dart @@ -2,10 +2,13 @@ import 'package:flutter/material.dart'; import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:provider/provider.dart'; import '../modelos/inicio_partida_multijugador.dart'; +import '../modelos/gamificacion_usuario.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/tema_app.dart'; import 'pantalla_notas_online.dart'; import 'pantalla_principal.dart'; @@ -30,6 +33,7 @@ class PantallaFinPartidaOnline extends StatefulWidget { class _PantallaFinPartidaOnlineState extends State { bool _guardada = false; + ProgresoGamificacionUsuario? _progreso; @override Widget build(BuildContext context) { @@ -41,11 +45,18 @@ class _PantallaFinPartidaOnlineState extends State { if (!_guardada && snapshot.ganador != null) { _guardada = true; - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) async { if (mounted) { - context - .read() - .guardarSnapshotOnline(snapshot); + final historial = context.read(); + final perfil = context.read(); + await historial.guardarSnapshotOnline(snapshot); + final progreso = await perfil.registrarPartidaCompletada( + victoria: _perfilLocalGano(snapshot, jugadoresControlados), + comoImpostor: jugadoresControlados.any((j) => j.esImpostor), + victoriaComoImpostor: snapshot.ganador == 'impostores' && + jugadoresControlados.any((j) => j.esImpostor), + ); + if (mounted) setState(() => _progreso = progreso); } }); } @@ -132,6 +143,10 @@ class _PantallaFinPartidaOnlineState extends State { ), ), const SizedBox(height: 24), + if (_progreso != null) ...[ + _TarjetaProgresoGamificacion(progreso: _progreso!), + const SizedBox(height: 16), + ], Card( child: Padding( padding: const EdgeInsets.all(20), @@ -254,4 +269,71 @@ class _PantallaFinPartidaOnlineState extends State { ), ); } + + bool _perfilLocalGano( + SnapshotPartidaOnline snapshot, + List jugadoresControlados, + ) { + if (jugadoresControlados.isEmpty) return snapshot.ganador == 'jugadores'; + final ganaronImpostores = snapshot.ganador == 'impostores'; + return jugadoresControlados.any( + (jugador) => jugador.esImpostor ? ganaronImpostores : !ganaronImpostores, + ); + } +} + +class _TarjetaProgresoGamificacion extends StatelessWidget { + final ProgresoGamificacionUsuario progreso; + + const _TarjetaProgresoGamificacion({required this.progreso}); + + @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), + ], + ], + ), + ), + ); + } } diff --git a/lib/pantallas/pantalla_gestor_host.dart b/lib/pantallas/pantalla_gestor_host.dart index 75ec5d7..a5fd006 100644 --- a/lib/pantallas/pantalla_gestor_host.dart +++ b/lib/pantallas/pantalla_gestor_host.dart @@ -4,12 +4,14 @@ 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/gamificacion_usuario.dart'; import '../modelos/inicio_partida_multijugador.dart'; import '../modelos/jugador.dart'; import '../modelos/partida.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/tema_app.dart'; import 'pantalla_notas_online.dart'; @@ -31,6 +33,7 @@ class _PantallaGestorHostState extends State { int _segundosRestantes = 0; bool _hostListo = false; bool _partidaOnlineGuardada = false; + ProgresoGamificacionUsuario? _progresoGamificacion; String? _primerTurnoId; String? _primerTurnoNombre; final Map _clientesListos = {}; @@ -695,7 +698,7 @@ class _PantallaGestorHostState extends State { label: Text( _hostYaVoto(context) ? 'Votos del host registrados' - : 'Votar por los jugadores de este m?vil', + : 'Votar por los jugadores de este móvil', ), ), ), @@ -934,12 +937,57 @@ class _PantallaGestorHostState extends State { partida == null ? '' : l10n.theWordWas(partida.palabraSecreta), textAlign: TextAlign.center, ), + if (_progresoGamificacion != null) ...[ + const SizedBox(height: 16), + _buildProgresoGamificacion(context, _progresoGamificacion!), + ], ], ), ), ); } + Widget _buildProgresoGamificacion( + BuildContext context, + ProgresoGamificacionUsuario progreso, + ) { + final nuevas = progreso.nuevasMedallas; + return PanelFarolero( + padding: const EdgeInsets.all(14), + color: TemaApp.colorSuperficie.withValues(alpha: 0.82), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Tu progreso', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 10), + Row( + children: [ + const Text('🔥', style: TextStyle(fontSize: 22)), + const SizedBox(width: 8), + 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: 8), + Text('${progreso.despues.fuego}%'), + ], + ), + if (nuevas.isNotEmpty) ...[ + const SizedBox(height: 10), + MedallasCompactasFarolero(ids: nuevas, max: nuevas.length), + ], + ], + ), + ); + } + bool _hostYaVoto(BuildContext context) { final estado = context.read(); final sala = context.read().estadoSala; @@ -1182,7 +1230,28 @@ class _PantallaGestorHostState extends State { final partida = context.read().partida; if (partida?.ganador == null) return; _partidaOnlineGuardada = true; - await context.read().guardarPartida(partida!); + final historial = context.read(); + final nearby = context.read(); + final perfil = context.read(); + await historial.guardarPartida(partida!); + final jugadoresHost = _jugadoresHostControlados( + partida, + nearby, + ); + final ganaronImpostores = partida.ganador == 'impostores'; + final victoria = jugadoresHost.isEmpty + ? partida.ganador == 'jugadores' + : jugadoresHost.any( + (jugador) => + jugador.esImpostor ? ganaronImpostores : !ganaronImpostores, + ); + final progreso = await perfil.registrarPartidaCompletada( + victoria: victoria, + comoImpostor: jugadoresHost.any((j) => j.esImpostor), + victoriaComoImpostor: + ganaronImpostores && jugadoresHost.any((j) => j.esImpostor), + ); + if (mounted) setState(() => _progresoGamificacion = progreso); } Future _iniciarAdivinanzaOnline(BuildContext context) async { diff --git a/lib/pantallas/pantalla_lobby_host.dart b/lib/pantallas/pantalla_lobby_host.dart index 7179c05..34f9952 100644 --- a/lib/pantallas/pantalla_lobby_host.dart +++ b/lib/pantallas/pantalla_lobby_host.dart @@ -217,15 +217,26 @@ class _PantallaLobbyHostState extends State { ? TemaApp.colorNaranja : TemaApp.colorAzul, size: 38, + fuego: usuario.fuego, + medallas: usuario.medallas, ), ), title: Text(usuario.nombre), - subtitle: Text( - seleccionadoPorMi - ? 'Seleccionado por este móvil' - : seleccionadoPorOtro - ? 'Seleccionado por otro cliente' - : 'Disponible', + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + seleccionadoPorMi + ? 'Seleccionado por este móvil' + : seleccionadoPorOtro + ? 'Seleccionado por otro cliente' + : 'Disponible', + ), + if (usuario.medallas.isNotEmpty) ...[ + const SizedBox(height: 4), + MedallasCompactasFarolero(ids: usuario.medallas), + ], + ], ), trailing: Row( mainAxisSize: MainAxisSize.min, @@ -300,11 +311,15 @@ class _PantallaLobbyHostState extends State { ); if (nombre != null && nombre.trim().isNotEmpty) { + final gamificacion = + context.read().resumenGamificacion; await nearby.crearUsuarioSala( nombre.trim(), seleccionar: true, nick: perfil.nick, avatar: perfil.avatarAsset, + fuego: gamificacion.fuego, + medallas: gamificacion.medallas, ); } } diff --git a/lib/pantallas/pantalla_principal.dart b/lib/pantallas/pantalla_principal.dart index 90674ae..ade0ad1 100644 --- a/lib/pantallas/pantalla_principal.dart +++ b/lib/pantallas/pantalla_principal.dart @@ -16,7 +16,9 @@ class PantallaPrincipal extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final perfil = context.watch().perfil; + final servicioPerfil = context.watch(); + final perfil = servicioPerfil.perfil; + final gamificacion = servicioPerfil.resumenGamificacion; return Scaffold( body: FondoFarolero( @@ -35,6 +37,8 @@ class PantallaPrincipal extends StatelessWidget { texto: perfil.nombre.substring(0, 1).toUpperCase(), assetPath: perfil.avatarAsset, size: 48, + fuego: gamificacion.fuego, + medallas: gamificacion.medallas, ), const SizedBox(width: 10), Expanded( @@ -53,9 +57,9 @@ class PantallaPrincipal extends StatelessWidget { ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( - value: 0.68, + value: gamificacion.fuego / 100, minHeight: 4, - color: TemaApp.colorPurpura, + color: TemaApp.colorNaranja, backgroundColor: Colors.black.withValues(alpha: 0.45), ), ), diff --git a/lib/pantallas/pantalla_unirse.dart b/lib/pantallas/pantalla_unirse.dart index 8733405..a22f46f 100644 --- a/lib/pantallas/pantalla_unirse.dart +++ b/lib/pantallas/pantalla_unirse.dart @@ -314,11 +314,15 @@ class _PantallaUnirseState extends State { if (!mounted) return; final nearby = context.read(); - final perfil = context.read().perfil; + final servicioPerfil = context.read(); + final perfil = servicioPerfil.perfil; + final gamificacion = servicioPerfil.resumenGamificacion; final ok = await nearby.buscarHosts( _nombreController.text.trim(), miNick: perfil.nick, miAvatar: perfil.avatarAsset, + miFuego: gamificacion.fuego, + miMedallas: gamificacion.medallas, ); if (ok) { @@ -342,7 +346,9 @@ class _PantallaUnirseState extends State { }); final nearby = context.read(); - final perfil = context.read().perfil; + final servicioPerfil = context.read(); + final perfil = servicioPerfil.perfil; + final gamificacion = servicioPerfil.resumenGamificacion; // Parar discovery antes de conectar await nearby.pararBusqueda(); final ok = await nearby.conectarAHost( @@ -350,6 +356,8 @@ class _PantallaUnirseState extends State { _nombreController.text.trim(), miNick: perfil.nick, miAvatar: perfil.avatarAsset, + miFuego: gamificacion.fuego, + miMedallas: gamificacion.medallas, ); if (!ok && mounted) { @@ -389,11 +397,15 @@ class _PantallaUnirseState extends State { // Iniciar búsqueda para que Nearby encuentre al host final nearby = context.read(); if (!nearby.buscando) { - final perfil = context.read().perfil; + final servicioPerfil = context.read(); + final perfil = servicioPerfil.perfil; + final gamificacion = servicioPerfil.resumenGamificacion; await nearby.buscarHosts( _nombreController.text.trim(), miNick: perfil.nick, miAvatar: perfil.avatarAsset, + miFuego: gamificacion.fuego, + miMedallas: gamificacion.medallas, ); } return; @@ -835,11 +847,15 @@ class _PantallaUnirseState extends State { ); if (nombre != null && nombre.trim().isNotEmpty) { + final gamificacion = + context.read().resumenGamificacion; await nearby.crearUsuarioSala( nombre.trim(), seleccionar: true, nick: perfil.nick, avatar: perfil.avatarAsset, + fuego: gamificacion.fuego, + medallas: gamificacion.medallas, ); } } @@ -861,17 +877,29 @@ class _PantallaUnirseState extends State { usuario.estaSeleccionado && usuario.clienteIdSeleccionado != miClientId; return ListTile( - leading: Text( - usuario.avatar ?? '??', - style: const TextStyle(fontSize: 24), + leading: AvatarFarolero( + texto: usuario.nombre.isEmpty ? '?' : usuario.nombre[0], + assetPath: usuario.avatar, + size: 38, + fuego: usuario.fuego, + medallas: usuario.medallas, ), title: Text(usuario.nombre), - subtitle: Text( - seleccionadoPorMi - ? 'Seleccionado por este m?vil' - : seleccionadoPorOtro - ? 'No disponible' - : 'Disponible', + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + seleccionadoPorMi + ? 'Seleccionado por este móvil' + : seleccionadoPorOtro + ? 'No disponible' + : 'Disponible', + ), + if (usuario.medallas.isNotEmpty) ...[ + const SizedBox(height: 4), + MedallasCompactasFarolero(ids: usuario.medallas), + ], + ], ), trailing: seleccionadoPorMi ? IconButton( diff --git a/lib/servicios/servicio_nearby.dart b/lib/servicios/servicio_nearby.dart index 7d4791a..bedfd21 100644 --- a/lib/servicios/servicio_nearby.dart +++ b/lib/servicios/servicio_nearby.dart @@ -85,6 +85,8 @@ class ServicioNearby extends ChangeNotifier { String? _miNombre; String? _miNick; String? _miAvatar; + int _miFuego = 0; + List _miMedallas = const []; final Map _jugadores = {}; final List _listeners = []; @@ -213,6 +215,8 @@ class ServicioNearby extends ChangeNotifier { String miNombre, { String? miNick, String? miAvatar, + int miFuego = 0, + List miMedallas = const [], }) async { if (_conectado || _anunciando || @@ -245,6 +249,8 @@ class ServicioNearby extends ChangeNotifier { foto: miAvatar, creadoPorClienteId: _hostClientId, clienteIdSeleccionado: _hostClientId, + fuego: miFuego, + medallas: miMedallas, ); _estadoSala!.crearUsuario(usuarioHost); _sincronizarPoolDesdeSala(); @@ -281,10 +287,14 @@ class ServicioNearby extends ChangeNotifier { String miNombre, { String? miNick, String? miAvatar, + int miFuego = 0, + List miMedallas = const [], }) async { _miNombre = miNombre; _miNick = miNick; _miAvatar = miAvatar; + _miFuego = miFuego; + _miMedallas = miMedallas; try { final resultado = await Nearby().startDiscovery( @@ -312,10 +322,14 @@ class ServicioNearby extends ChangeNotifier { String miNombre, { String? miNick, String? miAvatar, + int miFuego = 0, + List miMedallas = const [], }) async { _miNombre = miNombre; _miNick = miNick; _miAvatar = miAvatar; + _miFuego = miFuego; + _miMedallas = miMedallas; try { await Nearby().requestConnection( miNombre, @@ -359,6 +373,8 @@ class ServicioNearby extends ChangeNotifier { 'nombre': _miNombre ?? 'Jugador', if (_miNick != null) 'nick': _miNick, if (_miAvatar != null) 'avatar': _miAvatar, + 'fuego': _miFuego, + 'medallas': _miMedallas, }, ), ); @@ -515,6 +531,10 @@ class ServicioNearby extends ChangeNotifier { final nombre = mensaje.datos['nombre'] as String? ?? 'Jugador'; final nick = mensaje.datos['nick'] as String?; final avatar = mensaje.datos['avatar'] as String?; + final fuego = (mensaje.datos['fuego'] as num?)?.toInt() ?? 0; + final medallas = (mensaje.datos['medallas'] as List? ?? const []) + .map((valor) => valor.toString()) + .toList(); final clientId = endpointId; _jugadores[endpointId] = JugadorConectado( endpointId: endpointId, @@ -528,6 +548,8 @@ class ServicioNearby extends ChangeNotifier { nombre: nombre, nick: nick, avatar: avatar, + fuego: fuego, + medallas: medallas, ); enviarMensaje( @@ -555,6 +577,8 @@ class ServicioNearby extends ChangeNotifier { required String nombre, String? nick, String? avatar, + int fuego = 0, + List medallas = const [], }) { final sala = _estadoSala; if (sala == null || sala.fase != FaseSalaMultijugador.lobby) return; @@ -579,6 +603,8 @@ class ServicioNearby extends ChangeNotifier { avatar: avatar, foto: avatar, creadoPorClienteId: clientId, + fuego: fuego, + medallas: medallas, ); final resultadoCrear = sala.crearUsuario(usuario); if (!resultadoCrear.exitoso) return; @@ -769,6 +795,8 @@ class ServicioNearby extends ChangeNotifier { bool seleccionar = true, String? nick, String? avatar, + int fuego = 0, + List medallas = const [], }) async { final nombreLimpio = nombre.trim(); if (nombreLimpio.isEmpty) return; @@ -780,6 +808,8 @@ class ServicioNearby extends ChangeNotifier { avatar: avatar, foto: avatar, creadoPorClienteId: clientId, + fuego: fuego, + medallas: medallas, ); if (_esHost && _estadoSala != null && clientId != null) { final resultado = _estadoSala!.crearUsuario(usuario); @@ -979,6 +1009,8 @@ class ServicioNearby extends ChangeNotifier { _miNombre = null; _miNick = null; _miAvatar = null; + _miFuego = 0; + _miMedallas = const []; _palabraRecibida = null; _soyImpostor = null; _faseActual = null; diff --git a/lib/servicios/servicio_perfil_usuario.dart b/lib/servicios/servicio_perfil_usuario.dart index 3801f4e..9126e11 100644 --- a/lib/servicios/servicio_perfil_usuario.dart +++ b/lib/servicios/servicio_perfil_usuario.dart @@ -1,6 +1,10 @@ +import 'dart:convert'; + import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../modelos/gamificacion_usuario.dart'; + class PerfilUsuario { final String nombre; final String nick; @@ -25,6 +29,7 @@ class ServicioPerfilUsuario extends ChangeNotifier { static const _claveNombre = 'perfil.nombre'; static const _claveNick = 'perfil.nick'; static const _claveAvatar = 'perfil.avatar'; + static const _claveEstadisticas = 'perfil.estadisticas'; static const avatares = [ 'assets/avatars/avatar_01.png', @@ -57,6 +62,18 @@ class ServicioPerfilUsuario extends ChangeNotifier { 'assets/avatars/avatar_28.png', 'assets/avatars/avatar_29.png', 'assets/avatars/avatar_30.png', + 'assets/avatars/capybara_01.png', + 'assets/avatars/capybara_02.png', + 'assets/avatars/capybara_03.png', + 'assets/avatars/capybara_04.png', + 'assets/avatars/capybara_05.png', + 'assets/avatars/capybara_06.png', + 'assets/avatars/capybara_07.png', + 'assets/avatars/capybara_08.png', + 'assets/avatars/capybara_09.png', + 'assets/avatars/capybara_10.png', + 'assets/avatars/capybara_11.png', + 'assets/avatars/capybara_12.png', ]; PerfilUsuario _perfil = const PerfilUsuario( @@ -64,9 +81,13 @@ class ServicioPerfilUsuario extends ChangeNotifier { nick: 'farolero', avatarAsset: 'assets/avatars/avatar_01.png', ); + EstadisticasPerfilUsuario _estadisticas = + const EstadisticasPerfilUsuario(); bool _cargado = false; PerfilUsuario get perfil => _perfil; + EstadisticasPerfilUsuario get estadisticas => _estadisticas; + ResumenGamificacionUsuario get resumenGamificacion => _estadisticas.resumen; bool get cargado => _cargado; Future cargar() async { @@ -76,6 +97,16 @@ class ServicioPerfilUsuario extends ChangeNotifier { nick: prefs.getString(_claveNick) ?? _perfil.nick, avatarAsset: prefs.getString(_claveAvatar) ?? _perfil.avatarAsset, ); + final estadisticasJson = prefs.getString(_claveEstadisticas); + if (estadisticasJson != null) { + try { + _estadisticas = EstadisticasPerfilUsuario.fromJson( + json.decode(estadisticasJson) as Map, + ); + } catch (_) { + _estadisticas = const EstadisticasPerfilUsuario(); + } + } _cargado = true; notifyListeners(); } @@ -101,4 +132,34 @@ class ServicioPerfilUsuario extends ChangeNotifier { await prefs.setString(_claveAvatar, _perfil.avatarAsset); notifyListeners(); } + + Future registrarPartidaCompletada({ + required bool victoria, + bool comoImpostor = false, + bool victoriaComoImpostor = false, + DateTime? fecha, + }) async { + final antes = _estadisticas; + final despues = antes.registrarPartida( + victoria: victoria, + comoImpostor: comoImpostor, + victoriaComoImpostor: victoriaComoImpostor, + fecha: fecha, + ); + final medallasPrevias = antes.medallas.toSet(); + final nuevasMedallas = despues.medallas + .where((id) => !medallasPrevias.contains(id)) + .toList(growable: false); + + _estadisticas = despues; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_claveEstadisticas, json.encode(despues.toJson())); + notifyListeners(); + + return ProgresoGamificacionUsuario( + antes: antes, + despues: despues, + nuevasMedallas: nuevasMedallas, + ); + } } diff --git a/lib/tema/componentes_farolero.dart b/lib/tema/componentes_farolero.dart index f3a1103..2fbbc4a 100644 --- a/lib/tema/componentes_farolero.dart +++ b/lib/tema/componentes_farolero.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; +import '../modelos/gamificacion_usuario.dart'; import 'tema_app.dart'; class FondoFarolero extends StatelessWidget { @@ -272,6 +273,8 @@ class AvatarFarolero extends StatelessWidget { final String? assetPath; final Color color; final double size; + final int fuego; + final List medallas; const AvatarFarolero({ super.key, @@ -279,43 +282,172 @@ class AvatarFarolero extends StatelessWidget { this.assetPath, this.color = TemaApp.colorNaranja, this.size = 40, + this.fuego = 0, + this.medallas = const [], }); @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, - ), - ), + final lienzo = size + 10; + return SizedBox( + width: lienzo, + height: lienzo, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + CustomPaint( + size: Size(lienzo, lienzo), + painter: _FuegoAvatarPainter(fuego: fuego), ), + 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, + ), + ), + ), + ), + if (medallas.isNotEmpty) + Positioned( + right: -2, + bottom: -4, + child: _MiniMedalla(id: medallas.first), + ), + ], + ), ); } } +class MedallasCompactasFarolero extends StatelessWidget { + final List ids; + final int max; + + const MedallasCompactasFarolero({ + super.key, + required this.ids, + this.max = 3, + }); + + @override + Widget build(BuildContext context) { + final visibles = ids.take(max).toList(); + if (visibles.isEmpty) return const SizedBox.shrink(); + return Wrap( + spacing: 4, + runSpacing: 4, + children: [ + for (final id in visibles) _MiniMedalla(id: id), + ], + ); + } +} + +class _MiniMedalla extends StatelessWidget { + final String id; + + const _MiniMedalla({required this.id}); + + @override + Widget build(BuildContext context) { + final medalla = EstadisticasPerfilUsuario.catalogoMedallas[id]; + if (medalla == null) return const SizedBox.shrink(); + return Tooltip( + message: '${medalla.nombre}: ${medalla.descripcion}', + child: Container( + width: 26, + height: 26, + alignment: Alignment.center, + decoration: BoxDecoration( + color: TemaApp.colorSuperficie.withValues(alpha: 0.92), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.32), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: Image.asset( + medalla.assetPath, + width: 26, + height: 26, + fit: BoxFit.contain, + errorBuilder: (_, __, ___) => + Text(medalla.emoji, style: const TextStyle(fontSize: 12)), + ), + ), + ); + } +} + +class _FuegoAvatarPainter extends CustomPainter { + final int fuego; + + const _FuegoAvatarPainter({required this.fuego}); + + @override + void paint(Canvas canvas, Size size) { + final porcentaje = fuego.clamp(0, 100) / 100; + final rect = Offset.zero & size; + final centro = rect.center; + final radio = math.min(size.width, size.height) / 2 - 3; + final base = Paint() + ..isAntiAlias = true + ..style = PaintingStyle.stroke + ..strokeWidth = 4 + ..strokeCap = StrokeCap.round + ..color = Colors.black.withValues(alpha: 0.28); + canvas.drawCircle(centro, radio, base); + if (porcentaje <= 0) return; + + final fuegoPaint = Paint() + ..isAntiAlias = true + ..style = PaintingStyle.stroke + ..strokeWidth = 4 + ..strokeCap = StrokeCap.round + ..shader = const SweepGradient( + colors: [Color(0xFFFFC247), Color(0xFFFF7A1A), Color(0xFFE53935)], + ).createShader(Rect.fromCircle(center: centro, radius: radio)); + canvas.drawArc( + Rect.fromCircle(center: centro, radius: radio), + -math.pi / 2, + math.pi * 2 * porcentaje, + false, + fuegoPaint, + ); + } + + @override + bool shouldRepaint(covariant _FuegoAvatarPainter oldDelegate) { + return oldDelegate.fuego != fuego; + } +} + class _FondoFaroleroPainter extends CustomPainter { final bool intenso; diff --git a/pubspec.yaml b/pubspec.yaml index aa4423b..4af11c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,3 +34,4 @@ flutter: - assets/palabras_fr.json - assets/words/ - assets/avatars/ + - assets/medals/ diff --git a/test/gamificacion_usuario_test.dart b/test/gamificacion_usuario_test.dart new file mode 100644 index 0000000..3189ee4 --- /dev/null +++ b/test/gamificacion_usuario_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:farolero/modelos/gamificacion_usuario.dart'; +import 'package:farolero/modelos/usuario.dart'; +import 'package:farolero/servicios/servicio_perfil_usuario.dart'; + +void main() { + group('EstadisticasPerfilUsuario', () { + test('sube fuego con rendimientos decrecientes y tope diario', () { + var stats = const EstadisticasPerfilUsuario(); + final fecha = DateTime(2026, 5, 9); + + for (var i = 0; i < 20; i++) { + stats = stats.registrarPartida(victoria: false, fecha: fecha); + } + + expect(stats.fuego, 25); + expect(stats.subidaFuegoHoy, 25); + expect(stats.partidasHoy, 20); + }); + + test('permite descansar un dia sin penalizar y penaliza dias extra', () { + final inicial = EstadisticasPerfilUsuario( + fuego: 50, + fechaUltimaPartidaIso: DateTime(2026, 5, 1).toIso8601String(), + ); + + final unDiaDespues = inicial.registrarPartida( + victoria: false, + fecha: DateTime(2026, 5, 2), + ); + final tresDiasDespues = inicial.registrarPartida( + victoria: false, + fecha: DateTime(2026, 5, 4), + ); + + expect(unDiaDespues.fuego, 56); + expect(tresDiasDespues.fuego, 48); + }); + + test('calcula medallas principales sin usar llama para partidas jugadas', () { + final stats = const EstadisticasPerfilUsuario( + partidasJugadas: 10, + partidasGanadas: 1, + fuego: 20, + ); + + expect(stats.medallas, contains('habitual')); + expect(stats.medallas, contains('brasa')); + expect(stats.medallas, isNot(contains('llama_suave'))); + expect(stats.medallasPrincipales, contains('habitual')); + }); + }); + + test('Usuario serializa fuego y medallas', () { + final usuario = Usuario( + id: 'u1', + nombre: 'León', + fuego: 42, + medallas: const ['brasa', 'habitual'], + ); + + final restaurado = Usuario.fromJson(usuario.toJson()); + + expect(restaurado.nombre, 'León'); + expect(restaurado.fuego, 42); + expect(restaurado.medallas, ['brasa', 'habitual']); + }); + + test('los avatares de capibara están disponibles en el perfil', () { + expect( + ServicioPerfilUsuario.avatares, + contains('assets/avatars/capybara_01.png'), + ); + expect( + ServicioPerfilUsuario.avatares, + contains('assets/avatars/capybara_12.png'), + ); + }); +}