Gamificación

This commit is contained in:
2026-05-09 17:24:46 +02:00
parent dcecee805b
commit e2cebafdbb
29 changed files with 877 additions and 58 deletions

BIN
assets/medals/brasa.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/medals/habitual.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
assets/medals/leyenda.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/medals/novato.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
assets/medals/veterano.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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<String> medallas;
const ResumenGamificacionUsuario({
required this.fuego,
required this.medallas,
});
Map<String, dynamic> toJson() => {
'fuego': fuego,
'medallas': medallas,
};
factory ResumenGamificacionUsuario.fromJson(Map<String, dynamic> json) {
return ResumenGamificacionUsuario(
fuego: (json['fuego'] as num?)?.toInt() ?? 0,
medallas: (json['medallas'] as List<dynamic>? ?? const [])
.map((valor) => valor.toString())
.toList(),
);
}
}
class ProgresoGamificacionUsuario {
final EstadisticasPerfilUsuario antes;
final EstadisticasPerfilUsuario despues;
final List<String> 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 = <String, MedallaUsuario>{
'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<String> get medallas {
final resultado = <String>[];
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<String> 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<String, dynamic> 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<String, dynamic> 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,
);
}
}

View File

@@ -7,6 +7,8 @@ class Usuario {
final String? foto;
final String? creadoPorClienteId;
final String? clienteIdSeleccionado;
final int fuego;
final List<String> 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<String>? 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<String, dynamic> 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<dynamic>? ?? const [])
.map((valor) => valor.toString())
.toList(),
);
}

View File

@@ -145,13 +145,17 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
// 3. Iniciar host en Nearby
if (!mounted) return;
final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>().perfil;
final servicioPerfil = context.read<ServicioPerfilUsuario>();
final perfil = servicioPerfil.perfil;
final gamificacion = servicioPerfil.resumenGamificacion;
final nombreSala = '${nombre.trim()} - Farolero';
final ok = await nearby.iniciarHost(
nombreSala,
nombre.trim(),
miNick: perfil.nick,
miAvatar: perfil.avatarAsset,
miFuego: gamificacion.fuego,
miMedallas: gamificacion.medallas,
);
if (!ok) {

View File

@@ -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<PantallaFinPartida> {
bool _guardada = false;
ProgresoGamificacionUsuario? _progreso;
@override
Widget build(BuildContext context) {
@@ -31,9 +35,15 @@ class _PantallaFinPartidaState extends State<PantallaFinPartida> {
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<ServicioHistorialPartidas>().guardarPartida(partida);
final historial = context.read<ServicioHistorialPartidas>();
final perfil = context.read<ServicioPerfilUsuario>();
await historial.guardarPartida(partida);
final progreso = await perfil.registrarPartidaCompletada(
victoria: ganaronJugadores,
);
if (mounted) setState(() => _progreso = progreso);
}
});
}
@@ -91,6 +101,10 @@ class _PantallaFinPartidaState extends State<PantallaFinPartida> {
),
),
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<PantallaFinPartida> {
);
}
}
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),
],
],
),
),
);
}
}

View File

@@ -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<PantallaFinPartidaOnline> {
bool _guardada = false;
ProgresoGamificacionUsuario? _progreso;
@override
Widget build(BuildContext context) {
@@ -41,11 +45,18 @@ class _PantallaFinPartidaOnlineState extends State<PantallaFinPartidaOnline> {
if (!_guardada && snapshot.ganador != null) {
_guardada = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) {
context
.read<ServicioHistorialPartidas>()
.guardarSnapshotOnline(snapshot);
final historial = context.read<ServicioHistorialPartidas>();
final perfil = context.read<ServicioPerfilUsuario>();
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<PantallaFinPartidaOnline> {
),
),
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<PantallaFinPartidaOnline> {
),
);
}
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,
);
}
}
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),
],
],
),
),
);
}
}

View File

