feat(app): add onboarding and harden alarms
Build & Deploy Pluriwave / Análisis de código (push) Successful in 21s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m6s

This commit is contained in:
2026-05-23 01:22:37 +02:00
parent 27b8fccac9
commit 896349ad5f
44 changed files with 1772 additions and 241 deletions
+124 -47
View File
@@ -18,6 +18,7 @@ import '../widgets/ecualizador_widget.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_onboarding_dialog.dart';
import '../widgets/pluri_premium_widgets.dart';
class PantallaAjustes extends StatelessWidget {
@@ -225,7 +226,6 @@ class _SeccionGrabaciones extends StatelessWidget {
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onTap: () => _seleccionarRuta(context),
),
),
Wrap(
@@ -353,7 +353,8 @@ class _SeccionTimerSueno extends StatelessWidget {
icon: const Icon(Icons.restore_rounded),
label: Text(l10n.timerSectionRestoreRecommended),
onPressed:
() => context.read<EstadoRadio>().restaurarTimerSuenoPresets(),
() =>
context.read<EstadoRadio>().restaurarTimerSuenoPresets(),
),
),
],
@@ -387,8 +388,7 @@ class _SeccionIdioma extends StatelessWidget {
final l10n = AppLocalizations.of(context);
final estadoIdioma = context.watch<EstadoIdioma>();
final locale = estadoIdioma.localeSeleccionado;
final valorActual =
locale == null ? _codigoSistema : _codigoLocale(locale);
final valorActual = locale == null ? _codigoSistema : _codigoLocale(locale);
return PluriGlassSurface(
child: Column(
@@ -479,7 +479,8 @@ class _FormularioDuracionTimer extends StatefulWidget {
const _FormularioDuracionTimer();
@override
State<_FormularioDuracionTimer> createState() => _FormularioDuracionTimerState();
State<_FormularioDuracionTimer> createState() =>
_FormularioDuracionTimerState();
}
class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
@@ -506,9 +507,9 @@ class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
seconds: _leer(_segundosCtrl),
);
if (duracion <= Duration.zero) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.durationGreaterThanZero)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.durationGreaterThanZero)));
return;
}
Navigator.pop(context, duracion);
@@ -593,7 +594,9 @@ class _SeccionEcualizador extends StatelessWidget {
const Spacer(),
Chip(
label: Text(
estado.ecualizadorActivo ? l10n.equalizerActive : l10n.equalizerDisabled,
estado.ecualizadorActivo
? l10n.equalizerActive
: l10n.equalizerDisabled,
),
visualDensity: VisualDensity.compact,
),
@@ -701,7 +704,10 @@ class _SeccionOrdenListas extends StatelessWidget {
class _SeccionGruposFavoritos extends StatelessWidget {
const _SeccionGruposFavoritos();
Future<void> _editarGrupo(BuildContext context, [GrupoFavoritos? grupo]) async {
Future<void> _editarGrupo(
BuildContext context, [
GrupoFavoritos? grupo,
]) async {
final l10n = AppLocalizations.of(context);
final controller = TextEditingController(text: grupo?.nombre ?? '');
final nombre = await showModalBottomSheet<String>(
@@ -717,7 +723,9 @@ class _SeccionGruposFavoritos extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
grupo == null ? l10n.favoriteGroupsAdd : l10n.favoriteGroupsEdit,
grupo == null
? l10n.favoriteGroupsAdd
: l10n.favoriteGroupsEdit,
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: 16),
@@ -756,17 +764,26 @@ class _SeccionGruposFavoritos extends StatelessWidget {
}
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(grupo == null ? l10n.favoriteGroupsCreated : l10n.favoriteGroupsUpdated)),
SnackBar(
content: Text(
grupo == null
? l10n.favoriteGroupsCreated
: l10n.favoriteGroupsUpdated,
),
),
);
}
Future<void> _eliminarGrupo(BuildContext context, GrupoFavoritos grupo) async {
Future<void> _eliminarGrupo(
BuildContext context,
GrupoFavoritos grupo,
) async {
final l10n = AppLocalizations.of(context);
await context.read<EstadoRadio>().eliminarGrupoFavoritos(grupo.id);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.favoriteGroupsDeleted)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.favoriteGroupsDeleted)));
}
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
@@ -808,24 +825,28 @@ class _SeccionGruposFavoritos extends StatelessWidget {
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
),
title: Text(_nombreVisible(l10n, grupo)),
subtitle: grupo.esSinAsignar ? Text(l10n.favoriteGroupsProtectedHint) : null,
trailing: grupo.esSinAsignar
? null
: Wrap(
spacing: 4,
children: [
IconButton(
tooltip: l10n.favoriteGroupsEdit,
icon: const Icon(Icons.edit_rounded),
onPressed: () => _editarGrupo(context, grupo),
),
IconButton(
tooltip: l10n.favoriteGroupsDelete,
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => _eliminarGrupo(context, grupo),
),
],
),
subtitle:
grupo.esSinAsignar
? Text(l10n.favoriteGroupsProtectedHint)
: null,
trailing:
grupo.esSinAsignar
? null
: Wrap(
spacing: 4,
children: [
IconButton(
tooltip: l10n.favoriteGroupsEdit,
icon: const Icon(Icons.edit_rounded),
onPressed: () => _editarGrupo(context, grupo),
),
IconButton(
tooltip: l10n.favoriteGroupsDelete,
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => _eliminarGrupo(context, grupo),
),
],
),
),
],
),
@@ -911,7 +932,10 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
icon: const Icon(Icons.play_arrow_rounded),
label: Text(l10n.preferredStationPlay),
onPressed:
() => context.read<EstadoRadio>().reproducirEmisoraPreferida(),
() =>
context
.read<EstadoRadio>()
.reproducirEmisoraPreferida(),
),
),
],
@@ -925,7 +949,9 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
estado.listaFavoritos.isNotEmpty
? estado.listaFavoritos
: estado.emisorasDisponiblesPreferencia;
final mapa = <String, Emisora>{for (final emisora in base) emisora.uuid: emisora};
final mapa = <String, Emisora>{
for (final emisora in base) emisora.uuid: emisora,
};
if (preferida != null) {
mapa[preferida.uuid] = preferida;
}
@@ -1055,7 +1081,12 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
Widget build(BuildContext context) {
final bottom = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(PluriLayout.horizontal, PluriLayout.horizontal, PluriLayout.horizontal, PluriLayout.horizontal + bottom),
padding: EdgeInsets.fromLTRB(
PluriLayout.horizontal,
PluriLayout.horizontal,
PluriLayout.horizontal,
PluriLayout.horizontal + bottom,
),
child: Form(
key: _formKey,
child: Column(
@@ -1089,7 +1120,9 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
),
keyboardType: TextInputType.url,
validator: (v) {
if (v == null || v.trim().isEmpty) return AppLocalizations.of(context).requiredField;
if (v == null || v.trim().isEmpty) {
return AppLocalizations.of(context).requiredField;
}
final uri = Uri.tryParse(v.trim());
if (uri == null || !uri.hasScheme) return 'URL no válida';
return null;
@@ -1143,9 +1176,13 @@ class _SeccionBackup extends StatelessWidget {
);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).backupExportError(e.toString()))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).backupExportError(e.toString()),
),
),
);
}
}
}
@@ -1197,9 +1234,13 @@ class _SeccionBackup extends StatelessWidget {
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).backupImportError(e.toString()))));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).backupImportError(e.toString()),
),
),
);
}
}
}
@@ -1264,7 +1305,9 @@ class _SeccionInfo extends StatelessWidget {
variant: PluriIconVariant.filled,
),
title: const Text('PluriWave'),
subtitle: Text(AppLocalizations.of(ctx).appVersionSubtitle(version)),
subtitle: Text(
AppLocalizations.of(ctx).appVersionSubtitle(version),
),
);
},
),
@@ -1274,18 +1317,30 @@ class _SeccionInfo extends StatelessWidget {
(ctx, snap) => ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.favorite_outline),
title: Text(AppLocalizations.of(ctx).savedFavoritesTitle),
title: Text(
AppLocalizations.of(ctx).savedFavoritesTitle,
),
trailing: Text(
snap.data?.toString() ?? '',
style: Theme.of(ctx).textTheme.bodyLarge,
),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.help_outline_rounded),
title: Text(_helpTitle(ctx)),
subtitle: Text(_helpSubtitle(ctx)),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => PluriOnboardingDialog.mostrar(ctx),
),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.verified_outlined),
title: Text(AppLocalizations.of(ctx).stationFilterTitle),
subtitle: Text(AppLocalizations.of(ctx).stationFilterSubtitle),
subtitle: Text(
AppLocalizations.of(ctx).stationFilterSubtitle,
),
trailing: const Icon(Icons.check_circle, color: Colors.green),
),
const ListTile(
@@ -1302,6 +1357,28 @@ class _SeccionInfo extends StatelessWidget {
}
}
String _helpTitle(BuildContext context) => switch (Localizations.localeOf(
context,
).languageCode) {
'es' => 'Ayuda y tutorial',
'fr' => 'Aide et tutoriel',
'de' => 'Hilfe und Tutorial',
'it' => 'Aiuto e tutorial',
'pt' => 'Ajuda e tutorial',
_ => 'Help and tutorial',
};
String _helpSubtitle(BuildContext context) => switch (Localizations.localeOf(
context,
).languageCode) {
'es' => 'Repasá funciones, consejos y novedades de PluriWave.',
'fr' => 'Revoyez les fonctions, conseils et nouveautés de PluriWave.',
'de' => 'Funktionen, Tipps und Neuigkeiten von PluriWave ansehen.',
'it' => 'Rivedi funzioni, consigli e novità di PluriWave.',
'pt' => 'Revê funções, dicas e novidades do PluriWave.',
_ => 'Review PluriWave features, tips and whats new.',
};
String _formatearDuracionTimer(Duration duracion) {
final horas = duracion.inHours;
final minutos = duracion.inMinutes.remainder(60);
+228 -126
View File
@@ -1,4 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
@@ -58,7 +58,10 @@ class PantallaAlarmas extends StatelessWidget {
);
}
Future<void> _abrirEditor(BuildContext context, {AlarmaMusical? alarma}) async {
Future<void> _abrirEditor(
BuildContext context, {
AlarmaMusical? alarma,
}) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
@@ -77,6 +80,10 @@ class _PanelProximaAlarma extends StatelessWidget {
@override
Widget build(BuildContext context) {
final proxima = estado.proximaAlarma;
final activasSinProxima =
estado.alarmas
.where((a) => a.activa && a.proximaEjecucion == null)
.length;
return PluriGlassSurface(
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28),
child: Row(
@@ -88,7 +95,11 @@ class _PanelProximaAlarma extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
proxima == null ? 'Sin alarmas activas' : 'Próxima alarma',
proxima == null
? activasSinProxima > 0
? 'Alarmas activas sin próxima ejecución'
: 'Sin alarmas activas'
: 'Próxima alarma',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
@@ -96,7 +107,9 @@ class _PanelProximaAlarma extends StatelessWidget {
const SizedBox(height: 4),
Text(
proxima == null
? 'Creá una alarma y PluriWave calculará la siguiente ejecución automáticamente.'
? activasSinProxima > 0
? 'Hay $activasSinProxima alarma(s) activas, pero ahora mismo no tienen una fecha futura válida. Revisá fecha, días y vacaciones.'
: 'Creá una alarma y PluriWave calculará la siguiente ejecución automáticamente.'
: '${proxima.nombre} · ${_fechaHora(proxima.proximaEjecucion!)}',
),
],
@@ -125,7 +138,10 @@ class _TarjetaAlarma extends StatelessWidget {
children: [
Row(
children: [
const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 64),
const _AssetIcon(
'assets/icons/alarmas/alarm_music.png',
size: 64,
),
const SizedBox(width: 12),
Expanded(
child: Column(
@@ -133,7 +149,9 @@ class _TarjetaAlarma extends StatelessWidget {
children: [
Text(
_hora(alarma),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -1,
),
@@ -153,13 +171,20 @@ class _TarjetaAlarma extends StatelessWidget {
spacing: 8,
runSpacing: 8,
children: [
_InfoChip(icon: Icons.repeat_rounded, label: _programacion(alarma)),
_InfoChip(icon: Icons.snooze_rounded, label: '${alarma.snoozeMinutos} min'),
_InfoChip(
icon: Icons.repeat_rounded,
label: _programacion(alarma),
),
_InfoChip(
icon: Icons.snooze_rounded,
label: '${alarma.snoozeMinutos} min',
),
_InfoChip(
icon: Icons.beach_access_rounded,
label: alarma.sonarEnVacaciones
? 'Suena en vacaciones'
: 'Pausa en vacaciones',
label:
alarma.sonarEnVacaciones
? 'Suena en vacaciones'
: 'Pausa en vacaciones',
),
_InfoChip(
icon: Icons.volume_up_rounded,
@@ -171,7 +196,8 @@ class _TarjetaAlarma extends StatelessWidget {
if (alarma.proximaEjecucion != null)
_NoticeLine(
icon: Icons.event_available_rounded,
text: 'Siguiente ejecución: ${_fechaHora(alarma.proximaEjecucion!)}',
text:
'Siguiente ejecución: ${_fechaHora(alarma.proximaEjecucion!)}',
)
else
const _NoticeLine(
@@ -182,7 +208,8 @@ class _TarjetaAlarma extends StatelessWidget {
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.skip_next_rounded,
text: 'Una ejecución fue omitida: ${_fechaHora(excepcion.ejecucion)}.',
text:
'Una ejecución fue omitida: ${_fechaHora(excepcion.ejecucion)}.',
),
],
if (mensajeVacaciones != null) ...[
@@ -203,31 +230,32 @@ class _TarjetaAlarma extends StatelessWidget {
TextButton.icon(
icon: const Icon(Icons.skip_next_rounded),
label: const Text('Omitir siguiente'),
onPressed: alarma.proximaEjecucion == null
? null
: () async {
await estado.saltarProxima(alarma.id);
if (context.mounted) {
final alarmas =
context.read<EstadoAlarmas>().alarmas;
AlarmaMusical? actualizada;
for (final item in alarmas) {
if (item.id == alarma.id) {
actualizada = item;
break;
onPressed:
alarma.proximaEjecucion == null
? null
: () async {
await estado.saltarProxima(alarma.id);
if (context.mounted) {
final alarmas =
context.read<EstadoAlarmas>().alarmas;
AlarmaMusical? actualizada;
for (final item in alarmas) {
if (item.id == alarma.id) {
actualizada = item;
break;
}
}
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
actualizada?.proximaEjecucion == null
? 'Alarma omitida. No queda próxima ejecución.'
: 'Alarma omitida. Volverá el ${_fechaHora(actualizada!.proximaEjecucion!)}.',
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
actualizada?.proximaEjecucion == null
? 'Alarma omitida. No queda próxima ejecución.'
: 'Alarma omitida. Volverá el ${_fechaHora(actualizada!.proximaEjecucion!)}.',
),
),
),
);
}
},
);
}
},
),
const Spacer(),
IconButton(
@@ -305,7 +333,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
_nombreController = TextEditingController(
text: alarma?.nombre ?? 'Despertador musical',
);
_hora = TimeOfDay(hour: alarma?.hora ?? ahora.hour, minute: alarma?.minuto ?? ahora.minute);
_hora = TimeOfDay(
hour: alarma?.hora ?? ahora.hour,
minute: alarma?.minuto ?? ahora.minute,
);
_fecha = alarma?.fechaUnica ?? ahora;
_tipo = alarma?.tipoProgramacion ?? TipoProgramacionAlarma.unica;
_diasSemana = {...alarma?.diasSemana ?? const <int>[]};
@@ -332,7 +363,9 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
if (mounted) context.read<EstadoRadio>().cargarFavoritos();
});
}
if (_emisora == null && widget.alarma == null && radio.emisoraPreferida != null) {
if (_emisora == null &&
widget.alarma == null &&
radio.emisoraPreferida != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _emisora == null) {
setState(() => _emisora = radio.emisoraPreferida);
@@ -352,7 +385,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
children: [
Row(
children: [
const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 58),
const _AssetIcon(
'assets/icons/alarmas/alarm_music.png',
size: 58,
),
const SizedBox(width: 12),
Expanded(
child: Text(
@@ -390,7 +426,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
icon: Icons.event_rounded,
label: 'Fecha',
value: _fechaCorta(_fecha),
onTap: _tipo == TipoProgramacionAlarma.unica ? _elegirFecha : null,
onTap:
_tipo == TipoProgramacionAlarma.unica
? _elegirFecha
: null,
),
),
],
@@ -398,12 +437,22 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
const SizedBox(height: 12),
SegmentedButton<TipoProgramacionAlarma>(
segments: const [
ButtonSegment(value: TipoProgramacionAlarma.unica, label: Text('Una vez')),
ButtonSegment(value: TipoProgramacionAlarma.diaria, label: Text('Diaria')),
ButtonSegment(value: TipoProgramacionAlarma.diasSemana, label: Text('Días')),
ButtonSegment(
value: TipoProgramacionAlarma.unica,
label: Text('Una vez'),
),
ButtonSegment(
value: TipoProgramacionAlarma.diaria,
label: Text('Diaria'),
),
ButtonSegment(
value: TipoProgramacionAlarma.diasSemana,
label: Text('Días'),
),
],
selected: {_tipo},
onSelectionChanged: (value) => setState(() => _tipo = value.first),
onSelectionChanged:
(value) => setState(() => _tipo = value.first),
),
if (_tipo == TipoProgramacionAlarma.diasSemana) ...[
const SizedBox(height: 10),
@@ -414,15 +463,21 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
FilterChip(
label: Text(_diaCorto(i)),
selected: _diasSemana.contains(i),
onSelected: (selected) => setState(() {
selected ? _diasSemana.add(i) : _diasSemana.remove(i);
}),
onSelected:
(selected) => setState(() {
selected
? _diasSemana.add(i)
: _diasSemana.remove(i);
}),
),
],
),
],
const SizedBox(height: 14),
_SectionLabel(icon: 'assets/icons/alarmas/snooze_wave.png', text: 'Postponer'),
_SectionLabel(
icon: 'assets/icons/alarmas/snooze_wave.png',
text: 'Postponer',
),
SegmentedButton<int>(
segments: const [
ButtonSegment(value: 3, label: Text('3 min')),
@@ -430,10 +485,14 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
ButtonSegment(value: 10, label: Text('10 min')),
],
selected: {_snooze},
onSelectionChanged: (value) => setState(() => _snooze = value.first),
onSelectionChanged:
(value) => setState(() => _snooze = value.first),
),
const SizedBox(height: 14),
_SectionLabel(icon: 'assets/icons/alarmas/fallback_sound.png', text: 'Sonido y volumen'),
_SectionLabel(
icon: 'assets/icons/alarmas/fallback_sound.png',
text: 'Sonido y volumen',
),
Slider(
value: _volumen,
min: 0.25,
@@ -444,13 +503,27 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
),
DropdownButtonFormField<SonidoInternoAlarma>(
initialValue: _sonidoInterno,
decoration: const InputDecoration(labelText: 'Sonido seguro interno'),
decoration: const InputDecoration(
labelText: 'Sonido seguro interno',
),
items: const [
DropdownMenuItem(value: SonidoInternoAlarma.amanecer, child: Text('Amanecer cálido')),
DropdownMenuItem(value: SonidoInternoAlarma.campanaSuave, child: Text('Campana suave')),
DropdownMenuItem(value: SonidoInternoAlarma.pulsoDigital, child: Text('Pulso digital')),
DropdownMenuItem(
value: SonidoInternoAlarma.amanecer,
child: Text('Amanecer cálido'),
),
DropdownMenuItem(
value: SonidoInternoAlarma.campanaSuave,
child: Text('Campana suave'),
),
DropdownMenuItem(
value: SonidoInternoAlarma.pulsoDigital,
child: Text('Pulso digital'),
),
],
onChanged: (value) => setState(() => _sonidoInterno = value ?? _sonidoInterno),
onChanged:
(value) => setState(
() => _sonidoInterno = value ?? _sonidoInterno,
),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
@@ -474,13 +547,14 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
),
),
],
onChanged: (uuid) => setState(() {
if (uuid == null || uuid.isEmpty) {
_emisora = null;
return;
}
_emisora = favoritas.firstWhere((e) => e.uuid == uuid);
}),
onChanged:
(uuid) => setState(() {
if (uuid == null || uuid.isEmpty) {
_emisora = null;
return;
}
_emisora = favoritas.firstWhere((e) => e.uuid == uuid);
}),
),
if (favoritas.isEmpty) ...[
const SizedBox(height: 6),
@@ -493,7 +567,8 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
onPressed: () => setState(() => _emisora = radio.emisoraActual),
onPressed:
() => setState(() => _emisora = radio.emisoraActual),
icon: const Icon(Icons.add_task_rounded),
label: const Text('Usar emisora actual'),
),
@@ -503,10 +578,16 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: _sonarEnVacaciones,
onChanged: (value) => setState(() => _sonarEnVacaciones = value),
secondary: const _AssetIcon('assets/icons/alarmas/vacation_wave.png', size: 42),
onChanged:
(value) => setState(() => _sonarEnVacaciones = value),
secondary: const _AssetIcon(
'assets/icons/alarmas/vacation_wave.png',
size: 42,
),
title: const Text('Sonar durante vacaciones'),
subtitle: const Text('Si lo apagás, la próxima ejecución saltará al primer día válido.'),
subtitle: const Text(
'Si lo apagás, la próxima ejecución saltará al primer día válido.',
),
),
const SizedBox(height: 16),
FilledButton.icon(
@@ -556,24 +637,26 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
diasSemana: _diasSemana.toList()..sort(),
))
.copyWith(
nombre: _nombreController.text.trim().isEmpty
? 'Despertador musical'
: _nombreController.text.trim(),
hora: _hora.hour,
minuto: _hora.minute,
tipoProgramacion: _tipo,
diasSemana: _tipo == TipoProgramacionAlarma.diasSemana
? (_diasSemana.toList()..sort())
: const [],
fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
limpiarFechaUnica: _tipo != TipoProgramacionAlarma.unica,
emisora: _emisora,
sonarEnVacaciones: _sonarEnVacaciones,
snoozeMinutos: _snooze,
volumen: _volumen,
sonidoInterno: _sonidoInterno,
activa: true,
);
nombre:
_nombreController.text.trim().isEmpty
? 'Despertador musical'
: _nombreController.text.trim(),
hora: _hora.hour,
minuto: _hora.minute,
tipoProgramacion: _tipo,
diasSemana:
_tipo == TipoProgramacionAlarma.diasSemana
? (_diasSemana.toList()..sort())
: const [],
fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
limpiarFechaUnica: _tipo != TipoProgramacionAlarma.unica,
emisora: _emisora,
sonarEnVacaciones: _sonarEnVacaciones,
snoozeMinutos: _snooze,
volumen: _volumen,
sonidoInterno: _sonidoInterno,
activa: true,
);
await estado.guardarAlarma(alarma);
if (mounted) Navigator.pop(context);
}
@@ -600,13 +683,21 @@ class _AccesoDiagnostico extends StatelessWidget {
Widget build(BuildContext context) {
final diag = estado.diagnostico;
return TextButton.icon(
icon: const _AssetIcon('assets/icons/alarmas/android_reliability.png', size: 28),
icon: const _AssetIcon(
'assets/icons/alarmas/android_reliability.png',
size: 28,
),
label: Text(
diag == null
? 'Revisar fiabilidad Android'
: 'Fiabilidad: exactas ${diag.puedeProgramarExactas ? 'OK' : 'pendiente'} · notificaciones ${diag.notificacionesPermitidas ? 'OK' : 'pendiente'}',
),
onPressed: estado.cargarDiagnostico,
onPressed: () async {
if (diag != null && !diag.puedeProgramarExactas) {
await estado.android.solicitarPermisoAlarmasExactas();
}
await estado.cargarDiagnostico();
},
);
}
}
@@ -627,7 +718,10 @@ class _PanelVacaciones extends StatelessWidget {
children: [
Row(
children: [
const _AssetIcon('assets/icons/alarmas/vacation_wave.png', size: 48),
const _AssetIcon(
'assets/icons/alarmas/vacation_wave.png',
size: 48,
),
const SizedBox(width: 10),
Expanded(
child: Text(
@@ -723,9 +817,9 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
children: [
Text(
'Nuevo rango de vacaciones',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 12),
TextField(
@@ -811,7 +905,8 @@ class _AssetIcon extends StatelessWidget {
width: size,
height: size,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65),
errorBuilder:
(_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65),
);
}
}
@@ -857,7 +952,12 @@ class _SectionLabel extends StatelessWidget {
children: [
_AssetIcon(icon, size: 34),
const SizedBox(width: 8),
Text(text, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800)),
Text(
text,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
),
],
);
}
@@ -918,9 +1018,11 @@ String _hora(AlarmaMusical alarma) =>
String _programacion(AlarmaMusical alarma) {
return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.unica => 'Una vez · ${_fechaCorta(alarma.fechaUnica ?? DateTime.now())}',
TipoProgramacionAlarma.unica =>
'Una vez · ${_fechaCorta(alarma.fechaUnica ?? DateTime.now())}',
TipoProgramacionAlarma.diaria => 'Diaria',
TipoProgramacionAlarma.diasSemana => 'Días: ${alarma.diasSemana.map(_diaCorto).join(', ')}',
TipoProgramacionAlarma.diasSemana =>
'Días: ${alarma.diasSemana.map(_diaCorto).join(', ')}',
};
}
@@ -934,39 +1036,39 @@ String _fechaCorta(DateTime fecha) =>
'${fecha.day.toString().padLeft(2, '0')}/${fecha.month.toString().padLeft(2, '0')}/${fecha.year}';
String _diaCorto(int dia) => switch (dia) {
DateTime.monday => 'Lun',
DateTime.tuesday => 'Mar',
DateTime.wednesday => 'Mié',
DateTime.thursday => 'Jue',
DateTime.friday => 'Vie',
DateTime.saturday => 'Sáb',
DateTime.sunday => 'Dom',
_ => '?',
};
DateTime.monday => 'Lun',
DateTime.tuesday => 'Mar',
DateTime.wednesday => 'Mié',
DateTime.thursday => 'Jue',
DateTime.friday => 'Vie',
DateTime.saturday => 'Sáb',
DateTime.sunday => 'Dom',
_ => '?',
};
String _diaLargo(int dia) => switch (dia) {
DateTime.monday => 'lunes',
DateTime.tuesday => 'martes',
DateTime.wednesday => 'miércoles',
DateTime.thursday => 'jueves',
DateTime.friday => 'viernes',
DateTime.saturday => 'sábado',
DateTime.sunday => 'domingo',
_ => 'día',
};
DateTime.monday => 'lunes',
DateTime.tuesday => 'martes',
DateTime.wednesday => 'miércoles',
DateTime.thursday => 'jueves',
DateTime.friday => 'viernes',
DateTime.saturday => 'sábado',
DateTime.sunday => 'domingo',
_ => 'día',
};
String _mes(int mes) => switch (mes) {
1 => 'enero',
2 => 'febrero',
3 => 'marzo',
4 => 'abril',
5 => 'mayo',
6 => 'junio',
7 => 'julio',
8 => 'agosto',
9 => 'septiembre',
10 => 'octubre',
11 => 'noviembre',
12 => 'diciembre',
_ => 'mes',
};
1 => 'enero',
2 => 'febrero',
3 => 'marzo',
4 => 'abril',
5 => 'mayo',
6 => 'junio',
7 => 'julio',
8 => 'agosto',
9 => 'septiembre',
10 => 'octubre',
11 => 'noviembre',
12 => 'diciembre',
_ => 'mes',
};
+21 -2
View File
@@ -411,9 +411,15 @@ class _GrabacionWidget extends StatelessWidget {
? estado.detenerGrabacion
: () => _mostrarDialogoGrabacion(context),
),
if (!activa)
IconButton.filledTonal(
tooltip: 'Abrir carpeta',
icon: const Icon(Icons.folder_open_rounded),
onPressed: () => _abrirCarpetaGrabaciones(context),
),
if (!activa && hayUltimaGrabacion)
IconButton.filledTonal(
tooltip: 'Abrir ?ltima grabaci?n',
tooltip: 'Abrir última grabación',
icon: const Icon(Icons.audio_file_rounded),
onPressed: () => _abrirUltimaGrabacion(context),
),
@@ -430,7 +436,20 @@ class _GrabacionWidget extends StatelessWidget {
if (!context.mounted) return;
if (!abierto) {
messenger.showSnackBar(
const SnackBar(content: Text('No se pudo abrir la ?ltima grabaci?n')),
const SnackBar(content: Text('No se pudo abrir la última grabación')),
);
}
}
Future<void> _abrirCarpetaGrabaciones(BuildContext context) async {
final messenger = ScaffoldMessenger.of(context);
final abierto = await estado.abrirDirectorioGrabacion();
if (!context.mounted) return;
if (!abierto) {
messenger.showSnackBar(
const SnackBar(
content: Text('No se pudo abrir la carpeta de grabaciones'),
),
);
}
}