diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..263e3bb --- /dev/null +++ b/TODO.md @@ -0,0 +1,12 @@ +# TODO + +## Internacionalización AAA + +- [ ] Repasar absolutamente todos los literales de la aplicación en todas las pantallas, componentes, servicios con mensajes visibles y notificaciones. +- [ ] Diseñar una arquitectura de internacionalización profesional con ficheros separados por idioma. +- [ ] Permitir que el usuario cambie el idioma manualmente desde la aplicación, sin depender únicamente del idioma del sistema. +- [ ] Soportar formatos locales de fecha, hora, números y duración. +- [ ] Resolver correctamente singular/plural y variantes por cantidad, por ejemplo `1 emisora` vs `2 emisoras`. +- [ ] Preparar traducciones para la mayoría de idiomas más usados del planeta. +- [ ] Revisar la aplicación de Farolero como referencia para detectar el conjunto de idiomas que nos interesa mantener. +- [ ] Verificar que no queda ningún literal hardcodeado fuera del sistema de traducciones. diff --git a/lib/app.dart b/lib/app.dart index d086cd3..c0d42bf 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -11,8 +11,9 @@ import 'pantallas/pantalla_buscar.dart'; import 'pantallas/pantalla_favoritos.dart'; import 'pantallas/pantalla_ajustes.dart'; import 'tema/pluriwave_theme.dart'; -import 'widgets/pluri_glass_surface.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'; @@ -62,45 +63,25 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { PantallaAjustes(), ]; - static const _destinos = [ - NavigationDestination( - icon: PluriIcon(glyph: PluriIconGlyph.home), - selectedIcon: PluriIcon( - glyph: PluriIconGlyph.home, - variant: PluriIconVariant.activeGlow, - ), + static const _navItems = [ + PluriNavItem( + glyph: PluriIconGlyph.home, label: 'Inicio', ), - NavigationDestination( - icon: PluriIcon(glyph: PluriIconGlyph.search), - selectedIcon: PluriIcon( - glyph: PluriIconGlyph.search, - variant: PluriIconVariant.activeGlow, - ), + PluriNavItem( + glyph: PluriIconGlyph.search, label: 'Buscar', ), - NavigationDestination( - icon: PluriIcon(glyph: PluriIconGlyph.favorites), - selectedIcon: PluriIcon( - glyph: PluriIconGlyph.favorites, - variant: PluriIconVariant.activeGlow, - ), + PluriNavItem( + glyph: PluriIconGlyph.favorites, label: 'Favoritos', ), - NavigationDestination( - icon: PluriIcon(glyph: PluriIconGlyph.alarm), - selectedIcon: PluriIcon( - glyph: PluriIconGlyph.alarm, - variant: PluriIconVariant.activeGlow, - ), + PluriNavItem( + glyph: PluriIconGlyph.alarm, label: 'Alarmas', ), - NavigationDestination( - icon: PluriIcon(glyph: PluriIconGlyph.settings), - selectedIcon: PluriIcon( - glyph: PluriIconGlyph.settings, - variant: PluriIconVariant.activeGlow, - ), + PluriNavItem( + glyph: PluriIconGlyph.settings, label: 'Ajustes', ), ]; @@ -158,30 +139,47 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { actions: [ IconButton( icon: const Icon(Icons.bedtime_outlined), - tooltip: 'Timer de sueno', + tooltip: 'Timer de sueño', onPressed: () => _mostrarTimerDialog(context), ), ], ), - body: SafeArea(top: false, child: _paginas[_indice]), + 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: 8), + minimum: const EdgeInsets.only(bottom: PluriLayout.compactGap), child: Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 0), + padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), child: Column( mainAxisSize: MainAxisSize.min, children: [ const MiniReproductor(), - PluriGlassSurface( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), - borderRadius: BorderRadius.circular(999), - child: NavigationBar( - selectedIndex: _indice, - height: 66, - onDestinationSelected: (i) => setState(() => _indice = i), - destinations: _destinos, - ), + PluriBottomNavigation( + items: _navItems, + selectedIndex: _indice, + onSelected: (i) => setState(() => _indice = i), ), ], ), @@ -241,74 +239,210 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { } void _mostrarTimerDialog(BuildContext context) { - final estado = context.read(); showModalBottomSheet( context: context, + showDragHandle: true, builder: - (ctx) => SafeArea( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Timer de sueño', - style: Theme.of(ctx).textTheme.titleLarge, - ), - const SizedBox(height: 16), - if (estado.timer.activo) - StreamBuilder( - stream: estado.timer.tiempoRestanteStream, - builder: (ctx, snap) { - final t = snap.data ?? Duration.zero; - final h = t.inHours; - final m = t.inMinutes - .remainder(60) - .toString() - .padLeft(2, '0'); - final s = t.inSeconds - .remainder(60) - .toString() - .padLeft(2, '0'); - return Column( - children: [ - Text( - '${h > 0 ? "${h}h " : ""}${m}m ${s}s', - style: Theme.of(ctx).textTheme.headlineMedium, - ), - const SizedBox(height: 8), - FilledButton.tonal( + (ctx) => Consumer( + builder: (ctx, estado, _) => SafeArea( + child: Padding( + padding: PluriLayout.sheetPadding, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Timer de sueño', + style: Theme.of(ctx).textTheme.titleLarge, + ), + const SizedBox(height: PluriLayout.sectionGap), + Text( + 'Apagado suave de la radio con cuenta atrás exacta.', + 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: const Text('Cancelar timer'), + ), + ], + ); + }, + ) + else + Wrap( + spacing: PluriLayout.compactGap, + runSpacing: PluriLayout.compactGap, + children: [ + for (final segundos + in estado.timerSuenoPresetsSegundos) + ActionChip( + label: Text( + _formatearDuracionTimer( + Duration(seconds: segundos), + ), + ), onPressed: () { - estado.cancelarTimer(); + estado.iniciarTimerDuracion( + Duration(seconds: segundos), + ); Navigator.pop(ctx); }, - child: const Text('Cancelar timer'), ), - ], - ); - }, - ) - else - Wrap( - spacing: 8, - children: - [3, 5, 10, 15, 30, 60, 90, 120, 180] - .map( - (min) => ActionChip( - label: Text('$min min'), - onPressed: () { - estado.iniciarTimer(min); - Navigator.pop(ctx); - }, - ), - ) - .toList(), - ), - ], + ActionChip( + avatar: const Icon(Icons.tune_rounded, size: 18), + label: const Text('Otro'), + 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( + const SnackBar(content: Text('Elegí una duración mayor que cero.')), + ); + 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( + 'Duración personalizada', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: PluriLayout.sectionGap), + Row( + children: [ + Expanded(child: _campoTiempo(_horasCtrl, 'Horas')), + const SizedBox(width: PluriLayout.compactGap), + Expanded(child: _campoTiempo(_minutosCtrl, 'Minutos')), + const SizedBox(width: PluriLayout.compactGap), + Expanded(child: _campoTiempo(_segundosCtrl, 'Segundos')), + ], + ), + const SizedBox(height: PluriLayout.compactGap), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('Guardar como acceso rápido'), + value: _guardarPreset, + onChanged: (value) => setState(() => _guardarPreset = value), + ), + const SizedBox(height: PluriLayout.sectionGap), + FilledButton.icon( + icon: const Icon(Icons.bedtime_rounded), + label: const Text('Iniciar timer'), + onPressed: _confirmar, + ), + ], + ), + ), + ); + } + + Widget _campoTiempo(TextEditingController controller, String label) { + return TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + ); + } } diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index 85a27aa..ec10903 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -87,6 +87,21 @@ class EstadoRadio extends ChangeNotifier { String? _ultimoTagBusqueda; String? _errorCarga; static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1'; + static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1'; + static const _timerSuenoPresetsDefecto = [ + 180, + 300, + 600, + 900, + 1800, + 3600, + 5400, + 7200, + 10800, + ]; + List _timerSuenoPresetsSegundos = List.from( + _timerSuenoPresetsDefecto, + ); List get populares => _populares; List get tendencias => _tendencias; @@ -110,6 +125,8 @@ class EstadoRadio extends ChangeNotifier { PresetEcualizador get presetPrincipalEcualizador => _presetPrincipal; bool get ecualizadorActivo => _ecualizadorActivo; bool get ecualizadorDisponible => audio.ecualizadorDisponible; + List get timerSuenoPresetsSegundos => + List.unmodifiable(_timerSuenoPresetsSegundos); bool get emisoraActualEsFavorita { final actual = emisoraActual; @@ -171,6 +188,7 @@ class EstadoRadio extends ChangeNotifier { await grabacion.inicializar(); await _cargarEcualizadorPersistido(); await _cargarEmisoraPreferida(); + await _cargarTimerSuenoPresets(); await Future.wait([ cargarPopulares(), cargarFavoritos(), @@ -260,6 +278,29 @@ class EstadoRadio extends ChangeNotifier { await reproducir(preferida); } + Future _cargarTimerSuenoPresets() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_keyTimerSuenoPresets); + if (raw == null) return; + final decoded = jsonDecode(raw); + if (decoded is! List) return; + final presets = + decoded + .whereType() + .map((n) => n.toInt()) + .where((s) => s > 0) + .toSet() + .toList() + ..sort(); + if (presets.isNotEmpty) { + _timerSuenoPresetsSegundos = presets.take(12).toList(); + } + } catch (_) { + _timerSuenoPresetsSegundos = List.from(_timerSuenoPresetsDefecto); + } + } + Future _cargarEmisoraPreferida() async { final prefs = await SharedPreferences.getInstance(); _emisoraPreferidaUuid = prefs.getString(_keyEmisoraPreferida); @@ -749,11 +790,56 @@ class EstadoRadio extends ChangeNotifier { notifyListeners(); } + void iniciarTimerDuracion(Duration duracion) { + timer.iniciarDuracion(duracion); + notifyListeners(); + } + void cancelarTimer() { unawaited(timer.cancelar()); notifyListeners(); } + Future guardarTimerSuenoPresetsSegundos(List segundos) async { + final normalizados = + segundos + .where((s) => s > 0) + .map((s) => s.clamp(1, const Duration(hours: 23).inSeconds)) + .toSet() + .toList() + ..sort(); + _timerSuenoPresetsSegundos = + normalizados.isEmpty + ? List.from(_timerSuenoPresetsDefecto) + : normalizados.take(12).toList(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _keyTimerSuenoPresets, + jsonEncode(_timerSuenoPresetsSegundos), + ); + notifyListeners(); + } + + Future agregarTimerSuenoPreset(Duration duracion) async { + await guardarTimerSuenoPresetsSegundos([ + ..._timerSuenoPresetsSegundos, + duracion.inSeconds, + ]); + } + + Future eliminarTimerSuenoPreset(int segundos) async { + await guardarTimerSuenoPresetsSegundos( + _timerSuenoPresetsSegundos.where((s) => s != segundos).toList(), + ); + } + + Future restaurarTimerSuenoPresets() async { + _timerSuenoPresetsSegundos = List.from(_timerSuenoPresetsDefecto); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyTimerSuenoPresets); + notifyListeners(); + } + @override void dispose() { _suscripcionEstadoAudio?.cancel(); diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 48737ab..7b97fb7 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -14,6 +14,7 @@ import '../modelos/emisora.dart'; import '../widgets/ecualizador_widget.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; +import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; class PantallaAjustes extends StatelessWidget { @@ -22,7 +23,7 @@ class PantallaAjustes extends StatelessWidget { @override Widget build(BuildContext context) { return ListView( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 124), + padding: PluriLayout.pageListPadding, children: const [ PluriScreenHeader( title: 'Ajustes', @@ -35,7 +36,7 @@ class PantallaAjustes extends StatelessWidget { ), ), Padding( - padding: EdgeInsets.symmetric(horizontal: 16), + padding: PluriLayout.pageContentPadding, child: _AjustesContent(), ), ], @@ -54,6 +55,8 @@ class _AjustesContent extends StatelessWidget { SizedBox(height: 12), _SeccionGrabaciones(), SizedBox(height: 12), + _SeccionTimerSueno(), + SizedBox(height: 12), _SeccionEmisoraPreferida(), SizedBox(height: 12), _SeccionEmisoras(), @@ -158,6 +161,177 @@ class _SeccionGrabaciones extends StatelessWidget { } } +class _SeccionTimerSueno extends StatelessWidget { + const _SeccionTimerSueno(); + + Future _anadirPreset(BuildContext context) async { + final duracion = await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (_) => const _FormularioDuracionTimer(), + ); + if (duracion == null || !context.mounted) return; + await context.read().agregarTimerSuenoPreset(duracion); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Acceso rápido añadido: ${_formatearDuracionTimer(duracion)}', + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final estado = context.watch(); + final presets = estado.timerSuenoPresetsSegundos; + return PluriGlassSurface( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.bedtime_rounded), + const SizedBox(width: 12), + Text( + 'Timer de sueño', + style: Theme.of(context).textTheme.titleMedium, + ), + const Spacer(), + TextButton.icon( + icon: const Icon(Icons.add_rounded), + label: const Text('Añadir'), + onPressed: () => _anadirPreset(context), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Personalizá los accesos rápidos que aparecen al apagar la radio automáticamente.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final segundos in presets) + InputChip( + label: Text( + _formatearDuracionTimer(Duration(seconds: segundos)), + ), + onDeleted: + presets.length <= 1 + ? null + : () => context + .read() + .eliminarTimerSuenoPreset(segundos), + ), + ], + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + icon: const Icon(Icons.restore_rounded), + label: const Text('Restaurar tiempos recomendados'), + onPressed: + () => context.read().restaurarTimerSuenoPresets(), + ), + ), + ], + ), + ); + } +} + +class _FormularioDuracionTimer extends StatefulWidget { + const _FormularioDuracionTimer(); + + @override + State<_FormularioDuracionTimer> createState() => _FormularioDuracionTimerState(); +} + +class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> { + final _horasCtrl = TextEditingController(); + final _minutosCtrl = TextEditingController(text: '15'); + final _segundosCtrl = TextEditingController(); + + @override + void dispose() { + _horasCtrl.dispose(); + _minutosCtrl.dispose(); + _segundosCtrl.dispose(); + super.dispose(); + } + + int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0; + + void _guardar() { + final duracion = Duration( + hours: _leer(_horasCtrl), + minutes: _leer(_minutosCtrl), + seconds: _leer(_segundosCtrl), + ); + if (duracion <= Duration.zero) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Elegí una duración mayor que cero.')), + ); + return; + } + 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( + 'Nuevo acceso rápido', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded(child: _campo(_horasCtrl, 'Horas')), + const SizedBox(width: 8), + Expanded(child: _campo(_minutosCtrl, 'Minutos')), + const SizedBox(width: 8), + Expanded(child: _campo(_segundosCtrl, 'Segundos')), + ], + ), + const SizedBox(height: 16), + FilledButton.icon( + icon: const Icon(Icons.save_rounded), + label: const Text('Guardar acceso rápido'), + onPressed: _guardar, + ), + ], + ), + ), + ); + } + + Widget _campo(TextEditingController controller, String label) { + return TextField( + controller: controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + ), + ); + } +} + class _SeccionEcualizador extends StatelessWidget { const _SeccionEcualizador(); @@ -463,7 +637,7 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> { Widget build(BuildContext context) { final bottom = MediaQuery.of(context).viewInsets.bottom; return Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottom), + padding: EdgeInsets.fromLTRB(PluriLayout.horizontal, PluriLayout.horizontal, PluriLayout.horizontal, PluriLayout.horizontal + bottom), child: Form( key: _formKey, child: Column( @@ -711,3 +885,16 @@ class _SeccionInfo extends StatelessWidget { ); } } + +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'; +} diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index 66abd67..460c601 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -7,6 +7,7 @@ import '../modelos/alarma_musical.dart'; import '../modelos/emisora.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; +import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; class PantallaAlarmas extends StatelessWidget { @@ -19,7 +20,7 @@ class PantallaAlarmas extends StatelessWidget { return RefreshIndicator( onRefresh: estado.refrescarProgramacion, child: ListView( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 124), + padding: PluriLayout.pageListPadding, children: [ PluriScreenHeader( title: 'Despertar musical', @@ -34,7 +35,7 @@ class PantallaAlarmas extends StatelessWidget { ), ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + padding: PluriLayout.pageContentPadding, child: Column( children: [ _PanelProximaAlarma(estado: estado), diff --git a/lib/pantallas/pantalla_buscar.dart b/lib/pantallas/pantalla_buscar.dart index e061763..3855793 100644 --- a/lib/pantallas/pantalla_buscar.dart +++ b/lib/pantallas/pantalla_buscar.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../estado/estado_radio.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; +import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; @@ -68,7 +69,7 @@ class _PantallaBuscarState extends State { final theme = Theme.of(context); return ListView( - padding: const EdgeInsets.fromLTRB(0, 0, 0, 124), + padding: PluriLayout.pageListPadding, children: [ PluriScreenHeader( title: 'Buscar senal', @@ -80,7 +81,7 @@ class _PantallaBuscarState extends State { ), ), Padding( - padding: const EdgeInsets.fromLTRB(16, 10, 16, 0), + padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 10, PluriLayout.horizontal, 0), child: PluriGlassSurface( padding: const EdgeInsets.all(10), borderRadius: BorderRadius.circular(999), @@ -135,7 +136,7 @@ class _PantallaBuscarState extends State { final theme = Theme.of(context); final pais = estado.paisCercanoDetectado; return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0), child: PluriGlassSurface( padding: const EdgeInsets.all(12), child: Column( @@ -209,7 +210,7 @@ class _PantallaBuscarState extends State { ) { final theme = Theme.of(context); return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0), child: PluriGlassSurface( padding: const EdgeInsets.all(10), child: Column( @@ -277,7 +278,7 @@ class _PantallaBuscarState extends State { return ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + padding: const EdgeInsets.all(PluriLayout.horizontal), itemCount: total, separatorBuilder: (_, __) => const SizedBox(height: 10), itemBuilder: (context, i) { diff --git a/lib/pantallas/pantalla_favoritos.dart b/lib/pantallas/pantalla_favoritos.dart index 7c605e9..bbaf9d0 100644 --- a/lib/pantallas/pantalla_favoritos.dart +++ b/lib/pantallas/pantalla_favoritos.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../estado/estado_radio.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; +import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; @@ -19,7 +20,7 @@ class PantallaFavoritos extends StatelessWidget { if (favoritos.isEmpty) { return ListView( - padding: EdgeInsets.fromLTRB(0, 0, 0, 124), + padding: PluriLayout.pageListPadding, children: [ PluriScreenHeader( title: 'Favoritos', @@ -56,7 +57,7 @@ class PantallaFavoritos extends StatelessWidget { ), ), SliverPadding( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 124), + padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 4, PluriLayout.horizontal, PluriLayout.bottomChromeInset), sliver: SliverReorderableList( proxyDecorator: (child, index, animation) => ScaleTransition( scale: Tween(begin: 1, end: 1.03).animate(animation), diff --git a/lib/pantallas/pantalla_inicio.dart b/lib/pantallas/pantalla_inicio.dart index 1b738a2..b0f403b 100644 --- a/lib/pantallas/pantalla_inicio.dart +++ b/lib/pantallas/pantalla_inicio.dart @@ -4,9 +4,9 @@ import 'package:provider/provider.dart'; import 'package:shimmer/shimmer.dart' as shimmer; import '../estado/estado_radio.dart'; -import '../tema/pluriwave_theme.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; +import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; import 'package:pluriwave/widgets/tarjeta_emisora.dart'; @@ -41,7 +41,6 @@ class _PantallaInicioState extends State { Widget build(BuildContext context) { final estado = context.watch(); final theme = Theme.of(context); - final t = context.pluriTokens; return RefreshIndicator( onRefresh: estado.cargarPopulares, @@ -54,7 +53,7 @@ class _PantallaInicioState extends State { if (estado.error != null) SliverToBoxAdapter(child: _errorBanner(estado, theme)), SliverPadding( - padding: EdgeInsets.fromLTRB(t.spacingMd, 0, t.spacingMd, 124), + padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 0, PluriLayout.horizontal, PluriLayout.bottomChromeInset), sliver: _gridEmisoras(estado), ), ], @@ -90,7 +89,7 @@ class _PantallaInicioState extends State { Widget _seccionCercanas(EstadoRadio estado, ThemeData theme) { final pais = estado.paisCercanoDetectado; return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0), child: PluriGlassSurface( padding: const EdgeInsets.all(12), child: Column( @@ -158,7 +157,7 @@ class _PantallaInicioState extends State { Widget _seccionTendencias(EstadoRadio estado, ThemeData theme) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 8, PluriLayout.horizontal, 0), child: PluriGlassSurface( padding: const EdgeInsets.all(12), child: Column( @@ -202,7 +201,7 @@ class _PantallaInicioState extends State { Widget _chipGeneros(BuildContext context, ThemeData theme) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + padding: const EdgeInsets.fromLTRB(PluriLayout.horizontal, 16, PluriLayout.horizontal, 8), child: PluriGlassSurface( padding: const EdgeInsets.all(12), child: Column( @@ -287,7 +286,7 @@ class _PantallaInicioState extends State { child: PluriEmptyState( glyph: PluriIconGlyph.home, title: 'No hay emisoras disponibles', - subtitle: 'Proba refrescar o elegir otro g?nero para volver a capturar se?al.', + subtitle: 'Proba refrescar o elegir otro género para volver a capturar señal.', ), ); } diff --git a/lib/servicios/servicio_timer.dart b/lib/servicios/servicio_timer.dart index 20892ba..ba0ecb8 100644 --- a/lib/servicios/servicio_timer.dart +++ b/lib/servicios/servicio_timer.dart @@ -1,7 +1,20 @@ import 'dart:async'; import 'servicio_audio.dart'; -/// Opciones predefinidas de timer en minutos. +/// Opciones predefinidas de timer en segundos. +const opcionesTimerSegundos = [ + 180, + 300, + 600, + 900, + 1800, + 3600, + 5400, + 7200, + 10800, +]; + +/// Compatibilidad con el reproductor completo, que aún muestra minutos. const opcionesTimer = [3, 5, 10, 15, 30, 60, 90, 120, 180]; /// Servicio de auto-apagado de la radio. @@ -34,8 +47,13 @@ class ServicioTimer { /// Inicia el timer para [minutos] minutos. void iniciar(int minutos) { + iniciarDuracion(Duration(minutes: minutos)); + } + + /// Inicia el timer para una duración exacta. + void iniciarDuracion(Duration duracion) { + if (duracion <= Duration.zero) return; unawaited(cancelar()); - final duracion = Duration(minutes: minutos); _finAt = DateTime.now().add(duracion); _tiempoRestante = duracion; _activo = true; diff --git a/lib/widgets/pluri_bottom_navigation.dart b/lib/widgets/pluri_bottom_navigation.dart new file mode 100644 index 0000000..5c7bb6c --- /dev/null +++ b/lib/widgets/pluri_bottom_navigation.dart @@ -0,0 +1,148 @@ +import 'package:flutter/material.dart'; + +import '../tema/pluriwave_theme.dart'; +import 'pluri_glass_surface.dart'; +import 'pluri_icon.dart'; + +class PluriNavItem { + const PluriNavItem({required this.glyph, required this.label}); + + final PluriIconGlyph glyph; + final String label; +} + +class PluriBottomNavigation extends StatelessWidget { + const PluriBottomNavigation({ + super.key, + required this.items, + required this.selectedIndex, + required this.onSelected, + }); + + final List items; + final int selectedIndex; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final t = context.pluriTokens; + return PluriGlassSurface( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 7), + borderRadius: BorderRadius.circular(999), + glowColor: t.glowColor.withValues(alpha: 0.28), + child: Row( + children: [ + for (var i = 0; i < items.length; i++) + Expanded( + flex: i == selectedIndex ? 15 : 10, + child: _PluriNavButton( + item: items[i], + selected: i == selectedIndex, + onTap: () => onSelected(i), + ), + ), + ], + ), + ); + } +} + +class _PluriNavButton extends StatelessWidget { + const _PluriNavButton({ + required this.item, + required this.selected, + required this.onTap, + }); + + final PluriNavItem item; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final t = context.pluriTokens; + final foreground = Theme.of(context).colorScheme.onSurface; + return Semantics( + button: true, + selected: selected, + label: item.label, + child: AnimatedContainer( + duration: context.pluriMotion.normal, + curve: Curves.easeOutCubic, + margin: EdgeInsets.symmetric(horizontal: selected ? 3 : 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + gradient: selected + ? LinearGradient( + colors: [ + t.electricMagenta.withValues(alpha: 0.32), + t.warmCoral.withValues(alpha: 0.18), + ], + ) + : null, + border: Border.all( + color: selected + ? Colors.white.withValues(alpha: 0.22) + : Colors.white.withValues(alpha: 0.06), + ), + boxShadow: selected + ? [ + BoxShadow( + color: t.glowColor.withValues(alpha: 0.36), + blurRadius: 24, + spreadRadius: -6, + offset: const Offset(0, 8), + ), + ] + : const [], + ), + child: InkWell( + borderRadius: BorderRadius.circular(999), + onTap: onTap, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: selected ? 8 : 3, + vertical: selected ? 8 : 7, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedScale( + scale: selected ? 1.16 : 0.96, + duration: context.pluriMotion.quick, + curve: Curves.easeOutBack, + child: PluriIcon( + glyph: item.glyph, + variant: selected + ? PluriIconVariant.activeGlow + : PluriIconVariant.filled, + size: selected ? 42 : 34, + ), + ), + AnimatedSize( + duration: context.pluriMotion.quick, + curve: Curves.easeOutCubic, + child: selected + ? Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + item.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w900, + letterSpacing: -0.2, + ), + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/pluri_layout.dart b/lib/widgets/pluri_layout.dart new file mode 100644 index 0000000..1723035 --- /dev/null +++ b/lib/widgets/pluri_layout.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../tema/pluriwave_theme.dart'; + +abstract final class PluriLayout { + static const double horizontal = 16; + static const double sectionGap = 12; + static const double panelGap = 12; + static const double compactGap = 8; + static const double bottomChromeInset = 146; + + static const EdgeInsets pageListPadding = EdgeInsets.fromLTRB( + 0, + 0, + 0, + bottomChromeInset, + ); + + static const EdgeInsets pageContentPadding = EdgeInsets.symmetric( + horizontal: horizontal, + ); + + static const EdgeInsets sheetPadding = EdgeInsets.all(18); +} + +class PluriPanelColumn extends StatelessWidget { + const PluriPanelColumn({super.key, required this.children, this.gap}); + + final List children; + final double? gap; + + @override + Widget build(BuildContext context) { + final resolvedGap = gap ?? context.pluriTokens.spacingSm + 4; + return Column( + children: [ + for (var i = 0; i < children.length; i++) ...[ + if (i > 0) SizedBox(height: resolvedGap), + children[i], + ], + ], + ); + } +}