Files
farolero/lib/tema/componentes_farolero.dart
T
FreeTLab 863690168c feat(multidispositivo): reconnection, impostor awareness and protocol fixes
Fixes
- SnapshotPartidaOnline no longer serializes esImpostor unless the game is
  over. Every phase change was broadcasting the impostor roles in clear to
  all clients.
- The impostor clue travels in its own field and only when the game enables
  it. It used to be sent as the category key ("todas" when no category was
  picked) and was overwritten by each phase snapshot.
- Eliminated players can no longer vote nor be voted for, on both the client
  screen and the host listener.
- Nearby listeners are removed on dispose; votes are no longer dispatched
  twice; votacionResultado no longer triggers two navigations.
- _jugadoresHostControlados handed the secret word to the host's own
  impostors in the payload.
- Impostor cap unified as maxImpostoresPara(n) for both modes, and the
  silent clamp in multi-device now reports the effective number.
- palabraAleatoria uses Random.secure.

Impostors know each other
- ConfigPartida.impostoresSeConocen, on by default. companerosImpostores is
  nullable: null means nothing to show, an empty list means "you are the
  only impostor".

Reconnection
- IdentidadDispositivo persists a stable device id; the host uses it as the
  clientId so a phone that drops and returns is the same client. Falls back
  to the endpointId for clients that do not send one.
- Usuario.absorbidoDe records the original owner when the host takes over a
  disconnected player, and they are handed back automatically on return.
- New solicitarResync/resync messages: the host replies with the current
  phase plus that client's own players, and the client jumps straight to the
  running phase.
- Readiness is keyed by clientId instead of endpointId, and a disconnected
  client no longer blocks the round.
- The retry gives up after three minutes, only reconnects to the host it
  belonged to, and is cancelled correctly while shutting down.

Per-word clue support
- The word bank loader accepts both the old list-of-strings format and the
  new one carrying a clue per word, falling back to the category clue.

Not verified on real devices: Nearby reconnection timing still needs two
Android phones.
2026-07-25 20:36:32 +02:00

