feat(app): add onboarding and harden alarms
Build & Deploy Pluriwave / Análisis de código (push) Successful in 21s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m6s

This commit is contained in:
2026-05-23 01:22:37 +02:00
parent 27b8fccac9
commit 896349ad5f
44 changed files with 1772 additions and 241 deletions
+13
View File
@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK"/> <uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <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.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/> <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
@@ -73,6 +74,18 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver
android:name=".PluriWaveBootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<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 <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
@@ -7,6 +7,7 @@ import android.content.Intent
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import org.json.JSONObject
class AlarmScheduler(private val context: Context) { class AlarmScheduler(private val context: Context) {
private val tag = "PluriWave" private val tag = "PluriWave"
@@ -22,7 +23,7 @@ class AlarmScheduler(private val context: Context) {
stationUrl: String?, stationUrl: String?,
fallbackSound: String?, fallbackSound: String?,
volume: Float volume: Float
) { ): Boolean {
Log.d( Log.d(
tag, tag,
"alarm.schedule id=$id title=$title triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis canExact=${canScheduleExactAlarms()}" "alarm.schedule id=$id title=$title triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis canExact=${canScheduleExactAlarms()}"
@@ -55,10 +56,25 @@ class AlarmScheduler(private val context: Context) {
) )
val mainScheduled = scheduleMainAlarm(id, triggerAtMillis, showIntent, alarmIntent) val mainScheduled = scheduleMainAlarm(id, triggerAtMillis, showIntent, alarmIntent)
if (mainScheduled) {
saveScheduledAlarm(
id,
title,
triggerAtMillis,
preNoticeAtMillis,
stationName,
stationUrl,
fallbackSound,
volume
)
} else {
removeScheduledAlarm(id)
}
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (!mainScheduled) { if (!mainScheduled) {
Log.w(tag, "alarm.schedule main alarm fallback failed or degraded id=$id") Log.w(tag, "alarm.schedule main alarm fallback failed or degraded id=$id")
return false
} }
if (preNoticeAtMillis > now) { if (preNoticeAtMillis > now) {
@@ -94,6 +110,7 @@ class AlarmScheduler(private val context: Context) {
} else { } else {
Log.d(tag, "alarm.schedule preNotice skipped id=$id") Log.d(tag, "alarm.schedule preNotice skipped id=$id")
} }
return true
} }
private fun scheduleMainAlarm( private fun scheduleMainAlarm(
@@ -123,6 +140,12 @@ class AlarmScheduler(private val context: Context) {
alarmIntent alarmIntent
) )
Log.d(tag, "alarm.schedule setExactAndAllowWhileIdle fallback OK id=$id") 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) { } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setAndAllowWhileIdle( alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP, AlarmManager.RTC_WAKEUP,
@@ -147,16 +170,10 @@ class AlarmScheduler(private val context: Context) {
fun cancelAlarm(id: String) { fun cancelAlarm(id: String) {
Log.d(tag, "alarm.cancel id=$id") Log.d(tag, "alarm.cancel id=$id")
for (slot in 1..3) { removeScheduledAlarm(id)
alarmManager.cancel( cancelPending("fire", pendingFireIntent(id, PendingIntent.FLAG_NO_CREATE))
PendingIntent.getBroadcast( cancelPending("show", pendingShowIntent(id, PendingIntent.FLAG_NO_CREATE))
context, cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE))
requestCode(id, slot),
Intent(context, PluriWaveAlarmReceiver::class.java),
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
) ?: continue
)
}
NotificationManagerCompat.from(context).cancel( NotificationManagerCompat.from(context).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(id) PluriWaveAlarmReceiver.notificationIdForAlarm(id)
) )
@@ -176,5 +193,119 @@ class AlarmScheduler(private val context: Context) {
alarmManager.canScheduleExactAlarms() 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 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_"
}
} }
@@ -7,6 +7,8 @@ import android.content.ActivityNotFoundException
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.media.audiofx.Visualizer import android.media.audiofx.Visualizer
import android.app.AlarmManager
import android.content.Context
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.Handler import android.os.Handler
@@ -73,7 +75,7 @@ class MainActivity : AudioServiceActivity() {
Log.w(tag, "alarm.channel scheduleAlarm invalid id=$id triggerAtMillis=$triggerAtMillis") Log.w(tag, "alarm.channel scheduleAlarm invalid id=$id triggerAtMillis=$triggerAtMillis")
result.error("INVALID_ALARM", "Missing alarm id or trigger time", null) result.error("INVALID_ALARM", "Missing alarm id or trigger time", null)
} else { } else {
alarmScheduler.scheduleAlarm( val scheduled = alarmScheduler.scheduleAlarm(
id, id,
title, title,
triggerAtMillis, triggerAtMillis,
@@ -83,7 +85,7 @@ class MainActivity : AudioServiceActivity() {
fallbackSound, fallbackSound,
volume volume
) )
result.success(null) result.success(scheduled)
} }
} }
"cancelAlarm" -> { "cancelAlarm" -> {
@@ -92,7 +94,6 @@ class MainActivity : AudioServiceActivity() {
if (id == null) { if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null) result.error("INVALID_ALARM", "Missing alarm id", null)
} else { } else {
PluriWaveAlarmService.stop(this, id)
alarmScheduler.cancelAlarm(id) alarmScheduler.cancelAlarm(id)
result.success(null) result.success(null)
} }
@@ -129,6 +130,10 @@ class MainActivity : AudioServiceActivity() {
) )
) )
} }
"requestExactAlarmPermission" -> {
Log.d(tag, "alarm.channel requestExactAlarmPermission")
result.success(requestExactAlarmPermission())
}
"getInitialAlarmIntent" -> { "getInitialAlarmIntent" -> {
val payload = alarmPayload(intent) val payload = alarmPayload(intent)
Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload") Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload")
@@ -153,6 +158,15 @@ class MainActivity : AudioServiceActivity() {
result.success(openDirectory(path)) 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" -> { "openFile" -> {
val path = call.argument<String>("path") val path = call.argument<String>("path")
val mimeType = call.argument<String>("mimeType") ?: "audio/*" val mimeType = call.argument<String>("mimeType") ?: "audio/*"
@@ -193,25 +207,120 @@ class MainActivity : AudioServiceActivity() {
) )
} }
private fun openDirectory(path: String): Boolean { private fun requestExactAlarmPermission(): Boolean {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
addFlags( val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
Intent.FLAG_GRANT_READ_URI_PERMISSION or if (alarmManager.canScheduleExactAlarms()) return true
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or return try {
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or startActivity(
Intent.FLAG_GRANT_PREFIX_URI_PERMISSION 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<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 { return try {
startActivity(intent) startActivity(intent)
Log.d(tag, "file_actions.openDirectory launched path=$path") Log.d(tag, "$origin launched path=$path")
true true
} catch (_: ActivityNotFoundException) { } catch (_: ActivityNotFoundException) {
Log.w(tag, "file_actions.openDirectory no activity for path=$path") Log.w(tag, "$origin no activity for path=$path")
false false
} catch (error: Throwable) { } catch (error: Throwable) {
Log.e(tag, "file_actions.openDirectory failed path=$path", error) Log.e(tag, "$origin failed path=$path", error)
false 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() { private fun startVisualizerWhenAllowed() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
checkSelfPermission(Manifest.permission.RECORD_AUDIO) checkSelfPermission(Manifest.permission.RECORD_AUDIO)
@@ -234,7 +234,8 @@ class PluriWaveAlarmService : Service() {
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId) putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
} }
try { try {
context.startService(intent) context.stopService(intent)
Log.d(TAG, "alarm.service stop requested id=$alarmId")
} catch (error: Throwable) { } catch (error: Throwable) {
Log.e(TAG, "alarm.service stop request failed id=$alarmId", error) Log.e(TAG, "alarm.service stop request failed id=$alarmId", error)
} }
@@ -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"
}
}
+27
View File
@@ -0,0 +1,27 @@
# أهلاً بك في PluriWave
PluriWave هو راديوك العالمي المميز: محطات مباشرة، مفضلات منظمة، تسجيلات، معادل صوت ومنبّهات موسيقية ضمن تجربة مصممة بعناية.
## راديو مباشر
- ابحث عن المحطات حسب الاسم والبلد واللغة والجودة.
- استكشف المحطات القريبة واكتشف محطات جديدة.
- رتّب القوائم حسب الاسم أو الجودة.
## موسيقى بطريقتك
- احفظ المفضلات ونظّمها في مجموعات.
- اضبط المعادل العام أو إعدادات كل محطة.
- استخدم مؤقّت النوم بمدد مخصّصة.
## التسجيلات
- سجّل الراديو بدون إعادة ضغط البث الأصلي.
- حدّد الحجم الأقصى للملف لتبقى بأمان.
- افتح مجلد التسجيلات للمشاركة أو النقل أو التعديل.
## منبّهات موسيقية
- أنشئ منبّهات لمرة واحدة أو يومية أو لأيام العمل.
- اختر محطة مفضلة وصوتاً داخلياً آمناً.
- استخدم العطلات وتخطي التنفيذ التالي والغفوة.
+27
View File
@@ -0,0 +1,27 @@
# PluriWave-এ স্বাগতম
PluriWave আপনার প্রিমিয়াম বিশ্ব রেডিও: লাইভ স্টেশন, গোছানো ফেভারিট, রেকর্ডিং, ইকুয়ালাইজার এবং মিউজিক অ্যালার্ম—সবই যত্নসহ তৈরি এক অভিজ্ঞতায়।
## লাইভ রেডিও
- নাম, দেশ, ভাষা ও মান অনুযায়ী স্টেশন খুঁজুন।
- কাছাকাছি স্টেশন দেখুন এবং নতুন রেডিও আবিষ্কার করুন।
- তালিকা নাম বা মান অনুযায়ী সাজান।
## আপনার মতো করে সঙ্গীত
- ফেভারিট সংরক্ষণ করুন এবং গ্রুপে সাজান।
- গ্লোবাল ইকুয়ালাইজার বা স্টেশনভিত্তিক প্রিসেট ঠিক করুন।
- নিজের মতো সময় দিয়ে স্লিপ টাইমার ব্যবহার করুন।
## রেকর্ডিং
- মূল স্ট্রিম রিকমপ্রেস না করে রেডিও রেকর্ড করুন।
- নিরাপদ থাকতে সর্বোচ্চ ফাইল সাইজ সীমা দিন।
- শেয়ার, সরানো বা সম্পাদনার জন্য রেকর্ডিং ফোল্ডার খুলুন।
## মিউজিক অ্যালার্ম
- একবার, প্রতিদিন বা কর্মদিবসের অ্যালার্ম তৈরি করুন।
- প্রিয় স্টেশন ও নিরাপদ অভ্যন্তরীণ সাউন্ড বেছে নিন।
- ছুটি, পরের রান স্কিপ এবং স্নুজ ব্যবহার করুন।
+27
View File
@@ -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.
+27
View File
@@ -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.
+27
View File
@@ -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.
+27
View File
@@ -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.
+27
View File
@@ -0,0 +1,27 @@
# PluriWave में आपका स्वागत है
PluriWave आपका प्रीमियम विश्व रेडियो है: लाइव स्टेशन, व्यवस्थित पसंदीदा, रिकॉर्डिंग, इक्वलाइज़र और संगीत अलार्म एक सधे हुए अनुभव में।
## लाइव रेडियो
- स्टेशन को नाम, देश, भाषा और गुणवत्ता से खोजें।
- पास के स्टेशन देखें और नए रेडियो खोजें।
- सूचियों को नाम या गुणवत्ता के अनुसार क्रमित करें।
## संगीत आपके तरीके से
- पसंदीदा सहेजें और उन्हें समूहों में व्यवस्थित करें।
- ग्लोबल इक्वलाइज़र या स्टेशन-विशिष्ट प्रीसेट समायोजित करें।
- अपनी पसंद की अवधि वाला स्लीप टाइमर इस्तेमाल करें।
## रिकॉर्डिंग
- मूल स्ट्रीम को फिर से कंप्रेस किए बिना रेडियो रिकॉर्ड करें।
- सुरक्षित रहने के लिए अधिकतम फ़ाइल आकार सीमित करें।
- फ़ाइलें साझा करने, स्थानांतरित करने या संपादित करने के लिए रिकॉर्डिंग फ़ोल्डर खोलें।
## संगीत अलार्म
- एक बार, रोज़ाना या कार्यदिवस अलार्म बनाएँ।
- पसंदीदा स्टेशन और सुरक्षित आंतरिक ध्वनि चुनें।
- छुट्टियाँ, अगला निष्पादन छोड़ना और स्नूज़ का उपयोग करें।
+27
View File
@@ -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.
+27
View File
@@ -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.
+27
View File
@@ -0,0 +1,27 @@
# PluriWave へようこそ
PluriWave は、ライブ局、お気に入り整理、録音、イコライザー、音楽アラームを備えた高品質なワールドラジオです。
## ライブラジオ
- 名前、国、言語、音質で局を検索できます。
- 近くの局を探して新しいラジオを見つけられます。
- リストを名前または音質で並べ替えできます。
## あなた好みの音楽体験
- お気に入りを保存してグループで整理できます。
- 全体イコライザーや局ごとのプリセットを調整できます。
- 時間を指定できるスリープタイマーを使えます。
## 録音
- 元のストリームを再圧縮せずに録音できます。
- 最大ファイルサイズを制限して安全に使えます。
- 録音フォルダーを開いて共有・移動・編集できます。
## 音楽アラーム
- 1回のみ、毎日、平日のアラームを作成できます。
- お気に入り局と安全な内蔵サウンドを選べます。
- 休日設定、次回スキップ、スヌーズに対応しています。
+27
View File
@@ -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.
+27
View File
@@ -0,0 +1,27 @@
# Добро пожаловать в PluriWave
PluriWave — ваше премиальное мировое радио: прямые станции, организованные избранные, записи, эквалайзер и музыкальные будильники в продуманном интерфейсе.
## Прямое радио
- Ищите станции по названию, стране, языку и качеству.
- Изучайте ближайшие станции и открывайте новое радио.
- Сортируйте списки по названию или качеству.
## Музыка по-вашему
- Сохраняйте избранное и организуйте его по группам.
- Настраивайте глобальный эквалайзер или пресеты для станций.
- Используйте таймер сна с нужной длительностью.
## Записи
- Записывайте радио без повторного сжатия исходного потока.
- Ограничивайте максимальный размер файла для безопасности.
- Открывайте папку записей, чтобы делиться, перемещать и редактировать файлы.
## Музыкальные будильники
- Создавайте разовые, ежедневные или будничные будильники.
- Выбирайте любимую станцию и безопасный встроенный звук.
- Используйте праздники, пропуск следующего запуска и отложенный сигнал.
+27
View File
@@ -0,0 +1,27 @@
# 欢迎使用 PluriWave
PluriWave 是你的高品质全球电台:直播电台、分组收藏、录音、均衡器和音乐闹钟,体验精致流畅。
## 直播电台
- 按名称、国家、语言和音质搜索电台。
- 探索附近电台,发现新的广播内容。
- 按名称或音质排序列表。
## 按你的方式听音乐
- 保存收藏并按分组整理。
- 调整全局均衡器或单电台预设。
- 使用可自定义时长的睡眠定时器。
## 录音
- 录制电台时不重新压缩原始流。
- 限制最大文件大小,更安全省心。
- 打开录音文件夹以分享、移动或编辑文件。
## 音乐闹钟
- 创建一次性、每日或工作日闹钟。
- 选择喜爱的电台和安全的内置提示音。
- 支持假期、跳过下次执行和贪睡。
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · منبّهات وملفات أكثر موثوقية
الملخّص: عززنا أساس منبّهات Android وفصلنا بوضوح بين فتح المجلد وتغيير مساره.
## التحسينات
- أساس أصلي جديد للمنبّهات مع صوت داخلي آمن.
- تشخيص أفضل لأذونات Android الخاصة بالمنبّهات الدقيقة.
- المنبّهات التي تُنشأ في الدقيقة نفسها لم تعد تُستبعد بسبب الثواني.
- لوحة المنبّهات تميّز بين المنبّهات النشطة والمنبّهات بلا تنفيذ تالٍ صالح.
- فتح المجلد يحاول الآن فتح المسار المحفوظ؛ تغيير المسار أصبح منفصلاً.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · আরও নির্ভরযোগ্য অ্যালার্ম ও ফাইল
সারাংশ: আমরা Android অ্যালার্মের ভিত্তি শক্ত করেছি এবং ফোল্ডার খোলা ও পথ পরিবর্তনকে স্পষ্টভাবে আলাদা করেছি।
## উন্নতি
- নিরাপদ অভ্যন্তরীণ সাউন্ডসহ অ্যালার্মের জন্য নতুন নেটিভ ভিত্তি।
- Android exact-alarm অনুমতির উন্নত ডায়াগনস্টিক।
- একই মিনিটে তৈরি অ্যালার্ম এখন সেকেন্ডের কারণে বাদ পড়ে না।
- অ্যালার্ম প্যানেল সক্রিয় অ্যালার্ম ও বৈধ পরের রানবিহীন অ্যালার্ম আলাদা করে।
- ফোল্ডার খোলা এখন সংরক্ষিত পথ খোলার চেষ্টা করে; পথ বদল আলাদা করা হয়েছে।
+11
View File
@@ -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.
+11
View File
@@ -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.
+11
View File
@@ -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.
+11
View File
@@ -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é.
+12
View File
@@ -0,0 +1,12 @@
# v0.1.47 · अधिक भरोसेमंद अलार्म और फ़ाइलें
सारांश: हमने Android अलार्म की बुनियाद मजबूत की और फ़ोल्डर खोलने को उसका पथ बदलने से स्पष्ट रूप से अलग किया।
## सुधार
- सुरक्षित आंतरिक ध्वनि के साथ अलार्म के लिए नई नेटिव बुनियाद।
- Android exact-alarm अनुमति के बेहतर निदान।
- एक ही मिनट में बने अलार्म अब सेकंड की वजह से हटाए नहीं जाते।
- अलार्म पैनल सक्रिय अलार्म और बिना वैध अगली निष्पादन के अलार्म में अंतर करता है।
- फ़ोल्डर खोलना अब सहेजा गया पथ खोलने की कोशिश करता है; पथ बदलना अलग है।
+11
View File
@@ -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.
+11
View File
@@ -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.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · より信頼できるアラームとファイル
概要: Android のアラーム基盤を強化し、フォルダーを開く操作とパス変更を明確に分離しました。
## 改善点
- 安全な内部サウンドを備えた、新しいネイティブアラーム基盤を導入。
- Android の正確なアラーム権限診断を改善。
- 同じ分に作成したアラームが秒の違いで破棄されなくなりました。
- アラームパネルで、有効な次回実行があるアラームとないアラームを区別。
- フォルダーを開くは保存済みパスを開くようになり、パス変更は別操作になりました。
+11
View File
@@ -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.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Более надежные будильники и файлы
Кратко: мы усилили основу будильников Android и четко разделили открытие папки и изменение её пути.
## Улучшения
- Новая нативная основа будильников с безопасным встроенным звуком.
- Улучшена диагностика разрешений Android для точных будильников.
- Будильники, созданные в ту же минуту, больше не отбрасываются из-за секунд.
- Панель будильников различает активные будильники и будильники без валидного следующего запуска.
- Открыть папку теперь пытается открыть сохраненный путь; изменение пути вынесено отдельно.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · 更可靠的闹钟与文件
摘要:我们强化了 Android 闹钟基础,并清晰区分了“打开文件夹”和“更改路径”。
## 改进
- 闹钟采用新的原生基础,配有安全的内置提示音。
- 改进 Android 精确闹钟权限诊断。
- 同一分钟创建的闹钟不再因秒数被丢弃。
- 闹钟面板可区分活跃闹钟与无有效下次执行的闹钟。
- “打开文件夹”现在会尝试打开已保存路径;“更改路径”独立处理。
+21 -2
View File
@@ -16,6 +16,7 @@ import 'tema/pluriwave_theme.dart';
import 'widgets/pluri_bottom_navigation.dart'; import 'widgets/pluri_bottom_navigation.dart';
import 'widgets/pluri_icon.dart'; import 'widgets/pluri_icon.dart';
import 'widgets/pluri_layout.dart'; import 'widgets/pluri_layout.dart';
import 'widgets/pluri_onboarding_dialog.dart';
import 'widgets/pluri_wave_scaffold.dart'; import 'widgets/pluri_wave_scaffold.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart'; import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'servicios/servicio_alarmas_android.dart'; import 'servicios/servicio_alarmas_android.dart';
@@ -64,6 +65,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
EstadoRadio? _estadoSuscrito; EstadoRadio? _estadoSuscrito;
bool _alarmaInicialProcesada = false; bool _alarmaInicialProcesada = false;
bool _alarmaSonandoActiva = false; bool _alarmaSonandoActiva = false;
bool _onboardingInicialSolicitado = false;
String? _alarmaSonandoId; String? _alarmaSonandoId;
static const _paginas = [ static const _paginas = [
@@ -120,6 +122,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
_alarmaInicialProcesada = true; _alarmaInicialProcesada = true;
unawaited(_procesarAlarmaInicial(alarmas)); unawaited(_procesarAlarmaInicial(alarmas));
} }
if (!_onboardingInicialSolicitado) {
_onboardingInicialSolicitado = true;
unawaited(_mostrarOnboardingInicial());
}
} }
@override @override
@@ -196,9 +202,17 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
} }
} }
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 { Future<void> _abrirAlarmaSonando(EventoAlarmaAndroid evento) async {
final estado = context.read<EstadoAlarmas>(); final estado = context.read<EstadoAlarmas>();
await estado.refrescarProgramacion(); if (estado.alarmas.isEmpty) {
await estado.cargarPersistidasSinRecalcular();
}
AlarmaMusical? alarma; AlarmaMusical? alarma;
for (final item in estado.alarmas) { for (final item in estado.alarmas) {
if (item.id == evento.alarmaId) { if (item.id == evento.alarmaId) {
@@ -206,7 +220,12 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> {
break; 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')) { if (evento.accion.endsWith('.SKIP_NEXT')) {
await estado.saltarProxima(alarma.id); await estado.saltarProxima(alarma.id);
if (!mounted) return; if (!mounted) return;
+31 -10
View File
@@ -27,7 +27,8 @@ class EstadoAlarmas extends ChangeNotifier {
DiagnosticoAlarmasAndroid? _diagnostico; DiagnosticoAlarmasAndroid? _diagnostico;
Timer? _refresco; Timer? _refresco;
Timer? _vigilancia; Timer? _vigilancia;
final _alarmasVencidasController = StreamController<AlarmaMusical>.broadcast(); final _alarmasVencidasController =
StreamController<AlarmaMusical>.broadcast();
final Set<String> _ejecucionesEmitidas = {}; final Set<String> _ejecucionesEmitidas = {};
static const _margenDisparoLocal = Duration(seconds: 45); static const _margenDisparoLocal = Duration(seconds: 45);
bool _cargando = false; bool _cargando = false;
@@ -57,7 +58,9 @@ class EstadoAlarmas extends ChangeNotifier {
try { try {
final config = await servicio.recalcularTodas(); final config = await servicio.recalcularTodas();
_aplicar(config); _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 _sincronizarTodas();
await cargarDiagnostico(); await cargarDiagnostico();
_activarRefresco(); _activarRefresco();
@@ -71,16 +74,19 @@ class EstadoAlarmas extends ChangeNotifier {
} }
Future<void> guardarAlarma(AlarmaMusical alarma) async { 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}'); 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); final config = await servicio.guardarAlarma(alarma);
_aplicar(config); _aplicar(config);
try { try {
final guardada = _alarmas.firstWhere((a) => a.id == alarma.id); 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); await android.programar(guardada);
} catch (e) { } catch (e) {
_error = _error = 'Alarma guardada, pero Android no pudo programarla todavía: $e';
'Alarma guardada, pero Android no pudo programarla todavía: $e';
} }
notifyListeners(); notifyListeners();
} }
@@ -96,6 +102,12 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> cargarPersistidasSinRecalcular() async {
final config = await servicio.cargar();
_aplicar(config);
notifyListeners();
}
void marcarEjecucionGestionada(AlarmaMusical alarma) { void marcarEjecucionGestionada(AlarmaMusical alarma) {
final proxima = alarma.proximaEjecucion; final proxima = alarma.proximaEjecucion;
if (proxima == null) return; if (proxima == null) return;
@@ -110,6 +122,7 @@ class EstadoAlarmas extends ChangeNotifier {
debugPrint('[PluriWave][alarmas] eliminar id=$id'); debugPrint('[PluriWave][alarmas] eliminar id=$id');
final config = await servicio.eliminarAlarma(id); final config = await servicio.eliminarAlarma(id);
_aplicar(config); _aplicar(config);
await android.detenerSonidoNativo(id);
await android.cancelar(id); await android.cancelar(id);
notifyListeners(); notifyListeners();
} }
@@ -136,7 +149,9 @@ class EstadoAlarmas extends ChangeNotifier {
} }
Future<void> guardarVacaciones(List<RangoVacaciones> vacaciones) async { Future<void> guardarVacaciones(List<RangoVacaciones> vacaciones) async {
debugPrint('[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}'); debugPrint(
'[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}',
);
final config = await servicio.guardarVacaciones(vacaciones); final config = await servicio.guardarVacaciones(vacaciones);
_aplicar(config); _aplicar(config);
await _sincronizarTodas(); await _sincronizarTodas();
@@ -145,7 +160,9 @@ class EstadoAlarmas extends ChangeNotifier {
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async { Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
final proxima = DateTime.now().add(Duration(minutes: minutos)); 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.ocultarNotificacionAlarma(alarma.id);
await android.programar(alarma.copyWith(proximaEjecucion: proxima)); await android.programar(alarma.copyWith(proximaEjecucion: proxima));
} }
@@ -184,7 +201,9 @@ class EstadoAlarmas extends ChangeNotifier {
} }
Future<void> _sincronizarTodas() async { Future<void> _sincronizarTodas() async {
debugPrint('[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}'); debugPrint(
'[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}',
);
for (final alarma in _alarmas) { for (final alarma in _alarmas) {
await android.programar(alarma); await android.programar(alarma);
} }
@@ -224,7 +243,9 @@ class EstadoAlarmas extends ChangeNotifier {
continue; continue;
} }
if (_ejecucionesEmitidas.add(key)) { 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); _alarmasVencidasController.add(alarma);
} }
} }
+5 -7
View File
@@ -121,7 +121,8 @@ class EstadoRadio extends ChangeNotifier {
List<Emisora> get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda); List<Emisora> get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda);
List<Emisora> get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas); List<Emisora> get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas);
List<Emisora> get listaFavoritos => _ordenarEmisoras(_listaFavoritos); List<Emisora> get listaFavoritos => _ordenarEmisoras(_listaFavoritos);
List<GrupoFavoritos> get gruposFavoritos => List.unmodifiable(_gruposFavoritos); List<GrupoFavoritos> get gruposFavoritos =>
List.unmodifiable(_gruposFavoritos);
List<Emisora> get emisorasCustom => _ordenarEmisoras(_emisorasCustom); List<Emisora> get emisorasCustom => _ordenarEmisoras(_emisorasCustom);
bool get cargandoPopulares => _cargandoPopulares; bool get cargandoPopulares => _cargandoPopulares;
bool get cargandoBusqueda => _cargandoBusqueda; bool get cargandoBusqueda => _cargandoBusqueda;
@@ -633,7 +634,7 @@ class EstadoRadio extends ChangeNotifier {
await Directory(ruta).create(recursive: true); await Directory(ruta).create(recursive: true);
if (!kIsWeb && Platform.isAndroid) { if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>( final abierto = await _fileActionsChannel.invokeMethod<bool>(
'openDirectory', 'viewDirectory',
{'path': ruta}, {'path': ruta},
); );
return abierto ?? false; return abierto ?? false;
@@ -650,13 +651,10 @@ class EstadoRadio extends ChangeNotifier {
} }
debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}'); debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}');
if (!kIsWeb && Platform.isAndroid) { if (!kIsWeb && Platform.isAndroid) {
final abierto = await _fileActionsChannel.invokeMethod<bool>( final abierto = await _fileActionsChannel.invokeMethod<bool>('openFile', {
'openFile',
{
'path': archivo.path, 'path': archivo.path,
'mimeType': 'audio/*', 'mimeType': 'audio/*',
}, });
);
return abierto ?? false; return abierto ?? false;
} }
return launchUrl( return launchUrl(
+108 -31
View File
@@ -18,6 +18,7 @@ import '../widgets/ecualizador_widget.dart';
import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart'; import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart'; import '../widgets/pluri_layout.dart';
import '../widgets/pluri_onboarding_dialog.dart';
import '../widgets/pluri_premium_widgets.dart'; import '../widgets/pluri_premium_widgets.dart';
class PantallaAjustes extends StatelessWidget { class PantallaAjustes extends StatelessWidget {
@@ -225,7 +226,6 @@ class _SeccionGrabaciones extends StatelessWidget {
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
onTap: () => _seleccionarRuta(context),
), ),
), ),
Wrap( Wrap(
@@ -353,7 +353,8 @@ class _SeccionTimerSueno extends StatelessWidget {
icon: const Icon(Icons.restore_rounded), icon: const Icon(Icons.restore_rounded),
label: Text(l10n.timerSectionRestoreRecommended), label: Text(l10n.timerSectionRestoreRecommended),
onPressed: onPressed:
() => context.read<EstadoRadio>().restaurarTimerSuenoPresets(), () =>
context.read<EstadoRadio>().restaurarTimerSuenoPresets(),
), ),
), ),
], ],
@@ -387,8 +388,7 @@ class _SeccionIdioma extends StatelessWidget {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final estadoIdioma = context.watch<EstadoIdioma>(); final estadoIdioma = context.watch<EstadoIdioma>();
final locale = estadoIdioma.localeSeleccionado; final locale = estadoIdioma.localeSeleccionado;
final valorActual = final valorActual = locale == null ? _codigoSistema : _codigoLocale(locale);
locale == null ? _codigoSistema : _codigoLocale(locale);
return PluriGlassSurface( return PluriGlassSurface(
child: Column( child: Column(
@@ -479,7 +479,8 @@ class _FormularioDuracionTimer extends StatefulWidget {
const _FormularioDuracionTimer(); const _FormularioDuracionTimer();
@override @override
State<_FormularioDuracionTimer> createState() => _FormularioDuracionTimerState(); State<_FormularioDuracionTimer> createState() =>
_FormularioDuracionTimerState();
} }
class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> { class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
@@ -506,9 +507,9 @@ class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> {
seconds: _leer(_segundosCtrl), seconds: _leer(_segundosCtrl),
); );
if (duracion <= Duration.zero) { if (duracion <= Duration.zero) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l10n.durationGreaterThanZero)), context,
); ).showSnackBar(SnackBar(content: Text(l10n.durationGreaterThanZero)));
return; return;
} }
Navigator.pop(context, duracion); Navigator.pop(context, duracion);
@@ -593,7 +594,9 @@ class _SeccionEcualizador extends StatelessWidget {
const Spacer(), const Spacer(),
Chip( Chip(
label: Text( label: Text(
estado.ecualizadorActivo ? l10n.equalizerActive : l10n.equalizerDisabled, estado.ecualizadorActivo
? l10n.equalizerActive
: l10n.equalizerDisabled,
), ),
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,
), ),
@@ -701,7 +704,10 @@ class _SeccionOrdenListas extends StatelessWidget {
class _SeccionGruposFavoritos extends StatelessWidget { class _SeccionGruposFavoritos extends StatelessWidget {
const _SeccionGruposFavoritos(); const _SeccionGruposFavoritos();
Future<void> _editarGrupo(BuildContext context, [GrupoFavoritos? grupo]) async { Future<void> _editarGrupo(
BuildContext context, [
GrupoFavoritos? grupo,
]) async {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final controller = TextEditingController(text: grupo?.nombre ?? ''); final controller = TextEditingController(text: grupo?.nombre ?? '');
final nombre = await showModalBottomSheet<String>( final nombre = await showModalBottomSheet<String>(
@@ -717,7 +723,9 @@ class _SeccionGruposFavoritos extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
grupo == null ? l10n.favoriteGroupsAdd : l10n.favoriteGroupsEdit, grupo == null
? l10n.favoriteGroupsAdd
: l10n.favoriteGroupsEdit,
style: Theme.of(ctx).textTheme.titleLarge, style: Theme.of(ctx).textTheme.titleLarge,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -756,17 +764,26 @@ class _SeccionGruposFavoritos extends StatelessWidget {
} }
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(grupo == null ? l10n.favoriteGroupsCreated : l10n.favoriteGroupsUpdated)), SnackBar(
content: Text(
grupo == null
? l10n.favoriteGroupsCreated
: l10n.favoriteGroupsUpdated,
),
),
); );
} }
Future<void> _eliminarGrupo(BuildContext context, GrupoFavoritos grupo) async { Future<void> _eliminarGrupo(
BuildContext context,
GrupoFavoritos grupo,
) async {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
await context.read<EstadoRadio>().eliminarGrupoFavoritos(grupo.id); await context.read<EstadoRadio>().eliminarGrupoFavoritos(grupo.id);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(l10n.favoriteGroupsDeleted)), context,
); ).showSnackBar(SnackBar(content: Text(l10n.favoriteGroupsDeleted)));
} }
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) => String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
@@ -808,8 +825,12 @@ class _SeccionGruposFavoritos extends StatelessWidget {
grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded, grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded,
), ),
title: Text(_nombreVisible(l10n, grupo)), title: Text(_nombreVisible(l10n, grupo)),
subtitle: grupo.esSinAsignar ? Text(l10n.favoriteGroupsProtectedHint) : null, subtitle:
trailing: grupo.esSinAsignar grupo.esSinAsignar
? Text(l10n.favoriteGroupsProtectedHint)
: null,
trailing:
grupo.esSinAsignar
? null ? null
: Wrap( : Wrap(
spacing: 4, spacing: 4,
@@ -911,7 +932,10 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
icon: const Icon(Icons.play_arrow_rounded), icon: const Icon(Icons.play_arrow_rounded),
label: Text(l10n.preferredStationPlay), label: Text(l10n.preferredStationPlay),
onPressed: onPressed:
() => context.read<EstadoRadio>().reproducirEmisoraPreferida(), () =>
context
.read<EstadoRadio>()
.reproducirEmisoraPreferida(),
), ),
), ),
], ],
@@ -925,7 +949,9 @@ class _SeccionEmisoraPreferida extends StatelessWidget {
estado.listaFavoritos.isNotEmpty estado.listaFavoritos.isNotEmpty
? estado.listaFavoritos ? estado.listaFavoritos
: estado.emisorasDisponiblesPreferencia; : estado.emisorasDisponiblesPreferencia;
final mapa = <String, Emisora>{for (final emisora in base) emisora.uuid: emisora}; final mapa = <String, Emisora>{
for (final emisora in base) emisora.uuid: emisora,
};
if (preferida != null) { if (preferida != null) {
mapa[preferida.uuid] = preferida; mapa[preferida.uuid] = preferida;
} }
@@ -1055,7 +1081,12 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottom = MediaQuery.of(context).viewInsets.bottom; final bottom = MediaQuery.of(context).viewInsets.bottom;
return Padding( 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( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
@@ -1089,7 +1120,9 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> {
), ),
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
validator: (v) { 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()); final uri = Uri.tryParse(v.trim());
if (uri == null || !uri.hasScheme) return 'URL no válida'; if (uri == null || !uri.hasScheme) return 'URL no válida';
return null; return null;
@@ -1143,9 +1176,13 @@ class _SeccionBackup extends StatelessWidget {
); );
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).backupExportError(e.toString())))); content: Text(
AppLocalizations.of(context).backupExportError(e.toString()),
),
),
);
} }
} }
} }
@@ -1197,9 +1234,13 @@ class _SeccionBackup extends StatelessWidget {
} }
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).backupImportError(e.toString())))); content: Text(
AppLocalizations.of(context).backupImportError(e.toString()),
),
),
);
} }
} }
} }
@@ -1264,7 +1305,9 @@ class _SeccionInfo extends StatelessWidget {
variant: PluriIconVariant.filled, variant: PluriIconVariant.filled,
), ),
title: const Text('PluriWave'), 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( (ctx, snap) => ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.favorite_outline), leading: const Icon(Icons.favorite_outline),
title: Text(AppLocalizations.of(ctx).savedFavoritesTitle), title: Text(
AppLocalizations.of(ctx).savedFavoritesTitle,
),
trailing: Text( trailing: Text(
snap.data?.toString() ?? '', snap.data?.toString() ?? '',
style: Theme.of(ctx).textTheme.bodyLarge, 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( ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.verified_outlined), leading: const Icon(Icons.verified_outlined),
title: Text(AppLocalizations.of(ctx).stationFilterTitle), 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), trailing: const Icon(Icons.check_circle, color: Colors.green),
), ),
const ListTile( 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 whats new.',
};
String _formatearDuracionTimer(Duration duracion) { String _formatearDuracionTimer(Duration duracion) {
final horas = duracion.inHours; final horas = duracion.inHours;
final minutos = duracion.inMinutes.remainder(60); final minutos = duracion.inMinutes.remainder(60);
+152 -50
View File
@@ -1,4 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart'; import '../estado/estado_alarmas.dart';
@@ -58,7 +58,10 @@ class PantallaAlarmas extends StatelessWidget {
); );
} }
Future<void> _abrirEditor(BuildContext context, {AlarmaMusical? alarma}) async { Future<void> _abrirEditor(
BuildContext context, {
AlarmaMusical? alarma,
}) async {
await showModalBottomSheet<void>( await showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@@ -77,6 +80,10 @@ class _PanelProximaAlarma extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final proxima = estado.proximaAlarma; final proxima = estado.proximaAlarma;
final activasSinProxima =
estado.alarmas
.where((a) => a.activa && a.proximaEjecucion == null)
.length;
return PluriGlassSurface( return PluriGlassSurface(
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28), glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28),
child: Row( child: Row(
@@ -88,7 +95,11 @@ class _PanelProximaAlarma extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( 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( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
), ),
@@ -96,7 +107,9 @@ class _PanelProximaAlarma extends StatelessWidget {
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
proxima == null 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!)}', : '${proxima.nombre} · ${_fechaHora(proxima.proximaEjecucion!)}',
), ),
], ],
@@ -125,7 +138,10 @@ class _TarjetaAlarma extends StatelessWidget {
children: [ children: [
Row( Row(
children: [ 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), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
@@ -133,7 +149,9 @@ class _TarjetaAlarma extends StatelessWidget {
children: [ children: [
Text( Text(
_hora(alarma), _hora(alarma),
style: Theme.of(context).textTheme.headlineMedium?.copyWith( style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
letterSpacing: -1, letterSpacing: -1,
), ),
@@ -153,11 +171,18 @@ class _TarjetaAlarma extends StatelessWidget {
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
_InfoChip(icon: Icons.repeat_rounded, label: _programacion(alarma)), _InfoChip(
_InfoChip(icon: Icons.snooze_rounded, label: '${alarma.snoozeMinutos} min'), icon: Icons.repeat_rounded,
label: _programacion(alarma),
),
_InfoChip(
icon: Icons.snooze_rounded,
label: '${alarma.snoozeMinutos} min',
),
_InfoChip( _InfoChip(
icon: Icons.beach_access_rounded, icon: Icons.beach_access_rounded,
label: alarma.sonarEnVacaciones label:
alarma.sonarEnVacaciones
? 'Suena en vacaciones' ? 'Suena en vacaciones'
: 'Pausa en vacaciones', : 'Pausa en vacaciones',
), ),
@@ -171,7 +196,8 @@ class _TarjetaAlarma extends StatelessWidget {
if (alarma.proximaEjecucion != null) if (alarma.proximaEjecucion != null)
_NoticeLine( _NoticeLine(
icon: Icons.event_available_rounded, icon: Icons.event_available_rounded,
text: 'Siguiente ejecución: ${_fechaHora(alarma.proximaEjecucion!)}', text:
'Siguiente ejecución: ${_fechaHora(alarma.proximaEjecucion!)}',
) )
else else
const _NoticeLine( const _NoticeLine(
@@ -182,7 +208,8 @@ class _TarjetaAlarma extends StatelessWidget {
const SizedBox(height: 6), const SizedBox(height: 6),
_NoticeLine( _NoticeLine(
icon: Icons.skip_next_rounded, 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) ...[ if (mensajeVacaciones != null) ...[
@@ -203,7 +230,8 @@ class _TarjetaAlarma extends StatelessWidget {
TextButton.icon( TextButton.icon(
icon: const Icon(Icons.skip_next_rounded), icon: const Icon(Icons.skip_next_rounded),
label: const Text('Omitir siguiente'), label: const Text('Omitir siguiente'),
onPressed: alarma.proximaEjecucion == null onPressed:
alarma.proximaEjecucion == null
? null ? null
: () async { : () async {
await estado.saltarProxima(alarma.id); await estado.saltarProxima(alarma.id);
@@ -305,7 +333,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
_nombreController = TextEditingController( _nombreController = TextEditingController(
text: alarma?.nombre ?? 'Despertador musical', 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; _fecha = alarma?.fechaUnica ?? ahora;
_tipo = alarma?.tipoProgramacion ?? TipoProgramacionAlarma.unica; _tipo = alarma?.tipoProgramacion ?? TipoProgramacionAlarma.unica;
_diasSemana = {...alarma?.diasSemana ?? const <int>[]}; _diasSemana = {...alarma?.diasSemana ?? const <int>[]};
@@ -332,7 +363,9 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
if (mounted) context.read<EstadoRadio>().cargarFavoritos(); if (mounted) context.read<EstadoRadio>().cargarFavoritos();
}); });
} }
if (_emisora == null && widget.alarma == null && radio.emisoraPreferida != null) { if (_emisora == null &&
widget.alarma == null &&
radio.emisoraPreferida != null) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _emisora == null) { if (mounted && _emisora == null) {
setState(() => _emisora = radio.emisoraPreferida); setState(() => _emisora = radio.emisoraPreferida);
@@ -352,7 +385,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
children: [ children: [
Row( Row(
children: [ 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), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
@@ -390,7 +426,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
icon: Icons.event_rounded, icon: Icons.event_rounded,
label: 'Fecha', label: 'Fecha',
value: _fechaCorta(_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), const SizedBox(height: 12),
SegmentedButton<TipoProgramacionAlarma>( SegmentedButton<TipoProgramacionAlarma>(
segments: const [ segments: const [
ButtonSegment(value: TipoProgramacionAlarma.unica, label: Text('Una vez')), ButtonSegment(
ButtonSegment(value: TipoProgramacionAlarma.diaria, label: Text('Diaria')), value: TipoProgramacionAlarma.unica,
ButtonSegment(value: TipoProgramacionAlarma.diasSemana, label: Text('Días')), label: Text('Una vez'),
),
ButtonSegment(
value: TipoProgramacionAlarma.diaria,
label: Text('Diaria'),
),
ButtonSegment(
value: TipoProgramacionAlarma.diasSemana,
label: Text('Días'),
),
], ],
selected: {_tipo}, selected: {_tipo},
onSelectionChanged: (value) => setState(() => _tipo = value.first), onSelectionChanged:
(value) => setState(() => _tipo = value.first),
), ),
if (_tipo == TipoProgramacionAlarma.diasSemana) ...[ if (_tipo == TipoProgramacionAlarma.diasSemana) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
@@ -414,15 +463,21 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
FilterChip( FilterChip(
label: Text(_diaCorto(i)), label: Text(_diaCorto(i)),
selected: _diasSemana.contains(i), selected: _diasSemana.contains(i),
onSelected: (selected) => setState(() { onSelected:
selected ? _diasSemana.add(i) : _diasSemana.remove(i); (selected) => setState(() {
selected
? _diasSemana.add(i)
: _diasSemana.remove(i);
}), }),
), ),
], ],
), ),
], ],
const SizedBox(height: 14), 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<int>( SegmentedButton<int>(
segments: const [ segments: const [
ButtonSegment(value: 3, label: Text('3 min')), ButtonSegment(value: 3, label: Text('3 min')),
@@ -430,10 +485,14 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
ButtonSegment(value: 10, label: Text('10 min')), ButtonSegment(value: 10, label: Text('10 min')),
], ],
selected: {_snooze}, selected: {_snooze},
onSelectionChanged: (value) => setState(() => _snooze = value.first), onSelectionChanged:
(value) => setState(() => _snooze = value.first),
), ),
const SizedBox(height: 14), 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( Slider(
value: _volumen, value: _volumen,
min: 0.25, min: 0.25,
@@ -444,13 +503,27 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
), ),
DropdownButtonFormField<SonidoInternoAlarma>( DropdownButtonFormField<SonidoInternoAlarma>(
initialValue: _sonidoInterno, initialValue: _sonidoInterno,
decoration: const InputDecoration(labelText: 'Sonido seguro interno'), decoration: const InputDecoration(
labelText: 'Sonido seguro interno',
),
items: const [ items: const [
DropdownMenuItem(value: SonidoInternoAlarma.amanecer, child: Text('Amanecer cálido')), DropdownMenuItem(
DropdownMenuItem(value: SonidoInternoAlarma.campanaSuave, child: Text('Campana suave')), value: SonidoInternoAlarma.amanecer,
DropdownMenuItem(value: SonidoInternoAlarma.pulsoDigital, child: Text('Pulso digital')), 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), const SizedBox(height: 8),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
@@ -474,7 +547,8 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
), ),
), ),
], ],
onChanged: (uuid) => setState(() { onChanged:
(uuid) => setState(() {
if (uuid == null || uuid.isEmpty) { if (uuid == null || uuid.isEmpty) {
_emisora = null; _emisora = null;
return; return;
@@ -493,7 +567,8 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon( child: FilledButton.tonalIcon(
onPressed: () => setState(() => _emisora = radio.emisoraActual), onPressed:
() => setState(() => _emisora = radio.emisoraActual),
icon: const Icon(Icons.add_task_rounded), icon: const Icon(Icons.add_task_rounded),
label: const Text('Usar emisora actual'), label: const Text('Usar emisora actual'),
), ),
@@ -503,10 +578,16 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
SwitchListTile.adaptive( SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
value: _sonarEnVacaciones, value: _sonarEnVacaciones,
onChanged: (value) => setState(() => _sonarEnVacaciones = value), onChanged:
secondary: const _AssetIcon('assets/icons/alarmas/vacation_wave.png', size: 42), (value) => setState(() => _sonarEnVacaciones = value),
secondary: const _AssetIcon(
'assets/icons/alarmas/vacation_wave.png',
size: 42,
),
title: const Text('Sonar durante vacaciones'), 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), const SizedBox(height: 16),
FilledButton.icon( FilledButton.icon(
@@ -556,13 +637,15 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
diasSemana: _diasSemana.toList()..sort(), diasSemana: _diasSemana.toList()..sort(),
)) ))
.copyWith( .copyWith(
nombre: _nombreController.text.trim().isEmpty nombre:
_nombreController.text.trim().isEmpty
? 'Despertador musical' ? 'Despertador musical'
: _nombreController.text.trim(), : _nombreController.text.trim(),
hora: _hora.hour, hora: _hora.hour,
minuto: _hora.minute, minuto: _hora.minute,
tipoProgramacion: _tipo, tipoProgramacion: _tipo,
diasSemana: _tipo == TipoProgramacionAlarma.diasSemana diasSemana:
_tipo == TipoProgramacionAlarma.diasSemana
? (_diasSemana.toList()..sort()) ? (_diasSemana.toList()..sort())
: const [], : const [],
fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null, fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
@@ -600,13 +683,21 @@ class _AccesoDiagnostico extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final diag = estado.diagnostico; final diag = estado.diagnostico;
return TextButton.icon( 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( label: Text(
diag == null diag == null
? 'Revisar fiabilidad Android' ? 'Revisar fiabilidad Android'
: 'Fiabilidad: exactas ${diag.puedeProgramarExactas ? 'OK' : 'pendiente'} · notificaciones ${diag.notificacionesPermitidas ? 'OK' : 'pendiente'}', : '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: [ children: [
Row( Row(
children: [ 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), const SizedBox(width: 10),
Expanded( Expanded(
child: Text( child: Text(
@@ -723,9 +817,9 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
children: [ children: [
Text( Text(
'Nuevo rango de vacaciones', 'Nuevo rango de vacaciones',
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(
fontWeight: FontWeight.w900, context,
), ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextField( TextField(
@@ -811,7 +905,8 @@ class _AssetIcon extends StatelessWidget {
width: size, width: size,
height: size, height: size,
fit: BoxFit.contain, 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: [ children: [
_AssetIcon(icon, size: 34), _AssetIcon(icon, size: 34),
const SizedBox(width: 8), 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) { String _programacion(AlarmaMusical alarma) {
return switch (alarma.tipoProgramacion) { 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.diaria => 'Diaria',
TipoProgramacionAlarma.diasSemana => 'Días: ${alarma.diasSemana.map(_diaCorto).join(', ')}', TipoProgramacionAlarma.diasSemana =>
'Días: ${alarma.diasSemana.map(_diaCorto).join(', ')}',
}; };
} }
@@ -942,7 +1044,7 @@ String _diaCorto(int dia) => switch (dia) {
DateTime.saturday => 'Sáb', DateTime.saturday => 'Sáb',
DateTime.sunday => 'Dom', DateTime.sunday => 'Dom',
_ => '?', _ => '?',
}; };
String _diaLargo(int dia) => switch (dia) { String _diaLargo(int dia) => switch (dia) {
DateTime.monday => 'lunes', DateTime.monday => 'lunes',
@@ -953,7 +1055,7 @@ String _diaLargo(int dia) => switch (dia) {
DateTime.saturday => 'sábado', DateTime.saturday => 'sábado',
DateTime.sunday => 'domingo', DateTime.sunday => 'domingo',
_ => 'día', _ => 'día',
}; };
String _mes(int mes) => switch (mes) { String _mes(int mes) => switch (mes) {
1 => 'enero', 1 => 'enero',
@@ -969,4 +1071,4 @@ String _mes(int mes) => switch (mes) {
11 => 'noviembre', 11 => 'noviembre',
12 => 'diciembre', 12 => 'diciembre',
_ => 'mes', _ => 'mes',
}; };
+21 -2
View File
@@ -411,9 +411,15 @@ class _GrabacionWidget extends StatelessWidget {
? estado.detenerGrabacion ? estado.detenerGrabacion
: () => _mostrarDialogoGrabacion(context), : () => _mostrarDialogoGrabacion(context),
), ),
if (!activa)
IconButton.filledTonal(
tooltip: 'Abrir carpeta',
icon: const Icon(Icons.folder_open_rounded),
onPressed: () => _abrirCarpetaGrabaciones(context),
),
if (!activa && hayUltimaGrabacion) if (!activa && hayUltimaGrabacion)
IconButton.filledTonal( IconButton.filledTonal(
tooltip: 'Abrir ?ltima grabaci?n', tooltip: 'Abrir última grabación',
icon: const Icon(Icons.audio_file_rounded), icon: const Icon(Icons.audio_file_rounded),
onPressed: () => _abrirUltimaGrabacion(context), onPressed: () => _abrirUltimaGrabacion(context),
), ),
@@ -430,7 +436,20 @@ class _GrabacionWidget extends StatelessWidget {
if (!context.mounted) return; if (!context.mounted) return;
if (!abierto) { if (!abierto) {
messenger.showSnackBar( 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<void> _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'),
),
); );
} }
} }
+13 -1
View File
@@ -74,7 +74,7 @@ class ServicioAlarmasAndroid {
debugPrint( debugPrint(
'[PluriWave][alarmas] programar id=${alarma.id} nombre=${alarma.nombre} proxima=${proxima.toIso8601String()} preaviso=${proxima.subtract(const Duration(minutes: 30)).toIso8601String()}', '[PluriWave][alarmas] programar id=${alarma.id} nombre=${alarma.nombre} proxima=${proxima.toIso8601String()} preaviso=${proxima.subtract(const Duration(minutes: 30)).toIso8601String()}',
); );
await _channel.invokeMethod<void>('scheduleAlarm', { final programada = await _channel.invokeMethod<bool>('scheduleAlarm', {
'id': alarma.id, 'id': alarma.id,
'title': alarma.nombre, 'title': alarma.nombre,
'triggerAtMillis': proxima.millisecondsSinceEpoch, 'triggerAtMillis': proxima.millisecondsSinceEpoch,
@@ -85,6 +85,11 @@ class ServicioAlarmasAndroid {
'fallbackSound': alarma.sonidoInterno.name, 'fallbackSound': alarma.sonidoInterno.name,
'volume': alarma.volumen, 'volume': alarma.volumen,
}); });
if (programada != true) {
throw StateError(
'Android no pudo programar una alarma exacta. Revisa el permiso de alarmas exactas.',
);
}
} }
Future<void> cancelar(String alarmaId) => Future<void> cancelar(String alarmaId) =>
@@ -96,6 +101,13 @@ class ServicioAlarmasAndroid {
Future<void> detenerSonidoNativo(String alarmaId) => Future<void> detenerSonidoNativo(String alarmaId) =>
_logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId}); _logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId});
Future<bool> solicitarPermisoAlarmasExactas() async {
final abierto = await _channel.invokeMethod<bool>(
'requestExactAlarmPermission',
);
return abierto ?? false;
}
Future<DiagnosticoAlarmasAndroid> diagnostico() async { Future<DiagnosticoAlarmasAndroid> diagnostico() async {
debugPrint('[PluriWave][alarmas] diagnostico android'); debugPrint('[PluriWave][alarmas] diagnostico android');
final raw = await _channel.invokeMethod<Map<Object?, Object?>>( final raw = await _channel.invokeMethod<Map<Object?, Object?>>(
+168
View File
@@ -0,0 +1,168 @@
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
class NotaVersionPluri {
const NotaVersionPluri({
required this.version,
required this.resumen,
required this.markdown,
});
final String version;
final String resumen;
final String markdown;
}
class ContenidoAyudaPluri {
const ContenidoAyudaPluri({
required this.onboarding,
required this.notas,
required this.versionActual,
});
final String onboarding;
final List<NotaVersionPluri> notas;
final String versionActual;
}
class ServicioContenidoApp {
static const _keyOnboardingVisto = 'pluri_onboarding_visto_v1';
static const _keyVersionVista = 'pluri_ultima_version_novedades_v1';
static const _versiones = ['0.1.47'];
Future<bool> debeMostrarInicio() async {
final prefs = await SharedPreferences.getInstance();
final info = await PackageInfo.fromPlatform();
final versionActual = info.version;
return !(prefs.getBool(_keyOnboardingVisto) ?? false) ||
prefs.getString(_keyVersionVista) != versionActual;
}
Future<void> marcarVisto() async {
final prefs = await SharedPreferences.getInstance();
final info = await PackageInfo.fromPlatform();
await prefs.setBool(_keyOnboardingVisto, true);
await prefs.setString(_keyVersionVista, info.version);
}
Future<ContenidoAyudaPluri> cargar(
String codigoIdioma, {
bool soloPendientes = false,
}) async {
final info = await PackageInfo.fromPlatform();
final prefs = await SharedPreferences.getInstance();
final ultimaVista = prefs.getString(_keyVersionVista);
final idioma = _idiomaSoportado(codigoIdioma);
final mostrarOnboarding =
!soloPendientes || !(prefs.getBool(_keyOnboardingVisto) ?? false);
final onboarding =
mostrarOnboarding
? await _cargarMarkdown(
'assets/content/onboarding/$idioma.md',
fallback: 'assets/content/onboarding/en.md',
)
: '';
final notas = <NotaVersionPluri>[];
for (final version in _versiones) {
if (soloPendientes &&
ultimaVista != null &&
_compararVersiones(version, ultimaVista.split('+').first) <= 0) {
continue;
}
final markdown = await _cargarMarkdown(
'assets/content/updates/$idioma/$version.md',
fallback: 'assets/content/updates/en/$version.md',
);
if (markdown.trim().isEmpty) continue;
notas.add(
NotaVersionPluri(
version: version,
resumen: _resumen(markdown),
markdown: markdown,
),
);
}
return ContenidoAyudaPluri(
onboarding: onboarding,
notas: notas,
versionActual: _versionCompleta(info),
);
}
Future<String> _cargarMarkdown(
String path, {
required String fallback,
}) async {
try {
return await rootBundle.loadString(path);
} catch (_) {
return rootBundle.loadString(fallback);
}
}
String _idiomaSoportado(String codigo) {
const soportados = {
'ar',
'bn',
'de',
'en',
'es',
'fr',
'hi',
'id',
'it',
'ja',
'pt',
'ru',
'zh',
};
return soportados.contains(codigo) ? codigo : 'en';
}
String _resumen(String markdown) {
for (final line in markdown.split('\n')) {
final limpia = line.trim();
if (limpia.toLowerCase().startsWith('resumen:')) {
return limpia.substring(limpia.indexOf(':') + 1).trim();
}
if (limpia.toLowerCase().startsWith('summary:')) {
return limpia.substring(limpia.indexOf(':') + 1).trim();
}
if (limpia.contains(':')) {
final etiqueta = limpia.substring(0, limpia.indexOf(':')).toLowerCase();
const etiquetasResumen = {
'résumé',
'zusammenfassung',
'riepilogo',
'resumo',
'ملخص',
'সারাংশ',
'सारांश',
'ringkasan',
'概要',
'摘要',
'резюме',
};
if (etiquetasResumen.contains(etiqueta)) {
return limpia.substring(limpia.indexOf(':') + 1).trim();
}
}
}
return '';
}
String _versionCompleta(PackageInfo info) =>
'${info.version}+${info.buildNumber}';
int _compararVersiones(String a, String b) {
final pa = a.split('.').map((e) => int.tryParse(e) ?? 0).toList();
final pb = b.split('.').map((e) => int.tryParse(e) ?? 0).toList();
for (var i = 0; i < 3; i++) {
final va = i < pa.length ? pa[i] : 0;
final vb = i < pb.length ? pb[i] : 0;
if (va != vb) return va.compareTo(vb);
}
return 0;
}
}
@@ -1,6 +1,8 @@
import '../modelos/alarma_musical.dart'; import '../modelos/alarma_musical.dart';
class ServicioProgramacionAlarmas { class ServicioProgramacionAlarmas {
static const Duration toleranciaDisparoInminente = Duration(seconds: 90);
DateTime? calcularProxima({ DateTime? calcularProxima({
required AlarmaMusical alarma, required AlarmaMusical alarma,
required DateTime desde, required DateTime desde,
@@ -24,25 +26,27 @@ class ServicioProgramacionAlarmas {
final primerCandidato = final primerCandidato =
alarma.tipoProgramacion == TipoProgramacionAlarma.unica alarma.tipoProgramacion == TipoProgramacionAlarma.unica
? inicio ? inicio
: inicio.isAfter(desde) : _sigueSiendoInminente(inicio, desde)
? inicio ? inicio
: inicio.add(const Duration(days: 1)); : inicio.add(const Duration(days: 1));
return switch (alarma.tipoProgramacion) { return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.unica => TipoProgramacionAlarma.unica =>
primerCandidato.isAfter(desde) && _sigueSiendoInminente(primerCandidato, desde) &&
_esValida(alarma, primerCandidato, vacaciones, excepciones) _esValida(alarma, primerCandidato, vacaciones, excepciones)
? primerCandidato ? _normalizarInminente(primerCandidato, desde)
: null, : null,
TipoProgramacionAlarma.diaria => _buscarDiaria( TipoProgramacionAlarma.diaria => _buscarDiaria(
alarma, alarma,
primerCandidato, primerCandidato,
desde,
vacaciones, vacaciones,
excepciones, excepciones,
), ),
TipoProgramacionAlarma.diasSemana => _buscarPorDiasSemana( TipoProgramacionAlarma.diasSemana => _buscarPorDiasSemana(
alarma, alarma,
primerCandidato, primerCandidato,
desde,
vacaciones, vacaciones,
excepciones, excepciones,
), ),
@@ -60,12 +64,16 @@ class ServicioProgramacionAlarmas {
DateTime? _buscarDiaria( DateTime? _buscarDiaria(
AlarmaMusical alarma, AlarmaMusical alarma,
DateTime candidato, DateTime candidato,
DateTime desde,
List<RangoVacaciones> vacaciones, List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones, List<ExcepcionAlarma> excepciones,
) { ) {
var actual = candidato; var actual = candidato;
for (var i = 0; i < 370; i++) { for (var i = 0; i < 370; i++) {
if (_esValida(alarma, actual, vacaciones, excepciones)) return actual; if (_sigueSiendoInminente(actual, desde) &&
_esValida(alarma, actual, vacaciones, excepciones)) {
return _normalizarInminente(actual, desde);
}
actual = actual.add(const Duration(days: 1)); actual = actual.add(const Duration(days: 1));
} }
return null; return null;
@@ -74,6 +82,7 @@ class ServicioProgramacionAlarmas {
DateTime? _buscarPorDiasSemana( DateTime? _buscarPorDiasSemana(
AlarmaMusical alarma, AlarmaMusical alarma,
DateTime candidato, DateTime candidato,
DateTime desde,
List<RangoVacaciones> vacaciones, List<RangoVacaciones> vacaciones,
List<ExcepcionAlarma> excepciones, List<ExcepcionAlarma> excepciones,
) { ) {
@@ -81,8 +90,9 @@ class ServicioProgramacionAlarmas {
var actual = candidato; var actual = candidato;
for (var i = 0; i < 370; i++) { for (var i = 0; i < 370; i++) {
if (alarma.diasSemana.contains(actual.weekday) && if (alarma.diasSemana.contains(actual.weekday) &&
_sigueSiendoInminente(actual, desde) &&
_esValida(alarma, actual, vacaciones, excepciones)) { _esValida(alarma, actual, vacaciones, excepciones)) {
return actual; return _normalizarInminente(actual, desde);
} }
actual = actual.add(const Duration(days: 1)); actual = actual.add(const Duration(days: 1));
} }
@@ -111,4 +121,13 @@ class ServicioProgramacionAlarmas {
a.day == b.day && a.day == b.day &&
a.hour == b.hour && a.hour == b.hour &&
a.minute == b.minute; a.minute == b.minute;
bool _sigueSiendoInminente(DateTime candidato, DateTime desde) =>
candidato.isAfter(desde) ||
desde.difference(candidato) <= toleranciaDisparoInminente;
DateTime _normalizarInminente(DateTime candidato, DateTime desde) =>
candidato.isAfter(desde)
? candidato
: desde.add(const Duration(seconds: 2));
} }
+88
View File
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
class PluriMarkdown extends StatelessWidget {
const PluriMarkdown(this.markdown, {super.key});
final String markdown;
@override
Widget build(BuildContext context) {
final widgets = <Widget>[];
for (final raw in markdown.split('\n')) {
final line = raw.trimRight();
if (line.trim().isEmpty) {
widgets.add(const SizedBox(height: 8));
} else if (line.startsWith('# ')) {
widgets.add(_Heading(line.substring(2), level: 1));
} else if (line.startsWith('## ')) {
widgets.add(_Heading(line.substring(3), level: 2));
} else if (line.startsWith('- ')) {
widgets.add(_Bullet(line.substring(2)));
} else if (!line.toLowerCase().startsWith('resumen:') &&
!line.toLowerCase().startsWith('summary:')) {
widgets.add(_Paragraph(line));
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widgets,
);
}
}
class _Heading extends StatelessWidget {
const _Heading(this.text, {required this.level});
final String text;
final int level;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: EdgeInsets.only(top: level == 1 ? 0 : 14, bottom: 8),
child: Text(
text,
style: (level == 1
? theme.textTheme.headlineSmall
: theme.textTheme.titleMedium)
?.copyWith(fontWeight: FontWeight.w900),
),
);
}
}
class _Paragraph extends StatelessWidget {
const _Paragraph(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(text, style: Theme.of(context).textTheme.bodyMedium),
);
}
class _Bullet extends StatelessWidget {
const _Bullet(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.auto_awesome_rounded,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(child: Text(text)),
],
),
);
}
+198
View File
@@ -0,0 +1,198 @@
import 'package:flutter/material.dart';
import '../servicios/servicio_contenido_app.dart';
import 'pluri_glass_surface.dart';
import 'pluri_markdown.dart';
class PluriOnboardingDialog {
PluriOnboardingDialog._();
static final _servicio = ServicioContenidoApp();
static Future<void> mostrarSiProcede(BuildContext context) async {
if (!await _servicio.debeMostrarInicio()) return;
if (!context.mounted) return;
await mostrar(context, soloPendientes: true);
await _servicio.marcarVisto();
}
static Future<void> mostrar(
BuildContext context, {
bool soloPendientes = false,
}) async {
final idioma = Localizations.localeOf(context).languageCode;
final contenido = await _servicio.cargar(
idioma,
soloPendientes: soloPendientes,
);
if (!context.mounted) return;
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => _PluriOnboardingContent(contenido: contenido),
);
}
}
class _PluriOnboardingContent extends StatelessWidget {
const _PluriOnboardingContent({required this.contenido});
final ContenidoAyudaPluri contenido;
@override
Widget build(BuildContext context) {
final labels = _labels(Localizations.localeOf(context).languageCode);
final size = MediaQuery.sizeOf(context);
return Dialog(
insetPadding: const EdgeInsets.all(16),
backgroundColor: Colors.transparent,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 720,
maxHeight: size.height * 0.86,
),
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(32),
glowColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.28),
child: Column(
children: [
Row(
children: [
Container(
width: 54,
height: 54,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.primary,
Theme.of(context).colorScheme.tertiary,
],
),
),
child: const Icon(
Icons.graphic_eq_rounded,
color: Colors.white,
),
),
const SizedBox(width: 14),
Expanded(
child: Text(
labels.title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
IconButton(
tooltip: labels.close,
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close_rounded),
),
],
),
const SizedBox(height: 12),
Expanded(
child: ListView(
children: [
if (contenido.onboarding.trim().isNotEmpty)
PluriMarkdown(contenido.onboarding),
if (contenido.notas.isNotEmpty) ...[
const SizedBox(height: 18),
Text(
labels.news,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 8),
for (final nota in contenido.notas)
ExpansionTile(
tilePadding: EdgeInsets.zero,
title: Text('v${nota.version}'),
subtitle:
nota.resumen.isEmpty ? null : Text(nota.resumen),
children: [
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: PluriMarkdown(nota.markdown),
),
],
),
],
],
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.check_rounded),
label: Text(labels.start),
),
),
],
),
),
),
);
}
}
_OnboardingLabels _labels(String languageCode) {
return switch (languageCode) {
'es' => const _OnboardingLabels(
title: 'Bienvenido a PluriWave',
news: 'Novedades',
start: 'Empezar',
close: 'Cerrar',
),
'fr' => const _OnboardingLabels(
title: 'Bienvenue sur PluriWave',
news: 'Nouveautés',
start: 'Commencer',
close: 'Fermer',
),
'de' => const _OnboardingLabels(
title: 'Willkommen bei PluriWave',
news: 'Neuigkeiten',
start: 'Starten',
close: 'Schließen',
),
'it' => const _OnboardingLabels(
title: 'Benvenuto in PluriWave',
news: 'Novità',
start: 'Inizia',
close: 'Chiudi',
),
'pt' => const _OnboardingLabels(
title: 'Bem-vindo ao PluriWave',
news: 'Novidades',
start: 'Começar',
close: 'Fechar',
),
_ => const _OnboardingLabels(
title: 'Welcome to PluriWave',
news: 'Whats new',
start: 'Start',
close: 'Close',
),
};
}
class _OnboardingLabels {
const _OnboardingLabels({
required this.title,
required this.news,
required this.start,
required this.close,
});
final String title;
final String news;
final String start;
final String close;
}
+1
View File
@@ -72,3 +72,4 @@ flutter:
- assets/audio/ - assets/audio/
- assets/mockups/ - assets/mockups/
- assets/generated/ - assets/generated/
- assets/content/
@@ -86,7 +86,9 @@ void main() {
); );
}); });
test('servicio limpia proxima ejecucion obsoleta al recalcular unica vencida', () async { test(
'servicio limpia proxima ejecucion obsoleta al recalcular unica vencida',
() async {
SharedPreferences.setMockInitialValues({}); SharedPreferences.setMockInitialValues({});
final servicioAlarmas = ServicioAlarmas( final servicioAlarmas = ServicioAlarmas(
reloj: () => DateTime(2026, 5, 22, 10), reloj: () => DateTime(2026, 5, 22, 10),
@@ -105,6 +107,26 @@ void main() {
final guardada = await servicioAlarmas.guardarAlarma(alarma); final guardada = await servicioAlarmas.guardarAlarma(alarma);
expect(guardada.alarmas.single.proximaEjecucion, isNull); expect(guardada.alarmas.single.proximaEjecucion, isNull);
},
);
test('mantiene alarma unica creada dentro del mismo minuto', () {
final alarma = AlarmaMusical(
id: 'a5',
nombre: 'Ahora',
hora: 20,
minuto: 13,
tipoProgramacion: TipoProgramacionAlarma.unica,
diasSemana: const [],
fechaUnica: DateTime(2026, 5, 23),
);
final proxima = servicio.calcularProxima(
alarma: alarma,
desde: DateTime(2026, 5, 23, 20, 13, 45),
);
expect(proxima, DateTime(2026, 5, 23, 20, 13, 47));
}); });
}); });
} }