@@ -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<PantallaGestorHost> {
int _segundosRestantes = 0;
bool _hostListo = false;
bool _partidaOnlineGuardada = false;
ProgresoGamificacionUsuario? _progresoGamificacion;
String? _primerTurnoId;
String? _primerTurnoNombre;
final Map<String, bool> _clientesListos = {};
@@ -695,7 +698,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
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<PantallaGestorHost> {
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<EstadoJuego>();
final sala = context.read<ServicioNearby>().estadoSala;
@@ -1182,7 +1230,28 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
final partida = context.read<EstadoJuego>().partida;
if (partida?.ganador == null) return;
_partidaOnlineGuardada = true;
await context.read<ServicioHistorialPartidas>().guardarPartida(partida!);
final historial = context.read<ServicioHistorialPartidas>();
final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>();
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<void> _iniciarAdivinanzaOnline(BuildContext context) async {

View File

@@ -217,15 +217,26 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
? 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<PantallaLobbyHost> {
);
if (nombre != null && nombre.trim().isNotEmpty) {
final gamificacion =
context.read<ServicioPerfilUsuario>().resumenGamificacion;
await nearby.crearUsuarioSala(
nombre.trim(),
seleccionar: true,
nick: perfil.nick,
avatar: perfil.avatarAsset,
fuego: gamificacion.fuego,
medallas: gamificacion.medallas,
);
}
}

View File

@@ -16,7 +16,9 @@ class PantallaPrincipal extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final perfil = context.watch<ServicioPerfilUsuario>().perfil;
final servicioPerfil = context.watch<ServicioPerfilUsuario>();
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),
),
),

View File

@@ -314,11 +314,15 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
if (!mounted) return;
final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>().perfil;
final servicioPerfil = context.read<ServicioPerfilUsuario>();
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<PantallaUnirse> {
});
final nearby = context.read<ServicioNearby>();
final perfil = context.read<ServicioPerfilUsuario>().perfil;
final servicioPerfil = context.read<ServicioPerfilUsuario>();
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<PantallaUnirse> {
_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<PantallaUnirse> {
// Iniciar búsqueda para que Nearby encuentre al host
final nearby = context.read<ServicioNearby>();
if (!nearby.buscando) {
final perfil = context.read<ServicioPerfilUsuario>().perfil;
final servicioPerfil = context.read<ServicioPerfilUsuario>();
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<PantallaUnirse> {
);
if (nombre != null && nombre.trim().isNotEmpty) {
final gamificacion =
context.read<ServicioPerfilUsuario>().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<PantallaUnirse> {
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(

View File

@@ -85,6 +85,8 @@ class ServicioNearby extends ChangeNotifier {
String? _miNombre;
String? _miNick;
String? _miAvatar;
int _miFuego = 0;
List<String> _miMedallas = const [];
final Map<String, JugadorConectado> _jugadores = {};
final List<OnMensajeCallback> _listeners = [];
@@ -213,6 +215,8 @@ class ServicioNearby extends ChangeNotifier {
String miNombre, {
String? miNick,
String? miAvatar,
int miFuego = 0,
List<String> 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<String> 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<String> 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<dynamic>? ?? 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<String> 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<String> 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;

View File

@@ -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<void> 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<String, dynamic>,
);
} catch (_) {
_estadisticas = const EstadisticasPerfilUsuario();
}
}
_cargado = true;
notifyListeners();
}
@@ -101,4 +132,34 @@ class ServicioPerfilUsuario extends ChangeNotifier {
await prefs.setString(_claveAvatar, _perfil.avatarAsset);
notifyListeners();
}
Future<ProgresoGamificacionUsuario> 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,
);
}
}

View File

@@ -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<String> 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<String> 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;

View File

@@ -34,3 +34,4 @@ flutter:
- assets/palabras_fr.json
- assets/words/
- assets/avatars/
- assets/medals/

View File

@@ -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'),
);
});
}