feat(ui): refine navigation and sleep timer
Build & Deploy Pluriwave / Análisis de código (push) Successful in 21s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m19s

This commit is contained in:
2026-05-22 13:12:50 +02:00
parent 0edad1bfcb
commit e1d1d6c639
11 changed files with 755 additions and 124 deletions
+237 -103
View File
@@ -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<Offset>(
begin: const Offset(0.035, 0),
end: Offset.zero,
).animate(animation),
child: child,
),
),
child: KeyedSubtree(
key: ValueKey<int>(_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<EstadoRadio>();
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<Duration>(
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<EstadoRadio>(
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<Duration>(
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<Duration?> _pedirDuracionPersonalizada(BuildContext context) {
return showModalBottomSheet<Duration>(
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<void> _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<EstadoRadio>().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(),
),
);
}
}