import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'estado/estado_radio.dart'; import 'estado/estado_alarmas.dart'; import 'estado/estado_idioma.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_inicio.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_wave_scaffold.dart'; import 'package:pluriwave/widgets/mini_reproductor.dart'; import 'servicios/servicio_alarmas_android.dart'; class PluriWaveApp extends StatelessWidget { const PluriWaveApp({super.key}); @override Widget build(BuildContext context) { return MultiProvider( providers: [ ChangeNotifierProvider(create: (_) => EstadoRadio()), ChangeNotifierProvider(create: (_) => EstadoAlarmas()), ChangeNotifierProvider(create: (_) => EstadoIdioma()), ], 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> { int _indice = 0; StreamSubscription? _errorSubscription; StreamSubscription? _alarmaSubscription; StreamSubscription? _alarmaVencidaSubscription; EstadoRadio? _estadoSuscrito; bool _alarmaInicialProcesada = false; bool _alarmaSonandoActiva = false; String? _alarmaSonandoId; 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 didChangeDependencies() { super.didChangeDependencies(); 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)); } } @override void dispose() { _errorSubscription?.cancel(); _alarmaSubscription?.cancel(); _alarmaVencidaSubscription?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return PluriWaveScaffold( appBar: AppBar( title: Text(l10n.appTitle), actions: [ IconButton( icon: const Icon(Icons.bedtime_outlined), tooltip: l10n.sleepTimer, onPressed: () => _mostrarTimerDialog(context), ), ], ), body: 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: Padding( padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), child: Column( mainAxisSize: MainAxisSize.min, children: [ const MiniReproductor(), PluriBottomNavigation( items: _navItems(l10n), selectedIndex: _indice, onSelected: (i) => setState(() => _indice = i), ), ], ), ), ), ); } Future _procesarAlarmaInicial(EstadoAlarmas alarmas) async { final evento = await alarmas.android.obtenerEventoInicial(); if (evento != null && mounted) { await _abrirAlarmaSonando(evento); } } Future _abrirAlarmaSonando(EventoAlarmaAndroid evento) async { final estado = context.read(); await estado.refrescarProgramacion(); AlarmaMusical? alarma; for (final item in estado.alarmas) { if (item.id == evento.alarmaId) { alarma = item; break; } } if (alarma == null || !mounted) return; if (evento.accion.endsWith('.SKIP_NEXT')) { await estado.saltarProxima(alarma.id); if (!mounted) return; setState(() => _indice = 3); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context).skipCurrentAlarmExecution( alarma.nombre, ), ), ), ); return; } if (evento.accion.endsWith('.PRE_NOTICE')) { setState(() => _indice = 3); 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', ); await alarmas.android.ocultarNotificacionAlarma(alarma.id); return; } _alarmaSonandoActiva = true; _alarmaSonandoId = alarma.id; try { await _prearrancarAudioAlarma(alarma); if (!mounted) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => PantallaAlarmaSonando( alarma: alarma, audioPrearrancado: alarma.emisora != null, ), fullscreenDialog: true, ), ); } finally { if (_alarmaSonandoId == alarma.id) { _alarmaSonandoActiva = false; _alarmaSonandoId = null; } } } Future _prearrancarAudioAlarma(AlarmaMusical alarma) async { final emisora = alarma.emisora; if (emisora == null) return; final radio = context.read(); debugPrint( '[PluriWave][alarmas] prearrancar emisora alarma id=${alarma.id} emisora=${emisora.nombre}', ); await radio.audio.setVolumen(alarma.volumen.clamp(0.0, 1.0)); unawaited(radio.reproducir(emisora)); } void _mostrarTimerDialog(BuildContext context) { showModalBottomSheet( context: context, showDragHandle: true, builder: (ctx) => Consumer( builder: (ctx, estado, _) => SafeArea( child: Padding( padding: PluriLayout.sheetPadding, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( AppLocalizations.of(ctx).sleepTimer, style: Theme.of(ctx).textTheme.titleLarge, ), const SizedBox(height: PluriLayout.sectionGap), Text( AppLocalizations.of(ctx).sleepTimerDescription, style: Theme.of(ctx).textTheme.bodySmall, ), const SizedBox(height: PluriLayout.panelGap), if (estado.timer.activo) StreamBuilder( stream: estado.timer.tiempoRestanteStream, builder: (ctx, snap) { final restante = snap.data ?? estado.timer.tiempoRestante; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( _formatearDuracionTimer(restante), style: Theme.of(ctx).textTheme.headlineMedium, ), const SizedBox(height: PluriLayout.compactGap), FilledButton.tonal( onPressed: () { estado.cancelarTimer(); Navigator.pop(ctx); }, child: Text( AppLocalizations.of(ctx).cancelTimer, ), ), ], ); }, ) else Wrap( spacing: PluriLayout.compactGap, runSpacing: PluriLayout.compactGap, children: [ for (final segundos in estado.timerSuenoPresetsSegundos) ActionChip( label: Text( _formatearDuracionTimer( Duration(seconds: segundos), ), ), onPressed: () { estado.iniciarTimerDuracion( Duration(seconds: segundos), ); Navigator.pop(ctx); }, ), ActionChip( avatar: const Icon(Icons.tune_rounded, size: 18), label: Text( AppLocalizations.of(ctx).optionOther, ), onPressed: () async { final duracion = await _pedirDuracionPersonalizada(ctx); if (duracion == null || !ctx.mounted) return; estado.iniciarTimerDuracion(duracion); Navigator.pop(ctx); }, ), ], ), ], ), ), ), ), ); } Future _pedirDuracionPersonalizada(BuildContext context) { return showModalBottomSheet( context: context, isScrollControlled: true, showDragHandle: true, builder: (ctx) => const _TimerPersonalizadoSheet(), ); } } String _formatearDuracionTimer(Duration duracion) { final horas = duracion.inHours; final minutos = duracion.inMinutes.remainder(60); final segundos = duracion.inSeconds.remainder(60); if (horas > 0) { return '${horas}h ${minutos.toString().padLeft(2, '0')}m ${segundos.toString().padLeft(2, '0')}s'; } if (minutos > 0) { return segundos == 0 ? '$minutos min' : '${minutos}m ${segundos}s'; } return '$segundos s'; } class _TimerPersonalizadoSheet extends StatefulWidget { const _TimerPersonalizadoSheet(); @override State<_TimerPersonalizadoSheet> createState() => _TimerPersonalizadoSheetState(); } class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> { final _horasCtrl = TextEditingController(); final _minutosCtrl = TextEditingController(text: '15'); final _segundosCtrl = TextEditingController(); bool _guardarPreset = true; @override void dispose() { _horasCtrl.dispose(); _minutosCtrl.dispose(); _segundosCtrl.dispose(); super.dispose(); } int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0; Future _confirmar() async { final duracion = Duration( hours: _leer(_horasCtrl), minutes: _leer(_minutosCtrl), seconds: _leer(_segundosCtrl), ); if (duracion <= Duration.zero) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( AppLocalizations.of(context).durationGreaterThanZero, ), ), ); return; } if (_guardarPreset) { await context.read().agregarTimerSuenoPreset(duracion); } if (mounted) Navigator.pop(context, duracion); } @override Widget build(BuildContext context) { final bottom = MediaQuery.viewInsetsOf(context).bottom; return SafeArea( child: Padding( padding: EdgeInsets.fromLTRB(18, 0, 18, 18 + bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( AppLocalizations.of(context).customDurationTitle, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: PluriLayout.sectionGap), Row( children: [ Expanded( child: _campoTiempo( _horasCtrl, AppLocalizations.of(context).hoursLabel, ), ), const SizedBox(width: PluriLayout.compactGap), Expanded( child: _campoTiempo( _minutosCtrl, AppLocalizations.of(context).minutesLabel, ), ), const SizedBox(width: PluriLayout.compactGap), Expanded( child: _campoTiempo( _segundosCtrl, AppLocalizations.of(context).secondsLabel, ), ), ], ), const SizedBox(height: PluriLayout.compactGap), SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, title: Text( AppLocalizations.of(context).saveQuickAccess, ), value: _guardarPreset, onChanged: (value) => setState(() => _guardarPreset = value), ), const SizedBox(height: PluriLayout.sectionGap), FilledButton.icon( icon: const Icon(Icons.bedtime_rounded), label: Text( AppLocalizations.of(context).startTimer, ), onPressed: _confirmar, ), ], ), ), ); } Widget _campoTiempo(TextEditingController controller, String label) { return TextField( controller: controller, keyboardType: TextInputType.number, decoration: InputDecoration( labelText: label, border: const OutlineInputBorder(), ), ); } }