1 Commits

205 changed files with 754 additions and 44770 deletions
-169
View File
@@ -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:
flutter-ci:
name: Test + Build
#runs-on: macos-14
runs-on: [self-hosted, macos, arm64, flutter]
env:
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
runs-on: macmini-flutter
steps:
- name: Checkout
-1
View File
@@ -32,7 +32,6 @@ migrate_working_dir/
.pub/
/build/
/coverage/
.atl/
# Symbolication related
app.*.symbols
-14
View File
@@ -1,19 +1,5 @@
# 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
### Fixes (prioridad alta — petición WhikY)
-50
View File
@@ -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.
+7 -35
View File
@@ -1,21 +1,10 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
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 {
namespace = "es.freetimelab.pluriwave"
compileSdk = flutter.compileSdkVersion
@@ -31,38 +20,21 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
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
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
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 {
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")
}
}
}
+1 -55
View File
@@ -4,30 +4,18 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<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
android:label="PluriWave"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config">
android:roundIcon="@mipmap/ic_launcher_round">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
@@ -51,11 +39,6 @@
</intent-filter>
</service>
<service
android:name=".PluriWaveAlarmService"
android:foregroundServiceType="mediaPlayback"
android:exported="false" />
<!-- Receptor de controles de media (auriculares, notificación) -->
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
@@ -65,43 +48,6 @@
</intent-filter>
</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
android:name="flutterEmbedding"
android:value="2" />
@@ -1,663 +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
): 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,
fallbackSound = fallbackSound,
volume = volume.coerceIn(0f, 1f),
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)
}
}
fun snooze(id: String, minutes: Int) {
val spec = readSpec(id) ?: return
val safeMinutes = sanitizeSnoozeMinutes(minutes)
val snoozeUntil = System.currentTimeMillis() + safeMinutes * 60_000L
scheduleSpec(
spec.copy(
snoozeUntilMillis = snoozeUntil,
snoozeOriginMillis = spec.snoozeOriginMillis ?: spec.triggerAtMillis
),
persistOnSuccess = true
)
}
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
)
}
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_SOUND, spec.fallbackSound)
putExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, spec.volume)
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
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 fallbackSound: String?,
val volume: Float,
val timezoneId: String
) {
fun toJson(): JSONObject = JSONObject().apply {
put("schemaVersion", 2)
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("fallbackSound", fallbackSound)
put("volume", volume)
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() },
fallbackSound = json.optString("fallbackSound").takeIf { it.isNotBlank() },
volume = json.optDouble("volume", 0.85).toFloat(),
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,581 +1,5 @@
package es.freetimelab.pluriwave
import android.Manifest
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
import io.flutter.embedding.android.FlutterActivity
class MainActivity : AudioServiceActivity() {
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
)
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())
}
"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())
}
else -> result.notImplemented()
}
}
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 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() {
stopVisualizer()
super.onDestroy()
}
}
class MainActivity : FlutterActivity()
@@ -1,288 +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)
}
showFireNotification(context, alarmId, title, launch, snoozeMinutes)
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 showFireNotification(
context: Context,
alarmId: String,
title: String,
launch: Intent,
snoozeMinutes: Int
) {
ensureFireChannel(context)
val fullScreenIntent = PendingIntent.getActivity(
context,
requestCode(alarmId, 10),
launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, FIRE_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle("Alarma PluriWave")
.setContentText(title)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(fullScreenIntent)
.setFullScreenIntent(fullScreenIntent, true)
.addAction(0, "Posponer", snoozePendingIntent(context, alarmId, snoozeMinutes))
.addAction(0, "Detener", stopPendingIntent(context, alarmId))
.build()
try {
NotificationManagerCompat.from(context).notify(
fireNotificationIdForAlarm(alarmId),
notification,
)
Log.d(TAG, "alarm.notification fire shown id=$alarmId")
} catch (error: SecurityException) {
Log.e(TAG, "alarm.notification fire SecurityException id=$alarmId", error)
}
}
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 ensureFireChannel(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(FIRE_CHANNEL_ID)
if (existing != null) return
val channel = NotificationChannel(
FIRE_CHANNEL_ID,
"Alarmas sonando",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Pantalla urgente cuando una alarma musical debe sonar"
enableVibration(true)
}
manager.createNotificationChannel(channel)
}
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
private fun snoozePendingIntent(context: Context, alarmId: String, minutes: Int): PendingIntent =
PendingIntent.getService(
context,
requestCode(alarmId, 20 + minutes),
Intent(context, PluriWaveAlarmService::class.java).apply {
action = PluriWaveAlarmService.ACTION_SNOOZE
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(PluriWaveAlarmService.EXTRA_SNOOZE_MINUTES, minutes)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun stopPendingIntent(context: Context, alarmId: String): PendingIntent =
PendingIntent.getService(
context,
requestCode(alarmId, 40),
Intent(context, PluriWaveAlarmService::class.java).apply {
action = PluriWaveAlarmService.ACTION_STOP
putExtra(EXTRA_ALARM_ID, alarmId)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
companion object {
const val TAG = "PluriWave"
const val CHANNEL_ID = "pluriwave_alarm_pre_notice"
const val FIRE_CHANNEL_ID = "pluriwave_alarm_fire"
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_SOUND = "fallbackSound"
const val EXTRA_VOLUME = "volume"
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,430 +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.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.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import java.io.File
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
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) {
AlarmScheduler(this).snooze(requestedId, 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 fallbackSound = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND)
val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f)
val snoozeMinutes = sanitizeSnoozeMinutes(
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5)
)
acquireWakeLock()
try {
startForeground(NOTIFICATION_ID, buildNotification(alarmId, title, stationName, snoozeMinutes))
} catch (error: Throwable) {
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
releaseWakeLock()
activeAlarmId = null
stopSelf()
return
}
startAudio(alarmId, stationName, stationUrl, fallbackSound, volume)
}
private fun startAudio(
alarmId: String,
stationName: String?,
stationUrl: String?,
fallbackSound: String?,
volume: Float
) {
player?.release()
player = null
if (!stationUrl.isNullOrBlank()) {
startStationAudio(
alarmId,
stationName,
stationUrl.trim(),
fallbackSound,
volume
)
return
}
startFallbackAudio(alarmId, fallbackSound, volume, "station url missing")
}
private fun startStationAudio(
alarmId: String,
stationName: String?,
stationUrl: String,
fallbackSound: String?,
volume: Float
) {
scheduleStationFallback(alarmId, fallbackSound, volume)
try {
player = MediaPlayer().apply {
setAudioAttributes(alarmAudioAttributes())
isLooping = false
setVolume(volume, volume)
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()
Log.d(
TAG,
"alarm.service station started id=$alarmId station=$stationName url=$stationUrl"
)
}
setOnCompletionListener {
if (activeAlarmId != alarmId) return@setOnCompletionListener
Log.w(TAG, "alarm.service station completed id=$alarmId url=$stationUrl")
startFallbackAudio(alarmId, fallbackSound, volume, "station completed")
}
setOnErrorListener { mp, what, extra ->
Log.e(
TAG,
"alarm.service station error id=$alarmId what=$what extra=$extra url=$stationUrl"
)
runCatching { mp.reset() }
if (activeAlarmId == alarmId) {
startFallbackAudio(alarmId, fallbackSound, volume, "station error")
}
true
}
prepareAsync()
}
Log.d(TAG, "alarm.service station preparing id=$alarmId station=$stationName url=$stationUrl")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service station prepare failed id=$alarmId url=$stationUrl", error)
startFallbackAudio(alarmId, fallbackSound, volume, "station prepare failed")
}
}
private fun startFallbackAudio(
alarmId: String,
fallbackSound: String?,
volume: Float,
reason: String
) {
cancelStationFallback()
player?.release()
player = null
val source = fallbackAssetPath(fallbackSound)
try {
player = MediaPlayer().apply {
setAudioAttributes(alarmAudioAttributes())
isLooping = true
setVolume(volume, volume)
setFallbackAssetDataSource(this, fallbackSound)
setOnPreparedListener {
if (activeAlarmId != alarmId) return@setOnPreparedListener
it.start()
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,
fallbackSound: String?,
volume: Float
) {
cancelStationFallback()
val runnable = Runnable {
if (activeAlarmId == alarmId) {
Log.w(TAG, "alarm.service station timeout id=$alarmId; using fallback")
startFallbackAudio(alarmId, fallbackSound, volume, "station timeout")
}
}
stationFallbackRunnable = runnable
mainHandler.postDelayed(runnable, STATION_START_TIMEOUT_MILLIS)
}
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()
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_native"
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
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
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
val channel = NotificationChannel(
CHANNEL_ID,
"Alarma musical",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Sonido de alarma musical con pantalla apagada"
enableVibration(true)
}
manager.createNotificationChannel(channel)
}
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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

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>
Binary file not shown.
Binary file not shown.
Binary file not shown.
-27
View File
@@ -1,27 +0,0 @@
# أهلاً بك في PluriWave
PluriWave هو راديوك العالمي المميز: محطات مباشرة، مفضلات منظمة، تسجيلات، معادل صوت ومنبّهات موسيقية ضمن تجربة مصممة بعناية.
## راديو مباشر
- ابحث عن المحطات حسب الاسم والبلد واللغة والجودة.
- استكشف المحطات القريبة واكتشف محطات جديدة.
- رتّب القوائم حسب الاسم أو الجودة.
## موسيقى بطريقتك
- احفظ المفضلات ونظّمها في مجموعات.
- اضبط المعادل العام أو إعدادات كل محطة.
- استخدم مؤقّت النوم بمدد مخصّصة.
## التسجيلات
- سجّل الراديو بدون إعادة ضغط البث الأصلي.
- حدّد الحجم الأقصى للملف لتبقى بأمان.
- افتح مجلد التسجيلات للمشاركة أو النقل أو التعديل.
## منبّهات موسيقية
- أنشئ منبّهات لمرة واحدة أو يومية أو لأيام العمل.
- اختر محطة مفضلة وصوتاً داخلياً آمناً.
- استخدم العطلات وتخطي التنفيذ التالي والغفوة.
-27
View File
@@ -1,27 +0,0 @@
# PluriWave-এ স্বাগতম
PluriWave আপনার প্রিমিয়াম বিশ্ব রেডিও: লাইভ স্টেশন, গোছানো ফেভারিট, রেকর্ডিং, ইকুয়ালাইজার এবং মিউজিক অ্যালার্ম—সবই যত্নসহ তৈরি এক অভিজ্ঞতায়।
## লাইভ রেডিও
- নাম, দেশ, ভাষা ও মান অনুযায়ী স্টেশন খুঁজুন।
- কাছাকাছি স্টেশন দেখুন এবং নতুন রেডিও আবিষ্কার করুন।
- তালিকা নাম বা মান অনুযায়ী সাজান।
## আপনার মতো করে সঙ্গীত
- ফেভারিট সংরক্ষণ করুন এবং গ্রুপে সাজান।
- গ্লোবাল ইকুয়ালাইজার বা স্টেশনভিত্তিক প্রিসেট ঠিক করুন।
- নিজের মতো সময় দিয়ে স্লিপ টাইমার ব্যবহার করুন।
## রেকর্ডিং
- মূল স্ট্রিম রিকমপ্রেস না করে রেডিও রেকর্ড করুন।
- নিরাপদ থাকতে সর্বোচ্চ ফাইল সাইজ সীমা দিন।
- শেয়ার, সরানো বা সম্পাদনার জন্য রেকর্ডিং ফোল্ডার খুলুন।
## মিউজিক অ্যালার্ম
- একবার, প্রতিদিন বা কর্মদিবসের অ্যালার্ম তৈরি করুন।
- প্রিয় স্টেশন ও নিরাপদ অভ্যন্তরীণ সাউন্ড বেছে নিন।
- ছুটি, পরের রান স্কিপ এবং স্নুজ ব্যবহার করুন।
-27
View File
@@ -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.
-27
View File
@@ -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.
-27
View File
@@ -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.
-27
View File
@@ -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.
-27
View File
@@ -1,27 +0,0 @@
# PluriWave में आपका स्वागत है
PluriWave आपका प्रीमियम विश्व रेडियो है: लाइव स्टेशन, व्यवस्थित पसंदीदा, रिकॉर्डिंग, इक्वलाइज़र और संगीत अलार्म एक सधे हुए अनुभव में।
## लाइव रेडियो
- स्टेशन को नाम, देश, भाषा और गुणवत्ता से खोजें।
- पास के स्टेशन देखें और नए रेडियो खोजें।
- सूचियों को नाम या गुणवत्ता के अनुसार क्रमित करें।
## संगीत आपके तरीके से
- पसंदीदा सहेजें और उन्हें समूहों में व्यवस्थित करें।
- ग्लोबल इक्वलाइज़र या स्टेशन-विशिष्ट प्रीसेट समायोजित करें।
- अपनी पसंद की अवधि वाला स्लीप टाइमर इस्तेमाल करें।
## रिकॉर्डिंग
- मूल स्ट्रीम को फिर से कंप्रेस किए बिना रेडियो रिकॉर्ड करें।
- सुरक्षित रहने के लिए अधिकतम फ़ाइल आकार सीमित करें।
- फ़ाइलें साझा करने, स्थानांतरित करने या संपादित करने के लिए रिकॉर्डिंग फ़ोल्डर खोलें।
## संगीत अलार्म
- एक बार, रोज़ाना या कार्यदिवस अलार्म बनाएँ।
- पसंदीदा स्टेशन और सुरक्षित आंतरिक ध्वनि चुनें।
- छुट्टियाँ, अगला निष्पादन छोड़ना और स्नूज़ का उपयोग करें।
-27
View File
@@ -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.
-27
View File
@@ -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.
-27
View File
@@ -1,27 +0,0 @@
# PluriWave へようこそ
PluriWave は、ライブ局、お気に入り整理、録音、イコライザー、音楽アラームを備えた高品質なワールドラジオです。
## ライブラジオ
- 名前、国、言語、音質で局を検索できます。
- 近くの局を探して新しいラジオを見つけられます。
- リストを名前または音質で並べ替えできます。
## あなた好みの音楽体験
- お気に入りを保存してグループで整理できます。
- 全体イコライザーや局ごとのプリセットを調整できます。
- 時間を指定できるスリープタイマーを使えます。
## 録音
- 元のストリームを再圧縮せずに録音できます。
- 最大ファイルサイズを制限して安全に使えます。
- 録音フォルダーを開いて共有・移動・編集できます。
## 音楽アラーム
- 1回のみ、毎日、平日のアラームを作成できます。
- お気に入り局と安全な内蔵サウンドを選べます。
- 休日設定、次回スキップ、スヌーズに対応しています。
-27
View File
@@ -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.
-27
View File
@@ -1,27 +0,0 @@
# Добро пожаловать в PluriWave
PluriWave — ваше премиальное мировое радио: прямые станции, организованные избранные, записи, эквалайзер и музыкальные будильники в продуманном интерфейсе.
## Прямое радио
- Ищите станции по названию, стране, языку и качеству.
- Изучайте ближайшие станции и открывайте новое радио.
- Сортируйте списки по названию или качеству.
## Музыка по-вашему
- Сохраняйте избранное и организуйте его по группам.
- Настраивайте глобальный эквалайзер или пресеты для станций.
- Используйте таймер сна с нужной длительностью.
## Записи
- Записывайте радио без повторного сжатия исходного потока.
- Ограничивайте максимальный размер файла для безопасности.
- Открывайте папку записей, чтобы делиться, перемещать и редактировать файлы.
## Музыкальные будильники
- Создавайте разовые, ежедневные или будничные будильники.
- Выбирайте любимую станцию и безопасный встроенный звук.
- Используйте праздники, пропуск следующего запуска и отложенный сигнал.
-27
View File
@@ -1,27 +0,0 @@
# 欢迎使用 PluriWave
PluriWave 是你的高品质全球电台:直播电台、分组收藏、录音、均衡器和音乐闹钟,体验精致流畅。
## 直播电台
- 按名称、国家、语言和音质搜索电台。
- 探索附近电台,发现新的广播内容。
- 按名称或音质排序列表。
## 按你的方式听音乐
- 保存收藏并按分组整理。
- 调整全局均衡器或单电台预设。
- 使用可自定义时长的睡眠定时器。
## 录音
- 录制电台时不重新压缩原始流。
- 限制最大文件大小,更安全省心。
- 打开录音文件夹以分享、移动或编辑文件。
## 音乐闹钟
- 创建一次性、每日或工作日闹钟。
- 选择喜爱的电台和安全的内置提示音。
- 支持假期、跳过下次执行和贪睡。
-11
View File
@@ -1,11 +0,0 @@
# v0.1.47 · منبّهات وملفات أكثر موثوقية
الملخّص: عززنا أساس منبّهات Android وفصلنا بوضوح بين فتح المجلد وتغيير مساره.
## التحسينات
- أساس أصلي جديد للمنبّهات مع صوت داخلي آمن.
- تشخيص أفضل لأذونات Android الخاصة بالمنبّهات الدقيقة.
- المنبّهات التي تُنشأ في الدقيقة نفسها لم تعد تُستبعد بسبب الثواني.
- لوحة المنبّهات تميّز بين المنبّهات النشطة والمنبّهات بلا تنفيذ تالٍ صالح.
- فتح المجلد يحاول الآن فتح المسار المحفوظ؛ تغيير المسار أصبح منفصلاً.
-11
View File
@@ -1,11 +0,0 @@
# v0.1.47 · আরও নির্ভরযোগ্য অ্যালার্ম ও ফাইল
সারাংশ: আমরা Android অ্যালার্মের ভিত্তি শক্ত করেছি এবং ফোল্ডার খোলা ও পথ পরিবর্তনকে স্পষ্টভাবে আলাদা করেছি।
## উন্নতি
- নিরাপদ অভ্যন্তরীণ সাউন্ডসহ অ্যালার্মের জন্য নতুন নেটিভ ভিত্তি।
- Android exact-alarm অনুমতির উন্নত ডায়াগনস্টিক।
- একই মিনিটে তৈরি অ্যালার্ম এখন সেকেন্ডের কারণে বাদ পড়ে না।
- অ্যালার্ম প্যানেল সক্রিয় অ্যালার্ম ও বৈধ পরের রানবিহীন অ্যালার্ম আলাদা করে।
- ফোল্ডার খোলা এখন সংরক্ষিত পথ খোলার চেষ্টা করে; পথ বদল আলাদা করা হয়েছে।
-11
View File
@@ -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.
-11
View File
@@ -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.
-11
View File
@@ -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.
-11
View File
@@ -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é.
-12
View File
@@ -1,12 +0,0 @@
# v0.1.47 · अधिक भरोसेमंद अलार्म और फ़ाइलें
सारांश: हमने Android अलार्म की बुनियाद मजबूत की और फ़ोल्डर खोलने को उसका पथ बदलने से स्पष्ट रूप से अलग किया।
## सुधार
- सुरक्षित आंतरिक ध्वनि के साथ अलार्म के लिए नई नेटिव बुनियाद।
- Android exact-alarm अनुमति के बेहतर निदान।
- एक ही मिनट में बने अलार्म अब सेकंड की वजह से हटाए नहीं जाते।
- अलार्म पैनल सक्रिय अलार्म और बिना वैध अगली निष्पादन के अलार्म में अंतर करता है।
- फ़ोल्डर खोलना अब सहेजा गया पथ खोलने की कोशिश करता है; पथ बदलना अलग है।
-11
View File
@@ -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.
-11
View File
@@ -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.
-11
View File
@@ -1,11 +0,0 @@
# v0.1.47 · より信頼できるアラームとファイル
概要: Android のアラーム基盤を強化し、フォルダーを開く操作とパス変更を明確に分離しました。
## 改善点
- 安全な内部サウンドを備えた、新しいネイティブアラーム基盤を導入。
- Android の正確なアラーム権限診断を改善。
- 同じ分に作成したアラームが秒の違いで破棄されなくなりました。
- アラームパネルで、有効な次回実行があるアラームとないアラームを区別。
- フォルダーを開くは保存済みパスを開くようになり、パス変更は別操作になりました。
-11
View File
@@ -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.
-11
View File
@@ -1,11 +0,0 @@
# v0.1.47 · Более надежные будильники и файлы
Кратко: мы усилили основу будильников Android и четко разделили открытие папки и изменение её пути.
## Улучшения
- Новая нативная основа будильников с безопасным встроенным звуком.
- Улучшена диагностика разрешений Android для точных будильников.
- Будильники, созданные в ту же минуту, больше не отбрасываются из-за секунд.
- Панель будильников различает активные будильники и будильники без валидного следующего запуска.
- Открыть папку теперь пытается открыть сохраненный путь; изменение пути вынесено отдельно.
-11
View File
@@ -1,11 +0,0 @@
# v0.1.47 · 更可靠的闹钟与文件
摘要:我们强化了 Android 闹钟基础,并清晰区分了“打开文件夹”和“更改路径”。
## 改进
- 闹钟采用新的原生基础,配有安全的内置提示音。
- 改进 Android 精确闹钟权限诊断。
- 同一分钟创建的闹钟不再因秒数被丢弃。
- 闹钟面板可区分活跃闹钟与无有效下次执行的闹钟。
- “打开文件夹”现在会尝试打开已保存路径;“更改路径”独立处理。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

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.
Binary file not shown.

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.
Binary file not shown.

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.
-27
View File
@@ -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.
-30
View File
@@ -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.
-32
View File
@@ -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
-31
View File
@@ -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
-110
View File
@@ -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
View File
@@ -1 +0,0 @@
package_name(ENV["PLAY_PACKAGE_NAME"] || "es.freetimelab.pluriwave")
-25
View File
@@ -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
-2
View File
@@ -66,7 +66,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSLocationWhenInUseUsageDescription</key>
<string>PluriWave usa tu ubicacion aproximada para sugerirte emisoras cercanas.</string>
</dict>
</plist>
-6
View File
@@ -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
+117 -507
View File
@@ -1,51 +1,49 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.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_buscar.dart';
import 'pantallas/pantalla_favoritos.dart';
import 'pantallas/pantalla_ajustes.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';
import 'widgets/mini_reproductor.dart';
class PluriWaveApp extends StatelessWidget {
const PluriWaveApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => EstadoRadio()),
ChangeNotifierProvider(create: (_) => EstadoAlarmas()),
ChangeNotifierProvider(create: (_) => EstadoIdioma()),
],
child: Consumer<EstadoIdioma>(
builder:
(context, estadoIdioma, _) => MaterialApp(
title: 'PluriWave',
debugShowCheckedModeBanner: false,
theme: PluriWaveTheme.dark(),
darkTheme: PluriWaveTheme.dark(),
themeMode: ThemeMode.dark,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: estadoIdioma.localeSeleccionado,
home: const _PaginaPrincipal(),
),
return ChangeNotifierProvider(
create: (_) => EstadoRadio(),
child: MaterialApp(
title: 'PluriWave',
debugShowCheckedModeBanner: false,
theme: _buildTheme(Brightness.dark),
darkTheme: _buildTheme(Brightness.dark),
themeMode: ThemeMode.dark,
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)),
),
);
}
@@ -59,520 +57,132 @@ class _PaginaPrincipal extends StatefulWidget {
}
class _PaginaPrincipalState extends State<_PaginaPrincipal> {
static const _volumenInicialFadeInAlarmas = 0.05;
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;
static const _paginas = [
PantallaInicio(),
PantallaBuscar(),
PantallaFavoritos(),
PantallaAlarmas(),
PantallaAjustes(),
];
List<PluriNavItem> _navItems(AppLocalizations l10n) => [
PluriNavItem(glyph: PluriIconGlyph.home, label: l10n.navHome),
PluriNavItem(glyph: PluriIconGlyph.search, label: l10n.navSearch),
PluriNavItem(glyph: PluriIconGlyph.favorites, label: l10n.navFavorites),
PluriNavItem(glyph: PluriIconGlyph.alarm, label: l10n.navAlarms),
PluriNavItem(glyph: PluriIconGlyph.settings, label: l10n.navSettings),
static const _destinos = [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Inicio',
),
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
void didChangeDependencies() {
super.didChangeDependencies();
final estado = context.read<EstadoRadio>();
if (identical(_estadoSuscrito, estado) && _errorSubscription != null) {
return;
}
_errorSubscription?.cancel();
_estadoSuscrito = estado;
_errorSubscription = estado.errorStream.listen((msg) {
// Suscribir al stream de errores → SnackBar flotante
context.read<EstadoRadio>().errorStream.listen((msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
duration: const Duration(seconds: 3),
action: SnackBarAction(
label: AppLocalizations.of(context).actionOk,
onPressed: () {},
),
action: SnackBarAction(label: 'OK', 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
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriWaveScaffold(
return Scaffold(
appBar: AppBar(
title: Text(l10n.appTitle),
title: const Text('PluriWave'),
actions: [
IconButton(
icon: const Icon(Icons.bedtime_outlined),
tooltip: l10n.sleepTimer,
tooltip: 'Timer de sueño',
onPressed: () => _mostrarTimerDialog(context),
),
],
),
body: SafeArea(
top: false,
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],
body: _paginas[_indice],
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
const MiniReproductor(),
NavigationBar(
selectedIndex: _indice,
onDestinationSelected: (i) => setState(() => _indice = i),
destinations: _destinos,
),
),
],
),
bottomNavigationBar: SafeArea(
top: false,
minimum: const EdgeInsets.only(bottom: PluriLayout.compactGap),
);
}
void _mostrarTimerDialog(BuildContext context) {
final estado = context.read<EstadoRadio>();
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const MiniReproductor(),
PluriBottomNavigation(
items: _navItems(l10n),
selectedIndex: _indice,
onSelected: (i) => setState(() => _indice = i),
),
Text('Timer de sueño', style: Theme.of(ctx).textTheme.titleLarge),
const SizedBox(height: 16),
if (estado.timer.activo)
StreamBuilder<Duration>(
stream: estado.timer.tiempoRestanteStream,
builder: (ctx, snap) {
final t = snap.data ?? Duration.zero;
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(
children: [
Text(
'${h > 0 ? "${h}h " : ""}${m}m ${s}s',
style: Theme.of(ctx).textTheme.headlineMedium,
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () {
estado.cancelarTimer();
Navigator.pop(ctx);
},
child: const Text('Cancelar timer'),
),
],
);
},
)
else
Wrap(
spacing: 8,
children: [15, 30, 60, 90]
.map((min) => ActionChip(
label: Text('$min min'),
onPressed: () {
estado.iniciarTimer(min);
Navigator.pop(ctx);
},
))
.toList(),
),
],
),
),
),
);
}
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 {
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) {
showModalBottomSheet(
context: context,
showDragHandle: true,
builder:
(ctx) => Consumer<EstadoRadio>(
builder:
(ctx, estado, _) => SafeArea(
child: Padding(
padding: PluriLayout.sheetPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(ctx).sleepTimer,
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)
StreamBuilder<Duration>(
stream: estado.timer.tiempoRestanteStream,
builder: (ctx, snap) {
final restante =
snap.data ?? estado.timer.tiempoRestante;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_formatearDuracionTimer(
AppLocalizations.of(ctx),
restante,
),
style:
Theme.of(ctx).textTheme.headlineMedium,
),
const SizedBox(
height: PluriLayout.compactGap,
),
FilledButton.tonal(
onPressed: () {
estado.cancelarTimer();
Navigator.pop(ctx);
},
child: Text(
AppLocalizations.of(ctx).cancelTimer,
),
),
],
);
},
)
else
Wrap(
spacing: PluriLayout.compactGap,
runSpacing: PluriLayout.compactGap,
children: [
for (final segundos
in estado.timerSuenoPresetsSegundos)
ActionChip(
label: Text(
_formatearDuracionTimer(
AppLocalizations.of(ctx),
Duration(seconds: segundos),
),
),
onPressed: () {
estado.iniciarTimerDuracion(
Duration(seconds: segundos),
);
Navigator.pop(ctx);
},
),
ActionChip(
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(),
),
);
}
}
-357
View File
@@ -1,357 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.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,
bool iniciarAutomaticamente = true,
}) : servicio = servicio ?? ServicioAlarmas(),
android = android ?? ServicioAlarmasAndroid() {
if (iniciarAutomaticamente) {
inicializar();
}
}
final ServicioAlarmas servicio;
final PuertoAlarmasAndroid android;
List<AlarmaMusical> _alarmas = [];
List<RangoVacaciones> _vacaciones = [];
List<ExcepcionAlarma> _excepciones = [];
DiagnosticoAlarmasAndroid? _diagnostico;
Timer? _refresco;
Timer? _vigilancia;
final _alarmasVencidasController =
StreamController<AlarmaMusical>.broadcast();
final Set<String> _ejecucionesEmitidas = {};
static const _margenDisparoLocal = Duration(seconds: 45);
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}';
_ejecucionesEmitidas.add(key);
debugPrint(
'[PluriWave][alarmas] ejecucion gestionada id=${alarma.id} proxima=${proxima.toIso8601String()}',
);
}
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();
}
Future<void> _sincronizarEjecucionesGestionadasPorAndroid() async {
try {
final ejecuciones = await android.obtenerEjecucionesNativasGestionadas();
if (ejecuciones.isEmpty) return;
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');
}
}
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();
}
} catch (e) {
debugPrint('[PluriWave][alarmas] permisos android ERROR $e');
}
}
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();
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) {
_ejecucionesEmitidas.add(key);
debugPrint(
'[PluriWave][alarmas] vencida local ignorada por antigua id=${alarma.id} proxima=${proxima.toIso8601String()} retraso=${retraso.inSeconds}s',
);
continue;
}
if (_ejecucionesEmitidas.add(key)) {
debugPrint(
'[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}',
);
_alarmasVencidasController.add(alarma);
}
}
}
@override
void dispose() {
_refresco?.cancel();
_vigilancia?.cancel();
_alarmasVencidasController.close();
super.dispose();
}
}
-68
View File
@@ -1,68 +0,0 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class EstadoIdioma extends ChangeNotifier {
EstadoIdioma({SharedPreferences? sharedPreferences})
: _sharedPreferences = sharedPreferences {
_cargar();
}
static const String _keyLocale = 'idioma_manual_v1';
final SharedPreferences? _sharedPreferences;
Locale? _localeSeleccionado;
Locale? get localeSeleccionado => _localeSeleccionado;
bool get usaSistema => _localeSeleccionado == null;
Future<void> seleccionarSistema() async {
_localeSeleccionado = null;
notifyListeners();
final prefs = await _resolverPrefs();
await prefs.remove(_keyLocale);
}
Future<void> seleccionarLocale(Locale locale) async {
final tag = _serializarLocale(locale);
_localeSeleccionado = locale;
notifyListeners();
final prefs = await _resolverPrefs();
await prefs.setString(_keyLocale, tag);
}
Future<void> _cargar() async {
final prefs = await _resolverPrefs();
final localeGuardado = prefs.getString(_keyLocale);
_localeSeleccionado = _parsearLocale(localeGuardado);
notifyListeners();
}
Future<SharedPreferences> _resolverPrefs() async {
return _sharedPreferences ?? SharedPreferences.getInstance();
}
Locale? _parsearLocale(String? value) {
if (value == null || value.trim().isEmpty) return null;
final partes = value.split('_');
final languageCode = partes.first;
if (languageCode.isEmpty) return null;
final countryCode = partes.length > 1 && partes[1].isNotEmpty
? partes[1]
: null;
return Locale.fromSubtags(
languageCode: languageCode,
countryCode: countryCode,
);
}
String _serializarLocale(Locale locale) {
final countryCode = locale.countryCode;
if (countryCode == null || countryCode.isEmpty) {
return locale.languageCode;
}
return '${locale.languageCode}_$countryCode';
}
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More