import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'estado/estado_busqueda.dart'; import 'estado/estado_ecualizador.dart'; import 'estado/estado_entitlement.dart'; import 'estado/estado_grabacion.dart'; import 'estado/estado_radio.dart'; import 'estado/estado_alarmas.dart'; import 'estado/estado_idioma.dart'; import 'estado/estado_navegacion.dart'; import 'servicios/servicio_anuncios.dart'; import 'servicios/servicio_compras.dart'; import 'widgets/banner_anuncio_superior.dart'; import 'l10n/display_names.dart'; import 'l10n/gen/app_localizations.dart'; import 'modelos/alarma_musical.dart'; import 'pantallas/pantalla_alarmas.dart'; import 'pantallas/pantalla_alarma_sonando.dart'; import 'pantallas/pantalla_bienvenida.dart'; import 'pantallas/pantalla_inicio.dart'; import 'pantallas/pantalla_tutorial_ayuda.dart'; import 'pantallas/pantalla_buscar.dart'; import 'pantallas/pantalla_favoritos.dart'; import 'pantallas/pantalla_ajustes.dart'; import 'tema/pluriwave_theme.dart'; import 'widgets/pluri_bottom_navigation.dart'; import 'widgets/pluri_icon.dart'; import 'widgets/pluri_layout.dart'; import 'widgets/pluri_onboarding_dialog.dart'; import 'widgets/pluri_wave_scaffold.dart'; import 'package:pluriwave/widgets/mini_reproductor.dart'; import 'servicios/navegacion_auto.dart'; import 'servicios/servicio_alarmas_android.dart'; import 'servicios/servicio_dispositivo_audio.dart'; class PluriWaveApp extends StatelessWidget { const PluriWaveApp({super.key, this.prefs, this.fuenteAuto, this.compras}); /// Single SharedPreferences instance resolved in main() (S3-R4) and /// injected into every state/service. final SharedPreferences? prefs; /// Android Auto browse source (Design "Data Flow" — cold-bind local read /// available before EstadoRadio builds). Optional: defaults to `null`, /// same as every other existing caller/test that constructs /// [PluriWaveApp] without it. final FuenteEmisorasAuto? fuenteAuto; /// Purchase I/O port (iap-freemium-unlock, Design ADR-2). Optional and /// `null` by default — mirrors [fuenteAuto]'s injection shape, so every /// pre-existing test that constructs [PluriWaveApp] without it never /// touches the real `in_app_purchase` plugin channel. `main.dart` wires /// the real [ServicioComprasPlayBilling]. final PuertoCompras? compras; @override Widget build(BuildContext context) { return MultiProvider( providers: [ // iap-freemium-unlock (Design ADR-3): registered FIRST so every // provider below can read it via `context.read` inside a lazy // `esPremium` closure — `MultiProvider` nests top-to-bottom, so only // a provider ABOVE a given one is reachable from its own `create`. ChangeNotifierProvider( create: (_) => EstadoEntitlement(prefs: prefs, compras: compras), ), ChangeNotifierProvider( create: (context) => EstadoRadio( prefs: prefs, dispositivoAudio: ServicioDispositivoAudioReal(), fuenteAuto: fuenteAuto, esPremium: () => context.read().esPremium, ), ), // Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio // (they need its services and callbacks at construction); these // providers only expose the instances, so they declare no dispose // callback. ListenableProvider( create: (context) => context.read().ecualizador, ), ListenableProvider( create: (context) => context.read().grabacion, ), ListenableProvider( create: (context) => context.read().busqueda, ), ChangeNotifierProvider( create: (context) => EstadoAlarmas( prefs: prefs, esPremium: () => context.read().esPremium, ), ), ChangeNotifierProvider( create: (_) => EstadoIdioma(sharedPreferences: prefs), ), // Design ADR-8: root-to-root navigation state. `_PaginaPrincipal` // watches this instead of owning `_indice` locally. ChangeNotifierProvider(create: (_) => EstadoNavegacionRaiz()), // iap-freemium-unlock (Design "Interfaces / Contracts", ADR-6): a // plain (non-notifier) `Provider` — session-scoped ad state, never // rebuilds the widget tree itself. Provider( create: (context) => ServicioAnuncios( esPremium: () => context.read().esPremium, ), ), ], child: Consumer( builder: (context, estadoIdioma, _) => MaterialApp( title: 'PluriWave', debugShowCheckedModeBanner: false, theme: PluriWaveTheme.dark(), darkTheme: PluriWaveTheme.dark(), themeMode: ThemeMode.dark, localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, locale: estadoIdioma.localeSeleccionado, home: const _PaginaPrincipal(), ), ), ); } } class _PaginaPrincipal extends StatefulWidget { const _PaginaPrincipal(); @override State<_PaginaPrincipal> createState() => _PaginaPrincipalState(); } class _PaginaPrincipalState extends State<_PaginaPrincipal> with WidgetsBindingObserver { StreamSubscription? _errorSubscription; StreamSubscription? _alarmaSubscription; StreamSubscription? _alarmaVencidaSubscription; EstadoRadio? _estadoSuscrito; bool _alarmaInicialProcesada = false; bool _alarmaSonandoActiva = false; // WU17b: renamed from `_onboardingInicialSolicitado` — this single guard // now covers the whole first-launch sequence (welcome screen, then the // pre-existing what's-new dialog), not only the dialog. bool _flujoPrimerLanzamientoSolicitado = false; String? _alarmaSonandoId; Locale? _localeAlarmasConfigurado; static const _paginas = [ PantallaInicio(), PantallaBuscar(), PantallaFavoritos(), PantallaAlarmas(), PantallaAjustes(), ]; List _navItems(AppLocalizations l10n) => [ PluriNavItem(glyph: PluriIconGlyph.home, label: l10n.navHome), PluriNavItem(glyph: PluriIconGlyph.search, label: l10n.navSearch), PluriNavItem(glyph: PluriIconGlyph.favorites, label: l10n.navFavorites), PluriNavItem(glyph: PluriIconGlyph.alarm, label: l10n.navAlarms), PluriNavItem(glyph: PluriIconGlyph.settings, label: l10n.navSettings), ]; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state != AppLifecycleState.resumed) return; // Fix "stale green dot": on return to foreground the Activity may have // been recreated over the cached engine, leaving the device event channel // without a live native sink. Re-subscribe and re-seed the active device // (no-op when multi-device EQ is off). unawaited(context.read().refrescarDispositivoActual()); } @override void didChangeDependencies() { super.didChangeDependencies(); // S3-R3 / Decision 3.2: keep the alarm bridge l10n in sync, once per // locale change (this hook re-runs when Localizations changes). final locale = Localizations.localeOf(context); if (_localeAlarmasConfigurado != locale) { _localeAlarmasConfigurado = locale; context.read().configurarLocalizaciones( AppLocalizations.of(context), ); } final estado = context.read(); if (identical(_estadoSuscrito, estado) && _errorSubscription != null) { return; } _errorSubscription?.cancel(); _estadoSuscrito = estado; _errorSubscription = estado.errorStream.listen((msg) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(msg), duration: const Duration(seconds: 3), action: SnackBarAction( label: AppLocalizations.of(context).actionOk, onPressed: () {}, ), ), ); }); final alarmas = context.read(); _alarmaSubscription ??= alarmas.android.eventosAlarma.listen((evento) { if (!mounted) return; _abrirAlarmaSonando(evento); }); _alarmaVencidaSubscription ??= alarmas.alarmasVencidasStream.listen(( alarma, ) { if (!mounted) return; _abrirAlarmaDirecta(alarma); }); if (!_alarmaInicialProcesada) { _alarmaInicialProcesada = true; unawaited(_procesarAlarmaInicial(alarmas)); } if (!_flujoPrimerLanzamientoSolicitado) { _flujoPrimerLanzamientoSolicitado = true; unawaited(_mostrarFlujoPrimerLanzamiento()); } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _errorSubscription?.cancel(); _alarmaSubscription?.cancel(); _alarmaVencidaSubscription?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final navegacion = context.watch(); final indice = navegacion.indice; return PluriWaveScaffold( // ad-display spec "Persistent Top Banner, Never Overlapping Content" // (design.md ADR-6): a `Column` sibling, NEVER a `Stack`/overlay — the // banner RESERVES its own space above the existing body instead of // covering any of it. `BannerAnuncioSuperior` itself collapses to // `SizedBox.shrink()` (zero layout impact) for premium/unloaded. body: Column( children: [ const SafeArea(bottom: false, child: BannerAnuncioSuperior()), Expanded( child: SafeArea( top: false, child: AnimatedSwitcher( duration: context.pluriMotion.normal, switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, animation) => FadeTransition( opacity: animation, child: SlideTransition( position: Tween( begin: const Offset(0.035, 0), end: Offset.zero, ).animate(animation), child: child, ), ), child: KeyedSubtree( key: ValueKey(indice), child: _paginas[indice], ), ), ), ), ], ), bottomNavigationBar: SafeArea( top: false, minimum: const EdgeInsets.only(bottom: PluriLayout.compactGap), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Item 22 / audit 3.6 (t4:185 left:0;right:0): the mini player // is full-bleed — it does NOT share the balloon bar's 8px side // margin below. ADR-7(b): hidden on Escuchar (index 0) only — // its embedded hero already shows the same station. Stays // mounted (visible: false renders SizedBox.shrink(), not tree // removal) so its didChangeDependencies side effect (S3-R3) // keeps running. MiniReproductor(visible: indice != RaizPluriWave.escuchar.index), Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), child: PluriBottomNavigation( items: _navItems(l10n), selectedIndex: indice, onSelected: (i) => navegacion.irA(RaizPluriWave.values[i]), ), ), ], ), ), ); } Future _procesarAlarmaInicial(EstadoAlarmas alarmas) async { final evento = await alarmas.android.obtenerEventoInicial(); if (evento != null && mounted) { await _abrirAlarmaSonando(evento); } } // WU17b: runs the welcome screen's once-ever check BEFORE the recurring // what's-new dialog, so the two never show at the same time. The welcome // screen (PantallaBienvenida) is the genuine first-run surface; the // pre-existing PluriOnboardingDialog is an unrelated "what's new"/help // modal that keeps its own independent per-version due-or-not logic, // completely unchanged by this sequencing. // // The 9-screen help/tutorial carousel (PantallaTutorialAyuda) runs // BETWEEN the two: after the welcome screen (fresh installs only) and // before the what's-new dialog. Unlike the welcome screen, the tutorial // shows once to EVERY install -- fresh AND existing -- via its own plain // one-time flag (ServicioTutorialAyuda), which is what makes an // already-installed app show it once after updating to this version. Future _mostrarFlujoPrimerLanzamiento() async { if (mounted) { await PantallaBienvenida.mostrarSiProcede(context); } if (mounted) { await PantallaTutorialAyuda.mostrarSiProcede(context); } await _mostrarOnboardingInicial(); } Future _mostrarOnboardingInicial() async { await Future.delayed(const Duration(milliseconds: 900)); if (!mounted || _alarmaSonandoActiva) return; await PluriOnboardingDialog.mostrarSiProcede(context); } Future _abrirAlarmaSonando(EventoAlarmaAndroid evento) async { if (evento.accion == EventoAlarmaAndroid.accionSnoozed) { // EstadoAlarmas records native snoozes itself (Decision 2.1); there is // nothing to open for this event. return; } if (evento.accion == EventoAlarmaAndroid.accionMissed) { // EstadoAlarmas' own native-event listener already recorded this // transition (RES-1); the ring already ended, so opening the ringing // screen here would only show a stale, already-silent alarm. return; } final estado = context.read(); if (estado.alarmas.isEmpty) { await estado.cargarPersistidasSinRecalcular(); } AlarmaMusical? alarma; for (final item in estado.alarmas) { if (item.id == evento.alarmaId) { alarma = item; break; } } if (alarma == null || !mounted) { debugPrint( '[PluriWave][alarmas] evento sin alarma persistida id=${evento.alarmaId} accion=${evento.accion}', ); return; } if (evento.accion.endsWith('.SKIP_NEXT')) { await estado.saltarProxima(alarma.id); if (!mounted) return; context.read().irA(RaizPluriWave.alarmas); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context).skipCurrentAlarmExecution( localizedAlarmName(AppLocalizations.of(context), alarma.nombre), ), ), ), ); return; } if (evento.accion.endsWith('.POSTPONE_NEXT')) { final ejecucion = evento.occurrenceAtMillis > 0 ? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis) : alarma.proximaEjecucion ?? DateTime.now(); await estado.posponerProximaDesdePreaviso( alarma, evento.snoozeMinutes, ejecucion, ); if (!mounted) return; context.read().irA(RaizPluriWave.alarmas); // posponerProximaDesdePreaviso no longer throws on a native scheduling // failure — it records the failure into EstadoAlarmas.error instead. // Branch on it here so the user sees the real outcome instead of an // always-success message. final error = estado.error; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( error ?? AppLocalizations.of(context).alarmPostponedCurrentExecution, ), ), ); return; } if (evento.accion.endsWith('.PRE_NOTICE')) { context.read().irA(RaizPluriWave.alarmas); return; } await _mostrarAlarmaSonando(alarma); } Future _abrirAlarmaDirecta(AlarmaMusical alarma) async { await _mostrarAlarmaSonando(alarma); } Future _mostrarAlarmaSonando(AlarmaMusical alarma) async { final alarmas = context.read(); alarmas.marcarEjecucionGestionada(alarma); if (_alarmaSonandoActiva) { debugPrint( '[PluriWave][alarmas] alarma ignorada porque ya hay una activa id=${alarma.id} activa=$_alarmaSonandoId', ); // A duplicate delivery of the SAME ring's own fire event (the live // eventosAlarma stream and the one-shot obtenerEventoInicial() read // the same native event and can both reach here) must be a no-op. // When a genuinely DIFFERENT alarm fired while this one is active // (single-ring-at-a-time by design), hide ONLY its notification // (RES-1): ocultarNotificacionAlarma -> dismissAlarmNotification // unconditionally stops PluriWaveAlarmService, which would silently // kill the OTHER alarm's ring if it is the one genuinely sounding. if (alarma.id != _alarmaSonandoId) { await alarmas.android.ocultarSoloNotificacion(alarma.id); } return; } _alarmaSonandoActiva = true; _alarmaSonandoId = alarma.id; try { if (!mounted) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => PantallaAlarmaSonando(alarma: alarma), fullscreenDialog: true, ), ); } finally { if (_alarmaSonandoId == alarma.id) { _alarmaSonandoActiva = false; _alarmaSonandoId = null; } } } }