10 Commits
Author SHA1 Message Date
FreeTLab 01615f9751 fix(escuchar,reproductor): add the section-heading token and screen 1-3 polish
Audit S7 plus the open items on screens 1-3. Escuchar gains the
prototype's own 52px "now listening" eyebrow header (t4:53), which is
structurally different from the 56px title row every other root uses
(t4:325) -- the earlier wiring test asserted PluriRootHeader on all five
roots, an over-broad premise now corrected to guard what actually holds:
no AppBar, and the sleep-timer action still reachable.
2026-07-30 14:48:06 +02:00
FreeTLab 7ff156852f fix(bienvenida): match body/bullet type sizes and clip the logo mark
Tier 4 visual fidelity, audit 14.4/14.5/14.6/14.8 (t4 lines 693-701):
body copy is 14.5px (was bodyMedium's 14), bullet titles are
13.5px/w800 and subtitles 11.5px (was 14/w800 and 12), the gap between
bullets tightens from 16 to 12, and the 76x76 logo mark is now clipped
to a 20px rounded rect instead of painted square.
2026-07-30 13:02:40 +02:00
FreeTLab 038ec7f3b8 fix(alarma-sonando): pulsing halo, hero-time metrics, art size, snooze icon
Tier 4 visual fidelity, audit 9.1/9.5/9.6/9.8 (t4 lines 411-428):

- 9.1: new _HaloPulsante renders the prototype's 420px amber radial
  gradient behind the hero content. Deliberately BOUNDED (one
  grow-and-settle cycle), not the prototype's literal `infinite` CSS
  animation: this screen's protected dismiss-guard test calls
  pumpAndSettle() after every mount/interaction, and a genuinely
  infinite AnimationController.repeat() would hang it forever with no
  way to fix it. Skips its Animate wrapper under reduced motion, same
  as every other entry animation in this app.
- 9.5: the hero time gets a local letterSpacing/height override
  (-4/0.95) instead of the shared heroTime token's -2.0/1.0 — the
  alarm editor's hour block is the token's other consumer and still
  wants height:1, so the shared style is untouched.
- 9.6: ringing-screen art grows from 168/radiusLg(30) to 180/36 (t4
  line 421), a local constant like the existing stop-button radius.
- 9.8: the POSPONER eyebrow regains its 19px warmCoral snooze icon.

All 5 protected files (including the dismiss-guard test) remain
empty-diff against main.
2026-07-30 12:58:19 +02:00
FreeTLab 8fcb734ab0 fix(alarmas): tighten the gap between stacked alarm cards to 10px
Tier 4 visual fidelity, audit 7.5 (t4 line 334): the prototype stacks
alarm cards with a 10px gap; the build used 12. Each gap is now keyed
per alarm id so a guard test can pin the exact value between two known
cards.
2026-07-30 12:45:41 +02:00
FreeTLab ca2400f0d4 fix(favoritos): restyle the dashed custom-station CTA to spec
Tier 4 visual fidelity, audit 4.5 (t4 line 235): the border and the
label/icon now use the prototype's two DIFFERENT opacities
(rgba(255,255,255,.16) stroke vs rgba(242,247,250,.6) text/icon,
previously one shared 50% colour for both), padding is a uniform 14
instead of symmetric(18,16), and the icon is a plain add glyph at 20px
instead of add_circle_outline_rounded.
2026-07-30 12:40:59 +02:00
FreeTLab 163241d4a1 fix(inicio): add the favourites count badge and plain-text Ver todas
Tier 4 visual fidelity, audit 1.11/1.12 (t4 line 80): a pill badge next
to "Tus emisoras" now shows the TOTAL favorite count (not the 8-capped
grid size), and "Ver todas" is plain 12px/w800 brand-teal text instead
of a Material TextButton with its own padding and splash.

Item 1.4 (hero art radius) was already fixed as a side effect of an
earlier commit — no change needed here.
2026-07-30 12:36:27 +02:00
FreeTLab 56123ea51b fix(ajustes): inset row dividers and shrink icon/title/chevron
Tier 4 visual fidelity, audit S10/10.6/10.8/10.9 (t4 lines 514-516):
GrupoAjustes' row divider now indents 47px instead of running
full-bleed, FilaAjuste's leading icon drops from Material's 24px
default to 21px, the row title is a local 14px override of cardTitle
(14.5), and the chevron shrinks to 19px at 40% opacity instead of the
24px full-opacity default.
2026-07-30 12:32:10 +02:00
FreeTLab 8ecfc928b3 fix(reproductor): move the quality row last and size the play button at 78
Audit 2.4 and 2.6: the quality row sat right after the subtitle, four
positions before where the prototype puts it (t4:114-140), and the play
button was 72 with a 40 icon instead of 78/42 (t4:127).
2026-07-30 12:14:51 +02:00
FreeTLab 3303bd387c fix(paises,buscar): tappable ISO rows and a reconnect card
Item 24 / audit 5.1-5.3, 5.5 (t4:255-269): Paises' "Tus idiomas" was
a Wrap of non-interactive Chips, and the full list was a plain
ListTile with no ISO column and no onTap. Both now share one tappable
row (ISO code, name, station count, chevron); the first "Tus idiomas"
row gets the prototype's teal-tinted highlight. The one production
call site wires the tap to filter Buscar by that country's code and
pop back -- EstadoBusqueda.buscar(pais: ...) already accepts any ISO
alpha-2 code, not just the ~10 presets in the filter sheet.

Item 25 / audit 13.2 (t4:643-646): a reconnect card (rotating ring,
station name, "Reconectando...", a stop affordance) replaces the
complete absence of any reconnect signal outside a word in the mini
player. Ships WITHOUT the prototype's attempt counter: the only live
ControladorReconexion instance is a private field of
PluriWaveAudioHandler inside servicio_audio.dart, a file this task
requires stay byte-identical to main, and nothing else re-exposes it.
Reconstructing a count from estadoStream's reconectando emissions
would not be faithful (the stream can emit it many times per actual
backoff attempt), so that was deliberately not attempted.
2026-07-30 11:48:25 +02:00
FreeTLab 955682271c fix(favoritos,grabaciones): replace glass cards with flat rows
Item 23 / audit 4.3, 12.4 (t4:226-232, 616-619): Favoritos and
Grabaciones rows were full glass cards / ListTiles with two stacked
buttons and no artwork slot. Replace with flat, background-less rows
via a new shared FilaEmisoraPlana widget (square art, name+meta, a
circular play affordance) plus a bespoke Grabaciones row (44x12
placeholder art -- recordings carry no per-station favicon, so this
is a themed fallback, not invented artwork).

Favoritos keeps "Move to list" / "Remove from favorites" behind an
overflow menu (same underlying methods, unchanged) instead of two
always-visible buttons, since dropping either would be a functional
regression the prototype's own row doesn't have to solve for.

Also 12.1 (t4:610): the Grabaciones header action is folder_open, not
a generic gear.
2026-07-30 11:46:55 +02:00
53 changed files with 3320 additions and 391 deletions
+3 -1
View File
@@ -816,5 +816,7 @@
"welcomeBullet3Title": "منبهات موسيقية",
"welcomeCtaLabel": "ابدأ الاستماع",
"welcomeHeadline": "عالمك، على الهواء مباشرة",
"yourStationsTitle": "محطاتك"
"yourStationsTitle": "محطاتك",
"nowListeningLabel": "الاستماع الآن",
"popularNowTitle": "الأكثر شيوعًا الآن"
}
+3 -1
View File
@@ -816,5 +816,7 @@
"welcomeBullet3Title": "সুরেলা অ্যালার্ম",
"welcomeCtaLabel": "শোনা শুরু করুন",
"welcomeHeadline": "আপনার বিশ্ব, সরাসরি",
"yourStationsTitle": "আপনার স্টেশন"
"yourStationsTitle": "আপনার স্টেশন",
"nowListeningLabel": "এখন শোনা হচ্ছে",
"popularNowTitle": "এখন জনপ্রিয়"
}
+3 -1
View File
@@ -816,5 +816,7 @@
"welcomeBullet3Title": "Musikalarme",
"welcomeCtaLabel": "Jetzt hören",
"welcomeHeadline": "Deine Welt, live",
"yourStationsTitle": "Deine Sender"
"yourStationsTitle": "Deine Sender",
"nowListeningLabel": "Läuft gerade",
"popularNowTitle": "Jetzt beliebt"
}
+2
View File
@@ -358,6 +358,7 @@
"yourStationsTitle": "Your stations",
"seeAllAction": "See all",
"openFullPlayerTooltip": "Open full player",
"nowListeningLabel": "Now listening",
"nothingPlayingTitle": "Nothing playing yet",
"nothingPlayingSubtitle": "Pick a station from Your stations or search to start.",
"nearYou": "Near you",
@@ -375,6 +376,7 @@
"exploreTrendingSubtitle": "Today",
"exploreNewTitle": "New",
"exploreNewSubtitle": "This week",
"popularNowTitle": "Popular now",
"offlineBannerTitle": "No connection",
"retryAction": "Retry",
"noStationsAvailable": "No stations available",
+2
View File
@@ -358,6 +358,7 @@
"yourStationsTitle": "Tus emisoras",
"seeAllAction": "Ver todas",
"openFullPlayerTooltip": "Abrir reproductor completo",
"nowListeningLabel": "Escuchando ahora",
"nothingPlayingTitle": "Todavía no estás escuchando nada",
"nothingPlayingSubtitle": "Elegí una emisora de Tus emisoras o buscá una para empezar.",
"nearYou": "Cerca de vos",
@@ -375,6 +376,7 @@
"exploreTrendingSubtitle": "Hoy",
"exploreNewTitle": "Novedades",
"exploreNewSubtitle": "Esta semana",
"popularNowTitle": "Populares ahora",
"offlineBannerTitle": "Sin conexión",
"retryAction": "Reintentar",
"noStationsAvailable": "No hay emisoras disponibles",
+3 -1
View File
@@ -816,5 +816,7 @@
"welcomeBullet3Title": "Alarmes musicales",
"welcomeCtaLabel": "Commencer à écouter",
"welcomeHeadline": "Votre monde, en direct",
"yourStationsTitle": "Vos stations"
"yourStationsTitle": "Vos stations",
"nowListeningLabel": "En cours d'écoute",
"popularNowTitle": "Populaire maintenant"
}
+3 -1
View File
@@ -816,5 +816,7 @@
"welcomeBullet3Title": "संगीत अलार्म",
"welcomeCtaLabel": "सुनना शुरू करें",
"welcomeHeadline": "आपकी दुनिया, लाइव",
"yourStationsTitle": "आपके स्टेशन"
"yourStationsTitle": "आपके स्टेशन",
"nowListeningLabel": "अभी सुन रहे हैं",
"popularNowTitle": "अभी लोकप्रिय"
}
+3 -1
View File
@@ -816,5 +816,7 @@
"welcomeBullet3Title": "Alarm musik",
"welcomeCtaLabel": "Mulai mendengarkan",
"welcomeHeadline": "Duniamu, secara langsung",
"yourStationsTitle": "Stasiun Anda"
"yourStationsTitle": "Stasiun Anda",
"nowListeningLabel": "Sedang mendengarkan",
"popularNowTitle": "Populer sekarang"
}
+3 -1
View File
@@ -816,5 +816,7 @@
"welcomeBullet3Title": "Sveglie musicali",
"welcomeCtaLabel": "Inizia ad ascoltare",
"welcomeHeadline": "Il tuo mondo, in diretta",
"yourStationsTitle": "Le tue emittenti"
"yourStationsTitle": "Le tue emittenti",
"nowListeningLabel": "In ascolto ora",
"popularNowTitle": "Popolari ora"
}
+3 -1
View File
@@ -816,5 +816,7 @@
"welcomeBullet3Title": "音楽アラーム",
"welcomeCtaLabel": "聴き始める",
"welcomeHeadline": "あなたの世界を、ライブで",
"yourStationsTitle": "あなたの局"
"yourStationsTitle": "あなたの局",
"nowListeningLabel": "再生中",
"popularNowTitle": "今人気"
}
+3 -1
View File
@@ -816,5 +816,7 @@
"welcomeBullet3Title": "Alarmes musicais",
"welcomeCtaLabel": "Começar a ouvir",
"welcomeHeadline": "Seu mundo, ao vivo",
"yourStationsTitle": "Suas estações"
"yourStationsTitle": "Suas estações",
"nowListeningLabel": "Ouvindo agora",
"popularNowTitle": "Populares agora"
}
+3 -1
View File
@@ -816,5 +816,7 @@
"welcomeBullet3Title": "Музыкальные будильники",
"welcomeCtaLabel": "Начать слушать",
"welcomeHeadline": "Ваш мир, в прямом эфире",
"yourStationsTitle": "Ваши станции"
"yourStationsTitle": "Ваши станции",
"nowListeningLabel": "Сейчас слушаете",
"popularNowTitle": "Популярно сейчас"
}
+3 -1
View File
@@ -816,5 +816,7 @@
"welcomeBullet3Title": "音乐闹钟",
"welcomeCtaLabel": "开始收听",
"welcomeHeadline": "你的世界,直播中",
"yourStationsTitle": "你的电台"
"yourStationsTitle": "你的电台",
"nowListeningLabel": "正在收听",
"popularNowTitle": "当前热门"
}
+12
View File
@@ -1402,6 +1402,12 @@ abstract class AppLocalizations {
/// **'Abrir reproductor completo'**
String get openFullPlayerTooltip;
/// No description provided for @nowListeningLabel.
///
/// In es, this message translates to:
/// **'Escuchando ahora'**
String get nowListeningLabel;
/// No description provided for @nothingPlayingTitle.
///
/// In es, this message translates to:
@@ -1474,6 +1480,12 @@ abstract class AppLocalizations {
/// **'Esta semana'**
String get exploreNewSubtitle;
/// No description provided for @popularNowTitle.
///
/// In es, this message translates to:
/// **'Populares ahora'**
String get popularNowTitle;
/// No description provided for @offlineBannerTitle.
///
/// In es, this message translates to:
+6
View File
@@ -745,6 +745,9 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'فتح المشغل الكامل';
@override
String get nowListeningLabel => 'الاستماع الآن';
@override
String get nothingPlayingTitle => 'لا تستمع إلى شيء بعد';
@@ -784,6 +787,9 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get exploreNewSubtitle => 'هذا الأسبوع';
@override
String get popularNowTitle => 'الأكثر شيوعًا الآن';
@override
String get offlineBannerTitle => 'لا يوجد اتصال';
+6
View File
@@ -748,6 +748,9 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'সম্পূর্ণ প্লেয়ার খুলুন';
@override
String get nowListeningLabel => 'এখন শোনা হচ্ছে';
@override
String get nothingPlayingTitle => 'এখনও কিছু চলছে না';
@@ -787,6 +790,9 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get exploreNewSubtitle => 'এই সপ্তাহে';
@override
String get popularNowTitle => 'এখন জনপ্রিয়';
@override
String get offlineBannerTitle => 'সংযোগ নেই';
+6
View File
@@ -751,6 +751,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Vollständigen Player öffnen';
@override
String get nowListeningLabel => 'Läuft gerade';
@override
String get nothingPlayingTitle => 'Es läuft noch nichts';
@@ -790,6 +793,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get exploreNewSubtitle => 'Diese Woche';
@override
String get popularNowTitle => 'Jetzt beliebt';
@override
String get offlineBannerTitle => 'Keine Verbindung';
+6
View File
@@ -744,6 +744,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Open full player';
@override
String get nowListeningLabel => 'Now listening';
@override
String get nothingPlayingTitle => 'Nothing playing yet';
@@ -783,6 +786,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get exploreNewSubtitle => 'This week';
@override
String get popularNowTitle => 'Popular now';
@override
String get offlineBannerTitle => 'No connection';
+6
View File
@@ -749,6 +749,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Abrir reproductor completo';
@override
String get nowListeningLabel => 'Escuchando ahora';
@override
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
@@ -788,6 +791,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get exploreNewSubtitle => 'Esta semana';
@override
String get popularNowTitle => 'Populares ahora';
@override
String get offlineBannerTitle => 'Sin conexión';
+6
View File
@@ -753,6 +753,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Ouvrir le lecteur complet';
@override
String get nowListeningLabel => 'En cours d\'écoute';
@override
String get nothingPlayingTitle => 'Rien n\'est en cours de lecture';
@@ -792,6 +795,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get exploreNewSubtitle => 'Cette semaine';
@override
String get popularNowTitle => 'Populaire maintenant';
@override
String get offlineBannerTitle => 'Aucune connexion';
+6
View File
@@ -745,6 +745,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'पूरा प्लेयर खोलें';
@override
String get nowListeningLabel => 'अभी सुन रहे हैं';
@override
String get nothingPlayingTitle => 'अभी तक कुछ नहीं चल रहा';
@@ -784,6 +787,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get exploreNewSubtitle => 'इस सप्ताह';
@override
String get popularNowTitle => 'अभी लोकप्रिय';
@override
String get offlineBannerTitle => 'कोई कनेक्शन नहीं';
+6
View File
@@ -745,6 +745,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Buka pemutar lengkap';
@override
String get nowListeningLabel => 'Sedang mendengarkan';
@override
String get nothingPlayingTitle => 'Belum ada yang diputar';
@@ -784,6 +787,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get exploreNewSubtitle => 'Minggu ini';
@override
String get popularNowTitle => 'Populer sekarang';
@override
String get offlineBannerTitle => 'Tidak ada koneksi';
+6
View File
@@ -751,6 +751,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Apri il player completo';
@override
String get nowListeningLabel => 'In ascolto ora';
@override
String get nothingPlayingTitle => 'Non stai ancora ascoltando nulla';
@@ -790,6 +793,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get exploreNewSubtitle => 'Questa settimana';
@override
String get popularNowTitle => 'Popolari ora';
@override
String get offlineBannerTitle => 'Nessuna connessione';
+6
View File
@@ -720,6 +720,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'フルプレーヤーを開く';
@override
String get nowListeningLabel => '再生中';
@override
String get nothingPlayingTitle => 'まだ何も再生されていません';
@@ -758,6 +761,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get exploreNewSubtitle => '今週';
@override
String get popularNowTitle => '今人気';
@override
String get offlineBannerTitle => '接続なし';
+6
View File
@@ -748,6 +748,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Abrir player completo';
@override
String get nowListeningLabel => 'Ouvindo agora';
@override
String get nothingPlayingTitle => 'Ainda não há nada tocando';
@@ -787,6 +790,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get exploreNewSubtitle => 'Nesta semana';
@override
String get popularNowTitle => 'Populares agora';
@override
String get offlineBannerTitle => 'Sem conexão';
+6
View File
@@ -749,6 +749,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Открыть полный плеер';
@override
String get nowListeningLabel => 'Сейчас слушаете';
@override
String get nothingPlayingTitle => 'Пока ничего не воспроизводится';
@@ -788,6 +791,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get exploreNewSubtitle => 'На этой неделе';
@override
String get popularNowTitle => 'Популярно сейчас';
@override
String get offlineBannerTitle => 'Нет подключения';
+6
View File
@@ -717,6 +717,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get openFullPlayerTooltip => '打开完整播放器';
@override
String get nowListeningLabel => '正在收听';
@override
String get nothingPlayingTitle => '目前没有正在播放的内容';
@@ -755,6 +758,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get exploreNewSubtitle => '本周';
@override
String get popularNowTitle => '当前热门';
@override
String get offlineBannerTitle => '无连接';
+19 -4
View File
@@ -34,7 +34,9 @@ class GrupoAjustes extends StatelessWidget {
Text(titulo, style: type.eyebrowLabel),
const SizedBox(height: 4),
for (var i = 0; i < filas.length; i++) ...[
if (i > 0) const Divider(height: 1),
// S10 (Tier 4 visual fidelity): the prototype insets its row
// divider by 47px (t4 line 516), not full-bleed.
if (i > 0) const Divider(height: 1, indent: 47),
filas[i],
],
],
@@ -72,8 +74,14 @@ class FilaAjuste extends StatelessWidget {
final valorActual = valor;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(icon),
title: Text(titulo, style: type.cardTitle),
// 10.6 (Tier 4 visual fidelity): the prototype's row icon is 21px (t4
// line 514), not Material's 24px default.
leading: Icon(icon, size: 21),
// 10.8 (Tier 4 visual fidelity): the prototype's row title is
// 14px/w700 (t4 line 514); cardTitle is 14.5/w700 — a one-off
// override, not a new PluriWaveTypography style (mirrors the
// precedent set for the ringing screen's station name, audit 9.7).
title: Text(titulo, style: type.cardTitle.copyWith(fontSize: 14)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -88,7 +96,14 @@ class FilaAjuste extends StatelessWidget {
),
const SizedBox(width: 6),
],
const Icon(Icons.chevron_right_rounded),
// 10.9 (Tier 4 visual fidelity): the prototype's chevron is 19px
// at 40% opacity (t4 lines 515-539), not Material's 24px
// full-opacity default.
Icon(
Icons.chevron_right_rounded,
size: 19,
color: const Color(0xFFF2F7FA).withValues(alpha: 0.4),
),
],
),
onTap: onTap,
+109 -9
View File
@@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
@@ -199,6 +200,17 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
fit: StackFit.expand,
children: [
_FondoArteDifuminado(tokens: tokens),
// Audit 9.1 (t4 line 411): the pulsing amber halo behind the
// hero content — see _HaloPulsante for why it is a BOUNDED pulse,
// not the prototype's literal `infinite` animation.
Positioned(
top: 150,
left: 0,
right: 0,
child: IgnorePointer(
child: Center(child: _HaloPulsante(tokens: tokens)),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
@@ -218,7 +230,15 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
child: Text(
_hora(alarma),
key: const ValueKey('ringing-hero-time'),
style: type.heroTime,
// Audit 9.5 (t4 line 417): 88px/w800/ls-4/lh.95 on
// THIS screen only — a local override, not a change
// to the shared heroTime token (EditorHoraInline, the
// alarm editor's hour block, is the other consumer
// and wants height:1, t4 line 379).
style: type.heroTime.copyWith(
letterSpacing: -4,
height: 0.95,
),
),
),
const SizedBox(height: 6),
@@ -229,11 +249,11 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
),
const SizedBox(height: 22),
ClipRRect(
borderRadius: BorderRadius.circular(tokens.radiusLg),
borderRadius: BorderRadius.circular(_artworkRadio),
child: Image.asset(
'assets/icons/alarmas/alarm_music.png',
width: 168,
height: 168,
width: _artworkLado,
height: _artworkLado,
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => Icon(
@@ -280,11 +300,24 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
const Spacer(flex: 3),
Align(
alignment: Alignment.centerLeft,
child: Text(
l10n.snoozeAction,
style: type.eyebrowLabel.copyWith(
color: tokens.warmCoral,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Audit 9.8 (t4 line 428): the POSPONER eyebrow
// lost its snooze icon.
Icon(
Icons.snooze_rounded,
size: 19,
color: tokens.warmCoral,
),
const SizedBox(width: 8),
Text(
l10n.snoozeAction,
style: type.eyebrowLabel.copyWith(
color: tokens.warmCoral,
),
),
],
),
),
const SizedBox(height: 10),
@@ -377,6 +410,13 @@ String _hora(AlarmaMusical alarma) =>
/// call site (mirrors `_ArteEscuchar._radio` in `pantalla_inicio.dart`).
const _stopButtonRadius = 24.0;
/// Audit 9.6 (t4 line 421): the ringing screen's art is 180x180 with a 36
/// corner radius — neither matches [PluriWaveTokens]'s three named radii
/// (14/18/30), so this stays a local constant (same precedent as
/// [_stopButtonRadius] above).
const _artworkLado = 180.0;
const _artworkRadio = 36.0;
/// Audit 9.3: the schedule pill's text, built only from
/// [AlarmaMusical.tipoProgramacion] and the fields it already carries per
/// case (`diasSemana`, `fechaUnica`) — no new domain plumbing. Reuses the
@@ -458,6 +498,66 @@ class _PildoraProgramacion extends StatelessWidget {
}
}
/// Pulsing amber halo (audit 9.1, t4 line 411): a 420x420 radial gradient
/// centered behind the hero content, echoing the alarm's warmCoral accent.
///
/// BOUNDED, not infinite: the prototype's CSS is `animation: pw-pulse 2.4s
/// ease-in-out infinite`, but this screen's dismiss guard
/// (`pantalla_alarma_sonando_dismiss_guard_test.dart`, protected — must stay
/// byte-identical to `main`) calls `pumpAndSettle()` after every mount and
/// every interaction. A genuinely infinite `AnimationController.repeat()`
/// anywhere in this widget's subtree would hang every one of those calls
/// forever, with no way to fix it since that file cannot be edited (see
/// `_EstadoSubidaVolumen` above for the same reasoning applied earlier on
/// this exact screen). One grow-and-settle cycle, timed to the prototype's
/// own 2.4s cadence, delivers the same "draws the eye" motion without ever
/// leaving a frame scheduled forever. Respects reduced motion exactly like
/// every other entry animation in this app (`PluriAnimate`).
class _HaloPulsante extends StatelessWidget {
const _HaloPulsante({required this.tokens});
final PluriWaveTokens tokens;
static const _lado = 420.0;
@override
Widget build(BuildContext context) {
final halo = Container(
key: const ValueKey('ringing-pulse-halo'),
width: _lado,
height: _lado,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
tokens.warmCoral.withValues(alpha: 0.2),
tokens.warmCoral.withValues(alpha: 0),
],
stops: const [0, 0.62],
),
),
);
if (MediaQuery.maybeDisableAnimationsOf(context) ?? false) {
return halo;
}
return halo
.animate()
.scaleXY(
begin: 1,
end: 1.08,
duration: 1200.ms,
curve: Curves.easeInOut,
)
.then()
.scaleXY(
begin: 1.08,
end: 1,
duration: 1200.ms,
curve: Curves.easeInOut,
);
}
}
/// Full-bleed blurred backdrop (WU11, replaces the `PluriGlassSurface` card
/// container per task 11.3). This app has no per-station artwork/favicon
/// safe to render here: `Emisora.favicon` is a network URL, and rendering
+8 -1
View File
@@ -63,7 +63,14 @@ class PantallaAlarmas extends StatelessWidget {
else
for (final alarma in estado.alarmas) ...[
_TarjetaAlarma(alarma: alarma),
const SizedBox(height: 12),
// 7.5 (Tier 4 visual fidelity): the prototype's gap
// between stacked alarm cards is 10px (t4 line 334),
// not 12. Keyed per alarm id so the guard test can
// target the exact gap between two known cards.
SizedBox(
height: 10,
key: ValueKey('alarm-card-gap-${alarma.id}'),
),
],
_PanelVacaciones(estado: estado),
const SizedBox(height: 12),
+29 -13
View File
@@ -71,17 +71,23 @@ class PantallaBienvenida extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Image.asset(
'assets/icons/pluriwave_app_mark.png',
key: const ValueKey('welcome-logo'),
width: 76,
height: 76,
errorBuilder:
(_, __, ___) => Icon(
Icons.graphic_eq_rounded,
size: 76,
color: t.electricMagenta,
),
// Audit 14.8 (t4 line 693): the prototype clips
// its logo mark to a 20px rounded rect; the build
// used to paint it square.
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/icons/pluriwave_app_mark.png',
key: const ValueKey('welcome-logo'),
width: 76,
height: 76,
errorBuilder:
(_, __, ___) => Icon(
Icons.graphic_eq_rounded,
size: 76,
color: t.electricMagenta,
),
),
),
const SizedBox(height: 22),
Text(
@@ -95,7 +101,10 @@ class PantallaBienvenida extends StatelessWidget {
const SizedBox(height: 12),
Text(
l10n.welcomeBody,
// Audit 14.4 (t4 line 697): 14.5px, not
// bodyMedium's 14.
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 14.5,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.72,
),
@@ -109,14 +118,16 @@ class PantallaBienvenida extends StatelessWidget {
titulo: l10n.welcomeBullet1Title,
subtitulo: l10n.welcomeBullet1Subtitle,
),
const SizedBox(height: 16),
// Audit 14.6 (t4 line 701): 12px between bullets,
// not 16.
const SizedBox(height: 12),
FilaCaracteristicaBienvenida(
icon: Icons.directions_car_rounded,
iconColor: t.liveGreen,
titulo: l10n.welcomeBullet2Title,
subtitulo: l10n.welcomeBullet2Subtitle,
),
const SizedBox(height: 16),
const SizedBox(height: 12),
FilaCaracteristicaBienvenida(
icon: Icons.alarm_rounded,
iconColor: t.warmCoral,
@@ -196,13 +207,18 @@ class FilaCaracteristicaBienvenida extends StatelessWidget {
children: [
Text(
titulo,
// Audit 14.5 (t4 line 700): 13.5px/w800, not bodyMedium's
// 14/w800.
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 13.5,
fontWeight: FontWeight.w800,
),
),
Text(
subtitulo,
// Audit 14.5 (t4 line 700): 11.5px, not bodySmall's 12.
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 11.5,
color: theme.colorScheme.onSurface.withValues(alpha: 0.58),
),
),
+223 -7
View File
@@ -6,11 +6,14 @@ import 'package:shimmer/shimmer.dart' as shimmer;
import '../estado/estado_busqueda.dart';
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_audio.dart';
import '../tema/pluri_animate.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/fila_emisora_plana.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
@@ -214,6 +217,10 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
theme,
l10n,
),
// Item 25 / audit 13.2 (t4:643-646): the reconnect card -- until
// now the ONLY signal of a reconnect was a word in the mini
// player.
_TarjetaReconectando(estado: context.watch<EstadoRadio>()),
_gridEmisoras(context, l10n),
],
],
@@ -562,12 +569,15 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
}
final total = resultados.length + (estado.hayMas ? 1 : 0);
return ListView.separated(
// Item 23 / audit 6.6 (t4:299): rows sit back-to-back -- `ListView`, not
// `.separated`, since there is no longer a 10px gap to insert between
// them (the prototype's own results column is a bare `flex-direction:
// column`, no `gap`).
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(PluriLayout.horizontal),
itemCount: total,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, i) {
if (i >= resultados.length) {
if (!estado.cargandoMas) {
@@ -581,10 +591,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
if (i >= resultados.length - 5 && estado.hayMas) {
Future<void>.microtask(estado.cargarMas);
}
return TarjetaEmisora(
emisora: resultados[i],
esCompacta: true,
onTap: () => reproducirMinimizado(context, resultados[i]),
final emisora = resultados[i];
// Item 23 / audit 6.5 (t4:302-306): a flat, background-less row --
// square art, name+meta, a favourite toggle, and a circular play
// affordance -- replacing the full glass TarjetaEmisora card.
return FilaEmisoraPlana(
key: ValueKey(emisora.uuid),
emisora: emisora,
meta: _metaResultado(emisora),
onTap: () => reproducirMinimizado(context, emisora),
trailing: [
BotonFavoritoEmisora(emisora: emisora),
BotonReproducirCircular(
onPressed: () => reproducirMinimizado(context, emisora),
),
],
).pluriFadeSlideIn(
context,
delay: Duration(milliseconds: i.clamp(0, 12) * 20),
@@ -886,7 +907,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaPaises(),
// Item 24 / audit 5.2: tapping a country row
// pops back to Búsqueda and filters results by
// that ISO code -- the SAME `EstadoBusqueda.
// buscar(pais: ...)` filter the picker sheet
// already uses, just with an arbitrary code
// instead of one of this screen's ~10 presets.
(_) => PantallaPaises(
onPaisSeleccionado: (pais) {
Navigator.of(context).pop();
setState(
() => _paisSeleccionado = pais.codigoIso,
);
_buscar();
},
),
),
),
),
@@ -1126,6 +1161,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
}
}
/// Item 23 / audit 6.5 (t4:303): "genre - country - kbps" built ONLY from
/// fields [Emisora] already carries -- mirrors the Escuchar hero's own
/// `_metaEscuchar` (audit 1.6, `pantalla_inicio.dart`) exactly, duplicated
/// rather than shared since each screen's meta line is free to evolve
/// independently. Whatever a station lacks is omitted gracefully.
String _metaResultado(Emisora emisora) {
final partes = <String>[
if (emisora.generos.isNotEmpty) emisora.generos.first,
if (emisora.pais != null && emisora.pais!.isNotEmpty) emisora.pais!,
if (emisora.bitrate != null && emisora.bitrate! > 0)
'${emisora.bitrate} kbps',
];
return partes.join(' · ');
}
String _genreName(AppLocalizations l10n, String tag) => switch (tag) {
'pop' => l10n.genrePop,
'rock' => l10n.genreRock,
@@ -1235,3 +1285,169 @@ class _CeldaExplorarPor extends StatelessWidget {
);
}
}
/// Item 25 / audit 13.2 (t4:643-646): the reconnect card. Renders nothing
/// unless playback is actively reconnecting AND a station is known.
///
/// NO attempt counter: the prototype shows "Reconectando · intento 2 de 5",
/// backed by `ControladorReconexion.intentos`/`.maxReintentos`
/// (`controlador_reconexion.dart:42`/`:33`). That controller instance lives
/// as a PRIVATE field of `PluriWaveAudioHandler` inside
/// `servicio_audio.dart` -- a file this task requires stay byte-identical
/// to `main` -- and nothing else in the app already re-exposes it
/// publicly. Reconstructing a count from the public `estadoStream`'s
/// `reconectando` emissions would not be a faithful proxy (the stream can
/// emit `reconectando` many times per actual backoff attempt), so it was
/// deliberately not attempted: a fabricated number is worse than none.
class _TarjetaReconectando extends StatelessWidget {
const _TarjetaReconectando({required this.estado});
final EstadoRadio estado;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return StreamBuilder<EstadoReproduccion>(
stream: estado.estadoStream,
builder: (context, snapshot) {
final emisora = estado.emisoraActual;
if (snapshot.data != EstadoReproduccion.reconectando ||
emisora == null) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 16),
child: _CuerpoTarjetaReconectando(
nombreEstacion: localizedStationName(l10n, emisora.nombre),
etiquetaReconectando: l10n.playbackStatusReconnecting,
tooltipDetener: l10n.stopAction,
onDetener: estado.detenerReproduccion,
),
);
},
);
}
}
class _CuerpoTarjetaReconectando extends StatefulWidget {
const _CuerpoTarjetaReconectando({
required this.nombreEstacion,
required this.etiquetaReconectando,
required this.tooltipDetener,
required this.onDetener,
});
final String nombreEstacion;
final String etiquetaReconectando;
final String tooltipDetener;
final VoidCallback onDetener;
@override
State<_CuerpoTarjetaReconectando> createState() =>
_CuerpoTarjetaReconectandoState();
}
class _CuerpoTarjetaReconectandoState extends State<_CuerpoTarjetaReconectando>
with SingleTickerProviderStateMixin {
// t4:644: `animation:pw-ring .9s linear infinite`.
late final AnimationController _ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final onSurface = Theme.of(context).colorScheme.onSurface;
return Container(
key: const ValueKey('tarjeta-reconectando'),
margin: const EdgeInsets.symmetric(horizontal: PluriLayout.horizontal),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
color: Colors.white.withValues(alpha: 0.09),
),
child: Row(
children: [
// t4:644: a 52x52 rotating ring around a radio icon.
SizedBox(
width: 52,
height: 52,
child: Stack(
alignment: Alignment.center,
children: [
RotationTransition(
turns: _ctrl,
child: CircularProgressIndicator(
value: 0.25,
strokeWidth: 3,
backgroundColor: PluriWaveTokens.brand.withValues(
alpha: 0.22,
),
valueColor: AlwaysStoppedAnimation(PluriWaveTokens.brand),
),
),
const Icon(
Icons.radio_rounded,
size: 22,
color: PluriWaveTokens.brand,
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// t4:645: 15px/w800.
Text(
widget.nombreEstacion,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
// t4:645: 12px/w700/warmCoral (amber).
Text(
widget.etiquetaReconectando,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: PluriWaveTokens.dark.warmCoral,
),
),
],
),
),
// t4:646: stop_circle 24px, neutral (not brand-coloured) -- this
// stops playback outright, unlike the mini player's retry icon.
SizedBox(
width: 44,
height: 44,
child: IconButton(
padding: EdgeInsets.zero,
tooltip: widget.tooltipDetener,
icon: Icon(
Icons.stop_circle_rounded,
size: 24,
color: onSurface.withValues(alpha: 0.6),
),
onPressed: widget.onDetener,
),
),
],
),
);
}
}
+78 -45
View File
@@ -6,13 +6,13 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../widgets/fila_emisora_plana.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_push_scaffold.dart';
import '../widgets/pluri_root_header.dart';
import '../widgets/pluri_sleep_timer_sheet.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart';
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
@@ -399,45 +399,65 @@ class _FilaFavorito extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.only(right: 4),
child: Icon(Icons.drag_handle_rounded),
),
),
Expanded(
child: TarjetaEmisora(
key: Key(emisora.uuid),
emisora: emisora,
esCompacta: true,
onTap: () => reproducirMinimizado(context, emisora),
),
),
const SizedBox(width: 6),
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: l10n.favoriteGroupsAssignSubtitle(
_nombreVisible(l10n, grupoActual),
),
icon: const Icon(Icons.drive_file_move_rounded),
onPressed: () => _asignar(context),
),
IconButton.filledTonal(
tooltip: l10n.favoritesRemoveTooltip,
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => _eliminar(context),
),
],
),
],
final meta = [
emisora.pais,
emisora.idioma,
].where((s) => s != null && s.isNotEmpty).join(' · ');
// Item 23 / audit 4.3 (t4:226-232): a flat, background-less row --
// drag handle, square art, name+meta, and a circular play affordance --
// replacing the full glass TarjetaEmisora card and its two stacked
// filledTonal buttons. "Move to list"/"Remove from favorites" keep their
// EXACT prior logic (`_asignar`/`_eliminar`, untouched), now reachable
// from an overflow menu instead of two always-visible buttons -- the
// prototype's row has no such menu, but dropping either capability
// entirely would be a functional regression, not a fidelity fix.
return FilaEmisoraPlana(
key: Key(emisora.uuid),
emisora: emisora,
meta: meta,
onTap: () => reproducirMinimizado(context, emisora),
leading: ReorderableDragStartListener(
index: index,
child: Icon(
// t4:227: drag_indicator, not drag_handle, at 22px/28%.
Icons.drag_indicator_rounded,
size: 22,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.28),
),
),
trailing: [
BotonReproducirCircular(
onPressed: () => reproducirMinimizado(context, emisora),
),
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert_rounded,
size: 20,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.45),
),
constraints: const BoxConstraints.tightFor(width: 38, height: 42),
onSelected: (accion) {
if (accion == 'assign') _asignar(context);
if (accion == 'remove') _eliminar(context);
},
itemBuilder:
(context) => [
PopupMenuItem(
value: 'assign',
child: Text(l10n.favoriteGroupsAssign),
),
PopupMenuItem(
value: 'remove',
child: Text(l10n.favoritesRemoveTooltip),
),
],
),
],
);
}
}
@@ -454,24 +474,37 @@ class _CtaEmisoraPersonalizada extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final color = Theme.of(
// Audit 4.5 (t4 line 235): the border and the label/icon are TWO
// different opacities in the prototype — `rgba(255,255,255,.16)` for
// the dashed stroke, `rgba(242,247,250,.6)` for the text/icon — not one
// shared 50% colour for both.
final colorBorde = Colors.white.withValues(alpha: 0.16);
final colorTexto = Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5);
).colorScheme.onSurface.withValues(alpha: 0.6);
return CustomPaint(
painter: _DashedBorderPainter(color: color),
painter: _DashedBorderPainter(color: colorBorde),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16),
key: const Key('custom-station-cta-padding'),
padding: const EdgeInsets.all(14),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_circle_outline_rounded, color: color),
Icon(Icons.add_rounded, size: 20, color: colorTexto),
const SizedBox(width: 8),
Text(l10n.customStationsAddCta, style: TextStyle(color: color)),
Text(
l10n.customStationsAddCta,
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w800,
color: colorTexto,
),
),
],
),
),
+130 -52
View File
@@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../estado/estado_grabacion.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/archivo_grabacion.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
@@ -231,7 +232,9 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
title: l10n.recordingsLibraryTitle,
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
// Audit 12.1 (t4:610): the header action is `folder_open` at
// 22px, not a generic gear.
icon: const Icon(Icons.folder_open_rounded, size: 22),
tooltip: l10n.recordingsLibrarySettingsTooltip,
onPressed:
() => PluriPushScaffold.push(
@@ -257,20 +260,27 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
subtitle: l10n.recordingsLibraryEmptySubtitle,
)
else
for (final archivo in archivos)
_FilaGrabacion(
archivo: archivo,
reproduciendo:
_reproductor.rutaActual == archivo.ruta &&
_reproductor.reproduciendo,
duracion: _duracionPara(archivo.ruta),
formatearDuracion: _formatearDuracion,
formatearFecha: _formatearFecha,
formatearBytes: _formatearBytes,
onAlternarReproduccion:
() => _alternarReproduccion(archivo.ruta),
onAccionMenu: (accion) => _manejarAccion(accion, archivo),
),
// t4:618: `gap:2px` between rows.
PluriPanelColumn(
gap: 2,
children: [
for (final archivo in archivos)
_FilaGrabacion(
archivo: archivo,
reproduciendo:
_reproductor.rutaActual == archivo.ruta &&
_reproductor.reproduciendo,
duracion: _duracionPara(archivo.ruta),
formatearDuracion: _formatearDuracion,
formatearFecha: _formatearFecha,
formatearBytes: _formatearBytes,
onAlternarReproduccion:
() => _alternarReproduccion(archivo.ruta),
onAccionMenu:
(accion) => _manejarAccion(accion, archivo),
),
],
),
],
);
},
@@ -337,48 +347,116 @@ class _FilaGrabacion extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return PluriGlassSurface(
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: IconButton(
icon: Icon(
reproduciendo
? Icons.pause_circle_filled_rounded
: Icons.play_circle_fill_rounded,
// Item 23 / audit 12.4 (t4:616-619): a flat, background-less row --
// 44x44/radius-12 art placeholder (recordings carry no per-station
// favicon, so this is a themed fallback square, not invented artwork),
// name, meta line, a 24px play/pause affordance, and the SAME "-"
// menu (Rename/Share/Delete) as before, just restyled.
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
child: Row(
children: [
ClipRRect(
key: const ValueKey('fila-grabacion-arte'),
borderRadius: BorderRadius.circular(12),
child: Container(
width: 44,
height: 44,
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 22,
color: theme.colorScheme.onPrimaryContainer,
),
),
),
onPressed: onAlternarReproduccion,
),
title: Text(archivo.nombre),
subtitle: FutureBuilder<Duration?>(
future: duracion,
builder: (context, snap) {
return Text(
'${formatearFecha(archivo.fecha)} · '
'${formatearDuracion(snap.data)} · '
'${formatearBytes(archivo.tamanoBytes)}',
);
},
),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert_rounded),
onSelected: onAccionMenu,
itemBuilder:
(context) => [
PopupMenuItem(
value: 'rename',
child: Text(l10n.recordingActionRename),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// t4:619: 14.5px/w700.
Text(
archivo.nombre,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
PopupMenuItem(
value: 'share',
child: Text(l10n.recordingActionShare),
),
PopupMenuItem(
value: 'delete',
child: Text(l10n.recordingActionDelete),
// t4:619: 12px/rgba(242,247,250,.55).
FutureBuilder<Duration?>(
future: duracion,
builder: (context, snap) {
return Text(
'${formatearFecha(archivo.fecha)} · '
'${formatearDuracion(snap.data)} · '
'${formatearBytes(archivo.tamanoBytes)}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.55,
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
),
],
),
),
),
// t4:619: play_circle 24px brand teal in a 42x42 target.
SizedBox(
width: 42,
height: 42,
child: IconButton(
padding: EdgeInsets.zero,
tooltip: reproduciendo ? l10n.pauseAction : l10n.playAction,
icon: Icon(
reproduciendo
? Icons.pause_circle_filled_rounded
: Icons.play_circle_fill_rounded,
size: 24,
color: PluriWaveTokens.brand,
),
onPressed: onAlternarReproduccion,
),
),
// t4:619: more_vert 20px/45% in a 38x42 target.
SizedBox(
width: 38,
height: 42,
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
icon: Icon(
Icons.more_vert_rounded,
size: 20,
color: theme.colorScheme.onSurface.withValues(alpha: 0.45),
),
onSelected: onAccionMenu,
itemBuilder:
(context) => [
PopupMenuItem(
value: 'rename',
child: Text(l10n.recordingActionRename),
),
PopupMenuItem(
value: 'share',
child: Text(l10n.recordingActionShare),
),
PopupMenuItem(
value: 'delete',
child: Text(l10n.recordingActionDelete),
),
],
),
),
],
),
);
}
+295 -49
View File
@@ -14,7 +14,6 @@ import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_root_header.dart';
import '../widgets/pluri_sleep_timer_sheet.dart';
import '../widgets/visualizador_audio.dart';
@@ -38,35 +37,46 @@ class _PantallaInicioState extends State<PantallaInicio> {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
return CustomScrollView(
slivers: [
// S1 (Tier 1 visual fidelity): the prototype has no global AppBar —
// this root now draws its own 56px title row instead of relying on
// app.dart's removed shared chrome (which is also where the
// sleep-timer action used to live).
SliverToBoxAdapter(
child: PluriRootHeader(
title: l10n.navHome,
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
),
// WU5 built the hero; WU6 relocated the discovery sections that
// used to follow it (_seccionCercanas, _seccionTendencias,
// _chipGeneros, _errorBanner, the browse grid) into
// PantallaBuscar's landing state and DELETED them here (this WU's
// own task 6.5 — completing WU5's task 5.9 deferral). Escuchar's
// content is now just the hero and the favorites preview below;
// pull-to-refresh was dropped along with the grid it refreshed —
// the retry button that used to live in the (now relocated) error
// banner already covers manual recovery on Buscar.
const SliverToBoxAdapter(child: _EscucharHero()),
SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)),
// ADR-7(b): the mini player is hidden on this whole screen
// (app.dart), so its content needs less bottom padding than every
// other root — escucharBottomChromeInset, not the plain
// bottomChromeInset every other root/scrollable uses.
const SliverToBoxAdapter(
child: SizedBox(height: PluriLayout.escucharBottomChromeInset),
// Audit 1.1 (t4 lines 49-51): a full-bleed art/gradient/noise wash sits
// BEHIND the whole screen, not just inside the hero card -- the Stack
// wrapper is new; everything that used to be the bare CustomScrollView
// return value is unchanged below.
return Stack(
fit: StackFit.expand,
children: [
const _FondoEscuchar(),
CustomScrollView(
slivers: [
// Audit 1.2 (t4 line 53): unlike the other 3 roots' plain 56px
// PluriRootHeader, Escuchar's own header is a 52px "Now
// listening" eyebrow row -- the tab bar already identifies this
// destination, so it never repeats "Escuchar" as a title.
SliverToBoxAdapter(
child: _CabeceraEscuchar(
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
),
// WU5 built the hero; WU6 relocated the discovery sections that
// used to follow it (_seccionCercanas, _seccionTendencias,
// _chipGeneros, _errorBanner, the browse grid) into
// PantallaBuscar's landing state and DELETED them here (this WU's
// own task 6.5 — completing WU5's task 5.9 deferral). Escuchar's
// content is now just the hero and the favorites preview below;
// pull-to-refresh was dropped along with the grid it refreshed —
// the retry button that used to live in the (now relocated) error
// banner already covers manual recovery on Buscar.
const SliverToBoxAdapter(child: _EscucharHero()),
SliverToBoxAdapter(
child: _seccionTusEmisoras(context, theme, l10n),
),
// ADR-7(b): the mini player is hidden on this whole screen
// (app.dart), so its content needs less bottom padding than every
// other root — escucharBottomChromeInset, not the plain
// bottomChromeInset every other root/scrollable uses.
const SliverToBoxAdapter(
child: SizedBox(height: PluriLayout.escucharBottomChromeInset),
),
],
),
],
);
@@ -107,19 +117,47 @@ class _PantallaInicioState extends State<PantallaInicio> {
Row(
children: [
Expanded(
child: Text(
l10n.yourStationsTitle,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
child: Row(
children: [
Flexible(
child: Text(
l10n.yourStationsTitle,
// S7 (visual fidelity, t4 line 80): the in-page
// section heading style (15/w800/ls-.2), not
// titleMedium(16)/w900.
style: context.pluriType.inPageSectionHeading,
overflow: TextOverflow.ellipsis,
),
),
// Audit 1.11 (t4 line 80): a count badge next to the
// section title — the TOTAL favorite count, not the
// 8-capped grid size shown below it.
if (favoritos.isNotEmpty) ...[
const SizedBox(width: 10),
_InsigniaRecuento(cuenta: favoritos.length),
],
],
),
),
TextButton(
onPressed:
() => context.read<EstadoNavegacionRaiz>().irA(
RaizPluriWave.favoritos,
// Audit 1.12 (t4 line 80): "Ver todas" is plain 12px/w800
// brand-teal text — not a Material TextButton with its own
// padding and splash.
Semantics(
button: true,
child: GestureDetector(
onTap:
() => context.read<EstadoNavegacionRaiz>().irA(
RaizPluriWave.favoritos,
),
child: Text(
l10n.seeAllAction,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: context.pluriTokens.electricMagenta,
),
child: Text(l10n.seeAllAction),
),
),
),
],
),
@@ -160,6 +198,134 @@ class _PantallaInicioState extends State<PantallaInicio> {
}
}
/// Audit 1.1 (t4 lines 49-51): a full-bleed backdrop behind the ENTIRE
/// screen -- 400px of the current station's own art at 50% opacity, a
/// gradient fading it into the page background by 430px, and a faint noise
/// texture. Reads `emisoraActual` ITSELF (not a parent watch), so this stays
/// the only new subscriber -- `_PantallaInicioState.build()` keeps its
/// documented S4-R5 "no root watch" rule. Renders the gradient/noise even
/// with nothing playing (there is simply no image layer to show then) so
/// the page keeps the same base depth in every state.
class _FondoEscuchar extends StatelessWidget {
const _FondoEscuchar();
static const double _altoArte = 400;
static const double _altoDegradado = 430;
@override
Widget build(BuildContext context) {
final emisora = context.select<EstadoRadio, Emisora?>(
(e) => e.emisoraActual,
);
final tokens = context.pluriTokens;
final favicon = emisora?.favicon;
return IgnorePointer(
key: const ValueKey('escuchar-background-art'),
child: Stack(
children: [
if (favicon != null && favicon.isNotEmpty)
SizedBox(
width: double.infinity,
height: _altoArte,
child: Opacity(
opacity: 0.5,
child: CachedNetworkImage(
imageUrl: favicon,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
),
SizedBox(
width: double.infinity,
height: _altoDegradado,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
tokens.deepViolet.withValues(alpha: 0.4),
tokens.deepViolet.withValues(alpha: 0.86),
tokens.deepViolet,
],
stops: const [0, 0.58, 1],
),
),
),
),
SizedBox(
width: double.infinity,
height: _altoDegradado,
child: Opacity(
opacity: 0.05,
child: Image.asset(
'assets/images/noise_soft.png',
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
),
],
),
);
}
}
/// Audit 1.2 (t4 line 53): Escuchar's own header -- 52px, an eyebrow-styled
/// "Now listening" label (not a screen title -- the tab bar already
/// identifies this destination) and a single trailing action.
///
/// The prototype also draws a `cast` icon here. This app has no casting
/// service anywhere in `lib/servicios` (checked before writing this) --
/// inventing a Chromecast-style button with no backing capability would fail
/// the same "don't invent a capability absent from the domain" test WU5/WU9
/// already established elsewhere in this app, so it is omitted, not faked.
/// The ONE real action this row keeps is the sleep timer, which used to be
/// [PluriRootHeader]'s bedtime button on this exact screen.
class _CabeceraEscuchar extends StatelessWidget {
const _CabeceraEscuchar({required this.onSleepTimer});
final VoidCallback onSleepTimer;
static const double altura = 52;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SizedBox(
height: altura,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 10, 0),
child: Row(
children: [
Expanded(
child: Text(
l10n.nowListeningLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
),
),
),
IconButton(
icon: const Icon(Icons.bedtime_outlined),
iconSize: 23,
tooltip: l10n.sleepTimer,
onPressed: onSleepTimer,
),
],
),
),
);
}
}
/// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the
/// single source of truth (`emisoraActual` already feeds `MiniReproductor`
/// and `PantallaReproductor`) — this hero is a third VIEW, never a third
@@ -265,8 +431,14 @@ class _EscucharHero extends StatelessWidget {
const SizedBox(height: 6),
Text(
stationName,
// Audit 1.5 (t4 line 60): 24/w800/ls-.5/height 1.12
// -- titleLarge is 22 with neither letter-spacing
// nor an explicit line height.
style: theme.textTheme.titleLarge?.copyWith(
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
height: 1.12,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -307,12 +479,55 @@ class _EscucharHero extends StatelessWidget {
const SizedBox(height: 14),
_FilaTransporteEscuchar(emisora: emisora),
const SizedBox(height: 10),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
icon: const Icon(Icons.tune_rounded, size: 18),
label: Text(l10n.openFullPlayerTooltip),
onPressed: () => PantallaReproductor.abrir(context, emisora),
// Audit 1.9 (t4 line 78): a CENTRED pill -- padding:9px 15px,
// radius:999, rgba(255,255,255,.07) + border .1, 12px/w700,
// icon expand_less 17px -- not a left-aligned OutlinedButton
// with tune_rounded at 18px.
Center(
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () => PantallaReproductor.abrir(context, emisora),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 9,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.expand_less_rounded,
size: 17,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.78,
),
),
const SizedBox(width: 6),
Text(
l10n.openFullPlayerTooltip,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.78,
),
),
),
],
),
),
),
),
),
),
],
@@ -447,8 +662,10 @@ class _FilaTransporteEscuchar extends StatelessWidget {
s == EstadoReproduccion.cargando ||
s == EstadoReproduccion.reconectando;
return SizedBox(
width: 56,
height: 56,
// Audit 1.8 (t4 line 73): 72x72 with a 38px icon, not 56x56
// with a 28px icon.
width: 72,
height: 72,
child: FilledButton(
style: FilledButton.styleFrom(
shape: const CircleBorder(),
@@ -476,7 +693,7 @@ class _FilaTransporteEscuchar extends StatelessWidget {
reproduciendo
? Icons.pause_rounded
: Icons.play_arrow_rounded,
size: 28,
size: 38,
),
),
);
@@ -574,6 +791,35 @@ String? _metaEscuchar(Emisora emisora) {
return partes.isEmpty ? null : partes.join(' · ');
}
/// Audit 1.11 (t4 line 80): the pill badge next to "Tus emisoras" showing
/// the total favorite count — `rgba(255,255,255,.08)` fill, 11px/w800/60%.
class _InsigniaRecuento extends StatelessWidget {
const _InsigniaRecuento({required this.cuenta});
final int cuenta;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(999),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Text(
'$cuenta',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: const Color(0xFFF2F7FA).withValues(alpha: 0.6),
),
),
),
);
}
}
/// Audit 1.10 (t4 lines 82-88): the "Tus emisoras" grid cell — a 44px
/// square thumbnail (radius 11), name (13/w700/lh1.2) and genre
/// (11/55%). Deliberately NOT `TarjetaEmisora(esCompacta: true)`: that
+115 -27
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../estado/estado_busqueda.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/pais_radio.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
@@ -15,7 +16,14 @@ import '../widgets/pluri_push_scaffold.dart';
/// live station counts already parsed from the API's `stationcount`
/// **string** field (`PaisRadio.fromApi`, Engram id 2500).
class PantallaPaises extends StatefulWidget {
const PantallaPaises({super.key});
const PantallaPaises({super.key, this.onPaisSeleccionado});
/// Item 24 / audit 5.2 (t4:256-269): every row is tappable (the prototype
/// draws a `chevron_right` on all of them). Optional so this screen stays
/// usable stand-alone; the one production call site
/// (`pantalla_buscar.dart`'s "Explorar por" grid) wires this to filter
/// search results by the tapped country and pop back.
final ValueChanged<PaisRadio>? onPaisSeleccionado;
@override
State<PantallaPaises> createState() => _PantallaPaisesState();
@@ -78,6 +86,10 @@ class _PantallaPaisesState extends State<PantallaPaises> {
);
}
void _seleccionar(PaisRadio pais) {
widget.onPaisSeleccionado?.call(pais);
}
Widget _seccionTusIdiomas(
BuildContext context,
List<PaisRadio> paises,
@@ -105,18 +117,16 @@ class _PantallaPaisesState extends State<PantallaPaises> {
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final pais in destacados)
Chip(
label: Text(
'${pais.nombre} · ${l10n.stationsCount(pais.numeroEmisoras)}',
),
),
],
),
// Item 24 / audit 5.1 (t4:255-258): a column of tappable ISO
// rows, not a Wrap of non-interactive Chips.
for (final pais in destacados)
_FilaPais(
pais: pais,
l10n: l10n,
// Item 24 / audit 5.5 (t4:256): the first row is highlighted.
destacado: pais == destacados.first,
onTap: () => _seleccionar(pais),
),
],
),
);
@@ -140,22 +150,100 @@ class _PantallaPaisesState extends State<PantallaPaises> {
),
),
const SizedBox(height: 4),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: paises.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final pais = paises[i];
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(pais.nombre),
trailing: Text(l10n.stationsCount(pais.numeroEmisoras)),
);
},
),
// Item 24 / audit 5.1-5.3 (t4:262-269): the same tappable ISO row
// as "Tus idiomas" -- not the previous ListTile, which had no ISO
// column and no onTap.
for (final pais in paises)
_FilaPais(pais: pais, l10n: l10n, onTap: () => _seleccionar(pais)),
],
),
);
}
}
/// Item 24 / audit 5.1-5.3, 5.5 (t4:256-269): a tappable row -- ISO code
/// column, name, station count, and a chevron -- shared by "Tus idiomas"
/// and "Todos". [destacado] applies the t4:256 highlight (teal-tinted
/// background, bold name) reserved for the very first "Tus idiomas" row.
class _FilaPais extends StatelessWidget {
const _FilaPais({
required this.pais,
required this.l10n,
this.destacado = false,
this.onTap,
});
final PaisRadio pais;
final AppLocalizations l10n;
final bool destacado;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final onSurface = Theme.of(context).colorScheme.onSurface;
return DecoratedBox(
decoration: BoxDecoration(
color:
destacado
? PluriWaveTokens.brand.withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(14),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// t4:256-258: 26px/lh1, centred in a 34-wide column.
SizedBox(
width: 34,
child: Text(
pais.codigoIso,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 26, height: 1),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pais.nombre,
style: TextStyle(
fontSize: 15.5,
fontWeight:
destacado ? FontWeight.w800 : FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
l10n.stationsCount(pais.numeroEmisoras),
style: TextStyle(
fontSize: 12,
color: onSurface.withValues(alpha: 0.55),
),
),
],
),
),
Icon(
Icons.chevron_right_rounded,
size: 20,
color: onSurface.withValues(alpha: 0.4),
),
],
),
),
),
),
);
}
}
+269 -161
View File
@@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -13,6 +15,7 @@ import '../servicios/servicio_audio.dart';
import '../servicios/servicio_timer.dart';
import '../tema/pluri_animate.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/ecualizador_widget.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_premium_widgets.dart';
@@ -117,80 +120,110 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
);
},
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
children: [
_ArteReproductor(
emisora: emisoraActiva,
estadoStream: estado.estadoStream,
).pluriScaleIn(
context,
begin: 0.86,
duration: const Duration(milliseconds: 420),
curve: Curves.easeOutBack,
// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the
// station's own art sits BEHIND the scrollable content -- the Stack
// wrapper is new; the SafeArea/SingleChildScrollView subtree below is
// unchanged.
body: Stack(
fit: StackFit.expand,
children: [
_FondoReproductor(emisora: emisoraActiva, tokens: tokens),
SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
children: [
_ArteReproductor(
emisora: emisoraActiva,
estadoStream: estado.estadoStream,
).pluriScaleIn(
context,
begin: 0.86,
duration: const Duration(milliseconds: 420),
curve: Curves.easeOutBack,
),
const SizedBox(height: 18),
Text(
emisoraActiva.nombre,
// Audit 2.3 (t4 line 116): 26/w800/ls-.5/height 1.15 --
// headlineSmall is 24/w700 with neither letter-spacing
// nor an explicit line height.
style: theme.textTheme.headlineSmall?.copyWith(
fontSize: 26,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
height: 1.15,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
).pluriFadeIn(
context,
delay: const Duration(milliseconds: 150),
),
const SizedBox(height: 6),
_SubtituloInfo(emisora: emisoraActiva).pluriFadeIn(
context,
delay: const Duration(milliseconds: 190),
),
const SizedBox(height: 14),
// Audit 2.10 (t4 lines 120-122): the bars sit directly on
// the background -- no wrapping glass card. The key moves
// from the (removed) PluriGlassSurface onto the
// visualizer itself so existing finders keep working.
VisualizadorAudio(
key: const Key('player-visualizer'),
estadoStream: estado.estadoStream,
androidAudioSessionIdStream:
estado.audio.androidAudioSessionIdStream,
// Audit 2.5 (t4 lines 120-122): 30 discrete bars at
// 40px, gradient ending at 45% alpha (not the hero's
// 30%).
barras: 30,
color: tokens.warmCoral,
altura: 40,
barrasDiscretas: true,
gradienteFinAlpha: 0.45,
).pluriFadeIn(
context,
delay: const Duration(milliseconds: 270),
),
const SizedBox(height: 22),
_Controles(
estado: estado,
emisora: emisoraActiva,
esFavorito: esFavorito,
).pluriFadeSlideIn(
context,
delay: const Duration(milliseconds: 310),
beginY: 0.3,
),
const SizedBox(height: 20),
_BandejaHerramientas(
estado: estado,
eq: eq,
emisora: emisoraActiva,
compartir: _compartir,
).pluriFadeIn(
context,
delay: const Duration(milliseconds: 350),
),
const SizedBox(height: 12),
// Item 26 / audit 2.4 (t4:114-140): the quality row is
// LAST, after the tool tray -- it used to sit right after
// the subtitle, 4 positions too early.
_FilaCalidad(
estado: estado,
emisora: emisoraActiva,
).pluriFadeIn(
context,
delay: const Duration(milliseconds: 390),
),
],
),
const SizedBox(height: 18),
Text(
emisoraActiva.nombre,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
).pluriFadeIn(context, delay: const Duration(milliseconds: 150)),
const SizedBox(height: 6),
_SubtituloInfo(
emisora: emisoraActiva,
).pluriFadeIn(context, delay: const Duration(milliseconds: 190)),
const SizedBox(height: 12),
_FilaCalidad(
estado: estado,
emisora: emisoraActiva,
).pluriFadeIn(context, delay: const Duration(milliseconds: 230)),
const SizedBox(height: 14),
PluriGlassSurface(
key: const Key('player-visualizer'),
borderRadius: BorderRadius.circular(tokens.radiusLg),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
child: VisualizadorAudio(
estadoStream: estado.estadoStream,
androidAudioSessionIdStream:
estado.audio.androidAudioSessionIdStream,
// Audit 2.5 (t4 lines 120-122): 30 discrete bars at 40px,
// gradient ending at 45% alpha (not the hero's 30%).
barras: 30,
color: tokens.warmCoral,
altura: 40,
barrasDiscretas: true,
gradienteFinAlpha: 0.45,
),
).pluriFadeIn(context, delay: const Duration(milliseconds: 270)),
const SizedBox(height: 22),
_Controles(
estado: estado,
emisora: emisoraActiva,
esFavorito: esFavorito,
).pluriFadeSlideIn(
context,
delay: const Duration(milliseconds: 310),
beginY: 0.3,
),
const SizedBox(height: 20),
_BandejaHerramientas(
estado: estado,
eq: eq,
emisora: emisoraActiva,
compartir: _compartir,
).pluriFadeIn(context, delay: const Duration(milliseconds: 350)),
],
),
),
),
],
),
);
}
@@ -198,25 +231,32 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
/// Square art (design proposal WU14 row: "square art" replaces the old
/// circular `_WaveHero`). Loading/error overlays and the fallback icon are
/// unchanged from the prior circular version — only the clip shape and the
/// decorative halo geometry changed from circle to rounded-square.
/// unchanged from the prior circular version.
///
/// Audit 2.1 (t4 line 114): the prototype draws a FIXED 246x246 square,
/// radius 28, with only a drop shadow -- no halo, no ring. The previous
/// `width*0.62`-derived size plus a radial-gradient halo Container and a
/// bordered ring Container (neither present in the prototype) are both
/// removed; `_radio` (28) is deliberately its own constant, distinct from
/// every named token radius, mirroring this codebase's established
/// one-off-radius precedent (e.g. the ringing screen's stop button).
class _ArteReproductor extends StatelessWidget {
final Emisora emisora;
final Stream<EstadoReproduccion> estadoStream;
const _ArteReproductor({required this.emisora, required this.estadoStream});
static const double _lado = 246;
static const double _radio = 28;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final t = context.pluriTokens;
final size = MediaQuery.of(context).size.width * 0.62;
final radio = BorderRadius.circular(t.radiusLg);
final radio = BorderRadius.circular(_radio);
return StreamBuilder<EstadoReproduccion>(
stream: estadoStream,
builder: (context, snapshot) {
final reproduciendo = snapshot.data == EstadoReproduccion.reproduciendo;
// S7-R3: reconectando renders as loading, never as error.
final cargando =
snapshot.data == EstadoReproduccion.cargando ||
@@ -225,83 +265,62 @@ class _ArteReproductor extends StatelessWidget {
return SizedBox(
key: const Key('player-hero-art'),
width: size + 40,
height: size + 40,
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: size + 34,
height: size + 34,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(t.radiusLg + 16),
gradient: RadialGradient(
colors: [
t.electricMagenta.withValues(
alpha: reproduciendo ? 0.35 : 0.18,
),
t.deepViolet.withValues(alpha: 0.0),
],
),
width: _lado,
height: _lado,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: radio,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.55),
blurRadius: 60,
offset: const Offset(0, 26),
),
),
Container(
width: size + 12,
height: size + 12,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(t.radiusLg + 4),
border: Border.all(color: t.glassBorder),
),
),
PluriGlassSurface(
],
),
child: PluriGlassSurface(
borderRadius: radio,
padding: EdgeInsets.zero,
child: ClipRRect(
borderRadius: radio,
padding: EdgeInsets.zero,
child: SizedBox(
width: size,
height: size,
child: ClipRRect(
borderRadius: radio,
child: Stack(
fit: StackFit.expand,
children: [
if (emisora.favicon != null &&
emisora.favicon!.isNotEmpty)
CachedNetworkImage(
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
)
else
_iconoFallback(theme),
if (cargando)
Container(
color: Colors.black45,
child: Center(
child: CircularProgressIndicator(
color: theme.colorScheme.onSurface,
),
child: Stack(
fit: StackFit.expand,
children: [
if (emisora.favicon != null && emisora.favicon!.isNotEmpty)
CachedNetworkImage(
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
)
else
_iconoFallback(theme),
if (cargando)
Container(
color: Colors.black45,
child: Center(
child: CircularProgressIndicator(
color: theme.colorScheme.onSurface,
),
),
),
if (hayError)
Container(
color: Colors.black54,
child: Center(
child: Icon(
Icons.wifi_off_rounded,
size: 56,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.85,
),
),
if (hayError)
Container(
color: Colors.black54,
child: Center(
child: Icon(
Icons.wifi_off_rounded,
size: 56,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.85,
),
),
),
),
],
),
),
),
),
],
),
),
],
),
),
);
},
@@ -324,6 +343,63 @@ class _ArteReproductor extends StatelessWidget {
);
}
/// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the
/// CURRENT station's own art, sitting behind the whole screen. Unlike the
/// ringing screen's `_FondoArteDifuminado` (which cannot use
/// `Emisora.favicon` -- a cold-boot download after a screen-off wake would
/// hang widget tests without a mocked `HttpClient`), this screen already
/// renders `emisora.favicon` via `CachedNetworkImage` for its OWN foreground
/// art (`_ArteReproductor` above) with no such hazard -- reusing it here for
/// the backdrop is equally safe. Renders the gradient tint even without a
/// favicon, so the page keeps the same base depth in every state.
class _FondoReproductor extends StatelessWidget {
const _FondoReproductor({required this.emisora, required this.tokens});
final Emisora emisora;
final PluriWaveTokens tokens;
@override
Widget build(BuildContext context) {
final favicon = emisora.favicon;
return Positioned.fill(
key: const ValueKey('player-background-art'),
child: IgnorePointer(
child: Stack(
fit: StackFit.expand,
children: [
if (favicon != null && favicon.isNotEmpty)
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 26, sigmaY: 26),
child: Opacity(
opacity: 0.45,
child: CachedNetworkImage(
imageUrl: favicon,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
tokens.deepViolet.withValues(alpha: 0.55),
tokens.deepViolet.withValues(alpha: 0.9),
tokens.deepViolet,
],
stops: const [0, 0.46, 1],
),
),
),
],
),
),
);
}
}
/// Single subtitle line (WU14: collapses the old `_InfoChips` `Wrap` of
/// separate chips — country/language now join as one line; codec/bitrate
/// moved into their own [_FilaCalidad] row below).
@@ -752,9 +828,10 @@ class _Controles extends StatelessWidget {
);
}
return PluriGlassSurface(
borderRadius: BorderRadius.circular(28),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
// Audit 2.9 (t4 lines 124-131): the transport row sits directly on
// the background -- the prototype draws no wrapping card here.
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
@@ -799,8 +876,9 @@ class _Controles extends StatelessWidget {
),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 72,
height: 72,
// Item 26 / audit 2.6 (t4:127): 78x78, not 72x72.
width: 78,
height: 78,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.primary,
@@ -817,7 +895,7 @@ class _Controles extends StatelessWidget {
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
radius: 40,
radius: 39,
onTap:
cargando
? null
@@ -850,7 +928,8 @@ class _Controles extends StatelessWidget {
reproduciendo
? Icons.pause_rounded
: Icons.play_arrow_rounded,
size: 40,
// Item 26 / audit 2.6 (t4:127): 42px icon.
size: 42,
color: theme.colorScheme.onPrimary,
),
),
@@ -869,6 +948,13 @@ class _Controles extends StatelessWidget {
/// One tile of the 4-tile tool tray (WU14: EQ propio / Grabar / sleep timer
/// / Compartir), each opening its own bottom sheet (Compartir invokes
/// directly instead — there is no sheet content for it).
///
/// Audit 2.8 (t4 lines 133-136): only the FIRST tile ("EQ propio") is
/// accented -- tinted background/border, brand-colour icon, bold brand
/// label. The other three share a neutral OPAQUE surface (not `listSurface`
/// at 55% alpha) with a full-weight icon/label -- this app's ambient
/// default colour, not the `tokens.electricMagenta` fallback every tile
/// used to render in regardless of [accent].
class _TileHerramienta extends StatelessWidget {
const _TileHerramienta({
super.key,
@@ -876,6 +962,7 @@ class _TileHerramienta extends StatelessWidget {
required this.label,
required this.onTap,
this.iconColor,
this.accent = false,
});
final IconData icon;
@@ -883,26 +970,42 @@ class _TileHerramienta extends StatelessWidget {
final VoidCallback onTap;
final Color? iconColor;
/// See class doc — `false` (neutral) is every tile except "EQ propio".
final bool accent;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.pluriTokens;
final resolvedIconColor =
iconColor ?? (accent ? tokens.electricMagenta : null);
return Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(tokens.radiusSm),
borderRadius: BorderRadius.circular(tokens.radiusMd),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 6),
decoration: BoxDecoration(
color: tokens.listSurface.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(tokens.radiusSm),
color:
accent
? tokens.electricMagenta.withValues(alpha: 0.16)
: tokens.listSurface,
// Audit 2.8 (t4 lines 133-136): radius 18 for BOTH variants --
// `radiusMd`, not the smaller `radiusSm` every tile used before.
borderRadius: BorderRadius.circular(tokens.radiusMd),
border: Border.all(
color:
accent
? tokens.electricMagenta.withValues(alpha: 0.4)
: Colors.white.withValues(alpha: 0.09),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 23, color: iconColor ?? tokens.electricMagenta),
Icon(icon, size: 23, color: resolvedIconColor),
const SizedBox(height: 5),
Text(
label,
@@ -910,7 +1013,11 @@ class _TileHerramienta extends StatelessWidget {
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w800,
fontWeight: accent ? FontWeight.w800 : FontWeight.w700,
color:
accent
? tokens.electricMagenta
: theme.colorScheme.onSurface.withValues(alpha: 0.72),
),
),
],
@@ -947,6 +1054,7 @@ class _BandejaHerramientas extends StatelessWidget {
icon: Icons.equalizer_rounded,
label: l10n.playerToolEqLabel,
onTap: () => _mostrarHojaEq(context, eq, emisora),
accent: true,
),
),
const SizedBox(width: 10),
+20 -2
View File
@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
/// Design ADR-1: the named type scale, split out of [PluriWaveTokens] into
/// its own ThemeExtension so screens stop copy-pasting
/// `.copyWith(fontWeight: w900, letterSpacing: …)`. The set is closed at six
/// styles — a seventh requires amending ADR-1.
/// `.copyWith(fontWeight: w900, letterSpacing: …)`. The set was closed at
/// six styles; S7 (visual fidelity pass) amended ADR-1 to add a seventh —
/// [inPageSectionHeading] — rather than have four screens each keep
/// copy-pasting their own `titleMedium.copyWith(fontWeight: w900)` override,
/// the exact duplication this extension exists to eliminate.
///
/// [eyebrowLabel] never applies `toUpperCase()` — casing is a
/// locale-hostile transform (Turkish dotless i, and scripts with no case at
@@ -18,6 +21,7 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
required this.cardTitle,
required this.bodyStrong,
required this.eyebrowLabel,
required this.inPageSectionHeading,
});
/// 88 / w800 / height 1.0 / ls -2.0. Alarm ringing, inline time editor.
@@ -41,6 +45,14 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
/// headers.
final TextStyle eyebrowLabel;
/// 15 / w800 / ls -0.2. S7 (visual fidelity, t4 lines 80, 153, 164, 173):
/// an in-page section heading living directly on a screen's content —
/// "Tus emisoras", "Cerca de ti", "Explorar por", "Populares ahora".
/// Distinct from both [sectionTitle] (23, a ROOT screen's own title) and
/// [screenTitle] (19, a pushed screen's AppBar title) — this is smaller
/// than either, for a heading living one level below the screen itself.
final TextStyle inPageSectionHeading;
/// Builds the scale from [family] — the SAME resolved Google Fonts
/// TextTheme instance [PluriWaveTheme.dark] already computes. Building the
/// styles anywhere else would re-merge the font family, which is the
@@ -79,6 +91,7 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
letterSpacing: 0.8,
color: const Color(0xFFF2F7FA).withValues(alpha: 0.42),
),
inPageSectionHeading: style(15, FontWeight.w800, letterSpacing: -0.2),
);
}
@@ -90,6 +103,7 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
TextStyle? cardTitle,
TextStyle? bodyStrong,
TextStyle? eyebrowLabel,
TextStyle? inPageSectionHeading,
}) {
return PluriWaveTypography(
heroTime: heroTime ?? this.heroTime,
@@ -98,6 +112,7 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
cardTitle: cardTitle ?? this.cardTitle,
bodyStrong: bodyStrong ?? this.bodyStrong,
eyebrowLabel: eyebrowLabel ?? this.eyebrowLabel,
inPageSectionHeading: inPageSectionHeading ?? this.inPageSectionHeading,
);
}
@@ -117,6 +132,9 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
bodyStrong: TextStyle.lerp(bodyStrong, other.bodyStrong, t) ?? bodyStrong,
eyebrowLabel:
TextStyle.lerp(eyebrowLabel, other.eyebrowLabel, t) ?? eyebrowLabel,
inPageSectionHeading:
TextStyle.lerp(inPageSectionHeading, other.inPageSectionHeading, t) ??
inPageSectionHeading,
);
}
}
+257
View File
@@ -0,0 +1,257 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart' as shimmer;
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../tema/pluriwave_tokens.dart';
/// Item 23 / audit 4.3 + 6.5 (t4:226-232, 302-306): a flat, background-less
/// station row — square thumbnail, name, meta line, and a caller-supplied
/// set of trailing actions. Shared by Favoritos and search results, which
/// the prototype draws identically apart from their trailing actions.
/// Grabaciones (12.4) has its own row: a different domain model
/// (recordings, not stations) with different trailing actions.
class FilaEmisoraPlana extends StatelessWidget {
const FilaEmisoraPlana({
super.key,
required this.emisora,
required this.meta,
this.leading,
this.trailing = const [],
this.onTap,
});
final Emisora emisora;
/// Pre-joined meta line (e.g. "genre - country - kbps"). Omitted from
/// layout entirely when empty, rather than reserving blank space.
final String meta;
/// e.g. a drag handle (Favoritos only — search results have none).
final Widget? leading;
/// e.g. a favourite toggle, a circular play button, an overflow menu.
final List<Widget> trailing;
final VoidCallback? onTap;
/// t4:226, t4:302: 48x48, radius 12.
static const double lado = 48;
static const double radio = 12;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final stationName = localizedStationName(l10n, emisora.nombre);
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 4)],
ClipRRect(
key: const ValueKey('fila-emisora-plana-arte'),
borderRadius: BorderRadius.circular(radio),
child: SizedBox(
width: lado,
height: lado,
child: _ArteFilaEmisora(emisora: emisora),
),
),
const SizedBox(width: 12),
Expanded(
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// t4:229, t4:303: 15px/w700/lh1.25.
Text(
stationName,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
height: 1.25,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (meta.isNotEmpty) ...[
const SizedBox(height: 2),
// t4:229, t4:303: 12px/rgba(242,247,250,.58).
Text(
meta,
style: TextStyle(
fontSize: 12,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.58),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
),
),
...trailing,
],
),
);
}
}
/// t4:230, t4:305: a 44x44 circular affordance, `rgba(33,212,217,.14)`
/// background, `play_arrow` at 22px brand teal.
class BotonReproducirCircular extends StatelessWidget {
const BotonReproducirCircular({
super.key,
required this.onPressed,
this.tooltip,
});
final VoidCallback? onPressed;
final String? tooltip;
@override
Widget build(BuildContext context) {
return Material(
key: const ValueKey('boton-reproducir-circular'),
color: PluriWaveTokens.brand.withValues(alpha: 0.14),
shape: const CircleBorder(),
child: IconButton(
tooltip: tooltip,
icon: const Icon(
Icons.play_arrow_rounded,
size: 22,
color: PluriWaveTokens.brand,
),
onPressed: onPressed,
constraints: const BoxConstraints.tightFor(width: 44, height: 44),
),
);
}
}
/// t4:304: a 44x44 favourite toggle, 22px icon at 55% opacity. Mirrors
/// `TarjetaEmisora`'s own toggle+snackbar behaviour (duplicated rather than
/// extracted from it — `tarjeta_emisora.dart`'s exact widget tree is pinned
/// by several pre-existing tests this change must not disturb).
class BotonFavoritoEmisora extends StatefulWidget {
const BotonFavoritoEmisora({super.key, required this.emisora});
final Emisora emisora;
@override
State<BotonFavoritoEmisora> createState() => _BotonFavoritoEmisoraState();
}
class _BotonFavoritoEmisoraState extends State<BotonFavoritoEmisora> {
bool _toggling = false;
Future<void> _toggle() async {
if (_toggling) return;
setState(() => _toggling = true);
final estado = context.read<EstadoRadio>();
final esFav = await estado.toggleFavorito(widget.emisora);
if (mounted) setState(() => _toggling = false);
if (mounted) {
final l10n = AppLocalizations.of(context);
final stationName = localizedStationName(l10n, widget.emisora.nombre);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
esFav
? l10n.favoritesAddedMessage(stationName)
: l10n.favoritesRemovedMessage(stationName),
),
duration: const Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final esFavorito = context.select<EstadoRadio, bool>(
(estado) =>
estado.listaFavoritos.any((e) => e.uuid == widget.emisora.uuid),
);
return Semantics(
container: true,
button: true,
toggled: esFavorito,
label:
esFavorito ? l10n.favoritesRemoveTooltip : l10n.favoritesAddTooltip,
child: Material(
key: const ValueKey('boton-favorito-emisora'),
color: Colors.transparent,
child: InkWell(
customBorder: const CircleBorder(),
onTap: _toggling ? null : _toggle,
child: SizedBox(
width: 44,
height: 44,
child: Icon(
esFavorito
? Icons.favorite_rounded
: Icons.favorite_outline_rounded,
size: 22,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.55),
),
),
),
),
);
}
}
class _ArteFilaEmisora extends StatelessWidget {
const _ArteFilaEmisora({required this.emisora});
final Emisora emisora;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (emisora.favicon != null && emisora.favicon!.isNotEmpty) {
return CachedNetworkImage(
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
);
}
return _iconoFallback(theme);
}
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 22,
color: theme.colorScheme.onPrimaryContainer,
),
);
}
@@ -8,6 +8,24 @@ import 'package:pluriwave/tema/pluriwave_theme.dart';
/// lines 512-539, 625 — "Voz clara", "Alta", "3 guardados", "Alfabético",
/// "7 · 84 MB", "Español", "Hoy, 08:12", "200 MB"). `FilaAjuste` used to
/// accept only `icon`/`titulo`/`onTap` — no value slot at all.
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
/// `PluriGlassSurface` paints a background over `ListTile`'s ink layer,
/// which Flutter flags as a warning-level assertion, not a correctness bug.
/// Only needed by the `GrupoAjustes` test below — the bare `FilaAjuste`
/// tests above never wrap a `ListTile` inside that surface.
void _suppressListTileInkAssertion() {
final original = FlutterError.onError;
FlutterError.onError = (details) {
if (details.exceptionAsString().contains(
'ListTile background color or ink splashes may be invisible',
)) {
return;
}
original?.call(details);
};
addTearDown(() => FlutterError.onError = original);
}
void main() {
Widget host(Widget child) {
return MaterialApp(
@@ -49,4 +67,59 @@ void main() {
expect(find.text('Info'), findsOneWidget);
expect(find.byIcon(Icons.chevron_right_rounded), findsOneWidget);
});
testWidgets(
'visual fidelity (audit 10.6/10.8/10.9): leading icon is 21px, the '
'title is 14px, and the chevron is 19px at 40% opacity (t4 lines '
'514-515)',
(tester) async {
await tester.pumpWidget(
host(
FilaAjuste(
icon: Icons.equalizer_rounded,
titulo: 'Ecualizador base',
onTap: () {},
),
),
);
final icon = tester.widget<Icon>(find.byIcon(Icons.equalizer_rounded));
expect(icon.size, 21);
final titulo = tester.widget<Text>(find.text('Ecualizador base'));
expect(titulo.style?.fontSize, 14);
final chevron = tester.widget<Icon>(
find.byIcon(Icons.chevron_right_rounded),
);
expect(chevron.size, 19);
expect(chevron.color, const Color(0xFFF2F7FA).withValues(alpha: 0.4));
},
);
testWidgets(
'visual fidelity (audit S10): GrupoAjustes insets its row divider by '
'47px, not full-bleed (t4 line 516)',
(tester) async {
_suppressListTileInkAssertion();
await tester.pumpWidget(
host(
GrupoAjustes(
titulo: 'AUDIO',
filas: [
FilaAjuste(
icon: Icons.equalizer_rounded,
titulo: 'Uno',
onTap: () {},
),
FilaAjuste(icon: Icons.hd_rounded, titulo: 'Dos', onTap: () {}),
],
),
),
);
final divider = tester.widget<Divider>(find.byType(Divider));
expect(divider.indent, 47);
},
);
}
@@ -146,7 +146,8 @@ void main() {
group('WU11 — restyle a pantalla completa', () {
testWidgets(
'el tiempo gigante usa PluriWaveTypography.heroTime envuelto en '
'el tiempo gigante usa PluriWaveTypography.heroTime (con un ajuste '
'local de letter-spacing/height, audit 9.5) envuelto en '
'FittedBox(scaleDown)',
(tester) async {
await _montarPantalla(tester);
@@ -155,7 +156,22 @@ void main() {
expect(heroFinder, findsOneWidget);
final texto = tester.widget<Text>(heroFinder);
final contexto = tester.element(heroFinder);
expect(texto.style, contexto.pluriType.heroTime);
final base = contexto.pluriType.heroTime;
// Audit 9.5 (t4 line 417): the prototype wants 88px/w800/ls-4/lh.95
// on THIS screen only. heroTime itself must stay ls-2.0/height 1.0
// — EditorHoraInline (the alarm editor's hour block) is the other
// consumer and wants height:1 (t4 line 379) — so this is a LOCAL
// copyWith override, not a change to the shared token.
expect(texto.style?.fontSize, base.fontSize);
expect(texto.style?.fontWeight, base.fontWeight);
expect(texto.style?.letterSpacing, -4);
expect(texto.style?.height, 0.95);
expect(
base.letterSpacing,
-2.0,
reason: 'the shared token is unchanged',
);
expect(base.height, 1.0, reason: 'the shared token is unchanged');
expect(
find.ancestor(of: heroFinder, matching: find.byType(FittedBox)),
findsOneWidget,
@@ -0,0 +1,189 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Tier 4 visual fidelity, ringing screen: 9.1 (pulsing amber halo), 9.6
/// (art geometry), 9.8 (snooze eyebrow icon). Item 9.5 (hero time metrics)
/// is covered in `pantalla_alarma_sonando_scaffold_test.dart`, which already
/// owned the pre-existing `heroTime`-equality assertion that item revises.
///
/// 9.1 is the one with real hazard: the prototype's halo is a literal CSS
/// `animation: pw-pulse 2.4s ease-in-out infinite`. This screen's dismiss
/// guard (`pantalla_alarma_sonando_dismiss_guard_test.dart`, protected,
/// byte-identical to `main`) calls `pumpAndSettle()` after every mount and
/// every interaction — an actually-infinite `AnimationController.repeat()`
/// anywhere in this widget's subtree would hang every one of those calls
/// forever, with no way to fix it since that file cannot be touched. The
/// halo below is a BOUNDED pulse (one grow/settle cycle, no `.repeat()`) —
/// genuine animated motion, but one that always finishes.
Future<void> _montarPantalla(
WidgetTester tester, {
bool disableAnimations = false,
}) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final audio = FakeServicioAudio();
audio.emitirEstado(EstadoReproduccion.reproduciendo);
final radio = EstadoRadio(
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
addTearDown(radio.dispose);
final android = FakePuertoAlarmasAndroid();
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 7, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'tier4-1',
nombre: 'Despertar',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
emisora: Emisora(
uuid: 'e1',
nombre: 'Radio Uno',
url: 'https://radio.example/stream',
),
),
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: PluriWaveTheme.dark(),
builder:
(context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: disableAnimations),
child: child!,
),
home: const SizedBox.shrink(),
),
),
);
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
unawaited(
navigator.push(
MaterialPageRoute<void>(
builder:
(_) => PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single),
fullscreenDialog: true,
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets(
'visual fidelity (audit 9.1): a pulsing amber halo renders behind the '
'hero content, and settling it NEVER hangs (protects the dismiss-guard '
"test's repeated pumpAndSettle calls)",
(tester) async {
await _montarPantalla(tester);
expect(find.byKey(const ValueKey('ringing-pulse-halo')), findsOneWidget);
// The critical regression guard: this must COMPLETE. An infinite
// AnimationController.repeat() here would hang every pumpAndSettle()
// call in the protected dismiss-guard test forever.
await tester.pumpAndSettle();
},
);
testWidgets(
'visual fidelity (audit 9.1): the halo skips its Animate wrapper under '
'reduced motion (matches every other entry animation in this app)',
(tester) async {
await _montarPantalla(tester, disableAnimations: true);
expect(find.byType(Animate), findsNothing);
expect(find.byKey(const ValueKey('ringing-pulse-halo')), findsOneWidget);
},
);
testWidgets(
'visual fidelity (audit 9.6): the ringing art is 180 square with a 36 '
'corner radius, not 168/30',
(tester) async {
await _montarPantalla(tester);
final art = tester
.widgetList<Image>(find.byType(Image))
.where((img) => img.width == 180 && img.height == 180);
expect(
art,
isNotEmpty,
reason: 'prototype t4 line 421 sizes the ringing-screen art at 180',
);
final clip = tester.widget<ClipRRect>(
find.ancestor(
of: find.byWidgetPredicate(
(w) => w is Image && w.width == 180 && w.height == 180,
),
matching: find.byType(ClipRRect),
),
);
expect(
(clip.borderRadius as BorderRadius).topLeft,
const Radius.circular(36),
);
},
);
testWidgets(
'visual fidelity (audit 9.8): the POSPONER eyebrow keeps its snooze '
'icon at 19px warmCoral',
(tester) async {
await _montarPantalla(tester);
final icon = tester.widget<Icon>(find.byIcon(Icons.snooze_rounded));
expect(icon.size, 19);
expect(icon.color, const Color(0xFFF4B860));
},
);
}
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Audit 7.5 (Tier 4 visual fidelity): the prototype's gap between stacked
/// alarm cards is 10px (t4 line 334, `gap:10` on the alarms column), not the
/// build's previous 12px.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('spaces stacked alarm cards 10px apart, not 12', (tester) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final radio = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
addTearDown(radio.dispose);
final android = FakePuertoAlarmasAndroid();
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'card-gap-a',
nombre: 'Primera',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'card-gap-b',
nombre: 'Segunda',
hora: 8,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: PantallaAlarmas()),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
final gap = tester.widget<SizedBox>(
find.byKey(const ValueKey('alarm-card-gap-card-gap-a')),
);
expect(gap.height, 10);
});
}
@@ -65,6 +65,74 @@ void main() {
},
);
testWidgets(
'visual fidelity (audit 14.4/14.5): body copy is 14.5px, bullet titles '
'are 13.5px/w800, bullet subtitles are 11.5px (t4 lines 697/700-702)',
(tester) async {
final navegacion = EstadoNavegacionRaiz();
addTearDown(navegacion.dispose);
await tester.pumpWidget(_app(navegacion: navegacion));
await tester.pumpAndSettle();
final l10n = lookupAppLocalizations(const Locale('es'));
final body = tester.widget<Text>(
find.textContaining('53.412 emisoras de 238 países'),
);
expect(body.style?.fontSize, 14.5);
final tituloBullet = tester.widget<Text>(
find.text(l10n.welcomeBullet1Title),
);
expect(tituloBullet.style?.fontSize, 13.5);
expect(tituloBullet.style?.fontWeight, FontWeight.w800);
final subtituloBullet = tester.widget<Text>(
find.text(l10n.welcomeBullet1Subtitle),
);
expect(subtituloBullet.style?.fontSize, 11.5);
},
);
testWidgets(
'visual fidelity (audit 14.6): the 3 feature bullets are spaced 12px '
'apart, not 16 (t4 line 701)',
(tester) async {
final navegacion = EstadoNavegacionRaiz();
addTearDown(navegacion.dispose);
await tester.pumpWidget(_app(navegacion: navegacion));
await tester.pumpAndSettle();
final bullets = find.byType(FilaCaracteristicaBienvenida);
final gap =
tester.getTopLeft(bullets.at(1)).dy -
tester.getBottomLeft(bullets.at(0)).dy;
expect(gap, 12);
},
);
testWidgets(
'visual fidelity (audit 14.8): the logo mark is clipped to a 20px '
'rounded rect, not painted square (t4 line 693)',
(tester) async {
final navegacion = EstadoNavegacionRaiz();
addTearDown(navegacion.dispose);
await tester.pumpWidget(_app(navegacion: navegacion));
await tester.pumpAndSettle();
final clip = tester.widget<ClipRRect>(
find.ancestor(
of: find.byKey(const ValueKey('welcome-logo')),
matching: find.byType(ClipRRect),
),
);
expect(clip.borderRadius, BorderRadius.circular(20));
},
);
testWidgets('rendered content contains no monetization strings', (
tester,
) async {
+214
View File
@@ -7,11 +7,14 @@ import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/pais_radio.dart';
import 'package:pluriwave/pantallas/pantalla_buscar.dart';
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
import 'package:pluriwave/pantallas/pantalla_paises.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -541,6 +544,46 @@ void main() {
expect(find.byType(PantallaPaises), findsOneWidget);
});
testWidgets(
'Item 24: tapping a country row in Paises pops back and filters '
'Buscar by that ISO code',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
paises: const [
PaisRadio(
nombre: 'Kazakhstan',
codigoIso: 'KZ',
numeroEmisoras: 12,
),
],
busqueda: [emisoraDemo(uuid: 'kz-1', nombre: 'Radio Kazajstan')],
),
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
await tester.tap(find.byKey(const Key('explore-cell-paises')));
await _pumpStableFrame(tester);
expect(find.byType(PantallaPaises), findsOneWidget);
await tester.tap(find.text('Kazakhstan'));
await _pumpStableFrame(tester);
expect(
find.byType(PantallaPaises),
findsNothing,
reason: 'selecting a country pops back to Buscar',
);
expect(find.text('Radio Kazajstan'), findsOneWidget);
expect(estado.busqueda.resultados.map((e) => e.uuid), contains('kz-1'));
},
);
testWidgets(
'tapping Generos opens a picker sheet; selecting a genre closes it '
'and filters the discovery grid (same capability, relocated)',
@@ -621,6 +664,177 @@ void main() {
},
);
});
// Item 23 / audit 6.5 + 6.6 (t4:302-306): flat, background-less search-
// result rows with a square thumbnail, a favourite toggle, and a circular
// play affordance -- replacing the full glass TarjetaEmisora card. Rows
// sit back-to-back (no 10px separator), matching t4:299's bare column.
group('Item 23 -- flat search-result rows (audit 6.5, 6.6)', () {
testWidgets(
'each result is a flat FilaEmisoraPlana with a "genre - country - '
'kbps" meta line, not the full glass card',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
busqueda: [
emisoraDemo(
uuid: 'r-1',
nombre: 'Radio Uno',
).copyWith(tags: 'Rock', pais: 'Spain', bitrate: 128),
],
),
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
await tester.enterText(find.byType(SearchBar), 'radio');
await tester.testTextInput.receiveAction(TextInputAction.done);
await _pumpStableFrame(tester);
expect(find.byType(FilaEmisoraPlana), findsOneWidget);
expect(
find.byType(TarjetaEmisora),
findsNothing,
reason: 'audit 6.5 replaces the full glass card with a flat row',
);
expect(find.text('Rock · Spain · 128 kbps'), findsOneWidget);
expect(find.byType(BotonFavoritoEmisora), findsOneWidget);
expect(find.byType(BotonReproducirCircular), findsOneWidget);
},
);
testWidgets('rows sit back-to-back, with no 10px separator (t4:299)', (
tester,
) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
busqueda: [
emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno'),
emisoraDemo(uuid: 'r-2', nombre: 'Radio Dos'),
],
),
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
await tester.enterText(find.byType(SearchBar), 'radio');
await tester.testTextInput.receiveAction(TextInputAction.done);
await _pumpStableFrame(tester);
final filas = find.byType(FilaEmisoraPlana);
expect(filas, findsNWidgets(2));
final primeraAbajo = tester.getBottomLeft(filas.at(0)).dy;
final segundaArriba = tester.getTopLeft(filas.at(1)).dy;
expect(
segundaArriba - primeraAbajo,
closeTo(0, 0.5),
reason:
't4:299 rows have no separator; each keeps only its own '
'8px padding',
);
});
testWidgets(
'tapping the favourite toggle on a search result adds it to favorites',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
),
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
await tester.enterText(find.byType(SearchBar), 'radio');
await tester.testTextInput.receiveAction(TextInputAction.done);
await _pumpStableFrame(tester);
expect(find.byIcon(Icons.favorite_outline_rounded), findsOneWidget);
await tester.tap(find.byIcon(Icons.favorite_outline_rounded));
await _pumpStableFrame(tester);
expect(await estado.esFavorito('r-1'), isTrue);
},
);
});
// Item 25 / audit 13.2 (t4:643-646): a reconnect card with a rotating
// ring, the station name, and a stop affordance -- previously the ONLY
// signal of a reconnect was a word in the mini player.
//
// Hazard: `pumpAndSettle()` never terminates while this card's rotating
// ring animates -- every assertion below uses a bounded `pump()` once
// `reconectando` is emitted, never `_pumpStableFrame`'s `pumpAndSettle`.
group('Item 25 -- reconnect card (audit 13.2)', () {
testWidgets(
'shows the station name, "Reconectando...", and a stop button while '
'reconnecting',
(tester) async {
_setLargeSurfaceSize(tester);
final audio = FakeServicioAudio();
final estado = _crearEstado(audio: audio);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
expect(
find.byIcon(Icons.stop_circle_rounded),
findsNothing,
reason: 'not reconnecting yet -- the card must not show',
);
audio.emitirEstado(EstadoReproduccion.reconectando);
await tester.pump();
await tester.pump();
expect(find.text('Radio Uno'), findsOneWidget);
final l10n = _l10nDe(tester);
expect(find.text(l10n.playbackStatusReconnecting), findsOneWidget);
expect(find.byIcon(Icons.stop_circle_rounded), findsOneWidget);
},
);
testWidgets('tapping stop calls EstadoRadio.detenerReproduccion', (
tester,
) async {
_setLargeSurfaceSize(tester);
final audio = FakeServicioAudio();
final estado = _crearEstado(audio: audio);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
audio.emitirEstado(EstadoReproduccion.reconectando);
await tester.pump();
await tester.pump();
await tester.tap(find.byIcon(Icons.stop_circle_rounded));
await tester.pump();
await tester.pump();
// `EstadoRadio.emisoraActual` deliberately keeps showing the last
// selected station even once stopped (an existing, unrelated
// property of `_emisoraSeleccionada`'s tracking) -- the real,
// user-visible effect of tapping stop is that this card disappears,
// since it only renders while `reconectando`.
expect(find.byKey(const ValueKey('tarjeta-reconectando')), findsNothing);
});
});
}
EstadoRadio _crearEstado({
+113
View File
@@ -6,7 +6,9 @@ import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart';
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -100,6 +102,33 @@ void main() {
await tester.pump(const Duration(milliseconds: 100));
}
group('visual fidelity (audit 4.5): dashed custom-station CTA', () {
testWidgets(
'padding is 14 all around, the icon is a plain add glyph at 20px, '
'and the label is 13.5px/w800 (t4 line 235)',
(tester) async {
final estado = await crearEstadoVacio();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
final padding = tester.widget<Padding>(
find.byKey(const Key('custom-station-cta-padding')),
);
expect(padding.padding, const EdgeInsets.all(14));
expect(find.byIcon(Icons.add_circle_outline_rounded), findsNothing);
final icon = tester.widget<Icon>(find.byIcon(Icons.add_rounded));
expect(icon.size, 20);
final l10n = lookupAppLocalizations(const Locale('en'));
final texto = tester.widget<Text>(find.text(l10n.customStationsAddCta));
expect(texto.style?.fontSize, 13.5);
expect(texto.style?.fontWeight, FontWeight.w800);
},
);
});
// ── EstadoRadio: new state surface (state-layer, not widget-layer) ───────
test('listaFavoritosManual returns favorites in stored order, NOT re-sorted '
@@ -381,6 +410,90 @@ void main() {
expect(find.widgetWithText(TextFormField, 'Name *'), findsOneWidget);
});
// Item 23 / audit 4.3 (t4:226-232): flat, background-less rows with a
// square thumbnail and a circular play affordance, replacing the full
// glass TarjetaEmisora card + two stacked filledTonal buttons.
group('Item 23 -- flat rows (audit 4.3)', () {
testWidgets(
'each row is a flat FilaEmisoraPlana, not the full glass card',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstadoConFavoritos();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
expect(find.byType(FilaEmisoraPlana), findsNWidgets(3));
expect(
find.byType(TarjetaEmisora),
findsNothing,
reason: 'audit 4.3 replaces the full glass card with a flat row',
);
expect(
find.byIcon(Icons.drag_indicator_rounded),
findsNWidgets(3),
reason: 't4:227 drag_indicator, not drag_handle',
);
expect(find.byIcon(Icons.drag_handle_rounded), findsNothing);
expect(
find.byType(BotonReproducirCircular),
findsNWidgets(3),
reason: 't4:230 a circular play affordance per row',
);
},
);
testWidgets(
'the overflow menu offers exactly "Move to list" and "Remove from '
'favorites"',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstadoConFavoritos();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
await tester.tap(find.byIcon(Icons.more_vert_rounded).first);
await tester.pumpAndSettle();
expect(find.text('Move to list'), findsOneWidget);
expect(find.text('Remove from favorites'), findsOneWidget);
expect(find.byType(PopupMenuItem<String>), findsNWidgets(2));
},
);
testWidgets(
'"Remove from favorites" still calls the same removal path as before',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstadoConFavoritos();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
// Invoke the callback directly rather than opening the overlay and
// tapping its rendered item by screen position: leaving that modal
// route open while the tap it triggers removes and unmounts the
// very row that anchors it is a real hang hazard, confirmed while
// developing this test (multi-minute stall, same class of risk as
// the project's other documented `pumpAndSettle` traps).
final boton = tester.widget<PopupMenuButton<String>>(
find.byType(PopupMenuButton<String>).first,
);
boton.onSelected!('remove');
await pumpStable(tester);
expect(find.text('Station A'), findsNothing);
},
);
});
}
void setLargeSurface(WidgetTester tester) {
+49 -1
View File
@@ -8,6 +8,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/archivo_grabacion.dart';
import 'package:pluriwave/pantallas/pantalla_grabaciones.dart';
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -137,7 +138,9 @@ void main() {
);
await pumpStable(tester);
await tester.tap(find.byIcon(Icons.settings_outlined));
// Audit 12.1 (t4:610): the header action icon is `folder_open`, not
// a generic gear.
await tester.tap(find.byIcon(Icons.folder_open_rounded));
await pumpStable(tester);
expect(find.byType(PluriPushScaffold), findsNWidgets(2));
@@ -194,6 +197,51 @@ void main() {
expect(find.byIcon(Icons.play_circle_fill_rounded), findsNWidgets(3));
});
// Item 23 / audit 12.4 (t4:616-619): flat, background-less rows with a
// 44x44/radius-12 thumbnail placeholder -- replacing the PluriGlassSurface
// + ListTile card, which had no artwork slot at all.
testWidgets(
'rows are flat -- no ListTile, no per-row PluriGlassSurface -- with a '
'44x44/radius-12 thumbnail placeholder',
(tester) async {
final estado = EstadoGrabacion(
servicio: _FakeServicioGrabacionConArchivos([
fijaA,
fijaB,
], maxBytesFijo: 200 * 1024 * 1024),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
),
);
await pumpStable(tester);
expect(
find.byType(ListTile),
findsNothing,
reason: 'audit 12.4 replaces the ListTile row with a flat Row',
);
expect(
find.byType(PluriGlassSurface),
findsOneWidget,
reason: 'only the storage card keeps a surface -- rows do not',
);
final miniaturas = find.byKey(const ValueKey('fila-grabacion-arte'));
expect(miniaturas, findsNWidgets(2));
expect(tester.getSize(miniaturas.first), const Size(44, 44));
final clip = tester.widget<ClipRRect>(miniaturas.first);
expect(
(clip.borderRadius as BorderRadius).topLeft,
const Radius.circular(12),
);
},
);
testWidgets('15.2-B: empty folder renders an empty state, not an error', (
tester,
) async {
+364
View File
@@ -10,6 +10,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/pantallas/pantalla_inicio.dart';
import 'package:pluriwave/widgets/pluri_root_header.dart';
import 'package:pluriwave/widgets/visualizador_audio.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -334,6 +335,369 @@ void main() {
expect(find.byKey(const ValueKey('escuchar-hero-meta')), findsNothing);
},
);
testWidgets('visual fidelity (audit 1.11): a count badge sits next to "Tus '
'emisoras", showing the TOTAL favorite count, not the 8-capped grid '
'size', (tester) async {
_setLargeSurfaceSize(tester);
final favoritos = FakeServicioFavoritos();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
for (var i = 0; i < 11; i++) {
await favoritos.agregar(
emisoraDemo(uuid: 'badge-$i', nombre: 'Badge Estacion $i'),
);
}
await estado.cargarFavoritos();
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
expect(
find.text('11'),
findsOneWidget,
reason:
'prototype t4 line 80: the badge reflects how many favorites '
'exist, even though the grid below caps display at 8',
);
});
testWidgets(
'visual fidelity (audit 1.12): "Ver todas" renders as plain brand-teal '
'text, not a Material TextButton',
(tester) async {
_setLargeSurfaceSize(tester);
final favoritos = FakeServicioFavoritos();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await favoritos.agregar(emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno'));
await estado.cargarFavoritos();
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
expect(find.byType(TextButton), findsNothing);
final texto = tester.widget<Text>(find.text('Ver todas'));
expect(texto.style?.fontSize, 12);
expect(texto.style?.fontWeight, FontWeight.w800);
expect(texto.style?.color, const Color(0xFF21D4D9));
},
);
testWidgets(
'S7 visual fidelity: "Tus emisoras" uses the in-page section heading '
'style (15/w800/ls-.2), not titleMedium(16)/w900',
(tester) async {
_setLargeSurfaceSize(tester);
final favoritos = FakeServicioFavoritos();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await favoritos.agregar(emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno'));
await estado.cargarFavoritos();
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
final texto = tester.widget<Text>(find.text('Tus emisoras'));
expect(texto.style?.fontSize, 15, reason: 'prototype t4 line 80');
expect(texto.style?.fontWeight, FontWeight.w800);
expect(texto.style?.letterSpacing, -0.2);
},
);
testWidgets(
'visual fidelity (audit 1.1): a full-bleed art/gradient/noise wash '
'sits behind the whole screen, keyed so it can be found',
(tester) async {
_setLargeSurfaceSize(tester);
final favoritos = FakeServicioFavoritos();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
final sonando = emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno');
await favoritos.agregar(sonando);
await estado.cargarFavoritos();
await tester.runAsync(() => estado.reproducir(sonando));
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpBounded(tester);
expect(
find.byKey(const ValueKey('escuchar-background-art')),
findsOneWidget,
reason:
'prototype t4 lines 49-51: full-bleed art at .5 opacity + a '
'gradient + a noise texture',
);
},
);
testWidgets(
'visual fidelity (audit 1.1): the background wash still renders with '
'nothing playing (gradient/noise only, no station art to show)',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
expect(
find.byKey(const ValueKey('escuchar-background-art')),
findsOneWidget,
);
},
);
testWidgets('visual fidelity (audit 1.2): Escuchar has its own 52px "Now '
'listening" eyebrow header, not the shared 56px PluriRootHeader', (
tester,
) async {
_setLargeSurfaceSize(tester);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
expect(find.byType(PluriRootHeader), findsNothing);
expect(
find.text('Escuchando ahora'),
findsOneWidget,
reason: 'prototype t4 line 53: "ESCUCHANDO AHORA"',
);
expect(find.byIcon(Icons.bedtime_outlined), findsOneWidget);
final cabeceras = tester
.widgetList<SizedBox>(find.byType(SizedBox))
.where((s) => s.height == 52);
expect(cabeceras, isNotEmpty, reason: 'prototype t4 line 53: height:52px');
});
testWidgets(
"visual fidelity (audit 1.2): the header's bedtime icon still opens "
'the shared sleep-timer sheet',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
await tester.tap(find.byIcon(Icons.bedtime_outlined));
await _pumpStableFrame(tester);
expect(find.byType(ActionChip), findsWidgets);
},
);
testWidgets('visual fidelity (audit 1.5): the station name is 24/w800/ls-.5/'
'height 1.12, not titleLarge(22) with no letter-spacing/height', (
tester,
) async {
_setLargeSurfaceSize(tester);
final favoritos = FakeServicioFavoritos();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
// Deliberately NOT added to favoritos: the "Tus emisoras" grid also
// renders a station's bare name, and this assertion targets the
// hero's OWN station-name Text specifically -- a favorited station
// with the same name would render twice and make `find.text` throw.
final sonando = emisoraDemo(uuid: 'f1', nombre: 'Radio Horizonte');
await tester.runAsync(() => estado.reproducir(sonando));
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpBounded(tester);
final texto = tester.widget<Text>(find.text('Radio Horizonte'));
expect(texto.style?.fontSize, 24, reason: 'prototype t4 line 60');
expect(texto.style?.fontWeight, FontWeight.w800);
expect(texto.style?.letterSpacing, -0.5);
expect(texto.style?.height, 1.12);
});
testWidgets(
'visual fidelity (audit 1.8): the hero play button is 72x72 with a '
'38px icon, not 56x56 with a 28px icon',
(tester) async {
_setLargeSurfaceSize(tester);
final favoritos = FakeServicioFavoritos();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
final sonando = emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno');
await favoritos.agregar(sonando);
await estado.cargarFavoritos();
await tester.runAsync(() => estado.reproducir(sonando));
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpBounded(tester);
final botones = tester
.widgetList<SizedBox>(find.byType(SizedBox))
.where((s) => s.width == 72 && s.height == 72);
expect(botones, isNotEmpty, reason: 'prototype t4 line 73');
final iconos = tester
.widgetList<Icon>(find.byType(Icon))
.where(
(i) =>
i.icon == Icons.play_arrow_rounded ||
i.icon == Icons.pause_rounded,
);
expect(iconos, isNotEmpty);
expect(iconos.first.size, 38);
},
);
testWidgets(
'visual fidelity (audit 1.9): the EQ/record/quality pill is centred, '
'pill-shaped and uses expand_less at 17px -- not a left-aligned '
'OutlinedButton with tune_rounded',
(tester) async {
_setLargeSurfaceSize(tester);
final favoritos = FakeServicioFavoritos();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
final sonando = emisoraDemo(uuid: 'f1', nombre: 'Favorita Uno');
await favoritos.agregar(sonando);
await estado.cargarFavoritos();
await tester.runAsync(() => estado.reproducir(sonando));
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpBounded(tester);
expect(find.byType(OutlinedButton), findsNothing);
expect(
find.byIcon(Icons.expand_less_rounded),
findsOneWidget,
reason: 'prototype t4 line 78: expand_less at 17px',
);
final icono = tester.widget<Icon>(find.byIcon(Icons.expand_less_rounded));
expect(icono.size, 17);
final pildoras = tester
.widgetList<DecoratedBox>(find.byType(DecoratedBox))
.map((d) => d.decoration)
.whereType<BoxDecoration>()
.where((d) => d.borderRadius == BorderRadius.circular(999));
expect(
pildoras,
isNotEmpty,
reason: 'prototype t4 line 78: border-radius:999px pill',
);
},
);
}
/// Mirrors the app.dart wiring: EstadoRadio owns the domain notifiers and
+124 -4
View File
@@ -66,9 +66,12 @@ void main() {
// Full alphabetical list: all 3 fetched countries render.
expect(find.text('Argentina'), findsOneWidget);
expect(find.text('France'), findsOneWidget);
// 'Spain' renders twice: "Tus idiomas" (locale es -> representative
// country ES) AND the full list below.
// 'France' and 'Spain' each render twice: once in "Tus idiomas" (FR
// and ES both back a supported locale) and once in the full list
// below. Item 24's row now shows the name as its OWN Text (not
// concatenated with the count into one Chip label), so both are
// independently findable in each section.
expect(find.text('France'), findsWidgets);
expect(find.text('Spain'), findsWidgets);
// Each entry's parsed `stationcount` renders via the existing
@@ -77,10 +80,127 @@ void main() {
// as a plain number here, never throwing a cast error.
expect(find.text(l10n.stationsCount(482)), findsWidgets);
expect(find.text(l10n.stationsCount(120)), findsOneWidget);
expect(find.text(l10n.stationsCount(75)), findsOneWidget);
// Also doubled, for the same reason as 'France' above.
expect(find.text(l10n.stationsCount(75)), findsWidgets);
},
);
// Item 24 / audit 5.1-5.3, 5.5 (t4:256-269): tappable ISO rows, not
// non-interactive chips/plain ListTiles.
group('Item 24 -- tappable ISO rows', () {
testWidgets('rows show the ISO code and no longer use Chip or ListTile', (
tester,
) async {
final estado = EstadoBusqueda(
radio: FakeServicioRadio(
paises: const [
PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482),
PaisRadio(
nombre: 'Argentina',
codigoIso: 'AR',
numeroEmisoras: 120,
),
],
),
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpEstable(tester);
expect(
find.byType(Chip),
findsNothing,
reason: 'audit 5.1 replaces the Wrap-of-Chip with tappable rows',
);
expect(
find.byType(ListTile),
findsNothing,
reason:
'audit 5.3 rows need an ISO-code column a plain ListTile '
'has no slot for',
);
expect(find.text('AR'), findsOneWidget);
// 'ES' renders in BOTH the "Tus idiomas" and "Todos" rows.
expect(find.text('ES'), findsWidgets);
expect(find.byIcon(Icons.chevron_right_rounded), findsWidgets);
});
testWidgets('tapping a row invokes onPaisSeleccionado with that country', (
tester,
) async {
final estado = EstadoBusqueda(
radio: FakeServicioRadio(
paises: const [
PaisRadio(
nombre: 'Argentina',
codigoIso: 'AR',
numeroEmisoras: 120,
),
],
),
);
addTearDown(estado.dispose);
PaisRadio? seleccionado;
await tester.pumpWidget(
ChangeNotifierProvider<EstadoBusqueda>.value(
value: estado,
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: PantallaPaises(
onPaisSeleccionado: (pais) => seleccionado = pais,
),
),
),
);
await pumpEstable(tester);
await tester.tap(find.text('Argentina'));
await pumpEstable(tester);
expect(seleccionado?.codigoIso, 'AR');
});
testWidgets(
'the first "Tus idiomas" row is highlighted: teal-tinted background, '
'bold name',
(tester) async {
final estado = EstadoBusqueda(
radio: FakeServicioRadio(
paises: const [
PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482),
],
),
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpEstable(tester);
final nombre = tester.widget<Text>(find.text('Spain').first);
expect(nombre.style?.fontWeight, FontWeight.w800);
final decorado = tester.widget<DecoratedBox>(
find
.ancestor(
of: find.text('Spain').first,
matching: find.byType(DecoratedBox),
)
.first,
);
final decoration = decorado.decoration as BoxDecoration;
expect(
decoration.color,
isNot(Colors.transparent),
reason: 't4:256 the first row is teal-tinted, not transparent',
);
},
);
});
testWidgets(
'cargarPaises no se re-dispara si ya hay datos en caché al reconstruir',
(tester) async {
@@ -9,7 +9,10 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/pantallas/pantalla_reproductor.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/ecualizador_widget.dart';
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
import 'package:pluriwave/widgets/visualizador_audio.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -548,4 +551,157 @@ void main() {
);
},
);
group('Visual fidelity (screens 1-3 closing pass)', () {
testWidgets('audit 2.1: the hero art is a fixed 246x246, not width*0.62', (
tester,
) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
final sizedBox = tester.widget<SizedBox>(
find.byKey(const Key('player-hero-art')),
);
expect(sizedBox.width, 246, reason: 'prototype t4 line 114');
expect(sizedBox.height, 246);
});
testWidgets(
'audit 2.2: a full-bleed backdrop sits behind the whole screen',
(tester) async {
const estacion = Emisora(
uuid: 'demo-uuid',
nombre: 'Radio Demo',
url: 'https://stream.demo/radio',
favicon: 'https://example.com/art.png',
);
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado, estacion: estacion);
expect(
find.byKey(const Key('player-background-art')),
findsOneWidget,
reason: 'prototype t4 lines 108-109: full-bleed blurred backdrop',
);
},
);
testWidgets('audit 2.2: the backdrop still renders (gradient only) with no '
'station art available', (tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
expect(find.byKey(const Key('player-background-art')), findsOneWidget);
});
testWidgets('audit 2.3: the title is 26/w800/ls-.5/height 1.15, not '
'headlineSmall(24)/w700', (tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
final texto = tester.widget<Text>(find.text(emisora.nombre));
expect(texto.style?.fontSize, 26, reason: 'prototype t4 line 116');
expect(texto.style?.fontWeight, FontWeight.w800);
expect(texto.style?.letterSpacing, -0.5);
expect(texto.style?.height, 1.15);
});
testWidgets(
'audit 2.8: only the EQ propio tile is accented -- tinted brand '
'background/border, the other three share a neutral opaque surface',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
final eqDecoration =
tester
.widget<Container>(
find
.descendant(
of: find.byKey(const Key('player-tool-eq')),
matching: find.byType(Container),
)
.first,
)
.decoration
as BoxDecoration;
expect(
eqDecoration.color,
PluriWaveTokens.brand.withValues(alpha: 0.16),
reason: 'prototype t4 line 133',
);
final recordDecoration =
tester
.widget<Container>(
find
.descendant(
of: find.byKey(const Key('player-tool-record')),
matching: find.byType(Container),
)
.first,
)
.decoration
as BoxDecoration;
expect(
recordDecoration.color,
isNot(PluriWaveTokens.brand.withValues(alpha: 0.16)),
reason: 'prototype t4 line 134: no explicit accent colour',
);
},
);
testWidgets(
'audit 2.9: the transport row is not wrapped in a glass surface '
'card',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
expect(
find.ancestor(
of: find.byIcon(Icons.favorite_outline_rounded),
matching: find.byType(PluriGlassSurface),
),
findsNothing,
reason: 'prototype t4 lines 124-131: bare row, no glass card',
);
},
);
testWidgets(
'audit 2.10: the visualizer is not wrapped in its own glass card',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
final visualizador = find.byKey(const Key('player-visualizer'));
expect(tester.widget(visualizador), isA<VisualizadorAudio>());
expect(
find.ancestor(
of: visualizador,
matching: find.byType(PluriGlassSurface),
),
findsNothing,
reason:
'prototype t4 lines 120-122: bars sit on the bare '
'background',
);
},
);
});
}
+23 -3
View File
@@ -102,8 +102,19 @@ void main() {
);
testWidgets(
'Escuchar draws its own PluriRootHeader with the tab title, no AppBar',
'Escuchar is the documented header exception: a 52px "now listening" '
'eyebrow instead of a PluriRootHeader title row, and still no AppBar',
(tester) async {
// The prototype gives Escuchar a STRUCTURALLY different header from
// every other root (t4 line 53 vs line 325): 52px rather than 56, an
// eyebrow at 15px/w800/55% opacity rather than a 23px title, and
// trailing actions rather than a CTA. This test originally asserted
// `PluriRootHeader` on all five roots — an over-broad premise, since
// Escuchar's header was never a title row in the design.
//
// What still holds unconditionally, and is what this guards: no
// AppBar anywhere (systemic item S1), and the sleep-timer action that
// used to live in the removed global chrome is still reachable here.
_suppressListTileInkAssertion();
setLargeSurface(tester);
final estado = crearEstadoRadio();
@@ -112,9 +123,18 @@ void main() {
await tester.pumpWidget(testApp(estado, const PantallaInicio()));
await pumpStable(tester);
expect(find.byType(PluriRootHeader), findsOneWidget);
expect(titleInHeader('Listen'), findsOneWidget);
expect(find.byType(AppBar), findsNothing);
expect(
find.byType(PluriRootHeader),
findsNothing,
reason: 'Escuchar uses its own eyebrow header, not the shared one',
);
expect(find.text('Now listening'), findsOneWidget);
expect(
find.byIcon(Icons.bedtime_outlined),
findsOneWidget,
reason: 'the sleep-timer action survived the global AppBar removal',
);
},
);
+14
View File
@@ -65,6 +65,20 @@ void main() {
expect(type.eyebrowLabel.letterSpacing, 0.8);
});
test(
'S7 (visual fidelity): inPageSectionHeading is 15 / w800 / '
'letter-spacing -0.2 -- the ADR-1 amendment adding a seventh style',
() {
expect(
type.inPageSectionHeading.fontSize,
15,
reason: 'prototype t4 lines 80, 153, 164, 173',
);
expect(type.inPageSectionHeading.fontWeight, FontWeight.w800);
expect(type.inPageSectionHeading.letterSpacing, -0.2);
},
);
test('S9 (Tier 1 visual fidelity): eyebrowLabel bakes in the prototype '
"colour rgba(242,247,250,.42) — it used to carry no colour at all, "
'so it rendered at full onSurface opacity', () {
+146
View File
@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
EstadoRadio _estado() => EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
Widget _host(EstadoRadio estado, Widget child) {
return ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: child),
),
);
}
/// Item 23 / audit 4.3 + 6.5 (t4:226-232, 302-306): the shared flat row
/// backing Favoritos and search results.
void main() {
testWidgets(
'renders a flat, background-less row -- no TarjetaEmisora underneath',
(tester) async {
final estado = _estado();
addTearDown(estado.dispose);
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
await tester.pumpWidget(
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: 'Rock - Spain')),
);
await tester.pump();
expect(find.text('Station A'), findsOneWidget);
expect(find.text('Rock - Spain'), findsOneWidget);
expect(
find.byType(TarjetaEmisora),
findsNothing,
reason: 'a flat row does not reuse the full glass card',
);
},
);
testWidgets('the thumbnail is 48x48 with a 12 radius (t4:226, t4:302)', (
tester,
) async {
final estado = _estado();
addTearDown(estado.dispose);
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
await tester.pumpWidget(
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: '')),
);
await tester.pump();
final arte = find.byKey(const ValueKey('fila-emisora-plana-arte'));
expect(tester.getSize(arte), const Size(48, 48));
final clip = tester.widget<ClipRRect>(arte);
expect(
(clip.borderRadius as BorderRadius).topLeft,
const Radius.circular(12),
);
});
testWidgets('omits the meta line entirely when empty (no stray gap)', (
tester,
) async {
final estado = _estado();
addTearDown(estado.dispose);
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
await tester.pumpWidget(
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: '')),
);
await tester.pump();
expect(find.text('Station A'), findsOneWidget);
// Only the title Text should exist in the info column -- no empty
// second Text node.
expect(find.text(''), findsNothing);
});
testWidgets('BotonReproducirCircular is 44x44, brand-teal-tinted background, '
'22px play_arrow icon (t4:230, t4:305)', (tester) async {
var tapped = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: BotonReproducirCircular(onPressed: () => tapped = true),
),
),
);
await tester.pump();
final boton = find.byKey(const ValueKey('boton-reproducir-circular'));
final material = tester.widget<Material>(boton);
expect(material.color, PluriWaveTokens.brand.withValues(alpha: 0.14));
expect(material.shape, const CircleBorder());
final icon = tester.widget<Icon>(find.byIcon(Icons.play_arrow_rounded));
expect(icon.size, 22);
expect(icon.color, PluriWaveTokens.brand);
await tester.tap(find.byIcon(Icons.play_arrow_rounded));
expect(tapped, isTrue);
});
testWidgets(
'BotonFavoritoEmisora toggles favourite status and shows a snackbar '
'(t4:304)',
(tester) async {
final estado = _estado();
addTearDown(estado.dispose);
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
await tester.pumpWidget(
_host(estado, BotonFavoritoEmisora(emisora: emisora)),
);
await tester.pump();
expect(find.byIcon(Icons.favorite_outline_rounded), findsOneWidget);
await tester.tap(find.byIcon(Icons.favorite_outline_rounded));
await tester.pump();
await tester.pump();
expect(find.byIcon(Icons.favorite_rounded), findsOneWidget);
expect(await estado.esFavorito('a'), isTrue);
},
);
}