feat(app): add onboarding and harden alarms
This commit is contained in:
@@ -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?>>(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user