Files
pluriwave/lib/pantallas/pantalla_favoritos.dart
T
FreeTLab aa0b242374 feat(iap): add freemium unlock via one-time in-app purchase
Adds a permanent, non-consumable premium unlock (EstadoEntitlement +
PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks
alarm vacations, alarms past a 5-alarm free cap, recording start, and
full Android Auto browsing. The phone equalizer stays free for everyone.

- Entitlement is prefs-backed (compra_premium_v1), fail-open, and
  resolvable headlessly via esPremiumPersistido() for the Android Auto
  audio handler, which registers before runApp.
- Android Auto reduced mode keeps the real root folder labels for free
  users; browsing into any of them (and playFromMediaId/playFromSearch/
  skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio
  choke points, with a locked "Función Premium" item as the backstop.
  Current-station play/pause/stop stays untouched. A free -> premium
  transition actively invalidates the head unit's cached browse tree.
- Ads (top banner + capped interstitial before adding a station or an
  alarm) are gated behind entitlement via ServicioAnuncios, using
  official Google test ad unit IDs pending AdMob provisioning.
- Alarm cap UX shows an explanatory message with a secondary unlock
  action rather than a bare paywall jump; existing data is grandfathered.
- 4 new localization keys translated across all 13 supported locales.

Co-located tests use strict TDD (RED test before implementation) for
every new pure-logic unit; full existing suite passes unchanged.
2026-08-10 20:37:07 +02:00

629 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../servicios/servicio_anuncios.dart';
import '../tema/pluriwave_tokens.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 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart';
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
import 'reproducir_minimizado.dart';
/// WU4, `favorites-organization` spec: a chip-filtered flat list replacing
/// the previous stacked per-group panels, with drag-to-reorder, a `swap_vert`
/// sort action, group management, and the custom-station CTA all reachable
/// from this root screen. Favoritos is the one root that keeps its bottom
/// tab bar (design ADR-2's documented exemption) — this file constructs no
/// `PluriPushScaffold` and stays a plain body widget for that reason.
class PantallaFavoritos extends StatefulWidget {
const PantallaFavoritos({super.key});
@override
State<PantallaFavoritos> createState() => _PantallaFavoritosState();
}
class _PantallaFavoritosState extends State<PantallaFavoritos> {
/// Ephemeral UI state only (design's "State is for ephemeral UI only"
/// ruling) — null means the "All" chip is active.
String? _grupoSeleccionadoId;
Future<void> _abrirFormularioEmisoraPersonalizada() async {
// ad-display spec "Interstitial Before Manual Station Add" (design.md
// ADR-6): fires on the CTA tap, before the form even opens — a no-op
// for premium (ServicioAnuncios' own entitlement gate).
await context.read<ServicioAnuncios>().intentarInterstitial();
if (!mounted) return;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
useSafeArea: true,
showDragHandle: true,
builder: (ctx) => const FormularioEmisoraPersonalizada(),
);
}
void _abrirGestionDeListas() {
PluriPushScaffold.push(
context,
(_) => const PantallaAjustesGruposFavoritos(),
);
}
Future<void> _elegirOrden(OrdenEmisoras criterio) =>
context.read<EstadoRadio>().ordenarFavoritos(criterio);
/// Translates a drag within the currently FILTERED view into the absolute
/// global position [EstadoRadio.reordenarFavorito] expects, so a drag
/// while a group chip is active still produces a coherent global order
/// (other groups' relative order is left untouched).
///
/// [newIndex] arrives in `ReorderableListView.onReorder`'s pre-removal
/// coordinate space: dragging downwards reports the slot the row would
/// occupy while it is still in the list. The logic below indexes into the
/// list AFTER the row is removed, so shift by one in that direction first.
void _onReorder(
List<Emisora> filtrados,
List<Emisora> favoritos,
int oldIndex,
int newIndex,
) {
if (newIndex > oldIndex) newIndex -= 1;
final movido = filtrados[oldIndex];
final restantes = List<Emisora>.from(filtrados)..removeAt(oldIndex);
// `ServicioFavoritos.reordenar` removes the station first and THEN
// inserts at the index it is given, so the target index must be
// expressed in the global list WITHOUT the moved station. Locating the
// neighbour in the untrimmed list instead drifts by one whenever the
// moved station sits before it.
final globalSinMovido =
favoritos.where((e) => e.uuid != movido.uuid).toList();
final int nuevoIndiceGlobal;
if (restantes.isEmpty) {
nuevoIndiceGlobal = globalSinMovido.length;
} else if (newIndex >= restantes.length) {
nuevoIndiceGlobal =
globalSinMovido.indexWhere((e) => e.uuid == restantes.last.uuid) + 1;
} else {
nuevoIndiceGlobal = globalSinMovido.indexWhere(
(e) => e.uuid == restantes[newIndex].uuid,
);
}
context.read<EstadoRadio>().reordenarFavorito(
movido.uuid,
nuevoIndiceGlobal,
);
}
@override
Widget build(BuildContext context) {
// S4-R5: no root watch — select only the fields this screen reads. The
// getters are identity-memoized, so playback notifications that do not
// change favorites/groups no longer rebuild the screen.
final favoritos = context.select<EstadoRadio, List<Emisora>>(
(e) => e.listaFavoritosManual,
);
final grupos = context.select<EstadoRadio, List<GrupoFavoritos>>(
(e) => e.gruposFavoritos,
);
final l10n = AppLocalizations.of(context);
if (favoritos.isEmpty) {
return ListView(
padding: PluriLayout.pageListPadding,
children: [
// 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).
// S2 (Tier 1 visual fidelity): PluriScreenHeader (the glass hero
// this used to be) is retired — it is not in the prototype at
// all, and carried no functional action on this screen.
PluriRootHeader(
title: l10n.favoritesTitle,
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
SizedBox(
height: 320,
child: PluriEmptyState(
glyph: PluriIconGlyph.favorites,
title: l10n.favoritesEmptyTitle,
subtitle: l10n.favoritesEmptySubtitle,
),
),
Padding(
padding: PluriLayout.pageContentPadding,
child: _CtaEmisoraPersonalizada(
onTap: _abrirFormularioEmisoraPersonalizada,
),
),
],
);
}
final gruposVisibles =
grupos.isEmpty
? [
GrupoFavoritos(
id: GrupoFavoritos.sinAsignarId,
nombre: l10n.favoriteGroupsUnassigned,
orden: 0,
protegido: true,
),
]
: grupos;
// Defensive: a group selected before it was deleted elsewhere (e.g. via
// the pushed management screen) falls back to "All" instead of showing
// an empty list with no chip highlighted.
final seleccionEfectiva =
gruposVisibles.any((g) => g.id == _grupoSeleccionadoId)
? _grupoSeleccionadoId
: null;
final filtrados =
seleccionEfectiva == null
? favoritos
: favoritos
.where((e) => e.grupoFavoritosId == seleccionEfectiva)
.toList();
return ReorderableListView(
buildDefaultDragHandles: false,
// Issue 3 (feedback-pruebas): zero horizontal here, matching every
// other root's PluriLayout.pageListPadding convention (Alarmas,
// Ajustes, and this screen's OWN empty-state branch above).
// ReorderableListView.padding wraps header/children/footer UNIFORMLY,
// so a single horizontal value here can never be simultaneously right
// for PluriRootHeader (self-padded, wants none), the reorderable rows
// (want row tier, applied per item below) and the footer CTA (wants
// card tier, applied on the footer's own Padding below). The previous
// `PluriLayout.horizontal` doubled up on top of PluriRootHeader's own
// internal inset, pushing "Favorites" in by 36px instead of the 20px
// every other root uses for its title.
padding: const EdgeInsets.only(bottom: PluriLayout.bottomChromeInset),
header: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// S1/S2 (Tier 1 visual fidelity): see the empty-state branch
// above — PluriScreenHeader is retired everywhere.
//
// Audit 4.1 (t4:216): the prototype's two header icon actions
// (create_new_folder, swap_vert) now live in PluriRootHeader's
// own actions slot -- they used to be scattered as an
// ActionChip inside the chip strip and a PopupMenuButton
// sharing a Row with it. The back arrow the prototype also
// draws stays absent (binding decision: this root keeps its
// bottom tab bar, unlike the prototype's own pushed shape).
PluriRootHeader(
title: l10n.favoritesTitle,
onSleepTimer: () => showPluriSleepTimerSheet(context),
actions: [
IconButton(
key: const ValueKey('favorites-manage-groups-action'),
icon: const Icon(Icons.create_new_folder_rounded),
tooltip: l10n.favoriteGroupsManage,
onPressed: _abrirGestionDeListas,
),
PopupMenuButton<OrdenEmisoras>(
icon: const Icon(Icons.swap_vert_rounded),
tooltip: l10n.stationOrderTitle,
onSelected: _elegirOrden,
itemBuilder:
(context) => [
PopupMenuItem(
value: OrdenEmisoras.nombre,
child: Text(l10n.stationOrderByName),
),
PopupMenuItem(
value: OrdenEmisoras.calidad,
child: Text(l10n.stationOrderByQuality),
),
],
),
],
),
const SizedBox(height: 12),
// Issue 3 (feedback-pruebas): t4:218 draws this chip strip at
// title-tier (20px) horizontal inset, directly on the page
// background -- it now needs its OWN inset since the list's
// padding no longer supplies one.
Padding(
padding: const EdgeInsets.symmetric(
horizontal: PluriLayout.titleHorizontal,
),
child: _FilaChipsGrupos(
grupos: gruposVisibles,
favoritos: favoritos,
seleccionado: seleccionEfectiva,
onSeleccionar:
(id) => setState(() => _grupoSeleccionadoId = id),
),
),
],
),
),
footer: Padding(
// Issue 3 (feedback-pruebas): card tier (16, matching every other
// screen's dashed CTA) now that the list's own padding no longer
// supplies it, plus t4:234's 8px gap above the CTA
// (PluriLayout.compactGap) instead of the previous unwired literal
// 4 -- the ONLY state of this screen with a nonzero top gap before
// its own content used a value that matched neither this screen's
// own empty-state branch nor the prototype.
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
PluriLayout.compactGap,
PluriLayout.horizontal,
0,
),
child: _CtaEmisoraPersonalizada(
onTap: _abrirFormularioEmisoraPersonalizada,
),
),
onReorder:
(oldIndex, newIndex) =>
_onReorder(filtrados, favoritos, oldIndex, newIndex),
children: [
for (var i = 0; i < filtrados.length; i++)
// Issue 3 (feedback-pruebas): row tier (12), not card tier -- the
// key moves to this wrapper (ReorderableListView identifies each
// child by its own top-level key) since FilaEmisoraPlana rows are
// documented (audit 4.3) as flat, background-less rows, the same
// tier Buscar's results list already uses for the same widget.
Padding(
key: ValueKey(filtrados[i].uuid),
padding: const EdgeInsets.symmetric(
horizontal: PluriLayout.rowHorizontal,
),
child: _FilaFavorito(
index: i,
emisora: filtrados[i],
grupos: gruposVisibles,
grupoActual: gruposVisibles.firstWhere(
(g) => g.id == filtrados[i].grupoFavoritosId,
orElse: () => gruposVisibles.first,
),
),
),
],
);
}
}
class _FilaChipsGrupos extends StatelessWidget {
const _FilaChipsGrupos({
required this.grupos,
required this.favoritos,
required this.seleccionado,
required this.onSeleccionar,
});
final List<GrupoFavoritos> grupos;
final List<Emisora> favoritos;
final String? seleccionado;
final ValueChanged<String?> onSeleccionar;
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
/// Audit 4.2 (t4:219-221): active `#21D4D9`/`#062126` w800, inactive
/// `listSurface` + a faint border / w700 -- was Material's own
/// `ChoiceChip` theming (a plain checkbox-style selected fill).
Widget _chip({
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return ChoiceChip(
label: Text(label),
labelStyle: TextStyle(
fontWeight: selected ? FontWeight.w800 : FontWeight.w700,
color: selected ? const Color(0xFF062126) : const Color(0xFFF2F7FA),
),
selected: selected,
showCheckmark: false,
selectedColor: PluriWaveTokens.brand,
backgroundColor: PluriWaveTokens.dark.listSurface,
side: BorderSide(
color:
selected
? Colors.transparent
: Colors.white.withValues(alpha: 0.09),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (_) => onTap(),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: _chip(
label: l10n.favoriteGroupsChipLabel(
l10n.favoritesFilterAllLabel,
favoritos.length,
),
selected: seleccionado == null,
onTap: () => onSeleccionar(null),
),
),
for (final grupo in grupos)
Padding(
padding: const EdgeInsets.only(right: 8),
child: _chip(
label: l10n.favoriteGroupsChipLabel(
_nombreVisible(l10n, grupo),
favoritos.where((e) => e.grupoFavoritosId == grupo.id).length,
),
selected: seleccionado == grupo.id,
onTap: () => onSeleccionar(grupo.id),
),
),
],
),
);
}
}
class _FilaFavorito extends StatelessWidget {
const _FilaFavorito({
required this.index,
required this.emisora,
required this.grupos,
required this.grupoActual,
});
final int index;
final Emisora emisora;
final List<GrupoFavoritos> grupos;
final GrupoFavoritos grupoActual;
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
Future<void> _asignar(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final seleccionado = await showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder:
(ctx) => SafeArea(
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 12),
child: Text(
l10n.favoriteGroupsAssign,
style: Theme.of(ctx).textTheme.titleLarge,
),
),
for (final grupo in grupos)
ListTile(
leading: Icon(
grupo.id == emisora.grupoFavoritosId
? Icons.radio_button_checked_rounded
: Icons.radio_button_off_rounded,
),
title: Text(_nombreVisible(l10n, grupo)),
onTap: () => Navigator.pop(ctx, grupo.id),
),
],
),
),
);
if (seleccionado == null || !context.mounted) return;
await context.read<EstadoRadio>().asignarGrupoFavorito(
emisora.uuid,
seleccionado,
);
if (!context.mounted) return;
final destino = grupos.firstWhere((g) => g.id == seleccionado);
final stationName = localizedStationName(l10n, emisora.nombre);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.favoriteGroupsAssigned(
stationName,
_nombreVisible(l10n, destino),
),
),
),
);
}
Future<void> _eliminar(BuildContext context) async {
final l10n = AppLocalizations.of(context);
final estado = context.read<EstadoRadio>();
final stationName = localizedStationName(l10n, emisora.nombre);
await estado.favoritos.eliminar(emisora.uuid);
await estado.cargarFavoritos();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.favoritesRemovedMessage(stationName))),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(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),
),
// NO `constraints:` here. That property sizes the POPUP MENU, not
// the button — a tightFor(38x42) clipped every menu item down to
// its first letter ("M" for "Mover a lista", "E" for "Eliminar de
// favoritos"), which is what users actually saw. Constrain the
// tap target instead.
padding: EdgeInsets.zero,
iconSize: 20,
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),
),
],
),
],
);
}
}
/// The dashed "Añadir emisora personalizada" CTA (favorites-organization
/// spec, "Custom-Station CTA Preserved") — opens the SAME add-station form
/// used from Settings' Emisoras personalizadas screen
/// ([FormularioEmisoraPersonalizada]), not a duplicate.
class _CtaEmisoraPersonalizada extends StatelessWidget {
const _CtaEmisoraPersonalizada({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
// 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.6);
return CustomPaint(
painter: _DashedBorderPainter(color: colorBorde),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
key: const Key('custom-station-cta-padding'),
padding: const EdgeInsets.all(14),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_rounded, size: 20, color: colorTexto),
const SizedBox(width: 8),
Text(
l10n.customStationsAddCta,
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w800,
color: colorTexto,
),
),
],
),
),
),
),
);
}
}
class _DashedBorderPainter extends CustomPainter {
const _DashedBorderPainter({required this.color});
final Color color;
static const _radius = 16.0;
static const _dashWidth = 6.0;
static const _gapWidth = 4.0;
@override
void paint(Canvas canvas, Size size) {
final rrect = RRect.fromRectAndRadius(
Offset.zero & size,
const Radius.circular(_radius),
);
final path = Path()..addRRect(rrect);
final paint =
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
for (final metric in path.computeMetrics()) {
var distance = 0.0;
while (distance < metric.length) {
final next = distance + _dashWidth;
canvas.drawPath(
metric.extractPath(distance, next.clamp(0.0, metric.length)),
paint,
);
distance = next + _gapWidth;
}
}
}
@override
bool shouldRepaint(covariant _DashedBorderPainter oldDelegate) =>
oldDelegate.color != color;
}