diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 51a6655..04a81ea 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -6,6 +6,7 @@
+
@@ -73,6 +74,18 @@
+
+
+
+
+
+
+
+
+
+
now) {
@@ -94,6 +110,7 @@ class AlarmScheduler(private val context: Context) {
} else {
Log.d(tag, "alarm.schedule preNotice skipped id=$id")
}
+ return true
}
private fun scheduleMainAlarm(
@@ -123,6 +140,12 @@ class AlarmScheduler(private val context: Context) {
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,
@@ -147,16 +170,10 @@ class AlarmScheduler(private val context: Context) {
fun cancelAlarm(id: String) {
Log.d(tag, "alarm.cancel id=$id")
- for (slot in 1..3) {
- alarmManager.cancel(
- PendingIntent.getBroadcast(
- context,
- requestCode(id, slot),
- Intent(context, PluriWaveAlarmReceiver::class.java),
- PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
- ) ?: continue
- )
- }
+ removeScheduledAlarm(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(context).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(id)
)
@@ -176,5 +193,119 @@ class AlarmScheduler(private val context: Context) {
alarmManager.canScheduleExactAlarms()
}
+ fun reschedulePersistedAlarms() {
+ val now = System.currentTimeMillis()
+ for (id in prefs().getStringSet(KEY_IDS, emptySet()).orEmpty()) {
+ val raw = prefs().getString("$KEY_ALARM_PREFIX$id", null) ?: continue
+ try {
+ val data = JSONObject(raw)
+ val triggerAt = data.optLong("triggerAtMillis", 0L)
+ if (triggerAt <= now) {
+ Log.d(tag, "alarm.reschedule skip stale id=$id triggerAt=$triggerAt")
+ removeScheduledAlarm(id)
+ continue
+ }
+ scheduleAlarm(
+ id = id,
+ title = data.optString("title", "PluriWave"),
+ triggerAtMillis = triggerAt,
+ preNoticeAtMillis = data.optLong("preNoticeAtMillis", 0L),
+ stationName = data.optString("stationName").takeIf { it.isNotBlank() },
+ stationUrl = data.optString("stationUrl").takeIf { it.isNotBlank() },
+ fallbackSound = data.optString("fallbackSound").takeIf { it.isNotBlank() },
+ volume = data.optDouble("volume", 0.85).toFloat()
+ )
+ Log.d(tag, "alarm.reschedule OK id=$id")
+ } catch (error: Throwable) {
+ Log.e(tag, "alarm.reschedule failed id=$id", error)
+ }
+ }
+ }
+
+ private fun saveScheduledAlarm(
+ id: String,
+ title: String,
+ triggerAtMillis: Long,
+ preNoticeAtMillis: Long,
+ stationName: String?,
+ stationUrl: String?,
+ fallbackSound: String?,
+ volume: Float
+ ) {
+ val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet()
+ ids.add(id)
+ val data = JSONObject().apply {
+ put("title", title)
+ put("triggerAtMillis", triggerAtMillis)
+ put("preNoticeAtMillis", preNoticeAtMillis)
+ put("stationName", stationName)
+ put("stationUrl", stationUrl)
+ put("fallbackSound", fallbackSound)
+ put("volume", volume)
+ }
+ prefs().edit()
+ .putStringSet(KEY_IDS, ids)
+ .putString("$KEY_ALARM_PREFIX$id", data.toString())
+ .apply()
+ }
+
+ 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 prefs() = context.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 pendingFireIntent(id: String, flags: Int): PendingIntent? =
+ PendingIntent.getBroadcast(
+ context,
+ requestCode(id, 1),
+ Intent(context, PluriWaveAlarmReceiver::class.java).apply {
+ action = PluriWaveAlarmReceiver.ACTION_FIRE
+ },
+ flags or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ private fun pendingShowIntent(id: String, flags: Int): PendingIntent? =
+ PendingIntent.getActivity(
+ context,
+ requestCode(id, 2),
+ Intent(context, MainActivity::class.java).apply {
+ 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(
+ context,
+ requestCode(id, 3),
+ Intent(context, PluriWaveAlarmReceiver::class.java).apply {
+ action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
+ },
+ flags or PendingIntent.FLAG_IMMUTABLE
+ )
+
private fun requestCode(id: String, slot: Int): Int = 31 * id.hashCode() + slot
+
+ 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_"
+ }
}
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
index 1a3c291..4182d75 100644
--- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt
@@ -7,6 +7,8 @@ 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
@@ -73,7 +75,7 @@ class MainActivity : AudioServiceActivity() {
Log.w(tag, "alarm.channel scheduleAlarm invalid id=$id triggerAtMillis=$triggerAtMillis")
result.error("INVALID_ALARM", "Missing alarm id or trigger time", null)
} else {
- alarmScheduler.scheduleAlarm(
+ val scheduled = alarmScheduler.scheduleAlarm(
id,
title,
triggerAtMillis,
@@ -83,7 +85,7 @@ class MainActivity : AudioServiceActivity() {
fallbackSound,
volume
)
- result.success(null)
+ result.success(scheduled)
}
}
"cancelAlarm" -> {
@@ -92,7 +94,6 @@ class MainActivity : AudioServiceActivity() {
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
- PluriWaveAlarmService.stop(this, id)
alarmScheduler.cancelAlarm(id)
result.success(null)
}
@@ -129,6 +130,10 @@ class MainActivity : AudioServiceActivity() {
)
)
}
+ "requestExactAlarmPermission" -> {
+ Log.d(tag, "alarm.channel requestExactAlarmPermission")
+ result.success(requestExactAlarmPermission())
+ }
"getInitialAlarmIntent" -> {
val payload = alarmPayload(intent)
Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload")
@@ -153,6 +158,15 @@ class MainActivity : AudioServiceActivity() {
result.success(openDirectory(path))
}
}
+ "viewDirectory" -> {
+ val path = call.argument("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("path")
val mimeType = call.argument("mimeType") ?: "audio/*"
@@ -193,25 +207,120 @@ class MainActivity : AudioServiceActivity() {
)
}
- private fun openDirectory(path: String): Boolean {
- val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
- addFlags(
- Intent.FLAG_GRANT_READ_URI_PERMISSION or
- Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
- Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
- Intent.FLAG_GRANT_PREFIX_URI_PERMISSION
+ 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")
+ }
)
- directoryTreeUri(path)?.let { putExtra(DocumentsContract.EXTRA_INITIAL_URI, it) }
+ true
+ } catch (error: Throwable) {
+ Log.e(tag, "alarm.channel requestExactAlarmPermission failed", error)
+ false
}
+ }
+
+ private fun openDirectory(path: String): Boolean {
+ val folder = File(path)
+ if (!folder.exists()) {
+ Log.w(tag, "file_actions.openDirectory missing path=$path")
+ return false
+ }
+ if (!folder.isDirectory) {
+ Log.w(tag, "file_actions.openDirectory not directory path=$path")
+ return false
+ }
+
+ val fileProviderIntent = runCatching {
+ val uri = FileProvider.getUriForFile(
+ this,
+ "$packageName.fileprovider",
+ folder
+ )
+ Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, "resource/folder")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ }.getOrNull()
+
+ val documentIntent = Intent(Intent.ACTION_VIEW).apply {
+ directoryTreeUri(path)?.let { uri ->
+ setDataAndType(uri, DocumentsContract.Document.MIME_TYPE_DIR)
+ }
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+
+ val opened =
+ openIntentSafely(fileProviderIntent, "file_actions.openDirectory fileProvider", path) ||
+ openIntentSafely(documentIntent, "file_actions.openDirectory documents", path)
+ if (!opened) {
+ Log.w(tag, "file_actions.openDirectory unable to open path=$path")
+ }
+ return opened
+ }
+
+ private fun viewDirectory(path: String): Boolean {
+ val directory = File(path)
+ if (!directory.exists()) {
+ directory.mkdirs()
+ }
+
+ val candidates = mutableListOf()
+ 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, "file_actions.openDirectory launched path=$path")
+ Log.d(tag, "$origin launched path=$path")
true
} catch (_: ActivityNotFoundException) {
- Log.w(tag, "file_actions.openDirectory no activity for path=$path")
+ Log.w(tag, "$origin no activity for path=$path")
false
} catch (error: Throwable) {
- Log.e(tag, "file_actions.openDirectory failed path=$path", error)
+ Log.e(tag, "$origin failed path=$path", error)
false
}
}
@@ -257,6 +366,18 @@ class MainActivity : AudioServiceActivity() {
)
}
+ 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)
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt
index c64e7bb..899b983 100644
--- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt
@@ -234,7 +234,8 @@ class PluriWaveAlarmService : Service() {
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
}
try {
- context.startService(intent)
+ context.stopService(intent)
+ Log.d(TAG, "alarm.service stop requested id=$alarmId")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service stop request failed id=$alarmId", error)
}
diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveBootReceiver.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveBootReceiver.kt
new file mode 100644
index 0000000..5a82ca4
--- /dev/null
+++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveBootReceiver.kt
@@ -0,0 +1,26 @@
+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_BOOT_COMPLETED,
+ 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"
+ }
+}
diff --git a/assets/content/onboarding/ar.md b/assets/content/onboarding/ar.md
new file mode 100644
index 0000000..0984cca
--- /dev/null
+++ b/assets/content/onboarding/ar.md
@@ -0,0 +1,27 @@
+# أهلاً بك في PluriWave
+
+PluriWave هو راديوك العالمي المميز: محطات مباشرة، مفضلات منظمة، تسجيلات، معادل صوت ومنبّهات موسيقية ضمن تجربة مصممة بعناية.
+
+## راديو مباشر
+
+- ابحث عن المحطات حسب الاسم والبلد واللغة والجودة.
+- استكشف المحطات القريبة واكتشف محطات جديدة.
+- رتّب القوائم حسب الاسم أو الجودة.
+
+## موسيقى بطريقتك
+
+- احفظ المفضلات ونظّمها في مجموعات.
+- اضبط المعادل العام أو إعدادات كل محطة.
+- استخدم مؤقّت النوم بمدد مخصّصة.
+
+## التسجيلات
+
+- سجّل الراديو بدون إعادة ضغط البث الأصلي.
+- حدّد الحجم الأقصى للملف لتبقى بأمان.
+- افتح مجلد التسجيلات للمشاركة أو النقل أو التعديل.
+
+## منبّهات موسيقية
+
+- أنشئ منبّهات لمرة واحدة أو يومية أو لأيام العمل.
+- اختر محطة مفضلة وصوتاً داخلياً آمناً.
+- استخدم العطلات وتخطي التنفيذ التالي والغفوة.
diff --git a/assets/content/onboarding/bn.md b/assets/content/onboarding/bn.md
new file mode 100644
index 0000000..7bcfb5f
--- /dev/null
+++ b/assets/content/onboarding/bn.md
@@ -0,0 +1,27 @@
+# PluriWave-এ স্বাগতম
+
+PluriWave আপনার প্রিমিয়াম বিশ্ব রেডিও: লাইভ স্টেশন, গোছানো ফেভারিট, রেকর্ডিং, ইকুয়ালাইজার এবং মিউজিক অ্যালার্ম—সবই যত্নসহ তৈরি এক অভিজ্ঞতায়।
+
+## লাইভ রেডিও
+
+- নাম, দেশ, ভাষা ও মান অনুযায়ী স্টেশন খুঁজুন।
+- কাছাকাছি স্টেশন দেখুন এবং নতুন রেডিও আবিষ্কার করুন।
+- তালিকা নাম বা মান অনুযায়ী সাজান।
+
+## আপনার মতো করে সঙ্গীত
+
+- ফেভারিট সংরক্ষণ করুন এবং গ্রুপে সাজান।
+- গ্লোবাল ইকুয়ালাইজার বা স্টেশনভিত্তিক প্রিসেট ঠিক করুন।
+- নিজের মতো সময় দিয়ে স্লিপ টাইমার ব্যবহার করুন।
+
+## রেকর্ডিং
+
+- মূল স্ট্রিম রিকমপ্রেস না করে রেডিও রেকর্ড করুন।
+- নিরাপদ থাকতে সর্বোচ্চ ফাইল সাইজ সীমা দিন।
+- শেয়ার, সরানো বা সম্পাদনার জন্য রেকর্ডিং ফোল্ডার খুলুন।
+
+## মিউজিক অ্যালার্ম
+
+- একবার, প্রতিদিন বা কর্মদিবসের অ্যালার্ম তৈরি করুন।
+- প্রিয় স্টেশন ও নিরাপদ অভ্যন্তরীণ সাউন্ড বেছে নিন।
+- ছুটি, পরের রান স্কিপ এবং স্নুজ ব্যবহার করুন।
diff --git a/assets/content/onboarding/de.md b/assets/content/onboarding/de.md
new file mode 100644
index 0000000..a6e7c00
--- /dev/null
+++ b/assets/content/onboarding/de.md
@@ -0,0 +1,27 @@
+# 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.
diff --git a/assets/content/onboarding/en.md b/assets/content/onboarding/en.md
new file mode 100644
index 0000000..c050c24
--- /dev/null
+++ b/assets/content/onboarding/en.md
@@ -0,0 +1,27 @@
+# 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.
diff --git a/assets/content/onboarding/es.md b/assets/content/onboarding/es.md
new file mode 100644
index 0000000..dd30476
--- /dev/null
+++ b/assets/content/onboarding/es.md
@@ -0,0 +1,27 @@
+# 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.
diff --git a/assets/content/onboarding/fr.md b/assets/content/onboarding/fr.md
new file mode 100644
index 0000000..33750da
--- /dev/null
+++ b/assets/content/onboarding/fr.md
@@ -0,0 +1,27 @@
+# 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.
diff --git a/assets/content/onboarding/hi.md b/assets/content/onboarding/hi.md
new file mode 100644
index 0000000..3a28c68
--- /dev/null
+++ b/assets/content/onboarding/hi.md
@@ -0,0 +1,27 @@
+# PluriWave में आपका स्वागत है
+
+PluriWave आपका प्रीमियम विश्व रेडियो है: लाइव स्टेशन, व्यवस्थित पसंदीदा, रिकॉर्डिंग, इक्वलाइज़र और संगीत अलार्म एक सधे हुए अनुभव में।
+
+## लाइव रेडियो
+
+- स्टेशन को नाम, देश, भाषा और गुणवत्ता से खोजें।
+- पास के स्टेशन देखें और नए रेडियो खोजें।
+- सूचियों को नाम या गुणवत्ता के अनुसार क्रमित करें।
+
+## संगीत आपके तरीके से
+
+- पसंदीदा सहेजें और उन्हें समूहों में व्यवस्थित करें।
+- ग्लोबल इक्वलाइज़र या स्टेशन-विशिष्ट प्रीसेट समायोजित करें।
+- अपनी पसंद की अवधि वाला स्लीप टाइमर इस्तेमाल करें।
+
+## रिकॉर्डिंग
+
+- मूल स्ट्रीम को फिर से कंप्रेस किए बिना रेडियो रिकॉर्ड करें।
+- सुरक्षित रहने के लिए अधिकतम फ़ाइल आकार सीमित करें।
+- फ़ाइलें साझा करने, स्थानांतरित करने या संपादित करने के लिए रिकॉर्डिंग फ़ोल्डर खोलें।
+
+## संगीत अलार्म
+
+- एक बार, रोज़ाना या कार्यदिवस अलार्म बनाएँ।
+- पसंदीदा स्टेशन और सुरक्षित आंतरिक ध्वनि चुनें।
+- छुट्टियाँ, अगला निष्पादन छोड़ना और स्नूज़ का उपयोग करें।
diff --git a/assets/content/onboarding/id.md b/assets/content/onboarding/id.md
new file mode 100644
index 0000000..367f420
--- /dev/null
+++ b/assets/content/onboarding/id.md
@@ -0,0 +1,27 @@
+# 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.
diff --git a/assets/content/onboarding/it.md b/assets/content/onboarding/it.md
new file mode 100644
index 0000000..097af86
--- /dev/null
+++ b/assets/content/onboarding/it.md
@@ -0,0 +1,27 @@
+# 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.
diff --git a/assets/content/onboarding/ja.md b/assets/content/onboarding/ja.md
new file mode 100644
index 0000000..4a5a82e
--- /dev/null
+++ b/assets/content/onboarding/ja.md
@@ -0,0 +1,27 @@
+# PluriWave へようこそ
+
+PluriWave は、ライブ局、お気に入り整理、録音、イコライザー、音楽アラームを備えた高品質なワールドラジオです。
+
+## ライブラジオ
+
+- 名前、国、言語、音質で局を検索できます。
+- 近くの局を探して新しいラジオを見つけられます。
+- リストを名前または音質で並べ替えできます。
+
+## あなた好みの音楽体験
+
+- お気に入りを保存してグループで整理できます。
+- 全体イコライザーや局ごとのプリセットを調整できます。
+- 時間を指定できるスリープタイマーを使えます。
+
+## 録音
+
+- 元のストリームを再圧縮せずに録音できます。
+- 最大ファイルサイズを制限して安全に使えます。
+- 録音フォルダーを開いて共有・移動・編集できます。
+
+## 音楽アラーム
+
+- 1回のみ、毎日、平日のアラームを作成できます。
+- お気に入り局と安全な内蔵サウンドを選べます。
+- 休日設定、次回スキップ、スヌーズに対応しています。
diff --git a/assets/content/onboarding/pt.md b/assets/content/onboarding/pt.md
new file mode 100644
index 0000000..c9b12e4
--- /dev/null
+++ b/assets/content/onboarding/pt.md
@@ -0,0 +1,27 @@
+# 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.
diff --git a/assets/content/onboarding/ru.md b/assets/content/onboarding/ru.md
new file mode 100644
index 0000000..974f1e0
--- /dev/null
+++ b/assets/content/onboarding/ru.md
@@ -0,0 +1,27 @@
+# Добро пожаловать в PluriWave
+
+PluriWave — ваше премиальное мировое радио: прямые станции, организованные избранные, записи, эквалайзер и музыкальные будильники в продуманном интерфейсе.
+
+## Прямое радио
+
+- Ищите станции по названию, стране, языку и качеству.
+- Изучайте ближайшие станции и открывайте новое радио.
+- Сортируйте списки по названию или качеству.
+
+## Музыка по-вашему
+
+- Сохраняйте избранное и организуйте его по группам.
+- Настраивайте глобальный эквалайзер или пресеты для станций.
+- Используйте таймер сна с нужной длительностью.
+
+## Записи
+
+- Записывайте радио без повторного сжатия исходного потока.
+- Ограничивайте максимальный размер файла для безопасности.
+- Открывайте папку записей, чтобы делиться, перемещать и редактировать файлы.
+
+## Музыкальные будильники
+
+- Создавайте разовые, ежедневные или будничные будильники.
+- Выбирайте любимую станцию и безопасный встроенный звук.
+- Используйте праздники, пропуск следующего запуска и отложенный сигнал.
diff --git a/assets/content/onboarding/zh.md b/assets/content/onboarding/zh.md
new file mode 100644
index 0000000..d857239
--- /dev/null
+++ b/assets/content/onboarding/zh.md
@@ -0,0 +1,27 @@
+# 欢迎使用 PluriWave
+
+PluriWave 是你的高品质全球电台:直播电台、分组收藏、录音、均衡器和音乐闹钟,体验精致流畅。
+
+## 直播电台
+
+- 按名称、国家、语言和音质搜索电台。
+- 探索附近电台,发现新的广播内容。
+- 按名称或音质排序列表。
+
+## 按你的方式听音乐
+
+- 保存收藏并按分组整理。
+- 调整全局均衡器或单电台预设。
+- 使用可自定义时长的睡眠定时器。
+
+## 录音
+
+- 录制电台时不重新压缩原始流。
+- 限制最大文件大小,更安全省心。
+- 打开录音文件夹以分享、移动或编辑文件。
+
+## 音乐闹钟
+
+- 创建一次性、每日或工作日闹钟。
+- 选择喜爱的电台和安全的内置提示音。
+- 支持假期、跳过下次执行和贪睡。
diff --git a/assets/content/updates/ar/0.1.47.md b/assets/content/updates/ar/0.1.47.md
new file mode 100644
index 0000000..db23d11
--- /dev/null
+++ b/assets/content/updates/ar/0.1.47.md
@@ -0,0 +1,11 @@
+# v0.1.47 · منبّهات وملفات أكثر موثوقية
+
+الملخّص: عززنا أساس منبّهات Android وفصلنا بوضوح بين فتح المجلد وتغيير مساره.
+
+## التحسينات
+
+- أساس أصلي جديد للمنبّهات مع صوت داخلي آمن.
+- تشخيص أفضل لأذونات Android الخاصة بالمنبّهات الدقيقة.
+- المنبّهات التي تُنشأ في الدقيقة نفسها لم تعد تُستبعد بسبب الثواني.
+- لوحة المنبّهات تميّز بين المنبّهات النشطة والمنبّهات بلا تنفيذ تالٍ صالح.
+- فتح المجلد يحاول الآن فتح المسار المحفوظ؛ تغيير المسار أصبح منفصلاً.
diff --git a/assets/content/updates/bn/0.1.47.md b/assets/content/updates/bn/0.1.47.md
new file mode 100644
index 0000000..c572968
--- /dev/null
+++ b/assets/content/updates/bn/0.1.47.md
@@ -0,0 +1,11 @@
+# v0.1.47 · আরও নির্ভরযোগ্য অ্যালার্ম ও ফাইল
+
+সারাংশ: আমরা Android অ্যালার্মের ভিত্তি শক্ত করেছি এবং ফোল্ডার খোলা ও পথ পরিবর্তনকে স্পষ্টভাবে আলাদা করেছি।
+
+## উন্নতি
+
+- নিরাপদ অভ্যন্তরীণ সাউন্ডসহ অ্যালার্মের জন্য নতুন নেটিভ ভিত্তি।
+- Android exact-alarm অনুমতির উন্নত ডায়াগনস্টিক।
+- একই মিনিটে তৈরি অ্যালার্ম এখন সেকেন্ডের কারণে বাদ পড়ে না।
+- অ্যালার্ম প্যানেল সক্রিয় অ্যালার্ম ও বৈধ পরের রানবিহীন অ্যালার্ম আলাদা করে।
+- ফোল্ডার খোলা এখন সংরক্ষিত পথ খোলার চেষ্টা করে; পথ বদল আলাদা করা হয়েছে।
diff --git a/assets/content/updates/de/0.1.47.md b/assets/content/updates/de/0.1.47.md
new file mode 100644
index 0000000..f91b79b
--- /dev/null
+++ b/assets/content/updates/de/0.1.47.md
@@ -0,0 +1,11 @@
+# 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.
diff --git a/assets/content/updates/en/0.1.47.md b/assets/content/updates/en/0.1.47.md
new file mode 100644
index 0000000..12f9c29
--- /dev/null
+++ b/assets/content/updates/en/0.1.47.md
@@ -0,0 +1,11 @@
+# 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.
diff --git a/assets/content/updates/es/0.1.47.md b/assets/content/updates/es/0.1.47.md
new file mode 100644
index 0000000..c2ccc57
--- /dev/null
+++ b/assets/content/updates/es/0.1.47.md
@@ -0,0 +1,11 @@
+# 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.
diff --git a/assets/content/updates/fr/0.1.47.md b/assets/content/updates/fr/0.1.47.md
new file mode 100644
index 0000000..65123e1
--- /dev/null
+++ b/assets/content/updates/fr/0.1.47.md
@@ -0,0 +1,11 @@
+# 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é.
diff --git a/assets/content/updates/hi/0.1.47.md b/assets/content/updates/hi/0.1.47.md
new file mode 100644
index 0000000..cc1c8ec
--- /dev/null
+++ b/assets/content/updates/hi/0.1.47.md
@@ -0,0 +1,12 @@
+# v0.1.47 · अधिक भरोसेमंद अलार्म और फ़ाइलें
+
+सारांश: हमने Android अलार्म की बुनियाद मजबूत की और फ़ोल्डर खोलने को उसका पथ बदलने से स्पष्ट रूप से अलग किया।
+
+## सुधार
+
+- सुरक्षित आंतरिक ध्वनि के साथ अलार्म के लिए नई नेटिव बुनियाद।
+- Android exact-alarm अनुमति के बेहतर निदान।
+- एक ही मिनट में बने अलार्म अब सेकंड की वजह से हटाए नहीं जाते।
+- अलार्म पैनल सक्रिय अलार्म और बिना वैध अगली निष्पादन के अलार्म में अंतर करता है।
+- फ़ोल्डर खोलना अब सहेजा गया पथ खोलने की कोशिश करता है; पथ बदलना अलग है।
+
diff --git a/assets/content/updates/id/0.1.47.md b/assets/content/updates/id/0.1.47.md
new file mode 100644
index 0000000..b6f7a5d
--- /dev/null
+++ b/assets/content/updates/id/0.1.47.md
@@ -0,0 +1,11 @@
+# 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.
diff --git a/assets/content/updates/it/0.1.47.md b/assets/content/updates/it/0.1.47.md
new file mode 100644
index 0000000..715b136
--- /dev/null
+++ b/assets/content/updates/it/0.1.47.md
@@ -0,0 +1,11 @@
+# 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.
diff --git a/assets/content/updates/ja/0.1.47.md b/assets/content/updates/ja/0.1.47.md
new file mode 100644
index 0000000..227f12a
--- /dev/null
+++ b/assets/content/updates/ja/0.1.47.md
@@ -0,0 +1,11 @@
+# v0.1.47 · より信頼できるアラームとファイル
+
+概要: Android のアラーム基盤を強化し、フォルダーを開く操作とパス変更を明確に分離しました。
+
+## 改善点
+
+- 安全な内部サウンドを備えた、新しいネイティブアラーム基盤を導入。
+- Android の正確なアラーム権限診断を改善。
+- 同じ分に作成したアラームが秒の違いで破棄されなくなりました。
+- アラームパネルで、有効な次回実行があるアラームとないアラームを区別。
+- フォルダーを開くは保存済みパスを開くようになり、パス変更は別操作になりました。
diff --git a/assets/content/updates/pt/0.1.47.md b/assets/content/updates/pt/0.1.47.md
new file mode 100644
index 0000000..9509de6
--- /dev/null
+++ b/assets/content/updates/pt/0.1.47.md
@@ -0,0 +1,11 @@
+# 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.
diff --git a/assets/content/updates/ru/0.1.47.md b/assets/content/updates/ru/0.1.47.md
new file mode 100644
index 0000000..8d759e8
--- /dev/null
+++ b/assets/content/updates/ru/0.1.47.md
@@ -0,0 +1,11 @@
+# v0.1.47 · Более надежные будильники и файлы
+
+Кратко: мы усилили основу будильников Android и четко разделили открытие папки и изменение её пути.
+
+## Улучшения
+
+- Новая нативная основа будильников с безопасным встроенным звуком.
+- Улучшена диагностика разрешений Android для точных будильников.
+- Будильники, созданные в ту же минуту, больше не отбрасываются из-за секунд.
+- Панель будильников различает активные будильники и будильники без валидного следующего запуска.
+- Открыть папку теперь пытается открыть сохраненный путь; изменение пути вынесено отдельно.
diff --git a/assets/content/updates/zh/0.1.47.md b/assets/content/updates/zh/0.1.47.md
new file mode 100644
index 0000000..6621722
--- /dev/null
+++ b/assets/content/updates/zh/0.1.47.md
@@ -0,0 +1,11 @@
+# v0.1.47 · 更可靠的闹钟与文件
+
+摘要:我们强化了 Android 闹钟基础,并清晰区分了“打开文件夹”和“更改路径”。
+
+## 改进
+
+- 闹钟采用新的原生基础,配有安全的内置提示音。
+- 改进 Android 精确闹钟权限诊断。
+- 同一分钟创建的闹钟不再因秒数被丢弃。
+- 闹钟面板可区分活跃闹钟与无有效下次执行的闹钟。
+- “打开文件夹”现在会尝试打开已保存路径;“更改路径”独立处理。
diff --git a/lib/app.dart b/lib/app.dart
index 8d08c94..ba1d082 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -16,6 +16,7 @@ import 'tema/pluriwave_theme.dart';
import 'widgets/pluri_bottom_navigation.dart';
import 'widgets/pluri_icon.dart';
import 'widgets/pluri_layout.dart';
+import 'widgets/pluri_onboarding_dialog.dart';
import 'widgets/pluri_wave_scaffold.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'servicios/servicio_alarmas_android.dart';
@@ -64,6 +65,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
EstadoRadio? _estadoSuscrito;
bool _alarmaInicialProcesada = false;
bool _alarmaSonandoActiva = false;
+ bool _onboardingInicialSolicitado = false;
String? _alarmaSonandoId;
static const _paginas = [
@@ -120,6 +122,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
_alarmaInicialProcesada = true;
unawaited(_procesarAlarmaInicial(alarmas));
}
+ if (!_onboardingInicialSolicitado) {
+ _onboardingInicialSolicitado = true;
+ unawaited(_mostrarOnboardingInicial());
+ }
}
@override
@@ -196,9 +202,17 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
}
}
+ Future _mostrarOnboardingInicial() async {
+ await Future.delayed(const Duration(milliseconds: 900));
+ if (!mounted || _alarmaSonandoActiva) return;
+ await PluriOnboardingDialog.mostrarSiProcede(context);
+ }
+
Future _abrirAlarmaSonando(EventoAlarmaAndroid evento) async {
final estado = context.read();
- await estado.refrescarProgramacion();
+ if (estado.alarmas.isEmpty) {
+ await estado.cargarPersistidasSinRecalcular();
+ }
AlarmaMusical? alarma;
for (final item in estado.alarmas) {
if (item.id == evento.alarmaId) {
@@ -206,7 +220,12 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
break;
}
}
- if (alarma == null || !mounted) return;
+ if (alarma == null || !mounted) {
+ debugPrint(
+ '[PluriWave][alarmas] evento sin alarma persistida id=${evento.alarmaId} accion=${evento.accion}',
+ );
+ return;
+ }
if (evento.accion.endsWith('.SKIP_NEXT')) {
await estado.saltarProxima(alarma.id);
if (!mounted) return;
diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart
index b0c4807..4f9e7ec 100644
--- a/lib/estado/estado_alarmas.dart
+++ b/lib/estado/estado_alarmas.dart
@@ -27,7 +27,8 @@ class EstadoAlarmas extends ChangeNotifier {
DiagnosticoAlarmasAndroid? _diagnostico;
Timer? _refresco;
Timer? _vigilancia;
- final _alarmasVencidasController = StreamController.broadcast();
+ final _alarmasVencidasController =
+ StreamController.broadcast();
final Set _ejecucionesEmitidas = {};
static const _margenDisparoLocal = Duration(seconds: 45);
bool _cargando = false;
@@ -57,7 +58,9 @@ class EstadoAlarmas extends ChangeNotifier {
try {
final config = await servicio.recalcularTodas();
_aplicar(config);
- debugPrint('[PluriWave][alarmas] cargadas=${_alarmas.length} vacaciones=${_vacaciones.length} excepciones=${_excepciones.length}');
+ debugPrint(
+ '[PluriWave][alarmas] cargadas=${_alarmas.length} vacaciones=${_vacaciones.length} excepciones=${_excepciones.length}',
+ );
await _sincronizarTodas();
await cargarDiagnostico();
_activarRefresco();
@@ -71,16 +74,19 @@ class EstadoAlarmas extends ChangeNotifier {
}
Future guardarAlarma(AlarmaMusical alarma) async {
- debugPrint('[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}');
+ debugPrint(
+ '[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}',
+ );
final config = await servicio.guardarAlarma(alarma);
_aplicar(config);
try {
final guardada = _alarmas.firstWhere((a) => a.id == alarma.id);
- debugPrint('[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}');
+ debugPrint(
+ '[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}',
+ );
await android.programar(guardada);
} catch (e) {
- _error =
- 'Alarma guardada, pero Android no pudo programarla todavía: $e';
+ _error = 'Alarma guardada, pero Android no pudo programarla todavía: $e';
}
notifyListeners();
}
@@ -96,6 +102,12 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners();
}
+ Future cargarPersistidasSinRecalcular() async {
+ final config = await servicio.cargar();
+ _aplicar(config);
+ notifyListeners();
+ }
+
void marcarEjecucionGestionada(AlarmaMusical alarma) {
final proxima = alarma.proximaEjecucion;
if (proxima == null) return;
@@ -110,6 +122,7 @@ class EstadoAlarmas extends ChangeNotifier {
debugPrint('[PluriWave][alarmas] eliminar id=$id');
final config = await servicio.eliminarAlarma(id);
_aplicar(config);
+ await android.detenerSonidoNativo(id);
await android.cancelar(id);
notifyListeners();
}
@@ -136,7 +149,9 @@ class EstadoAlarmas extends ChangeNotifier {
}
Future guardarVacaciones(List vacaciones) async {
- debugPrint('[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}');
+ debugPrint(
+ '[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}',
+ );
final config = await servicio.guardarVacaciones(vacaciones);
_aplicar(config);
await _sincronizarTodas();
@@ -145,7 +160,9 @@ class EstadoAlarmas extends ChangeNotifier {
Future posponerAlarma(AlarmaMusical alarma, int minutos) async {
final proxima = DateTime.now().add(Duration(minutes: minutos));
- debugPrint('[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos proxima=${proxima.toIso8601String()}');
+ debugPrint(
+ '[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos proxima=${proxima.toIso8601String()}',
+ );
await android.ocultarNotificacionAlarma(alarma.id);
await android.programar(alarma.copyWith(proximaEjecucion: proxima));
}
@@ -184,7 +201,9 @@ class EstadoAlarmas extends ChangeNotifier {
}
Future _sincronizarTodas() async {
- debugPrint('[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}');
+ debugPrint(
+ '[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}',
+ );
for (final alarma in _alarmas) {
await android.programar(alarma);
}
@@ -224,7 +243,9 @@ class EstadoAlarmas extends ChangeNotifier {
continue;
}
if (_ejecucionesEmitidas.add(key)) {
- debugPrint('[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}');
+ debugPrint(
+ '[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}',
+ );
_alarmasVencidasController.add(alarma);
}
}
diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart
index fc3303a..504643e 100644
--- a/lib/estado/estado_radio.dart
+++ b/lib/estado/estado_radio.dart
@@ -121,7 +121,8 @@ class EstadoRadio extends ChangeNotifier {
List get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda);
List get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas);
List get listaFavoritos => _ordenarEmisoras(_listaFavoritos);
- List get gruposFavoritos => List.unmodifiable(_gruposFavoritos);
+ List get gruposFavoritos =>
+ List.unmodifiable(_gruposFavoritos);
List get emisorasCustom => _ordenarEmisoras(_emisorasCustom);
bool get cargandoPopulares => _cargandoPopulares;
bool get cargandoBusqueda => _cargandoBusqueda;
@@ -633,7 +634,7 @@ class EstadoRadio extends ChangeNotifier {
await Directory(ruta).create(recursive: true);
if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod(
- 'openDirectory',
+ 'viewDirectory',
{'path': ruta},
);
return abierto ?? false;
@@ -650,13 +651,10 @@ class EstadoRadio extends ChangeNotifier {
}
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}');
if (!kIsWeb && Platform.isAndroid) {
- final abierto = await _fileActionsChannel.invokeMethod(
- 'openFile',
- {
- 'path': archivo.path,
- 'mimeType': 'audio/*',
- },
- );
+ final abierto = await _fileActionsChannel.invokeMethod('openFile', {
+ 'path': archivo.path,
+ 'mimeType': 'audio/*',
+ });
return abierto ?? false;
}
return launchUrl(
diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart
index 6996782..963530e 100644
--- a/lib/pantallas/pantalla_ajustes.dart
+++ b/lib/pantallas/pantalla_ajustes.dart
@@ -18,6 +18,7 @@ import '../widgets/ecualizador_widget.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
+import '../widgets/pluri_onboarding_dialog.dart';
import '../widgets/pluri_premium_widgets.dart';
class PantallaAjustes extends StatelessWidget {
@@ -225,7 +226,6 @@ class _SeccionGrabaciones extends StatelessWidget {
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
- onTap: () => _seleccionarRuta(context),
),
),
Wrap(
@@ -353,7 +353,8 @@ class _SeccionTimerSueno extends StatelessWidget {
icon: const Icon(Icons.restore_rounded),
label: Text(l10n.timerSectionRestoreRecommended),
onPressed:
- () => context.read().restaurarTimerSuenoPresets(),
+ () =>
+ context.read().restaurarTimerSuenoPresets(),
),
),
],
@@ -387,8 +388,7 @@ class _SeccionIdioma extends StatelessWidget {
final l10n = AppLocalizations.of(context);
final estadoIdioma = context.watch();
final locale = estadoIdioma.localeSeleccionado;
- final valorActual =
- locale == null ? _codigoSistema : _codigoLocale(locale);
+ final valorActual = locale == null ? _codigoSistema : _codigoLocale(locale);
return PluriGlassSurface(
child: Column(
@@ -479,7 +479,8 @@ class _FormularioDuracionTimer extends StatefulWidget {
const _FormularioDuracionTimer();
@override
- State<_FormularioDuracionTimer> createState() => _FormularioDuracionTimerState();
+ State<_FormularioDuracionTimer> createState() =>
+ _FormularioDuracionTimerState();
}
class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
@@ -506,9 +507,9 @@ class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
seconds: _leer(_segundosCtrl),
);
if (duracion <= Duration.zero) {
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.durationGreaterThanZero)),
- );
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(l10n.durationGreaterThanZero)));
return;
}
Navigator.pop(context, duracion);
@@ -593,7 +594,9 @@ class _SeccionEcualizador extends StatelessWidget {
const Spacer(),
Chip(
label: Text(
- estado.ecualizadorActivo ? l10n.equalizerActive : l10n.equalizerDisabled,
+ estado.ecualizadorActivo
+ ? l10n.equalizerActive
+ : l10n.equalizerDisabled,
),
visualDensity: VisualDensity.compact,
),
@@ -701,7 +704,10 @@ class _SeccionOrdenListas extends StatelessWidget {
class _SeccionGruposFavoritos extends StatelessWidget {
const _SeccionGruposFavoritos();
- Future _editarGrupo(BuildContext context, [GrupoFavoritos? grupo]) async {
+ Future _editarGrupo(
+ BuildContext context, [
+ GrupoFavoritos? grupo,
+ ]) async {
final l10n = AppLocalizations.of(context);
final controller = TextEditingController(text: grupo?.nombre ?? '');
final nombre = await showModalBottomSheet(
@@ -717,7 +723,9 @@ class _SeccionGruposFavoritos extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
- grupo == null ? l10n.favoriteGroupsAdd : l10n.favoriteGroupsEdit,
+ grupo == null
+ ? l10n.favoriteGroupsAdd
+ : l10n.favoriteGroupsEdit,
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: 16),
@@ -756,17 +764,26 @@ class _SeccionGruposFavoritos extends StatelessWidget {
}
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(grupo == null ? l10n.favoriteGroupsCreated : l10n.favoriteGroupsUpdated)),
+ SnackBar(
+ content: Text(
+ grupo == null
+ ? l10n.favoriteGroupsCreated
+ : l10n.favoriteGroupsUpdated,
+ ),
+ ),
);
}
- Future _eliminarGrupo(BuildContext context, GrupoFavoritos grupo) async {
+ Future _eliminarGrupo(
+ BuildContext context,
+ GrupoFavoritos grupo,
+ ) async {
final l10n = AppLocalizations.of(context);
await context.read().eliminarGrupoFavoritos(grupo.id);
if (!context.mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text(l10n.favoriteGroupsDeleted)),
- );
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(l10n.favoriteGroupsDeleted)));
}
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
@@ -808,24 +825,28 @@ class _SeccionGruposFavoritos extends StatelessWidget {
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
),
title: Text(_nombreVisible(l10n, grupo)),
- subtitle: grupo.esSinAsignar ? Text(l10n.favoriteGroupsProtectedHint) : null,
- trailing: grupo.esSinAsignar
- ? null
- : Wrap(
- spacing: 4,
- children: [
- IconButton(
- tooltip: l10n.favoriteGroupsEdit,
- icon: const Icon(Icons.edit_rounded),
- onPressed: () => _editarGrupo(context, grupo),
- ),
- IconButton(
- tooltip: l10n.favoriteGroupsDelete,
- icon: const Icon(Icons.delete_outline_rounded),
- onPressed: () => _eliminarGrupo(context, grupo),
- ),
- ],
- ),
+ subtitle:
+ grupo.esSinAsignar
+ ? Text(l10n.favoriteGroupsProtectedHint)
+ : null,
+ trailing:
+ grupo.esSinAsignar
+ ? null
+ : Wrap(
+ spacing: 4,
+ children: [
+ IconButton(
+ tooltip: l10n.favoriteGroupsEdit,
+ icon: const Icon(Icons.edit_rounded),
+ onPressed: () => _editarGrupo(context, grupo),
+ ),
+ IconButton(
+ tooltip: l10n.favoriteGroupsDelete,
+ icon: const Icon(Icons.delete_outline_rounded),
+ onPressed: () => _eliminarGrupo(context, grupo),
+ ),
+ ],
+ ),
),
],
),
@@ -911,7 +932,10 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
icon: const Icon(Icons.play_arrow_rounded),
label: Text(l10n.preferredStationPlay),
onPressed:
- () => context.read().reproducirEmisoraPreferida(),
+ () =>
+ context
+ .read()
+ .reproducirEmisoraPreferida(),
),
),
],
@@ -925,7 +949,9 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
estado.listaFavoritos.isNotEmpty
? estado.listaFavoritos
: estado.emisorasDisponiblesPreferencia;
- final mapa = {for (final emisora in base) emisora.uuid: emisora};
+ final mapa = {
+ for (final emisora in base) emisora.uuid: emisora,
+ };
if (preferida != null) {
mapa[preferida.uuid] = preferida;
}
@@ -1055,7 +1081,12 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
Widget build(BuildContext context) {
final bottom = MediaQuery.of(context).viewInsets.bottom;
return Padding(
- padding: EdgeInsets.fromLTRB(PluriLayout.horizontal, PluriLayout.horizontal, PluriLayout.horizontal, PluriLayout.horizontal + bottom),
+ padding: EdgeInsets.fromLTRB(
+ PluriLayout.horizontal,
+ PluriLayout.horizontal,
+ PluriLayout.horizontal,
+ PluriLayout.horizontal + bottom,
+ ),
child: Form(
key: _formKey,
child: Column(
@@ -1089,7 +1120,9 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
),
keyboardType: TextInputType.url,
validator: (v) {
- if (v == null || v.trim().isEmpty) return AppLocalizations.of(context).requiredField;
+ if (v == null || v.trim().isEmpty) {
+ return AppLocalizations.of(context).requiredField;
+ }
final uri = Uri.tryParse(v.trim());
if (uri == null || !uri.hasScheme) return 'URL no válida';
return null;
@@ -1143,9 +1176,13 @@ class _SeccionBackup extends StatelessWidget {
);
} catch (e) {
if (context.mounted) {
- ScaffoldMessenger.of(
- context,
- ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).backupExportError(e.toString()))));
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ AppLocalizations.of(context).backupExportError(e.toString()),
+ ),
+ ),
+ );
}
}
}
@@ -1197,9 +1234,13 @@ class _SeccionBackup extends StatelessWidget {
}
} catch (e) {
if (context.mounted) {
- ScaffoldMessenger.of(
- context,
- ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).backupImportError(e.toString()))));
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ AppLocalizations.of(context).backupImportError(e.toString()),
+ ),
+ ),
+ );
}
}
}
@@ -1264,7 +1305,9 @@ class _SeccionInfo extends StatelessWidget {
variant: PluriIconVariant.filled,
),
title: const Text('PluriWave'),
- subtitle: Text(AppLocalizations.of(ctx).appVersionSubtitle(version)),
+ subtitle: Text(
+ AppLocalizations.of(ctx).appVersionSubtitle(version),
+ ),
);
},
),
@@ -1274,18 +1317,30 @@ class _SeccionInfo extends StatelessWidget {
(ctx, snap) => ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.favorite_outline),
- title: Text(AppLocalizations.of(ctx).savedFavoritesTitle),
+ title: Text(
+ AppLocalizations.of(ctx).savedFavoritesTitle,
+ ),
trailing: Text(
snap.data?.toString() ?? '—',
style: Theme.of(ctx).textTheme.bodyLarge,
),
),
),
+ ListTile(
+ contentPadding: EdgeInsets.zero,
+ leading: const Icon(Icons.help_outline_rounded),
+ title: Text(_helpTitle(ctx)),
+ subtitle: Text(_helpSubtitle(ctx)),
+ trailing: const Icon(Icons.chevron_right_rounded),
+ onTap: () => PluriOnboardingDialog.mostrar(ctx),
+ ),
ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.verified_outlined),
title: Text(AppLocalizations.of(ctx).stationFilterTitle),
- subtitle: Text(AppLocalizations.of(ctx).stationFilterSubtitle),
+ subtitle: Text(
+ AppLocalizations.of(ctx).stationFilterSubtitle,
+ ),
trailing: const Icon(Icons.check_circle, color: Colors.green),
),
const ListTile(
@@ -1302,6 +1357,28 @@ class _SeccionInfo extends StatelessWidget {
}
}
+String _helpTitle(BuildContext context) => switch (Localizations.localeOf(
+ context,
+).languageCode) {
+ 'es' => 'Ayuda y tutorial',
+ 'fr' => 'Aide et tutoriel',
+ 'de' => 'Hilfe und Tutorial',
+ 'it' => 'Aiuto e tutorial',
+ 'pt' => 'Ajuda e tutorial',
+ _ => 'Help and tutorial',
+};
+
+String _helpSubtitle(BuildContext context) => switch (Localizations.localeOf(
+ context,
+).languageCode) {
+ 'es' => 'Repasá funciones, consejos y novedades de PluriWave.',
+ 'fr' => 'Revoyez les fonctions, conseils et nouveautés de PluriWave.',
+ 'de' => 'Funktionen, Tipps und Neuigkeiten von PluriWave ansehen.',
+ 'it' => 'Rivedi funzioni, consigli e novità di PluriWave.',
+ 'pt' => 'Revê funções, dicas e novidades do PluriWave.',
+ _ => 'Review PluriWave features, tips and what’s new.',
+};
+
String _formatearDuracionTimer(Duration duracion) {
final horas = duracion.inHours;
final minutos = duracion.inMinutes.remainder(60);
diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart
index 460c601..14653f7 100644
--- a/lib/pantallas/pantalla_alarmas.dart
+++ b/lib/pantallas/pantalla_alarmas.dart
@@ -1,4 +1,4 @@
-import 'package:flutter/material.dart';
+import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
@@ -58,7 +58,10 @@ class PantallaAlarmas extends StatelessWidget {
);
}
- Future _abrirEditor(BuildContext context, {AlarmaMusical? alarma}) async {
+ Future _abrirEditor(
+ BuildContext context, {
+ AlarmaMusical? alarma,
+ }) async {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -77,6 +80,10 @@ class _PanelProximaAlarma extends StatelessWidget {
@override
Widget build(BuildContext context) {
final proxima = estado.proximaAlarma;
+ final activasSinProxima =
+ estado.alarmas
+ .where((a) => a.activa && a.proximaEjecucion == null)
+ .length;
return PluriGlassSurface(
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28),
child: Row(
@@ -88,7 +95,11 @@ class _PanelProximaAlarma extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
- proxima == null ? 'Sin alarmas activas' : 'Próxima alarma',
+ proxima == null
+ ? activasSinProxima > 0
+ ? 'Alarmas activas sin próxima ejecución'
+ : 'Sin alarmas activas'
+ : 'Próxima alarma',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
@@ -96,7 +107,9 @@ class _PanelProximaAlarma extends StatelessWidget {
const SizedBox(height: 4),
Text(
proxima == null
- ? 'Creá una alarma y PluriWave calculará la siguiente ejecución automáticamente.'
+ ? activasSinProxima > 0
+ ? 'Hay $activasSinProxima alarma(s) activas, pero ahora mismo no tienen una fecha futura válida. Revisá fecha, días y vacaciones.'
+ : 'Creá una alarma y PluriWave calculará la siguiente ejecución automáticamente.'
: '${proxima.nombre} · ${_fechaHora(proxima.proximaEjecucion!)}',
),
],
@@ -125,7 +138,10 @@ class _TarjetaAlarma extends StatelessWidget {
children: [
Row(
children: [
- const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 64),
+ const _AssetIcon(
+ 'assets/icons/alarmas/alarm_music.png',
+ size: 64,
+ ),
const SizedBox(width: 12),
Expanded(
child: Column(
@@ -133,7 +149,9 @@ class _TarjetaAlarma extends StatelessWidget {
children: [
Text(
_hora(alarma),
- style: Theme.of(context).textTheme.headlineMedium?.copyWith(
+ style: Theme.of(
+ context,
+ ).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -1,
),
@@ -153,13 +171,20 @@ class _TarjetaAlarma extends StatelessWidget {
spacing: 8,
runSpacing: 8,
children: [
- _InfoChip(icon: Icons.repeat_rounded, label: _programacion(alarma)),
- _InfoChip(icon: Icons.snooze_rounded, label: '${alarma.snoozeMinutos} min'),
+ _InfoChip(
+ icon: Icons.repeat_rounded,
+ label: _programacion(alarma),
+ ),
+ _InfoChip(
+ icon: Icons.snooze_rounded,
+ label: '${alarma.snoozeMinutos} min',
+ ),
_InfoChip(
icon: Icons.beach_access_rounded,
- label: alarma.sonarEnVacaciones
- ? 'Suena en vacaciones'
- : 'Pausa en vacaciones',
+ label:
+ alarma.sonarEnVacaciones
+ ? 'Suena en vacaciones'
+ : 'Pausa en vacaciones',
),
_InfoChip(
icon: Icons.volume_up_rounded,
@@ -171,7 +196,8 @@ class _TarjetaAlarma extends StatelessWidget {
if (alarma.proximaEjecucion != null)
_NoticeLine(
icon: Icons.event_available_rounded,
- text: 'Siguiente ejecución: ${_fechaHora(alarma.proximaEjecucion!)}',
+ text:
+ 'Siguiente ejecución: ${_fechaHora(alarma.proximaEjecucion!)}',
)
else
const _NoticeLine(
@@ -182,7 +208,8 @@ class _TarjetaAlarma extends StatelessWidget {
const SizedBox(height: 6),
_NoticeLine(
icon: Icons.skip_next_rounded,
- text: 'Una ejecución fue omitida: ${_fechaHora(excepcion.ejecucion)}.',
+ text:
+ 'Una ejecución fue omitida: ${_fechaHora(excepcion.ejecucion)}.',
),
],
if (mensajeVacaciones != null) ...[
@@ -203,31 +230,32 @@ class _TarjetaAlarma extends StatelessWidget {
TextButton.icon(
icon: const Icon(Icons.skip_next_rounded),
label: const Text('Omitir siguiente'),
- onPressed: alarma.proximaEjecucion == null
- ? null
- : () async {
- await estado.saltarProxima(alarma.id);
- if (context.mounted) {
- final alarmas =
- context.read().alarmas;
- AlarmaMusical? actualizada;
- for (final item in alarmas) {
- if (item.id == alarma.id) {
- actualizada = item;
- break;
+ onPressed:
+ alarma.proximaEjecucion == null
+ ? null
+ : () async {
+ await estado.saltarProxima(alarma.id);
+ if (context.mounted) {
+ final alarmas =
+ context.read().alarmas;
+ AlarmaMusical? actualizada;
+ for (final item in alarmas) {
+ if (item.id == alarma.id) {
+ actualizada = item;
+ break;
+ }
}
- }
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(
- content: Text(
- actualizada?.proximaEjecucion == null
- ? 'Alarma omitida. No queda próxima ejecución.'
- : 'Alarma omitida. Volverá el ${_fechaHora(actualizada!.proximaEjecucion!)}.',
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(
+ actualizada?.proximaEjecucion == null
+ ? 'Alarma omitida. No queda próxima ejecución.'
+ : 'Alarma omitida. Volverá el ${_fechaHora(actualizada!.proximaEjecucion!)}.',
+ ),
),
- ),
- );
- }
- },
+ );
+ }
+ },
),
const Spacer(),
IconButton(
@@ -305,7 +333,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
_nombreController = TextEditingController(
text: alarma?.nombre ?? 'Despertador musical',
);
- _hora = TimeOfDay(hour: alarma?.hora ?? ahora.hour, minute: alarma?.minuto ?? ahora.minute);
+ _hora = TimeOfDay(
+ hour: alarma?.hora ?? ahora.hour,
+ minute: alarma?.minuto ?? ahora.minute,
+ );
_fecha = alarma?.fechaUnica ?? ahora;
_tipo = alarma?.tipoProgramacion ?? TipoProgramacionAlarma.unica;
_diasSemana = {...alarma?.diasSemana ?? const []};
@@ -332,7 +363,9 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
if (mounted) context.read().cargarFavoritos();
});
}
- if (_emisora == null && widget.alarma == null && radio.emisoraPreferida != null) {
+ if (_emisora == null &&
+ widget.alarma == null &&
+ radio.emisoraPreferida != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _emisora == null) {
setState(() => _emisora = radio.emisoraPreferida);
@@ -352,7 +385,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
children: [
Row(
children: [
- const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 58),
+ const _AssetIcon(
+ 'assets/icons/alarmas/alarm_music.png',
+ size: 58,
+ ),
const SizedBox(width: 12),
Expanded(
child: Text(
@@ -390,7 +426,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
icon: Icons.event_rounded,
label: 'Fecha',
value: _fechaCorta(_fecha),
- onTap: _tipo == TipoProgramacionAlarma.unica ? _elegirFecha : null,
+ onTap:
+ _tipo == TipoProgramacionAlarma.unica
+ ? _elegirFecha
+ : null,
),
),
],
@@ -398,12 +437,22 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
const SizedBox(height: 12),
SegmentedButton(
segments: const [
- ButtonSegment(value: TipoProgramacionAlarma.unica, label: Text('Una vez')),
- ButtonSegment(value: TipoProgramacionAlarma.diaria, label: Text('Diaria')),
- ButtonSegment(value: TipoProgramacionAlarma.diasSemana, label: Text('Días')),
+ ButtonSegment(
+ value: TipoProgramacionAlarma.unica,
+ label: Text('Una vez'),
+ ),
+ ButtonSegment(
+ value: TipoProgramacionAlarma.diaria,
+ label: Text('Diaria'),
+ ),
+ ButtonSegment(
+ value: TipoProgramacionAlarma.diasSemana,
+ label: Text('Días'),
+ ),
],
selected: {_tipo},
- onSelectionChanged: (value) => setState(() => _tipo = value.first),
+ onSelectionChanged:
+ (value) => setState(() => _tipo = value.first),
),
if (_tipo == TipoProgramacionAlarma.diasSemana) ...[
const SizedBox(height: 10),
@@ -414,15 +463,21 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
FilterChip(
label: Text(_diaCorto(i)),
selected: _diasSemana.contains(i),
- onSelected: (selected) => setState(() {
- selected ? _diasSemana.add(i) : _diasSemana.remove(i);
- }),
+ onSelected:
+ (selected) => setState(() {
+ selected
+ ? _diasSemana.add(i)
+ : _diasSemana.remove(i);
+ }),
),
],
),
],
const SizedBox(height: 14),
- _SectionLabel(icon: 'assets/icons/alarmas/snooze_wave.png', text: 'Postponer'),
+ _SectionLabel(
+ icon: 'assets/icons/alarmas/snooze_wave.png',
+ text: 'Postponer',
+ ),
SegmentedButton(
segments: const [
ButtonSegment(value: 3, label: Text('3 min')),
@@ -430,10 +485,14 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
ButtonSegment(value: 10, label: Text('10 min')),
],
selected: {_snooze},
- onSelectionChanged: (value) => setState(() => _snooze = value.first),
+ onSelectionChanged:
+ (value) => setState(() => _snooze = value.first),
),
const SizedBox(height: 14),
- _SectionLabel(icon: 'assets/icons/alarmas/fallback_sound.png', text: 'Sonido y volumen'),
+ _SectionLabel(
+ icon: 'assets/icons/alarmas/fallback_sound.png',
+ text: 'Sonido y volumen',
+ ),
Slider(
value: _volumen,
min: 0.25,
@@ -444,13 +503,27 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
),
DropdownButtonFormField(
initialValue: _sonidoInterno,
- decoration: const InputDecoration(labelText: 'Sonido seguro interno'),
+ decoration: const InputDecoration(
+ labelText: 'Sonido seguro interno',
+ ),
items: const [
- DropdownMenuItem(value: SonidoInternoAlarma.amanecer, child: Text('Amanecer cálido')),
- DropdownMenuItem(value: SonidoInternoAlarma.campanaSuave, child: Text('Campana suave')),
- DropdownMenuItem(value: SonidoInternoAlarma.pulsoDigital, child: Text('Pulso digital')),
+ DropdownMenuItem(
+ value: SonidoInternoAlarma.amanecer,
+ child: Text('Amanecer cálido'),
+ ),
+ DropdownMenuItem(
+ value: SonidoInternoAlarma.campanaSuave,
+ child: Text('Campana suave'),
+ ),
+ DropdownMenuItem(
+ value: SonidoInternoAlarma.pulsoDigital,
+ child: Text('Pulso digital'),
+ ),
],
- onChanged: (value) => setState(() => _sonidoInterno = value ?? _sonidoInterno),
+ onChanged:
+ (value) => setState(
+ () => _sonidoInterno = value ?? _sonidoInterno,
+ ),
),
const SizedBox(height: 8),
DropdownButtonFormField(
@@ -474,13 +547,14 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
),
),
],
- onChanged: (uuid) => setState(() {
- if (uuid == null || uuid.isEmpty) {
- _emisora = null;
- return;
- }
- _emisora = favoritas.firstWhere((e) => e.uuid == uuid);
- }),
+ onChanged:
+ (uuid) => setState(() {
+ if (uuid == null || uuid.isEmpty) {
+ _emisora = null;
+ return;
+ }
+ _emisora = favoritas.firstWhere((e) => e.uuid == uuid);
+ }),
),
if (favoritas.isEmpty) ...[
const SizedBox(height: 6),
@@ -493,7 +567,8 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
- onPressed: () => setState(() => _emisora = radio.emisoraActual),
+ onPressed:
+ () => setState(() => _emisora = radio.emisoraActual),
icon: const Icon(Icons.add_task_rounded),
label: const Text('Usar emisora actual'),
),
@@ -503,10 +578,16 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
value: _sonarEnVacaciones,
- onChanged: (value) => setState(() => _sonarEnVacaciones = value),
- secondary: const _AssetIcon('assets/icons/alarmas/vacation_wave.png', size: 42),
+ onChanged:
+ (value) => setState(() => _sonarEnVacaciones = value),
+ secondary: const _AssetIcon(
+ 'assets/icons/alarmas/vacation_wave.png',
+ size: 42,
+ ),
title: const Text('Sonar durante vacaciones'),
- subtitle: const Text('Si lo apagás, la próxima ejecución saltará al primer día válido.'),
+ subtitle: const Text(
+ 'Si lo apagás, la próxima ejecución saltará al primer día válido.',
+ ),
),
const SizedBox(height: 16),
FilledButton.icon(
@@ -556,24 +637,26 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
diasSemana: _diasSemana.toList()..sort(),
))
.copyWith(
- nombre: _nombreController.text.trim().isEmpty
- ? 'Despertador musical'
- : _nombreController.text.trim(),
- hora: _hora.hour,
- minuto: _hora.minute,
- tipoProgramacion: _tipo,
- diasSemana: _tipo == TipoProgramacionAlarma.diasSemana
- ? (_diasSemana.toList()..sort())
- : const [],
- fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
- limpiarFechaUnica: _tipo != TipoProgramacionAlarma.unica,
- emisora: _emisora,
- sonarEnVacaciones: _sonarEnVacaciones,
- snoozeMinutos: _snooze,
- volumen: _volumen,
- sonidoInterno: _sonidoInterno,
- activa: true,
- );
+ nombre:
+ _nombreController.text.trim().isEmpty
+ ? 'Despertador musical'
+ : _nombreController.text.trim(),
+ hora: _hora.hour,
+ minuto: _hora.minute,
+ tipoProgramacion: _tipo,
+ diasSemana:
+ _tipo == TipoProgramacionAlarma.diasSemana
+ ? (_diasSemana.toList()..sort())
+ : const [],
+ fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
+ limpiarFechaUnica: _tipo != TipoProgramacionAlarma.unica,
+ emisora: _emisora,
+ sonarEnVacaciones: _sonarEnVacaciones,
+ snoozeMinutos: _snooze,
+ volumen: _volumen,
+ sonidoInterno: _sonidoInterno,
+ activa: true,
+ );
await estado.guardarAlarma(alarma);
if (mounted) Navigator.pop(context);
}
@@ -600,13 +683,21 @@ class _AccesoDiagnostico extends StatelessWidget {
Widget build(BuildContext context) {
final diag = estado.diagnostico;
return TextButton.icon(
- icon: const _AssetIcon('assets/icons/alarmas/android_reliability.png', size: 28),
+ icon: const _AssetIcon(
+ 'assets/icons/alarmas/android_reliability.png',
+ size: 28,
+ ),
label: Text(
diag == null
? 'Revisar fiabilidad Android'
: 'Fiabilidad: exactas ${diag.puedeProgramarExactas ? 'OK' : 'pendiente'} · notificaciones ${diag.notificacionesPermitidas ? 'OK' : 'pendiente'}',
),
- onPressed: estado.cargarDiagnostico,
+ onPressed: () async {
+ if (diag != null && !diag.puedeProgramarExactas) {
+ await estado.android.solicitarPermisoAlarmasExactas();
+ }
+ await estado.cargarDiagnostico();
+ },
);
}
}
@@ -627,7 +718,10 @@ class _PanelVacaciones extends StatelessWidget {
children: [
Row(
children: [
- const _AssetIcon('assets/icons/alarmas/vacation_wave.png', size: 48),
+ const _AssetIcon(
+ 'assets/icons/alarmas/vacation_wave.png',
+ size: 48,
+ ),
const SizedBox(width: 10),
Expanded(
child: Text(
@@ -723,9 +817,9 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
children: [
Text(
'Nuevo rango de vacaciones',
- style: Theme.of(context).textTheme.titleLarge?.copyWith(
- fontWeight: FontWeight.w900,
- ),
+ style: Theme.of(
+ context,
+ ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 12),
TextField(
@@ -811,7 +905,8 @@ class _AssetIcon extends StatelessWidget {
width: size,
height: size,
fit: BoxFit.contain,
- errorBuilder: (_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65),
+ errorBuilder:
+ (_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65),
);
}
}
@@ -857,7 +952,12 @@ class _SectionLabel extends StatelessWidget {
children: [
_AssetIcon(icon, size: 34),
const SizedBox(width: 8),
- Text(text, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800)),
+ Text(
+ text,
+ style: Theme.of(
+ context,
+ ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
+ ),
],
);
}
@@ -918,9 +1018,11 @@ String _hora(AlarmaMusical alarma) =>
String _programacion(AlarmaMusical alarma) {
return switch (alarma.tipoProgramacion) {
- TipoProgramacionAlarma.unica => 'Una vez · ${_fechaCorta(alarma.fechaUnica ?? DateTime.now())}',
+ TipoProgramacionAlarma.unica =>
+ 'Una vez · ${_fechaCorta(alarma.fechaUnica ?? DateTime.now())}',
TipoProgramacionAlarma.diaria => 'Diaria',
- TipoProgramacionAlarma.diasSemana => 'Días: ${alarma.diasSemana.map(_diaCorto).join(', ')}',
+ TipoProgramacionAlarma.diasSemana =>
+ 'Días: ${alarma.diasSemana.map(_diaCorto).join(', ')}',
};
}
@@ -934,39 +1036,39 @@ String _fechaCorta(DateTime fecha) =>
'${fecha.day.toString().padLeft(2, '0')}/${fecha.month.toString().padLeft(2, '0')}/${fecha.year}';
String _diaCorto(int dia) => switch (dia) {
- DateTime.monday => 'Lun',
- DateTime.tuesday => 'Mar',
- DateTime.wednesday => 'Mié',
- DateTime.thursday => 'Jue',
- DateTime.friday => 'Vie',
- DateTime.saturday => 'Sáb',
- DateTime.sunday => 'Dom',
- _ => '?',
- };
+ DateTime.monday => 'Lun',
+ DateTime.tuesday => 'Mar',
+ DateTime.wednesday => 'Mié',
+ DateTime.thursday => 'Jue',
+ DateTime.friday => 'Vie',
+ DateTime.saturday => 'Sáb',
+ DateTime.sunday => 'Dom',
+ _ => '?',
+};
String _diaLargo(int dia) => switch (dia) {
- DateTime.monday => 'lunes',
- DateTime.tuesday => 'martes',
- DateTime.wednesday => 'miércoles',
- DateTime.thursday => 'jueves',
- DateTime.friday => 'viernes',
- DateTime.saturday => 'sábado',
- DateTime.sunday => 'domingo',
- _ => 'día',
- };
+ DateTime.monday => 'lunes',
+ DateTime.tuesday => 'martes',
+ DateTime.wednesday => 'miércoles',
+ DateTime.thursday => 'jueves',
+ DateTime.friday => 'viernes',
+ DateTime.saturday => 'sábado',
+ DateTime.sunday => 'domingo',
+ _ => 'día',
+};
String _mes(int mes) => switch (mes) {
- 1 => 'enero',
- 2 => 'febrero',
- 3 => 'marzo',
- 4 => 'abril',
- 5 => 'mayo',
- 6 => 'junio',
- 7 => 'julio',
- 8 => 'agosto',
- 9 => 'septiembre',
- 10 => 'octubre',
- 11 => 'noviembre',
- 12 => 'diciembre',
- _ => 'mes',
- };
+ 1 => 'enero',
+ 2 => 'febrero',
+ 3 => 'marzo',
+ 4 => 'abril',
+ 5 => 'mayo',
+ 6 => 'junio',
+ 7 => 'julio',
+ 8 => 'agosto',
+ 9 => 'septiembre',
+ 10 => 'octubre',
+ 11 => 'noviembre',
+ 12 => 'diciembre',
+ _ => 'mes',
+};
diff --git a/lib/pantallas/pantalla_reproductor.dart b/lib/pantallas/pantalla_reproductor.dart
index 86386d8..e9b6495 100644
--- a/lib/pantallas/pantalla_reproductor.dart
+++ b/lib/pantallas/pantalla_reproductor.dart
@@ -411,9 +411,15 @@ class _GrabacionWidget extends StatelessWidget {
? estado.detenerGrabacion
: () => _mostrarDialogoGrabacion(context),
),
+ if (!activa)
+ IconButton.filledTonal(
+ tooltip: 'Abrir carpeta',
+ icon: const Icon(Icons.folder_open_rounded),
+ onPressed: () => _abrirCarpetaGrabaciones(context),
+ ),
if (!activa && hayUltimaGrabacion)
IconButton.filledTonal(
- tooltip: 'Abrir ?ltima grabaci?n',
+ tooltip: 'Abrir última grabación',
icon: const Icon(Icons.audio_file_rounded),
onPressed: () => _abrirUltimaGrabacion(context),
),
@@ -430,7 +436,20 @@ class _GrabacionWidget extends StatelessWidget {
if (!context.mounted) return;
if (!abierto) {
messenger.showSnackBar(
- const SnackBar(content: Text('No se pudo abrir la ?ltima grabaci?n')),
+ const SnackBar(content: Text('No se pudo abrir la última grabación')),
+ );
+ }
+ }
+
+ Future _abrirCarpetaGrabaciones(BuildContext context) async {
+ final messenger = ScaffoldMessenger.of(context);
+ final abierto = await estado.abrirDirectorioGrabacion();
+ if (!context.mounted) return;
+ if (!abierto) {
+ messenger.showSnackBar(
+ const SnackBar(
+ content: Text('No se pudo abrir la carpeta de grabaciones'),
+ ),
);
}
}
diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart
index 8bf5cf4..56eb6d3 100644
--- a/lib/servicios/servicio_alarmas_android.dart
+++ b/lib/servicios/servicio_alarmas_android.dart
@@ -74,7 +74,7 @@ class ServicioAlarmasAndroid {
debugPrint(
'[PluriWave][alarmas] programar id=${alarma.id} nombre=${alarma.nombre} proxima=${proxima.toIso8601String()} preaviso=${proxima.subtract(const Duration(minutes: 30)).toIso8601String()}',
);
- await _channel.invokeMethod('scheduleAlarm', {
+ final programada = await _channel.invokeMethod('scheduleAlarm', {
'id': alarma.id,
'title': alarma.nombre,
'triggerAtMillis': proxima.millisecondsSinceEpoch,
@@ -85,6 +85,11 @@ class ServicioAlarmasAndroid {
'fallbackSound': alarma.sonidoInterno.name,
'volume': alarma.volumen,
});
+ if (programada != true) {
+ throw StateError(
+ 'Android no pudo programar una alarma exacta. Revisa el permiso de alarmas exactas.',
+ );
+ }
}
Future cancelar(String alarmaId) =>
@@ -96,6 +101,13 @@ class ServicioAlarmasAndroid {
Future detenerSonidoNativo(String alarmaId) =>
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
+ Future solicitarPermisoAlarmasExactas() async {
+ final abierto = await _channel.invokeMethod(
+ 'requestExactAlarmPermission',
+ );
+ return abierto ?? false;
+ }
+
Future diagnostico() async {
debugPrint('[PluriWave][alarmas] diagnostico android');
final raw = await _channel.invokeMethod