fix: alinear permisos, paywall y grabacion con lo que la app hace de verdad
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 3m13s

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.
This commit is contained in:
2026-09-18 17:06:59 +02:00
parent 69eea0f2a1
commit acf2ebb55f
52 changed files with 1631 additions and 265 deletions
+53 -13
View File
@@ -3,7 +3,6 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../estado/estado_grabacion.dart';
import '../l10n/gen/app_localizations.dart';
@@ -90,19 +89,24 @@ class _ReproductorGrabacionesJustAudio implements ReproductorGrabaciones {
/// WU15: the recordings library — storage usage, browsable rows with
/// inline playback, and a "⋮" menu constrained to exactly
/// Rename/Share/Delete (`recordings-library` spec). Distinct from
/// `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit
/// Rename/Open-in-another-app/Delete (`recordings-library` spec). Distinct
/// from `PantallaAjustesGrabaciones` (WU3b), which is the folder/size-limit
/// SETTINGS screen, not this browsable file list.
class PantallaGrabaciones extends StatefulWidget {
const PantallaGrabaciones({
super.key,
ReproductorGrabaciones? reproductor,
Future<void> Function(String ruta)? compartir,
Future<bool> Function(String ruta)? abrirEnOtraApp,
}) : _reproductorInyectado = reproductor,
_compartirInyectado = compartir;
_abrirEnOtraAppInyectada = abrirEnOtraApp;
final ReproductorGrabaciones? _reproductorInyectado;
final Future<void> Function(String ruta)? _compartirInyectado;
/// Seam for the local-open action. Was `compartir`, which handed the audio
/// file to the system share sheet — redistribution of someone else's
/// broadcast. It now opens the file in a player already installed on THIS
/// device, and returns whether any app accepted it.
final Future<bool> Function(String ruta)? _abrirEnOtraAppInyectada;
@override
State<PantallaGrabaciones> createState() => _PantallaGrabacionesState();
@@ -111,8 +115,9 @@ class PantallaGrabaciones extends StatefulWidget {
class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
late final ReproductorGrabaciones _reproductor =
widget._reproductorInyectado ?? _ReproductorGrabacionesJustAudio();
late final Future<void> Function(String ruta) _compartir =
widget._compartirInyectado ?? (ruta) => Share.shareXFiles([XFile(ruta)]);
late final Future<bool> Function(String ruta) _abrirEnOtraApp =
widget._abrirEnOtraAppInyectada ??
(ruta) => context.read<EstadoGrabacion>().abrirGrabacion(ruta);
late Future<List<ArchivoGrabacion>> _grabaciones;
final Map<String, Future<Duration?>> _duracionCache = {};
@@ -153,8 +158,8 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
await _renombrar(archivo);
return;
}
if (accion == 'share') {
await _compartir(archivo.ruta);
if (accion == 'open') {
await _abrirLocalmente(archivo);
return;
}
if (accion == 'delete') {
@@ -162,6 +167,20 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
}
}
/// Plays the user's own recording in another app on the same device. A
/// device with no audio viewer installed (and the native side's own
/// fallback to the containing folder failing too) returns `false` — the
/// action then says so instead of looking like a dead menu entry.
Future<void> _abrirLocalmente(ArchivoGrabacion archivo) async {
final messenger = ScaffoldMessenger.of(context);
final l10n = AppLocalizations.of(context);
final abierto = await _abrirEnOtraApp(archivo.ruta);
if (!mounted || abierto) return;
messenger.showSnackBar(
SnackBar(content: Text(l10n.recordingOpenNoAppError)),
);
}
Future<void> _renombrar(ArchivoGrabacion archivo) async {
final nuevoNombre = await showDialog<String>(
context: context,
@@ -286,6 +305,27 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
),
],
),
// Production-readiness pass: recording a broadcast holds up as
// a private copy, and stops holding up the moment the product
// reads as a redistribution tool. The library had no such
// statement at all, while the manifest already publishes the
// recordings folder to the system file manager
// (RecordingsDocumentsProvider). Deliberately factual and
// low-key — a footnote, not a warning banner — and always
// visible, empty library included.
const SizedBox(height: 16),
Padding(
key: const ValueKey('grabaciones-aviso-uso-privado'),
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(
l10n.recordingsPrivateUseNotice,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.7),
),
),
),
],
);
},
@@ -373,7 +413,7 @@ class _FilaGrabacion extends StatelessWidget {
// 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.
// menu (Rename/Open in another app/Delete) as before, just restyled.
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
child: Row(
@@ -466,8 +506,8 @@ class _FilaGrabacion extends StatelessWidget {
child: Text(l10n.recordingActionRename),
),
PopupMenuItem(
value: 'share',
child: Text(l10n.recordingActionShare),
value: 'open',
child: Text(l10n.recordingActionOpenIn),
),
PopupMenuItem(
value: 'delete',