Compare commits
1 Commits
main
..
287295a345
| Author | SHA1 | Date | |
|---|---|---|---|
| 287295a345 |
@@ -1,169 +0,0 @@
|
|||||||
name: Build & Deploy PluriWave
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, PRO]
|
|
||||||
|
|
||||||
env:
|
|
||||||
PATH: /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
|
|
||||||
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
|
|
||||||
KEYSTORE_PATH: /Users/freetlab/.openclaw/workspace/.secure/pluriwave/pluriwave-upload.jks
|
|
||||||
KEYSTORE_ALIAS: pluriwave-upload
|
|
||||||
PLAY_PACKAGE_NAME: es.freetimelab.pluriwave
|
|
||||||
CURRENT_REF: ${{ gitea.ref }}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
analizar:
|
|
||||||
name: Análisis de código
|
|
||||||
runs-on: [self-hosted, macos, arm64, flutter]
|
|
||||||
steps:
|
|
||||||
- name: Clonar rama actual
|
|
||||||
run: |
|
|
||||||
BRANCH="${CURRENT_REF#refs/heads/}"
|
|
||||||
git clone https://ShanaiaBot:${{ secrets.GITEA_TOKEN }}@git.freetimelab.es/FreeTLab/pluriwave.git .
|
|
||||||
git fetch origin "$BRANCH"
|
|
||||||
git checkout "$BRANCH"
|
|
||||||
|
|
||||||
- name: Obtener dependencias
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Analizar código
|
|
||||||
run: flutter analyze --no-fatal-infos --no-fatal-warnings
|
|
||||||
|
|
||||||
- name: Ejecutar tests criticos
|
|
||||||
timeout-minutes: 15
|
|
||||||
run: |
|
|
||||||
flutter test test/servicios/servicio_programacion_alarmas_test.dart test/estado/estado_alarmas_test.dart --concurrency=1 --timeout=60s
|
|
||||||
|
|
||||||
- name: Limpiar procesos Flutter de tests
|
|
||||||
if: always()
|
|
||||||
run: pkill -f 'flutter_tester|flutter_tools.snapshot|dartaotruntime' 2>/dev/null || true
|
|
||||||
|
|
||||||
build:
|
|
||||||
name: Build APK + AAB release
|
|
||||||
runs-on: [self-hosted, macos, arm64, flutter]
|
|
||||||
needs: analizar
|
|
||||||
steps:
|
|
||||||
- name: Clonar rama actual
|
|
||||||
run: |
|
|
||||||
BRANCH="${CURRENT_REF#refs/heads/}"
|
|
||||||
git clone https://ShanaiaBot:${{ secrets.GITEA_TOKEN }}@git.freetimelab.es/FreeTLab/pluriwave.git .
|
|
||||||
git fetch origin "$BRANCH"
|
|
||||||
git checkout "$BRANCH"
|
|
||||||
|
|
||||||
- name: Configurar keystore de firma
|
|
||||||
env:
|
|
||||||
KEYSTORE_PASSWORD: ${{ secrets.PLURIWAVE_KEYSTORE_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
if [ ! -f "$KEYSTORE_PATH" ]; then
|
|
||||||
echo "ERROR: Keystore no encontrado en $KEYSTORE_PATH"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "storeFile=$KEYSTORE_PATH" > android/key.properties
|
|
||||||
echo "storePassword=$KEYSTORE_PASSWORD" >> android/key.properties
|
|
||||||
echo "keyAlias=$KEYSTORE_ALIAS" >> android/key.properties
|
|
||||||
echo "keyPassword=$KEYSTORE_PASSWORD" >> android/key.properties
|
|
||||||
echo "✅ Keystore configurado"
|
|
||||||
|
|
||||||
- name: Bump versión patch + commit
|
|
||||||
run: |
|
|
||||||
BRANCH="${CURRENT_REF#refs/heads/}"
|
|
||||||
git config user.name "ShanaiaBot"
|
|
||||||
git config user.email "shanaia@freetimelab.es"
|
|
||||||
CURRENT=$(grep '^version:' pubspec.yaml | awk '{print $2}')
|
|
||||||
SEMVER=$(echo "$CURRENT" | cut -d'+' -f1)
|
|
||||||
BUILD=$(echo "$CURRENT" | cut -d'+' -f2)
|
|
||||||
MAJOR=$(echo "$SEMVER" | cut -d. -f1)
|
|
||||||
MINOR=$(echo "$SEMVER" | cut -d. -f2)
|
|
||||||
PATCH=$(echo "$SEMVER" | cut -d. -f3)
|
|
||||||
NEW_PATCH=$((PATCH + 1))
|
|
||||||
NEW_BUILD=$((BUILD + 1))
|
|
||||||
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}"
|
|
||||||
sed -i '' "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
|
|
||||||
git add pubspec.yaml
|
|
||||||
git commit -m "chore: bump version to ${NEW_VERSION} [ci skip]"
|
|
||||||
git push origin "HEAD:${BRANCH}"
|
|
||||||
|
|
||||||
- name: Extraer versión
|
|
||||||
id: version
|
|
||||||
run: |
|
|
||||||
VERSION=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f1)
|
|
||||||
BUILD_NUMBER=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f2)
|
|
||||||
COMMIT=$(git rev-parse --short HEAD)
|
|
||||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "build_number=$BUILD_NUMBER" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "commit=$COMMIT" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Obtener dependencias
|
|
||||||
run: flutter pub get
|
|
||||||
|
|
||||||
- name: Build APK release
|
|
||||||
run: flutter build apk --release
|
|
||||||
|
|
||||||
- name: Build AAB release
|
|
||||||
run: flutter build appbundle --release
|
|
||||||
|
|
||||||
- name: Publicar en ftl-builds (Zimaboard)
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
|
||||||
APK_NOMBRE="pluriwave-v${VERSION}.apk"
|
|
||||||
AAB_NOMBRE="pluriwave-v${VERSION}.aab"
|
|
||||||
DESTINO="/opt/ftl-builds/builds/pluriwave/v${VERSION}"
|
|
||||||
SSH_KEY="/Users/freetlab/.openclaw/workspace/.secure/zimaboard_ed25519"
|
|
||||||
|
|
||||||
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ShanaiaBot@192.168.0.33 "mkdir -p ${DESTINO}"
|
|
||||||
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
|
|
||||||
build/app/outputs/flutter-apk/app-release.apk \
|
|
||||||
"ShanaiaBot@192.168.0.33:${DESTINO}/${APK_NOMBRE}"
|
|
||||||
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
|
|
||||||
build/app/outputs/bundle/release/app-release.aab \
|
|
||||||
"ShanaiaBot@192.168.0.33:${DESTINO}/${AAB_NOMBRE}"
|
|
||||||
echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION}"
|
|
||||||
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION}"
|
|
||||||
|
|
||||||
- name: Preparar credenciales de Google Play
|
|
||||||
if: ${{ gitea.ref == 'refs/heads/PRO' }}
|
|
||||||
env:
|
|
||||||
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
|
|
||||||
run: |
|
|
||||||
if [ -z "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then
|
|
||||||
echo "ERROR: falta el secreto GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
mkdir -p fastlane/credentials
|
|
||||||
printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" > fastlane/credentials/google-play-service-account.json
|
|
||||||
|
|
||||||
- name: Instalar Fastlane
|
|
||||||
if: ${{ gitea.ref == 'refs/heads/PRO' }}
|
|
||||||
run: |
|
|
||||||
gem list -i fastlane >/dev/null 2>&1 || gem install fastlane --no-document
|
|
||||||
|
|
||||||
- name: Publicar AAB en Google Play Internal Testing
|
|
||||||
if: ${{ gitea.ref == 'refs/heads/PRO' }}
|
|
||||||
env:
|
|
||||||
PLAY_JSON_KEY_PATH: fastlane/credentials/google-play-service-account.json
|
|
||||||
PLAY_AAB_PATH: build/app/outputs/bundle/release/app-release.aab
|
|
||||||
PLAY_TRACK: internal
|
|
||||||
PLAY_RELEASE_STATUS: completed
|
|
||||||
run: fastlane android upload_internal
|
|
||||||
|
|
||||||
- name: Notificar Telegram
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
|
||||||
COMMIT="${{ steps.version.outputs.commit }}"
|
|
||||||
BRANCH="${CURRENT_REF#refs/heads/}"
|
|
||||||
BOT_TOKEN=$(plutil -extract 'EnvironmentVariables:TELEGRAM_BOT_TOKEN' raw /Users/freetlab/Library/LaunchAgents/ai.openclaw.gateway.plist 2>/dev/null || echo "")
|
|
||||||
if [ -z "$BOT_TOKEN" ]; then exit 0; fi
|
|
||||||
if [ "${{ job.status }}" = "success" ]; then
|
|
||||||
MSG="✅ *PluriWave* v${VERSION} · rama ${BRANCH} · ${COMMIT}%0AAPK + AAB generados"
|
|
||||||
if [ "$BRANCH" = "PRO" ]; then
|
|
||||||
MSG="${MSG}%0APublicado en Google Play · Internal Testing"
|
|
||||||
else
|
|
||||||
MSG="${MSG}%0APublicado en builds.freetimelab.es"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
MSG="❌ *PluriWave* build FAILED · rama ${BRANCH} · ${COMMIT}"
|
|
||||||
fi
|
|
||||||
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
|
|
||||||
-d "chat_id=221721467" -d "parse_mode=Markdown" -d "text=${MSG}" || true
|
|
||||||
@@ -11,10 +11,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
flutter-ci:
|
flutter-ci:
|
||||||
name: Test + Build
|
name: Test + Build
|
||||||
#runs-on: macos-14
|
runs-on: macmini-flutter
|
||||||
runs-on: [self-hosted, macos, arm64, flutter]
|
|
||||||
env:
|
|
||||||
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
@@ -32,7 +32,6 @@ migrate_working_dir/
|
|||||||
.pub/
|
.pub/
|
||||||
/build/
|
/build/
|
||||||
/coverage/
|
/coverage/
|
||||||
.atl/
|
|
||||||
|
|
||||||
# Symbolication related
|
# Symbolication related
|
||||||
app.*.symbols
|
app.*.symbols
|
||||||
|
|||||||
@@ -1,19 +1,5 @@
|
|||||||
# Changelog — PluriWave
|
# Changelog — PluriWave
|
||||||
|
|
||||||
## [0.5.0] — 2026-04-04
|
|
||||||
|
|
||||||
### Añadido
|
|
||||||
- **VisualizadorAudio** — visualizador de barras animadas en `PantallaReproductor`. 24 barras verticales con movimiento orgánico pseudo-aleatorio (combinación de ondas seno con fases distintas). Se activa al reproducir y decae suavemente al parar. Sin FFT real ni permisos de micrófono — animación simulada visualmente equivalente a las apps de streaming.
|
|
||||||
- **IndicadorReproduccion** — versión compacta de 3 barras para el `MiniReproductor`. Reemplaza el icono estático de radio y pulsa mientras hay audio activo.
|
|
||||||
|
|
||||||
## [0.4.0] — 2026-04-04
|
|
||||||
|
|
||||||
### Añadido
|
|
||||||
- **PantallaReproductor** — pantalla completa del reproductor. Accesible tocando MiniReproductor o cualquier emisora. Incluye: artwork/logo grande con sombra animada al reproducir, nombre + chips info (país, idioma), codec/bitrate, controles play/pause/stop con indicador "en vivo", botón favorito (toggle), widget de timer (iniciar/cancelar desde la pantalla), animación de entrada slide-up. Transición pageRoute desde cualquier pantalla.
|
|
||||||
- **PantallaAjustes** — pantalla de ajustes básica (tab nuevo en NavigationBar). Muestra estado del sistema (filtro emisoras, audio background), conteo de favoritos, preview de features próximas (Export/Import, radio personalizada, ecualizador).
|
|
||||||
- **MiniReproductor** — ahora es tappable: toca la barra para abrir PantallaReproductor.
|
|
||||||
- **NavigationBar** — añadido tab "Ajustes" (4 destinos: Inicio/Buscar/Favoritos/Ajustes).
|
|
||||||
|
|
||||||
## [0.3.0] — 2026-04-04
|
## [0.3.0] — 2026-04-04
|
||||||
|
|
||||||
### Fixes (prioridad alta — petición WhikY)
|
### Fixes (prioridad alta — petición WhikY)
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
# TODO
|
|
||||||
|
|
||||||
## Internacionalización AAA
|
|
||||||
|
|
||||||
- [x] Diseñar una base de internacionalización profesional con ficheros ARB separados por idioma.
|
|
||||||
- [x] Permitir que el usuario cambie el idioma manualmente desde la aplicación, sin depender únicamente del idioma del sistema.
|
|
||||||
- [x] Añadir traducción inicial español/inglés para el shell, navegación, timer de sueño y selector de idioma.
|
|
||||||
- [x] Añadir soporte inicial para un conjunto amplio de idiomas muy hablados: inglés, español, chino, hindi, árabe, portugués, francés, ruso, alemán, japonés, indonesio, bengalí e italiano.
|
|
||||||
- [x] Ejecutar escaneo UTF-8 sobre ARB/código tocado y corregir corrupciones visibles en los textos migrados.
|
|
||||||
- [ ] Validar no solo el guardado UTF-8 en código, sino también el render real en la aplicación para acentos, ñ, signos, alfabetos no latinos y direcciones RTL.
|
|
||||||
- [ ] Repasar absolutamente todos los literales de la aplicación en todas las pantallas, componentes, servicios con mensajes visibles y notificaciones.
|
|
||||||
- [ ] Soportar formatos locales de fecha, hora, números y duración usando helpers centralizados.
|
|
||||||
- [ ] Resolver correctamente singular/plural y variantes por cantidad, por ejemplo `1 emisora` vs `2 emisoras`.
|
|
||||||
- [ ] Revisar profesionalmente todas las traducciones nuevas con hablantes nativos o servicio especializado antes de considerarlas definitivas.
|
|
||||||
- [ ] Preparar traducciones adicionales si se decide ampliar más allá del conjunto inicial.
|
|
||||||
- [ ] Revisar la aplicación de Farolero como referencia para detectar el conjunto de idiomas que nos interesa mantener.
|
|
||||||
- [ ] Verificar que no queda ningún literal hardcodeado fuera del sistema de traducciones.
|
|
||||||
|
|
||||||
## UX y accesibilidad visual
|
|
||||||
|
|
||||||
- [x] Revisar los paneles informativos superiores de cada pantalla: recuperar márgenes internos elegantes para que el texto no quede pegado a los bordes.
|
|
||||||
- [x] Añadir comportamiento adaptativo en el header premium para escalas de texto grandes y pantallas estrechas.
|
|
||||||
- [ ] Probar la aplicación con escalas de texto grandes/muy grandes del sistema en dispositivo real o golden tests.
|
|
||||||
- [ ] Diseñar una solución elegante para textos largos en todos los paneles secundarios: reflow, límites razonables, scroll, wraps controlados y jerarquías que mantengan la estética AAA.
|
|
||||||
|
|
||||||
## Grabaciones
|
|
||||||
|
|
||||||
- [x] Añadir en Ajustes un acceso elegante para abrir la carpeta de grabaciones con el gestor de ficheros del sistema mediante intent.
|
|
||||||
- [x] Añadir configuración de tamaño máximo de fichero de grabación; valor por defecto: 500 MB.
|
|
||||||
- [x] Detener automáticamente la grabación si se para o pausa la reproducción.
|
|
||||||
- [x] Detener automáticamente la grabación si se cambia de emisora.
|
|
||||||
- [ ] Probar en Android real que el intent de carpeta funciona con rutas internas y rutas escogidas por el usuario.
|
|
||||||
|
|
||||||
## Búsqueda de emisoras
|
|
||||||
|
|
||||||
- [x] Añadir filtro de calidad mínima de reproducción en kbps en el buscador de emisoras.
|
|
||||||
|
|
||||||
## Favoritos
|
|
||||||
|
|
||||||
- [x] Revisar el sistema de guardado de favoritos en instalaciones nuevas y migradas: inicialización de SQLite, creación de ruta/base de datos, migraciones de columnas y refresco de estado tras guardar. Reporte: en un móvil no se están guardando favoritos.
|
|
||||||
- [ ] Añadir tests de regresión para favoritos en base de datos real/migrada, incluyendo esquemas antiguos y primera instalación limpia.
|
|
||||||
|
|
||||||
## Agrupaciones de favoritos
|
|
||||||
|
|
||||||
- [x] Permitir crear listas de favoritos con nombre corto configurable por el usuario desde Ajustes.
|
|
||||||
- [x] Mantener siempre un grupo interno por defecto traducible llamado "Sin asignar", no editable y no borrable.
|
|
||||||
- [x] Gestionar desde la vista Favoritos qué emisoras pertenecen a cada agrupación/lista.
|
|
||||||
- [x] Diseñar migración SQLite base para asociar favoritos existentes al grupo "Sin asignar" sin perder datos.
|
|
||||||
- [x] Completar UI en Ajustes para crear, editar y borrar listas de favoritos.
|
|
||||||
- [x] Completar UI en Favoritos para mover emisoras entre listas.
|
|
||||||
@@ -23,11 +23,6 @@ linter:
|
|||||||
rules:
|
rules:
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||||
cancel_subscriptions: true
|
|
||||||
close_sinks: true
|
|
||||||
unawaited_futures: true
|
|
||||||
prefer_final_locals: true
|
|
||||||
avoid_dynamic_calls: true
|
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
# Additional information about this file can be found at
|
||||||
# https://dart.dev/guides/language/analysis-options
|
# https://dart.dev/guides/language/analysis-options
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
|
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
import java.util.Properties
|
|
||||||
|
|
||||||
val keystoreProperties = Properties()
|
|
||||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
|
||||||
if (keystorePropertiesFile.exists()) {
|
|
||||||
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun secret(name: String, propertyName: String): String? =
|
|
||||||
keystoreProperties.getProperty(propertyName)?.takeIf { it.isNotBlank() }
|
|
||||||
?: System.getenv(name)?.takeIf { it.isNotBlank() }
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "es.freetimelab.pluriwave"
|
namespace = "es.freetimelab.pluriwave"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -31,38 +20,21 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "es.freetimelab.pluriwave"
|
applicationId = "es.freetimelab.pluriwave"
|
||||||
|
// You can update the following values to match your application needs.
|
||||||
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
|
||||||
create("release") {
|
|
||||||
val storeFilePath = secret("KEYSTORE_PATH", "storeFile")
|
|
||||||
val storePasswordValue = secret("KEYSTORE_PASSWORD", "storePassword")
|
|
||||||
val keyAliasValue = secret("KEYSTORE_ALIAS", "keyAlias")
|
|
||||||
val keyPasswordValue = secret("KEY_PASSWORD", "keyPassword")
|
|
||||||
|
|
||||||
if (!storeFilePath.isNullOrBlank()) {
|
|
||||||
storeFile = file(storeFilePath)
|
|
||||||
}
|
|
||||||
if (!storePasswordValue.isNullOrBlank()) {
|
|
||||||
storePassword = storePasswordValue
|
|
||||||
}
|
|
||||||
if (!keyAliasValue.isNullOrBlank()) {
|
|
||||||
keyAlias = keyAliasValue
|
|
||||||
}
|
|
||||||
if (!keyPasswordValue.isNullOrBlank()) {
|
|
||||||
keyPassword = keyPasswordValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
// TODO: Add your own signing config for the release build.
|
||||||
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,32 +3,19 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/>
|
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
|
||||||
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
|
|
||||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM"/>
|
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
|
||||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
|
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="PluriWave"
|
android:label="PluriWave"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round">
|
||||||
android:networkSecurityConfig="@xml/network_security_config">
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:showWhenLocked="true"
|
|
||||||
android:turnScreenOn="true"
|
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
@@ -52,11 +39,6 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".PluriWaveAlarmService"
|
|
||||||
android:foregroundServiceType="mediaPlayback|systemExempted"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
<!-- Receptor de controles de media (auriculares, notificación) -->
|
<!-- Receptor de controles de media (auriculares, notificación) -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||||
@@ -66,43 +48,6 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name=".PluriWaveAlarmReceiver"
|
|
||||||
android:exported="false"
|
|
||||||
android:directBootAware="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="es.freetimelab.pluriwave.alarm.FIRE"/>
|
|
||||||
<action android:name="es.freetimelab.pluriwave.alarm.PRE_NOTICE"/>
|
|
||||||
<action android:name="es.freetimelab.pluriwave.alarm.SKIP_NEXT"/>
|
|
||||||
<action android:name="es.freetimelab.pluriwave.alarm.POSTPONE_NEXT"/>
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<receiver
|
|
||||||
android:name=".PluriWaveBootReceiver"
|
|
||||||
android:exported="true"
|
|
||||||
android:directBootAware="true">
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED"/>
|
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
|
||||||
<action android:name="android.intent.action.USER_UNLOCKED"/>
|
|
||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
|
|
||||||
<action android:name="android.intent.action.TIME_SET"/>
|
|
||||||
<action android:name="android.intent.action.TIMEZONE_CHANGED"/>
|
|
||||||
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED"/>
|
|
||||||
</intent-filter>
|
|
||||||
</receiver>
|
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="androidx.core.content.FileProvider"
|
|
||||||
android:authorities="${applicationId}.fileprovider"
|
|
||||||
android:exported="false"
|
|
||||||
android:grantUriPermissions="true">
|
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/pluriwave_file_paths" />
|
|
||||||
</provider>
|
|
||||||
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
|||||||
@@ -1,737 +0,0 @@
|
|||||||
package es.freetimelab.pluriwave
|
|
||||||
|
|
||||||
import android.app.AlarmManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.util.Calendar
|
|
||||||
import java.util.TimeZone
|
|
||||||
|
|
||||||
class AlarmScheduler(private val context: Context) {
|
|
||||||
private val tag = "PluriWave"
|
|
||||||
private val appContext = context.applicationContext
|
|
||||||
private val alarmManager =
|
|
||||||
appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
||||||
|
|
||||||
fun scheduleAlarm(
|
|
||||||
id: String,
|
|
||||||
title: String,
|
|
||||||
triggerAtMillis: Long,
|
|
||||||
preNoticeAtMillis: Long,
|
|
||||||
stationName: String?,
|
|
||||||
stationUrl: String?,
|
|
||||||
fallbackSound: String?,
|
|
||||||
volume: Float,
|
|
||||||
hour: Int? = null,
|
|
||||||
minute: Int? = null,
|
|
||||||
scheduleType: String? = null,
|
|
||||||
weekdays: List<Int> = emptyList(),
|
|
||||||
oneShotDateMillis: Long? = null,
|
|
||||||
snoozeUntilMillis: Long? = null,
|
|
||||||
snoozeOriginMillis: Long? = null,
|
|
||||||
lastHandledAtMillis: Long? = null,
|
|
||||||
soundOnVacation: Boolean = true,
|
|
||||||
snoozeMinutes: Int = 5,
|
|
||||||
fallbackStationName: String? = null,
|
|
||||||
fallbackStationUrl: String? = null,
|
|
||||||
fadeInSegundos: Int = 0
|
|
||||||
): Boolean {
|
|
||||||
val existing = readSpec(id)
|
|
||||||
val preservedSnooze = preserveNativeSnooze(
|
|
||||||
existing = existing,
|
|
||||||
requestedTriggerAtMillis = triggerAtMillis,
|
|
||||||
requestedSnoozeUntilMillis = snoozeUntilMillis
|
|
||||||
)
|
|
||||||
val spec = NativeAlarmSpec(
|
|
||||||
id = id,
|
|
||||||
title = title,
|
|
||||||
enabled = true,
|
|
||||||
triggerAtMillis = triggerAtMillis,
|
|
||||||
preNoticeAtMillis = preNoticeAtMillis,
|
|
||||||
hour = hour ?: localHour(triggerAtMillis),
|
|
||||||
minute = minute ?: localMinute(triggerAtMillis),
|
|
||||||
scheduleType = scheduleType ?: SCHEDULE_UNICA,
|
|
||||||
weekdays = weekdays,
|
|
||||||
oneShotDateMillis = oneShotDateMillis,
|
|
||||||
snoozeUntilMillis = preservedSnooze?.first ?: snoozeUntilMillis,
|
|
||||||
snoozeOriginMillis = preservedSnooze?.second ?: snoozeOriginMillis,
|
|
||||||
lastHandledAtMillis = lastHandledAtMillis,
|
|
||||||
soundOnVacation = soundOnVacation,
|
|
||||||
snoozeMinutes = sanitizeSnoozeMinutes(snoozeMinutes),
|
|
||||||
stationName = stationName,
|
|
||||||
stationUrl = stationUrl,
|
|
||||||
fallbackStationName = fallbackStationName,
|
|
||||||
fallbackStationUrl = fallbackStationUrl,
|
|
||||||
fallbackSound = fallbackSound,
|
|
||||||
volume = volume.coerceIn(0f, 1f),
|
|
||||||
fadeInSegundos = fadeInSegundos.coerceIn(0, 60),
|
|
||||||
timezoneId = TimeZone.getDefault().id
|
|
||||||
)
|
|
||||||
return scheduleSpec(spec, persistOnSuccess = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scheduleSpec(spec: NativeAlarmSpec, persistOnSuccess: Boolean): Boolean {
|
|
||||||
val nextTrigger = computeNextTriggerMillis(spec)
|
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"alarm.schedule id=${spec.id} title=${spec.title} trigger=$nextTrigger type=${spec.scheduleType} snooze=${spec.snoozeUntilMillis} canExact=${canScheduleExactAlarms()}"
|
|
||||||
)
|
|
||||||
if (nextTrigger == null) {
|
|
||||||
Log.d(tag, "alarm.schedule no next trigger id=${spec.id}")
|
|
||||||
removeScheduledAlarm(spec.id)
|
|
||||||
cancelPending("fire", pendingFireIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
|
|
||||||
cancelPending("show", pendingShowIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
|
|
||||||
cancelPending("preNotice", pendingPreNoticeIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
val scheduledSpec = spec.copy(
|
|
||||||
triggerAtMillis = nextTrigger,
|
|
||||||
preNoticeAtMillis = if (spec.snoozeUntilMillis == null) {
|
|
||||||
nextTrigger - PRE_NOTICE_MILLIS
|
|
||||||
} else {
|
|
||||||
0L
|
|
||||||
}
|
|
||||||
)
|
|
||||||
val alarmIntent = fireIntent(scheduledSpec)
|
|
||||||
val showIntent = showIntent(scheduledSpec)
|
|
||||||
|
|
||||||
val mainScheduled = scheduleMainAlarm(
|
|
||||||
scheduledSpec.id,
|
|
||||||
scheduledSpec.triggerAtMillis,
|
|
||||||
showIntent,
|
|
||||||
alarmIntent
|
|
||||||
)
|
|
||||||
if (!mainScheduled) {
|
|
||||||
Log.w(tag, "alarm.schedule main failed but keeping spec for future resync id=${scheduledSpec.id}")
|
|
||||||
saveScheduledAlarm(scheduledSpec)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (persistOnSuccess) {
|
|
||||||
saveScheduledAlarm(scheduledSpec)
|
|
||||||
}
|
|
||||||
schedulePreNotice(scheduledSpec)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun schedulePreNotice(spec: NativeAlarmSpec) {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
if (spec.snoozeUntilMillis != null) {
|
|
||||||
cancelPending("preNotice", pendingPreNoticeIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
|
|
||||||
Log.d(tag, "alarm.schedule preNotice skipped for snooze id=${spec.id}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (spec.preNoticeAtMillis > now) {
|
|
||||||
try {
|
|
||||||
alarmManager.setExactAndAllowWhileIdle(
|
|
||||||
AlarmManager.RTC_WAKEUP,
|
|
||||||
spec.preNoticeAtMillis,
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
appContext,
|
|
||||||
requestCode(spec.id, 3),
|
|
||||||
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
|
|
||||||
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
|
|
||||||
putExtra(
|
|
||||||
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
|
|
||||||
spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
|
||||||
)
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Log.d(tag, "alarm.schedule preNotice OK id=${spec.id}")
|
|
||||||
} catch (_: SecurityException) {
|
|
||||||
Log.w(tag, "alarm.schedule preNotice SecurityException id=${spec.id}")
|
|
||||||
}
|
|
||||||
} else if (spec.triggerAtMillis > now) {
|
|
||||||
appContext.sendBroadcast(
|
|
||||||
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
|
|
||||||
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
|
|
||||||
putExtra(
|
|
||||||
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
|
|
||||||
spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Log.d(tag, "alarm.schedule preNotice immediate id=${spec.id}")
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "alarm.schedule preNotice skipped id=${spec.id}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scheduleMainAlarm(
|
|
||||||
id: String,
|
|
||||||
triggerAtMillis: Long,
|
|
||||||
showIntent: PendingIntent,
|
|
||||||
alarmIntent: PendingIntent
|
|
||||||
): Boolean {
|
|
||||||
try {
|
|
||||||
alarmManager.setAlarmClock(
|
|
||||||
AlarmManager.AlarmClockInfo(triggerAtMillis, showIntent),
|
|
||||||
alarmIntent
|
|
||||||
)
|
|
||||||
Log.d(tag, "alarm.schedule setAlarmClock OK id=$id")
|
|
||||||
return true
|
|
||||||
} catch (error: SecurityException) {
|
|
||||||
Log.e(tag, "alarm.schedule setAlarmClock SecurityException id=$id", error)
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(tag, "alarm.schedule setAlarmClock ERROR id=$id", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return try {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && alarmManager.canScheduleExactAlarms()) {
|
|
||||||
alarmManager.setExactAndAllowWhileIdle(
|
|
||||||
AlarmManager.RTC_WAKEUP,
|
|
||||||
triggerAtMillis,
|
|
||||||
alarmIntent
|
|
||||||
)
|
|
||||||
Log.d(tag, "alarm.schedule setExactAndAllowWhileIdle fallback OK id=$id")
|
|
||||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
|
||||||
Log.e(tag, "alarm.schedule exact permission missing; refusing inexact fallback id=$id")
|
|
||||||
return false
|
|
||||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
alarmManager.setAndAllowWhileIdle(
|
|
||||||
AlarmManager.RTC_WAKEUP,
|
|
||||||
triggerAtMillis,
|
|
||||||
alarmIntent
|
|
||||||
)
|
|
||||||
Log.d(tag, "alarm.schedule setAndAllowWhileIdle fallback OK id=$id")
|
|
||||||
} else {
|
|
||||||
alarmManager.set(
|
|
||||||
AlarmManager.RTC_WAKEUP,
|
|
||||||
triggerAtMillis,
|
|
||||||
alarmIntent
|
|
||||||
)
|
|
||||||
Log.d(tag, "alarm.schedule set fallback OK id=$id")
|
|
||||||
}
|
|
||||||
true
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(tag, "alarm.schedule fallback ERROR id=$id", error)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onAlarmFired(id: String) {
|
|
||||||
val spec = readSpec(id) ?: return
|
|
||||||
val firedAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
|
||||||
saveHandledOccurrence(id, firedAt)
|
|
||||||
val next = spec.copy(
|
|
||||||
snoozeUntilMillis = null,
|
|
||||||
snoozeOriginMillis = null,
|
|
||||||
lastHandledAtMillis = firedAt,
|
|
||||||
enabled = spec.scheduleType != SCHEDULE_UNICA
|
|
||||||
)
|
|
||||||
if (next.enabled) {
|
|
||||||
scheduleSpec(next, persistOnSuccess = true)
|
|
||||||
} else {
|
|
||||||
removeScheduledAlarm(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun skipNext(id: String) {
|
|
||||||
val spec = readSpec(id) ?: return
|
|
||||||
val next = spec.copy(
|
|
||||||
snoozeUntilMillis = null,
|
|
||||||
snoozeOriginMillis = null,
|
|
||||||
lastHandledAtMillis = spec.triggerAtMillis,
|
|
||||||
enabled = spec.scheduleType != SCHEDULE_UNICA
|
|
||||||
)
|
|
||||||
if (next.enabled) {
|
|
||||||
scheduleSpec(next, persistOnSuccess = true)
|
|
||||||
} else {
|
|
||||||
cancelAlarm(id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snoozes using the SAME anchor as [postponeNext] (Design 2.2): the
|
|
||||||
* occurrence time + minutes, clamped to now + minutes when the target is
|
|
||||||
* already past. Returns the resulting snooze so the caller can report it
|
|
||||||
* back to Flutter (single source of truth), or null if the spec is gone.
|
|
||||||
*/
|
|
||||||
fun snooze(id: String, minutes: Int): NativeSnoozeResult? {
|
|
||||||
val spec = readSpec(id) ?: return null
|
|
||||||
val safeMinutes = sanitizeSnoozeMinutes(minutes)
|
|
||||||
val occurrenceAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
|
||||||
val target = occurrenceAt + safeMinutes * 60_000L
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val snoozeUntil = if (target > now) target else now + safeMinutes * 60_000L
|
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"alarm.snooze id=$id minutes=$safeMinutes occurrence=$occurrenceAt until=$snoozeUntil"
|
|
||||||
)
|
|
||||||
scheduleSpec(
|
|
||||||
spec.copy(
|
|
||||||
snoozeUntilMillis = snoozeUntil,
|
|
||||||
snoozeOriginMillis = occurrenceAt,
|
|
||||||
snoozeMinutes = safeMinutes
|
|
||||||
),
|
|
||||||
persistOnSuccess = true
|
|
||||||
)
|
|
||||||
return NativeSnoozeResult(
|
|
||||||
snoozeUntilMillis = snoozeUntil,
|
|
||||||
occurrenceAtMillis = occurrenceAt,
|
|
||||||
title = spec.title
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun postponeNext(id: String, minutes: Int): Long? {
|
|
||||||
val spec = readSpec(id) ?: return null
|
|
||||||
val safeMinutes = sanitizeSnoozeMinutes(minutes)
|
|
||||||
val occurrenceAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
|
||||||
val target = occurrenceAt + safeMinutes * 60_000L
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
val snoozeUntil = if (target > now) target else now + safeMinutes * 60_000L
|
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"alarm.postponeNext id=$id minutes=$safeMinutes occurrence=$occurrenceAt target=$snoozeUntil"
|
|
||||||
)
|
|
||||||
scheduleSpec(
|
|
||||||
spec.copy(
|
|
||||||
snoozeUntilMillis = snoozeUntil,
|
|
||||||
snoozeOriginMillis = occurrenceAt,
|
|
||||||
snoozeMinutes = safeMinutes
|
|
||||||
),
|
|
||||||
persistOnSuccess = true
|
|
||||||
)
|
|
||||||
return occurrenceAt
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelAlarm(id: String) {
|
|
||||||
Log.d(tag, "alarm.cancel id=$id")
|
|
||||||
removeScheduledAlarm(id)
|
|
||||||
removeHandledOccurrence(id)
|
|
||||||
cancelPending("fire", pendingFireIntent(id, PendingIntent.FLAG_NO_CREATE))
|
|
||||||
cancelPending("show", pendingShowIntent(id, PendingIntent.FLAG_NO_CREATE))
|
|
||||||
cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE))
|
|
||||||
NotificationManagerCompat.from(appContext).cancel(
|
|
||||||
PluriWaveAlarmReceiver.notificationIdForAlarm(id)
|
|
||||||
)
|
|
||||||
NotificationManagerCompat.from(appContext).cancel(
|
|
||||||
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun dismissFireNotification(id: String) {
|
|
||||||
NotificationManagerCompat.from(appContext).cancel(
|
|
||||||
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun canScheduleExactAlarms(): Boolean {
|
|
||||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
|
|
||||||
alarmManager.canScheduleExactAlarms()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reschedulePersistedAlarms() {
|
|
||||||
for (id in prefs().getStringSet(KEY_IDS, emptySet()).orEmpty()) {
|
|
||||||
val spec = readSpec(id) ?: continue
|
|
||||||
try {
|
|
||||||
scheduleSpec(spec, persistOnSuccess = true)
|
|
||||||
Log.d(tag, "alarm.reschedule OK id=$id")
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(tag, "alarm.reschedule failed id=$id", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pendingAlarmCount(): Int =
|
|
||||||
prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size
|
|
||||||
|
|
||||||
fun handledOccurrences(): List<Map<String, Any>> =
|
|
||||||
prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty()
|
|
||||||
.mapNotNull { id ->
|
|
||||||
val handledAt = prefs().getLong("$KEY_HANDLED_PREFIX$id", 0L)
|
|
||||||
.takeIf { it > 0L }
|
|
||||||
?: return@mapNotNull null
|
|
||||||
mapOf(
|
|
||||||
"alarmId" to id,
|
|
||||||
"handledAtMillis" to handledAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Active (future) native snoozes from the persisted specs, used by the
|
|
||||||
* Flutter cold-start sync (Decision 2.1, engine-dead case) so a snooze
|
|
||||||
* performed while the engine was down is imported on the next launch.
|
|
||||||
*/
|
|
||||||
fun nativeSnoozeStates(): List<Map<String, Any>> {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
return prefs().getStringSet(KEY_IDS, emptySet()).orEmpty()
|
|
||||||
.mapNotNull { id ->
|
|
||||||
val spec = readSpec(id) ?: return@mapNotNull null
|
|
||||||
val snoozeUntil = spec.snoozeUntilMillis ?: return@mapNotNull null
|
|
||||||
if (snoozeUntil <= now) return@mapNotNull null
|
|
||||||
mapOf(
|
|
||||||
"alarmId" to spec.id,
|
|
||||||
"snoozeUntilMillis" to snoozeUntil,
|
|
||||||
"snoozeOriginMillis" to (spec.snoozeOriginMillis ?: spec.triggerAtMillis)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun preserveNativeSnooze(
|
|
||||||
existing: NativeAlarmSpec?,
|
|
||||||
requestedTriggerAtMillis: Long,
|
|
||||||
requestedSnoozeUntilMillis: Long?
|
|
||||||
): Pair<Long, Long>? {
|
|
||||||
if (requestedSnoozeUntilMillis != null || existing == null) return null
|
|
||||||
val snoozeUntil = existing.snoozeUntilMillis ?: return null
|
|
||||||
val snoozeOrigin = existing.snoozeOriginMillis ?: return null
|
|
||||||
if (snoozeUntil <= System.currentTimeMillis()) return null
|
|
||||||
if (snoozeOrigin != requestedTriggerAtMillis) return null
|
|
||||||
Log.d(
|
|
||||||
tag,
|
|
||||||
"alarm.schedule preserving native snooze id=${existing.id} origin=$snoozeOrigin until=$snoozeUntil"
|
|
||||||
)
|
|
||||||
return snoozeUntil to snoozeOrigin
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeNextTriggerMillis(spec: NativeAlarmSpec): Long? {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
spec.snoozeUntilMillis?.let { if (it > now) return it }
|
|
||||||
if (!spec.enabled) return null
|
|
||||||
val base = maxOf(now, (spec.lastHandledAtMillis ?: 0L) + 60_000L)
|
|
||||||
return when (spec.scheduleType) {
|
|
||||||
SCHEDULE_UNICA -> computeOneShot(spec, base)
|
|
||||||
SCHEDULE_DIAS_SEMANA -> computeWeekday(spec, base)
|
|
||||||
else -> computeDaily(spec, base)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeOneShot(spec: NativeAlarmSpec, baseMillis: Long): Long? {
|
|
||||||
val candidate = Calendar.getInstance().apply {
|
|
||||||
timeInMillis = spec.oneShotDateMillis ?: spec.triggerAtMillis
|
|
||||||
set(Calendar.HOUR_OF_DAY, spec.hour)
|
|
||||||
set(Calendar.MINUTE, spec.minute)
|
|
||||||
set(Calendar.SECOND, 0)
|
|
||||||
set(Calendar.MILLISECOND, 0)
|
|
||||||
}
|
|
||||||
return candidate.timeInMillis.takeIf { it > baseMillis }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeDaily(spec: NativeAlarmSpec, baseMillis: Long): Long? {
|
|
||||||
val candidate = Calendar.getInstance().apply {
|
|
||||||
timeInMillis = baseMillis
|
|
||||||
set(Calendar.HOUR_OF_DAY, spec.hour)
|
|
||||||
set(Calendar.MINUTE, spec.minute)
|
|
||||||
set(Calendar.SECOND, 0)
|
|
||||||
set(Calendar.MILLISECOND, 0)
|
|
||||||
}
|
|
||||||
if (candidate.timeInMillis <= baseMillis) {
|
|
||||||
candidate.add(Calendar.DAY_OF_YEAR, 1)
|
|
||||||
}
|
|
||||||
return candidate.timeInMillis
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun computeWeekday(spec: NativeAlarmSpec, baseMillis: Long): Long? {
|
|
||||||
if (spec.weekdays.isEmpty()) return null
|
|
||||||
val candidate = Calendar.getInstance().apply {
|
|
||||||
timeInMillis = baseMillis
|
|
||||||
set(Calendar.HOUR_OF_DAY, spec.hour)
|
|
||||||
set(Calendar.MINUTE, spec.minute)
|
|
||||||
set(Calendar.SECOND, 0)
|
|
||||||
set(Calendar.MILLISECOND, 0)
|
|
||||||
}
|
|
||||||
for (i in 0 until 370) {
|
|
||||||
if (candidate.timeInMillis > baseMillis &&
|
|
||||||
spec.weekdays.contains(dartWeekday(candidate))
|
|
||||||
) {
|
|
||||||
return candidate.timeInMillis
|
|
||||||
}
|
|
||||||
candidate.add(Calendar.DAY_OF_YEAR, 1)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dartWeekday(calendar: Calendar): Int =
|
|
||||||
when (calendar.get(Calendar.DAY_OF_WEEK)) {
|
|
||||||
Calendar.MONDAY -> 1
|
|
||||||
Calendar.TUESDAY -> 2
|
|
||||||
Calendar.WEDNESDAY -> 3
|
|
||||||
Calendar.THURSDAY -> 4
|
|
||||||
Calendar.FRIDAY -> 5
|
|
||||||
Calendar.SATURDAY -> 6
|
|
||||||
else -> 7
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveScheduledAlarm(spec: NativeAlarmSpec) {
|
|
||||||
val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet()
|
|
||||||
ids.add(spec.id)
|
|
||||||
prefs().edit()
|
|
||||||
.putStringSet(KEY_IDS, ids)
|
|
||||||
.putString("$KEY_ALARM_PREFIX${spec.id}", spec.toJson().toString())
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readSpec(id: String): NativeAlarmSpec? {
|
|
||||||
val raw = prefs().getString("$KEY_ALARM_PREFIX$id", null) ?: return null
|
|
||||||
return try {
|
|
||||||
NativeAlarmSpec.fromJson(JSONObject(raw))
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(tag, "alarm.readSpec failed id=$id", error)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeScheduledAlarm(id: String) {
|
|
||||||
val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet()
|
|
||||||
ids.remove(id)
|
|
||||||
prefs().edit()
|
|
||||||
.putStringSet(KEY_IDS, ids)
|
|
||||||
.remove("$KEY_ALARM_PREFIX$id")
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveHandledOccurrence(id: String, handledAtMillis: Long) {
|
|
||||||
val ids = prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty().toMutableSet()
|
|
||||||
ids.add(id)
|
|
||||||
prefs().edit()
|
|
||||||
.putStringSet(KEY_HANDLED_IDS, ids)
|
|
||||||
.putLong("$KEY_HANDLED_PREFIX$id", handledAtMillis)
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeHandledOccurrence(id: String) {
|
|
||||||
val ids = prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty().toMutableSet()
|
|
||||||
ids.remove(id)
|
|
||||||
prefs().edit()
|
|
||||||
.putStringSet(KEY_HANDLED_IDS, ids)
|
|
||||||
.remove("$KEY_HANDLED_PREFIX$id")
|
|
||||||
.apply()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun prefs() =
|
|
||||||
appContext.createDeviceProtectedStorageContext()
|
|
||||||
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
|
||||||
|
|
||||||
private fun cancelPending(name: String, pendingIntent: PendingIntent?) {
|
|
||||||
if (pendingIntent == null) {
|
|
||||||
Log.d(tag, "alarm.cancel $name no pending intent")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
alarmManager.cancel(pendingIntent)
|
|
||||||
pendingIntent.cancel()
|
|
||||||
Log.d(tag, "alarm.cancel $name OK")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fireIntent(spec: NativeAlarmSpec): PendingIntent =
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
appContext,
|
|
||||||
requestCode(spec.id, 1),
|
|
||||||
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
|
|
||||||
action = PluriWaveAlarmReceiver.ACTION_FIRE
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME, spec.stationName)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL, spec.stationUrl)
|
|
||||||
putExtra(
|
|
||||||
PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_NAME,
|
|
||||||
spec.fallbackStationName
|
|
||||||
)
|
|
||||||
putExtra(
|
|
||||||
PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_URL,
|
|
||||||
spec.fallbackStationUrl
|
|
||||||
)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND, spec.fallbackSound)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, spec.volume)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_FADE_IN_SECONDS, spec.fadeInSegundos)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
|
|
||||||
putExtra(
|
|
||||||
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
|
|
||||||
spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
|
||||||
)
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun showIntent(spec: NativeAlarmSpec): PendingIntent =
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
appContext,
|
|
||||||
requestCode(spec.id, 2),
|
|
||||||
Intent(appContext, MainActivity::class.java).apply {
|
|
||||||
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
|
|
||||||
putExtra(
|
|
||||||
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
|
|
||||||
spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
|
||||||
)
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun pendingFireIntent(id: String, flags: Int): PendingIntent? =
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
appContext,
|
|
||||||
requestCode(id, 1),
|
|
||||||
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
|
|
||||||
action = PluriWaveAlarmReceiver.ACTION_FIRE
|
|
||||||
},
|
|
||||||
flags or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun pendingShowIntent(id: String, flags: Int): PendingIntent? =
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
appContext,
|
|
||||||
requestCode(id, 2),
|
|
||||||
Intent(appContext, MainActivity::class.java).apply {
|
|
||||||
this.flags =
|
|
||||||
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
|
|
||||||
},
|
|
||||||
flags or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun pendingPreNoticeIntent(id: String, flags: Int): PendingIntent? =
|
|
||||||
PendingIntent.getBroadcast(
|
|
||||||
appContext,
|
|
||||||
requestCode(id, 3),
|
|
||||||
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
|
|
||||||
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
|
|
||||||
},
|
|
||||||
flags or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun localHour(millis: Long): Int =
|
|
||||||
Calendar.getInstance().apply { timeInMillis = millis }.get(Calendar.HOUR_OF_DAY)
|
|
||||||
|
|
||||||
private fun localMinute(millis: Long): Int =
|
|
||||||
Calendar.getInstance().apply { timeInMillis = millis }.get(Calendar.MINUTE)
|
|
||||||
|
|
||||||
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
|
|
||||||
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
|
|
||||||
|
|
||||||
private fun requestCode(id: String, slot: Int): Int = 31 * id.hashCode() + slot
|
|
||||||
|
|
||||||
/** Result of a native snooze, reported back to Flutter via alarmFired. */
|
|
||||||
data class NativeSnoozeResult(
|
|
||||||
val snoozeUntilMillis: Long,
|
|
||||||
val occurrenceAtMillis: Long,
|
|
||||||
val title: String
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class NativeAlarmSpec(
|
|
||||||
val id: String,
|
|
||||||
val title: String,
|
|
||||||
val enabled: Boolean,
|
|
||||||
val triggerAtMillis: Long,
|
|
||||||
val preNoticeAtMillis: Long,
|
|
||||||
val hour: Int,
|
|
||||||
val minute: Int,
|
|
||||||
val scheduleType: String,
|
|
||||||
val weekdays: List<Int>,
|
|
||||||
val oneShotDateMillis: Long?,
|
|
||||||
val snoozeUntilMillis: Long?,
|
|
||||||
val snoozeOriginMillis: Long?,
|
|
||||||
val lastHandledAtMillis: Long?,
|
|
||||||
val soundOnVacation: Boolean,
|
|
||||||
val snoozeMinutes: Int,
|
|
||||||
val stationName: String?,
|
|
||||||
val stationUrl: String?,
|
|
||||||
val fallbackStationName: String? = null,
|
|
||||||
val fallbackStationUrl: String? = null,
|
|
||||||
val fallbackSound: String?,
|
|
||||||
val volume: Float,
|
|
||||||
val fadeInSegundos: Int = 0,
|
|
||||||
val timezoneId: String
|
|
||||||
) {
|
|
||||||
fun toJson(): JSONObject = JSONObject().apply {
|
|
||||||
put("schemaVersion", 3)
|
|
||||||
put("id", id)
|
|
||||||
put("title", title)
|
|
||||||
put("enabled", enabled)
|
|
||||||
put("triggerAtMillis", triggerAtMillis)
|
|
||||||
put("preNoticeAtMillis", preNoticeAtMillis)
|
|
||||||
put("hour", hour)
|
|
||||||
put("minute", minute)
|
|
||||||
put("scheduleType", scheduleType)
|
|
||||||
put("weekdays", JSONArray(weekdays))
|
|
||||||
put("oneShotDateMillis", oneShotDateMillis)
|
|
||||||
put("snoozeUntilMillis", snoozeUntilMillis)
|
|
||||||
put("snoozeOriginMillis", snoozeOriginMillis)
|
|
||||||
put("lastHandledAtMillis", lastHandledAtMillis)
|
|
||||||
put("soundOnVacation", soundOnVacation)
|
|
||||||
put("snoozeMinutes", snoozeMinutes)
|
|
||||||
put("stationName", stationName)
|
|
||||||
put("stationUrl", stationUrl)
|
|
||||||
put("fallbackStationName", fallbackStationName)
|
|
||||||
put("fallbackStationUrl", fallbackStationUrl)
|
|
||||||
put("fallbackSound", fallbackSound)
|
|
||||||
put("volume", volume)
|
|
||||||
put("fadeInSegundos", fadeInSegundos)
|
|
||||||
put("timezoneId", timezoneId)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun fromJson(json: JSONObject): NativeAlarmSpec {
|
|
||||||
val weekdaysJson = json.optJSONArray("weekdays") ?: JSONArray()
|
|
||||||
return NativeAlarmSpec(
|
|
||||||
id = json.optString("id"),
|
|
||||||
title = json.optString("title", "PluriWave"),
|
|
||||||
enabled = json.optBoolean("enabled", true),
|
|
||||||
triggerAtMillis = json.optLong("triggerAtMillis", 0L),
|
|
||||||
preNoticeAtMillis = json.optLong("preNoticeAtMillis", 0L),
|
|
||||||
hour = json.optInt("hour", 7),
|
|
||||||
minute = json.optInt("minute", 0),
|
|
||||||
scheduleType = json.optString("scheduleType", SCHEDULE_UNICA),
|
|
||||||
weekdays = (0 until weekdaysJson.length()).mapNotNull {
|
|
||||||
weekdaysJson.optInt(it).takeIf { day -> day in 1..7 }
|
|
||||||
},
|
|
||||||
oneShotDateMillis = json.optNullableLong("oneShotDateMillis"),
|
|
||||||
snoozeUntilMillis = json.optNullableLong("snoozeUntilMillis"),
|
|
||||||
snoozeOriginMillis = json.optNullableLong("snoozeOriginMillis"),
|
|
||||||
lastHandledAtMillis = json.optNullableLong("lastHandledAtMillis"),
|
|
||||||
soundOnVacation = json.optBoolean("soundOnVacation", true),
|
|
||||||
snoozeMinutes = json.optInt("snoozeMinutes", 5).let {
|
|
||||||
if (it == 3 || it == 5 || it == 10) it else 5
|
|
||||||
},
|
|
||||||
stationName = json.optString("stationName").takeIf { it.isNotBlank() },
|
|
||||||
stationUrl = json.optString("stationUrl").takeIf { it.isNotBlank() },
|
|
||||||
// schemaVersion 2 specs lack the v3 fields; default to null/0
|
|
||||||
// so persisted alarms keep working after the upgrade.
|
|
||||||
fallbackStationName = json.optString("fallbackStationName")
|
|
||||||
.takeIf { it.isNotBlank() },
|
|
||||||
fallbackStationUrl = json.optString("fallbackStationUrl")
|
|
||||||
.takeIf { it.isNotBlank() },
|
|
||||||
fallbackSound = json.optString("fallbackSound").takeIf { it.isNotBlank() },
|
|
||||||
volume = json.optDouble("volume", 0.85).toFloat(),
|
|
||||||
fadeInSegundos = json.optInt("fadeInSegundos", 0).coerceIn(0, 60),
|
|
||||||
timezoneId = json.optString("timezoneId", TimeZone.getDefault().id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val PREFS = "pluriwave_alarm_scheduler"
|
|
||||||
private const val KEY_IDS = "scheduled_alarm_ids"
|
|
||||||
private const val KEY_ALARM_PREFIX = "scheduled_alarm_"
|
|
||||||
private const val KEY_HANDLED_IDS = "handled_alarm_ids"
|
|
||||||
private const val KEY_HANDLED_PREFIX = "handled_alarm_"
|
|
||||||
private const val PRE_NOTICE_MILLIS = 30 * 60 * 1000L
|
|
||||||
private const val SCHEDULE_UNICA = "unica"
|
|
||||||
private const val SCHEDULE_DIAS_SEMANA = "diasSemana"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JSONObject.optNullableLong(name: String): Long? =
|
|
||||||
if (has(name) && !isNull(name)) optLong(name) else null
|
|
||||||
@@ -1,638 +1,5 @@
|
|||||||
package es.freetimelab.pluriwave
|
package es.freetimelab.pluriwave
|
||||||
|
|
||||||
import android.Manifest
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.content.ClipData
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.ActivityNotFoundException
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.Uri
|
|
||||||
import android.media.audiofx.Visualizer
|
|
||||||
import android.app.AlarmManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Environment
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.os.PowerManager
|
|
||||||
import android.provider.DocumentsContract
|
|
||||||
import android.provider.Settings
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import com.ryanheise.audioservice.AudioServiceActivity
|
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
|
||||||
import io.flutter.plugin.common.EventChannel
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class MainActivity : AudioServiceActivity() {
|
class MainActivity : FlutterActivity()
|
||||||
private val tag = "PluriWave"
|
|
||||||
private val visualizerChannel = "pluriwave/audio_visualizer"
|
|
||||||
private val alarmChannel = "pluriwave/alarm_scheduler"
|
|
||||||
private val fileActionsChannel = "pluriwave/file_actions"
|
|
||||||
private val visualizerPermissionRequestCode = 4821
|
|
||||||
private val notificationPermissionRequestCode = 4822
|
|
||||||
private var visualizer: Visualizer? = null
|
|
||||||
private var pendingSink: EventChannel.EventSink? = null
|
|
||||||
private var pendingArgs: Map<*, *>? = null
|
|
||||||
private var alarmMethodChannel: MethodChannel? = null
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
|
||||||
super.configureFlutterEngine(flutterEngine)
|
|
||||||
EventChannel(
|
|
||||||
flutterEngine.dartExecutor.binaryMessenger,
|
|
||||||
visualizerChannel
|
|
||||||
).setStreamHandler(object : EventChannel.StreamHandler {
|
|
||||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
|
||||||
pendingSink = events
|
|
||||||
pendingArgs = arguments as? Map<*, *>
|
|
||||||
startVisualizerWhenAllowed()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCancel(arguments: Any?) {
|
|
||||||
stopVisualizer()
|
|
||||||
pendingSink = null
|
|
||||||
pendingArgs = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
val alarmScheduler = AlarmScheduler(this)
|
|
||||||
alarmMethodChannel = MethodChannel(
|
|
||||||
flutterEngine.dartExecutor.binaryMessenger,
|
|
||||||
alarmChannel
|
|
||||||
)
|
|
||||||
alarmMethodChannel?.setMethodCallHandler { call, result ->
|
|
||||||
when (call.method) {
|
|
||||||
"scheduleAlarm" -> {
|
|
||||||
val id = call.argument<String>("id")
|
|
||||||
val title = call.argument<String>("title") ?: "PluriWave"
|
|
||||||
val triggerAtMillis = call.argument<Long>("triggerAtMillis")
|
|
||||||
val preNoticeAtMillis = call.argument<Long>("preNoticeAtMillis") ?: 0L
|
|
||||||
val stationName = call.argument<String>("stationName")
|
|
||||||
val stationUrl = call.argument<String>("stationUrl")
|
|
||||||
val fallbackSound = call.argument<String>("fallbackSound")
|
|
||||||
val volume = call.argument<Number>("volume")?.toFloat() ?: 0.85f
|
|
||||||
val weekdays =
|
|
||||||
(call.argument<List<Int>>("weekdays") ?: emptyList())
|
|
||||||
.filter { it in 1..7 }
|
|
||||||
Log.d(tag, "alarm.channel scheduleAlarm id=$id triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis")
|
|
||||||
if (id == null || triggerAtMillis == null) {
|
|
||||||
Log.w(tag, "alarm.channel scheduleAlarm invalid id=$id triggerAtMillis=$triggerAtMillis")
|
|
||||||
result.error("INVALID_ALARM", "Missing alarm id or trigger time", null)
|
|
||||||
} else {
|
|
||||||
val scheduled = alarmScheduler.scheduleAlarm(
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
triggerAtMillis,
|
|
||||||
preNoticeAtMillis,
|
|
||||||
stationName,
|
|
||||||
stationUrl,
|
|
||||||
fallbackSound,
|
|
||||||
volume,
|
|
||||||
hour = call.argument<Int>("hour"),
|
|
||||||
minute = call.argument<Int>("minute"),
|
|
||||||
scheduleType = call.argument<String>("scheduleType"),
|
|
||||||
weekdays = weekdays,
|
|
||||||
oneShotDateMillis = call.argument<Long>("oneShotDateMillis"),
|
|
||||||
snoozeUntilMillis = call.argument<Long>("snoozeUntilMillis"),
|
|
||||||
snoozeOriginMillis = call.argument<Long>("snoozeOriginMillis"),
|
|
||||||
lastHandledAtMillis = call.argument<Long>("lastHandledAtMillis"),
|
|
||||||
soundOnVacation = call.argument<Boolean>("soundOnVacation") ?: true,
|
|
||||||
snoozeMinutes = call.argument<Int>("snoozeMinutes") ?: 5,
|
|
||||||
fallbackStationName = call.argument<String>("fallbackStationName"),
|
|
||||||
fallbackStationUrl = call.argument<String>("fallbackStationUrl"),
|
|
||||||
fadeInSegundos = call.argument<Int>("fadeInSegundos") ?: 0
|
|
||||||
)
|
|
||||||
result.success(scheduled)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"cancelAlarm" -> {
|
|
||||||
val id = call.argument<String>("id")
|
|
||||||
Log.d(tag, "alarm.channel cancelAlarm id=$id")
|
|
||||||
if (id == null) {
|
|
||||||
result.error("INVALID_ALARM", "Missing alarm id", null)
|
|
||||||
} else {
|
|
||||||
alarmScheduler.cancelAlarm(id)
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"dismissAlarmNotification" -> {
|
|
||||||
val id = call.argument<String>("id")
|
|
||||||
Log.d(tag, "alarm.channel dismissAlarmNotification id=$id")
|
|
||||||
if (id == null) {
|
|
||||||
result.error("INVALID_ALARM", "Missing alarm id", null)
|
|
||||||
} else {
|
|
||||||
PluriWaveAlarmService.stop(this, id)
|
|
||||||
alarmScheduler.dismissFireNotification(id)
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"stopNativeAlarmSound" -> {
|
|
||||||
val id = call.argument<String>("id")
|
|
||||||
Log.d(tag, "alarm.channel stopNativeAlarmSound id=$id")
|
|
||||||
if (id == null) {
|
|
||||||
result.error("INVALID_ALARM", "Missing alarm id", null)
|
|
||||||
} else {
|
|
||||||
PluriWaveAlarmService.stop(this, id)
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"confirmFlutterAudio" -> {
|
|
||||||
val id = call.argument<String>("id")
|
|
||||||
Log.d(tag, "alarm.channel confirmFlutterAudio id=$id")
|
|
||||||
if (id == null) {
|
|
||||||
result.error("INVALID_ALARM", "Missing alarm id", null)
|
|
||||||
} else {
|
|
||||||
PluriWaveAlarmService.stop(this, id)
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"diagnostics" -> {
|
|
||||||
Log.d(tag, "alarm.channel diagnostics")
|
|
||||||
result.success(
|
|
||||||
mapOf(
|
|
||||||
"canScheduleExactAlarms" to alarmScheduler.canScheduleExactAlarms(),
|
|
||||||
"notificationsEnabled" to NotificationManagerCompat.from(this).areNotificationsEnabled(),
|
|
||||||
"canUseFullScreenIntent" to canUseFullScreenIntent(),
|
|
||||||
"isIgnoringBatteryOptimizations" to isIgnoringBatteryOptimizations(),
|
|
||||||
"nativePendingAlarmsCount" to alarmScheduler.pendingAlarmCount(),
|
|
||||||
"manufacturer" to Build.MANUFACTURER,
|
|
||||||
"sdkInt" to Build.VERSION.SDK_INT
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
"requestExactAlarmPermission" -> {
|
|
||||||
Log.d(tag, "alarm.channel requestExactAlarmPermission")
|
|
||||||
result.success(requestExactAlarmPermission())
|
|
||||||
}
|
|
||||||
"requestPostNotificationsPermission" -> {
|
|
||||||
Log.d(tag, "alarm.channel requestPostNotificationsPermission")
|
|
||||||
result.success(requestPostNotificationsPermission())
|
|
||||||
}
|
|
||||||
"requestFullScreenIntentPermission" -> {
|
|
||||||
Log.d(tag, "alarm.channel requestFullScreenIntentPermission")
|
|
||||||
result.success(requestFullScreenIntentPermission())
|
|
||||||
}
|
|
||||||
"requestIgnoreBatteryOptimizations" -> {
|
|
||||||
Log.d(tag, "alarm.channel requestIgnoreBatteryOptimizations")
|
|
||||||
result.success(requestIgnoreBatteryOptimizations())
|
|
||||||
}
|
|
||||||
"getInitialAlarmIntent" -> {
|
|
||||||
val payload = alarmPayload(intent)
|
|
||||||
Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload")
|
|
||||||
result.success(payload)
|
|
||||||
intent?.removeExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION)
|
|
||||||
}
|
|
||||||
"getHandledAlarmOccurrences" -> {
|
|
||||||
Log.d(tag, "alarm.channel getHandledAlarmOccurrences")
|
|
||||||
result.success(alarmScheduler.handledOccurrences())
|
|
||||||
}
|
|
||||||
"getNativeSnoozeState" -> {
|
|
||||||
Log.d(tag, "alarm.channel getNativeSnoozeState")
|
|
||||||
result.success(alarmScheduler.nativeSnoozeStates())
|
|
||||||
}
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activeInstance = this
|
|
||||||
|
|
||||||
MethodChannel(
|
|
||||||
flutterEngine.dartExecutor.binaryMessenger,
|
|
||||||
fileActionsChannel
|
|
||||||
).setMethodCallHandler { call, result ->
|
|
||||||
when (call.method) {
|
|
||||||
"openDirectory" -> {
|
|
||||||
val path = call.argument<String>("path")
|
|
||||||
Log.d(tag, "file_actions.openDirectory path=$path")
|
|
||||||
if (path.isNullOrBlank()) {
|
|
||||||
result.success(false)
|
|
||||||
} else {
|
|
||||||
result.success(openDirectory(path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"viewDirectory" -> {
|
|
||||||
val path = call.argument<String>("path")
|
|
||||||
Log.d(tag, "file_actions.viewDirectory path=$path")
|
|
||||||
if (path.isNullOrBlank()) {
|
|
||||||
result.success(false)
|
|
||||||
} else {
|
|
||||||
result.success(viewDirectory(path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"openFile" -> {
|
|
||||||
val path = call.argument<String>("path")
|
|
||||||
val mimeType = call.argument<String>("mimeType") ?: "audio/*"
|
|
||||||
Log.d(tag, "file_actions.openFile path=$path mimeType=$mimeType")
|
|
||||||
if (path.isNullOrBlank()) {
|
|
||||||
result.success(false)
|
|
||||||
} else {
|
|
||||||
result.success(openFile(path, mimeType))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
|
||||||
super.onNewIntent(intent)
|
|
||||||
setIntent(intent)
|
|
||||||
val payload = alarmPayload(intent)
|
|
||||||
if (payload.isNotEmpty()) {
|
|
||||||
Log.d(tag, "alarm.channel onNewIntent payload=$payload")
|
|
||||||
alarmMethodChannel?.invokeMethod("alarmFired", payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun alarmPayload(intent: Intent?): Map<String, Any> {
|
|
||||||
if (intent == null) return emptyMap()
|
|
||||||
val action = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION)
|
|
||||||
?: return emptyMap()
|
|
||||||
val alarmId = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
|
|
||||||
?: return emptyMap()
|
|
||||||
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE)
|
|
||||||
?: "PluriWave"
|
|
||||||
return mapOf(
|
|
||||||
"alarmId" to alarmId,
|
|
||||||
"alarmTitle" to title,
|
|
||||||
"alarmAction" to action,
|
|
||||||
"triggerAtMillis" to intent.getLongExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, 0L),
|
|
||||||
"occurrenceAtMillis" to intent.getLongExtra(PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT, 0L),
|
|
||||||
"snoozeMinutes" to intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestExactAlarmPermission(): Boolean {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
|
|
||||||
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
|
||||||
if (alarmManager.canScheduleExactAlarms()) return true
|
|
||||||
return try {
|
|
||||||
startActivity(
|
|
||||||
Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
|
|
||||||
data = Uri.parse("package:$packageName")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
true
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(tag, "alarm.channel requestExactAlarmPermission failed", error)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestPostNotificationsPermission(): Boolean {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
|
|
||||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
requestPermissions(
|
|
||||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
|
||||||
notificationPermissionRequestCode
|
|
||||||
)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestFullScreenIntentPermission(): Boolean {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
|
|
||||||
if (canUseFullScreenIntent()) return true
|
|
||||||
return try {
|
|
||||||
startActivity(
|
|
||||||
Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT).apply {
|
|
||||||
data = Uri.parse("package:$packageName")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
true
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(tag, "alarm.channel requestFullScreenIntentPermission failed", error)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun canUseFullScreenIntent(): Boolean {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
|
|
||||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
return manager.canUseFullScreenIntent()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isIgnoringBatteryOptimizations(): Boolean {
|
|
||||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
|
||||||
return powerManager.isIgnoringBatteryOptimizations(packageName)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestIgnoreBatteryOptimizations(): Boolean {
|
|
||||||
if (isIgnoringBatteryOptimizations()) return true
|
|
||||||
return try {
|
|
||||||
startActivity(
|
|
||||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
|
||||||
data = Uri.parse("package:$packageName")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
true
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(tag, "alarm.channel requestIgnoreBatteryOptimizations failed", error)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openDirectory(path: String): Boolean {
|
|
||||||
val folder = File(path)
|
|
||||||
if (!folder.exists()) {
|
|
||||||
Log.w(tag, "file_actions.openDirectory missing path=$path")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (!folder.isDirectory) {
|
|
||||||
Log.w(tag, "file_actions.openDirectory not directory path=$path")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
val fileProviderIntent = runCatching {
|
|
||||||
val uri = FileProvider.getUriForFile(
|
|
||||||
this,
|
|
||||||
"$packageName.fileprovider",
|
|
||||||
folder
|
|
||||||
)
|
|
||||||
Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, "resource/folder")
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
}.getOrNull()
|
|
||||||
|
|
||||||
val documentIntent = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
directoryTreeUri(path)?.let { uri ->
|
|
||||||
setDataAndType(uri, DocumentsContract.Document.MIME_TYPE_DIR)
|
|
||||||
}
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
|
|
||||||
val opened =
|
|
||||||
openIntentSafely(fileProviderIntent, "file_actions.openDirectory fileProvider", path) ||
|
|
||||||
openIntentSafely(documentIntent, "file_actions.openDirectory documents", path)
|
|
||||||
if (!opened) {
|
|
||||||
Log.w(tag, "file_actions.openDirectory unable to open path=$path")
|
|
||||||
}
|
|
||||||
return opened
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun viewDirectory(path: String): Boolean {
|
|
||||||
val directory = File(path)
|
|
||||||
if (!directory.exists()) {
|
|
||||||
directory.mkdirs()
|
|
||||||
}
|
|
||||||
|
|
||||||
val candidates = mutableListOf<Intent>()
|
|
||||||
directoryDocumentUri(path)?.let { uri ->
|
|
||||||
candidates.add(
|
|
||||||
Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, "vnd.android.document/directory")
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
candidates.add(
|
|
||||||
Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setData(uri)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", directory)
|
|
||||||
candidates.add(
|
|
||||||
Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, "resource/folder")
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.w(tag, "file_actions.viewDirectory fileprovider unavailable path=$path", error)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (intent in candidates) {
|
|
||||||
try {
|
|
||||||
startActivity(Intent.createChooser(intent, "Abrir carpeta"))
|
|
||||||
Log.d(tag, "file_actions.viewDirectory launched path=$path")
|
|
||||||
return true
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
Log.w(tag, "file_actions.viewDirectory no activity for candidate path=$path")
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(tag, "file_actions.viewDirectory candidate failed path=$path", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openIntentSafely(intent: Intent?, origin: String, path: String): Boolean {
|
|
||||||
if (intent == null || intent.data == null) return false
|
|
||||||
return try {
|
|
||||||
startActivity(intent)
|
|
||||||
Log.d(tag, "$origin launched path=$path")
|
|
||||||
true
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
Log.w(tag, "$origin no activity for path=$path")
|
|
||||||
false
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(tag, "$origin failed path=$path", error)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun openFile(path: String, mimeType: String): Boolean {
|
|
||||||
val file = File(path)
|
|
||||||
if (!file.exists()) {
|
|
||||||
Log.w(tag, "file_actions.openFile missing path=$path")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return try {
|
|
||||||
val uri = FileProvider.getUriForFile(
|
|
||||||
this,
|
|
||||||
"$packageName.fileprovider",
|
|
||||||
file
|
|
||||||
)
|
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, mimeType)
|
|
||||||
clipData = ClipData.newUri(contentResolver, "recording", uri)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
startActivity(Intent.createChooser(intent, "Abrir grabación"))
|
|
||||||
Log.d(tag, "file_actions.openFile launched path=$path")
|
|
||||||
true
|
|
||||||
} catch (_: ActivityNotFoundException) {
|
|
||||||
Log.w(tag, "file_actions.openFile no viewer path=$path; opening parent")
|
|
||||||
openDirectory(file.parentFile?.absolutePath ?: path)
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(tag, "file_actions.openFile failed path=$path; opening parent", error)
|
|
||||||
openDirectory(file.parentFile?.absolutePath ?: path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun directoryTreeUri(path: String): Uri? {
|
|
||||||
val external = Environment.getExternalStorageDirectory()?.absolutePath ?: return null
|
|
||||||
if (!path.startsWith(external)) return null
|
|
||||||
|
|
||||||
val relative = path.removePrefix(external).trimStart('/')
|
|
||||||
val documentId = if (relative.isBlank()) "primary:" else "primary:$relative"
|
|
||||||
return DocumentsContract.buildTreeDocumentUri(
|
|
||||||
"com.android.externalstorage.documents",
|
|
||||||
documentId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun directoryDocumentUri(path: String): Uri? {
|
|
||||||
val external = Environment.getExternalStorageDirectory()?.absolutePath ?: return null
|
|
||||||
if (!path.startsWith(external)) return null
|
|
||||||
|
|
||||||
val relative = path.removePrefix(external).trimStart('/')
|
|
||||||
val documentId = if (relative.isBlank()) "primary:" else "primary:$relative"
|
|
||||||
return DocumentsContract.buildDocumentUri(
|
|
||||||
"com.android.externalstorage.documents",
|
|
||||||
documentId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startVisualizerWhenAllowed() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
|
|
||||||
checkSelfPermission(Manifest.permission.RECORD_AUDIO)
|
|
||||||
!= PackageManager.PERMISSION_GRANTED
|
|
||||||
) {
|
|
||||||
requestPermissions(
|
|
||||||
arrayOf(Manifest.permission.RECORD_AUDIO),
|
|
||||||
visualizerPermissionRequestCode
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
startVisualizer()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startVisualizer() {
|
|
||||||
val sink = pendingSink ?: return
|
|
||||||
val args = pendingArgs
|
|
||||||
val sessionId = (args?.get("sessionId") as? Number)?.toInt() ?: 0
|
|
||||||
val bands = ((args?.get("bands") as? Number)?.toInt() ?: 26).coerceIn(8, 96)
|
|
||||||
|
|
||||||
stopVisualizer()
|
|
||||||
|
|
||||||
try {
|
|
||||||
val captureSize = Visualizer.getCaptureSizeRange()[1]
|
|
||||||
visualizer = Visualizer(sessionId).apply {
|
|
||||||
enabled = false
|
|
||||||
setCaptureSize(captureSize)
|
|
||||||
setDataCaptureListener(
|
|
||||||
object : Visualizer.OnDataCaptureListener {
|
|
||||||
override fun onWaveFormDataCapture(
|
|
||||||
visualizer: Visualizer?,
|
|
||||||
waveform: ByteArray?,
|
|
||||||
samplingRate: Int
|
|
||||||
) {
|
|
||||||
val data = waveform ?: return
|
|
||||||
val values = downsample(data, bands)
|
|
||||||
mainHandler.post { sink.success(values) }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFftDataCapture(
|
|
||||||
visualizer: Visualizer?,
|
|
||||||
fft: ByteArray?,
|
|
||||||
samplingRate: Int
|
|
||||||
) = Unit
|
|
||||||
},
|
|
||||||
Visualizer.getMaxCaptureRate() / 2,
|
|
||||||
true,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
enabled = true
|
|
||||||
}
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
sink.error("VISUALIZER_UNAVAILABLE", error.message, null)
|
|
||||||
stopVisualizer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun downsample(data: ByteArray, bands: Int): List<Double> {
|
|
||||||
if (data.isEmpty()) return emptyList()
|
|
||||||
val bucket = maxOf(1, data.size / bands)
|
|
||||||
val values = ArrayList<Double>(bands)
|
|
||||||
|
|
||||||
var index = 0
|
|
||||||
while (index < data.size && values.size < bands) {
|
|
||||||
var sum = 0.0
|
|
||||||
var count = 0
|
|
||||||
val end = minOf(index + bucket, data.size)
|
|
||||||
for (i in index until end) {
|
|
||||||
val centered = (data[i].toInt() and 0xFF) - 128
|
|
||||||
sum += kotlin.math.abs(centered) / 128.0
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
values.add(if (count == 0) 0.0 else (sum / count).coerceIn(0.0, 1.0))
|
|
||||||
index = end
|
|
||||||
}
|
|
||||||
|
|
||||||
while (values.size < bands) values.add(0.0)
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopVisualizer() {
|
|
||||||
try {
|
|
||||||
visualizer?.enabled = false
|
|
||||||
visualizer?.release()
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
} finally {
|
|
||||||
visualizer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRequestPermissionsResult(
|
|
||||||
requestCode: Int,
|
|
||||||
permissions: Array<out String>,
|
|
||||||
grantResults: IntArray
|
|
||||||
) {
|
|
||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
|
||||||
if (requestCode == notificationPermissionRequestCode) return
|
|
||||||
if (requestCode != visualizerPermissionRequestCode) return
|
|
||||||
|
|
||||||
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
|
|
||||||
startVisualizer()
|
|
||||||
} else {
|
|
||||||
pendingSink?.error(
|
|
||||||
"RECORD_AUDIO_DENIED",
|
|
||||||
"Permiso de audio denegado para visualizar la onda real",
|
|
||||||
null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
if (activeInstance === this) {
|
|
||||||
activeInstance = null
|
|
||||||
}
|
|
||||||
stopVisualizer()
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val STATIC_TAG = "PluriWave"
|
|
||||||
|
|
||||||
/** alarmAction reported when the native service snoozed by itself. */
|
|
||||||
const val ALARM_ACTION_SNOOZED = "snoozed"
|
|
||||||
|
|
||||||
@Volatile
|
|
||||||
private var activeInstance: MainActivity? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bridge for components without an activity (PluriWaveAlarmService):
|
|
||||||
* forwards alarm events through the existing alarmFired MethodChannel
|
|
||||||
* when the Flutter engine is alive; no-op when dead — the cold-start
|
|
||||||
* sync (getNativeSnoozeState) covers that case (Decision 2.1).
|
|
||||||
*/
|
|
||||||
fun notifyAlarmEvent(payload: Map<String, Any?>) {
|
|
||||||
val activity = activeInstance
|
|
||||||
if (activity == null) {
|
|
||||||
Log.d(STATIC_TAG, "alarm.channel notifyAlarmEvent skipped (engine dead)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
activity.mainHandler.post {
|
|
||||||
activity.alarmMethodChannel?.invokeMethod("alarmFired", payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
package es.freetimelab.pluriwave
|
|
||||||
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
|
|
||||||
class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
val alarmId = intent.getStringExtra(EXTRA_ALARM_ID) ?: run {
|
|
||||||
Log.w(TAG, "alarm.receiver missing alarmId action=${intent.action}")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val title = intent.getStringExtra(EXTRA_ALARM_TITLE) ?: "PluriWave"
|
|
||||||
val snoozeMinutes = sanitizeSnoozeMinutes(intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5))
|
|
||||||
Log.d(TAG, "alarm.receiver action=${intent.action} id=$alarmId title=$title")
|
|
||||||
|
|
||||||
when (intent.action) {
|
|
||||||
ACTION_FIRE -> {
|
|
||||||
AlarmScheduler(context).onAlarmFired(alarmId)
|
|
||||||
PluriWaveAlarmService.start(context, intent)
|
|
||||||
val launch = Intent(context, MainActivity::class.java).apply {
|
|
||||||
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
|
||||||
putExtra(EXTRA_ALARM_TITLE, title)
|
|
||||||
putExtra(EXTRA_ALARM_ACTION, ACTION_FIRE)
|
|
||||||
putExtra(EXTRA_TRIGGER_AT, intent.getLongExtra(EXTRA_TRIGGER_AT, 0L))
|
|
||||||
putExtra(EXTRA_OCCURRENCE_AT, intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L))
|
|
||||||
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
|
|
||||||
}
|
|
||||||
// The service's startForeground notification (single FSI owner) is
|
|
||||||
// posted by PluriWaveAlarmService.start above; the receiver must NOT
|
|
||||||
// post a duplicate fire notification.
|
|
||||||
try {
|
|
||||||
context.startActivity(launch)
|
|
||||||
Log.d(TAG, "alarm.receiver fire startActivity OK id=$alarmId")
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(TAG, "alarm.receiver fire startActivity ERROR id=$alarmId", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ACTION_PRE_NOTICE -> {
|
|
||||||
showPreNoticeNotification(
|
|
||||||
context,
|
|
||||||
alarmId,
|
|
||||||
title,
|
|
||||||
snoozeMinutes,
|
|
||||||
intent.getLongExtra(EXTRA_TRIGGER_AT, 0L),
|
|
||||||
intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ACTION_POSTPONE_NEXT -> {
|
|
||||||
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
|
|
||||||
val occurrenceAt = AlarmScheduler(context).postponeNext(alarmId, snoozeMinutes)
|
|
||||||
?: intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
|
|
||||||
val launch = Intent(context, MainActivity::class.java).apply {
|
|
||||||
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
|
||||||
putExtra(EXTRA_ALARM_TITLE, title)
|
|
||||||
putExtra(EXTRA_ALARM_ACTION, ACTION_POSTPONE_NEXT)
|
|
||||||
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
|
|
||||||
putExtra(EXTRA_OCCURRENCE_AT, occurrenceAt)
|
|
||||||
putExtra(EXTRA_TRIGGER_AT, intent.getLongExtra(EXTRA_TRIGGER_AT, 0L))
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
context.startActivity(launch)
|
|
||||||
Log.d(TAG, "alarm.receiver postponeNext startActivity OK id=$alarmId")
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(TAG, "alarm.receiver postponeNext startActivity ERROR id=$alarmId", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ACTION_SKIP_NEXT -> {
|
|
||||||
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
|
|
||||||
AlarmScheduler(context).skipNext(alarmId)
|
|
||||||
val launch = Intent(context, MainActivity::class.java).apply {
|
|
||||||
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
|
||||||
putExtra(EXTRA_ALARM_TITLE, title)
|
|
||||||
putExtra(EXTRA_ALARM_ACTION, ACTION_SKIP_NEXT)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
context.startActivity(launch)
|
|
||||||
Log.d(TAG, "alarm.receiver skipNext startActivity OK id=$alarmId")
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(TAG, "alarm.receiver skipNext startActivity ERROR id=$alarmId", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> Log.w(TAG, "alarm.receiver unknown action=${intent.action} id=$alarmId")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun showPreNoticeNotification(
|
|
||||||
context: Context,
|
|
||||||
alarmId: String,
|
|
||||||
title: String,
|
|
||||||
snoozeMinutes: Int,
|
|
||||||
triggerAtMillis: Long,
|
|
||||||
occurrenceAtMillis: Long
|
|
||||||
) {
|
|
||||||
ensureChannel(context)
|
|
||||||
|
|
||||||
val openAppIntent = PendingIntent.getActivity(
|
|
||||||
context,
|
|
||||||
requestCode(alarmId, 1),
|
|
||||||
Intent(context, MainActivity::class.java).apply {
|
|
||||||
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
|
||||||
putExtra(EXTRA_ALARM_TITLE, title)
|
|
||||||
putExtra(EXTRA_ALARM_ACTION, ACTION_PRE_NOTICE)
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
val skipNextIntent = PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
requestCode(alarmId, 2),
|
|
||||||
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
|
|
||||||
action = ACTION_SKIP_NEXT
|
|
||||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
|
||||||
putExtra(EXTRA_ALARM_TITLE, title)
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
val postponeNextIntent = PendingIntent.getBroadcast(
|
|
||||||
context,
|
|
||||||
requestCode(alarmId, 3),
|
|
||||||
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
|
|
||||||
action = ACTION_POSTPONE_NEXT
|
|
||||||
putExtra(EXTRA_ALARM_ID, alarmId)
|
|
||||||
putExtra(EXTRA_ALARM_TITLE, title)
|
|
||||||
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
|
|
||||||
putExtra(EXTRA_TRIGGER_AT, triggerAtMillis)
|
|
||||||
putExtra(EXTRA_OCCURRENCE_AT, occurrenceAtMillis)
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
|
||||||
.setContentTitle(title)
|
|
||||||
.setContentText("Empieza en 30 minutos")
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
|
||||||
.setSilent(true)
|
|
||||||
.setAutoCancel(true)
|
|
||||||
.setContentIntent(openAppIntent)
|
|
||||||
.addAction(0, "Posponer", postponeNextIntent)
|
|
||||||
.addAction(0, "Omitir esta vez", skipNextIntent)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
try {
|
|
||||||
NotificationManagerCompat.from(context).notify(notificationIdForAlarm(alarmId), notification)
|
|
||||||
Log.d(TAG, "alarm.notification preNotice shown id=$alarmId")
|
|
||||||
} catch (error: SecurityException) {
|
|
||||||
Log.e(TAG, "alarm.notification preNotice SecurityException id=$alarmId", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureChannel(context: Context) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
||||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
val existing = manager.getNotificationChannel(CHANNEL_ID)
|
|
||||||
if (existing != null) return
|
|
||||||
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
"Preavisos de alarmas",
|
|
||||||
NotificationManager.IMPORTANCE_LOW
|
|
||||||
).apply {
|
|
||||||
description = "Notificaciones silenciosas 30 minutos antes de la alarma"
|
|
||||||
setSound(null, null)
|
|
||||||
enableVibration(false)
|
|
||||||
}
|
|
||||||
manager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot
|
|
||||||
|
|
||||||
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
|
|
||||||
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val TAG = "PluriWave"
|
|
||||||
const val CHANNEL_ID = "pluriwave_alarm_pre_notice"
|
|
||||||
const val ACTION_FIRE = "es.freetimelab.pluriwave.alarm.FIRE"
|
|
||||||
const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE"
|
|
||||||
const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT"
|
|
||||||
const val ACTION_POSTPONE_NEXT = "es.freetimelab.pluriwave.alarm.POSTPONE_NEXT"
|
|
||||||
const val EXTRA_ALARM_ID = "alarmId"
|
|
||||||
const val EXTRA_ALARM_TITLE = "alarmTitle"
|
|
||||||
const val EXTRA_ALARM_ACTION = "alarmAction"
|
|
||||||
const val EXTRA_STATION_NAME = "stationName"
|
|
||||||
const val EXTRA_STATION_URL = "stationUrl"
|
|
||||||
const val EXTRA_FALLBACK_STATION_NAME = "fallbackStationName"
|
|
||||||
const val EXTRA_FALLBACK_STATION_URL = "fallbackStationUrl"
|
|
||||||
const val EXTRA_FALLBACK_SOUND = "fallbackSound"
|
|
||||||
const val EXTRA_VOLUME = "volume"
|
|
||||||
const val EXTRA_FADE_IN_SECONDS = "fadeInSegundos"
|
|
||||||
const val EXTRA_TRIGGER_AT = "triggerAtMillis"
|
|
||||||
const val EXTRA_OCCURRENCE_AT = "occurrenceAtMillis"
|
|
||||||
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
|
|
||||||
|
|
||||||
fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7
|
|
||||||
fun fireNotificationIdForAlarm(alarmId: String): Int = 59 * alarmId.hashCode() + 9
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,590 +0,0 @@
|
|||||||
package es.freetimelab.pluriwave
|
|
||||||
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.media.AudioAttributes
|
|
||||||
import android.media.MediaPlayer
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.os.Looper
|
|
||||||
import android.os.PowerManager
|
|
||||||
import android.provider.Settings
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.app.NotificationManagerCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Foreground service that owns native alarm audio and the single ringing
|
|
||||||
* notification (NOTIFICATION_ID, full-screen intent).
|
|
||||||
*
|
|
||||||
* Fade-in ownership boundary: this service ramps volume ONLY for its own
|
|
||||||
* MediaPlayer audio (station stream, fallback station or bundled WAV). The
|
|
||||||
* Flutter ringing screen owns the fade for the audio it starts itself
|
|
||||||
* (radio handler / local fallback player). They never play the same source
|
|
||||||
* simultaneously: the service stops once Flutter confirms its own audio via
|
|
||||||
* confirmFlutterAudio.
|
|
||||||
*/
|
|
||||||
class PluriWaveAlarmService : Service() {
|
|
||||||
private var player: MediaPlayer? = null
|
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
|
||||||
private var activeAlarmId: String? = null
|
|
||||||
private val mainHandler = Handler(Looper.getMainLooper())
|
|
||||||
private var stationFallbackRunnable: Runnable? = null
|
|
||||||
private var fadeInRunnable: Runnable? = null
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
val action = intent?.action
|
|
||||||
val requestedId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
|
|
||||||
Log.d(TAG, "alarm.service onStartCommand action=$action id=$requestedId active=$activeAlarmId")
|
|
||||||
|
|
||||||
when (action) {
|
|
||||||
ACTION_STOP -> {
|
|
||||||
stopAlarm(requestedId)
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
ACTION_SNOOZE -> {
|
|
||||||
val minutes = intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5)
|
|
||||||
if (requestedId != null) {
|
|
||||||
val snoozed = AlarmScheduler(this).snooze(requestedId, minutes)
|
|
||||||
if (snoozed != null) {
|
|
||||||
// D1 fix (Decision 2.1): report the native snooze back to
|
|
||||||
// Flutter so the canonical config records it. If the engine
|
|
||||||
// is dead this is a no-op and the cold-start sync
|
|
||||||
// (getNativeSnoozeState) reconciles on next launch.
|
|
||||||
MainActivity.notifyAlarmEvent(
|
|
||||||
mapOf(
|
|
||||||
"alarmId" to requestedId,
|
|
||||||
"alarmTitle" to snoozed.title,
|
|
||||||
"alarmAction" to MainActivity.ALARM_ACTION_SNOOZED,
|
|
||||||
"occurrenceAtMillis" to snoozed.occurrenceAtMillis,
|
|
||||||
"snoozeUntilMillis" to snoozed.snoozeUntilMillis,
|
|
||||||
"snoozeMinutes" to minutes
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stopAlarm(requestedId)
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
PluriWaveAlarmReceiver.ACTION_FIRE, null -> startAlarm(intent)
|
|
||||||
else -> Log.w(TAG, "alarm.service unknown action=$action id=$requestedId")
|
|
||||||
}
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startAlarm(intent: Intent?) {
|
|
||||||
val alarmId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID) ?: return
|
|
||||||
if (activeAlarmId != null) {
|
|
||||||
Log.w(TAG, "alarm.service ignored id=$alarmId because active=$activeAlarmId")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
activeAlarmId = alarmId
|
|
||||||
|
|
||||||
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave"
|
|
||||||
val stationName = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME)
|
|
||||||
val stationUrl = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL)
|
|
||||||
val fallbackStationName =
|
|
||||||
intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_NAME)
|
|
||||||
val fallbackStationUrl =
|
|
||||||
intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_URL)
|
|
||||||
val fallbackSound = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND)
|
|
||||||
val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f)
|
|
||||||
val fadeInSegundos =
|
|
||||||
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_FADE_IN_SECONDS, 0).coerceIn(0, 60)
|
|
||||||
val snoozeMinutes = sanitizeSnoozeMinutes(
|
|
||||||
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5)
|
|
||||||
)
|
|
||||||
|
|
||||||
acquireWakeLock()
|
|
||||||
// The FSI notification must be visible BEFORE audio prepares (prepareAsync is
|
|
||||||
// slow); startForeground runs first so the ringing surface never lags audio.
|
|
||||||
try {
|
|
||||||
val notification = buildNotification(alarmId, title, stationName, snoozeMinutes)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
|
||||||
startForeground(
|
|
||||||
NOTIFICATION_ID,
|
|
||||||
notification,
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
|
|
||||||
releaseWakeLock()
|
|
||||||
activeAlarmId = null
|
|
||||||
stopSelf()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
startAudio(
|
|
||||||
alarmId,
|
|
||||||
stationName,
|
|
||||||
stationUrl,
|
|
||||||
fallbackStationName,
|
|
||||||
fallbackStationUrl,
|
|
||||||
fallbackSound,
|
|
||||||
volume,
|
|
||||||
fadeInSegundos
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startAudio(
|
|
||||||
alarmId: String,
|
|
||||||
stationName: String?,
|
|
||||||
stationUrl: String?,
|
|
||||||
fallbackStationName: String?,
|
|
||||||
fallbackStationUrl: String?,
|
|
||||||
fallbackSound: String?,
|
|
||||||
volume: Float,
|
|
||||||
fadeInSegundos: Int
|
|
||||||
) {
|
|
||||||
player?.release()
|
|
||||||
player = null
|
|
||||||
|
|
||||||
// Three-stage ordered fallback: primary station -> fallback station -> bundled WAV.
|
|
||||||
// Each stage owns its own 15s timeout window via scheduleStationFallback.
|
|
||||||
val startBundled: (String) -> Unit = { reason ->
|
|
||||||
startFallbackAudio(alarmId, fallbackSound, volume, fadeInSegundos, reason)
|
|
||||||
}
|
|
||||||
val startFallbackStation: (String) -> Unit = { reason ->
|
|
||||||
if (fallbackStationUrl.isNullOrBlank()) {
|
|
||||||
startBundled(reason)
|
|
||||||
} else {
|
|
||||||
startStationAudio(
|
|
||||||
alarmId,
|
|
||||||
fallbackStationName,
|
|
||||||
fallbackStationUrl.trim(),
|
|
||||||
volume,
|
|
||||||
fadeInSegundos,
|
|
||||||
"fallback-station",
|
|
||||||
startBundled
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stationUrl.isNullOrBlank()) {
|
|
||||||
startFallbackStation("station url missing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
startStationAudio(
|
|
||||||
alarmId,
|
|
||||||
stationName,
|
|
||||||
stationUrl.trim(),
|
|
||||||
volume,
|
|
||||||
fadeInSegundos,
|
|
||||||
"station",
|
|
||||||
startFallbackStation
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startStationAudio(
|
|
||||||
alarmId: String,
|
|
||||||
stationName: String?,
|
|
||||||
stationUrl: String,
|
|
||||||
volume: Float,
|
|
||||||
fadeInSegundos: Int,
|
|
||||||
stage: String,
|
|
||||||
onStageFailed: (String) -> Unit
|
|
||||||
) {
|
|
||||||
player?.release()
|
|
||||||
player = null
|
|
||||||
scheduleStationFallback(alarmId, stage, onStageFailed)
|
|
||||||
val startVolume = initialVolume(volume, fadeInSegundos)
|
|
||||||
try {
|
|
||||||
player = MediaPlayer().apply {
|
|
||||||
setAudioAttributes(alarmAudioAttributes())
|
|
||||||
isLooping = false
|
|
||||||
setVolume(startVolume, startVolume)
|
|
||||||
setDataSource(
|
|
||||||
this@PluriWaveAlarmService,
|
|
||||||
Uri.parse(stationUrl),
|
|
||||||
mapOf("User-Agent" to "PluriWave/0.1.0 (native alarm)")
|
|
||||||
)
|
|
||||||
setOnPreparedListener {
|
|
||||||
if (activeAlarmId != alarmId) return@setOnPreparedListener
|
|
||||||
cancelStationFallback()
|
|
||||||
it.start()
|
|
||||||
startFadeIn(alarmId, it, volume, fadeInSegundos)
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"alarm.service $stage started id=$alarmId station=$stationName url=$stationUrl"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
setOnCompletionListener {
|
|
||||||
if (activeAlarmId != alarmId) return@setOnCompletionListener
|
|
||||||
Log.w(TAG, "alarm.service $stage completed id=$alarmId url=$stationUrl")
|
|
||||||
onStageFailed("$stage completed")
|
|
||||||
}
|
|
||||||
setOnErrorListener { mp, what, extra ->
|
|
||||||
Log.e(
|
|
||||||
TAG,
|
|
||||||
"alarm.service $stage error id=$alarmId what=$what extra=$extra url=$stationUrl"
|
|
||||||
)
|
|
||||||
runCatching { mp.reset() }
|
|
||||||
if (activeAlarmId == alarmId) {
|
|
||||||
onStageFailed("$stage error")
|
|
||||||
}
|
|
||||||
true
|
|
||||||
}
|
|
||||||
prepareAsync()
|
|
||||||
}
|
|
||||||
Log.d(TAG, "alarm.service $stage preparing id=$alarmId station=$stationName url=$stationUrl")
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(TAG, "alarm.service $stage prepare failed id=$alarmId url=$stationUrl", error)
|
|
||||||
onStageFailed("$stage prepare failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startFallbackAudio(
|
|
||||||
alarmId: String,
|
|
||||||
fallbackSound: String?,
|
|
||||||
volume: Float,
|
|
||||||
fadeInSegundos: Int,
|
|
||||||
reason: String
|
|
||||||
) {
|
|
||||||
cancelStationFallback()
|
|
||||||
player?.release()
|
|
||||||
player = null
|
|
||||||
|
|
||||||
val source = fallbackAssetPath(fallbackSound)
|
|
||||||
val startVolume = initialVolume(volume, fadeInSegundos)
|
|
||||||
try {
|
|
||||||
player = MediaPlayer().apply {
|
|
||||||
setAudioAttributes(alarmAudioAttributes())
|
|
||||||
isLooping = true
|
|
||||||
setVolume(startVolume, startVolume)
|
|
||||||
setFallbackAssetDataSource(this, fallbackSound)
|
|
||||||
setOnPreparedListener {
|
|
||||||
if (activeAlarmId != alarmId) return@setOnPreparedListener
|
|
||||||
it.start()
|
|
||||||
startFadeIn(alarmId, it, volume, fadeInSegundos)
|
|
||||||
Log.d(TAG, "alarm.service fallback started id=$alarmId source=$source reason=$reason")
|
|
||||||
}
|
|
||||||
setOnErrorListener { mp, what, extra ->
|
|
||||||
Log.e(TAG, "alarm.service fallback error id=$alarmId what=$what extra=$extra source=$source")
|
|
||||||
mp.reset()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
prepareAsync()
|
|
||||||
}
|
|
||||||
Log.d(TAG, "alarm.service fallback preparing id=$alarmId source=$source reason=$reason")
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(TAG, "alarm.service fallback prepare failed id=$alarmId source=$source", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun scheduleStationFallback(
|
|
||||||
alarmId: String,
|
|
||||||
stage: String,
|
|
||||||
onStageFailed: (String) -> Unit
|
|
||||||
) {
|
|
||||||
cancelStationFallback()
|
|
||||||
val runnable = Runnable {
|
|
||||||
if (activeAlarmId == alarmId) {
|
|
||||||
Log.w(TAG, "alarm.service $stage timeout id=$alarmId; advancing audio chain")
|
|
||||||
onStageFailed("$stage timeout")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stationFallbackRunnable = runnable
|
|
||||||
mainHandler.postDelayed(runnable, STATION_START_TIMEOUT_MILLIS)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun initialVolume(volume: Float, fadeInSegundos: Int): Float =
|
|
||||||
if (fadeInSegundos > 0) {
|
|
||||||
(volume * FADE_IN_START_FRACTION).coerceIn(0f, 1f)
|
|
||||||
} else {
|
|
||||||
volume
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startFadeIn(
|
|
||||||
alarmId: String,
|
|
||||||
mediaPlayer: MediaPlayer,
|
|
||||||
targetVolume: Float,
|
|
||||||
fadeInSegundos: Int
|
|
||||||
) {
|
|
||||||
cancelFadeIn()
|
|
||||||
if (fadeInSegundos <= 0) return
|
|
||||||
val steps = ((fadeInSegundos * 1000L) / FADE_IN_STEP_MILLIS).toInt().coerceAtLeast(1)
|
|
||||||
val startVolume = initialVolume(targetVolume, fadeInSegundos)
|
|
||||||
var step = 0
|
|
||||||
val runnable = object : Runnable {
|
|
||||||
override fun run() {
|
|
||||||
if (activeAlarmId != alarmId) return
|
|
||||||
step++
|
|
||||||
val fraction = step.toFloat() / steps
|
|
||||||
val current = (startVolume + (targetVolume - startVolume) * fraction)
|
|
||||||
.coerceIn(0f, 1f)
|
|
||||||
runCatching { mediaPlayer.setVolume(current, current) }
|
|
||||||
if (step < steps) {
|
|
||||||
mainHandler.postDelayed(this, FADE_IN_STEP_MILLIS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fadeInRunnable = runnable
|
|
||||||
mainHandler.postDelayed(runnable, FADE_IN_STEP_MILLIS)
|
|
||||||
Log.d(TAG, "alarm.service fade-in started id=$alarmId seconds=$fadeInSegundos steps=$steps")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cancelFadeIn() {
|
|
||||||
fadeInRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
||||||
fadeInRunnable = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cancelStationFallback() {
|
|
||||||
stationFallbackRunnable?.let { mainHandler.removeCallbacks(it) }
|
|
||||||
stationFallbackRunnable = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun alarmAudioAttributes(): AudioAttributes =
|
|
||||||
AudioAttributes.Builder()
|
|
||||||
.setUsage(AudioAttributes.USAGE_ALARM)
|
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun stopAlarm(alarmId: String?) {
|
|
||||||
Log.d(TAG, "alarm.service stop id=$alarmId active=$activeAlarmId")
|
|
||||||
cancelStationFallback()
|
|
||||||
cancelFadeIn()
|
|
||||||
try {
|
|
||||||
player?.stop()
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.w(TAG, "alarm.service stop player failed", error)
|
|
||||||
}
|
|
||||||
player?.release()
|
|
||||||
player = null
|
|
||||||
activeAlarmId = null
|
|
||||||
releaseWakeLock()
|
|
||||||
if (alarmId != null) {
|
|
||||||
NotificationManagerCompat.from(this).cancel(
|
|
||||||
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
stopForeground(true)
|
|
||||||
}
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildNotification(
|
|
||||||
alarmId: String,
|
|
||||||
title: String,
|
|
||||||
stationName: String?,
|
|
||||||
snoozeMinutes: Int
|
|
||||||
) =
|
|
||||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
|
|
||||||
.setContentTitle("Alarma PluriWave")
|
|
||||||
.setContentText(
|
|
||||||
if (stationName.isNullOrBlank()) title else "$title - $stationName"
|
|
||||||
)
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
||||||
.setOngoing(true)
|
|
||||||
.setAutoCancel(false)
|
|
||||||
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes), true)
|
|
||||||
.setContentIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes))
|
|
||||||
.addAction(0, "Posponer", snoozePendingIntent(alarmId, snoozeMinutes))
|
|
||||||
.addAction(0, "Detener", stopPendingIntent(alarmId))
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun openAlarmPendingIntent(
|
|
||||||
alarmId: String,
|
|
||||||
title: String,
|
|
||||||
snoozeMinutes: Int
|
|
||||||
): PendingIntent =
|
|
||||||
PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
requestCode(alarmId, 20),
|
|
||||||
Intent(this, MainActivity::class.java).apply {
|
|
||||||
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, snoozeMinutes)
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun stopPendingIntent(alarmId: String): PendingIntent =
|
|
||||||
PendingIntent.getService(
|
|
||||||
this,
|
|
||||||
requestCode(alarmId, 21),
|
|
||||||
Intent(this, PluriWaveAlarmService::class.java).apply {
|
|
||||||
action = ACTION_STOP
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun snoozePendingIntent(alarmId: String, minutes: Int): PendingIntent =
|
|
||||||
PendingIntent.getService(
|
|
||||||
this,
|
|
||||||
requestCode(alarmId, 30 + minutes),
|
|
||||||
Intent(this, PluriWaveAlarmService::class.java).apply {
|
|
||||||
action = ACTION_SNOOZE
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
|
||||||
putExtra(EXTRA_SNOOZE_MINUTES, minutes)
|
|
||||||
},
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun acquireWakeLock() {
|
|
||||||
if (wakeLock?.isHeld == true) return
|
|
||||||
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
|
|
||||||
wakeLock = powerManager.newWakeLock(
|
|
||||||
PowerManager.PARTIAL_WAKE_LOCK,
|
|
||||||
"PluriWave:AlarmWakeLock"
|
|
||||||
).apply {
|
|
||||||
setReferenceCounted(false)
|
|
||||||
acquire(10 * 60 * 1000L)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun releaseWakeLock() {
|
|
||||||
try {
|
|
||||||
if (wakeLock?.isHeld == true) wakeLock?.release()
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.w(TAG, "alarm.service wakeLock release failed", error)
|
|
||||||
}
|
|
||||||
wakeLock = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setFallbackAssetDataSource(mediaPlayer: MediaPlayer, sound: String?) {
|
|
||||||
val path = fallbackAssetPath(sound)
|
|
||||||
try {
|
|
||||||
val descriptor = assets.openFd(path)
|
|
||||||
mediaPlayer.setDataSource(
|
|
||||||
descriptor.fileDescriptor,
|
|
||||||
descriptor.startOffset,
|
|
||||||
descriptor.length
|
|
||||||
)
|
|
||||||
descriptor.close()
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.w(TAG, "alarm.service asset descriptor failed path=$path; copying to cache", error)
|
|
||||||
val cached = File(cacheDir, path.substringAfterLast('/'))
|
|
||||||
assets.open(path).use { input ->
|
|
||||||
cached.outputStream().use { output -> input.copyTo(output) }
|
|
||||||
}
|
|
||||||
mediaPlayer.setDataSource(cached.absolutePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fallbackAssetPath(sound: String?): String {
|
|
||||||
val fileName = when (sound) {
|
|
||||||
"campanaSuave" -> "alarm_campana_suave.wav"
|
|
||||||
"pulsoDigital" -> "alarm_pulso_digital.wav"
|
|
||||||
else -> "alarm_amanecer.wav"
|
|
||||||
}
|
|
||||||
return "flutter_assets/assets/audio/$fileName"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
|
|
||||||
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
stopAlarm(activeAlarmId)
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "PluriWave"
|
|
||||||
private const val CHANNEL_ID = "pluriwave_alarm_fire_v2"
|
|
||||||
private const val LEGACY_CHANNEL_NATIVE = "pluriwave_alarm_native"
|
|
||||||
private const val LEGACY_CHANNEL_FIRE = "pluriwave_alarm_fire"
|
|
||||||
private const val CHANNELS_PREFS = "pluriwave_alarm_channels"
|
|
||||||
private const val KEY_CHANNELS_MIGRATED_V2 = "channels_migrated_v2"
|
|
||||||
private const val NOTIFICATION_ID = 92841
|
|
||||||
const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE"
|
|
||||||
const val ACTION_SNOOZE = "es.freetimelab.pluriwave.alarm.SNOOZE_NATIVE"
|
|
||||||
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
|
|
||||||
private const val STATION_START_TIMEOUT_MILLIS = 15_000L
|
|
||||||
private const val FADE_IN_STEP_MILLIS = 250L
|
|
||||||
private const val FADE_IN_START_FRACTION = 0.05f
|
|
||||||
|
|
||||||
fun start(context: Context, source: Intent) {
|
|
||||||
ensureChannel(context)
|
|
||||||
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
|
||||||
action = PluriWaveAlarmReceiver.ACTION_FIRE
|
|
||||||
putExtras(source)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
ContextCompat.startForegroundService(context, intent)
|
|
||||||
Log.d(TAG, "alarm.service start requested")
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(TAG, "alarm.service start failed", error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop(context: Context, alarmId: String) {
|
|
||||||
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
|
|
||||||
action = ACTION_STOP
|
|
||||||
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
context.startService(intent)
|
|
||||||
Log.d(TAG, "alarm.service stop action requested id=$alarmId")
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
Log.e(TAG, "alarm.service stop request failed id=$alarmId", error)
|
|
||||||
try {
|
|
||||||
context.stopService(intent)
|
|
||||||
} catch (fallbackError: Throwable) {
|
|
||||||
Log.e(TAG, "alarm.service stop fallback failed id=$alarmId", fallbackError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureChannel(context: Context) {
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
|
||||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
migrateLegacyChannels(context, manager)
|
|
||||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
"Alarmas sonando",
|
|
||||||
NotificationManager.IMPORTANCE_HIGH
|
|
||||||
).apply {
|
|
||||||
description = "Sonido y pantalla urgente cuando una alarma musical debe sonar"
|
|
||||||
enableVibration(true)
|
|
||||||
setSound(
|
|
||||||
Settings.System.DEFAULT_ALARM_ALERT_URI,
|
|
||||||
AudioAttributes.Builder()
|
|
||||||
.setUsage(AudioAttributes.USAGE_ALARM)
|
|
||||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
manager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Android locks channel sound at creation time; the only way to apply
|
|
||||||
// USAGE_ALARM on existing installs is deleting the legacy channels and
|
|
||||||
// recreating under the versioned id. Runs once, guarded by a flag.
|
|
||||||
private fun migrateLegacyChannels(context: Context, manager: NotificationManager) {
|
|
||||||
val prefs = context.createDeviceProtectedStorageContext()
|
|
||||||
.getSharedPreferences(CHANNELS_PREFS, Context.MODE_PRIVATE)
|
|
||||||
if (prefs.getBoolean(KEY_CHANNELS_MIGRATED_V2, false)) return
|
|
||||||
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_NATIVE) }
|
|
||||||
runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE) }
|
|
||||||
prefs.edit().putBoolean(KEY_CHANNELS_MIGRATED_V2, true).apply()
|
|
||||||
Log.d(TAG, "alarm.service legacy notification channels migrated to v2")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun requestCode(id: String, slot: Int): Int = 67 * id.hashCode() + slot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package es.freetimelab.pluriwave
|
|
||||||
|
|
||||||
import android.content.BroadcastReceiver
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.util.Log
|
|
||||||
|
|
||||||
class PluriWaveBootReceiver : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
|
||||||
when (intent.action) {
|
|
||||||
Intent.ACTION_LOCKED_BOOT_COMPLETED,
|
|
||||||
Intent.ACTION_BOOT_COMPLETED,
|
|
||||||
Intent.ACTION_USER_UNLOCKED,
|
|
||||||
Intent.ACTION_MY_PACKAGE_REPLACED,
|
|
||||||
Intent.ACTION_TIME_CHANGED,
|
|
||||||
Intent.ACTION_TIMEZONE_CHANGED,
|
|
||||||
"android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" -> {
|
|
||||||
Log.d(TAG, "alarm.bootReceiver action=${intent.action}")
|
|
||||||
AlarmScheduler(context).reschedulePersistedAlarms()
|
|
||||||
}
|
|
||||||
else -> Log.w(TAG, "alarm.bootReceiver unknown action=${intent.action}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "PluriWave"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 13 KiB |
@@ -1,17 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!--
|
|
||||||
network_security_config.xml
|
|
||||||
Permite tráfico HTTP cleartext para streams de radio que no soporten HTTPS.
|
|
||||||
Fix para: "Cleartext HTTP traffic to [host] not permitted" en ExoPlayer.
|
|
||||||
-->
|
|
||||||
<network-security-config>
|
|
||||||
<!-- Permitir HTTP cleartext para streams de radio -->
|
|
||||||
<base-config cleartextTrafficPermitted="true">
|
|
||||||
<trust-anchors>
|
|
||||||
<!-- Certificados del sistema (CA reconocidas) -->
|
|
||||||
<certificates src="system"/>
|
|
||||||
<!-- Certificados de usuario (para desarrollo) -->
|
|
||||||
<certificates src="user"/>
|
|
||||||
</trust-anchors>
|
|
||||||
</base-config>
|
|
||||||
</network-security-config>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<files-path
|
|
||||||
name="files"
|
|
||||||
path="." />
|
|
||||||
<cache-path
|
|
||||||
name="cache"
|
|
||||||
path="." />
|
|
||||||
<external-files-path
|
|
||||||
name="external_files"
|
|
||||||
path="." />
|
|
||||||
<external-cache-path
|
|
||||||
name="external_cache"
|
|
||||||
path="." />
|
|
||||||
</paths>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# أهلاً بك في PluriWave
|
|
||||||
|
|
||||||
PluriWave هو راديوك العالمي المميز: محطات مباشرة، مفضلات منظمة، تسجيلات، معادل صوت ومنبّهات موسيقية ضمن تجربة مصممة بعناية.
|
|
||||||
|
|
||||||
## راديو مباشر
|
|
||||||
|
|
||||||
- ابحث عن المحطات حسب الاسم والبلد واللغة والجودة.
|
|
||||||
- استكشف المحطات القريبة واكتشف محطات جديدة.
|
|
||||||
- رتّب القوائم حسب الاسم أو الجودة.
|
|
||||||
|
|
||||||
## موسيقى بطريقتك
|
|
||||||
|
|
||||||
- احفظ المفضلات ونظّمها في مجموعات.
|
|
||||||
- اضبط المعادل العام أو إعدادات كل محطة.
|
|
||||||
- استخدم مؤقّت النوم بمدد مخصّصة.
|
|
||||||
|
|
||||||
## التسجيلات
|
|
||||||
|
|
||||||
- سجّل الراديو بدون إعادة ضغط البث الأصلي.
|
|
||||||
- حدّد الحجم الأقصى للملف لتبقى بأمان.
|
|
||||||
- افتح مجلد التسجيلات للمشاركة أو النقل أو التعديل.
|
|
||||||
|
|
||||||
## منبّهات موسيقية
|
|
||||||
|
|
||||||
- أنشئ منبّهات لمرة واحدة أو يومية أو لأيام العمل.
|
|
||||||
- اختر محطة مفضلة وصوتاً داخلياً آمناً.
|
|
||||||
- استخدم العطلات وتخطي التنفيذ التالي والغفوة.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# PluriWave-এ স্বাগতম
|
|
||||||
|
|
||||||
PluriWave আপনার প্রিমিয়াম বিশ্ব রেডিও: লাইভ স্টেশন, গোছানো ফেভারিট, রেকর্ডিং, ইকুয়ালাইজার এবং মিউজিক অ্যালার্ম—সবই যত্নসহ তৈরি এক অভিজ্ঞতায়।
|
|
||||||
|
|
||||||
## লাইভ রেডিও
|
|
||||||
|
|
||||||
- নাম, দেশ, ভাষা ও মান অনুযায়ী স্টেশন খুঁজুন।
|
|
||||||
- কাছাকাছি স্টেশন দেখুন এবং নতুন রেডিও আবিষ্কার করুন।
|
|
||||||
- তালিকা নাম বা মান অনুযায়ী সাজান।
|
|
||||||
|
|
||||||
## আপনার মতো করে সঙ্গীত
|
|
||||||
|
|
||||||
- ফেভারিট সংরক্ষণ করুন এবং গ্রুপে সাজান।
|
|
||||||
- গ্লোবাল ইকুয়ালাইজার বা স্টেশনভিত্তিক প্রিসেট ঠিক করুন।
|
|
||||||
- নিজের মতো সময় দিয়ে স্লিপ টাইমার ব্যবহার করুন।
|
|
||||||
|
|
||||||
## রেকর্ডিং
|
|
||||||
|
|
||||||
- মূল স্ট্রিম রিকমপ্রেস না করে রেডিও রেকর্ড করুন।
|
|
||||||
- নিরাপদ থাকতে সর্বোচ্চ ফাইল সাইজ সীমা দিন।
|
|
||||||
- শেয়ার, সরানো বা সম্পাদনার জন্য রেকর্ডিং ফোল্ডার খুলুন।
|
|
||||||
|
|
||||||
## মিউজিক অ্যালার্ম
|
|
||||||
|
|
||||||
- একবার, প্রতিদিন বা কর্মদিবসের অ্যালার্ম তৈরি করুন।
|
|
||||||
- প্রিয় স্টেশন ও নিরাপদ অভ্যন্তরীণ সাউন্ড বেছে নিন।
|
|
||||||
- ছুটি, পরের রান স্কিপ এবং স্নুজ ব্যবহার করুন।
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Willkommen bei PluriWave
|
|
||||||
|
|
||||||
PluriWave ist Ihr Premium-Weltradio: Live-Sender, organisierte Favoriten, Aufnahmen, Equalizer und Musikalarme in einer sorgfältig gestalteten Erfahrung.
|
|
||||||
|
|
||||||
## Live-Radio
|
|
||||||
|
|
||||||
- Suche nach Sendern nach Name, Land, Sprache und Qualität.
|
|
||||||
- Entdecke Sender in der Nähe und finde neue Radios.
|
|
||||||
- Sortiere Listen nach Name oder Qualität.
|
|
||||||
|
|
||||||
## Musik auf deine Art
|
|
||||||
|
|
||||||
- Speichere Favoriten und organisiere sie in Gruppen.
|
|
||||||
- Stelle den globalen Equalizer oder Sender-Presets ein.
|
|
||||||
- Nutze den Sleep-Timer mit eigenen Laufzeiten.
|
|
||||||
|
|
||||||
## Aufnahmen
|
|
||||||
|
|
||||||
- Nimm Radio auf, ohne den Original-Stream neu zu komprimieren.
|
|
||||||
- Begrenze die maximale Dateigröße für mehr Sicherheit.
|
|
||||||
- Öffne den Aufnahmeordner zum Teilen, Verschieben oder Bearbeiten von Dateien.
|
|
||||||
|
|
||||||
## Musikalarme
|
|
||||||
|
|
||||||
- Erstelle einmalige, tägliche oder Wochentags-Alarme.
|
|
||||||
- Wähle einen Lieblingssender und einen sicheren internen Ton.
|
|
||||||
- Nutze Feiertage, "nächste Ausführung überspringen" und Snooze.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Welcome to PluriWave
|
|
||||||
|
|
||||||
PluriWave is your premium world radio: live stations, organized favorites, recordings, equalizer and musical alarms in a carefully crafted experience.
|
|
||||||
|
|
||||||
## Live radio
|
|
||||||
|
|
||||||
- Search stations by name, country, language and quality.
|
|
||||||
- Explore nearby stations and discover new radio.
|
|
||||||
- Sort lists by name or quality.
|
|
||||||
|
|
||||||
## Music your way
|
|
||||||
|
|
||||||
- Save favorites and organize them into groups.
|
|
||||||
- Tune the global equalizer or per-station presets.
|
|
||||||
- Use the sleep timer with custom durations.
|
|
||||||
|
|
||||||
## Recordings
|
|
||||||
|
|
||||||
- Record radio without recompressing the original stream.
|
|
||||||
- Limit maximum file size to stay safe.
|
|
||||||
- Open the recordings folder to share, move or edit files.
|
|
||||||
|
|
||||||
## Musical alarms
|
|
||||||
|
|
||||||
- Create one-time, daily or weekday alarms.
|
|
||||||
- Choose a favorite station and a safe internal sound.
|
|
||||||
- Use holidays, skip-next execution and snooze.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Bienvenido a PluriWave
|
|
||||||
|
|
||||||
PluriWave es tu radio mundial premium: emisoras en directo, favoritos organizados, grabaciones, ecualizador y alarmas musicales en una experiencia cuidada.
|
|
||||||
|
|
||||||
## Radio en vivo
|
|
||||||
|
|
||||||
- Buscá emisoras por nombre, país, idioma y calidad.
|
|
||||||
- Explorá emisoras cercanas y descubrí radios nuevas.
|
|
||||||
- Ordená listas por nombre o calidad.
|
|
||||||
|
|
||||||
## Música a tu manera
|
|
||||||
|
|
||||||
- Guardá favoritos y organizalos en grupos.
|
|
||||||
- Ajustá el ecualizador global o los presets por emisora.
|
|
||||||
- Usá el temporizador de sueño con duraciones personalizadas.
|
|
||||||
|
|
||||||
## Grabaciones
|
|
||||||
|
|
||||||
- Grabá radio sin recomprimir el stream original.
|
|
||||||
- Limitá el tamaño máximo del archivo para evitar sustos.
|
|
||||||
- Abrí la carpeta de grabaciones para compartir, mover o editar archivos.
|
|
||||||
|
|
||||||
## Alarmas musicales
|
|
||||||
|
|
||||||
- Creá alarmas únicas, diarias o por días de semana.
|
|
||||||
- Elegí una emisora favorita y un sonido interno seguro.
|
|
||||||
- Usá vacaciones, omitir la próxima ejecución y posponer.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Bienvenue dans PluriWave
|
|
||||||
|
|
||||||
PluriWave est votre radio mondiale premium : stations en direct, favoris organisés, enregistrements, égaliseur et alarmes musicales dans une expérience soignée.
|
|
||||||
|
|
||||||
## Radio en direct
|
|
||||||
|
|
||||||
- Recherchez des stations par nom, pays, langue et qualité.
|
|
||||||
- Explorez les stations proches et découvrez de nouvelles radios.
|
|
||||||
- Triez les listes par nom ou qualité.
|
|
||||||
|
|
||||||
## Votre musique, votre style
|
|
||||||
|
|
||||||
- Enregistrez vos favoris et organisez-les en groupes.
|
|
||||||
- Réglez l'égaliseur global ou des préréglages par station.
|
|
||||||
- Utilisez le minuteur de sommeil avec des durées personnalisées.
|
|
||||||
|
|
||||||
## Enregistrements
|
|
||||||
|
|
||||||
- Enregistrez la radio sans recompresser le flux d'origine.
|
|
||||||
- Limitez la taille maximale des fichiers pour rester serein.
|
|
||||||
- Ouvrez le dossier des enregistrements pour partager, déplacer ou modifier des fichiers.
|
|
||||||
|
|
||||||
## Alarmes musicales
|
|
||||||
|
|
||||||
- Créez des alarmes uniques, quotidiennes ou en semaine.
|
|
||||||
- Choisissez une station favorite et un son interne sûr.
|
|
||||||
- Utilisez les vacances, le saut de la prochaine exécution et le snooze.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# PluriWave में आपका स्वागत है
|
|
||||||
|
|
||||||
PluriWave आपका प्रीमियम विश्व रेडियो है: लाइव स्टेशन, व्यवस्थित पसंदीदा, रिकॉर्डिंग, इक्वलाइज़र और संगीत अलार्म एक सधे हुए अनुभव में।
|
|
||||||
|
|
||||||
## लाइव रेडियो
|
|
||||||
|
|
||||||
- स्टेशन को नाम, देश, भाषा और गुणवत्ता से खोजें।
|
|
||||||
- पास के स्टेशन देखें और नए रेडियो खोजें।
|
|
||||||
- सूचियों को नाम या गुणवत्ता के अनुसार क्रमित करें।
|
|
||||||
|
|
||||||
## संगीत आपके तरीके से
|
|
||||||
|
|
||||||
- पसंदीदा सहेजें और उन्हें समूहों में व्यवस्थित करें।
|
|
||||||
- ग्लोबल इक्वलाइज़र या स्टेशन-विशिष्ट प्रीसेट समायोजित करें।
|
|
||||||
- अपनी पसंद की अवधि वाला स्लीप टाइमर इस्तेमाल करें।
|
|
||||||
|
|
||||||
## रिकॉर्डिंग
|
|
||||||
|
|
||||||
- मूल स्ट्रीम को फिर से कंप्रेस किए बिना रेडियो रिकॉर्ड करें।
|
|
||||||
- सुरक्षित रहने के लिए अधिकतम फ़ाइल आकार सीमित करें।
|
|
||||||
- फ़ाइलें साझा करने, स्थानांतरित करने या संपादित करने के लिए रिकॉर्डिंग फ़ोल्डर खोलें।
|
|
||||||
|
|
||||||
## संगीत अलार्म
|
|
||||||
|
|
||||||
- एक बार, रोज़ाना या कार्यदिवस अलार्म बनाएँ।
|
|
||||||
- पसंदीदा स्टेशन और सुरक्षित आंतरिक ध्वनि चुनें।
|
|
||||||
- छुट्टियाँ, अगला निष्पादन छोड़ना और स्नूज़ का उपयोग करें।
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Selamat datang di PluriWave
|
|
||||||
|
|
||||||
PluriWave adalah radio dunia premium Anda: stasiun langsung, favorit terorganisir, rekaman, equalizer, dan alarm musik dalam pengalaman yang dirancang rapi.
|
|
||||||
|
|
||||||
## Radio langsung
|
|
||||||
|
|
||||||
- Cari stasiun berdasarkan nama, negara, bahasa, dan kualitas.
|
|
||||||
- Jelajahi stasiun terdekat dan temukan radio baru.
|
|
||||||
- Urutkan daftar berdasarkan nama atau kualitas.
|
|
||||||
|
|
||||||
## Musik sesuai cara Anda
|
|
||||||
|
|
||||||
- Simpan favorit dan atur ke dalam grup.
|
|
||||||
- Atur equalizer global atau preset per stasiun.
|
|
||||||
- Gunakan sleep timer dengan durasi kustom.
|
|
||||||
|
|
||||||
## Rekaman
|
|
||||||
|
|
||||||
- Rekam radio tanpa mengompresi ulang stream asli.
|
|
||||||
- Batasi ukuran file maksimum agar tetap aman.
|
|
||||||
- Buka folder rekaman untuk berbagi, memindahkan, atau mengedit file.
|
|
||||||
|
|
||||||
## Alarm musik
|
|
||||||
|
|
||||||
- Buat alarm sekali, harian, atau hari kerja.
|
|
||||||
- Pilih stasiun favorit dan suara internal yang aman.
|
|
||||||
- Gunakan hari libur, lewati eksekusi berikutnya, dan snooze.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Benvenuto in PluriWave
|
|
||||||
|
|
||||||
PluriWave è la tua radio mondiale premium: stazioni live, preferiti organizzati, registrazioni, equalizzatore e sveglie musicali in un'esperienza curata.
|
|
||||||
|
|
||||||
## Radio live
|
|
||||||
|
|
||||||
- Cerca stazioni per nome, paese, lingua e qualità.
|
|
||||||
- Esplora le stazioni vicine e scopri nuove radio.
|
|
||||||
- Ordina le liste per nome o qualità.
|
|
||||||
|
|
||||||
## Musica a modo tuo
|
|
||||||
|
|
||||||
- Salva i preferiti e organizzali in gruppi.
|
|
||||||
- Regola l'equalizzatore globale o i preset per stazione.
|
|
||||||
- Usa il timer di spegnimento con durate personalizzate.
|
|
||||||
|
|
||||||
## Registrazioni
|
|
||||||
|
|
||||||
- Registra la radio senza ricomprimere il flusso originale.
|
|
||||||
- Limita la dimensione massima dei file per stare tranquillo.
|
|
||||||
- Apri la cartella registrazioni per condividere, spostare o modificare i file.
|
|
||||||
|
|
||||||
## Sveglie musicali
|
|
||||||
|
|
||||||
- Crea sveglie singole, giornaliere o nei giorni feriali.
|
|
||||||
- Scegli una stazione preferita e un suono interno sicuro.
|
|
||||||
- Usa ferie, salto della prossima esecuzione e snooze.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# PluriWave へようこそ
|
|
||||||
|
|
||||||
PluriWave は、ライブ局、お気に入り整理、録音、イコライザー、音楽アラームを備えた高品質なワールドラジオです。
|
|
||||||
|
|
||||||
## ライブラジオ
|
|
||||||
|
|
||||||
- 名前、国、言語、音質で局を検索できます。
|
|
||||||
- 近くの局を探して新しいラジオを見つけられます。
|
|
||||||
- リストを名前または音質で並べ替えできます。
|
|
||||||
|
|
||||||
## あなた好みの音楽体験
|
|
||||||
|
|
||||||
- お気に入りを保存してグループで整理できます。
|
|
||||||
- 全体イコライザーや局ごとのプリセットを調整できます。
|
|
||||||
- 時間を指定できるスリープタイマーを使えます。
|
|
||||||
|
|
||||||
## 録音
|
|
||||||
|
|
||||||
- 元のストリームを再圧縮せずに録音できます。
|
|
||||||
- 最大ファイルサイズを制限して安全に使えます。
|
|
||||||
- 録音フォルダーを開いて共有・移動・編集できます。
|
|
||||||
|
|
||||||
## 音楽アラーム
|
|
||||||
|
|
||||||
- 1回のみ、毎日、平日のアラームを作成できます。
|
|
||||||
- お気に入り局と安全な内蔵サウンドを選べます。
|
|
||||||
- 休日設定、次回スキップ、スヌーズに対応しています。
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Bem-vindo ao PluriWave
|
|
||||||
|
|
||||||
PluriWave é seu rádio mundial premium: estações ao vivo, favoritos organizados, gravações, equalizador e alarmes musicais em uma experiência caprichada.
|
|
||||||
|
|
||||||
## Rádio ao vivo
|
|
||||||
|
|
||||||
- Procure estações por nome, país, idioma e qualidade.
|
|
||||||
- Explore estações próximas e descubra novas rádios.
|
|
||||||
- Ordene listas por nome ou qualidade.
|
|
||||||
|
|
||||||
## Música do seu jeito
|
|
||||||
|
|
||||||
- Salve favoritos e organize em grupos.
|
|
||||||
- Ajuste o equalizador global ou presets por estação.
|
|
||||||
- Use o timer de sono com durações personalizadas.
|
|
||||||
|
|
||||||
## Gravações
|
|
||||||
|
|
||||||
- Grave rádio sem recomprimir o stream original.
|
|
||||||
- Limite o tamanho máximo dos arquivos para evitar problemas.
|
|
||||||
- Abra a pasta de gravações para compartilhar, mover ou editar arquivos.
|
|
||||||
|
|
||||||
## Alarmes musicais
|
|
||||||
|
|
||||||
- Crie alarmes únicos, diários ou de dias úteis.
|
|
||||||
- Escolha uma estação favorita e um som interno seguro.
|
|
||||||
- Use feriados, pular próxima execução e soneca.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Добро пожаловать в PluriWave
|
|
||||||
|
|
||||||
PluriWave — ваше премиальное мировое радио: прямые станции, организованные избранные, записи, эквалайзер и музыкальные будильники в продуманном интерфейсе.
|
|
||||||
|
|
||||||
## Прямое радио
|
|
||||||
|
|
||||||
- Ищите станции по названию, стране, языку и качеству.
|
|
||||||
- Изучайте ближайшие станции и открывайте новое радио.
|
|
||||||
- Сортируйте списки по названию или качеству.
|
|
||||||
|
|
||||||
## Музыка по-вашему
|
|
||||||
|
|
||||||
- Сохраняйте избранное и организуйте его по группам.
|
|
||||||
- Настраивайте глобальный эквалайзер или пресеты для станций.
|
|
||||||
- Используйте таймер сна с нужной длительностью.
|
|
||||||
|
|
||||||
## Записи
|
|
||||||
|
|
||||||
- Записывайте радио без повторного сжатия исходного потока.
|
|
||||||
- Ограничивайте максимальный размер файла для безопасности.
|
|
||||||
- Открывайте папку записей, чтобы делиться, перемещать и редактировать файлы.
|
|
||||||
|
|
||||||
## Музыкальные будильники
|
|
||||||
|
|
||||||
- Создавайте разовые, ежедневные или будничные будильники.
|
|
||||||
- Выбирайте любимую станцию и безопасный встроенный звук.
|
|
||||||
- Используйте праздники, пропуск следующего запуска и отложенный сигнал.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 欢迎使用 PluriWave
|
|
||||||
|
|
||||||
PluriWave 是你的高品质全球电台:直播电台、分组收藏、录音、均衡器和音乐闹钟,体验精致流畅。
|
|
||||||
|
|
||||||
## 直播电台
|
|
||||||
|
|
||||||
- 按名称、国家、语言和音质搜索电台。
|
|
||||||
- 探索附近电台,发现新的广播内容。
|
|
||||||
- 按名称或音质排序列表。
|
|
||||||
|
|
||||||
## 按你的方式听音乐
|
|
||||||
|
|
||||||
- 保存收藏并按分组整理。
|
|
||||||
- 调整全局均衡器或单电台预设。
|
|
||||||
- 使用可自定义时长的睡眠定时器。
|
|
||||||
|
|
||||||
## 录音
|
|
||||||
|
|
||||||
- 录制电台时不重新压缩原始流。
|
|
||||||
- 限制最大文件大小,更安全省心。
|
|
||||||
- 打开录音文件夹以分享、移动或编辑文件。
|
|
||||||
|
|
||||||
## 音乐闹钟
|
|
||||||
|
|
||||||
- 创建一次性、每日或工作日闹钟。
|
|
||||||
- 选择喜爱的电台和安全的内置提示音。
|
|
||||||
- 支持假期、跳过下次执行和贪睡。
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# v0.1.47 · منبّهات وملفات أكثر موثوقية
|
|
||||||
|
|
||||||
الملخّص: عززنا أساس منبّهات Android وفصلنا بوضوح بين فتح المجلد وتغيير مساره.
|
|
||||||
|
|
||||||
## التحسينات
|
|
||||||
|
|
||||||
- أساس أصلي جديد للمنبّهات مع صوت داخلي آمن.
|
|
||||||
- تشخيص أفضل لأذونات Android الخاصة بالمنبّهات الدقيقة.
|
|
||||||
- المنبّهات التي تُنشأ في الدقيقة نفسها لم تعد تُستبعد بسبب الثواني.
|
|
||||||
- لوحة المنبّهات تميّز بين المنبّهات النشطة والمنبّهات بلا تنفيذ تالٍ صالح.
|
|
||||||
- فتح المجلد يحاول الآن فتح المسار المحفوظ؛ تغيير المسار أصبح منفصلاً.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# v0.1.47 · আরও নির্ভরযোগ্য অ্যালার্ম ও ফাইল
|
|
||||||
|
|
||||||
সারাংশ: আমরা Android অ্যালার্মের ভিত্তি শক্ত করেছি এবং ফোল্ডার খোলা ও পথ পরিবর্তনকে স্পষ্টভাবে আলাদা করেছি।
|
|
||||||
|
|
||||||
## উন্নতি
|
|
||||||
|
|
||||||
- নিরাপদ অভ্যন্তরীণ সাউন্ডসহ অ্যালার্মের জন্য নতুন নেটিভ ভিত্তি।
|
|
||||||
- Android exact-alarm অনুমতির উন্নত ডায়াগনস্টিক।
|
|
||||||
- একই মিনিটে তৈরি অ্যালার্ম এখন সেকেন্ডের কারণে বাদ পড়ে না।
|
|
||||||
- অ্যালার্ম প্যানেল সক্রিয় অ্যালার্ম ও বৈধ পরের রানবিহীন অ্যালার্ম আলাদা করে।
|
|
||||||
- ফোল্ডার খোলা এখন সংরক্ষিত পথ খোলার চেষ্টা করে; পথ বদল আলাদা করা হয়েছে।
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# v0.1.47 · Zuverlässigere Alarme und Dateien
|
|
||||||
|
|
||||||
Zusammenfassung: Wir haben die Android-Alarmbasis verstärkt und das Öffnen eines Ordners klar vom Ändern seines Pfads getrennt.
|
|
||||||
|
|
||||||
## Verbesserungen
|
|
||||||
|
|
||||||
- Neue native Grundlage für Alarme mit sicherem internem Ton.
|
|
||||||
- Bessere Diagnose der Android-Berechtigung für exakte Alarme.
|
|
||||||
- Alarme, die in derselben Minute erstellt werden, werden wegen Sekunden nicht mehr verworfen.
|
|
||||||
- Das Alarmpanel unterscheidet aktive Alarme von Alarmen ohne gültige nächste Ausführung.
|
|
||||||
- Ordner öffnen versucht jetzt den gespeicherten Pfad zu öffnen; Pfad ändern ist separat.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# v0.1.47 · More reliable alarms and files
|
|
||||||
|
|
||||||
Summary: we reinforced the Android alarm foundation and clearly separated opening a folder from changing its path.
|
|
||||||
|
|
||||||
## Improvements
|
|
||||||
|
|
||||||
- New native foundation for alarms with a safe internal sound.
|
|
||||||
- Better Android exact-alarm permission diagnostics.
|
|
||||||
- Alarms created in the same minute are no longer discarded because of seconds.
|
|
||||||
- The alarms panel distinguishes active alarms from alarms without a valid next execution.
|
|
||||||
- Open folder now tries to open the saved path; change path is separate.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# v0.1.47 · Alarmas y archivos más fiables
|
|
||||||
|
|
||||||
Resumen: reforzamos la base de alarmas Android y separamos claramente abrir carpeta de cambiar ruta.
|
|
||||||
|
|
||||||
## Mejoras
|
|
||||||
|
|
||||||
- Nueva base nativa para alarmas con sonido interno seguro.
|
|
||||||
- Mejor diagnóstico de permisos Android para alarmas exactas.
|
|
||||||
- Las alarmas creadas en el mismo minuto ya no se descartan por segundos.
|
|
||||||
- El panel de alarmas distingue entre alarmas activas y alarmas sin próxima ejecución válida.
|
|
||||||
- Abrir carpeta ahora intenta abrir la ruta guardada; cambiar ruta queda separado.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# v0.1.47 · Alarmes et fichiers plus fiables
|
|
||||||
|
|
||||||
Résumé : nous avons renforcé la base des alarmes Android et séparé clairement l'ouverture d'un dossier du changement de chemin.
|
|
||||||
|
|
||||||
## Améliorations
|
|
||||||
|
|
||||||
- Nouvelle base native pour les alarmes avec un son interne sûr.
|
|
||||||
- Meilleur diagnostic des permissions Android pour les alarmes exactes.
|
|
||||||
- Les alarmes créées dans la même minute ne sont plus ignorées à cause des secondes.
|
|
||||||
- Le panneau d'alarmes distingue les alarmes actives de celles sans prochaine exécution valide.
|
|
||||||
- Ouvrir le dossier tente désormais d'ouvrir le chemin enregistré ; changer le chemin est séparé.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# v0.1.47 · अधिक भरोसेमंद अलार्म और फ़ाइलें
|
|
||||||
|
|
||||||
सारांश: हमने Android अलार्म की बुनियाद मजबूत की और फ़ोल्डर खोलने को उसका पथ बदलने से स्पष्ट रूप से अलग किया।
|
|
||||||
|
|
||||||
## सुधार
|
|
||||||
|
|
||||||
- सुरक्षित आंतरिक ध्वनि के साथ अलार्म के लिए नई नेटिव बुनियाद।
|
|
||||||
- Android exact-alarm अनुमति के बेहतर निदान।
|
|
||||||
- एक ही मिनट में बने अलार्म अब सेकंड की वजह से हटाए नहीं जाते।
|
|
||||||
- अलार्म पैनल सक्रिय अलार्म और बिना वैध अगली निष्पादन के अलार्म में अंतर करता है।
|
|
||||||
- फ़ोल्डर खोलना अब सहेजा गया पथ खोलने की कोशिश करता है; पथ बदलना अलग है।
|
|
||||||
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# v0.1.47 · Alarm dan file lebih andal
|
|
||||||
|
|
||||||
Ringkasan: kami memperkuat fondasi alarm Android dan memisahkan dengan jelas antara membuka folder dan mengubah jalurnya.
|
|
||||||
|
|
||||||
## Peningkatan
|
|
||||||
|
|
||||||
- Fondasi native baru untuk alarm dengan suara internal yang aman.
|
|
||||||
- Diagnostik izin exact-alarm Android yang lebih baik.
|
|
||||||
- Alarm yang dibuat pada menit yang sama tidak lagi dibuang karena detik.
|
|
||||||
- Panel alarm membedakan alarm aktif dari alarm tanpa eksekusi berikutnya yang valid.
|
|
||||||
- Buka folder sekarang mencoba membuka jalur tersimpan; ubah jalur dipisahkan.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# v0.1.47 · Allarmi e file più affidabili
|
|
||||||
|
|
||||||
Riepilogo: abbiamo rafforzato la base degli allarmi Android e separato chiaramente l'apertura di una cartella dalla modifica del suo percorso.
|
|
||||||
|
|
||||||
## Miglioramenti
|
|
||||||
|
|
||||||
- Nuova base nativa per gli allarmi con suono interno sicuro.
|
|
||||||
- Diagnostica migliore dei permessi Android per gli allarmi esatti.
|
|
||||||
- Gli allarmi creati nello stesso minuto non vengono più scartati a causa dei secondi.
|
|
||||||
- Il pannello allarmi distingue gli allarmi attivi da quelli senza prossima esecuzione valida.
|
|
||||||
- Apri cartella ora prova ad aprire il percorso salvato; cambia percorso è separato.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# v0.1.47 · より信頼できるアラームとファイル
|
|
||||||
|
|
||||||
概要: Android のアラーム基盤を強化し、フォルダーを開く操作とパス変更を明確に分離しました。
|
|
||||||
|
|
||||||
## 改善点
|
|
||||||
|
|
||||||
- 安全な内部サウンドを備えた、新しいネイティブアラーム基盤を導入。
|
|
||||||
- Android の正確なアラーム権限診断を改善。
|
|
||||||
- 同じ分に作成したアラームが秒の違いで破棄されなくなりました。
|
|
||||||
- アラームパネルで、有効な次回実行があるアラームとないアラームを区別。
|
|
||||||
- フォルダーを開くは保存済みパスを開くようになり、パス変更は別操作になりました。
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# v0.1.47 · Alarmes e arquivos mais confiáveis
|
|
||||||
|
|
||||||
Resumo: reforçamos a base de alarmes do Android e separamos claramente abrir pasta de mudar caminho.
|
|
||||||
|
|
||||||
## Melhorias
|
|
||||||
|
|
||||||
- Nova base nativa para alarmes com som interno seguro.
|
|
||||||
- Melhor diagnóstico de permissões Android para alarmes exatos.
|
|
||||||
- Alarmes criados no mesmo minuto não são mais descartados por causa dos segundos.
|
|
||||||
- O painel de alarmes distingue alarmes ativos de alarmes sem próxima execução válida.
|
|
||||||
- Abrir pasta agora tenta abrir o caminho salvo; mudar caminho fica separado.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# v0.1.47 · Более надежные будильники и файлы
|
|
||||||
|
|
||||||
Кратко: мы усилили основу будильников Android и четко разделили открытие папки и изменение её пути.
|
|
||||||
|
|
||||||
## Улучшения
|
|
||||||
|
|
||||||
- Новая нативная основа будильников с безопасным встроенным звуком.
|
|
||||||
- Улучшена диагностика разрешений Android для точных будильников.
|
|
||||||
- Будильники, созданные в ту же минуту, больше не отбрасываются из-за секунд.
|
|
||||||
- Панель будильников различает активные будильники и будильники без валидного следующего запуска.
|
|
||||||
- Открыть папку теперь пытается открыть сохраненный путь; изменение пути вынесено отдельно.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# v0.1.47 · 更可靠的闹钟与文件
|
|
||||||
|
|
||||||
摘要:我们强化了 Android 闹钟基础,并清晰区分了“打开文件夹”和“更改路径”。
|
|
||||||
|
|
||||||
## 改进
|
|
||||||
|
|
||||||
- 闹钟采用新的原生基础,配有安全的内置提示音。
|
|
||||||
- 改进 Android 精确闹钟权限诊断。
|
|
||||||
- 同一分钟创建的闹钟不再因秒数被丢弃。
|
|
||||||
- 闹钟面板可区分活跃闹钟与无有效下次执行的闹钟。
|
|
||||||
- “打开文件夹”现在会尝试打开已保存路径;“更改路径”独立处理。
|
|
||||||
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.5 MiB |
@@ -1,5 +0,0 @@
|
|||||||
# PluriWave Night Ocean asset sheet prompt
|
|
||||||
|
|
||||||
Generated with built-in image_gen for the Night Ocean Broadcast redesign.
|
|
||||||
|
|
||||||
Contents: app mark, station fallback artworks, aurora/waveform banner, and navigation glyph assets using teal/amber/cream over navy with no purple/magenta dominance.
|
|
||||||
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 552 KiB |
|
Before Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 382 KiB |
|
Before Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 346 KiB |
|
Before Width: | Height: | Size: 405 KiB |
|
Before Width: | Height: | Size: 552 KiB |
|
Before Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 519 KiB |
|
Before Width: | Height: | Size: 472 KiB |
|
Before Width: | Height: | Size: 466 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 548 KiB |
|
Before Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -1,3 +0,0 @@
|
|||||||
PluriWave AAA mockup generated with image_gen.
|
|
||||||
Visual direction: midnight-ocean glass, teal/cyan audio waves, coral sunrise accents, warm gold broadcast particles, accessible high contrast, no purple-dominant palette.
|
|
||||||
Launcher/app icon intentionally preserved.
|
|
||||||
|
Before Width: | Height: | Size: 1.7 MiB |
@@ -1,5 +0,0 @@
|
|||||||
# PluriWave award mockup prompt
|
|
||||||
|
|
||||||
Generated with built-in image_gen as the visual target for the premium redesign.
|
|
||||||
|
|
||||||
Focus: five mobile screens, dark aurora glassmorphism, cyan/violet/magenta gradients, premium iconography, accessible hierarchy, Home/Search/Favorites/Now Playing/Settings.
|
|
||||||
|
Before Width: | Height: | Size: 1.6 MiB |
@@ -1,5 +0,0 @@
|
|||||||
# PluriWave Night Ocean mockup prompt
|
|
||||||
|
|
||||||
Generated with built-in image_gen after user feedback rejecting purple-heavy futuristic UI.
|
|
||||||
|
|
||||||
Direction: Night Ocean Broadcast ? midnight navy and petrol teal base, mint action states, warm amber live/accent, cream text/surfaces, practical radio streaming UX with immediate Now Playing flow.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Alarmas Android en PluriWave
|
|
||||||
|
|
||||||
PluriWave programa las alarmas con `AlarmManager.setAlarmClock`, porque es el camino Android pensado para despertadores visibles y de alta fiabilidad. Flutter conserva la configuración, la UI, la emisora y los fallbacks; Android se encarga de despertar la app en el momento exacto.
|
|
||||||
|
|
||||||
## Flujo
|
|
||||||
|
|
||||||
1. Flutter calcula la próxima ejecución según tipo, días, vacaciones y omisiones.
|
|
||||||
2. `ServicioAlarmasAndroid` envía la programación al `MethodChannel pluriwave/alarm_scheduler`.
|
|
||||||
3. `AlarmScheduler` registra:
|
|
||||||
- alarma principal con `setAlarmClock`;
|
|
||||||
- preaviso silencioso 30 minutos antes con `setExactAndAllowWhileIdle`.
|
|
||||||
4. `PluriWaveAlarmReceiver` abre la app cuando suena la alarma.
|
|
||||||
5. Flutter muestra `PantallaAlarmaSonando`, intenta reproducir la emisora y activa audio interno si la radio falla o tarda demasiado.
|
|
||||||
|
|
||||||
## Permisos
|
|
||||||
|
|
||||||
- `SCHEDULE_EXACT_ALARM`: necesario en Android 12+ para exactitud.
|
|
||||||
- `POST_NOTIFICATIONS`: necesario en Android 13+ para el preaviso silencioso.
|
|
||||||
- `WAKE_LOCK` y foreground media playback ya están declarados para la reproducción.
|
|
||||||
|
|
||||||
## Fallbacks
|
|
||||||
|
|
||||||
Si la emisora no existe, falla o no empieza a reproducir en unos segundos, la pantalla usa sonidos internos incluidos en `assets/audio/`. Esto evita una alarma silenciosa por problemas de red o de radio.
|
|
||||||
|
|
||||||
## Vacaciones y omisiones
|
|
||||||
|
|
||||||
Las vacaciones se guardan en Flutter. Las alarmas configuradas para pausar en vacaciones saltan automáticamente esos rangos y muestran la próxima fecha válida. El preaviso permite omitir la siguiente ejecución abriendo la app y aplicando la misma lógica de omisión persistente.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Arquitectura de alarmas con pantalla apagada
|
|
||||||
|
|
||||||
## Diagnóstico
|
|
||||||
|
|
||||||
El flujo anterior hacía que Android recibiese la alarma con `AlarmManager`, pero el sonido real dependía de que se abriese `MainActivity` y de que Flutter llegase a pintar `PantallaAlarmaSonando`. Con pantalla apagada, Doze o restricciones del fabricante, ese arranque de UI puede retrasarse hasta que el usuario enciende la pantalla.
|
|
||||||
|
|
||||||
## Decisión
|
|
||||||
|
|
||||||
La alarma debe sonar desde Android nativo en cuanto llega `ACTION_FIRE`. Flutter pasa a ser la interfaz de control para detener, posponer y hacer handoff a la radio de la app, pero no el único origen del sonido.
|
|
||||||
|
|
||||||
## Flujo recomendado
|
|
||||||
|
|
||||||
1. `AlarmScheduler` programa la alarma con `setAlarmClock` y fallback exact/inexact.
|
|
||||||
2. `PluriWaveAlarmReceiver` recibe `ACTION_FIRE`.
|
|
||||||
3. El receiver arranca `PluriWaveAlarmService` como foreground service.
|
|
||||||
4. El servicio toma un `PARTIAL_WAKE_LOCK`, muestra notificación foreground y reproduce audio con `USAGE_ALARM`.
|
|
||||||
5. La UI Flutter se abre por full-screen intent si Android lo permite.
|
|
||||||
6. Al detener/posponer desde Flutter, se manda comando nativo para parar el servicio.
|
|
||||||
|
|
||||||
## Referencias
|
|
||||||
|
|
||||||
- Android alarms: https://developer.android.com/develop/background-work/services/alarms
|
|
||||||
- Foreground service restrictions: https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start
|
|
||||||
- AOSP DeskClock AlarmService: https://android.googlesource.com/platform/packages/apps/DeskClock/+/ac260c0096605526f772af7eec73d6a51dc6de32/src/com/android/deskclock/alarms/AlarmService.java
|
|
||||||
|
|
||||||
## Notas
|
|
||||||
|
|
||||||
- El audio local interno es el fallback más fiable para pantalla apagada.
|
|
||||||
- La radio remota puede fallar por red, DNS, TLS o timeout; por eso debe existir fallback interno.
|
|
||||||
- Si un fabricante bloquea incluso servicios arrancados desde alarma, habrá que guiar al usuario con permisos de batería/autostart.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# Notas de grabación y visualización real de audio
|
|
||||||
|
|
||||||
Referencia interna: este archivo vive en `docs/` y no está listado en
|
|
||||||
`flutter.assets`, así que no se compila dentro de la aplicación.
|
|
||||||
|
|
||||||
## Decisiones aplicadas
|
|
||||||
|
|
||||||
- La grabación de radio se hace leyendo el stream HTTP original de la emisora y
|
|
||||||
escribiendo sus bytes a disco. No se graba micrófono ni salida del sistema.
|
|
||||||
- La ventaja es que se conserva la calidad original del stream y se evita
|
|
||||||
recomprimir audio.
|
|
||||||
- La forma de onda real se intenta capturar en Android con
|
|
||||||
`android.media.audiofx.Visualizer` usando el `androidAudioSessionId` expuesto
|
|
||||||
por `just_audio`.
|
|
||||||
- Si Android deniega permisos o el dispositivo no permite capturar esa sesión,
|
|
||||||
la UI cae al visualizador animado anterior para no bloquear el reproductor.
|
|
||||||
|
|
||||||
## Fuentes consultadas
|
|
||||||
|
|
||||||
- `just_audio` expone `androidAudioSessionIdStream` para enlazar efectos o
|
|
||||||
visualizadores Android a la sesión activa:
|
|
||||||
https://pub.dev/packages/just_audio/versions/0.10.4
|
|
||||||
- Android `Visualizer` permite capturar waveform de contenido en reproducción y
|
|
||||||
requiere permiso `RECORD_AUDIO`:
|
|
||||||
https://www.android-doc.com/reference/android/media/audiofx/Visualizer.html
|
|
||||||
- Radio Browser permite ordenar búsquedas por `bitrate` y expone campos
|
|
||||||
`codec`/`bitrate`:
|
|
||||||
https://stations.radioss.app/
|
|
||||||
- El paquete `audio_visualizer` existe, pero se descartó como dependencia
|
|
||||||
inmediata porque duplicaría reproducción con su propio player; PluriWave ya
|
|
||||||
usa `audio_service` + `just_audio` y acabamos de estabilizar ese flujo:
|
|
||||||
https://pub.dev/packages/audio_visualizer
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# Notas de reproducción de radio con just_audio/audio_service
|
|
||||||
|
|
||||||
Referencia interna para futuras correcciones del reproductor de PluriWave. Este archivo está en `docs/` y no se incluye en `flutter.assets`, por lo que no compila dentro de la app.
|
|
||||||
|
|
||||||
## Hallazgos útiles
|
|
||||||
|
|
||||||
- `AudioPlayer.play()` completa cuando la reproducción termina, se pausa o se detiene. En radio en vivo no representa simplemente “ya empezó a sonar”.
|
|
||||||
- Las versiones antiguas de PluriWave que sí cambiaban de emisora usaban el flujo simple de `audio_service` + `just_audio`: `stop() -> setUrl() -> play()` dentro de `playMediaItem`.
|
|
||||||
- La regresión apareció cuando mezclamos dos responsabilidades: usar `handler.emisoraActual` como estado técnico de audio y también como estado visual inmediato para mostrar el mini reproductor.
|
|
||||||
- El logcat de 2026-05-21 mostró la media session de PluriWave atascada en `CONNECTING` sin `PlayerException`, con metadata de la emisora anterior (`Track FM`). Eso apunta a un player/ExoPlayer reutilizado que queda colgado entre `stop/setUrl/play`, no a un error HTTP visible.
|
|
||||||
|
|
||||||
## Decisión aplicada en PluriWave
|
|
||||||
|
|
||||||
- Mantener la selección visual inmediata en `EstadoRadio` mediante una emisora seleccionada propia, separada del `emisoraActual` interno del handler.
|
|
||||||
- No usar `setAudioSource(..., preload: false)` como reemplazo de `setUrl(...)`: en esta app rompió incluso la primera conexión.
|
|
||||||
- No esperar `play()` como operación de finalización para radio en vivo.
|
|
||||||
- Al cambiar emisora, recrear el `AudioPlayer`/ExoPlayer para matar completamente la reproducción anterior antes de `setUrl(...)`.
|
|
||||||
- Proteger `EstadoRadio.reproducir` con revisión para que una operación vieja no aplique presets/clicks encima de una nueva.
|
|
||||||
|
|
||||||
## Intentos descartados
|
|
||||||
|
|
||||||
- `setAudioSource(..., preload: false)`: teóricamente razonable, pero en PluriWave rompió la primera conexión.
|
|
||||||
- Hacer que el handler publique `emisoraActual` antes de que el flujo histórico de audio avance: arregla el mini reproductor, pero cambia la semántica que tenían las versiones viejas.
|
|
||||||
- Reutilizar siempre el mismo `AudioPlayer` con `stop()`: logcat mostró estado `CONNECTING` persistente sin excepción al cambiar/reintentar.
|
|
||||||
|
|
||||||
## Fuentes consultadas
|
|
||||||
|
|
||||||
- just_audio `AudioPlayer.play()` API: https://pub.dev/documentation/just_audio/latest/just_audio/AudioPlayer/play.html
|
|
||||||
- just_audio `AudioPlayer` API general: https://pub.dev/documentation/just_audio/latest/just_audio/AudioPlayer-class.html
|
|
||||||
- Ejemplo de radio en vivo de just_audio: https://gist.github.com/scysys/7f700cd49f09ba788021504e8d3477aa
|
|
||||||
- Discusión sobre cambiar fuente con audio_service + just_audio: https://stackoverflow.com/questions/70526156/changing-audio-source-in-audio-service-and-just-audio-flutter
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
# PluriWave · Guía de publicación automática en Google Play
|
|
||||||
|
|
||||||
> Estado: en preparación
|
|
||||||
> Última revisión: 2026-05-27
|
|
||||||
|
|
||||||
## Objetivo
|
|
||||||
|
|
||||||
Dejar **PluriWave** con un flujo de publicación lo más automático posible:
|
|
||||||
|
|
||||||
- `main` → desarrollo diario, pruebas y artefactos internos
|
|
||||||
- `PRO` → publicación automática a **Google Play Internal Testing**
|
|
||||||
|
|
||||||
## Estrategia acordada
|
|
||||||
|
|
||||||
### Ramas
|
|
||||||
|
|
||||||
- **`main`**
|
|
||||||
- desarrollo diario
|
|
||||||
- análisis, tests y builds internos
|
|
||||||
- NO publica en Google Play
|
|
||||||
- **`PRO`**
|
|
||||||
- rama de release permanente
|
|
||||||
- al subir cambios aquí, se genera el **AAB release firmado**
|
|
||||||
- publica automáticamente en **Google Play · Prueba interna**
|
|
||||||
|
|
||||||
### Publicación
|
|
||||||
|
|
||||||
1. Bootstrap manual inicial en Play Console
|
|
||||||
2. Configuración correcta del keystore de subida
|
|
||||||
3. Integración con Google Play Developer API
|
|
||||||
4. Automatización desde Gitea Actions
|
|
||||||
|
|
||||||
## Estado actual del proyecto
|
|
||||||
|
|
||||||
### Verificado en el repositorio
|
|
||||||
|
|
||||||
- Existe workflow en `.gitea/workflows/build.yml`
|
|
||||||
- Actualmente compila y firma correctamente en CI
|
|
||||||
- Genera:
|
|
||||||
- APK release
|
|
||||||
- AAB release
|
|
||||||
- Publica artefactos internos en `ftl-builds`
|
|
||||||
- Ya existe soporte para keystore release desde `android/key.properties`
|
|
||||||
|
|
||||||
### Verificado en Play Console
|
|
||||||
|
|
||||||
- La app ya está creada
|
|
||||||
- Nombre: `PluriWave`
|
|
||||||
- Package: `es.freetimelab.pluriwave`
|
|
||||||
- Ya se ha subido manualmente un **AAB** al canal de **prueba interna**
|
|
||||||
- Producción sigue bloqueada por el requisito de:
|
|
||||||
- prueba cerrada
|
|
||||||
- 12 testers
|
|
||||||
- 14 días
|
|
||||||
|
|
||||||
## Automatización prevista en CI
|
|
||||||
|
|
||||||
### `main`
|
|
||||||
|
|
||||||
- `flutter pub get`
|
|
||||||
- `flutter analyze`
|
|
||||||
- build release
|
|
||||||
- publicación de APK/AAB en infraestructura interna
|
|
||||||
|
|
||||||
### `PRO`
|
|
||||||
|
|
||||||
- `flutter pub get`
|
|
||||||
- `flutter analyze`
|
|
||||||
- build release firmado
|
|
||||||
- publicación de APK/AAB en infraestructura interna
|
|
||||||
- subida automática del `.aab` a Google Play **track internal**
|
|
||||||
|
|
||||||
## Secretos necesarios en Gitea
|
|
||||||
|
|
||||||
### Ya usados por firma
|
|
||||||
|
|
||||||
- `PLURIWAVE_KEYSTORE_PASSWORD`
|
|
||||||
- `GITEA_TOKEN`
|
|
||||||
|
|
||||||
### Necesarios para Play Store
|
|
||||||
|
|
||||||
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`
|
|
||||||
|
|
||||||
> Debe contener el JSON completo de una **Service Account** con acceso concedido en Play Console a esta aplicación.
|
|
||||||
|
|
||||||
## Ficheros implicados
|
|
||||||
|
|
||||||
- `.gitea/workflows/build.yml`
|
|
||||||
- `fastlane/Fastfile`
|
|
||||||
- `fastlane/Appfile`
|
|
||||||
- `android/app/build.gradle.kts`
|
|
||||||
|
|
||||||
## Siguiente validación manual
|
|
||||||
|
|
||||||
Cuando la automatización quede desplegada:
|
|
||||||
|
|
||||||
1. crear la rama `PRO` en remoto
|
|
||||||
2. configurar `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`
|
|
||||||
3. hacer push a `PRO`
|
|
||||||
4. comprobar que:
|
|
||||||
- compila
|
|
||||||
- firma
|
|
||||||
- genera AAB
|
|
||||||
- sube a Google Play Internal Testing
|
|
||||||
|
|
||||||
## Notas importantes
|
|
||||||
|
|
||||||
- El canal automatizado inicial será **internal testing**, no producción
|
|
||||||
- La primera publicación manual en Play Console ya quedó hecha
|
|
||||||
- La automatización NO elimina el requisito posterior de closed testing antes de producción
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
package_name(ENV["PLAY_PACKAGE_NAME"] || "es.freetimelab.pluriwave")
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
default_platform(:android)
|
|
||||||
|
|
||||||
platform :android do
|
|
||||||
desc "Sube el AAB actual al track internal de Google Play"
|
|
||||||
lane :upload_internal do
|
|
||||||
json_key_path = ENV["PLAY_JSON_KEY_PATH"]
|
|
||||||
aab_path = ENV["PLAY_AAB_PATH"] || "build/app/outputs/bundle/release/app-release.aab"
|
|
||||||
package_name = ENV["PLAY_PACKAGE_NAME"] || "es.freetimelab.pluriwave"
|
|
||||||
|
|
||||||
UI.user_error!("Falta PLAY_JSON_KEY_PATH") if json_key_path.to_s.empty?
|
|
||||||
UI.user_error!("No existe el AAB en #{aab_path}") unless File.exist?(aab_path)
|
|
||||||
|
|
||||||
upload_to_play_store(
|
|
||||||
json_key: json_key_path,
|
|
||||||
package_name: package_name,
|
|
||||||
aab: aab_path,
|
|
||||||
track: ENV["PLAY_TRACK"] || "internal",
|
|
||||||
release_status: ENV["PLAY_RELEASE_STATUS"] || "completed",
|
|
||||||
skip_upload_metadata: true,
|
|
||||||
skip_upload_images: true,
|
|
||||||
skip_upload_screenshots: true,
|
|
||||||
skip_upload_changelogs: true
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -66,7 +66,5 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSLocationWhenInUseUsageDescription</key>
|
|
||||||
<string>PluriWave usa tu ubicacion aproximada para sugerirte emisoras cercanas.</string>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
arb-dir: lib/l10n
|
|
||||||
template-arb-file: app_es.arb
|
|
||||||
output-localization-file: app_localizations.dart
|
|
||||||
output-class: AppLocalizations
|
|
||||||
output-dir: lib/l10n/gen
|
|
||||||
nullable-getter: false
|
|
||||||
@@ -1,74 +1,49 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'estado/estado_busqueda.dart';
|
|
||||||
import 'estado/estado_ecualizador.dart';
|
|
||||||
import 'estado/estado_grabacion.dart';
|
|
||||||
import 'estado/estado_radio.dart';
|
import 'estado/estado_radio.dart';
|
||||||
import 'estado/estado_alarmas.dart';
|
|
||||||
import 'estado/estado_idioma.dart';
|
|
||||||
import 'l10n/display_names.dart';
|
|
||||||
import 'l10n/gen/app_localizations.dart';
|
|
||||||
import 'modelos/alarma_musical.dart';
|
|
||||||
import 'pantallas/pantalla_alarmas.dart';
|
|
||||||
import 'pantallas/pantalla_alarma_sonando.dart';
|
|
||||||
import 'pantallas/pantalla_inicio.dart';
|
import 'pantallas/pantalla_inicio.dart';
|
||||||
import 'pantallas/pantalla_buscar.dart';
|
import 'pantallas/pantalla_buscar.dart';
|
||||||
import 'pantallas/pantalla_favoritos.dart';
|
import 'pantallas/pantalla_favoritos.dart';
|
||||||
import 'pantallas/pantalla_ajustes.dart';
|
import 'widgets/mini_reproductor.dart';
|
||||||
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';
|
|
||||||
|
|
||||||
class PluriWaveApp extends StatelessWidget {
|
class PluriWaveApp extends StatelessWidget {
|
||||||
const PluriWaveApp({super.key, this.prefs});
|
const PluriWaveApp({super.key});
|
||||||
|
|
||||||
/// Single SharedPreferences instance resolved in main() (S3-R4) and
|
|
||||||
/// injected into every state/service.
|
|
||||||
final SharedPreferences? prefs;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MultiProvider(
|
return ChangeNotifierProvider(
|
||||||
providers: [
|
create: (_) => EstadoRadio(),
|
||||||
ChangeNotifierProvider(create: (_) => EstadoRadio(prefs: prefs)),
|
child: MaterialApp(
|
||||||
// Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
|
|
||||||
// (they need its services and callbacks at construction); these
|
|
||||||
// providers only expose the instances, so they declare no dispose
|
|
||||||
// callback.
|
|
||||||
ListenableProvider<EstadoEcualizador>(
|
|
||||||
create: (context) => context.read<EstadoRadio>().ecualizador,
|
|
||||||
),
|
|
||||||
ListenableProvider<EstadoGrabacion>(
|
|
||||||
create: (context) => context.read<EstadoRadio>().grabacion,
|
|
||||||
),
|
|
||||||
ListenableProvider<EstadoBusqueda>(
|
|
||||||
create: (context) => context.read<EstadoRadio>().busqueda,
|
|
||||||
),
|
|
||||||
ChangeNotifierProvider(create: (_) => EstadoAlarmas(prefs: prefs)),
|
|
||||||
ChangeNotifierProvider(
|
|
||||||
create: (_) => EstadoIdioma(sharedPreferences: prefs),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
child: Consumer<EstadoIdioma>(
|
|
||||||
builder:
|
|
||||||
(context, estadoIdioma, _) => MaterialApp(
|
|
||||||
title: 'PluriWave',
|
title: 'PluriWave',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: PluriWaveTheme.dark(),
|
theme: _buildTheme(Brightness.dark),
|
||||||
darkTheme: PluriWaveTheme.dark(),
|
darkTheme: _buildTheme(Brightness.dark),
|
||||||
themeMode: ThemeMode.dark,
|
themeMode: ThemeMode.dark,
|
||||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
|
||||||
locale: estadoIdioma.localeSeleccionado,
|
|
||||||
home: const _PaginaPrincipal(),
|
home: const _PaginaPrincipal(),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeData _buildTheme(Brightness brightness) {
|
||||||
|
final colorScheme = ColorScheme.fromSeed(
|
||||||
|
seedColor: const Color(0xFF6750A4),
|
||||||
|
brightness: brightness,
|
||||||
|
);
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
textTheme: GoogleFonts.interTextTheme(
|
||||||
|
ThemeData(brightness: brightness).textTheme,
|
||||||
|
),
|
||||||
|
cardTheme: CardTheme(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
color: colorScheme.surfaceContainerLow,
|
||||||
|
),
|
||||||
|
snackBarTheme: SnackBarThemeData(
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -82,336 +57,110 @@ class _PaginaPrincipal extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
||||||
static const _volumenInicialFadeInAlarmas = 0.05;
|
|
||||||
int _indice = 0;
|
int _indice = 0;
|
||||||
StreamSubscription<String>? _errorSubscription;
|
|
||||||
StreamSubscription<EventoAlarmaAndroid>? _alarmaSubscription;
|
|
||||||
StreamSubscription<AlarmaMusical>? _alarmaVencidaSubscription;
|
|
||||||
EstadoRadio? _estadoSuscrito;
|
|
||||||
bool _alarmaInicialProcesada = false;
|
|
||||||
bool _alarmaSonandoActiva = false;
|
|
||||||
bool _onboardingInicialSolicitado = false;
|
|
||||||
String? _alarmaSonandoId;
|
|
||||||
Locale? _localeAlarmasConfigurado;
|
|
||||||
|
|
||||||
static const _paginas = [
|
static const _paginas = [
|
||||||
PantallaInicio(),
|
PantallaInicio(),
|
||||||
PantallaBuscar(),
|
PantallaBuscar(),
|
||||||
PantallaFavoritos(),
|
PantallaFavoritos(),
|
||||||
PantallaAlarmas(),
|
|
||||||
PantallaAjustes(),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
List<PluriNavItem> _navItems(AppLocalizations l10n) => [
|
static const _destinos = [
|
||||||
PluriNavItem(glyph: PluriIconGlyph.home, label: l10n.navHome),
|
NavigationDestination(
|
||||||
PluriNavItem(glyph: PluriIconGlyph.search, label: l10n.navSearch),
|
icon: Icon(Icons.home_outlined),
|
||||||
PluriNavItem(glyph: PluriIconGlyph.favorites, label: l10n.navFavorites),
|
selectedIcon: Icon(Icons.home),
|
||||||
PluriNavItem(glyph: PluriIconGlyph.alarm, label: l10n.navAlarms),
|
label: 'Inicio',
|
||||||
PluriNavItem(glyph: PluriIconGlyph.settings, label: l10n.navSettings),
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.search_outlined),
|
||||||
|
selectedIcon: Icon(Icons.search),
|
||||||
|
label: 'Buscar',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.favorite_outline),
|
||||||
|
selectedIcon: Icon(Icons.favorite),
|
||||||
|
label: 'Favoritos',
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
// S3-R3 / Decision 3.2: keep the alarm bridge l10n in sync, once per
|
// Suscribir al stream de errores → SnackBar flotante
|
||||||
// locale change (this hook re-runs when Localizations changes).
|
context.read<EstadoRadio>().errorStream.listen((msg) {
|
||||||
final locale = Localizations.localeOf(context);
|
|
||||||
if (_localeAlarmasConfigurado != locale) {
|
|
||||||
_localeAlarmasConfigurado = locale;
|
|
||||||
context.read<EstadoAlarmas>().configurarLocalizaciones(
|
|
||||||
AppLocalizations.of(context),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final estado = context.read<EstadoRadio>();
|
|
||||||
if (identical(_estadoSuscrito, estado) && _errorSubscription != null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_errorSubscription?.cancel();
|
|
||||||
_estadoSuscrito = estado;
|
|
||||||
_errorSubscription = estado.errorStream.listen((msg) {
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(msg),
|
content: Text(msg),
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
action: SnackBarAction(
|
action: SnackBarAction(label: 'OK', onPressed: () {}),
|
||||||
label: AppLocalizations.of(context).actionOk,
|
|
||||||
onPressed: () {},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
final alarmas = context.read<EstadoAlarmas>();
|
|
||||||
_alarmaSubscription ??= alarmas.android.eventosAlarma.listen((evento) {
|
|
||||||
if (!mounted) return;
|
|
||||||
_abrirAlarmaSonando(evento);
|
|
||||||
});
|
|
||||||
_alarmaVencidaSubscription ??= alarmas.alarmasVencidasStream.listen((
|
|
||||||
alarma,
|
|
||||||
) {
|
|
||||||
if (!mounted) return;
|
|
||||||
_abrirAlarmaDirecta(alarma);
|
|
||||||
});
|
|
||||||
if (!_alarmaInicialProcesada) {
|
|
||||||
_alarmaInicialProcesada = true;
|
|
||||||
unawaited(_procesarAlarmaInicial(alarmas));
|
|
||||||
}
|
|
||||||
if (!_onboardingInicialSolicitado) {
|
|
||||||
_onboardingInicialSolicitado = true;
|
|
||||||
unawaited(_mostrarOnboardingInicial());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_errorSubscription?.cancel();
|
|
||||||
_alarmaSubscription?.cancel();
|
|
||||||
_alarmaVencidaSubscription?.cancel();
|
|
||||||
super.dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
return Scaffold(
|
||||||
|
|
||||||
return PluriWaveScaffold(
|
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(l10n.appTitle),
|
title: const Text('PluriWave'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.bedtime_outlined),
|
icon: const Icon(Icons.bedtime_outlined),
|
||||||
tooltip: l10n.sleepTimer,
|
tooltip: 'Timer de sueño',
|
||||||
onPressed: () => _mostrarTimerDialog(context),
|
onPressed: () => _mostrarTimerDialog(context),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: _paginas[_indice],
|
||||||
top: false,
|
bottomNavigationBar: Column(
|
||||||
child: AnimatedSwitcher(
|
|
||||||
duration: context.pluriMotion.normal,
|
|
||||||
switchInCurve: Curves.easeOutCubic,
|
|
||||||
switchOutCurve: Curves.easeInCubic,
|
|
||||||
transitionBuilder:
|
|
||||||
(child, animation) => FadeTransition(
|
|
||||||
opacity: animation,
|
|
||||||
child: SlideTransition(
|
|
||||||
position: Tween<Offset>(
|
|
||||||
begin: const Offset(0.035, 0),
|
|
||||||
end: Offset.zero,
|
|
||||||
).animate(animation),
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: KeyedSubtree(
|
|
||||||
key: ValueKey<int>(_indice),
|
|
||||||
child: _paginas[_indice],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
bottomNavigationBar: SafeArea(
|
|
||||||
top: false,
|
|
||||||
minimum: const EdgeInsets.only(bottom: PluriLayout.compactGap),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const MiniReproductor(),
|
const MiniReproductor(),
|
||||||
PluriBottomNavigation(
|
NavigationBar(
|
||||||
items: _navItems(l10n),
|
|
||||||
selectedIndex: _indice,
|
selectedIndex: _indice,
|
||||||
onSelected: (i) => setState(() => _indice = i),
|
onDestinationSelected: (i) => setState(() => _indice = i),
|
||||||
|
destinations: _destinos,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _procesarAlarmaInicial(EstadoAlarmas alarmas) async {
|
|
||||||
final evento = await alarmas.android.obtenerEventoInicial();
|
|
||||||
if (evento != null && mounted) {
|
|
||||||
await _abrirAlarmaSonando(evento);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
if (evento.accion == EventoAlarmaAndroid.accionSnoozed) {
|
|
||||||
// EstadoAlarmas records native snoozes itself (Decision 2.1); there is
|
|
||||||
// nothing to open for this event.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final estado = context.read<EstadoAlarmas>();
|
|
||||||
if (estado.alarmas.isEmpty) {
|
|
||||||
await estado.cargarPersistidasSinRecalcular();
|
|
||||||
}
|
|
||||||
AlarmaMusical? alarma;
|
|
||||||
for (final item in estado.alarmas) {
|
|
||||||
if (item.id == evento.alarmaId) {
|
|
||||||
alarma = item;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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;
|
|
||||||
setState(() => _indice = 3);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
AppLocalizations.of(context).skipCurrentAlarmExecution(
|
|
||||||
localizedAlarmName(AppLocalizations.of(context), alarma.nombre),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (evento.accion.endsWith('.POSTPONE_NEXT')) {
|
|
||||||
final ejecucion =
|
|
||||||
evento.occurrenceAtMillis > 0
|
|
||||||
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
|
|
||||||
: alarma.proximaEjecucion ?? DateTime.now();
|
|
||||||
await estado.posponerProximaDesdePreaviso(
|
|
||||||
alarma,
|
|
||||||
evento.snoozeMinutes,
|
|
||||||
ejecucion,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _indice = 3);
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
AppLocalizations.of(context).alarmPostponedCurrentExecution,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (evento.accion.endsWith('.PRE_NOTICE')) {
|
|
||||||
setState(() => _indice = 3);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await _mostrarAlarmaSonando(alarma);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _abrirAlarmaDirecta(AlarmaMusical alarma) async {
|
|
||||||
await _mostrarAlarmaSonando(alarma);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _mostrarAlarmaSonando(AlarmaMusical alarma) async {
|
|
||||||
final alarmas = context.read<EstadoAlarmas>();
|
|
||||||
alarmas.marcarEjecucionGestionada(alarma);
|
|
||||||
|
|
||||||
if (_alarmaSonandoActiva) {
|
|
||||||
debugPrint(
|
|
||||||
'[PluriWave][alarmas] alarma ignorada porque ya hay una activa id=${alarma.id} activa=$_alarmaSonandoId',
|
|
||||||
);
|
|
||||||
await alarmas.android.ocultarNotificacionAlarma(alarma.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_alarmaSonandoActiva = true;
|
|
||||||
_alarmaSonandoId = alarma.id;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await _prearrancarAudioAlarma(alarma);
|
|
||||||
if (!mounted) return;
|
|
||||||
await Navigator.of(context).push(
|
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder:
|
|
||||||
(_) => PantallaAlarmaSonando(
|
|
||||||
alarma: alarma,
|
|
||||||
audioPrearrancado: alarma.emisora != null,
|
|
||||||
),
|
|
||||||
fullscreenDialog: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (_alarmaSonandoId == alarma.id) {
|
|
||||||
_alarmaSonandoActiva = false;
|
|
||||||
_alarmaSonandoId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _prearrancarAudioAlarma(AlarmaMusical alarma) async {
|
|
||||||
final emisora = alarma.emisora;
|
|
||||||
if (emisora == null) return;
|
|
||||||
final radio = context.read<EstadoRadio>();
|
|
||||||
debugPrint(
|
|
||||||
'[PluriWave][alarmas] prearrancar emisora alarma id=${alarma.id} emisora=${emisora.nombre}',
|
|
||||||
);
|
|
||||||
await radio.audio.setVolumen(_volumenInicialFadeInAlarmas);
|
|
||||||
unawaited(radio.reproducir(emisora));
|
|
||||||
}
|
|
||||||
|
|
||||||
void _mostrarTimerDialog(BuildContext context) {
|
void _mostrarTimerDialog(BuildContext context) {
|
||||||
|
final estado = context.read<EstadoRadio>();
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
showDragHandle: true,
|
builder: (ctx) => SafeArea(
|
||||||
builder:
|
|
||||||
(ctx) => Consumer<EstadoRadio>(
|
|
||||||
builder:
|
|
||||||
(ctx, estado, _) => SafeArea(
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: PluriLayout.sheetPadding,
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text('Timer de sueño', style: Theme.of(ctx).textTheme.titleLarge),
|
||||||
AppLocalizations.of(ctx).sleepTimer,
|
const SizedBox(height: 16),
|
||||||
style: Theme.of(ctx).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: PluriLayout.sectionGap),
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(ctx).sleepTimerDescription,
|
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: PluriLayout.panelGap),
|
|
||||||
if (estado.timer.activo)
|
if (estado.timer.activo)
|
||||||
StreamBuilder<Duration>(
|
StreamBuilder<Duration>(
|
||||||
stream: estado.timer.tiempoRestanteStream,
|
stream: estado.timer.tiempoRestanteStream,
|
||||||
builder: (ctx, snap) {
|
builder: (ctx, snap) {
|
||||||
final restante =
|
final t = snap.data ?? Duration.zero;
|
||||||
snap.data ?? estado.timer.tiempoRestante;
|
final h = t.inHours;
|
||||||
|
final m = t.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||||||
|
final s = t.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_formatearDuracionTimer(
|
'${h > 0 ? "${h}h " : ""}${m}m ${s}s',
|
||||||
AppLocalizations.of(ctx),
|
style: Theme.of(ctx).textTheme.headlineMedium,
|
||||||
restante,
|
|
||||||
),
|
|
||||||
style:
|
|
||||||
Theme.of(ctx).textTheme.headlineMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(
|
|
||||||
height: PluriLayout.compactGap,
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
FilledButton.tonal(
|
FilledButton.tonal(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
estado.cancelarTimer();
|
estado.cancelarTimer();
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
},
|
},
|
||||||
child: Text(
|
child: const Text('Cancelar timer'),
|
||||||
AppLocalizations.of(ctx).cancelTimer,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -419,192 +168,20 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
|
|||||||
)
|
)
|
||||||
else
|
else
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: PluriLayout.compactGap,
|
spacing: 8,
|
||||||
runSpacing: PluriLayout.compactGap,
|
children: [15, 30, 60, 90]
|
||||||
children: [
|
.map((min) => ActionChip(
|
||||||
for (final segundos
|
label: Text('$min min'),
|
||||||
in estado.timerSuenoPresetsSegundos)
|
|
||||||
ActionChip(
|
|
||||||
label: Text(
|
|
||||||
_formatearDuracionTimer(
|
|
||||||
AppLocalizations.of(ctx),
|
|
||||||
Duration(seconds: segundos),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
estado.iniciarTimerDuracion(
|
estado.iniciarTimer(min);
|
||||||
Duration(seconds: segundos),
|
|
||||||
);
|
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
},
|
},
|
||||||
),
|
))
|
||||||
ActionChip(
|
.toList(),
|
||||||
avatar: const Icon(
|
|
||||||
Icons.tune_rounded,
|
|
||||||
size: 18,
|
|
||||||
),
|
|
||||||
label: Text(
|
|
||||||
AppLocalizations.of(ctx).optionOther,
|
|
||||||
),
|
|
||||||
onPressed: () async {
|
|
||||||
final duracion =
|
|
||||||
await _pedirDuracionPersonalizada(ctx);
|
|
||||||
if (duracion == null || !ctx.mounted) return;
|
|
||||||
estado.iniciarTimerDuracion(duracion);
|
|
||||||
Navigator.pop(ctx);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Duration?> _pedirDuracionPersonalizada(BuildContext context) {
|
|
||||||
return showModalBottomSheet<Duration>(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
showDragHandle: true,
|
|
||||||
builder: (ctx) => const _TimerPersonalizadoSheet(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatearDuracionTimer(AppLocalizations l10n, Duration duracion) {
|
|
||||||
final horas = duracion.inHours;
|
|
||||||
final minutos = duracion.inMinutes.remainder(60);
|
|
||||||
final segundos = duracion.inSeconds.remainder(60);
|
|
||||||
if (horas > 0) {
|
|
||||||
return l10n.durationHoursMinutesSeconds(
|
|
||||||
horas,
|
|
||||||
minutos.toString().padLeft(2, '0'),
|
|
||||||
segundos.toString().padLeft(2, '0'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (minutos > 0) {
|
|
||||||
return segundos == 0
|
|
||||||
? l10n.durationMinutesOnly(minutos)
|
|
||||||
: l10n.durationMinutesSeconds(
|
|
||||||
minutos,
|
|
||||||
segundos.toString().padLeft(2, '0'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return l10n.durationSecondsOnly(segundos);
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TimerPersonalizadoSheet extends StatefulWidget {
|
|
||||||
const _TimerPersonalizadoSheet();
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_TimerPersonalizadoSheet> createState() =>
|
|
||||||
_TimerPersonalizadoSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> {
|
|
||||||
final _horasCtrl = TextEditingController();
|
|
||||||
final _minutosCtrl = TextEditingController(text: '15');
|
|
||||||
final _segundosCtrl = TextEditingController();
|
|
||||||
bool _guardarPreset = true;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_horasCtrl.dispose();
|
|
||||||
_minutosCtrl.dispose();
|
|
||||||
_segundosCtrl.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0;
|
|
||||||
|
|
||||||
Future<void> _confirmar() async {
|
|
||||||
final duracion = Duration(
|
|
||||||
hours: _leer(_horasCtrl),
|
|
||||||
minutes: _leer(_minutosCtrl),
|
|
||||||
seconds: _leer(_segundosCtrl),
|
|
||||||
);
|
|
||||||
if (duracion <= Duration.zero) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(AppLocalizations.of(context).durationGreaterThanZero),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_guardarPreset) {
|
|
||||||
await context.read<EstadoRadio>().agregarTimerSuenoPreset(duracion);
|
|
||||||
}
|
|
||||||
if (mounted) Navigator.pop(context, duracion);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final bottom = MediaQuery.viewInsetsOf(context).bottom;
|
|
||||||
return SafeArea(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(18, 0, 18, 18 + bottom),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context).customDurationTitle,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
const SizedBox(height: PluriLayout.sectionGap),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _campoTiempo(
|
|
||||||
_horasCtrl,
|
|
||||||
AppLocalizations.of(context).hoursLabel,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: PluriLayout.compactGap),
|
|
||||||
Expanded(
|
|
||||||
child: _campoTiempo(
|
|
||||||
_minutosCtrl,
|
|
||||||
AppLocalizations.of(context).minutesLabel,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: PluriLayout.compactGap),
|
|
||||||
Expanded(
|
|
||||||
child: _campoTiempo(
|
|
||||||
_segundosCtrl,
|
|
||||||
AppLocalizations.of(context).secondsLabel,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: PluriLayout.compactGap),
|
|
||||||
SwitchListTile.adaptive(
|
|
||||||
contentPadding: EdgeInsets.zero,
|
|
||||||
title: Text(AppLocalizations.of(context).saveQuickAccess),
|
|
||||||
value: _guardarPreset,
|
|
||||||
onChanged: (value) => setState(() => _guardarPreset = value),
|
|
||||||
),
|
|
||||||
const SizedBox(height: PluriLayout.sectionGap),
|
|
||||||
FilledButton.icon(
|
|
||||||
icon: const Icon(Icons.bedtime_rounded),
|
|
||||||
label: Text(AppLocalizations.of(context).startTimer),
|
|
||||||
onPressed: _confirmar,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _campoTiempo(TextEditingController controller, String label) {
|
|
||||||
return TextField(
|
|
||||||
controller: controller,
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: label,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,497 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
import '../l10n/gen/app_localizations.dart';
|
|
||||||
import '../modelos/alarma_musical.dart';
|
|
||||||
import '../servicios/servicio_alarmas.dart';
|
|
||||||
import '../servicios/servicio_alarmas_android.dart';
|
|
||||||
|
|
||||||
class EstadoAlarmas extends ChangeNotifier {
|
|
||||||
EstadoAlarmas({
|
|
||||||
ServicioAlarmas? servicio,
|
|
||||||
PuertoAlarmasAndroid? android,
|
|
||||||
SharedPreferences? prefs,
|
|
||||||
bool iniciarAutomaticamente = true,
|
|
||||||
}) : servicio = servicio ?? ServicioAlarmas(prefs: prefs),
|
|
||||||
android = android ?? ServicioAlarmasAndroid(),
|
|
||||||
_prefs = prefs {
|
|
||||||
// Decision 2.1 (snooze sync): the native layer reports its own snoozes
|
|
||||||
// back through alarmFired/snoozed; record them here so the Flutter
|
|
||||||
// config stays the single source of truth.
|
|
||||||
_eventosNativosSub = this.android.eventosAlarma.listen(
|
|
||||||
_alRecibirEventoNativo,
|
|
||||||
);
|
|
||||||
if (iniciarAutomaticamente) {
|
|
||||||
inicializar();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final ServicioAlarmas servicio;
|
|
||||||
final PuertoAlarmasAndroid android;
|
|
||||||
final SharedPreferences? _prefs;
|
|
||||||
static const _keyExencionBateriaSolicitada = 'bateria_exencion_solicitada';
|
|
||||||
|
|
||||||
List<AlarmaMusical> _alarmas = [];
|
|
||||||
List<RangoVacaciones> _vacaciones = [];
|
|
||||||
List<ExcepcionAlarma> _excepciones = [];
|
|
||||||
DiagnosticoAlarmasAndroid? _diagnostico;
|
|
||||||
Timer? _refresco;
|
|
||||||
Timer? _vigilancia;
|
|
||||||
StreamSubscription<EventoAlarmaAndroid>? _eventosNativosSub;
|
|
||||||
final _alarmasVencidasController =
|
|
||||||
StreamController<AlarmaMusical>.broadcast();
|
|
||||||
final Set<String> _ejecucionesEmitidas = {};
|
|
||||||
static const _margenDisparoLocal = Duration(seconds: 45);
|
|
||||||
|
|
||||||
// Bounds for _ejecucionesEmitidas (S3-R6): entries older than the
|
|
||||||
// retention window are pruned; the set never exceeds the cap.
|
|
||||||
static const _retencionEjecucionesEmitidas = Duration(hours: 24);
|
|
||||||
@visibleForTesting
|
|
||||||
static const maxEjecucionesEmitidas = 200;
|
|
||||||
bool _cargando = false;
|
|
||||||
String? _error;
|
|
||||||
|
|
||||||
List<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
|
|
||||||
List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones);
|
|
||||||
List<ExcepcionAlarma> get excepciones => List.unmodifiable(_excepciones);
|
|
||||||
DiagnosticoAlarmasAndroid? get diagnostico => _diagnostico;
|
|
||||||
bool get cargando => _cargando;
|
|
||||||
String? get error => _error;
|
|
||||||
Stream<AlarmaMusical> get alarmasVencidasStream =>
|
|
||||||
_alarmasVencidasController.stream;
|
|
||||||
|
|
||||||
AlarmaMusical? get proximaAlarma {
|
|
||||||
final candidatas =
|
|
||||||
_alarmas.where((a) => a.activa && a.proximaProgramable != null).toList()
|
|
||||||
..sort(
|
|
||||||
(a, b) => a.proximaProgramable!.compareTo(b.proximaProgramable!),
|
|
||||||
);
|
|
||||||
return candidatas.isEmpty ? null : candidatas.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> inicializar() async {
|
|
||||||
debugPrint('[PluriWave][alarmas] inicializar');
|
|
||||||
_cargando = true;
|
|
||||||
_error = null;
|
|
||||||
notifyListeners();
|
|
||||||
try {
|
|
||||||
await _sincronizarEjecucionesGestionadasPorAndroid();
|
|
||||||
final config = await servicio.recalcularTodas();
|
|
||||||
_aplicar(config);
|
|
||||||
debugPrint(
|
|
||||||
'[PluriWave][alarmas] cargadas=${_alarmas.length} vacaciones=${_vacaciones.length} excepciones=${_excepciones.length}',
|
|
||||||
);
|
|
||||||
await _sincronizarTodas();
|
|
||||||
await cargarDiagnostico();
|
|
||||||
_activarRefresco();
|
|
||||||
} catch (e) {
|
|
||||||
_error = 'No se pudieron cargar las alarmas: $e';
|
|
||||||
debugPrint('[PluriWave][alarmas] inicializar ERROR $e');
|
|
||||||
} finally {
|
|
||||||
_cargando = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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}',
|
|
||||||
);
|
|
||||||
final config = await servicio.guardarAlarma(alarma);
|
|
||||||
_aplicar(config);
|
|
||||||
try {
|
|
||||||
final guardada = _alarmas.firstWhere((a) => a.id == alarma.id);
|
|
||||||
await _solicitarPermisosNecesariosParaAlarma();
|
|
||||||
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';
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> refrescarProgramacion() async {
|
|
||||||
debugPrint('[PluriWave][alarmas] refrescar programacion');
|
|
||||||
final config = await servicio.recalcularTodas();
|
|
||||||
_aplicar(config);
|
|
||||||
debugPrint(
|
|
||||||
'[PluriWave][alarmas] proxima tras refrescar=${proximaAlarma?.id} ${proximaAlarma?.proximaEjecucion?.toIso8601String()}',
|
|
||||||
);
|
|
||||||
await _sincronizarTodas();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cargarPersistidasSinRecalcular() async {
|
|
||||||
final config = await servicio.cargar();
|
|
||||||
_aplicar(config);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
void marcarEjecucionGestionada(AlarmaMusical alarma) {
|
|
||||||
final proxima = alarma.proximaProgramable;
|
|
||||||
if (proxima == null) return;
|
|
||||||
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
|
|
||||||
_registrarEjecucionEmitida(key);
|
|
||||||
debugPrint(
|
|
||||||
'[PluriWave][alarmas] ejecucion gestionada id=${alarma.id} proxima=${proxima.toIso8601String()}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@visibleForTesting
|
|
||||||
int get ejecucionesEmitidasLength => _ejecucionesEmitidas.length;
|
|
||||||
|
|
||||||
/// Forwards the UI localizations to the native bridge so alarm and station
|
|
||||||
/// names sent to Android follow the app locale (Decision 3.2 — replaces
|
|
||||||
/// the old static `ServicioAlarmasAndroid.configurarLocalizaciones`).
|
|
||||||
void configurarLocalizaciones(AppLocalizations l10n) {
|
|
||||||
android.configurarLocalizaciones(l10n);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> eliminarAlarma(String id) async {
|
|
||||||
debugPrint('[PluriWave][alarmas] eliminar id=$id');
|
|
||||||
final config = await servicio.eliminarAlarma(id);
|
|
||||||
_aplicar(config);
|
|
||||||
await android.detenerSonidoNativo(id);
|
|
||||||
await android.cancelar(id);
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cambiarActiva(AlarmaMusical alarma, bool activa) async {
|
|
||||||
await guardarAlarma(alarma.copyWith(activa: activa));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> saltarProxima(String alarmaId) async {
|
|
||||||
debugPrint('[PluriWave][alarmas] saltar proxima id=$alarmaId');
|
|
||||||
final config = await servicio.saltarProxima(alarmaId);
|
|
||||||
_aplicar(config);
|
|
||||||
AlarmaMusical? alarma;
|
|
||||||
for (final item in _alarmas) {
|
|
||||||
if (item.id == alarmaId) {
|
|
||||||
alarma = item;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (alarma != null) {
|
|
||||||
await android.programar(alarma);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> guardarVacaciones(List<RangoVacaciones> vacaciones) async {
|
|
||||||
debugPrint(
|
|
||||||
'[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}',
|
|
||||||
);
|
|
||||||
final config = await servicio.guardarVacaciones(vacaciones);
|
|
||||||
_aplicar(config);
|
|
||||||
await _sincronizarTodas();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
|
|
||||||
final ejecucion =
|
|
||||||
alarma.snoozeOrigen ?? alarma.proximaEjecucion ?? DateTime.now();
|
|
||||||
debugPrint(
|
|
||||||
'[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos ejecucion=${ejecucion.toIso8601String()}',
|
|
||||||
);
|
|
||||||
await android.ocultarNotificacionAlarma(alarma.id);
|
|
||||||
final config = await servicio.posponerEjecucion(
|
|
||||||
alarma.id,
|
|
||||||
ejecucion,
|
|
||||||
minutos,
|
|
||||||
);
|
|
||||||
_aplicar(config);
|
|
||||||
final actualizada = _buscarAlarma(alarma.id);
|
|
||||||
if (actualizada != null) {
|
|
||||||
await android.programar(actualizada);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> posponerProximaDesdePreaviso(
|
|
||||||
AlarmaMusical alarma,
|
|
||||||
int minutos,
|
|
||||||
DateTime ejecucion,
|
|
||||||
) async {
|
|
||||||
final seguros = _snoozeSeguro(minutos);
|
|
||||||
final snoozeHasta = ejecucion.add(Duration(minutes: seguros));
|
|
||||||
debugPrint(
|
|
||||||
'[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros ejecucion=${ejecucion.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}',
|
|
||||||
);
|
|
||||||
await android.ocultarNotificacionAlarma(alarma.id);
|
|
||||||
final config = await servicio.posponerEjecucionHasta(
|
|
||||||
alarma.id,
|
|
||||||
ejecucion,
|
|
||||||
snoozeHasta,
|
|
||||||
);
|
|
||||||
_aplicar(config);
|
|
||||||
final actualizada = _buscarAlarma(alarma.id);
|
|
||||||
if (actualizada != null) {
|
|
||||||
await android.programar(actualizada);
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> finalizarEjecucion(String alarmaId) async {
|
|
||||||
debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId');
|
|
||||||
final alarma = _buscarAlarma(alarmaId);
|
|
||||||
final ejecucion =
|
|
||||||
alarma?.snoozeOrigen ??
|
|
||||||
alarma?.proximaEjecucion ??
|
|
||||||
alarma?.snoozeHasta ??
|
|
||||||
DateTime.now();
|
|
||||||
await android.ocultarNotificacionAlarma(alarmaId);
|
|
||||||
final config = await servicio.completarEjecucion(alarmaId, ejecucion);
|
|
||||||
_aplicar(config);
|
|
||||||
await _sincronizarTodas();
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> crearRangoVacaciones(RangoVacaciones rango) async {
|
|
||||||
final nuevos = [..._vacaciones, rango];
|
|
||||||
await guardarVacaciones(nuevos);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> eliminarRangoVacaciones(String id) async {
|
|
||||||
final nuevos = _vacaciones.where((v) => v.id != id).toList();
|
|
||||||
await guardarVacaciones(nuevos);
|
|
||||||
}
|
|
||||||
|
|
||||||
ExcepcionAlarma? ultimaExcepcionPara(String alarmaId) {
|
|
||||||
final candidatas =
|
|
||||||
_excepciones.where((e) => e.alarmaId == alarmaId).toList()
|
|
||||||
..sort((a, b) => b.ejecucion.compareTo(a.ejecucion));
|
|
||||||
return candidatas.isEmpty ? null : candidatas.first;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cargarDiagnostico() async {
|
|
||||||
try {
|
|
||||||
_diagnostico = await android.diagnostico();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[PluriWave][alarmas] diagnostico ERROR $e');
|
|
||||||
_diagnostico = null;
|
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Records a snooze the native layer performed by itself (Decision 2.1).
|
|
||||||
/// The native scheduler already re-registered setAlarmClock, so this only
|
|
||||||
/// persists the canonical state — it MUST NOT call android.programar again.
|
|
||||||
Future<void> _alRecibirEventoNativo(EventoAlarmaAndroid evento) async {
|
|
||||||
if (evento.accion != EventoAlarmaAndroid.accionSnoozed) return;
|
|
||||||
if (evento.alarmaId.isEmpty || evento.snoozeUntilMillis <= 0) return;
|
|
||||||
final hasta = DateTime.fromMillisecondsSinceEpoch(evento.snoozeUntilMillis);
|
|
||||||
final origen =
|
|
||||||
evento.occurrenceAtMillis > 0
|
|
||||||
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
|
|
||||||
: hasta.subtract(Duration(minutes: evento.snoozeMinutes));
|
|
||||||
debugPrint(
|
|
||||||
'[PluriWave][alarmas] snooze nativo id=${evento.alarmaId} hasta=${hasta.toIso8601String()}',
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
final config = await servicio.posponerEjecucionHasta(
|
|
||||||
evento.alarmaId,
|
|
||||||
origen,
|
|
||||||
hasta,
|
|
||||||
);
|
|
||||||
_aplicar(config);
|
|
||||||
notifyListeners();
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[PluriWave][alarmas] snooze nativo ERROR $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _sincronizarEjecucionesGestionadasPorAndroid() async {
|
|
||||||
try {
|
|
||||||
final ejecuciones = await android.obtenerEjecucionesNativasGestionadas();
|
|
||||||
if (ejecuciones.isNotEmpty) {
|
|
||||||
final config = await servicio.sincronizarEjecucionesNativas({
|
|
||||||
for (final ejecucion in ejecuciones)
|
|
||||||
ejecucion.alarmaId: ejecucion.gestionadaEn,
|
|
||||||
});
|
|
||||||
_aplicar(config);
|
|
||||||
debugPrint(
|
|
||||||
'[PluriWave][alarmas] sincronizadas ejecuciones nativas count=${ejecuciones.length}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e');
|
|
||||||
}
|
|
||||||
await _importarSnoozesNativosActivos();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cold-start half of Decision 2.1: imports snoozes the native scheduler
|
|
||||||
/// performed while the Flutter engine was dead, before any recalculation
|
|
||||||
/// could erase them.
|
|
||||||
Future<void> _importarSnoozesNativosActivos() async {
|
|
||||||
try {
|
|
||||||
final snoozes = await android.obtenerEstadoSnoozeNativo();
|
|
||||||
if (snoozes.isEmpty) return;
|
|
||||||
final ahora = DateTime.now();
|
|
||||||
var config = await servicio.cargar();
|
|
||||||
var huboCambios = false;
|
|
||||||
for (final snooze in snoozes) {
|
|
||||||
if (!snooze.snoozeHasta.isAfter(ahora)) continue;
|
|
||||||
AlarmaMusical? alarma;
|
|
||||||
for (final candidata in config.alarmas) {
|
|
||||||
if (candidata.id == snooze.alarmaId) {
|
|
||||||
alarma = candidata;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (alarma == null || !alarma.activa) continue;
|
|
||||||
if (alarma.snoozeHasta == snooze.snoozeHasta) continue;
|
|
||||||
config = await servicio.posponerEjecucionHasta(
|
|
||||||
snooze.alarmaId,
|
|
||||||
snooze.snoozeOrigen,
|
|
||||||
snooze.snoozeHasta,
|
|
||||||
);
|
|
||||||
huboCambios = true;
|
|
||||||
}
|
|
||||||
if (huboCambios) {
|
|
||||||
_aplicar(config);
|
|
||||||
debugPrint(
|
|
||||||
'[PluriWave][alarmas] snoozes nativos importados count=${snoozes.length}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[PluriWave][alarmas] importar snoozes nativos ERROR $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _solicitarPermisosNecesariosParaAlarma() async {
|
|
||||||
try {
|
|
||||||
final diag = await android.diagnostico();
|
|
||||||
_diagnostico = diag;
|
|
||||||
if (!diag.puedeProgramarExactas) {
|
|
||||||
await android.solicitarPermisoAlarmasExactas();
|
|
||||||
}
|
|
||||||
if (!diag.notificacionesPermitidas) {
|
|
||||||
await android.solicitarPermisoNotificaciones();
|
|
||||||
}
|
|
||||||
if (!diag.puedeUsarPantallaCompleta) {
|
|
||||||
await android.solicitarPermisoPantallaCompleta();
|
|
||||||
}
|
|
||||||
if (!diag.ignoraOptimizacionBateria) {
|
|
||||||
await _solicitarExencionBateriaUnaVez();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
debugPrint('[PluriWave][alarmas] permisos android ERROR $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _solicitarExencionBateriaUnaVez() async {
|
|
||||||
final prefs = _prefs ?? await SharedPreferences.getInstance();
|
|
||||||
if (prefs.getBool(_keyExencionBateriaSolicitada) ?? false) return;
|
|
||||||
await android.solicitarExencionBateria();
|
|
||||||
await prefs.setBool(_keyExencionBateriaSolicitada, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _sincronizarTodas() async {
|
|
||||||
debugPrint(
|
|
||||||
'[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}',
|
|
||||||
);
|
|
||||||
if (_alarmas.any((alarma) => alarma.activa)) {
|
|
||||||
await _solicitarPermisosNecesariosParaAlarma();
|
|
||||||
}
|
|
||||||
for (final alarma in _alarmas) {
|
|
||||||
await android.programar(alarma);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AlarmaMusical? _buscarAlarma(String id) {
|
|
||||||
for (final alarma in _alarmas) {
|
|
||||||
if (alarma.id == id) return alarma;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
int _snoozeSeguro(int minutos) =>
|
|
||||||
minutos == 3 || minutos == 5 || minutos == 10 ? minutos : 5;
|
|
||||||
|
|
||||||
void _aplicar(ConfiguracionAlarmas config) {
|
|
||||||
_alarmas = config.alarmas;
|
|
||||||
_vacaciones = config.vacaciones;
|
|
||||||
_excepciones = config.excepciones;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _activarRefresco() {
|
|
||||||
_refresco?.cancel();
|
|
||||||
_refresco = Timer.periodic(const Duration(minutes: 1), (_) {
|
|
||||||
refrescarProgramacion();
|
|
||||||
});
|
|
||||||
_vigilarAlarmasVencidas();
|
|
||||||
_vigilancia?.cancel();
|
|
||||||
_vigilancia = Timer.periodic(const Duration(seconds: 10), (_) {
|
|
||||||
_vigilarAlarmasVencidas();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _vigilarAlarmasVencidas() {
|
|
||||||
final ahora = DateTime.now();
|
|
||||||
_depurarEjecucionesEmitidas(ahora);
|
|
||||||
for (final alarma in _alarmas) {
|
|
||||||
final proxima = alarma.proximaProgramable;
|
|
||||||
if (!alarma.activa || proxima == null) continue;
|
|
||||||
if (proxima.isAfter(ahora)) continue;
|
|
||||||
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
|
|
||||||
final retraso = ahora.difference(proxima);
|
|
||||||
if (retraso > _margenDisparoLocal) {
|
|
||||||
_registrarEjecucionEmitida(key);
|
|
||||||
debugPrint(
|
|
||||||
'[PluriWave][alarmas] vencida local ignorada por antigua id=${alarma.id} proxima=${proxima.toIso8601String()} retraso=${retraso.inSeconds}s',
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (_registrarEjecucionEmitida(key)) {
|
|
||||||
debugPrint(
|
|
||||||
'[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}',
|
|
||||||
);
|
|
||||||
_alarmasVencidasController.add(alarma);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds a `alarmId:millis` key and keeps the set bounded (S3-R6).
|
|
||||||
/// Returns whether the key was newly added (fire-dedup contract).
|
|
||||||
bool _registrarEjecucionEmitida(String key) {
|
|
||||||
final agregada = _ejecucionesEmitidas.add(key);
|
|
||||||
_depurarEjecucionesEmitidas(DateTime.now());
|
|
||||||
return agregada;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _depurarEjecucionesEmitidas(DateTime ahora) {
|
|
||||||
final limite =
|
|
||||||
ahora.subtract(_retencionEjecucionesEmitidas).millisecondsSinceEpoch;
|
|
||||||
_ejecucionesEmitidas.removeWhere((key) => _millisDeEjecucion(key) < limite);
|
|
||||||
if (_ejecucionesEmitidas.length <= maxEjecucionesEmitidas) return;
|
|
||||||
// Still over the cap: evict the oldest occurrences first. Pruned keys
|
|
||||||
// cannot re-fire because occurrences beyond _margenDisparoLocal are
|
|
||||||
// ignored by _vigilarAlarmasVencidas anyway.
|
|
||||||
final ordenadas =
|
|
||||||
_ejecucionesEmitidas.toList()..sort(
|
|
||||||
(a, b) => _millisDeEjecucion(a).compareTo(_millisDeEjecucion(b)),
|
|
||||||
);
|
|
||||||
_ejecucionesEmitidas.removeAll(
|
|
||||||
ordenadas.take(_ejecucionesEmitidas.length - maxEjecucionesEmitidas),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
int _millisDeEjecucion(String key) {
|
|
||||||
final separador = key.lastIndexOf(':');
|
|
||||||
if (separador < 0) return 0;
|
|
||||||
return int.tryParse(key.substring(separador + 1)) ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_refresco?.cancel();
|
|
||||||
_vigilancia?.cancel();
|
|
||||||
_eventosNativosSub?.cancel();
|
|
||||||
_alarmasVencidasController.close();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import 'dart:ui' show Locale, PlatformDispatcher;
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:geocoding/geocoding.dart';
|
|
||||||
import 'package:geolocator/geolocator.dart';
|
|
||||||
|
|
||||||
import '../l10n/gen/app_localizations.dart';
|
|
||||||
import '../modelos/emisora.dart';
|
|
||||||
import '../servicios/servicio_radio.dart';
|
|
||||||
import 'orden_emisoras.dart';
|
|
||||||
|
|
||||||
/// Search state extracted from `EstadoRadio` (S4-R3).
|
|
||||||
///
|
|
||||||
/// Owns the search query/filters, paged results, the nearby-stations lookup
|
|
||||||
/// and every loading flag. Notifies ONLY its own listeners so search activity
|
|
||||||
/// never rebuilds `EstadoRadio` consumers (S4-R5).
|
|
||||||
class EstadoBusqueda extends ChangeNotifier {
|
|
||||||
EstadoBusqueda({
|
|
||||||
required this.radio,
|
|
||||||
OrdenEmisoras Function()? ordenListas,
|
|
||||||
AppLocalizations Function()? textos,
|
|
||||||
void Function(String mensaje)? alError,
|
|
||||||
}) : _ordenListas = ordenListas ?? (() => OrdenEmisoras.calidad),
|
|
||||||
_textos = textos ?? (() => lookupAppLocalizations(const Locale('es'))),
|
|
||||||
_alError = alError;
|
|
||||||
|
|
||||||
static const int _tamanoPagina = 30;
|
|
||||||
static const int _maxResultadosEnMemoria = 180;
|
|
||||||
|
|
||||||
final ServicioRadio radio;
|
|
||||||
|
|
||||||
/// Current list ordering, owned by EstadoRadio (user preference).
|
|
||||||
final OrdenEmisoras Function() _ordenListas;
|
|
||||||
final AppLocalizations Function() _textos;
|
|
||||||
|
|
||||||
/// User-visible error sink (EstadoRadio routes it to its snackbar stream).
|
|
||||||
final void Function(String mensaje)? _alError;
|
|
||||||
|
|
||||||
List<Emisora> _resultados = [];
|
|
||||||
List<Emisora> _cercanas = [];
|
|
||||||
bool _cargando = false;
|
|
||||||
bool _cargandoMas = false;
|
|
||||||
bool _hayMas = true;
|
|
||||||
bool _cargandoCercanas = false;
|
|
||||||
String? _paisCercanoDetectado;
|
|
||||||
String? _errorCercanas;
|
|
||||||
int _offset = 0;
|
|
||||||
String? _ultimoNombre;
|
|
||||||
String? _ultimoPais;
|
|
||||||
String? _ultimoIdioma;
|
|
||||||
String? _ultimoTag;
|
|
||||||
int? _ultimoMinBitrate;
|
|
||||||
|
|
||||||
final _memoResultados = MemoLista<Emisora>();
|
|
||||||
final _memoCercanas = MemoLista<Emisora>();
|
|
||||||
|
|
||||||
List<Emisora> get resultados => _memoResultados.obtener([
|
|
||||||
_resultados,
|
|
||||||
_ordenListas(),
|
|
||||||
], () => ordenarEmisoras(_resultados, _ordenListas()));
|
|
||||||
List<Emisora> get cercanas => _memoCercanas.obtener([
|
|
||||||
_cercanas,
|
|
||||||
_ordenListas(),
|
|
||||||
], () => ordenarEmisoras(_cercanas, _ordenListas()));
|
|
||||||
bool get cargando => _cargando;
|
|
||||||
bool get cargandoMas => _cargandoMas;
|
|
||||||
bool get hayMas => _hayMas;
|
|
||||||
bool get cargandoCercanas => _cargandoCercanas;
|
|
||||||
String? get paisCercanoDetectado => _paisCercanoDetectado;
|
|
||||||
String? get errorCercanas => _errorCercanas;
|
|
||||||
|
|
||||||
/// Re-renders sorted views after the user changes the list ordering
|
|
||||||
/// (called by EstadoRadio, which owns that preference).
|
|
||||||
void notificarCambioOrden() => notifyListeners();
|
|
||||||
|
|
||||||
Future<void> buscar({
|
|
||||||
String? nombre,
|
|
||||||
String? pais,
|
|
||||||
String? idioma,
|
|
||||||
String? tag,
|
|
||||||
int? minBitrate,
|
|
||||||
}) async {
|
|
||||||
_ultimoNombre = nombre;
|
|
||||||
_ultimoPais = pais;
|
|
||||||
_ultimoIdioma = idioma;
|
|
||||||
_ultimoTag = tag;
|
|
||||||
_ultimoMinBitrate = minBitrate;
|
|
||||||
_offset = 0;
|
|
||||||
_hayMas = true;
|
|
||||||
_cargando = true;
|
|
||||||
_resultados = [];
|
|
||||||
notifyListeners();
|
|
||||||
try {
|
|
||||||
final pagina = await _buscarPaginaFiltrada(
|
|
||||||
nombre: nombre,
|
|
||||||
pais: pais,
|
|
||||||
idioma: idioma,
|
|
||||||
tag: tag,
|
|
||||||
minBitrate: minBitrate,
|
|
||||||
);
|
|
||||||
_resultados = pagina;
|
|
||||||
} catch (_) {
|
|
||||||
_alError?.call(_textos().radioSearchError);
|
|
||||||
} finally {
|
|
||||||
_cargando = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cargarMas() async {
|
|
||||||
if (_cargando || _cargandoMas || !_hayMas) return;
|
|
||||||
_cargandoMas = true;
|
|
||||||
notifyListeners();
|
|
||||||
try {
|
|
||||||
final pagina = await _buscarPaginaFiltrada(
|
|
||||||
nombre: _ultimoNombre,
|
|
||||||
pais: _ultimoPais,
|
|
||||||
idioma: _ultimoIdioma,
|
|
||||||
tag: _ultimoTag,
|
|
||||||
minBitrate: _ultimoMinBitrate,
|
|
||||||
);
|
|
||||||
final porUuid = <String, Emisora>{
|
|
||||||
for (final emisora in _resultados) emisora.uuid: emisora,
|
|
||||||
};
|
|
||||||
for (final emisora in pagina) {
|
|
||||||
porUuid[emisora.uuid] = emisora;
|
|
||||||
}
|
|
||||||
var nuevaLista = porUuid.values.toList();
|
|
||||||
if (nuevaLista.length > _maxResultadosEnMemoria) {
|
|
||||||
nuevaLista = nuevaLista.sublist(
|
|
||||||
nuevaLista.length - _maxResultadosEnMemoria,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_resultados = nuevaLista;
|
|
||||||
// _buscarPaginaFiltrada actualiza offset/hayMas usando páginas crudas.
|
|
||||||
_hayMas = _hayMas && pagina.isNotEmpty;
|
|
||||||
} catch (_) {
|
|
||||||
_alError?.call(_textos().radioLoadMoreStationsError);
|
|
||||||
} finally {
|
|
||||||
_cargandoMas = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<Emisora>> _buscarPaginaFiltrada({
|
|
||||||
String? nombre,
|
|
||||||
String? pais,
|
|
||||||
String? idioma,
|
|
||||||
String? tag,
|
|
||||||
int? minBitrate,
|
|
||||||
}) async {
|
|
||||||
final acumuladas = <Emisora>[];
|
|
||||||
var intentos = 0;
|
|
||||||
while (intentos < 4 && acumuladas.isEmpty && _hayMas) {
|
|
||||||
final pagina = await radio.buscar(
|
|
||||||
nombre: nombre,
|
|
||||||
pais: pais,
|
|
||||||
idioma: idioma,
|
|
||||||
tag: tag,
|
|
||||||
limit: _tamanoPagina,
|
|
||||||
offset: _offset,
|
|
||||||
);
|
|
||||||
_offset += pagina.length;
|
|
||||||
_hayMas = pagina.length == _tamanoPagina;
|
|
||||||
acumuladas.addAll(_filtrarMinBitrate(pagina, minBitrate));
|
|
||||||
intentos++;
|
|
||||||
}
|
|
||||||
return acumuladas;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Emisora> _filtrarMinBitrate(List<Emisora> emisoras, int? minBitrate) {
|
|
||||||
if (minBitrate == null || minBitrate <= 0) return emisoras;
|
|
||||||
return emisoras.where((e) => (e.bitrate ?? 0) >= minBitrate).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> cargarEmisorasCercanas() async {
|
|
||||||
_cargandoCercanas = true;
|
|
||||||
_errorCercanas = null;
|
|
||||||
notifyListeners();
|
|
||||||
try {
|
|
||||||
var pais = PlatformDispatcher.instance.locale.countryCode;
|
|
||||||
final servicioActivo = await Geolocator.isLocationServiceEnabled();
|
|
||||||
if (servicioActivo) {
|
|
||||||
var permiso = await Geolocator.checkPermission();
|
|
||||||
if (permiso == LocationPermission.denied) {
|
|
||||||
permiso = await Geolocator.requestPermission();
|
|
||||||
}
|
|
||||||
if (permiso == LocationPermission.always ||
|
|
||||||
permiso == LocationPermission.whileInUse) {
|
|
||||||
final posicion = await Geolocator.getCurrentPosition(
|
|
||||||
locationSettings: const LocationSettings(
|
|
||||||
accuracy: LocationAccuracy.low,
|
|
||||||
timeLimit: Duration(seconds: 8),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
final marcas = await placemarkFromCoordinates(
|
|
||||||
posicion.latitude,
|
|
||||||
posicion.longitude,
|
|
||||||
);
|
|
||||||
if (marcas.isNotEmpty) {
|
|
||||||
pais = marcas.first.isoCountryCode ?? pais;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pais == null || pais.isEmpty) {
|
|
||||||
throw StateError('nearby-region-not-detected');
|
|
||||||
}
|
|
||||||
_paisCercanoDetectado = pais;
|
|
||||||
_cercanas = _filtrarMinBitrate(
|
|
||||||
await radio.buscar(pais: pais, limit: 30),
|
|
||||||
_ultimoMinBitrate,
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
_errorCercanas = _textos().radioNearbyStationsError;
|
|
||||||
_cercanas = [];
|
|
||||||
} finally {
|
|
||||||
_cargandoCercanas = false;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||