Files
pluriwave/lib/app.dart
T
FreeTLab a3a648c633
Build & Deploy Pluriwave / Análisis de código (push) Successful in 15s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 4m21s
feat(alarm): complete musical alarm flows
2026-05-22 00:40:01 +02:00

297 lines
9.2 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'estado/estado_radio.dart';
import 'estado/estado_alarmas.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_glass_surface.dart';
import 'widgets/pluri_icon.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()),
],
child: MaterialApp(
title: 'PluriWave',
debugShowCheckedModeBanner: false,
theme: PluriWaveTheme.dark(),
darkTheme: PluriWaveTheme.dark(),
themeMode: ThemeMode.dark,
home: const _PaginaPrincipal(),
),
);
}
}
class _PaginaPrincipal extends StatefulWidget {
const _PaginaPrincipal();
@override
State<_PaginaPrincipal> createState() => _PaginaPrincipalState();
}
class _PaginaPrincipalState extends State<_PaginaPrincipal> {
int _indice = 0;
StreamSubscription<String>? _errorSubscription;
StreamSubscription<EventoAlarmaAndroid>? _alarmaSubscription;
EstadoRadio? _estadoSuscrito;
bool _alarmaInicialProcesada = false;
static const _paginas = [
PantallaInicio(),
PantallaBuscar(),
PantallaFavoritos(),
PantallaAlarmas(),
PantallaAjustes(),
];
static const _destinos = [
NavigationDestination(
icon: PluriIcon(glyph: PluriIconGlyph.home),
selectedIcon: PluriIcon(
glyph: PluriIconGlyph.home,
variant: PluriIconVariant.activeGlow,
),
label: 'Inicio',
),
NavigationDestination(
icon: PluriIcon(glyph: PluriIconGlyph.search),
selectedIcon: PluriIcon(
glyph: PluriIconGlyph.search,
variant: PluriIconVariant.activeGlow,
),
label: 'Buscar',
),
NavigationDestination(
icon: PluriIcon(glyph: PluriIconGlyph.favorites),
selectedIcon: PluriIcon(
glyph: PluriIconGlyph.favorites,
variant: PluriIconVariant.activeGlow,
),
label: 'Favoritos',
),
NavigationDestination(
icon: PluriIcon(glyph: PluriIconGlyph.alarm),
selectedIcon: PluriIcon(
glyph: PluriIconGlyph.alarm,
variant: PluriIconVariant.activeGlow,
),
label: 'Alarmas',
),
NavigationDestination(
icon: PluriIcon(glyph: PluriIconGlyph.settings),
selectedIcon: PluriIcon(
glyph: PluriIconGlyph.settings,
variant: PluriIconVariant.activeGlow,
),
label: 'Ajustes',
),
];
@override
void didChangeDependencies() {
super.didChangeDependencies();
final estado = context.read<EstadoRadio>();
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: 'OK', onPressed: () {}),
),
);
});
final alarmas = context.read<EstadoAlarmas>();
_alarmaSubscription ??= alarmas.android.eventosAlarma.listen((evento) {
if (!mounted) return;
_abrirAlarmaSonando(evento);
});
if (!_alarmaInicialProcesada) {
_alarmaInicialProcesada = true;
unawaited(_procesarAlarmaInicial(alarmas));
}
}
@override
void dispose() {
_errorSubscription?.cancel();
_alarmaSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return PluriWaveScaffold(
appBar: AppBar(
title: const Text('PluriWave'),
actions: [
IconButton(
icon: const Icon(Icons.bedtime_outlined),
tooltip: 'Timer de sueno',
onPressed: () => _mostrarTimerDialog(context),
),
],
),
body: SafeArea(top: false, child: _paginas[_indice]),
bottomNavigationBar: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
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,
),
),
],
),
),
),
);
}
Future<void> _procesarAlarmaInicial(EstadoAlarmas alarmas) async {
final evento = await alarmas.android.obtenerEventoInicial();
if (evento != null && mounted) {
await _abrirAlarmaSonando(evento);
}
}
Future<void> _abrirAlarmaSonando(EventoAlarmaAndroid evento) async {
final estado = context.read<EstadoAlarmas>();
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('Omitida esta ejecución de ${alarma.nombre}.'),
),
);
return;
}
if (evento.accion.endsWith('.PRE_NOTICE')) {
setState(() => _indice = 3);
return;
}
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PantallaAlarmaSonando(alarma: alarma!),
fullscreenDialog: true,
),
);
}
void _mostrarTimerDialog(BuildContext context) {
final estado = context.read<EstadoRadio>();
showModalBottomSheet(
context: context,
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(
onPressed: () {
estado.cancelarTimer();
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(),
),
],
),
),
),
);
}
}