1336 lines
40 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../l10n/generated/app_localizations.dart';
import '../modelos/gamificacion_usuario.dart';
import 'tema_app.dart';
class IconoFarolero extends StatelessWidget {
final IconData icono;
final String? assetPath;
final Color? color;
final double size;
const IconoFarolero(
this.icono, {
super.key,
this.assetPath,
this.color,
this.size = 28,
});
static String? assetFor(IconData icono) {
if (icono == Icons.arrow_back) return 'assets/ui/generated/icons/nav_back.webp';
if (icono == Icons.close) return 'assets/ui/generated/icons/nav_close.webp';
if (icono == Icons.check ||
icono == Icons.check_circle ||
icono == Icons.check_circle_outline) {
return 'assets/ui/generated/icons/status_check.webp';
}
if (icono == Icons.arrow_forward ||
icono == Icons.arrow_forward_ios ||
icono == Icons.chevron_right_rounded ||
icono == Icons.skip_next) {
return 'assets/ui/generated/icons/nav_forward.webp';
}
if (icono == Icons.home) return 'assets/ui/generated/icons/nav_home.webp';
if (icono == Icons.replay) return 'assets/ui/generated/icons/action_replay.webp';
if (icono == Icons.save) return 'assets/ui/generated/icons/action_save.webp';
if (icono == Icons.settings_rounded) return 'assets/ui/generated/icons/settings_gear.webp';
if (icono == Icons.delete_outline) return 'assets/ui/generated/icons/action_delete.webp';
if (icono == Icons.lock) return 'assets/ui/generated/icons/status_lock.webp';
if (icono == Icons.key) return 'assets/ui/generated/icons/status_key.webp';
if (icono == Icons.visibility) return 'assets/ui/generated/icons/action_reveal_eye.webp';
if (icono == Icons.visibility_off) return 'assets/ui/generated/icons/action_hide_eye.webp';
if (icono == Icons.add ||
icono == Icons.add_circle_outline ||
icono == Icons.person_add ||
icono == Icons.person_add_alt_1) {
return 'assets/ui/generated/icons/control_plus.webp';
}
if (icono == Icons.remove_circle_outline) return 'assets/ui/generated/icons/control_minus.webp';
if (icono == Icons.cancel || icono == Icons.person_off) {
return 'assets/ui/generated/icons/status_cancel.webp';
}
if (icono == Icons.link_off) return 'assets/ui/generated/icons/status_disconnected.webp';
if (icono == Icons.alternate_email) return 'assets/ui/generated/icons/profile_identity.webp';
if (icono == Icons.question_mark_rounded) return 'assets/ui/generated/icons/help_question.webp';
if (icono == Icons.bolt_rounded) return 'assets/ui/generated/icons/quick_bolt.webp';
if (icono == Icons.mood) return 'assets/ui/generated/icons/mood_happy.webp';
if (icono == Icons.sentiment_dissatisfied) return 'assets/ui/generated/icons/mood_sad.webp';
if (icono == Icons.search ||
icono == Icons.person_search ||
icono == Icons.gps_fixed) {
return 'assets/ui/generated/actions/action_join_search.webp';
}
if (icono == Icons.how_to_vote) return 'assets/ui/generated/actions/action_vote_mask.webp';
if (icono == Icons.edit_note || icono == Icons.note) {
return 'assets/ui/generated/actions/action_notes_quill.webp';
}
if (icono == Icons.theater_comedy ||
icono == Icons.theater_comedy_rounded ||
icono == Icons.psychology) {
return 'assets/ui/generated/icons/impostor_shadow.webp';
}
if (icono == Icons.emoji_events ||
icono == Icons.celebration ||
icono == Icons.bar_chart) {
return icono == Icons.bar_chart
? 'assets/ui/generated/icons/stats_chart.webp'
: 'assets/ui/generated/icons/winner_laurel.webp';
}
if (icono == Icons.forum ||
icono == Icons.chat_bubble ||
icono == Icons.record_voice_over) {
return 'assets/ui/generated/actions/action_rules_book.webp';
}
if (icono == Icons.person) return 'assets/ui/generated/icons/profile_player.webp';
if (icono == Icons.groups ||
icono == Icons.groups_rounded) {
return 'assets/ui/generated/actions/action_players_group.webp';
}
if (icono == Icons.devices ||
icono == Icons.devices_rounded ||
icono == Icons.phone_android ||
icono == Icons.phone_android_rounded) {
return 'assets/ui/generated/actions/action_mobile_device.webp';
}
if (icono == Icons.wifi_tethering ||
icono == Icons.bluetooth_searching ||
icono == Icons.radar ||
icono == Icons.sync) {
return 'assets/ui/generated/actions/action_multidevice_signal.webp';
}
if (icono == Icons.qr_code_scanner) return 'assets/ui/generated/actions/action_qr_scan.webp';
if (icono == Icons.history_rounded) return 'assets/ui/generated/actions/action_history_ledger.webp';
if (icono == Icons.menu_book_rounded || icono == Icons.lightbulb) {
return 'assets/ui/generated/actions/action_rules_book.webp';
}
if (icono == Icons.hourglass_bottom || icono == Icons.hourglass_empty) {
return 'assets/ui/generated/icons/timer_hourglass.webp';
}
if (icono == Icons.play_arrow || icono == Icons.play_arrow_rounded) {
return 'assets/ui/generated/actions/action_create_game.webp';
}
return null;
}
@override
Widget build(BuildContext context) {
final resolved = assetPath ?? assetFor(icono);
if (resolved == null) {
return Icon(icono, color: color, size: size);
}
return Image.asset(
resolved,
width: size,
height: size,
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
errorBuilder: (context, error, stackTrace) =>
Icon(icono, color: color, size: size),
);
}
}
class FondoFarolero extends StatelessWidget {
final Widget child;
final bool intenso;
const FondoFarolero({
super.key,
required this.child,
this.intenso = false,
});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(gradient: TemaApp.gradienteFondo),
child: Stack(
children: [
Positioned.fill(
child: IgnorePointer(
child: Image.asset(
'assets/ui/generated/shared/screen_atmosphere_bg.webp',
fit: BoxFit.cover,
opacity: AlwaysStoppedAnimation(intenso ? 0.76 : 0.48),
filterQuality: FilterQuality.high,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
),
),
Positioned.fill(child: child),
],
),
);
}
}
class PanelFarolero extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? margin;
final Color? color;
final Color? borderColor;
const PanelFarolero({
super.key,
required this.child,
this.padding = const EdgeInsets.all(16),
this.margin,
this.color,
this.borderColor,
});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: margin,
decoration: TemaApp.decoracionPanel(color: color, borderColor: borderColor),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Stack(
children: [
Padding(padding: padding, child: child),
],
),
),
);
}
}
class EncabezadoFarolero extends StatelessWidget {
final IconData icono;
final String? assetIconPath;
final String titulo;
final String? subtitulo;
final Color color;
final Widget? trailing;
final EdgeInsetsGeometry padding;
const EncabezadoFarolero({
super.key,
required this.icono,
this.assetIconPath,
required this.titulo,
this.subtitulo,
this.color = TemaApp.colorNaranja,
this.trailing,
this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
});
@override
Widget build(BuildContext context) {
return PanelFarolero(
padding: padding,
child: Row(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
color.withValues(alpha: 0.34),
TemaApp.colorSuperficie.withValues(alpha: 0.72),
],
),
border: Border.all(color: color.withValues(alpha: 0.72)),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.22),
blurRadius: 22,
),
],
),
child: Padding(
padding: const EdgeInsets.all(6),
child: IconoFarolero(
icono,
assetPath: assetIconPath,
color: color,
size: 30,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
titulo,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: TemaApp.colorDorado,
fontWeight: FontWeight.w900,
),
),
if (subtitulo != null) ...[
const SizedBox(height: 3),
Text(
subtitulo!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
),
),
if (trailing != null) ...[
const SizedBox(width: 12),
trailing!,
],
],
),
);
}
}
class EstadoVacioFarolero extends StatelessWidget {
final IconData icono;
final String? assetIconPath;
final String titulo;
final String subtitulo;
const EstadoVacioFarolero({
super.key,
required this.icono,
this.assetIconPath,
required this.titulo,
required this.subtitulo,
});
@override
Widget build(BuildContext context) {
return PanelFarolero(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 64,
height: 64,
child: IconoFarolero(
icono,
assetPath: assetIconPath,
color: TemaApp.colorNaranja,
size: 58,
),
),
const SizedBox(height: 14),
Text(
titulo,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
subtitulo,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
);
}
}
class LogoFarolero extends StatelessWidget {
final double size;
const LogoFarolero({super.key, this.size = 64});
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
Positioned(
top: 0,
child: IconoFarolero(
Icons.lightbulb,
color: TemaApp.colorDorado.withValues(alpha: 0.32),
size: size * 0.82,
),
),
Text(
'FAROLERO',
style: GoogleFonts.bangers(
fontSize: size,
color: TemaApp.colorNaranja,
letterSpacing: 0,
shadows: const [
Shadow(offset: Offset(3, 4), blurRadius: 0, color: Color(0xFF5E1205)),
Shadow(offset: Offset(0, 0), blurRadius: 16, color: Color(0xFFFFC247)),
],
),
),
],
);
}
}
class BotonFarolero extends StatelessWidget {
final String texto;
final IconData icono;
final String? assetIconPath;
final VoidCallback? onPressed;
final LinearGradient gradient;
final Color foreground;
final bool destacado;
const BotonFarolero({
super.key,
required this.texto,
required this.icono,
this.assetIconPath,
required this.onPressed,
this.gradient = TemaApp.gradientePrimario,
this.foreground = Colors.black,
this.destacado = true,
});
const BotonFarolero.secundario({
super.key,
required this.texto,
required this.icono,
this.assetIconPath,
required this.onPressed,
}) : gradient = const LinearGradient(
colors: [TemaApp.colorPurpura, Color(0xFF2B1736)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
foreground = Colors.white,
destacado = false;
const BotonFarolero.oscuro({
super.key,
required this.texto,
required this.icono,
this.assetIconPath,
required this.onPressed,
}) : gradient = const LinearGradient(
colors: [Color(0xFF151F27), Color(0xFF090E13)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
foreground = TemaApp.colorTexto,
destacado = false;
@override
Widget build(BuildContext context) {
final habilitado = onPressed != null;
final colorTexto = habilitado ? foreground : TemaApp.colorTextoSecundario;
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(24),
onTap: onPressed,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 66),
child: CustomPaint(
painter: MarcoBotonFaroleroPainter(
gradient: habilitado
? gradient
: const LinearGradient(
colors: [TemaApp.colorTarjeta, TemaApp.colorSuperficie],
),
destacado: destacado,
habilitado: habilitado,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 15),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 44,
height: 40,
child: IconoFarolero(
icono,
assetPath: assetIconPath,
color: colorTexto,
size: 38,
),
),
const SizedBox(width: 10),
Expanded(
child: Center(
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
texto.toUpperCase(),
maxLines: 1,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: colorTexto,
fontSize: 19,
fontWeight: FontWeight.w900,
letterSpacing: 0.9,
),
),
),
),
),
const SizedBox(width: 54),
],
),
),
),
),
),
);
}
}
class TarjetaFaseFarolero extends StatelessWidget {
final IconData icono;
final String? assetIconPath;
final String titulo;
final String? subtitulo;
final Color color;
final Widget child;
final EdgeInsetsGeometry padding;
const TarjetaFaseFarolero({
super.key,
required this.icono,
this.assetIconPath,
required this.titulo,
this.subtitulo,
this.color = TemaApp.colorNaranja,
this.child = const SizedBox.shrink(),
this.padding = const EdgeInsets.all(18),
});
@override
Widget build(BuildContext context) {
return PanelFarolero(
padding: padding,
borderColor: color.withValues(alpha: 0.48),
color: TemaApp.colorSuperficie.withValues(alpha: 0.84),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 54,
height: 54,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
color.withValues(alpha: 0.42),
Colors.black.withValues(alpha: 0.62),
],
),
border: Border.all(color: color.withValues(alpha: 0.72)),
),
child: Padding(
padding: const EdgeInsets.all(6),
child: IconoFarolero(
icono,
assetPath: assetIconPath,
color: color,
size: 30,
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
titulo,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: TemaApp.colorDorado,
fontWeight: FontWeight.w900,
),
),
if (subtitulo != null) ...[
const SizedBox(height: 3),
Text(
subtitulo!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: TemaApp.colorTextoSecundario,
),
),
],
],
),
),
],
),
const SizedBox(height: 16),
child,
],
),
);
}
}
class EstadoJugadorFarolero extends StatelessWidget {
final String nombre;
final bool destacado;
final bool completado;
final IconData icono;
final String? assetIconPath;
final String? subtitulo;
final VoidCallback? onTap;
const EstadoJugadorFarolero({
super.key,
required this.nombre,
this.destacado = false,
this.completado = false,
this.icono = Icons.person,
this.assetIconPath,
this.subtitulo,
this.onTap,
});
@override
Widget build(BuildContext context) {
final color = completado ? TemaApp.colorVerde : TemaApp.colorNaranja;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: completado
? TemaApp.colorVerde.withValues(alpha: 0.14)
: TemaApp.colorTarjeta.withValues(alpha: 0.84),
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: destacado
? TemaApp.colorDorado.withValues(alpha: 0.72)
: color.withValues(alpha: completado ? 0.54 : 0.28),
),
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color.withValues(alpha: 0.16),
border: Border.all(color: color.withValues(alpha: 0.72)),
),
child: Padding(
padding: const EdgeInsets.all(5),
child: IconoFarolero(
icono,
assetPath: assetIconPath,
color: color,
size: 26,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
nombre,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: destacado ? FontWeight.w900 : FontWeight.w700,
),
),
if (subtitulo != null) ...[
const SizedBox(height: 2),
Text(
subtitulo!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: TemaApp.colorTextoSecundario,
),
),
],
],
),
),
IconoFarolero(
completado ? Icons.check_circle : Icons.hourglass_bottom,
color: completado ? TemaApp.colorVerde : TemaApp.colorTextoSecundario,
size: 28,
),
],
),
),
),
),
);
}
}
class SelectorVotoFarolero extends StatelessWidget {
final String nombre;
final bool seleccionado;
final VoidCallback onTap;
const SelectorVotoFarolero({
super.key,
required this.nombre,
required this.seleccionado,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return EstadoJugadorFarolero(
nombre: nombre,
destacado: seleccionado,
completado: seleccionado,
icono: seleccionado ? Icons.check : Icons.person_search,
onTap: onTap,
);
}
}
class TemporizadorFarolero extends StatelessWidget {
final String etiqueta;
final String tiempo;
final bool agotado;
const TemporizadorFarolero({
super.key,
required this.etiqueta,
required this.tiempo,
this.agotado = false,
});
@override
Widget build(BuildContext context) {
final color = agotado ? TemaApp.colorAcento : TemaApp.colorNaranja;
return PanelFarolero(
padding: const EdgeInsets.all(22),
borderColor: color.withValues(alpha: 0.62),
color: color.withValues(alpha: agotado ? 0.18 : 0.10),
child: Stack(
alignment: Alignment.center,
children: [
Image.asset(
'assets/ui/generated/actions/action_timer_hourglass.webp',
height: 132,
fit: BoxFit.contain,
opacity: const AlwaysStoppedAnimation(0.34),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
etiqueta,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
tiempo,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: color,
fontWeight: FontWeight.w900,
),
),
],
),
],
),
);
}
}
class MarcoBotonFaroleroPainter extends CustomPainter {
final LinearGradient gradient;
final bool destacado;
final bool habilitado;
final double radio;
const MarcoBotonFaroleroPainter({
required this.gradient,
required this.destacado,
required this.habilitado,
this.radio = 24,
});
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
final cuerpo = rect.deflate(3);
final rrect = RRect.fromRectAndRadius(cuerpo, Radius.circular(radio));
final alpha = habilitado ? 1.0 : 0.42;
final oro = TemaApp.colorDorado.withValues(alpha: 0.95 * alpha);
final brillo = TemaApp.colorNaranja.withValues(alpha: 0.80 * alpha);
final sombra = Paint()
..color = Colors.black.withValues(alpha: 0.38)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12);
canvas.drawRRect(rrect.shift(const Offset(0, 8)), sombra);
final base = Paint()..shader = gradient.createShader(cuerpo);
canvas.drawRRect(rrect, base);
final vignette = Paint()
..shader = LinearGradient(
colors: [
Colors.white.withValues(alpha: destacado ? 0.34 : 0.08),
Colors.transparent,
Colors.black.withValues(alpha: destacado ? 0.10 : 0.34),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
).createShader(cuerpo);
canvas.drawRRect(rrect, vignette);
final bordeExterior = Paint()
..style = PaintingStyle.stroke
..strokeWidth = destacado ? 2.4 : 1.8
..shader = LinearGradient(
colors: [oro, Colors.white.withValues(alpha: 0.72 * alpha), brillo, oro],
).createShader(cuerpo);
canvas.drawRRect(rrect, bordeExterior);
final bordeInterior = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.1
..color = Colors.white.withValues(alpha: destacado ? 0.38 * alpha : 0.16 * alpha);
canvas.drawRRect(
RRect.fromRectAndRadius(cuerpo.deflate(6), Radius.circular(math.max(8, radio - 7))),
bordeInterior,
);
final railPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..color = oro.withValues(alpha: destacado ? 0.48 * alpha : 0.34 * alpha);
final yTop = cuerpo.top + size.height * 0.25;
final yBottom = cuerpo.bottom - size.height * 0.25;
canvas.drawLine(Offset(cuerpo.left + 54, yTop), Offset(cuerpo.right - 54, yTop), railPaint);
canvas.drawLine(
Offset(cuerpo.left + 54, yBottom),
Offset(cuerpo.right - 54, yBottom),
railPaint,
);
_drawSideOrnament(canvas, cuerpo, true, oro, brillo, alpha);
_drawSideOrnament(canvas, cuerpo, false, oro, brillo, alpha);
_drawCenterJewel(canvas, cuerpo.topCenter, 1, oro, brillo, alpha);
_drawCenterJewel(canvas, cuerpo.bottomCenter, -1, oro, brillo, alpha);
}
void _drawSideOrnament(
Canvas canvas,
Rect rect,
bool left,
Color oro,
Color brillo,
double alpha,
) {
final cx = left ? rect.left + 24 : rect.right - 24;
final cy = rect.center.dy;
final dir = left ? -1.0 : 1.0;
final path = Path()
..moveTo(cx, cy - 18)
..lineTo(cx + dir * 24, cy)
..lineTo(cx, cy + 18)
..lineTo(cx - dir * 7, cy)
..close();
final paint = Paint()
..shader = LinearGradient(
colors: [oro, brillo, Colors.white.withValues(alpha: 0.70 * alpha)],
).createShader(path.getBounds());
canvas.drawPath(path, paint);
canvas.drawPath(
path,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1
..color = Colors.white.withValues(alpha: 0.42 * alpha),
);
}
void _drawCenterJewel(
Canvas canvas,
Offset anchor,
double direction,
Color oro,
Color brillo,
double alpha,
) {
final cy = anchor.dy + direction * 3;
final path = Path()
..moveTo(anchor.dx, cy - direction * 9)
..lineTo(anchor.dx + 12, cy)
..lineTo(anchor.dx, cy + direction * 9)
..lineTo(anchor.dx - 12, cy)
..close();
canvas.drawPath(
path,
Paint()
..shader = LinearGradient(
colors: [Colors.white.withValues(alpha: 0.78 * alpha), oro, brillo],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
).createShader(path.getBounds()),
);
}
@override
bool shouldRepaint(covariant MarcoBotonFaroleroPainter oldDelegate) {
return oldDelegate.gradient != gradient ||
oldDelegate.destacado != destacado ||
oldDelegate.habilitado != habilitado ||
oldDelegate.radio != radio;
}
}
class ArteGameplayFarolero extends StatelessWidget {
final String assetPath;
final double height;
final double opacity;
final EdgeInsetsGeometry padding;
const ArteGameplayFarolero({
super.key,
required this.assetPath,
this.height = 128,
this.opacity = 0.92,
this.padding = EdgeInsets.zero,
});
const ArteGameplayFarolero.fase({
super.key,
this.height = 128,
this.opacity = 0.92,
this.padding = EdgeInsets.zero,
}) : assetPath = 'assets/ui/generated/gameplay/gameplay_phase_emblem.webp';
const ArteGameplayFarolero.notas({
super.key,
this.height = 150,
this.opacity = 0.94,
this.padding = EdgeInsets.zero,
}) : assetPath = 'assets/ui/generated/gameplay/notes_strategy_art.webp';
const ArteGameplayFarolero.ajustes({
super.key,
this.height = 150,
this.opacity = 0.94,
this.padding = EdgeInsets.zero,
}) : assetPath = 'assets/ui/generated/meta/settings_profile_art.webp';
const ArteGameplayFarolero.historial({
super.key,
this.height = 150,
this.opacity = 0.94,
this.padding = EdgeInsets.zero,
}) : assetPath = 'assets/ui/generated/meta/history_ledger_art.webp';
const ArteGameplayFarolero.resultado({
super.key,
this.height = 150,
this.opacity = 0.94,
this.padding = EdgeInsets.zero,
}) : assetPath = 'assets/ui/generated/meta/result_verdict_art.webp';
@override
Widget build(BuildContext context) {
return ExcludeSemantics(
child: Padding(
padding: padding,
child: SizedBox(
height: height,
width: double.infinity,
child: Image.asset(
assetPath,
fit: BoxFit.contain,
opacity: AlwaysStoppedAnimation(opacity),
filterQuality: FilterQuality.high,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
),
),
);
}
}
class AccesoFarolero extends StatelessWidget {
final String etiqueta;
final IconData icono;
final VoidCallback onPressed;
const AccesoFarolero({
super.key,
required this.etiqueta,
required this.icono,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onPressed,
child: Ink(
height: 66,
decoration: TemaApp.decoracionPanel(color: TemaApp.colorSuperficie),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconoFarolero(icono, color: TemaApp.colorNaranja, size: 24),
const SizedBox(height: 5),
Text(
etiqueta.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: TemaApp.colorDorado,
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
);
}
}
class TarjetaPalabraFarolero extends StatelessWidget {
final String palabra;
const TarjetaPalabraFarolero({super.key, required this.palabra});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 28),
decoration: BoxDecoration(
color: const Color(0xFFC48642),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF6B3519), width: 2),
boxShadow: [
BoxShadow(
color: TemaApp.colorNaranja.withValues(alpha: 0.28),
blurRadius: 24,
),
],
),
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Image.asset(
'assets/ui/generated/gameplay/gameplay_phase_emblem.webp',
fit: BoxFit.contain,
opacity: const AlwaysStoppedAnimation(0.14),
filterQuality: FilterQuality.high,
errorBuilder: (context, error, stackTrace) =>
const SizedBox.shrink(),
),
),
Text(
palabra.toUpperCase(),
textAlign: TextAlign.center,
style: GoogleFonts.oswald(
color: const Color(0xFF1B0C05),
fontSize: 42,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
],
),
);
}
}
/// Pista que ve el impostor cuando la partida la tiene activada.
class PistaImpostorFarolero extends StatelessWidget {
final String pista;
const PistaImpostorFarolero({super.key, required this.pista});
@override
Widget build(BuildContext context) {
return Text(
AppLocalizations.of(context)!.clueIs(pista),
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyLarge?.copyWith(color: TemaApp.colorNaranja),
);
}
}
/// Panel con el resto de impostores. Solo se muestra a un impostor y solo
/// cuando la partida permite que se conozcan entre ellos.
class CompanerosImpostorFarolero extends StatelessWidget {
final List<String> nombres;
const CompanerosImpostorFarolero({super.key, required this.nombres});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
if (nombres.isEmpty) {
return Text(
l10n.youAreTheOnlyImpostor,
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(color: TemaApp.colorTextoSecundario),
);
}
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: TemaApp.decoracionPanel(
color: TemaApp.colorAcento.withValues(alpha: 0.16),
borderColor: TemaApp.colorAcento.withValues(alpha: 0.65),
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconoFarolero(
Icons.groups,
color: TemaApp.colorAcento,
size: 20,
),
const SizedBox(width: 8),
Flexible(
child: Text(
l10n.otherImpostorsTitle(nombres.length),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall,
),
),
],
),
const SizedBox(height: 6),
Text(
nombres.join(' · '),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: TemaApp.colorAcento,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
class AvatarFarolero extends StatelessWidget {
final String texto;
final String? assetPath;
final Color color;
final double size;
final int fuego;
final List<String> medallas;
const AvatarFarolero({
super.key,
required this.texto,
this.assetPath,
this.color = TemaApp.colorNaranja,
this.size = 40,
this.fuego = 0,
this.medallas = const [],
});
@override
Widget build(BuildContext context) {
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.contain,
filterQuality: FilterQuality.high,
),
),
),
),
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: (context, error, stackTrace) =>
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;
}
}