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