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
+21 -2
View File
@@ -16,6 +16,7 @@ import 'tema/pluriwave_theme.dart';
import 'widgets/pluri_bottom_navigation.dart';
import 'widgets/pluri_icon.dart';
import 'widgets/pluri_layout.dart';
import 'widgets/pluri_onboarding_dialog.dart';
import 'widgets/pluri_wave_scaffold.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'servicios/servicio_alarmas_android.dart';
@@ -64,6 +65,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
EstadoRadio? _estadoSuscrito;
bool _alarmaInicialProcesada = false;
bool _alarmaSonandoActiva = false;
bool _onboardingInicialSolicitado = false;
String? _alarmaSonandoId;
static const _paginas = [
@@ -120,6 +122,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
_alarmaInicialProcesada = true;
unawaited(_procesarAlarmaInicial(alarmas));
}
if (!_onboardingInicialSolicitado) {
_onboardingInicialSolicitado = true;
unawaited(_mostrarOnboardingInicial());
}
}
@override
@@ -196,9 +202,17 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
}
}
Future<void> _mostrarOnboardingInicial() async {
await Future<void>.delayed(const Duration(milliseconds: 900));
if (!mounted || _alarmaSonandoActiva) return;
await PluriOnboardingDialog.mostrarSiProcede(context);
}
Future<void> _abrirAlarmaSonando(EventoAlarmaAndroid evento) async {
final estado = context.read<EstadoAlarmas>();
await estado.refrescarProgramacion();
if (estado.alarmas.isEmpty) {
await estado.cargarPersistidasSinRecalcular();
}
AlarmaMusical? alarma;
for (final item in estado.alarmas) {
if (item.id == evento.alarmaId) {
@@ -206,7 +220,12 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
break;
}
}
if (alarma == null || !mounted) return;
if (alarma == null || !mounted) {
debugPrint(
'[PluriWave][alarmas] evento sin alarma persistida id=${evento.alarmaId} accion=${evento.accion}',
);
return;
}
if (evento.accion.endsWith('.SKIP_NEXT')) {
await estado.saltarProxima(alarma.id);
if (!mounted) return;
+31 -10
View File
@@ -27,7 +27,8 @@ class EstadoAlarmas extends ChangeNotifier {
DiagnosticoAlarmasAndroid? _diagnostico;
Timer? _refresco;
Timer? _vigilancia;
final _alarmasVencidasController = StreamController<AlarmaMusical>.broadcast();
final _alarmasVencidasController =
StreamController<AlarmaMusical>.broadcast();
final Set<String> _ejecucionesEmitidas = {};
static const _margenDisparoLocal = Duration(seconds: 45);
bool _cargando = false;
@@ -57,7 +58,9 @@ class EstadoAlarmas extends ChangeNotifier {
try {
final config = await servicio.recalcularTodas();
_aplicar(config);
debugPrint('[PluriWave][alarmas] cargadas=${_alarmas.length} vacaciones=${_vacaciones.length} excepciones=${_excepciones.length}');
debugPrint(
'[PluriWave][alarmas] cargadas=${_alarmas.length} vacaciones=${_vacaciones.length} excepciones=${_excepciones.length}',
);
await _sincronizarTodas();
await cargarDiagnostico();
_activarRefresco();
@@ -71,16 +74,19 @@ class EstadoAlarmas extends ChangeNotifier {
}
Future<void> guardarAlarma(AlarmaMusical alarma) async {
debugPrint('[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}');
debugPrint(
'[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}',
);
final config = await servicio.guardarAlarma(alarma);
_aplicar(config);
try {
final guardada = _alarmas.firstWhere((a) => a.id == alarma.id);
debugPrint('[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}');
debugPrint(
'[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}',
);
await android.programar(guardada);
} catch (e) {
_error =
'Alarma guardada, pero Android no pudo programarla todavía: $e';
_error = 'Alarma guardada, pero Android no pudo programarla todavía: $e';
}
notifyListeners();
}
@@ -96,6 +102,12 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners();
}
Future<void> cargarPersistidasSinRecalcular() async {
final config = await servicio.cargar();
_aplicar(config);
notifyListeners();
}
void marcarEjecucionGestionada(AlarmaMusical alarma) {
final proxima = alarma.proximaEjecucion;
if (proxima == null) return;
@@ -110,6 +122,7 @@ class EstadoAlarmas extends ChangeNotifier {
debugPrint('[PluriWave][alarmas] eliminar id=$id');
final config = await servicio.eliminarAlarma(id);
_aplicar(config);
await android.detenerSonidoNativo(id);
await android.cancelar(id);
notifyListeners();
}
@@ -136,7 +149,9 @@ class EstadoAlarmas extends ChangeNotifier {
}
Future<void> guardarVacaciones(List<RangoVacaciones> vacaciones) async {
debugPrint('[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}');
debugPrint(
'[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}',
);
final config = await servicio.guardarVacaciones(vacaciones);
_aplicar(config);
await _sincronizarTodas();
@@ -145,7 +160,9 @@ class EstadoAlarmas extends ChangeNotifier {
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
final proxima = DateTime.now().add(Duration(minutes: minutos));
debugPrint('[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos proxima=${proxima.toIso8601String()}');
debugPrint(
'[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos proxima=${proxima.toIso8601String()}',
);
await android.ocultarNotificacionAlarma(alarma.id);
await android.programar(alarma.copyWith(proximaEjecucion: proxima));
}
@@ -184,7 +201,9 @@ class EstadoAlarmas extends ChangeNotifier {
}
Future<void> _sincronizarTodas() async {
debugPrint('[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}');
debugPrint(
'[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}',
);
for (final alarma in _alarmas) {
await android.programar(alarma);
}
@@ -224,7 +243,9 @@ class EstadoAlarmas extends ChangeNotifier {
continue;
}
if (_ejecucionesEmitidas.add(key)) {
debugPrint('[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}');
debugPrint(
'[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}',
);
_alarmasVencidasController.add(alarma);
}
}
+7 -9
View File
@@ -121,7 +121,8 @@ class EstadoRadio extends ChangeNotifier {
List<Emisora> get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda);
List<Emisora> get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas);
List<Emisora> get listaFavoritos => _ordenarEmisoras(_listaFavoritos);
List<GrupoFavoritos> get gruposFavoritos => List.unmodifiable(_gruposFavoritos);
List<GrupoFavoritos> get gruposFavoritos =>
List.unmodifiable(_gruposFavoritos);
List<Emisora> get emisorasCustom => _ordenarEmisoras(_emisorasCustom);
bool get cargandoPopulares => _cargandoPopulares;
bool get cargandoBusqueda => _cargandoBusqueda;
@@ -633,7 +634,7 @@ class EstadoRadio extends ChangeNotifier {
await Directory(ruta).create(recursive: true);
if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>(
'openDirectory',
'viewDirectory',
{'path': ruta},
);
return abierto ?? false;
@@ -650,13 +651,10 @@ class EstadoRadio extends ChangeNotifier {
}
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}');
if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>(
'openFile',
{
'path': archivo.path,
'mimeType': 'audio/*',
},
);
final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
'path': archivo.path,
'mimeType': 'audio/*',
});
return abierto ?? false;
}
return launchUrl(
+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'),
),
);
}
}
+13 -1
View File
@@ -74,7 +74,7 @@ class ServicioAlarmasAndroid {
debugPrint(
'[PluriWave][alarmas] programar id=${alarma.id} nombre=${alarma.nombre} proxima=${proxima.toIso8601String()} preaviso=${proxima.subtract(const Duration(minutes: 30)).toIso8601String()}',
);
await _channel.invokeMethod<void>('scheduleAlarm', {
final programada = await _channel.invokeMethod<bool>('scheduleAlarm', {
'id': alarma.id,
'title': alarma.nombre,
'triggerAtMillis': proxima.millisecondsSinceEpoch,
@@ -85,6 +85,11 @@ class ServicioAlarmasAndroid {
'fallbackSound': alarma.sonidoInterno.name,
'volume': alarma.volumen,
});
if (programada != true) {
throw StateError(
'Android no pudo programar una alarma exacta. Revisa el permiso de alarmas exactas.',
);
}
}
Future<void> cancelar(String alarmaId) =>
@@ -96,6 +101,13 @@ class ServicioAlarmasAndroid {
Future<void> detenerSonidoNativo(String alarmaId) =>
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
Future<bool> solicitarPermisoAlarmasExactas() async {
final abierto = await _channel.invokeMethod<bool>(
'requestExactAlarmPermission',
);
return abierto ?? false;
}
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
debugPrint('[PluriWave][alarmas] diagnostico android');
final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
+168
View File
@@ -0,0 +1,168 @@
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
class NotaVersionPluri {
const NotaVersionPluri({
required this.version,
required this.resumen,
required this.markdown,
});
final String version;
final String resumen;
final String markdown;
}
class ContenidoAyudaPluri {
const ContenidoAyudaPluri({
required this.onboarding,
required this.notas,
required this.versionActual,
});
final String onboarding;
final List<NotaVersionPluri> notas;
final String versionActual;
}
class ServicioContenidoApp {
static const _keyOnboardingVisto = 'pluri_onboarding_visto_v1';
static const _keyVersionVista = 'pluri_ultima_version_novedades_v1';
static const _versiones = ['0.1.47'];
Future<bool> debeMostrarInicio() async {
final prefs = await SharedPreferences.getInstance();
final info = await PackageInfo.fromPlatform();
final versionActual = info.version;
return !(prefs.getBool(_keyOnboardingVisto) ?? false) ||
prefs.getString(_keyVersionVista) != versionActual;
}
Future<void> marcarVisto() async {
final prefs = await SharedPreferences.getInstance();
final info = await PackageInfo.fromPlatform();
await prefs.setBool(_keyOnboardingVisto, true);
await prefs.setString(_keyVersionVista, info.version);
}
Future<ContenidoAyudaPluri> cargar(
String codigoIdioma, {
bool soloPendientes = false,
}) async {
final info = await PackageInfo.fromPlatform();
final prefs = await SharedPreferences.getInstance();
final ultimaVista = prefs.getString(_keyVersionVista);
final idioma = _idiomaSoportado(codigoIdioma);
final mostrarOnboarding =
!soloPendientes || !(prefs.getBool(_keyOnboardingVisto) ?? false);
final onboarding =
mostrarOnboarding
? await _cargarMarkdown(
'assets/content/onboarding/$idioma.md',
fallback: 'assets/content/onboarding/en.md',
)
: '';
final notas = <NotaVersionPluri>[];
for (final version in _versiones) {
if (soloPendientes &&
ultimaVista != null &&
_compararVersiones(version, ultimaVista.split('+').first) <= 0) {
continue;
}
final markdown = await _cargarMarkdown(
'assets/content/updates/$idioma/$version.md',
fallback: 'assets/content/updates/en/$version.md',
);
if (markdown.trim().isEmpty) continue;
notas.add(
NotaVersionPluri(
version: version,
resumen: _resumen(markdown),
markdown: markdown,
),
);
}
return ContenidoAyudaPluri(
onboarding: onboarding,
notas: notas,
versionActual: _versionCompleta(info),
);
}
Future<String> _cargarMarkdown(
String path, {
required String fallback,
}) async {
try {
return await rootBundle.loadString(path);
} catch (_) {
return rootBundle.loadString(fallback);
}
}
String _idiomaSoportado(String codigo) {
const soportados = {
'ar',
'bn',
'de',
'en',
'es',
'fr',
'hi',
'id',
'it',
'ja',
'pt',
'ru',
'zh',
};
return soportados.contains(codigo) ? codigo : 'en';
}
String _resumen(String markdown) {
for (final line in markdown.split('\n')) {
final limpia = line.trim();
if (limpia.toLowerCase().startsWith('resumen:')) {
return limpia.substring(limpia.indexOf(':') + 1).trim();
}
if (limpia.toLowerCase().startsWith('summary:')) {
return limpia.substring(limpia.indexOf(':') + 1).trim();
}
if (limpia.contains(':')) {
final etiqueta = limpia.substring(0, limpia.indexOf(':')).toLowerCase();
const etiquetasResumen = {
'résumé',
'zusammenfassung',
'riepilogo',
'resumo',
'ملخص',
'সারাংশ',
'सारांश',
'ringkasan',
'概要',
'摘要',
'резюме',
};
if (etiquetasResumen.contains(etiqueta)) {
return limpia.substring(limpia.indexOf(':') + 1).trim();
}
}
}
return '';
}
String _versionCompleta(PackageInfo info) =>
'${info.version}+${info.buildNumber}';
int _compararVersiones(String a, String b) {
final pa = a.split('.').map((e) => int.tryParse(e) ?? 0).toList();
final pb = b.split('.').map((e) => int.tryParse(e) ?? 0).toList();
for (var i = 0; i < 3; i++) {
final va = i < pa.length ? pa[i] : 0;
final vb = i < pb.length ? pb[i] : 0;
if (va != vb) return va.compareTo(vb);
}
return 0;
}
}
@@ -1,6 +1,8 @@
import '../modelos/alarma_musical.dart';
class ServicioProgramacionAlarmas {
static const Duration toleranciaDisparoInminente = Duration(seconds: 90);
DateTime? calcularProxima({
required AlarmaMusical alarma,
required DateTime desde,
@@ -24,25 +26,27 @@ class ServicioProgramacionAlarmas {
final primerCandidato =
alarma.tipoProgramacion == TipoProgramacionAlarma.unica
? inicio
: inicio.isAfter(desde)
: _sigueSiendoInminente(inicio, desde)
? inicio
: inicio.add(const Duration(days: 1));
return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.unica =>
primerCandidato.isAfter(desde) &&
_sigueSiendoInminente(primerCandidato, desde) &&
_esValida(alarma, primerCandidato, vacaciones, excepciones)
? primerCandidato
? _normalizarInminente(primerCandidato, desde)
: null,
TipoProgramacionAlarma.diaria => _buscarDiaria(
alarma,
primerCandidato,
desde,
vacaciones,
excepciones,
),
TipoProgramacionAlarma.diasSemana => _buscarPorDiasSemana(
alarma,
primerCandidato,
desde,
vacaciones,
excepciones,
),
@@ -60,12 +64,16 @@ class ServicioProgramacionAlarmas {
DateTime? _buscarDiaria(
AlarmaMusical alarma,
DateTime candidato,
DateTime desde,
List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones,
) {
var actual = candidato;
for (var i = 0; i < 370; i++) {
if (_esValida(alarma, actual, vacaciones, excepciones)) return actual;
if (_sigueSiendoInminente(actual, desde) &&
_esValida(alarma, actual, vacaciones, excepciones)) {
return _normalizarInminente(actual, desde);
}
actual = actual.add(const Duration(days: 1));
}
return null;
@@ -74,6 +82,7 @@ class ServicioProgramacionAlarmas {
DateTime? _buscarPorDiasSemana(
AlarmaMusical alarma,
DateTime candidato,
DateTime desde,
List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones,
) {
@@ -81,8 +90,9 @@ class ServicioProgramacionAlarmas {
var actual = candidato;
for (var i = 0; i < 370; i++) {
if (alarma.diasSemana.contains(actual.weekday) &&
_sigueSiendoInminente(actual, desde) &&
_esValida(alarma, actual, vacaciones, excepciones)) {
return actual;
return _normalizarInminente(actual, desde);
}
actual = actual.add(const Duration(days: 1));
}
@@ -111,4 +121,13 @@ class ServicioProgramacionAlarmas {
a.day == b.day &&
a.hour == b.hour &&
a.minute == b.minute;
bool _sigueSiendoInminente(DateTime candidato, DateTime desde) =>
candidato.isAfter(desde) ||
desde.difference(candidato) <= toleranciaDisparoInminente;
DateTime _normalizarInminente(DateTime candidato, DateTime desde) =>
candidato.isAfter(desde)
? candidato
: desde.add(const Duration(seconds: 2));
}
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
class PluriMarkdown extends StatelessWidget {
const PluriMarkdown(this.markdown, {super.key});
final String markdown;
@override
Widget build(BuildContext context) {
final widgets = <Widget>[];
for (final raw in markdown.split('\n')) {
final line = raw.trimRight();
if (line.trim().isEmpty) {
widgets.add(const SizedBox(height: 8));
} else if (line.startsWith('# ')) {
widgets.add(_Heading(line.substring(2), level: 1));
} else if (line.startsWith('## ')) {
widgets.add(_Heading(line.substring(3), level: 2));
} else if (line.startsWith('- ')) {
widgets.add(_Bullet(line.substring(2)));
} else if (!line.toLowerCase().startsWith('resumen:') &&
!line.toLowerCase().startsWith('summary:')) {
widgets.add(_Paragraph(line));
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widgets,
);
}
}
class _Heading extends StatelessWidget {
const _Heading(this.text, {required this.level});
final String text;
final int level;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: EdgeInsets.only(top: level == 1 ? 0 : 14, bottom: 8),
child: Text(
text,
style: (level == 1
? theme.textTheme.headlineSmall
: theme.textTheme.titleMedium)
?.copyWith(fontWeight: FontWeight.w900),
),
);
}
}
class _Paragraph extends StatelessWidget {
const _Paragraph(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(text, style: Theme.of(context).textTheme.bodyMedium),
);
}
class _Bullet extends StatelessWidget {
const _Bullet(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.auto_awesome_rounded,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(child: Text(text)),
],
),
);
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import '../servicios/servicio_contenido_app.dart';
import 'pluri_glass_surface.dart';
import 'pluri_markdown.dart';
class PluriOnboardingDialog {
PluriOnboardingDialog._();
static final _servicio = ServicioContenidoApp();
static Future<void> mostrarSiProcede(BuildContext context) async {
if (!await _servicio.debeMostrarInicio()) return;
if (!context.mounted) return;
await mostrar(context, soloPendientes: true);
await _servicio.marcarVisto();
}
static Future<void> mostrar(
BuildContext context, {
bool soloPendientes = false,
}) async {
final idioma = Localizations.localeOf(context).languageCode;
final contenido = await _servicio.cargar(
idioma,
soloPendientes: soloPendientes,
);
if (!context.mounted) return;
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => _PluriOnboardingContent(contenido: contenido),
);
}
}
class _PluriOnboardingContent extends StatelessWidget {
const _PluriOnboardingContent({required this.contenido});
final ContenidoAyudaPluri contenido;
@override
Widget build(BuildContext context) {
final labels = _labels(Localizations.localeOf(context).languageCode);
final size = MediaQuery.sizeOf(context);
return Dialog(
insetPadding: const EdgeInsets.all(16),
backgroundColor: Colors.transparent,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 720,
maxHeight: size.height * 0.86,
),
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(32),
glowColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.28),
child: Column(
children: [
Row(
children: [
Container(
width: 54,
height: 54,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.tertiary,
],
),
),
child: const Icon(
Icons.graphic_eq_rounded,
color: Colors.white,
),
),
const SizedBox(width: 14),
Expanded(
child: Text(
labels.title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
IconButton(
tooltip: labels.close,
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded),
),
],
),
const SizedBox(height: 12),
Expanded(
child: ListView(
children: [
if (contenido.onboarding.trim().isNotEmpty)
PluriMarkdown(contenido.onboarding),
if (contenido.notas.isNotEmpty) ...[
const SizedBox(height: 18),
Text(
labels.news,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 8),
for (final nota in contenido.notas)
ExpansionTile(
tilePadding: EdgeInsets.zero,
title: Text('v${nota.version}'),
subtitle:
nota.resumen.isEmpty ? null : Text(nota.resumen),
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: PluriMarkdown(nota.markdown),
),
],
),
],
],
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.check_rounded),
label: Text(labels.start),
),
),
],
),
),
),
);
}
}
_OnboardingLabels _labels(String languageCode) {
return switch (languageCode) {
'es' => const _OnboardingLabels(
title: 'Bienvenido a PluriWave',
news: 'Novedades',
start: 'Empezar',
close: 'Cerrar',
),
'fr' => const _OnboardingLabels(
title: 'Bienvenue sur PluriWave',
news: 'Nouveautés',
start: 'Commencer',
close: 'Fermer',
),
'de' => const _OnboardingLabels(
title: 'Willkommen bei PluriWave',
news: 'Neuigkeiten',
start: 'Starten',
close: 'Schließen',
),
'it' => const _OnboardingLabels(
title: 'Benvenuto in PluriWave',
news: 'Novità',
start: 'Inizia',
close: 'Chiudi',
),
'pt' => const _OnboardingLabels(
title: 'Bem-vindo ao PluriWave',
news: 'Novidades',
start: 'Começar',
close: 'Fechar',
),
_ => const _OnboardingLabels(
title: 'Welcome to PluriWave',
news: 'Whats new',
start: 'Start',
close: 'Close',
),
};
}
class _OnboardingLabels {
const _OnboardingLabels({
required this.title,
required this.news,
required this.start,
required this.close,
});
final String title;
final String news;
final String start;
final String close;
}