feat(alarm): add musical alarm foundation
Build & Deploy Pluriwave / Análisis de código (push) Successful in 14s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m45s

This commit is contained in:
2026-05-21 23:46:52 +02:00
parent 8c2cba093c
commit fb808ebb60
30 changed files with 1437 additions and 43 deletions
+16 -2
View File
@@ -2,6 +2,8 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'estado/estado_radio.dart';
import 'estado/estado_alarmas.dart';
import 'pantallas/pantalla_alarmas.dart';
import 'pantallas/pantalla_inicio.dart';
import 'pantallas/pantalla_buscar.dart';
import 'pantallas/pantalla_favoritos.dart';
@@ -17,8 +19,11 @@ class PluriWaveApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => EstadoRadio(),
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => EstadoRadio()),
ChangeNotifierProvider(create: (_) => EstadoAlarmas()),
],
child: MaterialApp(
title: 'PluriWave',
debugShowCheckedModeBanner: false,
@@ -47,6 +52,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
PantallaInicio(),
PantallaBuscar(),
PantallaFavoritos(),
PantallaAlarmas(),
PantallaAjustes(),
];
@@ -75,6 +81,14 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
),
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(
+118
View File
@@ -0,0 +1,118 @@
import 'package:flutter/foundation.dart';
import '../modelos/alarma_musical.dart';
import '../servicios/servicio_alarmas.dart';
import '../servicios/servicio_alarmas_android.dart';
class EstadoAlarmas extends ChangeNotifier {
EstadoAlarmas({
ServicioAlarmas? servicio,
ServicioAlarmasAndroid? android,
bool iniciarAutomaticamente = true,
}) : servicio = servicio ?? ServicioAlarmas(),
android = android ?? ServicioAlarmasAndroid() {
if (iniciarAutomaticamente) {
inicializar();
}
}
final ServicioAlarmas servicio;
final ServicioAlarmasAndroid android;
List<AlarmaMusical> _alarmas = [];
List<RangoVacaciones> _vacaciones = [];
DiagnosticoAlarmasAndroid? _diagnostico;
bool _cargando = false;
String? _error;
List<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones);
DiagnosticoAlarmasAndroid? get diagnostico => _diagnostico;
bool get cargando => _cargando;
String? get error => _error;
AlarmaMusical? get proximaAlarma {
final candidatas =
_alarmas.where((a) => a.activa && a.proximaEjecucion != null).toList()
..sort((a, b) => a.proximaEjecucion!.compareTo(b.proximaEjecucion!));
return candidatas.isEmpty ? null : candidatas.first;
}
Future<void> inicializar() async {
_cargando = true;
_error = null;
notifyListeners();
try {
final config = await servicio.cargar();
_aplicar(config);
await _sincronizarTodas();
await cargarDiagnostico();
} catch (e) {
_error = 'No se pudieron cargar las alarmas: $e';
} finally {
_cargando = false;
notifyListeners();
}
}
Future<void> guardarAlarma(AlarmaMusical alarma) async {
final config = await servicio.guardarAlarma(alarma);
_aplicar(config);
await android.programar(_alarmas.firstWhere((a) => a.id == alarma.id));
notifyListeners();
}
Future<void> eliminarAlarma(String id) async {
final config = await servicio.eliminarAlarma(id);
_aplicar(config);
await android.cancelar(id);
notifyListeners();
}
Future<void> cambiarActiva(AlarmaMusical alarma, bool activa) async {
await guardarAlarma(alarma.copyWith(activa: activa));
}
Future<void> saltarProxima(String alarmaId) async {
final config = await servicio.saltarProxima(alarmaId);
_aplicar(config);
AlarmaMusical? alarma;
for (final item in _alarmas) {
if (item.id == alarmaId) {
alarma = item;
break;
}
}
if (alarma != null) {
await android.programar(alarma);
}
notifyListeners();
}
Future<void> guardarVacaciones(List<RangoVacaciones> vacaciones) async {
final config = await servicio.guardarVacaciones(vacaciones);
_aplicar(config);
await _sincronizarTodas();
notifyListeners();
}
Future<void> cargarDiagnostico() async {
try {
_diagnostico = await android.diagnostico();
} catch (_) {
_diagnostico = null;
}
notifyListeners();
}
Future<void> _sincronizarTodas() async {
for (final alarma in _alarmas) {
await android.programar(alarma);
}
}
void _aplicar(ConfiguracionAlarmas config) {
_alarmas = config.alarmas;
_vacaciones = config.vacaciones;
}
}
+219
View File
@@ -0,0 +1,219 @@
import 'emisora.dart';
enum TipoProgramacionAlarma { unica, diaria, diasSemana }
enum SonidoInternoAlarma { amanecer, campanaSuave, pulsoDigital }
class AlarmaMusical {
const AlarmaMusical({
required this.id,
required this.nombre,
required this.hora,
required this.minuto,
required this.tipoProgramacion,
required this.diasSemana,
this.emisora,
this.emisoraFallback,
this.activa = true,
this.sonarEnVacaciones = true,
this.snoozeMinutos = 5,
this.volumen = 0.85,
this.sonidoInterno = SonidoInternoAlarma.amanecer,
this.proximaEjecucion,
this.creadaEn,
this.actualizadaEn,
});
final String id;
final String nombre;
final bool activa;
final int hora;
final int minuto;
final TipoProgramacionAlarma tipoProgramacion;
final List<int> diasSemana;
final Emisora? emisora;
final Emisora? emisoraFallback;
final bool sonarEnVacaciones;
final int snoozeMinutos;
final double volumen;
final SonidoInternoAlarma sonidoInterno;
final DateTime? proximaEjecucion;
final DateTime? creadaEn;
final DateTime? actualizadaEn;
AlarmaMusical copyWith({
String? id,
String? nombre,
bool? activa,
int? hora,
int? minuto,
TipoProgramacionAlarma? tipoProgramacion,
List<int>? diasSemana,
Emisora? emisora,
Emisora? emisoraFallback,
bool? sonarEnVacaciones,
int? snoozeMinutos,
double? volumen,
SonidoInternoAlarma? sonidoInterno,
DateTime? proximaEjecucion,
DateTime? creadaEn,
DateTime? actualizadaEn,
}) {
return AlarmaMusical(
id: id ?? this.id,
nombre: nombre ?? this.nombre,
activa: activa ?? this.activa,
hora: hora ?? this.hora,
minuto: minuto ?? this.minuto,
tipoProgramacion: tipoProgramacion ?? this.tipoProgramacion,
diasSemana: diasSemana ?? this.diasSemana,
emisora: emisora ?? this.emisora,
emisoraFallback: emisoraFallback ?? this.emisoraFallback,
sonarEnVacaciones: sonarEnVacaciones ?? this.sonarEnVacaciones,
snoozeMinutos: snoozeMinutos ?? this.snoozeMinutos,
volumen: volumen ?? this.volumen,
sonidoInterno: sonidoInterno ?? this.sonidoInterno,
proximaEjecucion: proximaEjecucion ?? this.proximaEjecucion,
creadaEn: creadaEn ?? this.creadaEn,
actualizadaEn: actualizadaEn ?? this.actualizadaEn,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'nombre': nombre,
'activa': activa,
'hora': hora,
'minuto': minuto,
'tipoProgramacion': tipoProgramacion.name,
'diasSemana': diasSemana,
'emisora': emisora?.toMap(),
'emisoraFallback': emisoraFallback?.toMap(),
'sonarEnVacaciones': sonarEnVacaciones,
'snoozeMinutos': snoozeMinutos,
'volumen': volumen,
'sonidoInterno': sonidoInterno.name,
'proximaEjecucion': proximaEjecucion?.toIso8601String(),
'creadaEn': creadaEn?.toIso8601String(),
'actualizadaEn': actualizadaEn?.toIso8601String(),
};
factory AlarmaMusical.fromJson(Map<String, dynamic> json) {
return AlarmaMusical(
id: json['id'] as String,
nombre: json['nombre'] as String? ?? 'Alarma musical',
activa: json['activa'] as bool? ?? true,
hora: json['hora'] as int? ?? 7,
minuto: json['minuto'] as int? ?? 0,
tipoProgramacion: _enumFromName(
TipoProgramacionAlarma.values,
json['tipoProgramacion'] as String?,
TipoProgramacionAlarma.unica,
),
diasSemana:
(json['diasSemana'] as List? ?? const [])
.whereType<int>()
.where((d) => d >= DateTime.monday && d <= DateTime.sunday)
.toList(),
emisora: _emisoraFromJson(json['emisora']),
emisoraFallback: _emisoraFromJson(json['emisoraFallback']),
sonarEnVacaciones: json['sonarEnVacaciones'] as bool? ?? true,
snoozeMinutos: json['snoozeMinutos'] as int? ?? 5,
volumen: (json['volumen'] as num?)?.toDouble() ?? 0.85,
sonidoInterno: _enumFromName(
SonidoInternoAlarma.values,
json['sonidoInterno'] as String?,
SonidoInternoAlarma.amanecer,
),
proximaEjecucion: _dateFromJson(json['proximaEjecucion']),
creadaEn: _dateFromJson(json['creadaEn']),
actualizadaEn: _dateFromJson(json['actualizadaEn']),
);
}
static Emisora? _emisoraFromJson(Object? raw) {
if (raw is! Map) return null;
return Emisora.fromMap(Map<String, dynamic>.from(raw));
}
static DateTime? _dateFromJson(Object? raw) =>
raw is String ? DateTime.tryParse(raw) : null;
static T _enumFromName<T extends Enum>(
List<T> values,
String? name,
T fallback,
) {
for (final value in values) {
if (value.name == name) return value;
}
return fallback;
}
}
class RangoVacaciones {
const RangoVacaciones({
required this.id,
required this.nombre,
required this.inicio,
required this.fin,
this.activo = true,
});
final String id;
final String nombre;
final DateTime inicio;
final DateTime fin;
final bool activo;
bool contiene(DateTime fecha) {
final dia = DateTime(fecha.year, fecha.month, fecha.day);
final desde = DateTime(inicio.year, inicio.month, inicio.day);
final hasta = DateTime(fin.year, fin.month, fin.day);
return activo && !dia.isBefore(desde) && !dia.isAfter(hasta);
}
Map<String, dynamic> toJson() => {
'id': id,
'nombre': nombre,
'inicio': inicio.toIso8601String(),
'fin': fin.toIso8601String(),
'activo': activo,
};
factory RangoVacaciones.fromJson(Map<String, dynamic> json) {
return RangoVacaciones(
id: json['id'] as String,
nombre: json['nombre'] as String? ?? 'Vacaciones',
inicio: DateTime.parse(json['inicio'] as String),
fin: DateTime.parse(json['fin'] as String),
activo: json['activo'] as bool? ?? true,
);
}
}
class ExcepcionAlarma {
const ExcepcionAlarma({
required this.alarmaId,
required this.ejecucion,
required this.tipo,
});
final String alarmaId;
final DateTime ejecucion;
final String tipo;
Map<String, dynamic> toJson() => {
'alarmaId': alarmaId,
'ejecucion': ejecucion.toIso8601String(),
'tipo': tipo,
};
factory ExcepcionAlarma.fromJson(Map<String, dynamic> json) {
return ExcepcionAlarma(
alarmaId: json['alarmaId'] as String,
ejecucion: DateTime.parse(json['ejecucion'] as String),
tipo: json['tipo'] as String? ?? 'skipNext',
);
}
}
+227
View File
@@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
import '../modelos/alarma_musical.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_premium_widgets.dart';
class PantallaAlarmas extends StatelessWidget {
const PantallaAlarmas({super.key});
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoAlarmas>();
return ListView(
padding: const EdgeInsets.fromLTRB(0, 0, 0, 124),
children: [
PluriScreenHeader(
title: 'Alarmas musicales',
subtitle:
'Despertador con radio, vacaciones, aviso previo y fallbacks seguros.',
glyph: PluriIconGlyph.alarm,
primaryActionLabel: 'Nueva alarma',
onPrimaryAction: () => _crearDemo(context),
trailing: PluriStatusPill(
icon: Icons.alarm_on_rounded,
label: '${estado.alarmas.length} alarmas',
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
_DiagnosticoAlarmas(estado: estado),
const SizedBox(height: 12),
if (estado.alarmas.isEmpty)
const _EmptyAlarmas()
else
for (final alarma in estado.alarmas) ...[
_TarjetaAlarma(alarma: alarma),
const SizedBox(height: 10),
],
],
),
),
],
);
}
Future<void> _crearDemo(BuildContext context) async {
final estado = context.read<EstadoAlarmas>();
final ahora = TimeOfDay.now();
final alarma = estado.servicio.crearAlarma(
nombre: 'Despertador musical',
hora: ahora.hour,
minuto: (ahora.minute + 2) % 60,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
);
await estado.guardarAlarma(alarma);
}
}
class _DiagnosticoAlarmas extends StatelessWidget {
const _DiagnosticoAlarmas({required this.estado});
final EstadoAlarmas estado;
@override
Widget build(BuildContext context) {
final diag = estado.diagnostico;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.health_and_safety_outlined),
const SizedBox(width: 12),
Text(
'Fiabilidad Android',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
IconButton(
tooltip: 'Revisar',
icon: const Icon(Icons.refresh_rounded),
onPressed: estado.cargarDiagnostico,
),
],
),
const SizedBox(height: 8),
_EstadoPermiso(
label: 'Alarmas exactas',
ok: diag?.puedeProgramarExactas ?? false,
),
_EstadoPermiso(
label: 'Notificaciones',
ok: diag?.notificacionesPermitidas ?? false,
),
if (diag == null)
Text(
'Diagnóstico pendiente. En Android se revisan permisos nativos.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
);
}
}
class _EstadoPermiso extends StatelessWidget {
const _EstadoPermiso({required this.label, required this.ok});
final String label;
final bool ok;
@override
Widget build(BuildContext context) {
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: Icon(
ok ? Icons.check_circle_rounded : Icons.warning_amber_rounded,
color: ok ? Colors.greenAccent : Colors.orangeAccent,
),
title: Text(label),
subtitle: Text(ok ? 'Correcto' : 'Requiere revisión'),
);
}
}
class _TarjetaAlarma extends StatelessWidget {
const _TarjetaAlarma({required this.alarma});
final AlarmaMusical alarma;
@override
Widget build(BuildContext context) {
final estado = context.read<EstadoAlarmas>();
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: alarma.activa,
onChanged: (value) => estado.cambiarActiva(alarma, value),
title: Text(
_hora(alarma),
style: Theme.of(context).textTheme.headlineMedium,
),
subtitle: Text(alarma.nombre),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text(_programacion(alarma))),
Chip(label: Text('Snooze ${alarma.snoozeMinutos} min')),
Chip(
label: Text(
alarma.sonarEnVacaciones
? 'Suena en vacaciones'
: 'Respeta vacaciones',
),
),
],
),
if (alarma.proximaEjecucion != null) ...[
const SizedBox(height: 8),
Text('Próxima: ${alarma.proximaEjecucion!.toLocal()}'),
],
const SizedBox(height: 8),
Row(
children: [
TextButton.icon(
icon: const Icon(Icons.skip_next_rounded),
label: const Text('Saltar próxima'),
onPressed: () => estado.saltarProxima(alarma.id),
),
const Spacer(),
IconButton(
tooltip: 'Eliminar',
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => estado.eliminarAlarma(alarma.id),
),
],
),
],
),
);
}
String _hora(AlarmaMusical alarma) =>
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
String _programacion(AlarmaMusical alarma) {
return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.unica => 'Una vez',
TipoProgramacionAlarma.diaria => 'Diaria',
TipoProgramacionAlarma.diasSemana =>
'Días: ${alarma.diasSemana.join(', ')}',
};
}
}
class _EmptyAlarmas extends StatelessWidget {
const _EmptyAlarmas();
@override
Widget build(BuildContext context) {
return const PluriGlassSurface(
child: Column(
children: [
Icon(Icons.alarm_add_rounded, size: 42),
SizedBox(height: 12),
Text('Todavía no hay alarmas.'),
SizedBox(height: 4),
Text('Crea una para empezar a diseñar tu despertar musical.'),
],
),
);
}
}
+217
View File
@@ -0,0 +1,217 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../modelos/alarma_musical.dart';
import '../modelos/emisora.dart';
import 'servicio_programacion_alarmas.dart';
class ConfiguracionAlarmas {
const ConfiguracionAlarmas({
required this.alarmas,
required this.vacaciones,
required this.excepciones,
});
final List<AlarmaMusical> alarmas;
final List<RangoVacaciones> vacaciones;
final List<ExcepcionAlarma> excepciones;
}
class ServicioAlarmas {
ServicioAlarmas({
ServicioProgramacionAlarmas? programacion,
SharedPreferences? prefs,
DateTime Function()? reloj,
}) : _programacion = programacion ?? ServicioProgramacionAlarmas(),
_prefs = prefs,
_reloj = reloj ?? DateTime.now;
static const _keyConfig = 'alarmas_musicales_v1';
final ServicioProgramacionAlarmas _programacion;
final SharedPreferences? _prefs;
final DateTime Function() _reloj;
final _uuid = const Uuid();
Future<ConfiguracionAlarmas> cargar() async {
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyConfig);
if (raw == null || raw.trim().isEmpty) {
return const ConfiguracionAlarmas(
alarmas: [],
vacaciones: [],
excepciones: [],
);
}
try {
final data = jsonDecode(raw) as Map<String, dynamic>;
return ConfiguracionAlarmas(
alarmas:
(data['alarmas'] as List? ?? const [])
.whereType<Map>()
.map(
(e) => AlarmaMusical.fromJson(Map<String, dynamic>.from(e)),
)
.toList(),
vacaciones:
(data['vacaciones'] as List? ?? const [])
.whereType<Map>()
.map(
(e) => RangoVacaciones.fromJson(Map<String, dynamic>.from(e)),
)
.toList(),
excepciones:
(data['excepciones'] as List? ?? const [])
.whereType<Map>()
.map(
(e) => ExcepcionAlarma.fromJson(Map<String, dynamic>.from(e)),
)
.toList(),
);
} catch (_) {
return const ConfiguracionAlarmas(
alarmas: [],
vacaciones: [],
excepciones: [],
);
}
}
Future<ConfiguracionAlarmas> guardarAlarma(
AlarmaMusical alarma, {
List<RangoVacaciones>? vacaciones,
List<ExcepcionAlarma>? excepciones,
}) async {
final config = await cargar();
final ahora = _reloj();
final alarmas = List<AlarmaMusical>.from(config.alarmas);
final index = alarmas.indexWhere((a) => a.id == alarma.id);
final normalizada = _recalcular(
alarma.copyWith(creadaEn: alarma.creadaEn ?? ahora, actualizadaEn: ahora),
vacaciones ?? config.vacaciones,
excepciones ?? config.excepciones,
);
if (index >= 0) {
alarmas[index] = normalizada;
} else {
alarmas.add(normalizada);
}
final nuevo = ConfiguracionAlarmas(
alarmas: alarmas,
vacaciones: vacaciones ?? config.vacaciones,
excepciones: excepciones ?? config.excepciones,
);
await _guardar(nuevo);
return nuevo;
}
Future<ConfiguracionAlarmas> eliminarAlarma(String id) async {
final config = await cargar();
final nuevo = ConfiguracionAlarmas(
alarmas: config.alarmas.where((a) => a.id != id).toList(),
vacaciones: config.vacaciones,
excepciones: config.excepciones.where((e) => e.alarmaId != id).toList(),
);
await _guardar(nuevo);
return nuevo;
}
Future<ConfiguracionAlarmas> guardarVacaciones(
List<RangoVacaciones> vacaciones,
) async {
final config = await cargar();
final alarmas =
config.alarmas
.map((a) => _recalcular(a, vacaciones, config.excepciones))
.toList();
final nuevo = ConfiguracionAlarmas(
alarmas: alarmas,
vacaciones: vacaciones,
excepciones: config.excepciones,
);
await _guardar(nuevo);
return nuevo;
}
Future<ConfiguracionAlarmas> saltarProxima(String alarmaId) async {
final config = await cargar();
final alarma = config.alarmas.firstWhere((a) => a.id == alarmaId);
final proxima = alarma.proximaEjecucion;
if (proxima == null) return config;
final excepciones = [
...config.excepciones,
ExcepcionAlarma(alarmaId: alarmaId, ejecucion: proxima, tipo: 'skipNext'),
];
final alarmas =
config.alarmas
.map(
(a) =>
a.id == alarmaId
? _recalcular(a, config.vacaciones, excepciones)
: a,
)
.toList();
final nuevo = ConfiguracionAlarmas(
alarmas: alarmas,
vacaciones: config.vacaciones,
excepciones: excepciones,
);
await _guardar(nuevo);
return nuevo;
}
AlarmaMusical crearAlarma({
required String nombre,
required int hora,
required int minuto,
required TipoProgramacionAlarma tipoProgramacion,
required List<int> diasSemana,
Emisora? emisora,
}) {
final ahora = _reloj();
return AlarmaMusical(
id: _uuid.v4(),
nombre: nombre,
hora: hora,
minuto: minuto,
tipoProgramacion: tipoProgramacion,
diasSemana: diasSemana,
emisora: emisora,
creadaEn: ahora,
actualizadaEn: ahora,
);
}
Future<void> _guardar(ConfiguracionAlarmas config) async {
final prefs = await _resolverPrefs();
await prefs.setString(
_keyConfig,
jsonEncode({
'alarmas': config.alarmas.map((a) => a.toJson()).toList(),
'vacaciones': config.vacaciones.map((v) => v.toJson()).toList(),
'excepciones': config.excepciones.map((e) => e.toJson()).toList(),
}),
);
}
AlarmaMusical _recalcular(
AlarmaMusical alarma,
List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones,
) {
return alarma.copyWith(
proximaEjecucion: _programacion.calcularProxima(
alarma: alarma,
desde: _reloj(),
vacaciones: vacaciones,
excepciones: excepciones,
),
);
}
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
}
@@ -0,0 +1,59 @@
import 'package:flutter/services.dart';
import '../modelos/alarma_musical.dart';
class DiagnosticoAlarmasAndroid {
const DiagnosticoAlarmasAndroid({
required this.puedeProgramarExactas,
required this.notificacionesPermitidas,
required this.fabricante,
required this.versionSdk,
});
final bool puedeProgramarExactas;
final bool notificacionesPermitidas;
final String fabricante;
final int versionSdk;
factory DiagnosticoAlarmasAndroid.fromMap(Map<Object?, Object?> map) {
return DiagnosticoAlarmasAndroid(
puedeProgramarExactas: map['canScheduleExactAlarms'] as bool? ?? true,
notificacionesPermitidas: map['notificationsEnabled'] as bool? ?? true,
fabricante: map['manufacturer'] as String? ?? 'Android',
versionSdk: map['sdkInt'] as int? ?? 0,
);
}
}
class ServicioAlarmasAndroid {
ServicioAlarmasAndroid({
MethodChannel channel = const MethodChannel('pluriwave/alarm_scheduler'),
}) : _channel = channel;
final MethodChannel _channel;
Future<void> programar(AlarmaMusical alarma) async {
final proxima = alarma.proximaEjecucion;
if (proxima == null || !alarma.activa) {
await cancelar(alarma.id);
return;
}
await _channel.invokeMethod<void>('scheduleAlarm', {
'id': alarma.id,
'title': alarma.nombre,
'triggerAtMillis': proxima.millisecondsSinceEpoch,
'preNoticeAtMillis':
proxima.subtract(const Duration(minutes: 30)).millisecondsSinceEpoch,
});
}
Future<void> cancelar(String alarmaId) =>
_channel.invokeMethod<void>('cancelAlarm', {'id': alarmaId});
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
'diagnostics',
);
return DiagnosticoAlarmasAndroid.fromMap(raw ?? const {});
}
}
@@ -0,0 +1,104 @@
import '../modelos/alarma_musical.dart';
class ServicioProgramacionAlarmas {
DateTime? calcularProxima({
required AlarmaMusical alarma,
required DateTime desde,
List<RangoVacaciones> vacaciones = const [],
List<ExcepcionAlarma> excepciones = const [],
}) {
if (!alarma.activa) return null;
final inicio = DateTime(
desde.year,
desde.month,
desde.day,
alarma.hora,
alarma.minuto,
);
final primerCandidato =
inicio.isAfter(desde) ? inicio : inicio.add(const Duration(days: 1));
return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.unica =>
_esValida(alarma, primerCandidato, vacaciones, excepciones)
? primerCandidato
: null,
TipoProgramacionAlarma.diaria => _buscarDiaria(
alarma,
primerCandidato,
vacaciones,
excepciones,
),
TipoProgramacionAlarma.diasSemana => _buscarPorDiasSemana(
alarma,
primerCandidato,
vacaciones,
excepciones,
),
};
}
DateTime calcularSnooze(DateTime desde, int minutos) {
final seguro = minutos == 3 || minutos == 5 || minutos == 10 ? minutos : 5;
return desde.add(Duration(minutes: seguro));
}
bool estaEnVacaciones(DateTime fecha, List<RangoVacaciones> vacaciones) =>
vacaciones.any((rango) => rango.contiene(fecha));
DateTime? _buscarDiaria(
AlarmaMusical alarma,
DateTime candidato,
List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones,
) {
var actual = candidato;
for (var i = 0; i < 370; i++) {
if (_esValida(alarma, actual, vacaciones, excepciones)) return actual;
actual = actual.add(const Duration(days: 1));
}
return null;
}
DateTime? _buscarPorDiasSemana(
AlarmaMusical alarma,
DateTime candidato,
List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones,
) {
if (alarma.diasSemana.isEmpty) return null;
var actual = candidato;
for (var i = 0; i < 370; i++) {
if (alarma.diasSemana.contains(actual.weekday) &&
_esValida(alarma, actual, vacaciones, excepciones)) {
return actual;
}
actual = actual.add(const Duration(days: 1));
}
return null;
}
bool _esValida(
AlarmaMusical alarma,
DateTime candidato,
List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones,
) {
if (!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)) {
return false;
}
return !excepciones.any(
(excepcion) =>
excepcion.alarmaId == alarma.id &&
_mismaEjecucion(excepcion.ejecucion, candidato),
);
}
bool _mismaEjecucion(DateTime a, DateTime b) =>
a.year == b.year &&
a.month == b.month &&
a.day == b.day &&
a.hour == b.hour &&
a.minute == b.minute;
}
+49 -41
View File
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import '../tema/pluriwave_tokens.dart';
import '../tema/pluriwave_theme.dart';
enum PluriIconGlyph { home, search, favorites, player, settings }
enum PluriIconGlyph { home, search, favorites, alarm, player, settings }
enum PluriIconVariant { outline, filled, activeGlow }
@@ -26,40 +26,40 @@ class PluriIcon extends StatelessWidget {
final tokens = context.pluriTokens;
final asset = _resolveAsset();
final resolvedColor = _resolveColor(context, tokens);
final icon = asset == null
? Icon(_resolveData(), size: size, color: resolvedColor)
: Opacity(
opacity: variant == PluriIconVariant.outline ? 0.78 : 1,
child: Image.asset(
asset,
width: size,
height: size,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(
_resolveData(),
size: size,
color: resolvedColor,
final icon =
asset == null
? Icon(_resolveData(), size: size, color: resolvedColor)
: Opacity(
opacity: variant == PluriIconVariant.outline ? 0.78 : 1,
child: Image.asset(
asset,
width: size,
height: size,
fit: BoxFit.contain,
errorBuilder:
(_, __, ___) =>
Icon(_resolveData(), size: size, color: resolvedColor),
),
),
);
final child = variant == PluriIconVariant.activeGlow
? Container(
width: size + 14,
height: size + 14,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: tokens.glowColor,
blurRadius: 18,
spreadRadius: 1,
),
],
),
alignment: Alignment.center,
child: icon,
)
: icon;
);
final child =
variant == PluriIconVariant.activeGlow
? Container(
width: size + 14,
height: size + 14,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: tokens.glowColor,
blurRadius: 18,
spreadRadius: 1,
),
],
),
alignment: Alignment.center,
child: icon,
)
: icon;
return Semantics(
label: semanticLabel ?? _fallbackLabel(glyph),
@@ -68,13 +68,12 @@ class PluriIcon extends StatelessWidget {
);
}
String? _resolveAsset() {
return switch (glyph) {
PluriIconGlyph.home => 'assets/icons/pluri_home.png',
PluriIconGlyph.search => 'assets/icons/pluri_search.png',
PluriIconGlyph.favorites => 'assets/icons/pluri_favorites.png',
PluriIconGlyph.alarm => null,
PluriIconGlyph.player => 'assets/icons/pluri_player.png',
PluriIconGlyph.settings => 'assets/icons/pluri_settings.png',
};
@@ -82,7 +81,9 @@ class PluriIcon extends StatelessWidget {
Color _resolveColor(BuildContext context, PluriWaveTokens tokens) {
if (variant == PluriIconVariant.activeGlow) return tokens.electricMagenta;
if (variant == PluriIconVariant.filled) return Theme.of(context).colorScheme.onSurface;
if (variant == PluriIconVariant.filled) {
return Theme.of(context).colorScheme.onSurface;
}
return Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.78);
}
@@ -90,13 +91,19 @@ class PluriIcon extends StatelessWidget {
return switch ((glyph, variant)) {
(PluriIconGlyph.home, PluriIconVariant.outline) => Icons.home_outlined,
(PluriIconGlyph.home, _) => Icons.home_rounded,
(PluriIconGlyph.search, PluriIconVariant.outline) => Icons.search_outlined,
(PluriIconGlyph.search, PluriIconVariant.outline) =>
Icons.search_outlined,
(PluriIconGlyph.search, _) => Icons.search_rounded,
(PluriIconGlyph.favorites, PluriIconVariant.outline) => Icons.favorite_border_rounded,
(PluriIconGlyph.favorites, PluriIconVariant.outline) =>
Icons.favorite_border_rounded,
(PluriIconGlyph.favorites, _) => Icons.favorite_rounded,
(PluriIconGlyph.player, PluriIconVariant.outline) => Icons.play_circle_outline_rounded,
(PluriIconGlyph.alarm, PluriIconVariant.outline) => Icons.alarm_outlined,
(PluriIconGlyph.alarm, _) => Icons.alarm_rounded,
(PluriIconGlyph.player, PluriIconVariant.outline) =>
Icons.play_circle_outline_rounded,
(PluriIconGlyph.player, _) => Icons.play_circle_rounded,
(PluriIconGlyph.settings, PluriIconVariant.outline) => Icons.settings_outlined,
(PluriIconGlyph.settings, PluriIconVariant.outline) =>
Icons.settings_outlined,
(PluriIconGlyph.settings, _) => Icons.settings_rounded,
};
}
@@ -106,6 +113,7 @@ class PluriIcon extends StatelessWidget {
PluriIconGlyph.home => 'Inicio',
PluriIconGlyph.search => 'Buscar',
PluriIconGlyph.favorites => 'Favoritos',
PluriIconGlyph.alarm => 'Alarmas',
PluriIconGlyph.player => 'Reproductor',
PluriIconGlyph.settings => 'Ajustes',
};