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
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

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+228
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,
);
}
}
+14
View File
@@ -7,6 +7,8 @@ class Usuario {
final String? foto; final String? foto;
final String? creadoPorClienteId; final String? creadoPorClienteId;
final String? clienteIdSeleccionado; final String? clienteIdSeleccionado;
final int fuego;
final List<String> medallas;
Usuario({ Usuario({
required this.id, required this.id,
@@ -16,6 +18,8 @@ class Usuario {
this.foto, this.foto,
this.creadoPorClienteId, this.creadoPorClienteId,
this.clienteIdSeleccionado, this.clienteIdSeleccionado,
this.fuego = 0,
this.medallas = const [],
}); });
bool get estaSeleccionado => clienteIdSeleccionado != null; bool get estaSeleccionado => clienteIdSeleccionado != null;
@@ -29,6 +33,8 @@ class Usuario {
String? foto, String? foto,
String? creadoPorClienteId, String? creadoPorClienteId,
String? clienteIdSeleccionado, String? clienteIdSeleccionado,
int? fuego,
List<String>? medallas,
bool liberarSeleccion = false, bool liberarSeleccion = false,
}) { }) {
return Usuario( return Usuario(
@@ -41,6 +47,8 @@ class Usuario {
clienteIdSeleccionado: liberarSeleccion clienteIdSeleccionado: liberarSeleccion
? null ? null
: (clienteIdSeleccionado ?? this.clienteIdSeleccionado), : (clienteIdSeleccionado ?? this.clienteIdSeleccionado),
fuego: fuego ?? this.fuego,
medallas: medallas ?? this.medallas,
); );
} }
@@ -53,6 +61,8 @@ class Usuario {
if (creadoPorClienteId != null) 'creadoPorClienteId': creadoPorClienteId, if (creadoPorClienteId != null) 'creadoPorClienteId': creadoPorClienteId,
if (clienteIdSeleccionado != null) if (clienteIdSeleccionado != null)
'clienteIdSeleccionado': clienteIdSeleccionado, 'clienteIdSeleccionado': clienteIdSeleccionado,
if (fuego > 0) 'fuego': fuego,
if (medallas.isNotEmpty) 'medallas': medallas,
}; };
factory Usuario.fromJson(Map<String, dynamic> json) => Usuario( factory Usuario.fromJson(Map<String, dynamic> json) => Usuario(
@@ -63,5 +73,9 @@ class Usuario {
foto: json['foto'] as String?, foto: json['foto'] as String?,
creadoPorClienteId: json['creadoPorClienteId'] as String?, creadoPorClienteId: json['creadoPorClienteId'] as String?,
clienteIdSeleccionado: json['clienteIdSeleccionado'] 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(),
); );
} }
+5 -1
View File
@@ -145,13 +145,17 @@ class _PantallaCrearPartidaState extends State<PantallaCrearPartida> {
// 3. Iniciar host en Nearby // 3. Iniciar host en Nearby
if (!mounted) return; if (!mounted) return;
final nearby = context.read<ServicioNearby>(); 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 nombreSala = '${nombre.trim()} - Farolero';
final ok = await nearby.iniciarHost( final ok = await nearby.iniciarHost(
nombreSala, nombreSala,
nombre.trim(), nombre.trim(),
miNick: perfil.nick, miNick: perfil.nick,
miAvatar: perfil.avatarAsset, miAvatar: perfil.avatarAsset,
miFuego: gamificacion.fuego,
miMedallas: gamificacion.medallas,
); );
if (!ok) { if (!ok) {
+72 -2
View File
@@ -3,8 +3,11 @@ import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../estado/estado_juego.dart'; import '../estado/estado_juego.dart';
import '../modelos/palabra.dart'; import '../modelos/palabra.dart';
import '../modelos/gamificacion_usuario.dart';
import '../servicios/servicio_historial_partidas.dart'; import '../servicios/servicio_historial_partidas.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_principal.dart'; import 'pantalla_principal.dart';
import 'pantalla_ver_palabra.dart'; import 'pantalla_ver_palabra.dart';
@@ -18,6 +21,7 @@ class PantallaFinPartida extends StatefulWidget {
class _PantallaFinPartidaState extends State<PantallaFinPartida> { class _PantallaFinPartidaState extends State<PantallaFinPartida> {
bool _guardada = false; bool _guardada = false;
ProgresoGamificacionUsuario? _progreso;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -31,9 +35,15 @@ class _PantallaFinPartidaState extends State<PantallaFinPartida> {
partida.jugadores.where((j) => j.esImpostor).toList(); partida.jugadores.where((j) => j.esImpostor).toList();
if (!_guardada && partida.ganador != null) { if (!_guardada && partida.ganador != null) {
_guardada = true; _guardada = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) { 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), const SizedBox(height: 24),
if (_progreso != null) ...[
_TarjetaProgresoGamificacion(progreso: _progreso!),
const SizedBox(height: 16),
],
// Palabra secreta // Palabra secreta
Card( 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),
],
],
),
),
);
}
}
+86 -4
View File
@@ -2,10 +2,13 @@ import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../modelos/inicio_partida_multijugador.dart'; import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/gamificacion_usuario.dart';
import '../modelos/palabra.dart'; import '../modelos/palabra.dart';
import '../modelos/snapshot_partida_online.dart'; import '../modelos/snapshot_partida_online.dart';
import '../servicios/servicio_historial_partidas.dart'; import '../servicios/servicio_historial_partidas.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_notas_online.dart'; import 'pantalla_notas_online.dart';
import 'pantalla_principal.dart'; import 'pantalla_principal.dart';
@@ -30,6 +33,7 @@ class PantallaFinPartidaOnline extends StatefulWidget {
class _PantallaFinPartidaOnlineState extends State<PantallaFinPartidaOnline> { class _PantallaFinPartidaOnlineState extends State<PantallaFinPartidaOnline> {
bool _guardada = false; bool _guardada = false;
ProgresoGamificacionUsuario? _progreso;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -41,11 +45,18 @@ class _PantallaFinPartidaOnlineState extends State<PantallaFinPartidaOnline> {
if (!_guardada && snapshot.ganador != null) { if (!_guardada && snapshot.ganador != null) {
_guardada = true; _guardada = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) async {
if (mounted) { if (mounted) {
context final historial = context.read<ServicioHistorialPartidas>();
.read<ServicioHistorialPartidas>() final perfil = context.read<ServicioPerfilUsuario>();
.guardarSnapshotOnline(snapshot); 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), const SizedBox(height: 24),
if (_progreso != null) ...[
_TarjetaProgresoGamificacion(progreso: _progreso!),
const SizedBox(height: 16),
],
Card( Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), 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),
],
],
),
),
);
}
} }
+71 -2
View File
@@ -4,12 +4,14 @@ import 'package:flutter/material.dart';
import 'package:farolero/l10n/generated/app_localizations.dart'; import 'package:farolero/l10n/generated/app_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../estado/estado_juego.dart'; import '../estado/estado_juego.dart';
import '../modelos/gamificacion_usuario.dart';
import '../modelos/inicio_partida_multijugador.dart'; import '../modelos/inicio_partida_multijugador.dart';
import '../modelos/jugador.dart'; import '../modelos/jugador.dart';
import '../modelos/partida.dart'; import '../modelos/partida.dart';
import '../modelos/snapshot_partida_online.dart'; import '../modelos/snapshot_partida_online.dart';
import '../servicios/servicio_historial_partidas.dart'; import '../servicios/servicio_historial_partidas.dart';
import '../servicios/servicio_nearby.dart'; import '../servicios/servicio_nearby.dart';
import '../servicios/servicio_perfil_usuario.dart';
import '../tema/componentes_farolero.dart'; import '../tema/componentes_farolero.dart';
import '../tema/tema_app.dart'; import '../tema/tema_app.dart';
import 'pantalla_notas_online.dart'; import 'pantalla_notas_online.dart';
@@ -31,6 +33,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
int _segundosRestantes = 0; int _segundosRestantes = 0;
bool _hostListo = false; bool _hostListo = false;
bool _partidaOnlineGuardada = false; bool _partidaOnlineGuardada = false;
ProgresoGamificacionUsuario? _progresoGamificacion;
String? _primerTurnoId; String? _primerTurnoId;
String? _primerTurnoNombre; String? _primerTurnoNombre;
final Map<String, bool> _clientesListos = {}; final Map<String, bool> _clientesListos = {};
@@ -695,7 +698,7 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
label: Text( label: Text(
_hostYaVoto(context) _hostYaVoto(context)
? 'Votos del host registrados' ? '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), partida == null ? '' : l10n.theWordWas(partida.palabraSecreta),
textAlign: TextAlign.center, 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) { bool _hostYaVoto(BuildContext context) {
final estado = context.read<EstadoJuego>(); final estado = context.read<EstadoJuego>();
final sala = context.read<ServicioNearby>().estadoSala; final sala = context.read<ServicioNearby>().estadoSala;
@@ -1182,7 +1230,28 @@ class _PantallaGestorHostState extends State<PantallaGestorHost> {
final partida = context.read<EstadoJuego>().partida; final partida = context.read<EstadoJuego>().partida;
if (partida?.ganador == null) return; if (partida?.ganador == null) return;
_partidaOnlineGuardada = true; _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 { Future<void> _iniciarAdivinanzaOnline(BuildContext context) async {
+21 -6
View File
@@ -217,15 +217,26 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
? TemaApp.colorNaranja ? TemaApp.colorNaranja
: TemaApp.colorAzul, : TemaApp.colorAzul,
size: 38, size: 38,
fuego: usuario.fuego,
medallas: usuario.medallas,
), ),
), ),
title: Text(usuario.nombre), title: Text(usuario.nombre),
subtitle: Text( subtitle: Column(
seleccionadoPorMi crossAxisAlignment: CrossAxisAlignment.start,
? 'Seleccionado por este móvil' children: [
: seleccionadoPorOtro Text(
? 'Seleccionado por otro cliente' seleccionadoPorMi
: 'Disponible', ? '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( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -300,11 +311,15 @@ class _PantallaLobbyHostState extends State<PantallaLobbyHost> {
); );
if (nombre != null && nombre.trim().isNotEmpty) { if (nombre != null && nombre.trim().isNotEmpty) {
final gamificacion =
context.read<ServicioPerfilUsuario>().resumenGamificacion;
await nearby.crearUsuarioSala( await nearby.crearUsuarioSala(
nombre.trim(), nombre.trim(),
seleccionar: true, seleccionar: true,
nick: perfil.nick, nick: perfil.nick,
avatar: perfil.avatarAsset, avatar: perfil.avatarAsset,
fuego: gamificacion.fuego,
medallas: gamificacion.medallas,
); );
} }
} }
+7 -3
View File
@@ -16,7 +16,9 @@ class PantallaPrincipal extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(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( return Scaffold(
body: FondoFarolero( body: FondoFarolero(
@@ -35,6 +37,8 @@ class PantallaPrincipal extends StatelessWidget {
texto: perfil.nombre.substring(0, 1).toUpperCase(), texto: perfil.nombre.substring(0, 1).toUpperCase(),
assetPath: perfil.avatarAsset, assetPath: perfil.avatarAsset,
size: 48, size: 48,
fuego: gamificacion.fuego,
medallas: gamificacion.medallas,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
@@ -53,9 +57,9 @@ class PantallaPrincipal extends StatelessWidget {
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator( child: LinearProgressIndicator(
value: 0.68, value: gamificacion.fuego / 100,
minHeight: 4, minHeight: 4,
color: TemaApp.colorPurpura, color: TemaApp.colorNaranja,
backgroundColor: Colors.black.withValues(alpha: 0.45), backgroundColor: Colors.black.withValues(alpha: 0.45),
), ),
), ),
+40 -12
View File
@@ -314,11 +314,15 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
if (!mounted) return; if (!mounted) return;
final nearby = context.read<ServicioNearby>(); 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( final ok = await nearby.buscarHosts(
_nombreController.text.trim(), _nombreController.text.trim(),
miNick: perfil.nick, miNick: perfil.nick,
miAvatar: perfil.avatarAsset, miAvatar: perfil.avatarAsset,
miFuego: gamificacion.fuego,
miMedallas: gamificacion.medallas,
); );
if (ok) { if (ok) {
@@ -342,7 +346,9 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
}); });
final nearby = context.read<ServicioNearby>(); 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 // Parar discovery antes de conectar
await nearby.pararBusqueda(); await nearby.pararBusqueda();
final ok = await nearby.conectarAHost( final ok = await nearby.conectarAHost(
@@ -350,6 +356,8 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
_nombreController.text.trim(), _nombreController.text.trim(),
miNick: perfil.nick, miNick: perfil.nick,
miAvatar: perfil.avatarAsset, miAvatar: perfil.avatarAsset,
miFuego: gamificacion.fuego,
miMedallas: gamificacion.medallas,
); );
if (!ok && mounted) { if (!ok && mounted) {
@@ -389,11 +397,15 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
// Iniciar búsqueda para que Nearby encuentre al host // Iniciar búsqueda para que Nearby encuentre al host
final nearby = context.read<ServicioNearby>(); final nearby = context.read<ServicioNearby>();
if (!nearby.buscando) { 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( await nearby.buscarHosts(
_nombreController.text.trim(), _nombreController.text.trim(),
miNick: perfil.nick, miNick: perfil.nick,
miAvatar: perfil.avatarAsset, miAvatar: perfil.avatarAsset,
miFuego: gamificacion.fuego,
miMedallas: gamificacion.medallas,
); );
} }
return; return;
@@ -835,11 +847,15 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
); );
if (nombre != null && nombre.trim().isNotEmpty) { if (nombre != null && nombre.trim().isNotEmpty) {
final gamificacion =
context.read<ServicioPerfilUsuario>().resumenGamificacion;
await nearby.crearUsuarioSala( await nearby.crearUsuarioSala(
nombre.trim(), nombre.trim(),
seleccionar: true, seleccionar: true,
nick: perfil.nick, nick: perfil.nick,
avatar: perfil.avatarAsset, avatar: perfil.avatarAsset,
fuego: gamificacion.fuego,
medallas: gamificacion.medallas,
); );
} }
} }
@@ -861,17 +877,29 @@ class _PantallaUnirseState extends State<PantallaUnirse> {
usuario.estaSeleccionado && usuario.clienteIdSeleccionado != miClientId; usuario.estaSeleccionado && usuario.clienteIdSeleccionado != miClientId;
return ListTile( return ListTile(
leading: Text( leading: AvatarFarolero(
usuario.avatar ?? '??', texto: usuario.nombre.isEmpty ? '?' : usuario.nombre[0],
style: const TextStyle(fontSize: 24), assetPath: usuario.avatar,
size: 38,
fuego: usuario.fuego,
medallas: usuario.medallas,
), ),
title: Text(usuario.nombre), title: Text(usuario.nombre),
subtitle: Text( subtitle: Column(
seleccionadoPorMi crossAxisAlignment: CrossAxisAlignment.start,
? 'Seleccionado por este m?vil' children: [
: seleccionadoPorOtro Text(
? 'No disponible' seleccionadoPorMi
: 'Disponible', ? 'Seleccionado por este móvil'
: seleccionadoPorOtro
? 'No disponible'
: 'Disponible',
),
if (usuario.medallas.isNotEmpty) ...[
const SizedBox(height: 4),
MedallasCompactasFarolero(ids: usuario.medallas),
],
],
), ),
trailing: seleccionadoPorMi trailing: seleccionadoPorMi
? IconButton( ? IconButton(
+32
View File
@@ -85,6 +85,8 @@ class ServicioNearby extends ChangeNotifier {
String? _miNombre; String? _miNombre;
String? _miNick; String? _miNick;
String? _miAvatar; String? _miAvatar;
int _miFuego = 0;
List<String> _miMedallas = const [];
final Map<String, JugadorConectado> _jugadores = {}; final Map<String, JugadorConectado> _jugadores = {};
final List<OnMensajeCallback> _listeners = []; final List<OnMensajeCallback> _listeners = [];
@@ -213,6 +215,8 @@ class ServicioNearby extends ChangeNotifier {
String miNombre, { String miNombre, {
String? miNick, String? miNick,
String? miAvatar, String? miAvatar,
int miFuego = 0,
List<String> miMedallas = const [],
}) async { }) async {
if (_conectado || if (_conectado ||
_anunciando || _anunciando ||
@@ -245,6 +249,8 @@ class ServicioNearby extends ChangeNotifier {
foto: miAvatar, foto: miAvatar,
creadoPorClienteId: _hostClientId, creadoPorClienteId: _hostClientId,
clienteIdSeleccionado: _hostClientId, clienteIdSeleccionado: _hostClientId,
fuego: miFuego,
medallas: miMedallas,
); );
_estadoSala!.crearUsuario(usuarioHost); _estadoSala!.crearUsuario(usuarioHost);
_sincronizarPoolDesdeSala(); _sincronizarPoolDesdeSala();
@@ -281,10 +287,14 @@ class ServicioNearby extends ChangeNotifier {
String miNombre, { String miNombre, {
String? miNick, String? miNick,
String? miAvatar, String? miAvatar,
int miFuego = 0,
List<String> miMedallas = const [],
}) async { }) async {
_miNombre = miNombre; _miNombre = miNombre;
_miNick = miNick; _miNick = miNick;
_miAvatar = miAvatar; _miAvatar = miAvatar;
_miFuego = miFuego;
_miMedallas = miMedallas;
try { try {
final resultado = await Nearby().startDiscovery( final resultado = await Nearby().startDiscovery(
@@ -312,10 +322,14 @@ class ServicioNearby extends ChangeNotifier {
String miNombre, { String miNombre, {
String? miNick, String? miNick,
String? miAvatar, String? miAvatar,
int miFuego = 0,
List<String> miMedallas = const [],
}) async { }) async {
_miNombre = miNombre; _miNombre = miNombre;
_miNick = miNick; _miNick = miNick;
_miAvatar = miAvatar; _miAvatar = miAvatar;
_miFuego = miFuego;
_miMedallas = miMedallas;
try { try {
await Nearby().requestConnection( await Nearby().requestConnection(
miNombre, miNombre,
@@ -359,6 +373,8 @@ class ServicioNearby extends ChangeNotifier {
'nombre': _miNombre ?? 'Jugador', 'nombre': _miNombre ?? 'Jugador',
if (_miNick != null) 'nick': _miNick, if (_miNick != null) 'nick': _miNick,
if (_miAvatar != null) 'avatar': _miAvatar, 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 nombre = mensaje.datos['nombre'] as String? ?? 'Jugador';
final nick = mensaje.datos['nick'] as String?; final nick = mensaje.datos['nick'] as String?;
final avatar = mensaje.datos['avatar'] 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; final clientId = endpointId;
_jugadores[endpointId] = JugadorConectado( _jugadores[endpointId] = JugadorConectado(
endpointId: endpointId, endpointId: endpointId,
@@ -528,6 +548,8 @@ class ServicioNearby extends ChangeNotifier {
nombre: nombre, nombre: nombre,
nick: nick, nick: nick,
avatar: avatar, avatar: avatar,
fuego: fuego,
medallas: medallas,
); );
enviarMensaje( enviarMensaje(
@@ -555,6 +577,8 @@ class ServicioNearby extends ChangeNotifier {
required String nombre, required String nombre,
String? nick, String? nick,
String? avatar, String? avatar,
int fuego = 0,
List<String> medallas = const [],
}) { }) {
final sala = _estadoSala; final sala = _estadoSala;
if (sala == null || sala.fase != FaseSalaMultijugador.lobby) return; if (sala == null || sala.fase != FaseSalaMultijugador.lobby) return;
@@ -579,6 +603,8 @@ class ServicioNearby extends ChangeNotifier {
avatar: avatar, avatar: avatar,
foto: avatar, foto: avatar,
creadoPorClienteId: clientId, creadoPorClienteId: clientId,
fuego: fuego,
medallas: medallas,
); );
final resultadoCrear = sala.crearUsuario(usuario); final resultadoCrear = sala.crearUsuario(usuario);
if (!resultadoCrear.exitoso) return; if (!resultadoCrear.exitoso) return;
@@ -769,6 +795,8 @@ class ServicioNearby extends ChangeNotifier {
bool seleccionar = true, bool seleccionar = true,
String? nick, String? nick,
String? avatar, String? avatar,
int fuego = 0,
List<String> medallas = const [],
}) async { }) async {
final nombreLimpio = nombre.trim(); final nombreLimpio = nombre.trim();
if (nombreLimpio.isEmpty) return; if (nombreLimpio.isEmpty) return;
@@ -780,6 +808,8 @@ class ServicioNearby extends ChangeNotifier {
avatar: avatar, avatar: avatar,
foto: avatar, foto: avatar,
creadoPorClienteId: clientId, creadoPorClienteId: clientId,
fuego: fuego,
medallas: medallas,
); );
if (_esHost && _estadoSala != null && clientId != null) { if (_esHost && _estadoSala != null && clientId != null) {
final resultado = _estadoSala!.crearUsuario(usuario); final resultado = _estadoSala!.crearUsuario(usuario);
@@ -979,6 +1009,8 @@ class ServicioNearby extends ChangeNotifier {
_miNombre = null; _miNombre = null;
_miNick = null; _miNick = null;
_miAvatar = null; _miAvatar = null;
_miFuego = 0;
_miMedallas = const [];
_palabraRecibida = null; _palabraRecibida = null;
_soyImpostor = null; _soyImpostor = null;
_faseActual = null; _faseActual = null;
@@ -1,6 +1,10 @@
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../modelos/gamificacion_usuario.dart';
class PerfilUsuario { class PerfilUsuario {
final String nombre; final String nombre;
final String nick; final String nick;
@@ -25,6 +29,7 @@ class ServicioPerfilUsuario extends ChangeNotifier {
static const _claveNombre = 'perfil.nombre'; static const _claveNombre = 'perfil.nombre';
static const _claveNick = 'perfil.nick'; static const _claveNick = 'perfil.nick';
static const _claveAvatar = 'perfil.avatar'; static const _claveAvatar = 'perfil.avatar';
static const _claveEstadisticas = 'perfil.estadisticas';
static const avatares = [ static const avatares = [
'assets/avatars/avatar_01.png', 'assets/avatars/avatar_01.png',
@@ -57,6 +62,18 @@ class ServicioPerfilUsuario extends ChangeNotifier {
'assets/avatars/avatar_28.png', 'assets/avatars/avatar_28.png',
'assets/avatars/avatar_29.png', 'assets/avatars/avatar_29.png',
'assets/avatars/avatar_30.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( PerfilUsuario _perfil = const PerfilUsuario(
@@ -64,9 +81,13 @@ class ServicioPerfilUsuario extends ChangeNotifier {
nick: 'farolero', nick: 'farolero',
avatarAsset: 'assets/avatars/avatar_01.png', avatarAsset: 'assets/avatars/avatar_01.png',
); );
EstadisticasPerfilUsuario _estadisticas =
const EstadisticasPerfilUsuario();
bool _cargado = false; bool _cargado = false;
PerfilUsuario get perfil => _perfil; PerfilUsuario get perfil => _perfil;
EstadisticasPerfilUsuario get estadisticas => _estadisticas;
ResumenGamificacionUsuario get resumenGamificacion => _estadisticas.resumen;
bool get cargado => _cargado; bool get cargado => _cargado;
Future<void> cargar() async { Future<void> cargar() async {
@@ -76,6 +97,16 @@ class ServicioPerfilUsuario extends ChangeNotifier {
nick: prefs.getString(_claveNick) ?? _perfil.nick, nick: prefs.getString(_claveNick) ?? _perfil.nick,
avatarAsset: prefs.getString(_claveAvatar) ?? _perfil.avatarAsset, 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; _cargado = true;
notifyListeners(); notifyListeners();
} }
@@ -101,4 +132,34 @@ class ServicioPerfilUsuario extends ChangeNotifier {
await prefs.setString(_claveAvatar, _perfil.avatarAsset); await prefs.setString(_claveAvatar, _perfil.avatarAsset);
notifyListeners(); 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,
);
}
} }
+160 -28
View File
@@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import '../modelos/gamificacion_usuario.dart';
import 'tema_app.dart'; import 'tema_app.dart';
class FondoFarolero extends StatelessWidget { class FondoFarolero extends StatelessWidget {
@@ -272,6 +273,8 @@ class AvatarFarolero extends StatelessWidget {
final String? assetPath; final String? assetPath;
final Color color; final Color color;
final double size; final double size;
final int fuego;
final List<String> medallas;
const AvatarFarolero({ const AvatarFarolero({
super.key, super.key,
@@ -279,43 +282,172 @@ class AvatarFarolero extends StatelessWidget {
this.assetPath, this.assetPath,
this.color = TemaApp.colorNaranja, this.color = TemaApp.colorNaranja,
this.size = 40, this.size = 40,
this.fuego = 0,
this.medallas = const [],
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( final lienzo = size + 10;
width: size, return SizedBox(
height: size, width: lienzo,
decoration: BoxDecoration( height: lienzo,
shape: BoxShape.circle, child: Stack(
gradient: RadialGradient( clipBehavior: Clip.none,
colors: [color.withValues(alpha: 0.9), TemaApp.colorSuperficie], alignment: Alignment.center,
), children: [
border: Border.all(color: TemaApp.colorDorado, width: 2), CustomPaint(
), size: Size(lienzo, lienzo),
child: Center( painter: _FuegoAvatarPainter(fuego: fuego),
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,
),
),
), ),
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 { class _FondoFaroleroPainter extends CustomPainter {
final bool intenso; final bool intenso;
+1
View File
@@ -34,3 +34,4 @@ flutter:
- assets/palabras_fr.json - assets/palabras_fr.json
- assets/words/ - assets/words/
- assets/avatars/ - assets/avatars/
- assets/medals/
+79
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'),
);
});
}