Files
farolero/lib/tema/componentes_farolero.dart
T
FreeTLab ae0d55c00f
Build & Deploy Farolero / Análisis de código (push) Successful in 14s
Build & Deploy Farolero / Build APK + AAB release (push) Successful in 1m16s
fix(android): target API level 35 as required by Google Play
Play rejected version code 54 for still targeting 34.

From API 35 Android forces edge-to-edge: content is drawn behind the system
bars and it is up to each screen to keep clear of them. Seven screens had no
SafeArea and their bottom action button would have ended up under the
gesture bar, so FondoFarolero gains a respetarBarras flag that insets the
content while the background still paints full bleed. The seventeen screens
that already handle it are untouched.

The navigation bar colour is ignored under edge-to-edge, so its icons are
now forced light to stay visible over the dark background.

Verified on the merged manifest: targetSdkVersion 35, and the release bundle
builds. The visual result has not been checked on a device.
2026-07-25 22:27:28 +02:00

1348 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;
/// Aparta el contenido de las barras del sistema.
///
/// A partir de targetSdk 35 la app dibuja de borde a borde, así que sin esto
/// lo que quede abajo —normalmente el botón de acción— acaba debajo de la
/// barra de gestos. El fondo se sigue pintando entero; solo se aparta el
/// contenido. Las pantallas que ya envuelven su contenido en SafeArea no
/// necesitan activarlo.
final bool respetarBarras;
const FondoFarolero({
super.key,
required this.child,
this.intenso = false,
this.respetarBarras = 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: respetarBarras ? SafeArea(child: 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;
}
}