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.
629 lines
23 KiB
Dart
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;
|
|
}
|