Files
pluriwave/lib/pantallas/pantalla_ajustes.dart
T
FreeTLab acf2ebb55f
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m13s
fix: alinear permisos, paywall y grabacion con lo que la app hace de verdad
Revision previa al envio a produccion. Cada punto se verifico en el codigo
antes de tocarlo; lo que ya estaba bien se dejo como estaba.

Ubicacion: se declaraba precision fina sin usarla

El unico consumidor de ubicacion pide `LocationAccuracy.low` y se queda solo
con el codigo ISO del pais, asi que `ACCESS_FINE_LOCATION` no aportaba nada. Y
contradecia la declaracion de Seguridad de los datos ya aprobada en Play, que
dice ubicacion APROXIMADA: declarar una cosa y pedir otra es precisamente lo
que se penaliza en revision.

Verificado que los manifiestos de geolocator_android y geocoding_android no
declaran permisos propios, asi que el merge no lo reinyecta y no hace falta
`tools:node="remove"`. El plugin construye su peticion en tiempo de ejecucion a
partir de lo declarado, de modo que con COARSE pide COARSE. Sin cambio
funcional: la deteccion de pais sigue igual.

El paywall vendia Android Auto como exclusivo, y ya no lo es

La etiqueta era literalmente "Android Auto", a secas. Pero el tier gratuito
recibio una carpeta navegable con emisoras reproducibles cuando hubo que
cumplir las guias del coche, asi que esa frase dejo de ser cierta. Ahora dice
que PRO añade el catalogo completo, favoritos, mis emisoras y musica local, y
aclara que gratis tiene las destacadas. Un paywall que promete lo que el tier
gratuito ya tiene expone a reclamacion y a que se cite en revision.

Microfono: se pide al activar el visualizador, no antes

Con una explicacion previa en los 13 idiomas, en vez de aparecer sin contexto.

Grabacion: uso privado de verdad, no solo en el aviso

La pantalla de grabaciones entregaba el fichero a cualquier aplicacion con
`Share.shareXFiles`. La intencion era abrirlo en un reproductor del propio
telefono, no redistribuirlo, y una cosa es copia privada y la otra no. Ahora
usa el `openFile` que ya existia -- FileProvider + ACTION_VIEW -- y avisa
cuando ningun reproductor del dispositivo puede abrirla, en vez de fallar en
silencio. Se añade ademas el aviso de uso privado en esa pantalla.

`recordingActionShare` la usaban DOS botones con significados distintos: el de
grabaciones, que mandaba el audio, y el del reproductor, que comparte el nombre
y la url de la emisora. Una clave, dos sentidos, y esa ambiguedad basto para
que al leer el codigo pareciera que solo se compartian enlaces. Separadas en
`stationActionShare` y `recordingActionOpenIn`.

La grabacion sigue siendo PRO. Lo que reduce el riesgo es que la copia no salga
del dispositivo, no regalar la funcion: los anuncios tambien son monetizacion.

Suite completa: 1587 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos
preexistentes.
2026-09-18 17:06:59 +02:00

