From 896349ad5f598e36da19d4a9d228fff1750bcddf Mon Sep 17 00:00:00 2001 From: freetlab Date: Sat, 23 May 2026 01:22:37 +0200 Subject: [PATCH] feat(app): add onboarding and harden alarms --- android/app/src/main/AndroidManifest.xml | 13 + .../freetimelab/pluriwave/AlarmScheduler.kt | 153 +++++++- .../es/freetimelab/pluriwave/MainActivity.kt | 149 +++++++- .../pluriwave/PluriWaveAlarmService.kt | 3 +- .../pluriwave/PluriWaveBootReceiver.kt | 26 ++ assets/content/onboarding/ar.md | 27 ++ assets/content/onboarding/bn.md | 27 ++ assets/content/onboarding/de.md | 27 ++ assets/content/onboarding/en.md | 27 ++ assets/content/onboarding/es.md | 27 ++ assets/content/onboarding/fr.md | 27 ++ assets/content/onboarding/hi.md | 27 ++ assets/content/onboarding/id.md | 27 ++ assets/content/onboarding/it.md | 27 ++ assets/content/onboarding/ja.md | 27 ++ assets/content/onboarding/pt.md | 27 ++ assets/content/onboarding/ru.md | 27 ++ assets/content/onboarding/zh.md | 27 ++ assets/content/updates/ar/0.1.47.md | 11 + assets/content/updates/bn/0.1.47.md | 11 + assets/content/updates/de/0.1.47.md | 11 + assets/content/updates/en/0.1.47.md | 11 + assets/content/updates/es/0.1.47.md | 11 + assets/content/updates/fr/0.1.47.md | 11 + assets/content/updates/hi/0.1.47.md | 12 + assets/content/updates/id/0.1.47.md | 11 + assets/content/updates/it/0.1.47.md | 11 + assets/content/updates/ja/0.1.47.md | 11 + assets/content/updates/pt/0.1.47.md | 11 + assets/content/updates/ru/0.1.47.md | 11 + assets/content/updates/zh/0.1.47.md | 11 + lib/app.dart | 23 +- lib/estado/estado_alarmas.dart | 41 +- lib/estado/estado_radio.dart | 16 +- lib/pantallas/pantalla_ajustes.dart | 171 ++++++--- lib/pantallas/pantalla_alarmas.dart | 354 +++++++++++------- lib/pantallas/pantalla_reproductor.dart | 23 +- lib/servicios/servicio_alarmas_android.dart | 14 +- lib/servicios/servicio_contenido_app.dart | 168 +++++++++ .../servicio_programacion_alarmas.dart | 29 +- lib/widgets/pluri_markdown.dart | 88 +++++ lib/widgets/pluri_onboarding_dialog.dart | 198 ++++++++++ pubspec.yaml | 1 + .../servicio_programacion_alarmas_test.dart | 48 ++- 44 files changed, 1772 insertions(+), 241 deletions(-) create mode 100644 android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveBootReceiver.kt create mode 100644 assets/content/onboarding/ar.md create mode 100644 assets/content/onboarding/bn.md create mode 100644 assets/content/onboarding/de.md create mode 100644 assets/content/onboarding/en.md create mode 100644 assets/content/onboarding/es.md create mode 100644 assets/content/onboarding/fr.md create mode 100644 assets/content/onboarding/hi.md create mode 100644 assets/content/onboarding/id.md create mode 100644 assets/content/onboarding/it.md create mode 100644 assets/content/onboarding/ja.md create mode 100644 assets/content/onboarding/pt.md create mode 100644 assets/content/onboarding/ru.md create mode 100644 assets/content/onboarding/zh.md create mode 100644 assets/content/updates/ar/0.1.47.md create mode 100644 assets/content/updates/bn/0.1.47.md create mode 100644 assets/content/updates/de/0.1.47.md create mode 100644 assets/content/updates/en/0.1.47.md create mode 100644 assets/content/updates/es/0.1.47.md create mode 100644 assets/content/updates/fr/0.1.47.md create mode 100644 assets/content/updates/hi/0.1.47.md create mode 100644 assets/content/updates/id/0.1.47.md create mode 100644 assets/content/updates/it/0.1.47.md create mode 100644 assets/content/updates/ja/0.1.47.md create mode 100644 assets/content/updates/pt/0.1.47.md create mode 100644 assets/content/updates/ru/0.1.47.md create mode 100644 assets/content/updates/zh/0.1.47.md create mode 100644 lib/servicios/servicio_contenido_app.dart create mode 100644 lib/widgets/pluri_markdown.dart create mode 100644 lib/widgets/pluri_onboarding_dialog.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 51a6655..04a81ea 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + @@ -73,6 +74,18 @@ + + + + + + + + + + now) { @@ -94,6 +110,7 @@ class AlarmScheduler(private val context: Context) { } else { Log.d(tag, "alarm.schedule preNotice skipped id=$id") } + return true } private fun scheduleMainAlarm( @@ -123,6 +140,12 @@ class AlarmScheduler(private val context: Context) { alarmIntent ) Log.d(tag, "alarm.schedule setExactAndAllowWhileIdle fallback OK id=$id") + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Log.e( + tag, + "alarm.schedule exact permission missing; refusing inexact fallback id=$id" + ) + return false } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { alarmManager.setAndAllowWhileIdle( AlarmManager.RTC_WAKEUP, @@ -147,16 +170,10 @@ class AlarmScheduler(private val context: Context) { fun cancelAlarm(id: String) { Log.d(tag, "alarm.cancel id=$id") - for (slot in 1..3) { - alarmManager.cancel( - PendingIntent.getBroadcast( - context, - requestCode(id, slot), - Intent(context, PluriWaveAlarmReceiver::class.java), - PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE - ) ?: continue - ) - } + removeScheduledAlarm(id) + cancelPending("fire", pendingFireIntent(id, PendingIntent.FLAG_NO_CREATE)) + cancelPending("show", pendingShowIntent(id, PendingIntent.FLAG_NO_CREATE)) + cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE)) NotificationManagerCompat.from(context).cancel( PluriWaveAlarmReceiver.notificationIdForAlarm(id) ) @@ -176,5 +193,119 @@ class AlarmScheduler(private val context: Context) { alarmManager.canScheduleExactAlarms() } + fun reschedulePersistedAlarms() { + val now = System.currentTimeMillis() + for (id in prefs().getStringSet(KEY_IDS, emptySet()).orEmpty()) { + val raw = prefs().getString("$KEY_ALARM_PREFIX$id", null) ?: continue + try { + val data = JSONObject(raw) + val triggerAt = data.optLong("triggerAtMillis", 0L) + if (triggerAt <= now) { + Log.d(tag, "alarm.reschedule skip stale id=$id triggerAt=$triggerAt") + removeScheduledAlarm(id) + continue + } + scheduleAlarm( + id = id, + title = data.optString("title", "PluriWave"), + triggerAtMillis = triggerAt, + preNoticeAtMillis = data.optLong("preNoticeAtMillis", 0L), + stationName = data.optString("stationName").takeIf { it.isNotBlank() }, + stationUrl = data.optString("stationUrl").takeIf { it.isNotBlank() }, + fallbackSound = data.optString("fallbackSound").takeIf { it.isNotBlank() }, + volume = data.optDouble("volume", 0.85).toFloat() + ) + Log.d(tag, "alarm.reschedule OK id=$id") + } catch (error: Throwable) { + Log.e(tag, "alarm.reschedule failed id=$id", error) + } + } + } + + private fun saveScheduledAlarm( + id: String, + title: String, + triggerAtMillis: Long, + preNoticeAtMillis: Long, + stationName: String?, + stationUrl: String?, + fallbackSound: String?, + volume: Float + ) { + val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() + ids.add(id) + val data = JSONObject().apply { + put("title", title) + put("triggerAtMillis", triggerAtMillis) + put("preNoticeAtMillis", preNoticeAtMillis) + put("stationName", stationName) + put("stationUrl", stationUrl) + put("fallbackSound", fallbackSound) + put("volume", volume) + } + prefs().edit() + .putStringSet(KEY_IDS, ids) + .putString("$KEY_ALARM_PREFIX$id", data.toString()) + .apply() + } + + private fun removeScheduledAlarm(id: String) { + val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet() + ids.remove(id) + prefs().edit() + .putStringSet(KEY_IDS, ids) + .remove("$KEY_ALARM_PREFIX$id") + .apply() + } + + private fun prefs() = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + private fun cancelPending(name: String, pendingIntent: PendingIntent?) { + if (pendingIntent == null) { + Log.d(tag, "alarm.cancel $name no pending intent") + return + } + alarmManager.cancel(pendingIntent) + pendingIntent.cancel() + Log.d(tag, "alarm.cancel $name OK") + } + + private fun pendingFireIntent(id: String, flags: Int): PendingIntent? = + PendingIntent.getBroadcast( + context, + requestCode(id, 1), + Intent(context, PluriWaveAlarmReceiver::class.java).apply { + action = PluriWaveAlarmReceiver.ACTION_FIRE + }, + flags or PendingIntent.FLAG_IMMUTABLE + ) + + private fun pendingShowIntent(id: String, flags: Int): PendingIntent? = + PendingIntent.getActivity( + context, + requestCode(id, 2), + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE) + }, + flags or PendingIntent.FLAG_IMMUTABLE + ) + + private fun pendingPreNoticeIntent(id: String, flags: Int): PendingIntent? = + PendingIntent.getBroadcast( + context, + requestCode(id, 3), + Intent(context, PluriWaveAlarmReceiver::class.java).apply { + action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE + }, + flags or PendingIntent.FLAG_IMMUTABLE + ) + private fun requestCode(id: String, slot: Int): Int = 31 * id.hashCode() + slot + + companion object { + private const val PREFS = "pluriwave_alarm_scheduler" + private const val KEY_IDS = "scheduled_alarm_ids" + private const val KEY_ALARM_PREFIX = "scheduled_alarm_" + } } diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt index 1a3c291..4182d75 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -7,6 +7,8 @@ import android.content.ActivityNotFoundException import android.content.pm.PackageManager import android.net.Uri import android.media.audiofx.Visualizer +import android.app.AlarmManager +import android.content.Context import android.os.Build import android.os.Environment import android.os.Handler @@ -73,7 +75,7 @@ class MainActivity : AudioServiceActivity() { Log.w(tag, "alarm.channel scheduleAlarm invalid id=$id triggerAtMillis=$triggerAtMillis") result.error("INVALID_ALARM", "Missing alarm id or trigger time", null) } else { - alarmScheduler.scheduleAlarm( + val scheduled = alarmScheduler.scheduleAlarm( id, title, triggerAtMillis, @@ -83,7 +85,7 @@ class MainActivity : AudioServiceActivity() { fallbackSound, volume ) - result.success(null) + result.success(scheduled) } } "cancelAlarm" -> { @@ -92,7 +94,6 @@ class MainActivity : AudioServiceActivity() { if (id == null) { result.error("INVALID_ALARM", "Missing alarm id", null) } else { - PluriWaveAlarmService.stop(this, id) alarmScheduler.cancelAlarm(id) result.success(null) } @@ -129,6 +130,10 @@ class MainActivity : AudioServiceActivity() { ) ) } + "requestExactAlarmPermission" -> { + Log.d(tag, "alarm.channel requestExactAlarmPermission") + result.success(requestExactAlarmPermission()) + } "getInitialAlarmIntent" -> { val payload = alarmPayload(intent) Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload") @@ -153,6 +158,15 @@ class MainActivity : AudioServiceActivity() { result.success(openDirectory(path)) } } + "viewDirectory" -> { + val path = call.argument("path") + Log.d(tag, "file_actions.viewDirectory path=$path") + if (path.isNullOrBlank()) { + result.success(false) + } else { + result.success(viewDirectory(path)) + } + } "openFile" -> { val path = call.argument("path") val mimeType = call.argument("mimeType") ?: "audio/*" @@ -193,25 +207,120 @@ class MainActivity : AudioServiceActivity() { ) } - private fun openDirectory(path: String): Boolean { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { - addFlags( - Intent.FLAG_GRANT_READ_URI_PERMISSION or - Intent.FLAG_GRANT_WRITE_URI_PERMISSION or - Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or - Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + private fun requestExactAlarmPermission(): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true + val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager + if (alarmManager.canScheduleExactAlarms()) return true + return try { + startActivity( + Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply { + data = Uri.parse("package:$packageName") + } ) - directoryTreeUri(path)?.let { putExtra(DocumentsContract.EXTRA_INITIAL_URI, it) } + true + } catch (error: Throwable) { + Log.e(tag, "alarm.channel requestExactAlarmPermission failed", error) + false } + } + + private fun openDirectory(path: String): Boolean { + val folder = File(path) + if (!folder.exists()) { + Log.w(tag, "file_actions.openDirectory missing path=$path") + return false + } + if (!folder.isDirectory) { + Log.w(tag, "file_actions.openDirectory not directory path=$path") + return false + } + + val fileProviderIntent = runCatching { + val uri = FileProvider.getUriForFile( + this, + "$packageName.fileprovider", + folder + ) + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "resource/folder") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + }.getOrNull() + + val documentIntent = Intent(Intent.ACTION_VIEW).apply { + directoryTreeUri(path)?.let { uri -> + setDataAndType(uri, DocumentsContract.Document.MIME_TYPE_DIR) + } + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + + val opened = + openIntentSafely(fileProviderIntent, "file_actions.openDirectory fileProvider", path) || + openIntentSafely(documentIntent, "file_actions.openDirectory documents", path) + if (!opened) { + Log.w(tag, "file_actions.openDirectory unable to open path=$path") + } + return opened + } + + private fun viewDirectory(path: String): Boolean { + val directory = File(path) + if (!directory.exists()) { + directory.mkdirs() + } + + val candidates = mutableListOf() + directoryDocumentUri(path)?.let { uri -> + candidates.add( + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "vnd.android.document/directory") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + ) + candidates.add( + Intent(Intent.ACTION_VIEW).apply { + setData(uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + ) + } + try { + val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", directory) + candidates.add( + Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "resource/folder") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + ) + } catch (error: Throwable) { + Log.w(tag, "file_actions.viewDirectory fileprovider unavailable path=$path", error) + } + + for (intent in candidates) { + try { + startActivity(Intent.createChooser(intent, "Abrir carpeta")) + Log.d(tag, "file_actions.viewDirectory launched path=$path") + return true + } catch (_: ActivityNotFoundException) { + Log.w(tag, "file_actions.viewDirectory no activity for candidate path=$path") + } catch (error: Throwable) { + Log.e(tag, "file_actions.viewDirectory candidate failed path=$path", error) + } + } + return false + } + + private fun openIntentSafely(intent: Intent?, origin: String, path: String): Boolean { + if (intent == null || intent.data == null) return false return try { startActivity(intent) - Log.d(tag, "file_actions.openDirectory launched path=$path") + Log.d(tag, "$origin launched path=$path") true } catch (_: ActivityNotFoundException) { - Log.w(tag, "file_actions.openDirectory no activity for path=$path") + Log.w(tag, "$origin no activity for path=$path") false } catch (error: Throwable) { - Log.e(tag, "file_actions.openDirectory failed path=$path", error) + Log.e(tag, "$origin failed path=$path", error) false } } @@ -257,6 +366,18 @@ class MainActivity : AudioServiceActivity() { ) } + private fun directoryDocumentUri(path: String): Uri? { + val external = Environment.getExternalStorageDirectory()?.absolutePath ?: return null + if (!path.startsWith(external)) return null + + val relative = path.removePrefix(external).trimStart('/') + val documentId = if (relative.isBlank()) "primary:" else "primary:$relative" + return DocumentsContract.buildDocumentUri( + "com.android.externalstorage.documents", + documentId + ) + } + private fun startVisualizerWhenAllowed() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && checkSelfPermission(Manifest.permission.RECORD_AUDIO) diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt index c64e7bb..899b983 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt @@ -234,7 +234,8 @@ class PluriWaveAlarmService : Service() { putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId) } try { - context.startService(intent) + context.stopService(intent) + Log.d(TAG, "alarm.service stop requested id=$alarmId") } catch (error: Throwable) { Log.e(TAG, "alarm.service stop request failed id=$alarmId", error) } diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveBootReceiver.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveBootReceiver.kt new file mode 100644 index 0000000..5a82ca4 --- /dev/null +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveBootReceiver.kt @@ -0,0 +1,26 @@ +package es.freetimelab.pluriwave + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log + +class PluriWaveBootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + Intent.ACTION_BOOT_COMPLETED, + Intent.ACTION_MY_PACKAGE_REPLACED, + Intent.ACTION_TIME_CHANGED, + Intent.ACTION_TIMEZONE_CHANGED, + "android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" -> { + Log.d(TAG, "alarm.bootReceiver action=${intent.action}") + AlarmScheduler(context).reschedulePersistedAlarms() + } + else -> Log.w(TAG, "alarm.bootReceiver unknown action=${intent.action}") + } + } + + companion object { + private const val TAG = "PluriWave" + } +} diff --git a/assets/content/onboarding/ar.md b/assets/content/onboarding/ar.md new file mode 100644 index 0000000..0984cca --- /dev/null +++ b/assets/content/onboarding/ar.md @@ -0,0 +1,27 @@ +# أهلاً بك في PluriWave + +PluriWave هو راديوك العالمي المميز: محطات مباشرة، مفضلات منظمة، تسجيلات، معادل صوت ومنبّهات موسيقية ضمن تجربة مصممة بعناية. + +## راديو مباشر + +- ابحث عن المحطات حسب الاسم والبلد واللغة والجودة. +- استكشف المحطات القريبة واكتشف محطات جديدة. +- رتّب القوائم حسب الاسم أو الجودة. + +## موسيقى بطريقتك + +- احفظ المفضلات ونظّمها في مجموعات. +- اضبط المعادل العام أو إعدادات كل محطة. +- استخدم مؤقّت النوم بمدد مخصّصة. + +## التسجيلات + +- سجّل الراديو بدون إعادة ضغط البث الأصلي. +- حدّد الحجم الأقصى للملف لتبقى بأمان. +- افتح مجلد التسجيلات للمشاركة أو النقل أو التعديل. + +## منبّهات موسيقية + +- أنشئ منبّهات لمرة واحدة أو يومية أو لأيام العمل. +- اختر محطة مفضلة وصوتاً داخلياً آمناً. +- استخدم العطلات وتخطي التنفيذ التالي والغفوة. diff --git a/assets/content/onboarding/bn.md b/assets/content/onboarding/bn.md new file mode 100644 index 0000000..7bcfb5f --- /dev/null +++ b/assets/content/onboarding/bn.md @@ -0,0 +1,27 @@ +# PluriWave-এ স্বাগতম + +PluriWave আপনার প্রিমিয়াম বিশ্ব রেডিও: লাইভ স্টেশন, গোছানো ফেভারিট, রেকর্ডিং, ইকুয়ালাইজার এবং মিউজিক অ্যালার্ম—সবই যত্নসহ তৈরি এক অভিজ্ঞতায়। + +## লাইভ রেডিও + +- নাম, দেশ, ভাষা ও মান অনুযায়ী স্টেশন খুঁজুন। +- কাছাকাছি স্টেশন দেখুন এবং নতুন রেডিও আবিষ্কার করুন। +- তালিকা নাম বা মান অনুযায়ী সাজান। + +## আপনার মতো করে সঙ্গীত + +- ফেভারিট সংরক্ষণ করুন এবং গ্রুপে সাজান। +- গ্লোবাল ইকুয়ালাইজার বা স্টেশনভিত্তিক প্রিসেট ঠিক করুন। +- নিজের মতো সময় দিয়ে স্লিপ টাইমার ব্যবহার করুন। + +## রেকর্ডিং + +- মূল স্ট্রিম রিকমপ্রেস না করে রেডিও রেকর্ড করুন। +- নিরাপদ থাকতে সর্বোচ্চ ফাইল সাইজ সীমা দিন। +- শেয়ার, সরানো বা সম্পাদনার জন্য রেকর্ডিং ফোল্ডার খুলুন। + +## মিউজিক অ্যালার্ম + +- একবার, প্রতিদিন বা কর্মদিবসের অ্যালার্ম তৈরি করুন। +- প্রিয় স্টেশন ও নিরাপদ অভ্যন্তরীণ সাউন্ড বেছে নিন। +- ছুটি, পরের রান স্কিপ এবং স্নুজ ব্যবহার করুন। diff --git a/assets/content/onboarding/de.md b/assets/content/onboarding/de.md new file mode 100644 index 0000000..a6e7c00 --- /dev/null +++ b/assets/content/onboarding/de.md @@ -0,0 +1,27 @@ +# Willkommen bei PluriWave + +PluriWave ist Ihr Premium-Weltradio: Live-Sender, organisierte Favoriten, Aufnahmen, Equalizer und Musikalarme in einer sorgfältig gestalteten Erfahrung. + +## Live-Radio + +- Suche nach Sendern nach Name, Land, Sprache und Qualität. +- Entdecke Sender in der Nähe und finde neue Radios. +- Sortiere Listen nach Name oder Qualität. + +## Musik auf deine Art + +- Speichere Favoriten und organisiere sie in Gruppen. +- Stelle den globalen Equalizer oder Sender-Presets ein. +- Nutze den Sleep-Timer mit eigenen Laufzeiten. + +## Aufnahmen + +- Nimm Radio auf, ohne den Original-Stream neu zu komprimieren. +- Begrenze die maximale Dateigröße für mehr Sicherheit. +- Öffne den Aufnahmeordner zum Teilen, Verschieben oder Bearbeiten von Dateien. + +## Musikalarme + +- Erstelle einmalige, tägliche oder Wochentags-Alarme. +- Wähle einen Lieblingssender und einen sicheren internen Ton. +- Nutze Feiertage, "nächste Ausführung überspringen" und Snooze. diff --git a/assets/content/onboarding/en.md b/assets/content/onboarding/en.md new file mode 100644 index 0000000..c050c24 --- /dev/null +++ b/assets/content/onboarding/en.md @@ -0,0 +1,27 @@ +# Welcome to PluriWave + +PluriWave is your premium world radio: live stations, organized favorites, recordings, equalizer and musical alarms in a carefully crafted experience. + +## Live radio + +- Search stations by name, country, language and quality. +- Explore nearby stations and discover new radio. +- Sort lists by name or quality. + +## Music your way + +- Save favorites and organize them into groups. +- Tune the global equalizer or per-station presets. +- Use the sleep timer with custom durations. + +## Recordings + +- Record radio without recompressing the original stream. +- Limit maximum file size to stay safe. +- Open the recordings folder to share, move or edit files. + +## Musical alarms + +- Create one-time, daily or weekday alarms. +- Choose a favorite station and a safe internal sound. +- Use holidays, skip-next execution and snooze. diff --git a/assets/content/onboarding/es.md b/assets/content/onboarding/es.md new file mode 100644 index 0000000..dd30476 --- /dev/null +++ b/assets/content/onboarding/es.md @@ -0,0 +1,27 @@ +# Bienvenido a PluriWave + +PluriWave es tu radio mundial premium: emisoras en directo, favoritos organizados, grabaciones, ecualizador y alarmas musicales en una experiencia cuidada. + +## Radio en vivo + +- Buscá emisoras por nombre, país, idioma y calidad. +- Explorá emisoras cercanas y descubrí radios nuevas. +- Ordená listas por nombre o calidad. + +## Música a tu manera + +- Guardá favoritos y organizalos en grupos. +- Ajustá el ecualizador global o los presets por emisora. +- Usá el temporizador de sueño con duraciones personalizadas. + +## Grabaciones + +- Grabá radio sin recomprimir el stream original. +- Limitá el tamaño máximo del archivo para evitar sustos. +- Abrí la carpeta de grabaciones para compartir, mover o editar archivos. + +## Alarmas musicales + +- Creá alarmas únicas, diarias o por días de semana. +- Elegí una emisora favorita y un sonido interno seguro. +- Usá vacaciones, omitir la próxima ejecución y posponer. diff --git a/assets/content/onboarding/fr.md b/assets/content/onboarding/fr.md new file mode 100644 index 0000000..33750da --- /dev/null +++ b/assets/content/onboarding/fr.md @@ -0,0 +1,27 @@ +# Bienvenue dans PluriWave + +PluriWave est votre radio mondiale premium : stations en direct, favoris organisés, enregistrements, égaliseur et alarmes musicales dans une expérience soignée. + +## Radio en direct + +- Recherchez des stations par nom, pays, langue et qualité. +- Explorez les stations proches et découvrez de nouvelles radios. +- Triez les listes par nom ou qualité. + +## Votre musique, votre style + +- Enregistrez vos favoris et organisez-les en groupes. +- Réglez l'égaliseur global ou des préréglages par station. +- Utilisez le minuteur de sommeil avec des durées personnalisées. + +## Enregistrements + +- Enregistrez la radio sans recompresser le flux d'origine. +- Limitez la taille maximale des fichiers pour rester serein. +- Ouvrez le dossier des enregistrements pour partager, déplacer ou modifier des fichiers. + +## Alarmes musicales + +- Créez des alarmes uniques, quotidiennes ou en semaine. +- Choisissez une station favorite et un son interne sûr. +- Utilisez les vacances, le saut de la prochaine exécution et le snooze. diff --git a/assets/content/onboarding/hi.md b/assets/content/onboarding/hi.md new file mode 100644 index 0000000..3a28c68 --- /dev/null +++ b/assets/content/onboarding/hi.md @@ -0,0 +1,27 @@ +# PluriWave में आपका स्वागत है + +PluriWave आपका प्रीमियम विश्व रेडियो है: लाइव स्टेशन, व्यवस्थित पसंदीदा, रिकॉर्डिंग, इक्वलाइज़र और संगीत अलार्म एक सधे हुए अनुभव में। + +## लाइव रेडियो + +- स्टेशन को नाम, देश, भाषा और गुणवत्ता से खोजें। +- पास के स्टेशन देखें और नए रेडियो खोजें। +- सूचियों को नाम या गुणवत्ता के अनुसार क्रमित करें। + +## संगीत आपके तरीके से + +- पसंदीदा सहेजें और उन्हें समूहों में व्यवस्थित करें। +- ग्लोबल इक्वलाइज़र या स्टेशन-विशिष्ट प्रीसेट समायोजित करें। +- अपनी पसंद की अवधि वाला स्लीप टाइमर इस्तेमाल करें। + +## रिकॉर्डिंग + +- मूल स्ट्रीम को फिर से कंप्रेस किए बिना रेडियो रिकॉर्ड करें। +- सुरक्षित रहने के लिए अधिकतम फ़ाइल आकार सीमित करें। +- फ़ाइलें साझा करने, स्थानांतरित करने या संपादित करने के लिए रिकॉर्डिंग फ़ोल्डर खोलें। + +## संगीत अलार्म + +- एक बार, रोज़ाना या कार्यदिवस अलार्म बनाएँ। +- पसंदीदा स्टेशन और सुरक्षित आंतरिक ध्वनि चुनें। +- छुट्टियाँ, अगला निष्पादन छोड़ना और स्नूज़ का उपयोग करें। diff --git a/assets/content/onboarding/id.md b/assets/content/onboarding/id.md new file mode 100644 index 0000000..367f420 --- /dev/null +++ b/assets/content/onboarding/id.md @@ -0,0 +1,27 @@ +# Selamat datang di PluriWave + +PluriWave adalah radio dunia premium Anda: stasiun langsung, favorit terorganisir, rekaman, equalizer, dan alarm musik dalam pengalaman yang dirancang rapi. + +## Radio langsung + +- Cari stasiun berdasarkan nama, negara, bahasa, dan kualitas. +- Jelajahi stasiun terdekat dan temukan radio baru. +- Urutkan daftar berdasarkan nama atau kualitas. + +## Musik sesuai cara Anda + +- Simpan favorit dan atur ke dalam grup. +- Atur equalizer global atau preset per stasiun. +- Gunakan sleep timer dengan durasi kustom. + +## Rekaman + +- Rekam radio tanpa mengompresi ulang stream asli. +- Batasi ukuran file maksimum agar tetap aman. +- Buka folder rekaman untuk berbagi, memindahkan, atau mengedit file. + +## Alarm musik + +- Buat alarm sekali, harian, atau hari kerja. +- Pilih stasiun favorit dan suara internal yang aman. +- Gunakan hari libur, lewati eksekusi berikutnya, dan snooze. diff --git a/assets/content/onboarding/it.md b/assets/content/onboarding/it.md new file mode 100644 index 0000000..097af86 --- /dev/null +++ b/assets/content/onboarding/it.md @@ -0,0 +1,27 @@ +# Benvenuto in PluriWave + +PluriWave è la tua radio mondiale premium: stazioni live, preferiti organizzati, registrazioni, equalizzatore e sveglie musicali in un'esperienza curata. + +## Radio live + +- Cerca stazioni per nome, paese, lingua e qualità. +- Esplora le stazioni vicine e scopri nuove radio. +- Ordina le liste per nome o qualità. + +## Musica a modo tuo + +- Salva i preferiti e organizzali in gruppi. +- Regola l'equalizzatore globale o i preset per stazione. +- Usa il timer di spegnimento con durate personalizzate. + +## Registrazioni + +- Registra la radio senza ricomprimere il flusso originale. +- Limita la dimensione massima dei file per stare tranquillo. +- Apri la cartella registrazioni per condividere, spostare o modificare i file. + +## Sveglie musicali + +- Crea sveglie singole, giornaliere o nei giorni feriali. +- Scegli una stazione preferita e un suono interno sicuro. +- Usa ferie, salto della prossima esecuzione e snooze. diff --git a/assets/content/onboarding/ja.md b/assets/content/onboarding/ja.md new file mode 100644 index 0000000..4a5a82e --- /dev/null +++ b/assets/content/onboarding/ja.md @@ -0,0 +1,27 @@ +# PluriWave へようこそ + +PluriWave は、ライブ局、お気に入り整理、録音、イコライザー、音楽アラームを備えた高品質なワールドラジオです。 + +## ライブラジオ + +- 名前、国、言語、音質で局を検索できます。 +- 近くの局を探して新しいラジオを見つけられます。 +- リストを名前または音質で並べ替えできます。 + +## あなた好みの音楽体験 + +- お気に入りを保存してグループで整理できます。 +- 全体イコライザーや局ごとのプリセットを調整できます。 +- 時間を指定できるスリープタイマーを使えます。 + +## 録音 + +- 元のストリームを再圧縮せずに録音できます。 +- 最大ファイルサイズを制限して安全に使えます。 +- 録音フォルダーを開いて共有・移動・編集できます。 + +## 音楽アラーム + +- 1回のみ、毎日、平日のアラームを作成できます。 +- お気に入り局と安全な内蔵サウンドを選べます。 +- 休日設定、次回スキップ、スヌーズに対応しています。 diff --git a/assets/content/onboarding/pt.md b/assets/content/onboarding/pt.md new file mode 100644 index 0000000..c9b12e4 --- /dev/null +++ b/assets/content/onboarding/pt.md @@ -0,0 +1,27 @@ +# Bem-vindo ao PluriWave + +PluriWave é seu rádio mundial premium: estações ao vivo, favoritos organizados, gravações, equalizador e alarmes musicais em uma experiência caprichada. + +## Rádio ao vivo + +- Procure estações por nome, país, idioma e qualidade. +- Explore estações próximas e descubra novas rádios. +- Ordene listas por nome ou qualidade. + +## Música do seu jeito + +- Salve favoritos e organize em grupos. +- Ajuste o equalizador global ou presets por estação. +- Use o timer de sono com durações personalizadas. + +## Gravações + +- Grave rádio sem recomprimir o stream original. +- Limite o tamanho máximo dos arquivos para evitar problemas. +- Abra a pasta de gravações para compartilhar, mover ou editar arquivos. + +## Alarmes musicais + +- Crie alarmes únicos, diários ou de dias úteis. +- Escolha uma estação favorita e um som interno seguro. +- Use feriados, pular próxima execução e soneca. diff --git a/assets/content/onboarding/ru.md b/assets/content/onboarding/ru.md new file mode 100644 index 0000000..974f1e0 --- /dev/null +++ b/assets/content/onboarding/ru.md @@ -0,0 +1,27 @@ +# Добро пожаловать в PluriWave + +PluriWave — ваше премиальное мировое радио: прямые станции, организованные избранные, записи, эквалайзер и музыкальные будильники в продуманном интерфейсе. + +## Прямое радио + +- Ищите станции по названию, стране, языку и качеству. +- Изучайте ближайшие станции и открывайте новое радио. +- Сортируйте списки по названию или качеству. + +## Музыка по-вашему + +- Сохраняйте избранное и организуйте его по группам. +- Настраивайте глобальный эквалайзер или пресеты для станций. +- Используйте таймер сна с нужной длительностью. + +## Записи + +- Записывайте радио без повторного сжатия исходного потока. +- Ограничивайте максимальный размер файла для безопасности. +- Открывайте папку записей, чтобы делиться, перемещать и редактировать файлы. + +## Музыкальные будильники + +- Создавайте разовые, ежедневные или будничные будильники. +- Выбирайте любимую станцию и безопасный встроенный звук. +- Используйте праздники, пропуск следующего запуска и отложенный сигнал. diff --git a/assets/content/onboarding/zh.md b/assets/content/onboarding/zh.md new file mode 100644 index 0000000..d857239 --- /dev/null +++ b/assets/content/onboarding/zh.md @@ -0,0 +1,27 @@ +# 欢迎使用 PluriWave + +PluriWave 是你的高品质全球电台:直播电台、分组收藏、录音、均衡器和音乐闹钟,体验精致流畅。 + +## 直播电台 + +- 按名称、国家、语言和音质搜索电台。 +- 探索附近电台,发现新的广播内容。 +- 按名称或音质排序列表。 + +## 按你的方式听音乐 + +- 保存收藏并按分组整理。 +- 调整全局均衡器或单电台预设。 +- 使用可自定义时长的睡眠定时器。 + +## 录音 + +- 录制电台时不重新压缩原始流。 +- 限制最大文件大小,更安全省心。 +- 打开录音文件夹以分享、移动或编辑文件。 + +## 音乐闹钟 + +- 创建一次性、每日或工作日闹钟。 +- 选择喜爱的电台和安全的内置提示音。 +- 支持假期、跳过下次执行和贪睡。 diff --git a/assets/content/updates/ar/0.1.47.md b/assets/content/updates/ar/0.1.47.md new file mode 100644 index 0000000..db23d11 --- /dev/null +++ b/assets/content/updates/ar/0.1.47.md @@ -0,0 +1,11 @@ +# v0.1.47 · منبّهات وملفات أكثر موثوقية + +الملخّص: عززنا أساس منبّهات Android وفصلنا بوضوح بين فتح المجلد وتغيير مساره. + +## التحسينات + +- أساس أصلي جديد للمنبّهات مع صوت داخلي آمن. +- تشخيص أفضل لأذونات Android الخاصة بالمنبّهات الدقيقة. +- المنبّهات التي تُنشأ في الدقيقة نفسها لم تعد تُستبعد بسبب الثواني. +- لوحة المنبّهات تميّز بين المنبّهات النشطة والمنبّهات بلا تنفيذ تالٍ صالح. +- فتح المجلد يحاول الآن فتح المسار المحفوظ؛ تغيير المسار أصبح منفصلاً. diff --git a/assets/content/updates/bn/0.1.47.md b/assets/content/updates/bn/0.1.47.md new file mode 100644 index 0000000..c572968 --- /dev/null +++ b/assets/content/updates/bn/0.1.47.md @@ -0,0 +1,11 @@ +# v0.1.47 · আরও নির্ভরযোগ্য অ্যালার্ম ও ফাইল + +সারাংশ: আমরা Android অ্যালার্মের ভিত্তি শক্ত করেছি এবং ফোল্ডার খোলা ও পথ পরিবর্তনকে স্পষ্টভাবে আলাদা করেছি। + +## উন্নতি + +- নিরাপদ অভ্যন্তরীণ সাউন্ডসহ অ্যালার্মের জন্য নতুন নেটিভ ভিত্তি। +- Android exact-alarm অনুমতির উন্নত ডায়াগনস্টিক। +- একই মিনিটে তৈরি অ্যালার্ম এখন সেকেন্ডের কারণে বাদ পড়ে না। +- অ্যালার্ম প্যানেল সক্রিয় অ্যালার্ম ও বৈধ পরের রানবিহীন অ্যালার্ম আলাদা করে। +- ফোল্ডার খোলা এখন সংরক্ষিত পথ খোলার চেষ্টা করে; পথ বদল আলাদা করা হয়েছে। diff --git a/assets/content/updates/de/0.1.47.md b/assets/content/updates/de/0.1.47.md new file mode 100644 index 0000000..f91b79b --- /dev/null +++ b/assets/content/updates/de/0.1.47.md @@ -0,0 +1,11 @@ +# v0.1.47 · Zuverlässigere Alarme und Dateien + +Zusammenfassung: Wir haben die Android-Alarmbasis verstärkt und das Öffnen eines Ordners klar vom Ändern seines Pfads getrennt. + +## Verbesserungen + +- Neue native Grundlage für Alarme mit sicherem internem Ton. +- Bessere Diagnose der Android-Berechtigung für exakte Alarme. +- Alarme, die in derselben Minute erstellt werden, werden wegen Sekunden nicht mehr verworfen. +- Das Alarmpanel unterscheidet aktive Alarme von Alarmen ohne gültige nächste Ausführung. +- Ordner öffnen versucht jetzt den gespeicherten Pfad zu öffnen; Pfad ändern ist separat. diff --git a/assets/content/updates/en/0.1.47.md b/assets/content/updates/en/0.1.47.md new file mode 100644 index 0000000..12f9c29 --- /dev/null +++ b/assets/content/updates/en/0.1.47.md @@ -0,0 +1,11 @@ +# v0.1.47 · More reliable alarms and files + +Summary: we reinforced the Android alarm foundation and clearly separated opening a folder from changing its path. + +## Improvements + +- New native foundation for alarms with a safe internal sound. +- Better Android exact-alarm permission diagnostics. +- Alarms created in the same minute are no longer discarded because of seconds. +- The alarms panel distinguishes active alarms from alarms without a valid next execution. +- Open folder now tries to open the saved path; change path is separate. diff --git a/assets/content/updates/es/0.1.47.md b/assets/content/updates/es/0.1.47.md new file mode 100644 index 0000000..c2ccc57 --- /dev/null +++ b/assets/content/updates/es/0.1.47.md @@ -0,0 +1,11 @@ +# v0.1.47 · Alarmas y archivos más fiables + +Resumen: reforzamos la base de alarmas Android y separamos claramente abrir carpeta de cambiar ruta. + +## Mejoras + +- Nueva base nativa para alarmas con sonido interno seguro. +- Mejor diagnóstico de permisos Android para alarmas exactas. +- Las alarmas creadas en el mismo minuto ya no se descartan por segundos. +- El panel de alarmas distingue entre alarmas activas y alarmas sin próxima ejecución válida. +- Abrir carpeta ahora intenta abrir la ruta guardada; cambiar ruta queda separado. diff --git a/assets/content/updates/fr/0.1.47.md b/assets/content/updates/fr/0.1.47.md new file mode 100644 index 0000000..65123e1 --- /dev/null +++ b/assets/content/updates/fr/0.1.47.md @@ -0,0 +1,11 @@ +# v0.1.47 · Alarmes et fichiers plus fiables + +Résumé : nous avons renforcé la base des alarmes Android et séparé clairement l'ouverture d'un dossier du changement de chemin. + +## Améliorations + +- Nouvelle base native pour les alarmes avec un son interne sûr. +- Meilleur diagnostic des permissions Android pour les alarmes exactes. +- Les alarmes créées dans la même minute ne sont plus ignorées à cause des secondes. +- Le panneau d'alarmes distingue les alarmes actives de celles sans prochaine exécution valide. +- Ouvrir le dossier tente désormais d'ouvrir le chemin enregistré ; changer le chemin est séparé. diff --git a/assets/content/updates/hi/0.1.47.md b/assets/content/updates/hi/0.1.47.md new file mode 100644 index 0000000..cc1c8ec --- /dev/null +++ b/assets/content/updates/hi/0.1.47.md @@ -0,0 +1,12 @@ +# v0.1.47 · अधिक भरोसेमंद अलार्म और फ़ाइलें + +सारांश: हमने Android अलार्म की बुनियाद मजबूत की और फ़ोल्डर खोलने को उसका पथ बदलने से स्पष्ट रूप से अलग किया। + +## सुधार + +- सुरक्षित आंतरिक ध्वनि के साथ अलार्म के लिए नई नेटिव बुनियाद। +- Android exact-alarm अनुमति के बेहतर निदान। +- एक ही मिनट में बने अलार्म अब सेकंड की वजह से हटाए नहीं जाते। +- अलार्म पैनल सक्रिय अलार्म और बिना वैध अगली निष्पादन के अलार्म में अंतर करता है। +- फ़ोल्डर खोलना अब सहेजा गया पथ खोलने की कोशिश करता है; पथ बदलना अलग है। + diff --git a/assets/content/updates/id/0.1.47.md b/assets/content/updates/id/0.1.47.md new file mode 100644 index 0000000..b6f7a5d --- /dev/null +++ b/assets/content/updates/id/0.1.47.md @@ -0,0 +1,11 @@ +# v0.1.47 · Alarm dan file lebih andal + +Ringkasan: kami memperkuat fondasi alarm Android dan memisahkan dengan jelas antara membuka folder dan mengubah jalurnya. + +## Peningkatan + +- Fondasi native baru untuk alarm dengan suara internal yang aman. +- Diagnostik izin exact-alarm Android yang lebih baik. +- Alarm yang dibuat pada menit yang sama tidak lagi dibuang karena detik. +- Panel alarm membedakan alarm aktif dari alarm tanpa eksekusi berikutnya yang valid. +- Buka folder sekarang mencoba membuka jalur tersimpan; ubah jalur dipisahkan. diff --git a/assets/content/updates/it/0.1.47.md b/assets/content/updates/it/0.1.47.md new file mode 100644 index 0000000..715b136 --- /dev/null +++ b/assets/content/updates/it/0.1.47.md @@ -0,0 +1,11 @@ +# v0.1.47 · Allarmi e file più affidabili + +Riepilogo: abbiamo rafforzato la base degli allarmi Android e separato chiaramente l'apertura di una cartella dalla modifica del suo percorso. + +## Miglioramenti + +- Nuova base nativa per gli allarmi con suono interno sicuro. +- Diagnostica migliore dei permessi Android per gli allarmi esatti. +- Gli allarmi creati nello stesso minuto non vengono più scartati a causa dei secondi. +- Il pannello allarmi distingue gli allarmi attivi da quelli senza prossima esecuzione valida. +- Apri cartella ora prova ad aprire il percorso salvato; cambia percorso è separato. diff --git a/assets/content/updates/ja/0.1.47.md b/assets/content/updates/ja/0.1.47.md new file mode 100644 index 0000000..227f12a --- /dev/null +++ b/assets/content/updates/ja/0.1.47.md @@ -0,0 +1,11 @@ +# v0.1.47 · より信頼できるアラームとファイル + +概要: Android のアラーム基盤を強化し、フォルダーを開く操作とパス変更を明確に分離しました。 + +## 改善点 + +- 安全な内部サウンドを備えた、新しいネイティブアラーム基盤を導入。 +- Android の正確なアラーム権限診断を改善。 +- 同じ分に作成したアラームが秒の違いで破棄されなくなりました。 +- アラームパネルで、有効な次回実行があるアラームとないアラームを区別。 +- フォルダーを開くは保存済みパスを開くようになり、パス変更は別操作になりました。 diff --git a/assets/content/updates/pt/0.1.47.md b/assets/content/updates/pt/0.1.47.md new file mode 100644 index 0000000..9509de6 --- /dev/null +++ b/assets/content/updates/pt/0.1.47.md @@ -0,0 +1,11 @@ +# v0.1.47 · Alarmes e arquivos mais confiáveis + +Resumo: reforçamos a base de alarmes do Android e separamos claramente abrir pasta de mudar caminho. + +## Melhorias + +- Nova base nativa para alarmes com som interno seguro. +- Melhor diagnóstico de permissões Android para alarmes exatos. +- Alarmes criados no mesmo minuto não são mais descartados por causa dos segundos. +- O painel de alarmes distingue alarmes ativos de alarmes sem próxima execução válida. +- Abrir pasta agora tenta abrir o caminho salvo; mudar caminho fica separado. diff --git a/assets/content/updates/ru/0.1.47.md b/assets/content/updates/ru/0.1.47.md new file mode 100644 index 0000000..8d759e8 --- /dev/null +++ b/assets/content/updates/ru/0.1.47.md @@ -0,0 +1,11 @@ +# v0.1.47 · Более надежные будильники и файлы + +Кратко: мы усилили основу будильников Android и четко разделили открытие папки и изменение её пути. + +## Улучшения + +- Новая нативная основа будильников с безопасным встроенным звуком. +- Улучшена диагностика разрешений Android для точных будильников. +- Будильники, созданные в ту же минуту, больше не отбрасываются из-за секунд. +- Панель будильников различает активные будильники и будильники без валидного следующего запуска. +- Открыть папку теперь пытается открыть сохраненный путь; изменение пути вынесено отдельно. diff --git a/assets/content/updates/zh/0.1.47.md b/assets/content/updates/zh/0.1.47.md new file mode 100644 index 0000000..6621722 --- /dev/null +++ b/assets/content/updates/zh/0.1.47.md @@ -0,0 +1,11 @@ +# v0.1.47 · 更可靠的闹钟与文件 + +摘要:我们强化了 Android 闹钟基础,并清晰区分了“打开文件夹”和“更改路径”。 + +## 改进 + +- 闹钟采用新的原生基础,配有安全的内置提示音。 +- 改进 Android 精确闹钟权限诊断。 +- 同一分钟创建的闹钟不再因秒数被丢弃。 +- 闹钟面板可区分活跃闹钟与无有效下次执行的闹钟。 +- “打开文件夹”现在会尝试打开已保存路径;“更改路径”独立处理。 diff --git a/lib/app.dart b/lib/app.dart index 8d08c94..ba1d082 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -16,6 +16,7 @@ import 'tema/pluriwave_theme.dart'; import 'widgets/pluri_bottom_navigation.dart'; import 'widgets/pluri_icon.dart'; import 'widgets/pluri_layout.dart'; +import 'widgets/pluri_onboarding_dialog.dart'; import 'widgets/pluri_wave_scaffold.dart'; import 'package:pluriwave/widgets/mini_reproductor.dart'; import 'servicios/servicio_alarmas_android.dart'; @@ -64,6 +65,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { EstadoRadio? _estadoSuscrito; bool _alarmaInicialProcesada = false; bool _alarmaSonandoActiva = false; + bool _onboardingInicialSolicitado = false; String? _alarmaSonandoId; static const _paginas = [ @@ -120,6 +122,10 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { _alarmaInicialProcesada = true; unawaited(_procesarAlarmaInicial(alarmas)); } + if (!_onboardingInicialSolicitado) { + _onboardingInicialSolicitado = true; + unawaited(_mostrarOnboardingInicial()); + } } @override @@ -196,9 +202,17 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { } } + Future _mostrarOnboardingInicial() async { + await Future.delayed(const Duration(milliseconds: 900)); + if (!mounted || _alarmaSonandoActiva) return; + await PluriOnboardingDialog.mostrarSiProcede(context); + } + Future _abrirAlarmaSonando(EventoAlarmaAndroid evento) async { final estado = context.read(); - await estado.refrescarProgramacion(); + if (estado.alarmas.isEmpty) { + await estado.cargarPersistidasSinRecalcular(); + } AlarmaMusical? alarma; for (final item in estado.alarmas) { if (item.id == evento.alarmaId) { @@ -206,7 +220,12 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { break; } } - if (alarma == null || !mounted) return; + if (alarma == null || !mounted) { + debugPrint( + '[PluriWave][alarmas] evento sin alarma persistida id=${evento.alarmaId} accion=${evento.accion}', + ); + return; + } if (evento.accion.endsWith('.SKIP_NEXT')) { await estado.saltarProxima(alarma.id); if (!mounted) return; diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index b0c4807..4f9e7ec 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -27,7 +27,8 @@ class EstadoAlarmas extends ChangeNotifier { DiagnosticoAlarmasAndroid? _diagnostico; Timer? _refresco; Timer? _vigilancia; - final _alarmasVencidasController = StreamController.broadcast(); + final _alarmasVencidasController = + StreamController.broadcast(); final Set _ejecucionesEmitidas = {}; static const _margenDisparoLocal = Duration(seconds: 45); bool _cargando = false; @@ -57,7 +58,9 @@ class EstadoAlarmas extends ChangeNotifier { try { final config = await servicio.recalcularTodas(); _aplicar(config); - debugPrint('[PluriWave][alarmas] cargadas=${_alarmas.length} vacaciones=${_vacaciones.length} excepciones=${_excepciones.length}'); + debugPrint( + '[PluriWave][alarmas] cargadas=${_alarmas.length} vacaciones=${_vacaciones.length} excepciones=${_excepciones.length}', + ); await _sincronizarTodas(); await cargarDiagnostico(); _activarRefresco(); @@ -71,16 +74,19 @@ class EstadoAlarmas extends ChangeNotifier { } Future guardarAlarma(AlarmaMusical alarma) async { - debugPrint('[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}'); + debugPrint( + '[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}', + ); final config = await servicio.guardarAlarma(alarma); _aplicar(config); try { final guardada = _alarmas.firstWhere((a) => a.id == alarma.id); - debugPrint('[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}'); + debugPrint( + '[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}', + ); await android.programar(guardada); } catch (e) { - _error = - 'Alarma guardada, pero Android no pudo programarla todavía: $e'; + _error = 'Alarma guardada, pero Android no pudo programarla todavía: $e'; } notifyListeners(); } @@ -96,6 +102,12 @@ class EstadoAlarmas extends ChangeNotifier { notifyListeners(); } + Future cargarPersistidasSinRecalcular() async { + final config = await servicio.cargar(); + _aplicar(config); + notifyListeners(); + } + void marcarEjecucionGestionada(AlarmaMusical alarma) { final proxima = alarma.proximaEjecucion; if (proxima == null) return; @@ -110,6 +122,7 @@ class EstadoAlarmas extends ChangeNotifier { debugPrint('[PluriWave][alarmas] eliminar id=$id'); final config = await servicio.eliminarAlarma(id); _aplicar(config); + await android.detenerSonidoNativo(id); await android.cancelar(id); notifyListeners(); } @@ -136,7 +149,9 @@ class EstadoAlarmas extends ChangeNotifier { } Future guardarVacaciones(List vacaciones) async { - debugPrint('[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}'); + debugPrint( + '[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}', + ); final config = await servicio.guardarVacaciones(vacaciones); _aplicar(config); await _sincronizarTodas(); @@ -145,7 +160,9 @@ class EstadoAlarmas extends ChangeNotifier { Future posponerAlarma(AlarmaMusical alarma, int minutos) async { final proxima = DateTime.now().add(Duration(minutes: minutos)); - debugPrint('[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos proxima=${proxima.toIso8601String()}'); + debugPrint( + '[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos proxima=${proxima.toIso8601String()}', + ); await android.ocultarNotificacionAlarma(alarma.id); await android.programar(alarma.copyWith(proximaEjecucion: proxima)); } @@ -184,7 +201,9 @@ class EstadoAlarmas extends ChangeNotifier { } Future _sincronizarTodas() async { - debugPrint('[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}'); + debugPrint( + '[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}', + ); for (final alarma in _alarmas) { await android.programar(alarma); } @@ -224,7 +243,9 @@ class EstadoAlarmas extends ChangeNotifier { continue; } if (_ejecucionesEmitidas.add(key)) { - debugPrint('[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}'); + debugPrint( + '[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}', + ); _alarmasVencidasController.add(alarma); } } diff --git a/lib/estado/estado_radio.dart b/lib/estado/estado_radio.dart index fc3303a..504643e 100644 --- a/lib/estado/estado_radio.dart +++ b/lib/estado/estado_radio.dart @@ -121,7 +121,8 @@ class EstadoRadio extends ChangeNotifier { List get resultadosBusqueda => _ordenarEmisoras(_resultadosBusqueda); List get emisorasCercanas => _ordenarEmisoras(_emisorasCercanas); List get listaFavoritos => _ordenarEmisoras(_listaFavoritos); - List get gruposFavoritos => List.unmodifiable(_gruposFavoritos); + List get gruposFavoritos => + List.unmodifiable(_gruposFavoritos); List get emisorasCustom => _ordenarEmisoras(_emisorasCustom); bool get cargandoPopulares => _cargandoPopulares; bool get cargandoBusqueda => _cargandoBusqueda; @@ -633,7 +634,7 @@ class EstadoRadio extends ChangeNotifier { await Directory(ruta).create(recursive: true); if (!kIsWeb && Platform.isAndroid) { final abierto = await _fileActionsChannel.invokeMethod( - 'openDirectory', + 'viewDirectory', {'path': ruta}, ); return abierto ?? false; @@ -650,13 +651,10 @@ class EstadoRadio extends ChangeNotifier { } debugPrint('[PluriWave][recordings] opening last file: ${archivo.path}'); if (!kIsWeb && Platform.isAndroid) { - final abierto = await _fileActionsChannel.invokeMethod( - 'openFile', - { - 'path': archivo.path, - 'mimeType': 'audio/*', - }, - ); + final abierto = await _fileActionsChannel.invokeMethod('openFile', { + 'path': archivo.path, + 'mimeType': 'audio/*', + }); return abierto ?? false; } return launchUrl( diff --git a/lib/pantallas/pantalla_ajustes.dart b/lib/pantallas/pantalla_ajustes.dart index 6996782..963530e 100644 --- a/lib/pantallas/pantalla_ajustes.dart +++ b/lib/pantallas/pantalla_ajustes.dart @@ -18,6 +18,7 @@ import '../widgets/ecualizador_widget.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; +import '../widgets/pluri_onboarding_dialog.dart'; import '../widgets/pluri_premium_widgets.dart'; class PantallaAjustes extends StatelessWidget { @@ -225,7 +226,6 @@ class _SeccionGrabaciones extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, ), - onTap: () => _seleccionarRuta(context), ), ), Wrap( @@ -353,7 +353,8 @@ class _SeccionTimerSueno extends StatelessWidget { icon: const Icon(Icons.restore_rounded), label: Text(l10n.timerSectionRestoreRecommended), onPressed: - () => context.read().restaurarTimerSuenoPresets(), + () => + context.read().restaurarTimerSuenoPresets(), ), ), ], @@ -387,8 +388,7 @@ class _SeccionIdioma extends StatelessWidget { final l10n = AppLocalizations.of(context); final estadoIdioma = context.watch(); final locale = estadoIdioma.localeSeleccionado; - final valorActual = - locale == null ? _codigoSistema : _codigoLocale(locale); + final valorActual = locale == null ? _codigoSistema : _codigoLocale(locale); return PluriGlassSurface( child: Column( @@ -479,7 +479,8 @@ class _FormularioDuracionTimer extends StatefulWidget { const _FormularioDuracionTimer(); @override - State<_FormularioDuracionTimer> createState() => _FormularioDuracionTimerState(); + State<_FormularioDuracionTimer> createState() => + _FormularioDuracionTimerState(); } class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> { @@ -506,9 +507,9 @@ class _FormularioDuracionTimerState extends State<_FormularioDuracionTimer> { seconds: _leer(_segundosCtrl), ); if (duracion <= Duration.zero) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.durationGreaterThanZero)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.durationGreaterThanZero))); return; } Navigator.pop(context, duracion); @@ -593,7 +594,9 @@ class _SeccionEcualizador extends StatelessWidget { const Spacer(), Chip( label: Text( - estado.ecualizadorActivo ? l10n.equalizerActive : l10n.equalizerDisabled, + estado.ecualizadorActivo + ? l10n.equalizerActive + : l10n.equalizerDisabled, ), visualDensity: VisualDensity.compact, ), @@ -701,7 +704,10 @@ class _SeccionOrdenListas extends StatelessWidget { class _SeccionGruposFavoritos extends StatelessWidget { const _SeccionGruposFavoritos(); - Future _editarGrupo(BuildContext context, [GrupoFavoritos? grupo]) async { + Future _editarGrupo( + BuildContext context, [ + GrupoFavoritos? grupo, + ]) async { final l10n = AppLocalizations.of(context); final controller = TextEditingController(text: grupo?.nombre ?? ''); final nombre = await showModalBottomSheet( @@ -717,7 +723,9 @@ class _SeccionGruposFavoritos extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - grupo == null ? l10n.favoriteGroupsAdd : l10n.favoriteGroupsEdit, + grupo == null + ? l10n.favoriteGroupsAdd + : l10n.favoriteGroupsEdit, style: Theme.of(ctx).textTheme.titleLarge, ), const SizedBox(height: 16), @@ -756,17 +764,26 @@ class _SeccionGruposFavoritos extends StatelessWidget { } if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(grupo == null ? l10n.favoriteGroupsCreated : l10n.favoriteGroupsUpdated)), + SnackBar( + content: Text( + grupo == null + ? l10n.favoriteGroupsCreated + : l10n.favoriteGroupsUpdated, + ), + ), ); } - Future _eliminarGrupo(BuildContext context, GrupoFavoritos grupo) async { + Future _eliminarGrupo( + BuildContext context, + GrupoFavoritos grupo, + ) async { final l10n = AppLocalizations.of(context); await context.read().eliminarGrupoFavoritos(grupo.id); if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.favoriteGroupsDeleted)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.favoriteGroupsDeleted))); } String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) => @@ -808,24 +825,28 @@ class _SeccionGruposFavoritos extends StatelessWidget { grupo.esSinAsignar ? Icons.lock_rounded : Icons.folder_rounded, ), title: Text(_nombreVisible(l10n, grupo)), - subtitle: grupo.esSinAsignar ? Text(l10n.favoriteGroupsProtectedHint) : null, - trailing: grupo.esSinAsignar - ? null - : Wrap( - spacing: 4, - children: [ - IconButton( - tooltip: l10n.favoriteGroupsEdit, - icon: const Icon(Icons.edit_rounded), - onPressed: () => _editarGrupo(context, grupo), - ), - IconButton( - tooltip: l10n.favoriteGroupsDelete, - icon: const Icon(Icons.delete_outline_rounded), - onPressed: () => _eliminarGrupo(context, grupo), - ), - ], - ), + subtitle: + grupo.esSinAsignar + ? Text(l10n.favoriteGroupsProtectedHint) + : null, + trailing: + grupo.esSinAsignar + ? null + : Wrap( + spacing: 4, + children: [ + IconButton( + tooltip: l10n.favoriteGroupsEdit, + icon: const Icon(Icons.edit_rounded), + onPressed: () => _editarGrupo(context, grupo), + ), + IconButton( + tooltip: l10n.favoriteGroupsDelete, + icon: const Icon(Icons.delete_outline_rounded), + onPressed: () => _eliminarGrupo(context, grupo), + ), + ], + ), ), ], ), @@ -911,7 +932,10 @@ class _SeccionEmisoraPreferida extends StatelessWidget { icon: const Icon(Icons.play_arrow_rounded), label: Text(l10n.preferredStationPlay), onPressed: - () => context.read().reproducirEmisoraPreferida(), + () => + context + .read() + .reproducirEmisoraPreferida(), ), ), ], @@ -925,7 +949,9 @@ class _SeccionEmisoraPreferida extends StatelessWidget { estado.listaFavoritos.isNotEmpty ? estado.listaFavoritos : estado.emisorasDisponiblesPreferencia; - final mapa = {for (final emisora in base) emisora.uuid: emisora}; + final mapa = { + for (final emisora in base) emisora.uuid: emisora, + }; if (preferida != null) { mapa[preferida.uuid] = preferida; } @@ -1055,7 +1081,12 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> { Widget build(BuildContext context) { final bottom = MediaQuery.of(context).viewInsets.bottom; return Padding( - padding: EdgeInsets.fromLTRB(PluriLayout.horizontal, PluriLayout.horizontal, PluriLayout.horizontal, PluriLayout.horizontal + bottom), + padding: EdgeInsets.fromLTRB( + PluriLayout.horizontal, + PluriLayout.horizontal, + PluriLayout.horizontal, + PluriLayout.horizontal + bottom, + ), child: Form( key: _formKey, child: Column( @@ -1089,7 +1120,9 @@ class _FormularioEmisoraState extends State<_FormularioEmisora> { ), keyboardType: TextInputType.url, validator: (v) { - if (v == null || v.trim().isEmpty) return AppLocalizations.of(context).requiredField; + if (v == null || v.trim().isEmpty) { + return AppLocalizations.of(context).requiredField; + } final uri = Uri.tryParse(v.trim()); if (uri == null || !uri.hasScheme) return 'URL no válida'; return null; @@ -1143,9 +1176,13 @@ class _SeccionBackup extends StatelessWidget { ); } catch (e) { if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).backupExportError(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).backupExportError(e.toString()), + ), + ), + ); } } } @@ -1197,9 +1234,13 @@ class _SeccionBackup extends StatelessWidget { } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(AppLocalizations.of(context).backupImportError(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context).backupImportError(e.toString()), + ), + ), + ); } } } @@ -1264,7 +1305,9 @@ class _SeccionInfo extends StatelessWidget { variant: PluriIconVariant.filled, ), title: const Text('PluriWave'), - subtitle: Text(AppLocalizations.of(ctx).appVersionSubtitle(version)), + subtitle: Text( + AppLocalizations.of(ctx).appVersionSubtitle(version), + ), ); }, ), @@ -1274,18 +1317,30 @@ class _SeccionInfo extends StatelessWidget { (ctx, snap) => ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.favorite_outline), - title: Text(AppLocalizations.of(ctx).savedFavoritesTitle), + title: Text( + AppLocalizations.of(ctx).savedFavoritesTitle, + ), trailing: Text( snap.data?.toString() ?? '—', style: Theme.of(ctx).textTheme.bodyLarge, ), ), ), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.help_outline_rounded), + title: Text(_helpTitle(ctx)), + subtitle: Text(_helpSubtitle(ctx)), + trailing: const Icon(Icons.chevron_right_rounded), + onTap: () => PluriOnboardingDialog.mostrar(ctx), + ), ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.verified_outlined), title: Text(AppLocalizations.of(ctx).stationFilterTitle), - subtitle: Text(AppLocalizations.of(ctx).stationFilterSubtitle), + subtitle: Text( + AppLocalizations.of(ctx).stationFilterSubtitle, + ), trailing: const Icon(Icons.check_circle, color: Colors.green), ), const ListTile( @@ -1302,6 +1357,28 @@ class _SeccionInfo extends StatelessWidget { } } +String _helpTitle(BuildContext context) => switch (Localizations.localeOf( + context, +).languageCode) { + 'es' => 'Ayuda y tutorial', + 'fr' => 'Aide et tutoriel', + 'de' => 'Hilfe und Tutorial', + 'it' => 'Aiuto e tutorial', + 'pt' => 'Ajuda e tutorial', + _ => 'Help and tutorial', +}; + +String _helpSubtitle(BuildContext context) => switch (Localizations.localeOf( + context, +).languageCode) { + 'es' => 'Repasá funciones, consejos y novedades de PluriWave.', + 'fr' => 'Revoyez les fonctions, conseils et nouveautés de PluriWave.', + 'de' => 'Funktionen, Tipps und Neuigkeiten von PluriWave ansehen.', + 'it' => 'Rivedi funzioni, consigli e novità di PluriWave.', + 'pt' => 'Revê funções, dicas e novidades do PluriWave.', + _ => 'Review PluriWave features, tips and what’s new.', +}; + String _formatearDuracionTimer(Duration duracion) { final horas = duracion.inHours; final minutos = duracion.inMinutes.remainder(60); diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index 460c601..14653f7 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../estado/estado_alarmas.dart'; @@ -58,7 +58,10 @@ class PantallaAlarmas extends StatelessWidget { ); } - Future _abrirEditor(BuildContext context, {AlarmaMusical? alarma}) async { + Future _abrirEditor( + BuildContext context, { + AlarmaMusical? alarma, + }) async { await showModalBottomSheet( context: context, isScrollControlled: true, @@ -77,6 +80,10 @@ class _PanelProximaAlarma extends StatelessWidget { @override Widget build(BuildContext context) { final proxima = estado.proximaAlarma; + final activasSinProxima = + estado.alarmas + .where((a) => a.activa && a.proximaEjecucion == null) + .length; return PluriGlassSurface( glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.28), child: Row( @@ -88,7 +95,11 @@ class _PanelProximaAlarma extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - proxima == null ? 'Sin alarmas activas' : 'Próxima alarma', + proxima == null + ? activasSinProxima > 0 + ? 'Alarmas activas sin próxima ejecución' + : 'Sin alarmas activas' + : 'Próxima alarma', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w900, ), @@ -96,7 +107,9 @@ class _PanelProximaAlarma extends StatelessWidget { const SizedBox(height: 4), Text( proxima == null - ? 'Creá una alarma y PluriWave calculará la siguiente ejecución automáticamente.' + ? activasSinProxima > 0 + ? 'Hay $activasSinProxima alarma(s) activas, pero ahora mismo no tienen una fecha futura válida. Revisá fecha, días y vacaciones.' + : 'Creá una alarma y PluriWave calculará la siguiente ejecución automáticamente.' : '${proxima.nombre} · ${_fechaHora(proxima.proximaEjecucion!)}', ), ], @@ -125,7 +138,10 @@ class _TarjetaAlarma extends StatelessWidget { children: [ Row( children: [ - const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 64), + const _AssetIcon( + 'assets/icons/alarmas/alarm_music.png', + size: 64, + ), const SizedBox(width: 12), Expanded( child: Column( @@ -133,7 +149,9 @@ class _TarjetaAlarma extends StatelessWidget { children: [ Text( _hora(alarma), - style: Theme.of(context).textTheme.headlineMedium?.copyWith( + style: Theme.of( + context, + ).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.w900, letterSpacing: -1, ), @@ -153,13 +171,20 @@ class _TarjetaAlarma extends StatelessWidget { spacing: 8, runSpacing: 8, children: [ - _InfoChip(icon: Icons.repeat_rounded, label: _programacion(alarma)), - _InfoChip(icon: Icons.snooze_rounded, label: '${alarma.snoozeMinutos} min'), + _InfoChip( + icon: Icons.repeat_rounded, + label: _programacion(alarma), + ), + _InfoChip( + icon: Icons.snooze_rounded, + label: '${alarma.snoozeMinutos} min', + ), _InfoChip( icon: Icons.beach_access_rounded, - label: alarma.sonarEnVacaciones - ? 'Suena en vacaciones' - : 'Pausa en vacaciones', + label: + alarma.sonarEnVacaciones + ? 'Suena en vacaciones' + : 'Pausa en vacaciones', ), _InfoChip( icon: Icons.volume_up_rounded, @@ -171,7 +196,8 @@ class _TarjetaAlarma extends StatelessWidget { if (alarma.proximaEjecucion != null) _NoticeLine( icon: Icons.event_available_rounded, - text: 'Siguiente ejecución: ${_fechaHora(alarma.proximaEjecucion!)}', + text: + 'Siguiente ejecución: ${_fechaHora(alarma.proximaEjecucion!)}', ) else const _NoticeLine( @@ -182,7 +208,8 @@ class _TarjetaAlarma extends StatelessWidget { const SizedBox(height: 6), _NoticeLine( icon: Icons.skip_next_rounded, - text: 'Una ejecución fue omitida: ${_fechaHora(excepcion.ejecucion)}.', + text: + 'Una ejecución fue omitida: ${_fechaHora(excepcion.ejecucion)}.', ), ], if (mensajeVacaciones != null) ...[ @@ -203,31 +230,32 @@ class _TarjetaAlarma extends StatelessWidget { TextButton.icon( icon: const Icon(Icons.skip_next_rounded), label: const Text('Omitir siguiente'), - onPressed: alarma.proximaEjecucion == null - ? null - : () async { - await estado.saltarProxima(alarma.id); - if (context.mounted) { - final alarmas = - context.read().alarmas; - AlarmaMusical? actualizada; - for (final item in alarmas) { - if (item.id == alarma.id) { - actualizada = item; - break; + onPressed: + alarma.proximaEjecucion == null + ? null + : () async { + await estado.saltarProxima(alarma.id); + if (context.mounted) { + final alarmas = + context.read().alarmas; + AlarmaMusical? actualizada; + for (final item in alarmas) { + if (item.id == alarma.id) { + actualizada = item; + break; + } } - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - actualizada?.proximaEjecucion == null - ? 'Alarma omitida. No queda próxima ejecución.' - : 'Alarma omitida. Volverá el ${_fechaHora(actualizada!.proximaEjecucion!)}.', + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + actualizada?.proximaEjecucion == null + ? 'Alarma omitida. No queda próxima ejecución.' + : 'Alarma omitida. Volverá el ${_fechaHora(actualizada!.proximaEjecucion!)}.', + ), ), - ), - ); - } - }, + ); + } + }, ), const Spacer(), IconButton( @@ -305,7 +333,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { _nombreController = TextEditingController( text: alarma?.nombre ?? 'Despertador musical', ); - _hora = TimeOfDay(hour: alarma?.hora ?? ahora.hour, minute: alarma?.minuto ?? ahora.minute); + _hora = TimeOfDay( + hour: alarma?.hora ?? ahora.hour, + minute: alarma?.minuto ?? ahora.minute, + ); _fecha = alarma?.fechaUnica ?? ahora; _tipo = alarma?.tipoProgramacion ?? TipoProgramacionAlarma.unica; _diasSemana = {...alarma?.diasSemana ?? const []}; @@ -332,7 +363,9 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { if (mounted) context.read().cargarFavoritos(); }); } - if (_emisora == null && widget.alarma == null && radio.emisoraPreferida != null) { + if (_emisora == null && + widget.alarma == null && + radio.emisoraPreferida != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _emisora == null) { setState(() => _emisora = radio.emisoraPreferida); @@ -352,7 +385,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { children: [ Row( children: [ - const _AssetIcon('assets/icons/alarmas/alarm_music.png', size: 58), + const _AssetIcon( + 'assets/icons/alarmas/alarm_music.png', + size: 58, + ), const SizedBox(width: 12), Expanded( child: Text( @@ -390,7 +426,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { icon: Icons.event_rounded, label: 'Fecha', value: _fechaCorta(_fecha), - onTap: _tipo == TipoProgramacionAlarma.unica ? _elegirFecha : null, + onTap: + _tipo == TipoProgramacionAlarma.unica + ? _elegirFecha + : null, ), ), ], @@ -398,12 +437,22 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { const SizedBox(height: 12), SegmentedButton( segments: const [ - ButtonSegment(value: TipoProgramacionAlarma.unica, label: Text('Una vez')), - ButtonSegment(value: TipoProgramacionAlarma.diaria, label: Text('Diaria')), - ButtonSegment(value: TipoProgramacionAlarma.diasSemana, label: Text('Días')), + ButtonSegment( + value: TipoProgramacionAlarma.unica, + label: Text('Una vez'), + ), + ButtonSegment( + value: TipoProgramacionAlarma.diaria, + label: Text('Diaria'), + ), + ButtonSegment( + value: TipoProgramacionAlarma.diasSemana, + label: Text('Días'), + ), ], selected: {_tipo}, - onSelectionChanged: (value) => setState(() => _tipo = value.first), + onSelectionChanged: + (value) => setState(() => _tipo = value.first), ), if (_tipo == TipoProgramacionAlarma.diasSemana) ...[ const SizedBox(height: 10), @@ -414,15 +463,21 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { FilterChip( label: Text(_diaCorto(i)), selected: _diasSemana.contains(i), - onSelected: (selected) => setState(() { - selected ? _diasSemana.add(i) : _diasSemana.remove(i); - }), + onSelected: + (selected) => setState(() { + selected + ? _diasSemana.add(i) + : _diasSemana.remove(i); + }), ), ], ), ], const SizedBox(height: 14), - _SectionLabel(icon: 'assets/icons/alarmas/snooze_wave.png', text: 'Postponer'), + _SectionLabel( + icon: 'assets/icons/alarmas/snooze_wave.png', + text: 'Postponer', + ), SegmentedButton( segments: const [ ButtonSegment(value: 3, label: Text('3 min')), @@ -430,10 +485,14 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { ButtonSegment(value: 10, label: Text('10 min')), ], selected: {_snooze}, - onSelectionChanged: (value) => setState(() => _snooze = value.first), + onSelectionChanged: + (value) => setState(() => _snooze = value.first), ), const SizedBox(height: 14), - _SectionLabel(icon: 'assets/icons/alarmas/fallback_sound.png', text: 'Sonido y volumen'), + _SectionLabel( + icon: 'assets/icons/alarmas/fallback_sound.png', + text: 'Sonido y volumen', + ), Slider( value: _volumen, min: 0.25, @@ -444,13 +503,27 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { ), DropdownButtonFormField( initialValue: _sonidoInterno, - decoration: const InputDecoration(labelText: 'Sonido seguro interno'), + decoration: const InputDecoration( + labelText: 'Sonido seguro interno', + ), items: const [ - DropdownMenuItem(value: SonidoInternoAlarma.amanecer, child: Text('Amanecer cálido')), - DropdownMenuItem(value: SonidoInternoAlarma.campanaSuave, child: Text('Campana suave')), - DropdownMenuItem(value: SonidoInternoAlarma.pulsoDigital, child: Text('Pulso digital')), + DropdownMenuItem( + value: SonidoInternoAlarma.amanecer, + child: Text('Amanecer cálido'), + ), + DropdownMenuItem( + value: SonidoInternoAlarma.campanaSuave, + child: Text('Campana suave'), + ), + DropdownMenuItem( + value: SonidoInternoAlarma.pulsoDigital, + child: Text('Pulso digital'), + ), ], - onChanged: (value) => setState(() => _sonidoInterno = value ?? _sonidoInterno), + onChanged: + (value) => setState( + () => _sonidoInterno = value ?? _sonidoInterno, + ), ), const SizedBox(height: 8), DropdownButtonFormField( @@ -474,13 +547,14 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { ), ), ], - onChanged: (uuid) => setState(() { - if (uuid == null || uuid.isEmpty) { - _emisora = null; - return; - } - _emisora = favoritas.firstWhere((e) => e.uuid == uuid); - }), + onChanged: + (uuid) => setState(() { + if (uuid == null || uuid.isEmpty) { + _emisora = null; + return; + } + _emisora = favoritas.firstWhere((e) => e.uuid == uuid); + }), ), if (favoritas.isEmpty) ...[ const SizedBox(height: 6), @@ -493,7 +567,8 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { Align( alignment: Alignment.centerLeft, child: FilledButton.tonalIcon( - onPressed: () => setState(() => _emisora = radio.emisoraActual), + onPressed: + () => setState(() => _emisora = radio.emisoraActual), icon: const Icon(Icons.add_task_rounded), label: const Text('Usar emisora actual'), ), @@ -503,10 +578,16 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { SwitchListTile.adaptive( contentPadding: EdgeInsets.zero, value: _sonarEnVacaciones, - onChanged: (value) => setState(() => _sonarEnVacaciones = value), - secondary: const _AssetIcon('assets/icons/alarmas/vacation_wave.png', size: 42), + onChanged: + (value) => setState(() => _sonarEnVacaciones = value), + secondary: const _AssetIcon( + 'assets/icons/alarmas/vacation_wave.png', + size: 42, + ), title: const Text('Sonar durante vacaciones'), - subtitle: const Text('Si lo apagás, la próxima ejecución saltará al primer día válido.'), + subtitle: const Text( + 'Si lo apagás, la próxima ejecución saltará al primer día válido.', + ), ), const SizedBox(height: 16), FilledButton.icon( @@ -556,24 +637,26 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { diasSemana: _diasSemana.toList()..sort(), )) .copyWith( - nombre: _nombreController.text.trim().isEmpty - ? 'Despertador musical' - : _nombreController.text.trim(), - hora: _hora.hour, - minuto: _hora.minute, - tipoProgramacion: _tipo, - diasSemana: _tipo == TipoProgramacionAlarma.diasSemana - ? (_diasSemana.toList()..sort()) - : const [], - fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null, - limpiarFechaUnica: _tipo != TipoProgramacionAlarma.unica, - emisora: _emisora, - sonarEnVacaciones: _sonarEnVacaciones, - snoozeMinutos: _snooze, - volumen: _volumen, - sonidoInterno: _sonidoInterno, - activa: true, - ); + nombre: + _nombreController.text.trim().isEmpty + ? 'Despertador musical' + : _nombreController.text.trim(), + hora: _hora.hour, + minuto: _hora.minute, + tipoProgramacion: _tipo, + diasSemana: + _tipo == TipoProgramacionAlarma.diasSemana + ? (_diasSemana.toList()..sort()) + : const [], + fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null, + limpiarFechaUnica: _tipo != TipoProgramacionAlarma.unica, + emisora: _emisora, + sonarEnVacaciones: _sonarEnVacaciones, + snoozeMinutos: _snooze, + volumen: _volumen, + sonidoInterno: _sonidoInterno, + activa: true, + ); await estado.guardarAlarma(alarma); if (mounted) Navigator.pop(context); } @@ -600,13 +683,21 @@ class _AccesoDiagnostico extends StatelessWidget { Widget build(BuildContext context) { final diag = estado.diagnostico; return TextButton.icon( - icon: const _AssetIcon('assets/icons/alarmas/android_reliability.png', size: 28), + icon: const _AssetIcon( + 'assets/icons/alarmas/android_reliability.png', + size: 28, + ), label: Text( diag == null ? 'Revisar fiabilidad Android' : 'Fiabilidad: exactas ${diag.puedeProgramarExactas ? 'OK' : 'pendiente'} · notificaciones ${diag.notificacionesPermitidas ? 'OK' : 'pendiente'}', ), - onPressed: estado.cargarDiagnostico, + onPressed: () async { + if (diag != null && !diag.puedeProgramarExactas) { + await estado.android.solicitarPermisoAlarmasExactas(); + } + await estado.cargarDiagnostico(); + }, ); } } @@ -627,7 +718,10 @@ class _PanelVacaciones extends StatelessWidget { children: [ Row( children: [ - const _AssetIcon('assets/icons/alarmas/vacation_wave.png', size: 48), + const _AssetIcon( + 'assets/icons/alarmas/vacation_wave.png', + size: 48, + ), const SizedBox(width: 10), Expanded( child: Text( @@ -723,9 +817,9 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { children: [ Text( 'Nuevo rango de vacaciones', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w900, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), ), const SizedBox(height: 12), TextField( @@ -811,7 +905,8 @@ class _AssetIcon extends StatelessWidget { width: size, height: size, fit: BoxFit.contain, - errorBuilder: (_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65), + errorBuilder: + (_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65), ); } } @@ -857,7 +952,12 @@ class _SectionLabel extends StatelessWidget { children: [ _AssetIcon(icon, size: 34), const SizedBox(width: 8), - Text(text, style: Theme.of(context).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800)), + Text( + text, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800), + ), ], ); } @@ -918,9 +1018,11 @@ String _hora(AlarmaMusical alarma) => String _programacion(AlarmaMusical alarma) { return switch (alarma.tipoProgramacion) { - TipoProgramacionAlarma.unica => 'Una vez · ${_fechaCorta(alarma.fechaUnica ?? DateTime.now())}', + TipoProgramacionAlarma.unica => + 'Una vez · ${_fechaCorta(alarma.fechaUnica ?? DateTime.now())}', TipoProgramacionAlarma.diaria => 'Diaria', - TipoProgramacionAlarma.diasSemana => 'Días: ${alarma.diasSemana.map(_diaCorto).join(', ')}', + TipoProgramacionAlarma.diasSemana => + 'Días: ${alarma.diasSemana.map(_diaCorto).join(', ')}', }; } @@ -934,39 +1036,39 @@ String _fechaCorta(DateTime fecha) => '${fecha.day.toString().padLeft(2, '0')}/${fecha.month.toString().padLeft(2, '0')}/${fecha.year}'; String _diaCorto(int dia) => switch (dia) { - DateTime.monday => 'Lun', - DateTime.tuesday => 'Mar', - DateTime.wednesday => 'Mié', - DateTime.thursday => 'Jue', - DateTime.friday => 'Vie', - DateTime.saturday => 'Sáb', - DateTime.sunday => 'Dom', - _ => '?', - }; + DateTime.monday => 'Lun', + DateTime.tuesday => 'Mar', + DateTime.wednesday => 'Mié', + DateTime.thursday => 'Jue', + DateTime.friday => 'Vie', + DateTime.saturday => 'Sáb', + DateTime.sunday => 'Dom', + _ => '?', +}; String _diaLargo(int dia) => switch (dia) { - DateTime.monday => 'lunes', - DateTime.tuesday => 'martes', - DateTime.wednesday => 'miércoles', - DateTime.thursday => 'jueves', - DateTime.friday => 'viernes', - DateTime.saturday => 'sábado', - DateTime.sunday => 'domingo', - _ => 'día', - }; + DateTime.monday => 'lunes', + DateTime.tuesday => 'martes', + DateTime.wednesday => 'miércoles', + DateTime.thursday => 'jueves', + DateTime.friday => 'viernes', + DateTime.saturday => 'sábado', + DateTime.sunday => 'domingo', + _ => 'día', +}; String _mes(int mes) => switch (mes) { - 1 => 'enero', - 2 => 'febrero', - 3 => 'marzo', - 4 => 'abril', - 5 => 'mayo', - 6 => 'junio', - 7 => 'julio', - 8 => 'agosto', - 9 => 'septiembre', - 10 => 'octubre', - 11 => 'noviembre', - 12 => 'diciembre', - _ => 'mes', - }; + 1 => 'enero', + 2 => 'febrero', + 3 => 'marzo', + 4 => 'abril', + 5 => 'mayo', + 6 => 'junio', + 7 => 'julio', + 8 => 'agosto', + 9 => 'septiembre', + 10 => 'octubre', + 11 => 'noviembre', + 12 => 'diciembre', + _ => 'mes', +}; diff --git a/lib/pantallas/pantalla_reproductor.dart b/lib/pantallas/pantalla_reproductor.dart index 86386d8..e9b6495 100644 --- a/lib/pantallas/pantalla_reproductor.dart +++ b/lib/pantallas/pantalla_reproductor.dart @@ -411,9 +411,15 @@ class _GrabacionWidget extends StatelessWidget { ? estado.detenerGrabacion : () => _mostrarDialogoGrabacion(context), ), + if (!activa) + IconButton.filledTonal( + tooltip: 'Abrir carpeta', + icon: const Icon(Icons.folder_open_rounded), + onPressed: () => _abrirCarpetaGrabaciones(context), + ), if (!activa && hayUltimaGrabacion) IconButton.filledTonal( - tooltip: 'Abrir ?ltima grabaci?n', + tooltip: 'Abrir última grabación', icon: const Icon(Icons.audio_file_rounded), onPressed: () => _abrirUltimaGrabacion(context), ), @@ -430,7 +436,20 @@ class _GrabacionWidget extends StatelessWidget { if (!context.mounted) return; if (!abierto) { messenger.showSnackBar( - const SnackBar(content: Text('No se pudo abrir la ?ltima grabaci?n')), + const SnackBar(content: Text('No se pudo abrir la última grabación')), + ); + } + } + + Future _abrirCarpetaGrabaciones(BuildContext context) async { + final messenger = ScaffoldMessenger.of(context); + final abierto = await estado.abrirDirectorioGrabacion(); + if (!context.mounted) return; + if (!abierto) { + messenger.showSnackBar( + const SnackBar( + content: Text('No se pudo abrir la carpeta de grabaciones'), + ), ); } } diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index 8bf5cf4..56eb6d3 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -74,7 +74,7 @@ class ServicioAlarmasAndroid { debugPrint( '[PluriWave][alarmas] programar id=${alarma.id} nombre=${alarma.nombre} proxima=${proxima.toIso8601String()} preaviso=${proxima.subtract(const Duration(minutes: 30)).toIso8601String()}', ); - await _channel.invokeMethod('scheduleAlarm', { + final programada = await _channel.invokeMethod('scheduleAlarm', { 'id': alarma.id, 'title': alarma.nombre, 'triggerAtMillis': proxima.millisecondsSinceEpoch, @@ -85,6 +85,11 @@ class ServicioAlarmasAndroid { 'fallbackSound': alarma.sonidoInterno.name, 'volume': alarma.volumen, }); + if (programada != true) { + throw StateError( + 'Android no pudo programar una alarma exacta. Revisa el permiso de alarmas exactas.', + ); + } } Future cancelar(String alarmaId) => @@ -96,6 +101,13 @@ class ServicioAlarmasAndroid { Future detenerSonidoNativo(String alarmaId) => _logAndInvokeVoid('stopNativeAlarmSound', {'id': alarmaId}); + Future solicitarPermisoAlarmasExactas() async { + final abierto = await _channel.invokeMethod( + 'requestExactAlarmPermission', + ); + return abierto ?? false; + } + Future diagnostico() async { debugPrint('[PluriWave][alarmas] diagnostico android'); final raw = await _channel.invokeMethod>( diff --git a/lib/servicios/servicio_contenido_app.dart b/lib/servicios/servicio_contenido_app.dart new file mode 100644 index 0000000..d065c7f --- /dev/null +++ b/lib/servicios/servicio_contenido_app.dart @@ -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 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 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 marcarVisto() async { + final prefs = await SharedPreferences.getInstance(); + final info = await PackageInfo.fromPlatform(); + await prefs.setBool(_keyOnboardingVisto, true); + await prefs.setString(_keyVersionVista, info.version); + } + + Future 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 = []; + 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 _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; + } +} diff --git a/lib/servicios/servicio_programacion_alarmas.dart b/lib/servicios/servicio_programacion_alarmas.dart index 725bff4..e5b833c 100644 --- a/lib/servicios/servicio_programacion_alarmas.dart +++ b/lib/servicios/servicio_programacion_alarmas.dart @@ -1,6 +1,8 @@ import '../modelos/alarma_musical.dart'; class ServicioProgramacionAlarmas { + static const Duration toleranciaDisparoInminente = Duration(seconds: 90); + DateTime? calcularProxima({ required AlarmaMusical alarma, required DateTime desde, @@ -24,25 +26,27 @@ class ServicioProgramacionAlarmas { final primerCandidato = alarma.tipoProgramacion == TipoProgramacionAlarma.unica ? inicio - : inicio.isAfter(desde) + : _sigueSiendoInminente(inicio, desde) ? inicio : inicio.add(const Duration(days: 1)); return switch (alarma.tipoProgramacion) { TipoProgramacionAlarma.unica => - primerCandidato.isAfter(desde) && + _sigueSiendoInminente(primerCandidato, desde) && _esValida(alarma, primerCandidato, vacaciones, excepciones) - ? primerCandidato + ? _normalizarInminente(primerCandidato, desde) : null, TipoProgramacionAlarma.diaria => _buscarDiaria( alarma, primerCandidato, + desde, vacaciones, excepciones, ), TipoProgramacionAlarma.diasSemana => _buscarPorDiasSemana( alarma, primerCandidato, + desde, vacaciones, excepciones, ), @@ -60,12 +64,16 @@ class ServicioProgramacionAlarmas { DateTime? _buscarDiaria( AlarmaMusical alarma, DateTime candidato, + DateTime desde, List vacaciones, List excepciones, ) { var actual = candidato; for (var i = 0; i < 370; i++) { - if (_esValida(alarma, actual, vacaciones, excepciones)) return actual; + if (_sigueSiendoInminente(actual, desde) && + _esValida(alarma, actual, vacaciones, excepciones)) { + return _normalizarInminente(actual, desde); + } actual = actual.add(const Duration(days: 1)); } return null; @@ -74,6 +82,7 @@ class ServicioProgramacionAlarmas { DateTime? _buscarPorDiasSemana( AlarmaMusical alarma, DateTime candidato, + DateTime desde, List vacaciones, List excepciones, ) { @@ -81,8 +90,9 @@ class ServicioProgramacionAlarmas { var actual = candidato; for (var i = 0; i < 370; i++) { if (alarma.diasSemana.contains(actual.weekday) && + _sigueSiendoInminente(actual, desde) && _esValida(alarma, actual, vacaciones, excepciones)) { - return actual; + return _normalizarInminente(actual, desde); } actual = actual.add(const Duration(days: 1)); } @@ -111,4 +121,13 @@ class ServicioProgramacionAlarmas { a.day == b.day && a.hour == b.hour && a.minute == b.minute; + + bool _sigueSiendoInminente(DateTime candidato, DateTime desde) => + candidato.isAfter(desde) || + desde.difference(candidato) <= toleranciaDisparoInminente; + + DateTime _normalizarInminente(DateTime candidato, DateTime desde) => + candidato.isAfter(desde) + ? candidato + : desde.add(const Duration(seconds: 2)); } diff --git a/lib/widgets/pluri_markdown.dart b/lib/widgets/pluri_markdown.dart new file mode 100644 index 0000000..5416356 --- /dev/null +++ b/lib/widgets/pluri_markdown.dart @@ -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 = []; + 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)), + ], + ), + ); +} diff --git a/lib/widgets/pluri_onboarding_dialog.dart b/lib/widgets/pluri_onboarding_dialog.dart new file mode 100644 index 0000000..74f2cb1 --- /dev/null +++ b/lib/widgets/pluri_onboarding_dialog.dart @@ -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 mostrarSiProcede(BuildContext context) async { + if (!await _servicio.debeMostrarInicio()) return; + if (!context.mounted) return; + await mostrar(context, soloPendientes: true); + await _servicio.marcarVisto(); + } + + static Future 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( + 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: 'What’s 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; +} diff --git a/pubspec.yaml b/pubspec.yaml index e5343d9..f40a412 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,3 +72,4 @@ flutter: - assets/audio/ - assets/mockups/ - assets/generated/ + - assets/content/ diff --git a/test/servicios/servicio_programacion_alarmas_test.dart b/test/servicios/servicio_programacion_alarmas_test.dart index 6cdac8b..9b94372 100644 --- a/test/servicios/servicio_programacion_alarmas_test.dart +++ b/test/servicios/servicio_programacion_alarmas_test.dart @@ -86,25 +86,47 @@ void main() { ); }); - test('servicio limpia proxima ejecucion obsoleta al recalcular unica vencida', () async { - SharedPreferences.setMockInitialValues({}); - final servicioAlarmas = ServicioAlarmas( - reloj: () => DateTime(2026, 5, 22, 10), - ); + test( + 'servicio limpia proxima ejecucion obsoleta al recalcular unica vencida', + () async { + SharedPreferences.setMockInitialValues({}); + final servicioAlarmas = ServicioAlarmas( + reloj: () => DateTime(2026, 5, 22, 10), + ); + final alarma = AlarmaMusical( + id: 'a4', + nombre: 'Unica vencida', + hora: 9, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.unica, + diasSemana: const [], + fechaUnica: DateTime(2026, 5, 22), + proximaEjecucion: DateTime(2026, 5, 22, 9), + ); + + final guardada = await servicioAlarmas.guardarAlarma(alarma); + + expect(guardada.alarmas.single.proximaEjecucion, isNull); + }, + ); + + test('mantiene alarma unica creada dentro del mismo minuto', () { final alarma = AlarmaMusical( - id: 'a4', - nombre: 'Unica vencida', - hora: 9, - minuto: 0, + id: 'a5', + nombre: 'Ahora', + hora: 20, + minuto: 13, tipoProgramacion: TipoProgramacionAlarma.unica, diasSemana: const [], - fechaUnica: DateTime(2026, 5, 22), - proximaEjecucion: DateTime(2026, 5, 22, 9), + fechaUnica: DateTime(2026, 5, 23), ); - final guardada = await servicioAlarmas.guardarAlarma(alarma); + final proxima = servicio.calcularProxima( + alarma: alarma, + desde: DateTime(2026, 5, 23, 20, 13, 45), + ); - expect(guardada.alarmas.single.proximaEjecucion, isNull); + expect(proxima, DateTime(2026, 5, 23, 20, 13, 47)); }); }); }