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
+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));
}