feat(ui): implement award mockup redesign
Build & Deploy Pluriwave / Análisis de código (push) Successful in 10s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m19s

This commit is contained in:
2026-05-20 21:29:36 +02:00
parent eb0ef37c76
commit d8acf74771
14 changed files with 621 additions and 211 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,5 @@
# PluriWave award mockup prompt
Generated with built-in image_gen as the visual target for the premium redesign.
Focus: five mobile screens, dark aurora glassmorphism, cyan/violet/magenta gradients, premium iconography, accessible hierarchy, Home/Search/Favorites/Now Playing/Settings.
+19 -9
View File
@@ -7,7 +7,9 @@ import 'pantallas/pantalla_buscar.dart';
import 'pantallas/pantalla_favoritos.dart'; import 'pantallas/pantalla_favoritos.dart';
import 'pantallas/pantalla_ajustes.dart'; import 'pantallas/pantalla_ajustes.dart';
import 'tema/pluriwave_theme.dart'; import 'tema/pluriwave_theme.dart';
import 'widgets/pluri_glass_surface.dart';
import 'widgets/pluri_icon.dart'; import 'widgets/pluri_icon.dart';
import 'widgets/pluri_wave_scaffold.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart'; import 'package:pluriwave/widgets/mini_reproductor.dart';
class PluriWaveApp extends StatelessWidget { class PluriWaveApp extends StatelessWidget {
@@ -112,32 +114,40 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return PluriWaveScaffold(
appBar: appBar: AppBar(
_indice == 3
? null // PantallaAjustes tiene su propio AppBar
: AppBar(
title: const Text('PluriWave'), title: const Text('PluriWave'),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.bedtime_outlined), icon: const Icon(Icons.bedtime_outlined),
tooltip: 'Timer de sueño', tooltip: 'Timer de sueno',
onPressed: () => _mostrarTimerDialog(context), onPressed: () => _mostrarTimerDialog(context),
), ),
], ],
), ),
body: _paginas[_indice], body: SafeArea(top: false, child: _paginas[_indice]),
bottomNavigationBar: Column( bottomNavigationBar: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const MiniReproductor(), const MiniReproductor(),
NavigationBar( PluriGlassSurface(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
borderRadius: BorderRadius.circular(999),
child: NavigationBar(
selectedIndex: _indice, selectedIndex: _indice,
height: 66,
onDestinationSelected: (i) => setState(() => _indice = i), onDestinationSelected: (i) => setState(() => _indice = i),
destinations: _destinos, destinations: _destinos,
), ),
),
], ],
), ),
),
),
); );
} }
+29 -6
View File
@@ -13,17 +13,40 @@ import '../modelos/emisora.dart';
import '../widgets/ecualizador_widget.dart'; import '../widgets/ecualizador_widget.dart';
import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart'; import '../widgets/pluri_icon.dart';
import '../widgets/pluri_wave_scaffold.dart'; import '../widgets/pluri_premium_widgets.dart';
class PantallaAjustes extends StatelessWidget { class PantallaAjustes extends StatelessWidget {
const PantallaAjustes({super.key}); const PantallaAjustes({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PluriWaveScaffold( return ListView(
appBar: AppBar(title: const Text('Ajustes')), padding: const EdgeInsets.fromLTRB(0, 0, 0, 124),
body: ListView( children: const [
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), PluriScreenHeader(
title: 'Ajustes',
subtitle: 'Control fino de sonido, copias de seguridad y emisoras personalizadas.',
glyph: PluriIconGlyph.settings,
trailing: PluriStatusPill(
icon: Icons.security_rounded,
label: 'Seguro',
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: _AjustesContent(),
),
],
);
}
}
class _AjustesContent extends StatelessWidget {
const _AjustesContent();
@override
Widget build(BuildContext context) {
return Column(
children: const [ children: const [
_SeccionEcualizador(), _SeccionEcualizador(),
SizedBox(height: 12), SizedBox(height: 12),
@@ -33,11 +56,11 @@ class PantallaAjustes extends StatelessWidget {
SizedBox(height: 12), SizedBox(height: 12),
_SeccionInfo(), _SeccionInfo(),
], ],
),
); );
} }
} }
class _SeccionEcualizador extends StatelessWidget { class _SeccionEcualizador extends StatelessWidget {
const _SeccionEcualizador(); const _SeccionEcualizador();
+34 -31
View File
@@ -5,19 +5,20 @@ import 'package:provider/provider.dart';
import '../estado/estado_radio.dart'; import '../estado/estado_radio.dart';
import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart'; import '../widgets/pluri_icon.dart';
import '../widgets/pluri_premium_widgets.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart';
const _paises = [ const _paises = [
('España', 'ES'), ('Espana', 'ES'),
('USA', 'US'), ('USA', 'US'),
('México', 'MX'), ('Mexico', 'MX'),
('Argentina', 'AR'), ('Argentina', 'AR'),
('UK', 'GB'), ('UK', 'GB'),
('Francia', 'FR'), ('Francia', 'FR'),
('Alemania', 'DE'), ('Alemania', 'DE'),
('Italia', 'IT'), ('Italia', 'IT'),
('Brasil', 'BR'), ('Brasil', 'BR'),
('Japón', 'JP'), ('Japon', 'JP'),
]; ];
const _idiomas = [ const _idiomas = [
@@ -66,13 +67,23 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
return Column( return Column(
children: [ children: [
PluriScreenHeader(
title: 'Buscar senal',
subtitle: 'Encontra radios por nombre, pais o idioma con filtros rapidos y alto contraste.',
glyph: PluriIconGlyph.search,
trailing: const PluriStatusPill(
icon: Icons.tune_rounded,
label: 'Filtros',
),
),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 0), padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
child: PluriGlassSurface( child: PluriGlassSurface(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
borderRadius: BorderRadius.circular(999),
child: SearchBar( child: SearchBar(
controller: _controller, controller: _controller,
hintText: 'Nombre de la emisora...', hintText: 'Radio Horizonte, jazz, noticias...',
leading: const PluriIcon( leading: const PluriIcon(
glyph: PluriIconGlyph.search, glyph: PluriIconGlyph.search,
variant: PluriIconVariant.filled, variant: PluriIconVariant.filled,
@@ -94,7 +105,7 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
), ),
), ),
_seccionFiltro( _seccionFiltro(
'País', 'Pais',
_paises.map((p) => (p.$1, p.$2)).toList(), _paises.map((p) => (p.$1, p.$2)).toList(),
_paisSeleccionado, _paisSeleccionado,
(v) { (v) {
@@ -130,14 +141,19 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(titulo, style: theme.textTheme.labelLarge), Text(
const SizedBox(height: 4), titulo,
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 6),
SizedBox( SizedBox(
height: 36, height: 40,
child: ListView.separated( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: opciones.length, itemCount: opciones.length,
separatorBuilder: (_, __) => const SizedBox(width: 6), separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) { itemBuilder: (_, i) {
final (label, value) = opciones[i]; final (label, value) = opciones[i];
final sel = seleccionado == value; final sel = seleccionado == value;
@@ -168,37 +184,24 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
_controller.text.isEmpty && _controller.text.isEmpty &&
_paisSeleccionado == null && _paisSeleccionado == null &&
_idiomaSeleccionado == null; _idiomaSeleccionado == null;
return Center( return PluriEmptyState(
child: PluriGlassSurface(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const PluriIcon(
glyph: PluriIconGlyph.search, glyph: PluriIconGlyph.search,
variant: PluriIconVariant.activeGlow, title: sinFiltros ? 'Busca una emisora' : 'Sin resultados',
size: 44, subtitle: sinFiltros
), ? 'Usa la barra superior o los chips para descubrir senales de todo el mundo.'
const SizedBox(height: 14), : 'Proba quitar filtros o escribir otro nombre para encontrar una senal activa.',
Text(
sinFiltros ? 'Buscá una emisora' : 'Sin resultados',
style: theme.textTheme.titleMedium,
),
],
),
),
); );
} }
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(16, 16, 16, 120),
itemCount: resultados.length, itemCount: resultados.length,
separatorBuilder: (_, __) => const SizedBox(height: 6), separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: itemBuilder: (context, i) => TarjetaEmisora(
(context, i) => TarjetaEmisora(
emisora: resultados[i], emisora: resultados[i],
esCompacta: true, esCompacta: true,
onTap: () => context.read<EstadoRadio>().reproducir(resultados[i]), onTap: () => context.read<EstadoRadio>().reproducir(resultados[i]),
).animate().fadeIn(delay: (i * 20).ms), ).animate().fadeIn(delay: (i * 20).ms).slideY(begin: 0.08),
); );
} }
} }
+46 -27
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../estado/estado_radio.dart'; import '../estado/estado_radio.dart';
import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart'; import '../widgets/pluri_icon.dart';
import '../widgets/pluri_premium_widgets.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart';
class PantallaFavoritos extends StatelessWidget { class PantallaFavoritos extends StatelessWidget {
@@ -13,36 +14,48 @@ class PantallaFavoritos extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final estado = context.watch<EstadoRadio>(); final estado = context.watch<EstadoRadio>();
final favoritos = estado.listaFavoritos; final favoritos = estado.listaFavoritos;
final theme = Theme.of(context);
if (favoritos.isEmpty) { if (favoritos.isEmpty) {
return Center( return const Column(
child: PluriGlassSurface(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
const PluriIcon( PluriScreenHeader(
title: 'Favoritos',
subtitle: 'Tu cabina personal para volver a las senales que mas escuchas.',
glyph: PluriIconGlyph.favorites, glyph: PluriIconGlyph.favorites,
variant: PluriIconVariant.activeGlow, trailing: PluriStatusPill(
size: 52, icon: Icons.favorite_rounded,
label: 'Coleccion',
), ),
const SizedBox(height: 16), ),
Text('Sin favoritos aún', style: theme.textTheme.titleMedium), Expanded(
const SizedBox(height: 8), child: PluriEmptyState(
Text( glyph: PluriIconGlyph.favorites,
'Tocá ♥ en cualquier emisora para guardarla', title: 'Sin favoritos aun',
style: theme.textTheme.bodyMedium?.copyWith( subtitle: 'Toca el corazon en cualquier emisora para guardarla en tu coleccion.',
color: theme.colorScheme.onSurfaceVariant,
), ),
), ),
], ],
),
),
); );
} }
return ReorderableListView.builder( return Column(
padding: const EdgeInsets.all(12), children: [
PluriScreenHeader(
title: 'Favoritos',
subtitle: 'Reordena tu coleccion y deja arriba las radios que mas importan.',
glyph: PluriIconGlyph.favorites,
trailing: PluriStatusPill(
icon: Icons.library_music_rounded,
label: '${favoritos.length} guardadas',
),
),
Expanded(
child: ReorderableListView.builder(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 122),
proxyDecorator: (child, index, animation) => ScaleTransition(
scale: Tween<double>(begin: 1, end: 1.03).animate(animation),
child: child,
),
onReorder: (oldIndex, newIndex) async { onReorder: (oldIndex, newIndex) async {
if (newIndex > oldIndex) newIndex--; if (newIndex > oldIndex) newIndex--;
final emisora = favoritos[oldIndex]; final emisora = favoritos[oldIndex];
@@ -54,13 +67,18 @@ class PantallaFavoritos extends StatelessWidget {
final emisora = favoritos[i]; final emisora = favoritos[i];
return Padding( return Padding(
key: ValueKey('favorito-pad-${emisora.uuid}'), key: ValueKey('favorito-pad-${emisora.uuid}'),
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 5),
child: PluriGlassSurface( child: PluriGlassSurface(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 7),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.drag_indicator_rounded), ReorderableDragStartListener(
const SizedBox(width: 6), index: i,
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(Icons.drag_indicator_rounded),
),
),
Expanded( Expanded(
child: TarjetaEmisora( child: TarjetaEmisora(
key: Key(emisora.uuid), key: Key(emisora.uuid),
@@ -69,7 +87,7 @@ class PantallaFavoritos extends StatelessWidget {
onTap: () => estado.reproducir(emisora), onTap: () => estado.reproducir(emisora),
), ),
), ),
IconButton( IconButton.filledTonal(
tooltip: 'Eliminar de favoritos', tooltip: 'Eliminar de favoritos',
icon: const Icon(Icons.delete_outline_rounded), icon: const Icon(Icons.delete_outline_rounded),
onPressed: () async { onPressed: () async {
@@ -78,9 +96,7 @@ class PantallaFavoritos extends StatelessWidget {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text('${emisora.nombre} eliminada de favoritos'),
'${emisora.nombre} eliminada de favoritos',
),
), ),
); );
} }
@@ -91,6 +107,9 @@ class PantallaFavoritos extends StatelessWidget {
), ),
); );
}, },
),
),
],
); );
} }
} }
+32 -39
View File
@@ -7,6 +7,7 @@ import '../estado/estado_radio.dart';
import '../tema/pluriwave_theme.dart'; import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart'; import '../widgets/pluri_icon.dart';
import '../widgets/pluri_premium_widgets.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart';
/// Pantalla principal: emisoras populares y por género. /// Pantalla principal: emisoras populares y por género.
@@ -44,13 +45,13 @@ class _PantallaInicioState extends State<PantallaInicio> {
onRefresh: estado.cargarPopulares, onRefresh: estado.cargarPopulares,
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [
SliverToBoxAdapter(child: _heroHeader(context)), SliverToBoxAdapter(child: _heroHeader(context, estado)),
SliverToBoxAdapter(child: _seccionTendencias(estado, theme)), SliverToBoxAdapter(child: _seccionTendencias(estado, theme)),
SliverToBoxAdapter(child: _chipGeneros(context, theme)), SliverToBoxAdapter(child: _chipGeneros(context, theme)),
if (estado.error != null) if (estado.error != null)
SliverToBoxAdapter(child: _errorBanner(estado, theme)), SliverToBoxAdapter(child: _errorBanner(estado, theme)),
SliverPadding( SliverPadding(
padding: EdgeInsets.symmetric(horizontal: t.spacingMd), padding: EdgeInsets.fromLTRB(t.spacingMd, 0, t.spacingMd, 124),
sliver: _gridEmisoras(estado), sliver: _gridEmisoras(estado),
), ),
], ],
@@ -58,40 +59,28 @@ class _PantallaInicioState extends State<PantallaInicio> {
); );
} }
Widget _heroHeader(BuildContext context) { Widget _heroHeader(BuildContext context, EstadoRadio estado) {
final t = context.pluriTokens; return PluriScreenHeader(
final theme = Theme.of(context); title: 'PluriWave',
return Padding( subtitle: 'Radio global en vivo con senales limpias, favoritos inteligentes y una experiencia visual de concurso.',
padding: EdgeInsets.fromLTRB(
t.spacingMd,
t.spacingSm,
t.spacingMd,
t.spacingSm,
),
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(t.radiusLg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const PluriIcon(
glyph: PluriIconGlyph.home, glyph: PluriIconGlyph.home,
variant: PluriIconVariant.activeGlow, primaryActionLabel: 'Explorar emisoras',
size: 30, onPrimaryAction: estado.cargarPopulares,
trailing: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
PluriStatusPill(
icon: Icons.public_rounded,
label: '${estado.emisorasInicio.length} radios',
accent: Theme.of(context).colorScheme.secondary,
), ),
const SizedBox(height: 10), const SizedBox(height: 8),
Text( const PluriStatusPill(
'PluriWave', icon: Icons.hd_rounded,
style: theme.textTheme.headlineMedium?.copyWith( label: 'Calidad HD',
fontWeight: FontWeight.w700,
),
),
Text(
'Ondas vivas globales',
style: theme.textTheme.titleMedium?.copyWith(color: t.warmCoral),
), ),
], ],
), ),
),
); );
} }
@@ -103,7 +92,7 @@ class _PantallaInicioState extends State<PantallaInicio> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Tendencias premium', style: theme.textTheme.titleMedium), Text('Radar en directo', style: theme.textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
SizedBox( SizedBox(
height: 56, height: 56,
@@ -214,16 +203,20 @@ class _PantallaInicioState extends State<PantallaInicio> {
), ),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: 2,
childAspectRatio: 0.85, childAspectRatio: 0.78,
crossAxisSpacing: 8, crossAxisSpacing: 12,
mainAxisSpacing: 8, mainAxisSpacing: 12,
), ),
); );
} }
if (emisoras.isEmpty) { if (emisoras.isEmpty) {
return const SliverFillRemaining( return const SliverFillRemaining(
child: Center(child: Text('No hay emisoras disponibles')), child: PluriEmptyState(
glyph: PluriIconGlyph.home,
title: 'No hay emisoras disponibles',
subtitle: 'Proba refrescar o elegir otro g?nero para volver a capturar se?al.',
),
); );
} }
@@ -237,9 +230,9 @@ class _PantallaInicioState extends State<PantallaInicio> {
), ),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, crossAxisCount: 2,
childAspectRatio: 0.85, childAspectRatio: 0.78,
crossAxisSpacing: 8, crossAxisSpacing: 12,
mainAxisSpacing: 8, mainAxisSpacing: 12,
), ),
); );
} }
+30 -5
View File
@@ -9,10 +9,12 @@ abstract final class PluriWaveTheme {
const tokens = PluriWaveTokens.dark; const tokens = PluriWaveTokens.dark;
final colorScheme = const ColorScheme.dark().copyWith( final colorScheme = const ColorScheme.dark().copyWith(
primary: tokens.electricMagenta, primary: tokens.electricMagenta,
secondary: tokens.warmCoral, secondary: const Color(0xFF20E6FF),
surface: const Color(0xFF130B22), tertiary: tokens.warmCoral,
surfaceContainerLow: const Color(0xFF1A112C), surface: const Color(0xFF0B1024),
onSurface: const Color(0xFFF4EEFF), surfaceContainerLow: const Color(0xFF111831),
surfaceContainerHighest: const Color(0xFF202946),
onSurface: const Color(0xFFF7F2FF),
onPrimary: Colors.white, onPrimary: Colors.white,
); );
@@ -20,11 +22,34 @@ abstract final class PluriWaveTheme {
useMaterial3: true, useMaterial3: true,
colorScheme: colorScheme, colorScheme: colorScheme,
scaffoldBackgroundColor: tokens.deepViolet, scaffoldBackgroundColor: tokens.deepViolet,
textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme), textTheme: GoogleFonts.plusJakartaSansTextTheme(ThemeData.dark().textTheme),
extensions: const <ThemeExtension<dynamic>>[ extensions: const <ThemeExtension<dynamic>>[
tokens, tokens,
PluriWaveMotion.dark, PluriWaveMotion.dark,
], ],
appBarTheme: const AppBarTheme(
centerTitle: false,
backgroundColor: Colors.transparent,
foregroundColor: Color(0xFFF7F2FF),
elevation: 0,
scrolledUnderElevation: 0,
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: Colors.transparent,
indicatorColor: tokens.electricMagenta.withValues(alpha: 0.18),
labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle(
fontWeight: states.contains(WidgetState.selected) ? FontWeight.w800 : FontWeight.w600,
fontSize: 12,
),
),
),
chipTheme: ChipThemeData(
backgroundColor: const Color(0x1FFFFFFF),
selectedColor: tokens.electricMagenta.withValues(alpha: 0.24),
side: BorderSide(color: tokens.glassBorder),
labelStyle: const TextStyle(fontWeight: FontWeight.w700),
),
cardTheme: CardThemeData( cardTheme: CardThemeData(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerLow, color: colorScheme.surfaceContainerLow,
+9 -9
View File
@@ -37,15 +37,15 @@ class PluriWaveTokens extends ThemeExtension<PluriWaveTokens> {
final double spacingLg; final double spacingLg;
static const dark = PluriWaveTokens( static const dark = PluriWaveTokens(
deepViolet: Color(0xFF24123D), deepViolet: Color(0xFF070A18),
electricMagenta: Color(0xFFE228D1), electricMagenta: Color(0xFFFF3DF2),
warmCoral: Color(0xFFFF6F61), warmCoral: Color(0xFFFFB86B),
glassSurface: Color(0x2AFFFFFF), glassSurface: Color(0x24FFFFFF),
glassBorder: Color(0x40FFFFFF), glassBorder: Color(0x52FFFFFF),
glowColor: Color(0x66E228D1), glowColor: Color(0x88FF3DF2),
radiusSm: 10, radiusSm: 14,
radiusMd: 16, radiusMd: 22,
radiusLg: 24, radiusLg: 30,
spacingXs: 4, spacingXs: 4,
spacingSm: 8, spacingSm: 8,
spacingMd: 16, spacingMd: 16,
+11 -1
View File
@@ -10,13 +10,15 @@ class PluriGlassSurface extends StatelessWidget {
required this.child, required this.child,
this.padding = const EdgeInsets.all(16), this.padding = const EdgeInsets.all(16),
this.borderRadius, this.borderRadius,
this.blurSigma = 14, this.blurSigma = 18,
this.glowColor,
}); });
final Widget child; final Widget child;
final EdgeInsetsGeometry padding; final EdgeInsetsGeometry padding;
final BorderRadius? borderRadius; final BorderRadius? borderRadius;
final double blurSigma; final double blurSigma;
final Color? glowColor;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -32,6 +34,14 @@ class PluriGlassSurface extends StatelessWidget {
color: t.glassSurface, color: t.glassSurface,
borderRadius: radius, borderRadius: radius,
border: Border.all(color: t.glassBorder), border: Border.all(color: t.glassBorder),
boxShadow: [
BoxShadow(
color: glowColor ?? t.glowColor.withValues(alpha: 0.12),
blurRadius: 30,
spreadRadius: -14,
offset: const Offset(0, 18),
),
],
), ),
child: Padding(padding: padding, child: child), child: Padding(padding: padding, child: child),
), ),
+207
View File
@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_glass_surface.dart';
import 'pluri_icon.dart';
class PluriScreenHeader extends StatelessWidget {
const PluriScreenHeader({
super.key,
required this.title,
required this.subtitle,
required this.glyph,
this.primaryActionLabel,
this.onPrimaryAction,
this.trailing,
});
final String title;
final String subtitle;
final PluriIconGlyph glyph;
final String? primaryActionLabel;
final VoidCallback? onPrimaryAction;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
final theme = Theme.of(context);
return Padding(
padding: EdgeInsets.fromLTRB(t.spacingMd, t.spacingSm, t.spacingMd, t.spacingSm),
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(t.radiusLg + 8),
padding: const EdgeInsets.all(18),
child: Stack(
children: [
Positioned(
right: -36,
top: -42,
child: _Orb(color: t.electricMagenta.withValues(alpha: 0.38), size: 128),
),
Positioned(
right: 44,
bottom: -54,
child: _Orb(color: const Color(0xFF20E6FF).withValues(alpha: 0.22), size: 116),
),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
const Color(0xFF20E6FF).withValues(alpha: 0.95),
t.electricMagenta,
t.warmCoral,
],
),
boxShadow: [
BoxShadow(color: t.glowColor, blurRadius: 28, spreadRadius: 2),
],
),
child: Center(
child: PluriIcon(glyph: glyph, variant: PluriIconVariant.filled, size: 28),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -0.7,
height: 1.02,
),
),
const SizedBox(height: 6),
Text(
subtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.76),
height: 1.25,
),
),
if (primaryActionLabel != null) ...[
const SizedBox(height: 12),
FilledButton.tonalIcon(
onPressed: onPrimaryAction,
icon: const Icon(Icons.auto_awesome_rounded, size: 18),
label: Text(primaryActionLabel!),
),
],
],
),
),
if (trailing != null) trailing!,
],
),
],
),
),
);
}
}
class PluriStatusPill extends StatelessWidget {
const PluriStatusPill({
super.key,
required this.icon,
required this.label,
this.accent,
});
final IconData icon;
final String label;
final Color? accent;
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
final color = accent ?? t.electricMagenta;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: color.withValues(alpha: 0.13),
border: Border.all(color: color.withValues(alpha: 0.38)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 7),
Text(label, style: Theme.of(context).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w800)),
],
),
);
}
}
class PluriEmptyState extends StatelessWidget {
const PluriEmptyState({
super.key,
required this.glyph,
required this.title,
required this.subtitle,
});
final PluriIconGlyph glyph;
final String title;
final String subtitle;
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(t.radiusLg + 10),
padding: const EdgeInsets.all(28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
PluriIcon(glyph: glyph, variant: PluriIconVariant.activeGlow, size: 58),
const SizedBox(height: 18),
Text(title, textAlign: TextAlign.center, style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900)),
const SizedBox(height: 8),
Text(
subtitle,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.72)),
),
],
),
),
),
);
}
}
class _Orb extends StatelessWidget {
const _Orb({required this.color, required this.size});
final Color color;
final double size;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(colors: [color, color.withValues(alpha: 0)]),
),
),
);
}
}
+49 -7
View File
@@ -24,20 +24,62 @@ class PluriWaveScaffold extends StatelessWidget {
appBar: appBar, appBar: appBar,
bottomNavigationBar: bottomNavigationBar, bottomNavigationBar: bottomNavigationBar,
floatingActionButton: floatingActionButton, floatingActionButton: floatingActionButton,
extendBody: true,
body: DecoratedBox( body: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: RadialGradient( gradient: LinearGradient(
center: const Alignment(-0.75, -0.9), begin: Alignment.topLeft,
radius: 1.25, end: Alignment.bottomRight,
colors: [ colors: [
t.electricMagenta.withValues(alpha: 0.22), const Color(0xFF070A18),
t.deepViolet, t.deepViolet,
const Color(0xFF10091B), const Color(0xFF151033),
const Color(0xFF070A18),
], ],
stops: const [0.0, 0.42, 1.0], stops: const [0, 0.34, 0.68, 1],
), ),
), ),
child: body, child: Stack(
fit: StackFit.expand,
children: [
Positioned(
left: -120,
top: -120,
child: _AuroraOrb(size: 300, color: const Color(0xFF20E6FF).withValues(alpha: 0.32)),
),
Positioned(
right: -150,
top: 160,
child: _AuroraOrb(size: 340, color: t.electricMagenta.withValues(alpha: 0.26)),
),
Positioned(
left: -90,
bottom: 80,
child: _AuroraOrb(size: 260, color: t.warmCoral.withValues(alpha: 0.16)),
),
body,
],
),
),
);
}
}
class _AuroraOrb extends StatelessWidget {
const _AuroraOrb({required this.size, required this.color});
final double size;
final Color color;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(colors: [color, color.withValues(alpha: 0)]),
),
), ),
); );
} }
+75 -3
View File
@@ -79,7 +79,32 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
AspectRatio(aspectRatio: 1, child: _logo(60)), AspectRatio(
aspectRatio: 1,
child: Stack(
fit: StackFit.expand,
children: [
_logo(60),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.34),
],
),
),
),
Positioned(
left: t.spacingSm,
bottom: t.spacingSm,
child: _LiveBadge(mini: true),
),
],
),
),
Padding( Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
t.spacingMd, t.spacingMd,
@@ -140,9 +165,25 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
), ),
child: Row( child: Row(
children: [ children: [
Stack(
alignment: Alignment.center,
children: [
Container(
width: 58,
height: 58,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: SweepGradient(
colors: [t.electricMagenta, const Color(0xFF20E6FF), t.warmCoral, t.electricMagenta],
),
boxShadow: [BoxShadow(color: t.glowColor.withValues(alpha: 0.24), blurRadius: 22)],
),
),
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(t.radiusSm), borderRadius: BorderRadius.circular(18),
child: SizedBox(width: 48, height: 48, child: _logo(24)), child: SizedBox(width: 50, height: 50, child: _logo(24)),
),
],
), ),
SizedBox(width: t.spacingSm), SizedBox(width: t.spacingSm),
Expanded( Expanded(
@@ -172,6 +213,8 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
], ],
), ),
), ),
const SizedBox(width: 8),
_LiveBadge(mini: false),
_botonFavorito(mini: false), _botonFavorito(mini: false),
], ],
), ),
@@ -271,6 +314,35 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
} }
} }
class _LiveBadge extends StatelessWidget {
const _LiveBadge({required this.mini});
final bool mini;
@override
Widget build(BuildContext context) {
final color = Theme.of(context).colorScheme.secondary;
return Container(
padding: EdgeInsets.symmetric(horizontal: mini ? 8 : 6, vertical: mini ? 5 : 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
color: Colors.black.withValues(alpha: 0.35),
border: Border.all(color: color.withValues(alpha: 0.48)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.fiber_manual_record_rounded, size: mini ? 10 : 8, color: color),
if (mini) ...[
const SizedBox(width: 5),
Text('Live', style: Theme.of(context).textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w900)),
],
],
),
);
}
}
/// Placeholder shimmer para listas en carga. /// Placeholder shimmer para listas en carga.
class TarjetaEmisoraShimmer extends StatelessWidget { class TarjetaEmisoraShimmer extends StatelessWidget {
const TarjetaEmisoraShimmer({super.key}); const TarjetaEmisoraShimmer({super.key});
+1
View File
@@ -62,3 +62,4 @@ flutter:
assets: assets:
- assets/images/ - assets/images/
- assets/icons/ - assets/icons/
- assets/mockups/