Gamificación
BIN
assets/medals/brasa.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/medals/cincuenta_victorias.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/medals/diez_victorias.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/medals/habitual.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
assets/medals/impostor_habitual.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/medals/incandescente.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/medals/leyenda.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/medals/llama_fuerte.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/medals/llama_suave.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/medals/lobo_faroles.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/medals/novato.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/medals/primer_engano.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/medals/primera_victoria.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/medals/veinticinco_victorias.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
assets/medals/veterano.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
228
lib/modelos/gamificacion_usuario.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -34,3 +34,4 @@ flutter:
|
||||
- assets/palabras_fr.json
|
||||
- assets/words/
|
||||
- assets/avatars/
|
||||
- assets/medals/
|
||||
|
||||
79
test/gamificacion_usuario_test.dart
Normal 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'),
|
||||
);
|
||||
});
|
||||
}
|
||||