342 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_ecualizador.dart';
import '../estado/estado_entitlement.dart';
import '../estado/estado_grabacion.dart';
import '../estado/estado_visualizador.dart';
import '../estado/estado_idioma.dart';
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/archivo_grabacion.dart';
import '../modelos/emisora.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/hoja_premium.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
import '../widgets/pluri_root_header.dart';
import '../widgets/pluri_sleep_timer_sheet.dart';
import 'ajustes/pantalla_ajustes_backup.dart';
import 'ajustes/pantalla_ajustes_ecualizador.dart';
import 'ajustes/pantalla_ajustes_emisora_preferida.dart';
import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart';
import 'ajustes/pantalla_ajustes_grupos_favoritos.dart';
import 'ajustes/pantalla_ajustes_idioma.dart';
import 'ajustes/pantalla_ajustes_info.dart';
import 'ajustes/pantalla_ajustes_musica_local.dart';
import 'ajustes/pantalla_ajustes_orden_listas.dart';
import 'ajustes/pantalla_ajustes_salida_audio.dart';
import 'ajustes/pantalla_ajustes_timer_sueno.dart';
import 'ajustes/pantalla_ajustes_visualizador.dart';
import 'ajustes/widgets/fila_ajuste.dart';
import 'pantalla_grabaciones.dart';
class PantallaAjustes extends StatelessWidget {
const PantallaAjustes({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
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).
PluriRootHeader(
title: l10n.settingsTitle,
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
const Padding(
padding: PluriLayout.pageContentPadding,
child: _AjustesContent(),
),
],
);
}
}
/// Design ADR-3: the Settings root is grouped nav rows only — each
/// [FilaAjuste] pushes its own detail screen via `PluriPushScaffold.push`,
/// carrying zero inline controls. All 12 sections are now decomposed across
/// WU3a (AUDIO, EMISORAS) and WU3b (GRABACIONES Y MÚSICA, APLICACIÓN); sleep
/// timer and backup/restore stay reachable throughout — nothing was dropped.
class _AjustesContent extends StatelessWidget {
const _AjustesContent();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
// S8 (Tier 1 visual fidelity): the prototype shows every row's current
// value (t4 lines 512-539, 625). 8 of the 12 rows below read it from
// state already provided at the app root — no new providers needed.
//
// S4-R5 convention (see pantalla_inicio.dart, pantalla_buscar.dart):
// `context.select` per scalar, NOT a root `context.watch<EstadoRadio>()`
// — EstadoRadio also notifies on audio buffer/position events, which
// this screen has nothing to do with. A root watch here rebuilds the
// WHOLE settings list (including the Grabaciones row's FutureBuilder,
// which would re-issue `listarGrabaciones()` on every single one of
// those unrelated notifications) far more often than intended.
final gruposCount = context.select<EstadoRadio, int>(
(e) => e.gruposFavoritos.length,
);
final emisoraPreferida = context.select<EstadoRadio, Emisora?>(
(e) => e.emisoraPreferida,
);
final emisorasCustomCount = context.select<EstadoRadio, int>(
(e) => e.emisorasCustom.length,
);
final ordenListas = context.select<EstadoRadio, OrdenEmisoras>(
(e) => e.ordenListas,
);
final timerActivo = context.select<EstadoRadio, bool>(
(e) => e.timer.activo,
);
final ecualizadorActivo = context.select<EstadoEcualizador, bool>(
(e) => e.activo,
);
final grabacion = context.watch<EstadoGrabacion>();
final idioma = context.select<EstadoIdioma, Locale?>(
(e) => e.localeSeleccionado,
);
final esPremium = context.select<EstadoEntitlement, bool>(
(e) => e.esPremium,
);
final ondaRealActiva = context.select<EstadoVisualizador, bool>(
(e) => e.ondaRealHabilitada,
);
return Column(
children: [
GrupoAjustes(
titulo: l10n.settingsGroupAudioTitle,
filas: [
FilaAjuste(
icon: Icons.equalizer_rounded,
// Audit 10.5 (t4 line 514): the first AUDIO row's icon is
// brand cyan.
iconColor: PluriWaveTokens.brand,
titulo: l10n.equalizerTitle,
valor:
ecualizadorActivo
? l10n.equalizerActive
: l10n.equalizerDisabled,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesEcualizador(),
),
),
// No `valor`: per-device output naming needs a friendly-name
// lookup this root doesn't have (only a raw device id).
FilaAjuste(
icon: Icons.devices_rounded,
titulo: l10n.advancedEqSectionTitle,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesSalidaAudio(),
),
),
// Point-of-intent entry for the waveform visualizer's
// microphone opt-in. It is a nav row, not an inline switch,
// because the Settings root carries zero inline controls by
// design — the switch and its explanation live on the detail
// screen, which is also where the user reads what the
// permission is for before granting it.
FilaAjuste(
icon: Icons.graphic_eq_rounded,
titulo: l10n.visualizerRealWaveTitle,
valor: ondaRealActiva ? l10n.equalizerActive : null,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesVisualizador(),
),
),
FilaAjuste(
icon: Icons.bedtime_rounded,
titulo: l10n.timerSectionTitle,
// Reuses the equalizer's own "Active" string (generic enough
// in every locale) — shown only while running, matching how
// the preferred-station row shows nothing when unset.
valor: timerActivo ? l10n.equalizerActive : null,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesTimerSueno(),
),
),
],
),
// Issue 3 (feedback-pruebas): t4:523/534/541 all draw a 16px gap
// between these opaque, stacked settings groups -- not 12.
const SizedBox(height: 16, key: ValueKey('ajustes-group-gap-1')),
GrupoAjustes(
titulo: l10n.settingsGroupStationsTitle,
filas: [
FilaAjuste(
icon: Icons.playlist_add_check_circle_rounded,
// Audit 10.5 (t4 line 526): the first STATIONS row's icon
// is warmCoral (the prototype's own `folder` row accent).
iconColor: PluriWaveTokens.dark.warmCoral,
titulo: l10n.favoriteGroupsTitle,
valor: '$gruposCount',
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesGruposFavoritos(),
),
),
FilaAjuste(
icon: Icons.radio_rounded,
titulo: l10n.preferredStationTitle,
valor:
emisoraPreferida != null
? localizedStationName(l10n, emisoraPreferida.nombre)
: l10n.dash,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesEmisoraPreferida(),
),
),
FilaAjuste(
icon: Icons.add_circle_outline_rounded,
titulo: l10n.customStationsTitle,
valor: '$emisorasCustomCount',
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesEmisorasPersonalizadas(),
),
),
FilaAjuste(
icon: Icons.sort_rounded,
titulo: l10n.stationOrderTitle,
valor:
ordenListas == OrdenEmisoras.calidad
? l10n.stationOrderByQuality
: l10n.stationOrderByName,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesOrdenListas(),
),
),
],
),
const SizedBox(height: 16, key: ValueKey('ajustes-group-gap-2')),
GrupoAjustes(
titulo: l10n.settingsGroupRecordingsTitle,
filas: [
// FutureBuilder-wrapped (not a plain FilaAjuste): the count ·
// size value needs an async disk listing
// (EstadoGrabacion.listarGrabaciones), same source
// PantallaGrabaciones itself reads.
FutureBuilder<List<ArchivoGrabacion>>(
future: grabacion.listarGrabaciones(),
builder: (context, snapshot) {
final archivos = snapshot.data;
return FilaAjuste(
icon: Icons.radio_button_checked_rounded,
titulo: l10n.recordingsSectionTitle,
valor:
archivos == null
? null
: '${archivos.length} · ${_totalMb(archivos)} MB',
// WU15b: this row opens the recordings LIBRARY
// (PantallaGrabaciones), matching the approved mockup's
// "Ajustes > Grabaciones" screen. The folder/size
// settings form (PantallaAjustesGrabaciones) is still
// reachable, but now from within the library via its own
// settings action.
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaGrabaciones(),
),
);
},
),
// No `valor`: the configured folder is a SAF tree URI resolved
// by an async native channel call
// (FuenteMusicaLocalAutoImpl.carpetaActual) this root would
// need to invoke itself, unmocked, just to render a value.
FilaAjuste(
icon: Icons.library_music_outlined,
titulo: l10n.localMusicSectionTitle,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesMusicaLocal(),
),
),
],
),
const SizedBox(height: 16, key: ValueKey('ajustes-group-gap-3')),
GrupoAjustes(
titulo: l10n.settingsGroupApplicationTitle,
filas: [
// freemium-gating spec "Settings always shows a premium row":
// a persistent buy row (free tier) or a premium-active state
// with restore access (premium tier) — both open the same
// paywall sheet, which adapts its own body to the tier.
FilaAjuste(
key: const ValueKey('ajustes-fila-premium'),
icon: Icons.workspace_premium_rounded,
iconColor: PluriWaveTokens.brand,
titulo: l10n.funcionPremium,
valor: esPremium ? l10n.equalizerActive : null,
onTap: () => mostrarHojaPremium(context),
),
FilaAjuste(
icon: Icons.language_rounded,
titulo: l10n.languageSectionTitle,
valor: nombreIdiomaActual(idioma, l10n),
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesIdioma(),
),
),
// No `valor`: there is no persisted "last backup" timestamp to
// read — showing one here would mean fabricating it.
FilaAjuste(
icon: Icons.backup_outlined,
titulo: l10n.backupSectionTitle,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesBackup(),
),
),
// No `valor`: PackageInfo.fromPlatform() has no test-environment
// fallback anywhere else in this codebase either (see
// PantallaAjustesInfo's own FutureBuilder) — wiring it here
// would add a value this pass cannot deterministically test.
FilaAjuste(
icon: Icons.info_outline_rounded,
titulo: l10n.infoSectionTitle,
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaAjustesInfo(),
),
),
],
),
],
);
}
static int _totalMb(List<ArchivoGrabacion> archivos) {
final totalBytes = archivos.fold<int>(0, (a, b) => a + b.tamanoBytes);
return (totalBytes / (1024 * 1024)).round();
}
}