163 Commits

Author SHA1 Message Date
ShanaiaBot b5acf97ba4 chore: bump version to 0.1.60+61 [ci skip] 2026-06-04 16:30:30 +02:00
Javier Bautista Fernández cf9422dff3 Exportar e importar absolutamente toda la información de las preferencias de la aplicación
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s
2026-06-04 16:05:58 +02:00
ShanaiaBot 957615dcd6 chore: bump version to 0.1.59+60 [ci skip] 2026-06-03 22:07:12 +02:00
FreeTLab 089b8b4227 fix(i18n): normalize translations and fallbacks
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m34s
2026-06-03 21:20:08 +02:00
ShanaiaBot a5475ce118 chore: bump version to 0.1.58+59 [ci skip] 2026-06-03 14:55:56 +02:00
Javier Bautista Fernández 00fe49c309 fix: resolver advertencias de analisis i18n
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m26s
2026-06-03 14:54:50 +02:00
Javier Bautista Fernández 643ba1eb45 fix: completar migracion i18n de literales visibles
Build & Deploy PluriWave / Análisis de código (push) Failing after 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Has been skipped
2026-06-03 13:43:43 +02:00
ShanaiaBot 7abc8c3b0f chore: bump version to 0.1.57+58 [ci skip] 2026-06-02 10:20:40 +02:00
Javier Bautista Fernández 2e17dfd511 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m24s
2026-06-02 10:19:38 +02:00
Javier Bautista Fernández d449d8577b Add localization support for search and alarm features in multiple languages
- Updated Japanese, Portuguese, Russian, and Chinese localization files with new strings for search and alarm functionalities.
- Enhanced the search screen with localized titles, subtitles, and filter labels.
- Integrated localization into the alarm screen, including actions and messages related to alarm management.
- Refactored country and language lists to use localized keys for better maintainability.
- Improved user experience by providing localized hints and messages throughout the application.
2026-06-02 10:19:37 +02:00
Javier Bautista Fernández ffe1c41458 eliminados los snooze 2026-06-02 09:21:43 +02:00
ShanaiaBot d423676623 chore: bump version to 0.1.56+57 [ci skip] 2026-06-01 13:21:13 +02:00
Javier Bautista Fernández de07316d79 feat(alarmas): agregar fade-in configurable en activacion
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m29s
2026-06-01 13:20:06 +02:00
ShanaiaBot c3a22c4658 chore: bump version to 0.1.55+56 [ci skip] 2026-05-31 00:34:24 +02:00
FreeTLab 7c7bd64e85 Merge pull request 'fix(ci): remove keytool verification step that fails on runner' (#10) from fix/remove-keytool-verify into main
Build & Deploy PluriWave / Análisis de código (push) Successful in 47s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m5s
Reviewed-on: #10
2026-05-31 00:33:01 +02:00
FreeTLab 2aeef1626c fix(ci): remove keytool verification step that fails on runner 2026-05-31 00:32:21 +02:00
ShanaiaBot 1d39293fe3 chore: bump version to 0.1.54+55 [ci skip] 2026-05-31 00:25:54 +02:00
FreeTLab ef4b8ab323 Merge pull request 'fix(ci): simplify Flutter test execution' (#9) from fix/simplify-ci-tests into main
Build & Deploy PluriWave / Análisis de código (push) Successful in 41s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 2m29s
Reviewed-on: #9
2026-05-31 00:11:44 +02:00
FreeTLab 20c135a848 fix(ci): simplify Flutter test execution
- Remove complex Python wrapper that caused timeout issues
- Use direct flutter test with --concurrency=1 --timeout=60s
- Simplify cleanup with pkill instead of Python loop
- Increase timeout from 4m to 15m (tests take ~10s but CI overhead is high)
2026-05-31 00:08:01 +02:00
Javier Bautista Fernández 82f70e2fa3 fix(ci): poll flutter test output from file
Build & Deploy PluriWave / Análisis de código (push) Failing after 10m55s
Build & Deploy PluriWave / Build APK + AAB release (push) Has been skipped
2026-05-29 13:55:29 +02:00
Javier Bautista Fernández eb23a438b6 fix(ci): stop flutter tests after success sentinel
Build & Deploy PluriWave / Análisis de código (push) Failing after 12m35s
Build & Deploy PluriWave / Build APK + AAB release (push) Has been skipped
2026-05-29 13:36:51 +02:00
Javier Bautista Fernández d45fbe60db fix(alarms): skip handled occurrence when recalculating
Build & Deploy PluriWave / Build APK + AAB release (push) Has been cancelled
Build & Deploy PluriWave / Análisis de código (push) Has been cancelled
2026-05-29 13:29:41 +02:00
Javier Bautista Fernández 3640a76253 fix(ci): enforce critical test watchdog
Build & Deploy PluriWave / Build APK + AAB release (push) Has been cancelled
Build & Deploy PluriWave / Análisis de código (push) Has been cancelled
2026-05-29 13:26:15 +02:00
Javier Bautista Fernández 4a00472a83 fix(ci): isolate critical flutter tests
Build & Deploy PluriWave / Build APK + AAB release (push) Has been cancelled
Build & Deploy PluriWave / Análisis de código (push) Has been cancelled
2026-05-29 13:16:52 +02:00
Javier Bautista Fernández 028e2d69b1 fix(alarms): harden native alarm lifecycle
Build & Deploy PluriWave / Build APK + AAB release (push) Has been cancelled
Build & Deploy PluriWave / Análisis de código (push) Has been cancelled
2026-05-29 13:13:39 +02:00
FreeTLab 8f6124fc1a fix(ci): avoid killing flutter between test files
Build & Deploy PluriWave / Análisis de código (push) Failing after 13m30s
Build & Deploy PluriWave / Build APK + AAB release (push) Has been skipped
2026-05-29 00:02:51 +02:00
FreeTLab 6dd045ea42 fix(ci): clean up flutter test processes
Build & Deploy PluriWave / Build APK + AAB release (push) Has been cancelled
Build & Deploy PluriWave / Análisis de código (push) Has been cancelled
2026-05-28 23:59:32 +02:00
FreeTLab 8f77550a05 fix(ci): bound critical alarm tests
Build & Deploy PluriWave / Build APK + AAB release (push) Has been cancelled
Build & Deploy PluriWave / Análisis de código (push) Has been cancelled
2026-05-28 23:54:18 +02:00
FreeTLab cf994757a4 test(ci): stabilize hidden failures
Build & Deploy PluriWave / Análisis de código (push) Failing after 13m34s
Build & Deploy PluriWave / Build APK + AAB release (push) Has been skipped
2026-05-28 23:37:51 +02:00
FreeTLab e47c0a88e0 fix(alarms): skip completed occurrence when rescheduling
Build & Deploy PluriWave / Análisis de código (push) Failing after 14m55s
Build & Deploy PluriWave / Build APK + AAB release (push) Has been skipped
2026-05-28 19:51:23 +02:00
FreeTLab f5c2f0a879 chore: merge origin/main 2026-05-28 18:22:12 +02:00
Javier Bautista Fernández 9a9ef95b07 Merge remote-tracking branch 'origin/main'
Build & Deploy Pluriwave / Análisis de código (push) Failing after 13m6s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 14m58s
# Conflicts:
#	android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt
2026-05-28 12:31:12 +02:00
Javier Bautista Fernández 659e6da189 fix(alarms): harden native playback and pre-notice actions 2026-05-28 12:03:58 +02:00
FreeTLab eae19e1d70 feat(ci): automate play upload from PRO 2026-05-27 14:01:53 +02:00
ShanaiaBot 10d18b5064 chore: bump version to 0.1.53+54 [ci skip] 2026-05-25 22:32:33 +02:00
FreeTLab a46a7ede21 fix(ci): simplificar verificación de firma, quitar unzip que falla
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m45s
2026-05-25 22:31:46 +02:00
ShanaiaBot 04a281b80c chore: bump version to 0.1.52+53 [ci skip] 2026-05-25 21:59:46 +02:00
ShanaiaBot 7569a5b020 chore: bump version to 0.1.51+52 [ci skip] 2026-05-25 21:50:51 +02:00
FreeTLab 7dceed5dae fix(ci): load release signing from key properties
Build & Deploy Pluriwave / Análisis de código (push) Successful in 22s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m32s
2026-05-25 21:50:03 +02:00
ShanaiaBot 03b56c98e7 chore: bump version to 0.1.50+51 [ci skip] 2026-05-25 21:39:07 +02:00
FreeTLab e447816d3f Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m38s
2026-05-25 21:37:42 +02:00
FreeTLab c189078c26 Corrección de publicación 2026-05-25 21:37:40 +02:00
ShanaiaBot 18016cc406 chore: bump version to 0.1.49+50 [ci skip] 2026-05-25 21:12:35 +02:00
FreeTLab e5aa1439bd refactor(ci): reemplazar actions/checkout por git clone directo, eliminar dependencia de GitHub
Build & Deploy Pluriwave / Análisis de código (push) Successful in 25s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m30s
2026-05-25 21:11:47 +02:00
FreeTLab 42dd64635c feat(pluriwave): añadir firma release con keystore pluriwave-upload para Google Play
Build & Deploy Pluriwave / Análisis de código (push) Failing after 3s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-05-25 20:47:19 +02:00
ShanaiaBot 41bbd0ea17 chore: bump version to 0.1.48+49 [ci skip] 2026-05-23 01:23:59 +02:00
FreeTLab 896349ad5f feat(app): add onboarding and harden alarms
Build & Deploy Pluriwave / Análisis de código (push) Successful in 21s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m6s
2026-05-23 01:22:49 +02:00
ShanaiaBot 27b8fccac9 chore: bump version to 0.1.47+48 [ci skip] 2026-05-22 20:03:18 +02:00
FreeTLab 3ab138a4fa feat(alarms): add native ringing service
Build & Deploy Pluriwave / Análisis de código (push) Successful in 26s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m8s
2026-05-22 20:02:27 +02:00
ShanaiaBot c8fff0d977 chore: bump version to 0.1.46+47 [ci skip] 2026-05-22 19:40:56 +02:00
FreeTLab cfea818133 fix(alarms): prevent overlapping playback
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m0s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
2026-05-22 19:40:09 +02:00
ShanaiaBot bc27e7832d chore: bump version to 0.1.45+46 [ci skip] 2026-05-22 19:34:11 +02:00
FreeTLab 26078ad49b fix(alarms): skip stale startup executions
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m21s
2026-05-22 19:33:21 +02:00
ShanaiaBot 2816a97c93 chore: bump version to 0.1.44+45 [ci skip] 2026-05-22 19:24:53 +02:00
FreeTLab a976b8e797 fix(alarms): fallback native scheduling
Build & Deploy Pluriwave / Análisis de código (push) Successful in 27s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m28s
2026-05-22 19:23:57 +02:00
ShanaiaBot d7277a9274 chore: bump version to 0.1.43+44 [ci skip] 2026-05-22 18:55:28 +02:00
FreeTLab ee09224c13 fix(android): import activity not found exception
Build & Deploy Pluriwave / Análisis de código (push) Successful in 26s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m10s
2026-05-22 18:54:34 +02:00
ShanaiaBot 0675750b2e chore: bump version to 0.1.42+43 [ci skip] 2026-05-22 18:43:17 +02:00
FreeTLab a48dd6ddf9 fix(alarms): refresh next execution reliably
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m0s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
2026-05-22 18:42:11 +02:00
ShanaiaBot eb185231a1 chore: bump version to 0.1.41+42 [ci skip] 2026-05-22 18:31:57 +02:00
FreeTLab 809255bd43 fix(recordings): open last file on android
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m2s
2026-05-22 18:30:49 +02:00
ShanaiaBot fde651eee9 chore: bump version to 0.1.40+41 [ci skip] 2026-05-22 18:25:26 +02:00
FreeTLab 9ad58898e0 fix(recordings): open folder with android picker
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m0s
2026-05-22 18:24:25 +02:00
ShanaiaBot 6a5fcd8d96 chore: bump version to 0.1.39+40 [ci skip] 2026-05-22 17:22:10 +02:00
FreeTLab b6e66e75ce test(favorites): cover sqlite migrations
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m39s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
2026-05-22 17:21:03 +02:00
ShanaiaBot f6a9ba0086 chore: bump version to 0.1.38+39 [ci skip] 2026-05-22 16:59:29 +02:00
FreeTLab 157d52996e fix(i18n): localize settings order copy
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m44s
2026-05-22 16:58:19 +02:00
ShanaiaBot aaeee51233 chore: bump version to 0.1.37+38 [ci skip] 2026-05-22 16:19:43 +02:00
FreeTLab 5f35db6352 feat(favorites): manage favorite groups in ui
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m39s
2026-05-22 16:18:31 +02:00
ShanaiaBot c46d941e6c chore: bump version to 0.1.36+37 [ci skip] 2026-05-22 16:11:50 +02:00
FreeTLab 9bd973b327 feat(favorites): add group persistence foundation
Build & Deploy Pluriwave / Análisis de código (push) Successful in 25s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m50s
2026-05-22 16:10:18 +02:00
ShanaiaBot c347ce9d8e chore: bump version to 0.1.35+36 [ci skip] 2026-05-22 15:56:13 +02:00
FreeTLab f667277e35 feat(stations): add quality filters and list ordering
Build & Deploy Pluriwave / Análisis de código (push) Successful in 26s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m42s
2026-05-22 15:54:51 +02:00
ShanaiaBot 0114e4805e chore: bump version to 0.1.34+35 [ci skip] 2026-05-22 15:25:18 +02:00
FreeTLab 8190c4ab8d feat(recording): add safety limits and adaptive headers
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m37s
2026-05-22 15:24:25 +02:00
ShanaiaBot 2320dbdc5f chore: bump version to 0.1.33+34 [ci skip] 2026-05-22 15:05:07 +02:00
FreeTLab 785a41f0c4 docs: add pending ux recording and search tasks
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m37s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
2026-05-22 15:04:20 +02:00
ShanaiaBot 30fe6c6667 chore: bump version to 0.1.32+33 [ci skip] 2026-05-22 15:03:56 +02:00
FreeTLab 3b0efb641c feat(i18n): expand supported languages
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been cancelled
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
2026-05-22 15:03:07 +02:00
ShanaiaBot 4e22bd4e98 chore: bump version to 0.1.31+32 [ci skip] 2026-05-22 13:50:22 +02:00
FreeTLab 6480c56f99 feat(i18n): migrate settings literals
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m44s
2026-05-22 13:49:34 +02:00
ShanaiaBot 116d878a98 chore: bump version to 0.1.30+31 [ci skip] 2026-05-22 13:31:04 +02:00
FreeTLab 3f548fd53e feat(i18n): add localization foundation
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m52s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
2026-05-22 13:30:17 +02:00
ShanaiaBot d85dee6fa8 chore: bump version to 0.1.29+30 [ci skip] 2026-05-22 13:13:48 +02:00
FreeTLab e1d1d6c639 feat(ui): refine navigation and sleep timer
Build & Deploy Pluriwave / Análisis de código (push) Successful in 21s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m19s
2026-05-22 13:13:05 +02:00
ShanaiaBot 0edad1bfcb chore: bump version to 0.1.28+29 [ci skip] 2026-05-22 01:56:05 +02:00
FreeTLab a181cc8e85 feat(ui): refresh premium visual assets
Build & Deploy Pluriwave / Análisis de código (push) Successful in 27s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m50s
2026-05-22 01:54:33 +02:00
ShanaiaBot 72f6f4e974 chore: bump version to 0.1.27+28 [ci skip] 2026-05-22 01:27:10 +02:00
FreeTLab 4ae93182fa fix(alarm): add due alarm watchdog
Build & Deploy Pluriwave / Análisis de código (push) Successful in 14s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 4m14s
2026-05-22 01:26:36 +02:00
ShanaiaBot d8823a328d chore: bump version to 0.1.26+27 [ci skip] 2026-05-22 01:06:36 +02:00
FreeTLab eeadcc1cc6 fix(alarm): improve firing and preferred station
Build & Deploy Pluriwave / Análisis de código (push) Successful in 15s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 4m15s
2026-05-22 01:06:02 +02:00
ShanaiaBot 28067e392d chore: bump version to 0.1.25+26 [ci skip] 2026-05-22 00:40:36 +02:00
FreeTLab a3a648c633 feat(alarm): complete musical alarm flows
Build & Deploy Pluriwave / Análisis de código (push) Successful in 15s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 4m21s
2026-05-22 00:40:01 +02:00
ShanaiaBot 7f1874f873 chore: bump version to 0.1.24+25 [ci skip] 2026-05-21 23:47:41 +02:00
FreeTLab fb808ebb60 feat(alarm): add musical alarm foundation
Build & Deploy Pluriwave / Análisis de código (push) Successful in 14s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m45s
2026-05-21 23:47:06 +02:00
ShanaiaBot 8c2cba093c chore: bump version to 0.1.23+24 [ci skip] 2026-05-21 22:16:46 +02:00
FreeTLab a9202c6eb3 fix(settings): show real version and map equalizer gains
Build & Deploy Pluriwave / Análisis de código (push) Successful in 13s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m4s
2026-05-21 22:16:18 +02:00
ShanaiaBot dac1b602e2 chore: bump version to 0.1.22+23 [ci skip] 2026-05-21 22:00:26 +02:00
FreeTLab 921e972183 fix(player): stabilize equalizer and visualizer
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m50s
2026-05-21 21:59:59 +02:00
ShanaiaBot d0ceaac3f3 chore: bump version to 0.1.21+22 [ci skip] 2026-05-21 21:18:27 +02:00
FreeTLab a6a91af402 feat(player): add radio recording and real waveform
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m27s
2026-05-21 21:17:59 +02:00
ShanaiaBot 6aa9a59d7b chore: bump version to 0.1.20+21 [ci skip] 2026-05-21 20:53:03 +02:00
FreeTLab 0e18c82292 fix(player): recreate audio player on station switch
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m52s
2026-05-21 20:52:28 +02:00
ShanaiaBot 0456850f3d chore: bump version to 0.1.19+20 [ci skip] 2026-05-21 01:12:43 +02:00
FreeTLab ef22454350 fix(player): separate selection from audio state
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m15s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
2026-05-21 01:12:20 +02:00
ShanaiaBot b23450819c chore: bump version to 0.1.18+19 [ci skip] 2026-05-21 00:58:14 +02:00
FreeTLab 1791207bd4 fix(player): restore setUrl source loading
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m16s
2026-05-21 00:57:49 +02:00
ShanaiaBot fe531a1784 chore: bump version to 0.1.17+18 [ci skip] 2026-05-21 00:50:49 +02:00
FreeTLab 6b0faebc7f fix(player): serialize live stream switching
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m24s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
2026-05-21 00:50:23 +02:00
ShanaiaBot 26d8151d7a chore: bump version to 0.1.16+17 [ci skip] 2026-05-21 00:36:40 +02:00
FreeTLab f49d349616 fix(player): restore historical station switching
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m22s
2026-05-21 00:36:16 +02:00
ShanaiaBot 37aea7e99f chore: bump version to 0.1.15+16 [ci skip] 2026-05-21 00:24:26 +02:00
FreeTLab ee26c78d82 fix(player): handle play errors on station switch
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m23s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
2026-05-21 00:24:04 +02:00
ShanaiaBot 6249ed1b2c chore: bump version to 0.1.14+15 [ci skip] 2026-05-21 00:13:36 +02:00
FreeTLab 01135e8a3d fix(player): prevent stale station overwrite
Build & Deploy Pluriwave / Análisis de código (push) Successful in 14s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m19s
2026-05-21 00:13:12 +02:00
ShanaiaBot 67fe4413f4 chore: bump version to 0.1.13+14 [ci skip] 2026-05-20 23:56:24 +02:00
FreeTLab be0d6c5a9e fix(player): restore stable audio switching
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m19s
2026-05-20 23:56:03 +02:00
ShanaiaBot abea51ba3f chore: bump version to 0.1.12+13 [ci skip] 2026-05-20 23:44:46 +02:00
FreeTLab 10520fef48 fix(ui): unify scroll and improve playback switching
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m17s
2026-05-20 23:44:24 +02:00
ShanaiaBot 34022e0814 chore: bump version to 0.1.11+12 [ci skip] 2026-05-20 23:22:45 +02:00
FreeTLab 7fcd0f544e feat(radio): add nearby discovery and paged search
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m34s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
2026-05-20 23:22:23 +02:00
ShanaiaBot f888153aa9 chore: bump version to 0.1.10+11 [ci skip] 2026-05-20 22:51:15 +02:00
FreeTLab b9cf42b91c fix(player): stabilize first playback and refresh design
Build & Deploy Pluriwave / Análisis de código (push) Successful in 12s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m20s
2026-05-20 22:50:49 +02:00
ShanaiaBot 22e19d1cb0 chore: bump version to 0.1.9+10 [ci skip] 2026-05-20 22:15:45 +02:00
FreeTLab 3be59d740c feat(ui): add generated premium assets
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m17s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 11s
2026-05-20 22:15:24 +02:00
ShanaiaBot 2fb794a43b chore: bump version to 0.1.8+9 [ci skip] 2026-05-20 21:30:14 +02:00
FreeTLab d8acf74771 feat(ui): implement award mockup redesign
Build & Deploy Pluriwave / Análisis de código (push) Successful in 10s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m19s
2026-05-20 21:29:47 +02:00
ShanaiaBot eb0ef37c76 chore: bump version to 0.1.7+8 [ci skip] 2026-05-20 20:19:03 +02:00
FreeTLab 4bcd86f59c fix(ci): use compatible reorder callback
Build & Deploy Pluriwave / Análisis de código (push) Successful in 9s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m13s
2026-05-20 20:18:43 +02:00
FreeTLab 9c51454d57 fix(ci): resolve premium UI analyzer errors
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
Build & Deploy Pluriwave / Análisis de código (push) Failing after 9s
2026-05-20 20:07:24 +02:00
FreeTLab c707fc9911 feat(ui): add premium PluriWave redesign
Build & Deploy Pluriwave / Análisis de código (push) Failing after 21s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-05-20 18:42:22 +02:00
ShanaiaBot f95a8290ae chore: bump version to 0.1.6+7 [ci skip] 2026-04-27 17:36:54 +02:00
Javier Bautista Fernández 40f1d77a40 fix: Correct file resolver call and update preset equalizer in tests
Build & Deploy Pluriwave / Análisis de código (push) Successful in 8s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m39s
2026-04-27 17:36:35 +02:00
Javier Bautista Fernández 7dc8fbe99d Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy Pluriwave / Análisis de código (push) Failing after 10s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-04-27 17:34:16 +02:00
Javier Bautista Fernández d579a0e107 feat: Implement startup retry mechanism for custom stations and equalizer persistence
- Added state management for startup retry and custom station handling in `EstadoRadio`.
- Created tasks for implementing strict TDD with RED tests for HTTP failure retries and EQ persistence.
- Developed verification report to ensure compliance with TDD practices.
- Introduced fake services for testing, including `FakeServicioAudio`, `FakeServicioFavoritos`, and `FakeServicioRadio`.
- Implemented widget tests for `PantallaInicio` and `PantallaFavoritos` to validate UI behavior with custom stations.
- Enhanced `ServicioRadio` to support host rotation and retry logic for API calls.
- Established a new configuration file to enforce project constraints and testing rules.
2026-04-27 17:34:04 +02:00
ShanaiaBot 2f52a31242 chore: bump version to 0.1.5+6 [ci skip] 2026-04-07 14:51:59 +02:00
Javier Bautista Fernández 922b3b4859 Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy Pluriwave / Análisis de código (push) Successful in 9s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m8s
2026-04-07 14:51:39 +02:00
Javier Bautista Fernández bb5937e184 Mejora, aumentar el nº de elementos seleccionables como timer para apagar la radio 2026-04-07 14:51:26 +02:00
ShanaiaBot a51b8377a2 chore: bump version to 0.1.4+5 [ci skip] 2026-04-07 14:50:44 +02:00
Javier Bautista Fernández 547a667ada fix. In maldito ; de más
Build & Deploy Pluriwave / Análisis de código (push) Successful in 8s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been cancelled
2026-04-07 14:50:25 +02:00
Javier Bautista Fernández 8a455eb6bb Fix. Igual es un simple espacio de más
Build & Deploy Pluriwave / Análisis de código (push) Failing after 8s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-04-07 14:47:39 +02:00
Javier Bautista Fernández ebd26af169 Fix. corregir elementos tiempo desconexión
Build & Deploy Pluriwave / Análisis de código (push) Failing after 8s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-04-07 14:40:10 +02:00
Javier Bautista Fernández 933ced76ba Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy Pluriwave / Análisis de código (push) Failing after 9s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-04-07 12:52:28 +02:00
Javier Bautista Fernández a8e9c91f9d Actualización. CI. Añadir más minutos en el selector del timer del sueño 2026-04-07 12:52:18 +02:00
ShanaiaBot e59ac7d552 chore: bump version to 0.1.3+4 [ci skip] 2026-04-07 12:45:56 +02:00
Javier Bautista Fernández 556151c64d Merge branch 'main' of https://git.freetimelab.es/FreeTLab/pluriwave
Build & Deploy Pluriwave / Análisis de código (push) Successful in 9s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m17s
2026-04-07 12:45:38 +02:00
Javier Bautista Fernández 8e2c01f626 fix. faltaba el caso en el que el tiempo aún no fuese cero 2026-04-07 12:38:29 +02:00
ShanaiaBot b41a28452d chore: bump version to 0.1.2+3 [ci skip] 2026-04-07 12:31:08 +02:00
Javier Bautista Fernández a8425d65bc fix. Solución a que no se detenga la música
Build & Deploy Pluriwave / Análisis de código (push) Successful in 9s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m56s
2026-04-07 12:30:41 +02:00
ShanaiaBot 0dc554e5fb chore: bump version to 0.1.1+2 [ci skip] 2026-04-07 01:10:39 +02:00
FreeTLab ea4fc369f6 Actualizar .gitea/workflows/build.yml
Build & Deploy Pluriwave / Análisis de código (push) Successful in 7s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 1m10s
2026-04-07 01:10:23 +02:00
FreeTLab 47c6505c41 Actualizar .gitea/workflows/build.yml
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
Build & Deploy Pluriwave / Análisis de código (push) Failing after 9s
2026-04-07 01:00:55 +02:00
FreeTLab 23b73bf0e0 Actualizar .gitea/workflows/build.yml
Build & Deploy Pluriwave / Análisis de código (push) Failing after 4s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-04-07 00:59:03 +02:00
FreeTLab b13176eaeb Actualizar .gitea/workflows/build.yml
Build & Deploy Pluriwave / Análisis de código (push) Failing after 4s
Build & Deploy Pluriwave / Build APK + AAB release (push) Has been skipped
2026-04-07 00:45:12 +02:00
FreeTLab d97bc06a5b Añadir .gitea/workflows/build.yml 2026-04-07 00:43:48 +02:00
FreeTLab 2b1f3adb3a Actualizar .gitea/workflows/ci.back 2026-04-07 00:43:28 +02:00
FreeTLab 50088eb94f Actualizar .gitea/workflows/ci.yml
Flutter CI/CD — PluriWave / Test + Build (push) Failing after 2m15s
2026-04-07 00:40:11 +02:00
FreeTLab b61b3218fc fix(ci): runner macos-14 + ANDROID_HOME (#8)
Flutter CI/CD — PluriWave / Test + Build (push) Has been cancelled
2026-04-06 14:21:18 +02:00
FreeTLab 651c4e1360 Merge pull request 'fix(reproduccion): robustez HTTP cleartext, errores ExoPlayer y certificados SSL' (#7) from feature/fix-reproduccion-robustez into main
Flutter CI/CD — PluriWave / Test + Build (push) Has been cancelled
Reviewed-on: #7
2026-04-05 19:08:21 +02:00
FreeTLab 1250f40322 Merge pull request 'feat(v0.5.0): visualizador de audio animado' (#6) from feature/visualizador-audio into main
Flutter CI/CD — PluriWave / Test + Build (push) Has been cancelled
Reviewed-on: #6
2026-04-05 19:07:59 +02:00
ShanaiaBot b0fdba5119 ci: retrigger workflow
Flutter CI/CD — PluriWave / Test + Build (pull_request) Has been cancelled
2026-04-05 07:49:51 +02:00
ShanaiaBot 44849986d2 fix(reproduccion): robustez HTTP cleartext, errores ExoPlayer y certificados SSL
Flutter CI/CD — PluriWave / Test + Build (pull_request) Has been cancelled
**Fix 1 — HTTP cleartext (streams sin HTTPS):**
- Añadir android/app/src/main/res/xml/network_security_config.xml con
  cleartextTrafficPermitted=true para permitir streams de radio HTTP
- Referenciar en AndroidManifest.xml con android:networkSecurityConfig
- Resuelve: 'Cleartext HTTP traffic to [host] not permitted' en ExoPlayer
- Radio Paradise (Dance Wave, HTTP) y otras radios HTTP funcionan ahora

**Fix 2 — Gestión de error TYPE_SOURCE y todos los PlaybackException:**
- Añadir listener en playbackEventStream.onError en PluriWaveAudioHandler
- _gestionarErrorReproduccion() emite AudioProcessingState.error al UI,
  loggea el error y resetea el player a estado idle limpio
- _mensajeAmigable() traduce códigos ERROR_CODE_IO_*, ERROR_CODE_PARSING_*,
  ERROR_CODE_DECODING_* y mensajes de Cleartext/HandshakeException a texto legible
- EstadoRadio.reproducir() captura la excepción y cancela el timer si estaba activo
- EstadoRadio escucha el estadoStream y cancela timer ante cualquier error

**Fix 3 — Artwork con certificado autofirmado:**
- errorWidget en CachedNetworkImage captura HandshakeException silenciosamente
- Muestra _iconoFallback (icono de radio) en lugar de imagen rota
- El error de artwork no se propaga ni interrumpe la reproducción

**Fix 4 — UI consistente en estado de error:**
- PantallaReproductor._Controles muestra mensaje + botón Reintentar en error
- PantallaReproductor._Artwork muestra overlay wifi_off en estado de error
- MiniReproductor muestra botón refresh (reintentar) en estado de error
- EstadoReproduccion.error ya estaba definido; ahora el estadoStream lo emite
- Timer cancelado automáticamente cuando la reproducción falla
- Test de smoke corregido (boilerplate MyApp → placeholder válido)

Fixes: cleartext HTTP, cert autofirmado, ExoPlayer TYPE_SOURCE, UI inconsistente
2026-04-04 20:43:56 +02:00
Kira (Agent) c6dad82e8c feat(v0.5.0): visualizador de audio animado
Flutter CI/CD — PluriWave / Test + Build (pull_request) Has been cancelled
- VisualizadorAudio: 24 barras verticales con movimiento orgánico
  (senos compuestos con fases aleatorias). Activo=animación fluida,
  parado=decaimiento suave. Sin FFT/micrófono. Integrado en
  PantallaReproductor entre info emisora y controles.
- IndicadorReproduccion: 3 barras compactas para MiniReproductor.
  Reemplaza el icono estático, pulsa mientras hay audio activo.
2026-04-04 20:11:13 +02:00
205 changed files with 43418 additions and 1295 deletions
+169
View File
@@ -0,0 +1,169 @@
name: Build & Deploy PluriWave
on:
push:
branches: [main, PRO]
env:
PATH: /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
KEYSTORE_PATH: /Users/freetlab/.openclaw/workspace/.secure/pluriwave/pluriwave-upload.jks
KEYSTORE_ALIAS: pluriwave-upload
PLAY_PACKAGE_NAME: es.freetimelab.pluriwave
CURRENT_REF: ${{ gitea.ref }}
jobs:
analizar:
name: Análisis de código
runs-on: [self-hosted, macos, arm64, flutter]
steps:
- name: Clonar rama actual
run: |
BRANCH="${CURRENT_REF#refs/heads/}"
git clone https://ShanaiaBot:${{ secrets.GITEA_TOKEN }}@git.freetimelab.es/FreeTLab/pluriwave.git .
git fetch origin "$BRANCH"
git checkout "$BRANCH"
- name: Obtener dependencias
run: flutter pub get
- name: Analizar código
run: flutter analyze --no-fatal-infos --no-fatal-warnings
- name: Ejecutar tests criticos
timeout-minutes: 15
run: |
flutter test test/servicios/servicio_programacion_alarmas_test.dart test/estado/estado_alarmas_test.dart --concurrency=1 --timeout=60s
- name: Limpiar procesos Flutter de tests
if: always()
run: pkill -f 'flutter_tester|flutter_tools.snapshot|dartaotruntime' 2>/dev/null || true
build:
name: Build APK + AAB release
runs-on: [self-hosted, macos, arm64, flutter]
needs: analizar
steps:
- name: Clonar rama actual
run: |
BRANCH="${CURRENT_REF#refs/heads/}"
git clone https://ShanaiaBot:${{ secrets.GITEA_TOKEN }}@git.freetimelab.es/FreeTLab/pluriwave.git .
git fetch origin "$BRANCH"
git checkout "$BRANCH"
- name: Configurar keystore de firma
env:
KEYSTORE_PASSWORD: ${{ secrets.PLURIWAVE_KEYSTORE_PASSWORD }}
run: |
if [ ! -f "$KEYSTORE_PATH" ]; then
echo "ERROR: Keystore no encontrado en $KEYSTORE_PATH"
exit 1
fi
echo "storeFile=$KEYSTORE_PATH" > android/key.properties
echo "storePassword=$KEYSTORE_PASSWORD" >> android/key.properties
echo "keyAlias=$KEYSTORE_ALIAS" >> android/key.properties
echo "keyPassword=$KEYSTORE_PASSWORD" >> android/key.properties
echo "✅ Keystore configurado"
- name: Bump versión patch + commit
run: |
BRANCH="${CURRENT_REF#refs/heads/}"
git config user.name "ShanaiaBot"
git config user.email "shanaia@freetimelab.es"
CURRENT=$(grep '^version:' pubspec.yaml | awk '{print $2}')
SEMVER=$(echo "$CURRENT" | cut -d'+' -f1)
BUILD=$(echo "$CURRENT" | cut -d'+' -f2)
MAJOR=$(echo "$SEMVER" | cut -d. -f1)
MINOR=$(echo "$SEMVER" | cut -d. -f2)
PATCH=$(echo "$SEMVER" | cut -d. -f3)
NEW_PATCH=$((PATCH + 1))
NEW_BUILD=$((BUILD + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}+${NEW_BUILD}"
sed -i '' "s/^version: .*/version: ${NEW_VERSION}/" pubspec.yaml
git add pubspec.yaml
git commit -m "chore: bump version to ${NEW_VERSION} [ci skip]"
git push origin "HEAD:${BRANCH}"
- name: Extraer versión
id: version
run: |
VERSION=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f1)
BUILD_NUMBER=$(grep '^version:' pubspec.yaml | awk '{print $2}' | cut -d'+' -f2)
COMMIT=$(git rev-parse --short HEAD)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "build_number=$BUILD_NUMBER" >> "$GITHUB_OUTPUT"
echo "commit=$COMMIT" >> "$GITHUB_OUTPUT"
- name: Obtener dependencias
run: flutter pub get
- name: Build APK release
run: flutter build apk --release
- name: Build AAB release
run: flutter build appbundle --release
- name: Publicar en ftl-builds (Zimaboard)
run: |
VERSION="${{ steps.version.outputs.version }}"
APK_NOMBRE="pluriwave-v${VERSION}.apk"
AAB_NOMBRE="pluriwave-v${VERSION}.aab"
DESTINO="/opt/ftl-builds/builds/pluriwave/v${VERSION}"
SSH_KEY="/Users/freetlab/.openclaw/workspace/.secure/zimaboard_ed25519"
ssh -i "$SSH_KEY" -o StrictHostKeyChecking=no ShanaiaBot@192.168.0.33 "mkdir -p ${DESTINO}"
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
build/app/outputs/flutter-apk/app-release.apk \
"ShanaiaBot@192.168.0.33:${DESTINO}/${APK_NOMBRE}"
scp -i "$SSH_KEY" -o StrictHostKeyChecking=no \
build/app/outputs/bundle/release/app-release.aab \
"ShanaiaBot@192.168.0.33:${DESTINO}/${AAB_NOMBRE}"
echo "✅ APK: builds.freetimelab.es → pluriwave → v${VERSION}"
echo "✅ AAB: builds.freetimelab.es → pluriwave → v${VERSION}"
- name: Preparar credenciales de Google Play
if: ${{ gitea.ref == 'refs/heads/PRO' }}
env:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
run: |
if [ -z "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" ]; then
echo "ERROR: falta el secreto GOOGLE_PLAY_SERVICE_ACCOUNT_JSON"
exit 1
fi
mkdir -p fastlane/credentials
printf '%s' "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" > fastlane/credentials/google-play-service-account.json
- name: Instalar Fastlane
if: ${{ gitea.ref == 'refs/heads/PRO' }}
run: |
gem list -i fastlane >/dev/null 2>&1 || gem install fastlane --no-document
- name: Publicar AAB en Google Play Internal Testing
if: ${{ gitea.ref == 'refs/heads/PRO' }}
env:
PLAY_JSON_KEY_PATH: fastlane/credentials/google-play-service-account.json
PLAY_AAB_PATH: build/app/outputs/bundle/release/app-release.aab
PLAY_TRACK: internal
PLAY_RELEASE_STATUS: completed
run: fastlane android upload_internal
- name: Notificar Telegram
if: always()
run: |
VERSION="${{ steps.version.outputs.version }}"
COMMIT="${{ steps.version.outputs.commit }}"
BRANCH="${CURRENT_REF#refs/heads/}"
BOT_TOKEN=$(plutil -extract 'EnvironmentVariables:TELEGRAM_BOT_TOKEN' raw /Users/freetlab/Library/LaunchAgents/ai.openclaw.gateway.plist 2>/dev/null || echo "")
if [ -z "$BOT_TOKEN" ]; then exit 0; fi
if [ "${{ job.status }}" = "success" ]; then
MSG="✅ *PluriWave* v${VERSION} · rama ${BRANCH} · ${COMMIT}%0AAPK + AAB generados"
if [ "$BRANCH" = "PRO" ]; then
MSG="${MSG}%0APublicado en Google Play · Internal Testing"
else
MSG="${MSG}%0APublicado en builds.freetimelab.es"
fi
else
MSG="❌ *PluriWave* build FAILED · rama ${BRANCH} · ${COMMIT}"
fi
curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
-d "chat_id=221721467" -d "parse_mode=Markdown" -d "text=${MSG}" || true
@@ -11,7 +11,10 @@ on:
jobs:
flutter-ci:
name: Test + Build
runs-on: macmini-flutter
#runs-on: macos-14
runs-on: [self-hosted, macos, arm64, flutter]
env:
ANDROID_HOME: /Users/freetlab/Library/Android/sdk
steps:
- name: Checkout
+1
View File
@@ -32,6 +32,7 @@ migrate_working_dir/
.pub/
/build/
/coverage/
.atl/
# Symbolication related
app.*.symbols
+6
View File
@@ -1,5 +1,11 @@
# Changelog — PluriWave
## [0.5.0] — 2026-04-04
### Añadido
- **VisualizadorAudio** — visualizador de barras animadas en `PantallaReproductor`. 24 barras verticales con movimiento orgánico pseudo-aleatorio (combinación de ondas seno con fases distintas). Se activa al reproducir y decae suavemente al parar. Sin FFT real ni permisos de micrófono — animación simulada visualmente equivalente a las apps de streaming.
- **IndicadorReproduccion** — versión compacta de 3 barras para el `MiniReproductor`. Reemplaza el icono estático de radio y pulsa mientras hay audio activo.
## [0.4.0] — 2026-04-04
### Añadido
+50
View File
@@ -0,0 +1,50 @@
# TODO
## Internacionalización AAA
- [x] Diseñar una base de internacionalización profesional con ficheros ARB separados por idioma.
- [x] Permitir que el usuario cambie el idioma manualmente desde la aplicación, sin depender únicamente del idioma del sistema.
- [x] Añadir traducción inicial español/inglés para el shell, navegación, timer de sueño y selector de idioma.
- [x] Añadir soporte inicial para un conjunto amplio de idiomas muy hablados: inglés, español, chino, hindi, árabe, portugués, francés, ruso, alemán, japonés, indonesio, bengalí e italiano.
- [x] Ejecutar escaneo UTF-8 sobre ARB/código tocado y corregir corrupciones visibles en los textos migrados.
- [ ] Validar no solo el guardado UTF-8 en código, sino también el render real en la aplicación para acentos, ñ, signos, alfabetos no latinos y direcciones RTL.
- [ ] Repasar absolutamente todos los literales de la aplicación en todas las pantallas, componentes, servicios con mensajes visibles y notificaciones.
- [ ] Soportar formatos locales de fecha, hora, números y duración usando helpers centralizados.
- [ ] Resolver correctamente singular/plural y variantes por cantidad, por ejemplo `1 emisora` vs `2 emisoras`.
- [ ] Revisar profesionalmente todas las traducciones nuevas con hablantes nativos o servicio especializado antes de considerarlas definitivas.
- [ ] Preparar traducciones adicionales si se decide ampliar más allá del conjunto inicial.
- [ ] Revisar la aplicación de Farolero como referencia para detectar el conjunto de idiomas que nos interesa mantener.
- [ ] Verificar que no queda ningún literal hardcodeado fuera del sistema de traducciones.
## UX y accesibilidad visual
- [x] Revisar los paneles informativos superiores de cada pantalla: recuperar márgenes internos elegantes para que el texto no quede pegado a los bordes.
- [x] Añadir comportamiento adaptativo en el header premium para escalas de texto grandes y pantallas estrechas.
- [ ] Probar la aplicación con escalas de texto grandes/muy grandes del sistema en dispositivo real o golden tests.
- [ ] Diseñar una solución elegante para textos largos en todos los paneles secundarios: reflow, límites razonables, scroll, wraps controlados y jerarquías que mantengan la estética AAA.
## Grabaciones
- [x] Añadir en Ajustes un acceso elegante para abrir la carpeta de grabaciones con el gestor de ficheros del sistema mediante intent.
- [x] Añadir configuración de tamaño máximo de fichero de grabación; valor por defecto: 500 MB.
- [x] Detener automáticamente la grabación si se para o pausa la reproducción.
- [x] Detener automáticamente la grabación si se cambia de emisora.
- [ ] Probar en Android real que el intent de carpeta funciona con rutas internas y rutas escogidas por el usuario.
## Búsqueda de emisoras
- [x] Añadir filtro de calidad mínima de reproducción en kbps en el buscador de emisoras.
## Favoritos
- [x] Revisar el sistema de guardado de favoritos en instalaciones nuevas y migradas: inicialización de SQLite, creación de ruta/base de datos, migraciones de columnas y refresco de estado tras guardar. Reporte: en un móvil no se están guardando favoritos.
- [ ] Añadir tests de regresión para favoritos en base de datos real/migrada, incluyendo esquemas antiguos y primera instalación limpia.
## Agrupaciones de favoritos
- [x] Permitir crear listas de favoritos con nombre corto configurable por el usuario desde Ajustes.
- [x] Mantener siempre un grupo interno por defecto traducible llamado "Sin asignar", no editable y no borrable.
- [x] Gestionar desde la vista Favoritos qué emisoras pertenecen a cada agrupación/lista.
- [x] Diseñar migración SQLite base para asociar favoritos existentes al grupo "Sin asignar" sin perder datos.
- [x] Completar UI en Ajustes para crear, editar y borrar listas de favoritos.
- [x] Completar UI en Favoritos para mover emisoras entre listas.
+35 -7
View File
@@ -1,10 +1,21 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
import java.util.Properties
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
fun secret(name: String, propertyName: String): String? =
keystoreProperties.getProperty(propertyName)?.takeIf { it.isNotBlank() }
?: System.getenv(name)?.takeIf { it.isNotBlank() }
android {
namespace = "es.freetimelab.pluriwave"
compileSdk = flutter.compileSdkVersion
@@ -20,21 +31,38 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "es.freetimelab.pluriwave"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
val storeFilePath = secret("KEYSTORE_PATH", "storeFile")
val storePasswordValue = secret("KEYSTORE_PASSWORD", "storePassword")
val keyAliasValue = secret("KEYSTORE_ALIAS", "keyAlias")
val keyPasswordValue = secret("KEY_PASSWORD", "keyPassword")
if (!storeFilePath.isNullOrBlank()) {
storeFile = file(storeFilePath)
}
if (!storePasswordValue.isNullOrBlank()) {
storePassword = storePasswordValue
}
if (!keyAliasValue.isNullOrBlank()) {
keyAlias = keyAliasValue
}
if (!keyPasswordValue.isNullOrBlank()) {
keyPassword = keyPasswordValue
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
}
}
}
+55 -1
View File
@@ -4,18 +4,30 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.USE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<application
android:label="PluriWave"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round">
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:showWhenLocked="true"
android:turnScreenOn="true"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
@@ -39,6 +51,11 @@
</intent-filter>
</service>
<service
android:name=".PluriWaveAlarmService"
android:foregroundServiceType="mediaPlayback"
android:exported="false" />
<!-- Receptor de controles de media (auriculares, notificación) -->
<receiver
android:name="com.ryanheise.audioservice.MediaButtonReceiver"
@@ -48,6 +65,43 @@
</intent-filter>
</receiver>
<receiver
android:name=".PluriWaveAlarmReceiver"
android:exported="false"
android:directBootAware="true">
<intent-filter>
<action android:name="es.freetimelab.pluriwave.alarm.FIRE"/>
<action android:name="es.freetimelab.pluriwave.alarm.PRE_NOTICE"/>
<action android:name="es.freetimelab.pluriwave.alarm.SKIP_NEXT"/>
<action android:name="es.freetimelab.pluriwave.alarm.POSTPONE_NEXT"/>
</intent-filter>
</receiver>
<receiver
android:name=".PluriWaveBootReceiver"
android:exported="true"
android:directBootAware="true">
<intent-filter>
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED"/>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.USER_UNLOCKED"/>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED"/>
<action android:name="android.intent.action.TIME_SET"/>
<action android:name="android.intent.action.TIMEZONE_CHANGED"/>
<action android:name="android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED"/>
</intent-filter>
</receiver>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/pluriwave_file_paths" />
</provider>
<meta-data
android:name="flutterEmbedding"
android:value="2" />
@@ -0,0 +1,663 @@
package es.freetimelab.pluriwave
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import org.json.JSONArray
import org.json.JSONObject
import java.util.Calendar
import java.util.TimeZone
class AlarmScheduler(private val context: Context) {
private val tag = "PluriWave"
private val appContext = context.applicationContext
private val alarmManager =
appContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
fun scheduleAlarm(
id: String,
title: String,
triggerAtMillis: Long,
preNoticeAtMillis: Long,
stationName: String?,
stationUrl: String?,
fallbackSound: String?,
volume: Float,
hour: Int? = null,
minute: Int? = null,
scheduleType: String? = null,
weekdays: List<Int> = emptyList(),
oneShotDateMillis: Long? = null,
snoozeUntilMillis: Long? = null,
snoozeOriginMillis: Long? = null,
lastHandledAtMillis: Long? = null,
soundOnVacation: Boolean = true,
snoozeMinutes: Int = 5
): Boolean {
val existing = readSpec(id)
val preservedSnooze = preserveNativeSnooze(
existing = existing,
requestedTriggerAtMillis = triggerAtMillis,
requestedSnoozeUntilMillis = snoozeUntilMillis
)
val spec = NativeAlarmSpec(
id = id,
title = title,
enabled = true,
triggerAtMillis = triggerAtMillis,
preNoticeAtMillis = preNoticeAtMillis,
hour = hour ?: localHour(triggerAtMillis),
minute = minute ?: localMinute(triggerAtMillis),
scheduleType = scheduleType ?: SCHEDULE_UNICA,
weekdays = weekdays,
oneShotDateMillis = oneShotDateMillis,
snoozeUntilMillis = preservedSnooze?.first ?: snoozeUntilMillis,
snoozeOriginMillis = preservedSnooze?.second ?: snoozeOriginMillis,
lastHandledAtMillis = lastHandledAtMillis,
soundOnVacation = soundOnVacation,
snoozeMinutes = sanitizeSnoozeMinutes(snoozeMinutes),
stationName = stationName,
stationUrl = stationUrl,
fallbackSound = fallbackSound,
volume = volume.coerceIn(0f, 1f),
timezoneId = TimeZone.getDefault().id
)
return scheduleSpec(spec, persistOnSuccess = true)
}
private fun scheduleSpec(spec: NativeAlarmSpec, persistOnSuccess: Boolean): Boolean {
val nextTrigger = computeNextTriggerMillis(spec)
Log.d(
tag,
"alarm.schedule id=${spec.id} title=${spec.title} trigger=$nextTrigger type=${spec.scheduleType} snooze=${spec.snoozeUntilMillis} canExact=${canScheduleExactAlarms()}"
)
if (nextTrigger == null) {
Log.d(tag, "alarm.schedule no next trigger id=${spec.id}")
removeScheduledAlarm(spec.id)
cancelPending("fire", pendingFireIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
cancelPending("show", pendingShowIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
cancelPending("preNotice", pendingPreNoticeIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
return true
}
val scheduledSpec = spec.copy(
triggerAtMillis = nextTrigger,
preNoticeAtMillis = if (spec.snoozeUntilMillis == null) {
nextTrigger - PRE_NOTICE_MILLIS
} else {
0L
}
)
val alarmIntent = fireIntent(scheduledSpec)
val showIntent = showIntent(scheduledSpec)
val mainScheduled = scheduleMainAlarm(
scheduledSpec.id,
scheduledSpec.triggerAtMillis,
showIntent,
alarmIntent
)
if (!mainScheduled) {
Log.w(tag, "alarm.schedule main failed but keeping spec for future resync id=${scheduledSpec.id}")
saveScheduledAlarm(scheduledSpec)
return false
}
if (persistOnSuccess) {
saveScheduledAlarm(scheduledSpec)
}
schedulePreNotice(scheduledSpec)
return true
}
private fun schedulePreNotice(spec: NativeAlarmSpec) {
val now = System.currentTimeMillis()
if (spec.snoozeUntilMillis != null) {
cancelPending("preNotice", pendingPreNoticeIntent(spec.id, PendingIntent.FLAG_NO_CREATE))
Log.d(tag, "alarm.schedule preNotice skipped for snooze id=${spec.id}")
return
}
if (spec.preNoticeAtMillis > now) {
try {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
spec.preNoticeAtMillis,
PendingIntent.getBroadcast(
appContext,
requestCode(spec.id, 3),
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
putExtra(
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
spec.snoozeOriginMillis ?: spec.triggerAtMillis
)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
)
Log.d(tag, "alarm.schedule preNotice OK id=${spec.id}")
} catch (_: SecurityException) {
Log.w(tag, "alarm.schedule preNotice SecurityException id=${spec.id}")
}
} else if (spec.triggerAtMillis > now) {
appContext.sendBroadcast(
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
putExtra(
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
spec.snoozeOriginMillis ?: spec.triggerAtMillis
)
}
)
Log.d(tag, "alarm.schedule preNotice immediate id=${spec.id}")
} else {
Log.d(tag, "alarm.schedule preNotice skipped id=${spec.id}")
}
}
private fun scheduleMainAlarm(
id: String,
triggerAtMillis: Long,
showIntent: PendingIntent,
alarmIntent: PendingIntent
): Boolean {
try {
alarmManager.setAlarmClock(
AlarmManager.AlarmClockInfo(triggerAtMillis, showIntent),
alarmIntent
)
Log.d(tag, "alarm.schedule setAlarmClock OK id=$id")
return true
} catch (error: SecurityException) {
Log.e(tag, "alarm.schedule setAlarmClock SecurityException id=$id", error)
} catch (error: Throwable) {
Log.e(tag, "alarm.schedule setAlarmClock ERROR id=$id", error)
}
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
alarmIntent
)
Log.d(tag, "alarm.schedule setExactAndAllowWhileIdle fallback OK id=$id")
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Log.e(tag, "alarm.schedule exact permission missing; refusing inexact fallback id=$id")
return false
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
alarmIntent
)
Log.d(tag, "alarm.schedule setAndAllowWhileIdle fallback OK id=$id")
} else {
alarmManager.set(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
alarmIntent
)
Log.d(tag, "alarm.schedule set fallback OK id=$id")
}
true
} catch (error: Throwable) {
Log.e(tag, "alarm.schedule fallback ERROR id=$id", error)
false
}
}
fun onAlarmFired(id: String) {
val spec = readSpec(id) ?: return
val firedAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
saveHandledOccurrence(id, firedAt)
val next = spec.copy(
snoozeUntilMillis = null,
snoozeOriginMillis = null,
lastHandledAtMillis = firedAt,
enabled = spec.scheduleType != SCHEDULE_UNICA
)
if (next.enabled) {
scheduleSpec(next, persistOnSuccess = true)
} else {
removeScheduledAlarm(id)
}
}
fun skipNext(id: String) {
val spec = readSpec(id) ?: return
val next = spec.copy(
snoozeUntilMillis = null,
snoozeOriginMillis = null,
lastHandledAtMillis = spec.triggerAtMillis,
enabled = spec.scheduleType != SCHEDULE_UNICA
)
if (next.enabled) {
scheduleSpec(next, persistOnSuccess = true)
} else {
cancelAlarm(id)
}
}
fun snooze(id: String, minutes: Int) {
val spec = readSpec(id) ?: return
val safeMinutes = sanitizeSnoozeMinutes(minutes)
val snoozeUntil = System.currentTimeMillis() + safeMinutes * 60_000L
scheduleSpec(
spec.copy(
snoozeUntilMillis = snoozeUntil,
snoozeOriginMillis = spec.snoozeOriginMillis ?: spec.triggerAtMillis
),
persistOnSuccess = true
)
}
fun postponeNext(id: String, minutes: Int): Long? {
val spec = readSpec(id) ?: return null
val safeMinutes = sanitizeSnoozeMinutes(minutes)
val occurrenceAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis
val target = occurrenceAt + safeMinutes * 60_000L
val now = System.currentTimeMillis()
val snoozeUntil = if (target > now) target else now + safeMinutes * 60_000L
Log.d(
tag,
"alarm.postponeNext id=$id minutes=$safeMinutes occurrence=$occurrenceAt target=$snoozeUntil"
)
scheduleSpec(
spec.copy(
snoozeUntilMillis = snoozeUntil,
snoozeOriginMillis = occurrenceAt,
snoozeMinutes = safeMinutes
),
persistOnSuccess = true
)
return occurrenceAt
}
fun cancelAlarm(id: String) {
Log.d(tag, "alarm.cancel id=$id")
removeScheduledAlarm(id)
removeHandledOccurrence(id)
cancelPending("fire", pendingFireIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelPending("show", pendingShowIntent(id, PendingIntent.FLAG_NO_CREATE))
cancelPending("preNotice", pendingPreNoticeIntent(id, PendingIntent.FLAG_NO_CREATE))
NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.notificationIdForAlarm(id)
)
NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(id)
)
}
fun dismissFireNotification(id: String) {
NotificationManagerCompat.from(appContext).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(id)
)
}
fun canScheduleExactAlarms(): Boolean {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
alarmManager.canScheduleExactAlarms()
}
fun reschedulePersistedAlarms() {
for (id in prefs().getStringSet(KEY_IDS, emptySet()).orEmpty()) {
val spec = readSpec(id) ?: continue
try {
scheduleSpec(spec, persistOnSuccess = true)
Log.d(tag, "alarm.reschedule OK id=$id")
} catch (error: Throwable) {
Log.e(tag, "alarm.reschedule failed id=$id", error)
}
}
}
fun pendingAlarmCount(): Int =
prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size
fun handledOccurrences(): List<Map<String, Any>> =
prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty()
.mapNotNull { id ->
val handledAt = prefs().getLong("$KEY_HANDLED_PREFIX$id", 0L)
.takeIf { it > 0L }
?: return@mapNotNull null
mapOf(
"alarmId" to id,
"handledAtMillis" to handledAt
)
}
private fun preserveNativeSnooze(
existing: NativeAlarmSpec?,
requestedTriggerAtMillis: Long,
requestedSnoozeUntilMillis: Long?
): Pair<Long, Long>? {
if (requestedSnoozeUntilMillis != null || existing == null) return null
val snoozeUntil = existing.snoozeUntilMillis ?: return null
val snoozeOrigin = existing.snoozeOriginMillis ?: return null
if (snoozeUntil <= System.currentTimeMillis()) return null
if (snoozeOrigin != requestedTriggerAtMillis) return null
Log.d(
tag,
"alarm.schedule preserving native snooze id=${existing.id} origin=$snoozeOrigin until=$snoozeUntil"
)
return snoozeUntil to snoozeOrigin
}
private fun computeNextTriggerMillis(spec: NativeAlarmSpec): Long? {
val now = System.currentTimeMillis()
spec.snoozeUntilMillis?.let { if (it > now) return it }
if (!spec.enabled) return null
val base = maxOf(now, (spec.lastHandledAtMillis ?: 0L) + 60_000L)
return when (spec.scheduleType) {
SCHEDULE_UNICA -> computeOneShot(spec, base)
SCHEDULE_DIAS_SEMANA -> computeWeekday(spec, base)
else -> computeDaily(spec, base)
}
}
private fun computeOneShot(spec: NativeAlarmSpec, baseMillis: Long): Long? {
val candidate = Calendar.getInstance().apply {
timeInMillis = spec.oneShotDateMillis ?: spec.triggerAtMillis
set(Calendar.HOUR_OF_DAY, spec.hour)
set(Calendar.MINUTE, spec.minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
return candidate.timeInMillis.takeIf { it > baseMillis }
}
private fun computeDaily(spec: NativeAlarmSpec, baseMillis: Long): Long? {
val candidate = Calendar.getInstance().apply {
timeInMillis = baseMillis
set(Calendar.HOUR_OF_DAY, spec.hour)
set(Calendar.MINUTE, spec.minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
if (candidate.timeInMillis <= baseMillis) {
candidate.add(Calendar.DAY_OF_YEAR, 1)
}
return candidate.timeInMillis
}
private fun computeWeekday(spec: NativeAlarmSpec, baseMillis: Long): Long? {
if (spec.weekdays.isEmpty()) return null
val candidate = Calendar.getInstance().apply {
timeInMillis = baseMillis
set(Calendar.HOUR_OF_DAY, spec.hour)
set(Calendar.MINUTE, spec.minute)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}
for (i in 0 until 370) {
if (candidate.timeInMillis > baseMillis &&
spec.weekdays.contains(dartWeekday(candidate))
) {
return candidate.timeInMillis
}
candidate.add(Calendar.DAY_OF_YEAR, 1)
}
return null
}
private fun dartWeekday(calendar: Calendar): Int =
when (calendar.get(Calendar.DAY_OF_WEEK)) {
Calendar.MONDAY -> 1
Calendar.TUESDAY -> 2
Calendar.WEDNESDAY -> 3
Calendar.THURSDAY -> 4
Calendar.FRIDAY -> 5
Calendar.SATURDAY -> 6
else -> 7
}
private fun saveScheduledAlarm(spec: NativeAlarmSpec) {
val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet()
ids.add(spec.id)
prefs().edit()
.putStringSet(KEY_IDS, ids)
.putString("$KEY_ALARM_PREFIX${spec.id}", spec.toJson().toString())
.apply()
}
private fun readSpec(id: String): NativeAlarmSpec? {
val raw = prefs().getString("$KEY_ALARM_PREFIX$id", null) ?: return null
return try {
NativeAlarmSpec.fromJson(JSONObject(raw))
} catch (error: Throwable) {
Log.e(tag, "alarm.readSpec failed id=$id", error)
null
}
}
private fun removeScheduledAlarm(id: String) {
val ids = prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().toMutableSet()
ids.remove(id)
prefs().edit()
.putStringSet(KEY_IDS, ids)
.remove("$KEY_ALARM_PREFIX$id")
.apply()
}
private fun saveHandledOccurrence(id: String, handledAtMillis: Long) {
val ids = prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty().toMutableSet()
ids.add(id)
prefs().edit()
.putStringSet(KEY_HANDLED_IDS, ids)
.putLong("$KEY_HANDLED_PREFIX$id", handledAtMillis)
.apply()
}
private fun removeHandledOccurrence(id: String) {
val ids = prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty().toMutableSet()
ids.remove(id)
prefs().edit()
.putStringSet(KEY_HANDLED_IDS, ids)
.remove("$KEY_HANDLED_PREFIX$id")
.apply()
}
private fun prefs() =
appContext.createDeviceProtectedStorageContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
private fun cancelPending(name: String, pendingIntent: PendingIntent?) {
if (pendingIntent == null) {
Log.d(tag, "alarm.cancel $name no pending intent")
return
}
alarmManager.cancel(pendingIntent)
pendingIntent.cancel()
Log.d(tag, "alarm.cancel $name OK")
}
private fun fireIntent(spec: NativeAlarmSpec): PendingIntent =
PendingIntent.getBroadcast(
appContext,
requestCode(spec.id, 1),
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME, spec.stationName)
putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL, spec.stationUrl)
putExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND, spec.fallbackSound)
putExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, spec.volume)
putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
putExtra(
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
spec.snoozeOriginMillis ?: spec.triggerAtMillis
)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun showIntent(spec: NativeAlarmSpec): PendingIntent =
PendingIntent.getActivity(
appContext,
requestCode(spec.id, 2),
Intent(appContext, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, spec.id)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis)
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes)
putExtra(
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
spec.snoozeOriginMillis ?: spec.triggerAtMillis
)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun pendingFireIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getBroadcast(
appContext,
requestCode(id, 1),
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE
},
flags or PendingIntent.FLAG_IMMUTABLE
)
private fun pendingShowIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getActivity(
appContext,
requestCode(id, 2),
Intent(appContext, MainActivity::class.java).apply {
this.flags =
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
},
flags or PendingIntent.FLAG_IMMUTABLE
)
private fun pendingPreNoticeIntent(id: String, flags: Int): PendingIntent? =
PendingIntent.getBroadcast(
appContext,
requestCode(id, 3),
Intent(appContext, PluriWaveAlarmReceiver::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_PRE_NOTICE
},
flags or PendingIntent.FLAG_IMMUTABLE
)
private fun localHour(millis: Long): Int =
Calendar.getInstance().apply { timeInMillis = millis }.get(Calendar.HOUR_OF_DAY)
private fun localMinute(millis: Long): Int =
Calendar.getInstance().apply { timeInMillis = millis }.get(Calendar.MINUTE)
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
private fun requestCode(id: String, slot: Int): Int = 31 * id.hashCode() + slot
private data class NativeAlarmSpec(
val id: String,
val title: String,
val enabled: Boolean,
val triggerAtMillis: Long,
val preNoticeAtMillis: Long,
val hour: Int,
val minute: Int,
val scheduleType: String,
val weekdays: List<Int>,
val oneShotDateMillis: Long?,
val snoozeUntilMillis: Long?,
val snoozeOriginMillis: Long?,
val lastHandledAtMillis: Long?,
val soundOnVacation: Boolean,
val snoozeMinutes: Int,
val stationName: String?,
val stationUrl: String?,
val fallbackSound: String?,
val volume: Float,
val timezoneId: String
) {
fun toJson(): JSONObject = JSONObject().apply {
put("schemaVersion", 2)
put("id", id)
put("title", title)
put("enabled", enabled)
put("triggerAtMillis", triggerAtMillis)
put("preNoticeAtMillis", preNoticeAtMillis)
put("hour", hour)
put("minute", minute)
put("scheduleType", scheduleType)
put("weekdays", JSONArray(weekdays))
put("oneShotDateMillis", oneShotDateMillis)
put("snoozeUntilMillis", snoozeUntilMillis)
put("snoozeOriginMillis", snoozeOriginMillis)
put("lastHandledAtMillis", lastHandledAtMillis)
put("soundOnVacation", soundOnVacation)
put("snoozeMinutes", snoozeMinutes)
put("stationName", stationName)
put("stationUrl", stationUrl)
put("fallbackSound", fallbackSound)
put("volume", volume)
put("timezoneId", timezoneId)
}
companion object {
fun fromJson(json: JSONObject): NativeAlarmSpec {
val weekdaysJson = json.optJSONArray("weekdays") ?: JSONArray()
return NativeAlarmSpec(
id = json.optString("id"),
title = json.optString("title", "PluriWave"),
enabled = json.optBoolean("enabled", true),
triggerAtMillis = json.optLong("triggerAtMillis", 0L),
preNoticeAtMillis = json.optLong("preNoticeAtMillis", 0L),
hour = json.optInt("hour", 7),
minute = json.optInt("minute", 0),
scheduleType = json.optString("scheduleType", SCHEDULE_UNICA),
weekdays = (0 until weekdaysJson.length()).mapNotNull {
weekdaysJson.optInt(it).takeIf { day -> day in 1..7 }
},
oneShotDateMillis = json.optNullableLong("oneShotDateMillis"),
snoozeUntilMillis = json.optNullableLong("snoozeUntilMillis"),
snoozeOriginMillis = json.optNullableLong("snoozeOriginMillis"),
lastHandledAtMillis = json.optNullableLong("lastHandledAtMillis"),
soundOnVacation = json.optBoolean("soundOnVacation", true),
snoozeMinutes = json.optInt("snoozeMinutes", 5).let {
if (it == 3 || it == 5 || it == 10) it else 5
},
stationName = json.optString("stationName").takeIf { it.isNotBlank() },
stationUrl = json.optString("stationUrl").takeIf { it.isNotBlank() },
fallbackSound = json.optString("fallbackSound").takeIf { it.isNotBlank() },
volume = json.optDouble("volume", 0.85).toFloat(),
timezoneId = json.optString("timezoneId", TimeZone.getDefault().id)
)
}
}
}
companion object {
private const val PREFS = "pluriwave_alarm_scheduler"
private const val KEY_IDS = "scheduled_alarm_ids"
private const val KEY_ALARM_PREFIX = "scheduled_alarm_"
private const val KEY_HANDLED_IDS = "handled_alarm_ids"
private const val KEY_HANDLED_PREFIX = "handled_alarm_"
private const val PRE_NOTICE_MILLIS = 30 * 60 * 1000L
private const val SCHEDULE_UNICA = "unica"
private const val SCHEDULE_DIAS_SEMANA = "diasSemana"
}
}
private fun JSONObject.optNullableLong(name: String): Long? =
if (has(name) && !isNull(name)) optLong(name) else null
@@ -1,5 +1,581 @@
package es.freetimelab.pluriwave
import android.Manifest
import android.app.NotificationManager
import android.content.ClipData
import android.content.Intent
import android.content.ActivityNotFoundException
import android.content.pm.PackageManager
import android.net.Uri
import android.media.audiofx.Visualizer
import android.app.AlarmManager
import android.content.Context
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.provider.DocumentsContract
import android.provider.Settings
import android.util.Log
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.FileProvider
import com.ryanheise.audioservice.AudioServiceActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
import java.io.File
class MainActivity : AudioServiceActivity()
class MainActivity : AudioServiceActivity() {
private val tag = "PluriWave"
private val visualizerChannel = "pluriwave/audio_visualizer"
private val alarmChannel = "pluriwave/alarm_scheduler"
private val fileActionsChannel = "pluriwave/file_actions"
private val visualizerPermissionRequestCode = 4821
private val notificationPermissionRequestCode = 4822
private var visualizer: Visualizer? = null
private var pendingSink: EventChannel.EventSink? = null
private var pendingArgs: Map<*, *>? = null
private var alarmMethodChannel: MethodChannel? = null
private val mainHandler = Handler(Looper.getMainLooper())
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
EventChannel(
flutterEngine.dartExecutor.binaryMessenger,
visualizerChannel
).setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
pendingSink = events
pendingArgs = arguments as? Map<*, *>
startVisualizerWhenAllowed()
}
override fun onCancel(arguments: Any?) {
stopVisualizer()
pendingSink = null
pendingArgs = null
}
})
val alarmScheduler = AlarmScheduler(this)
alarmMethodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
alarmChannel
)
alarmMethodChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"scheduleAlarm" -> {
val id = call.argument<String>("id")
val title = call.argument<String>("title") ?: "PluriWave"
val triggerAtMillis = call.argument<Long>("triggerAtMillis")
val preNoticeAtMillis = call.argument<Long>("preNoticeAtMillis") ?: 0L
val stationName = call.argument<String>("stationName")
val stationUrl = call.argument<String>("stationUrl")
val fallbackSound = call.argument<String>("fallbackSound")
val volume = call.argument<Number>("volume")?.toFloat() ?: 0.85f
val weekdays =
(call.argument<List<Int>>("weekdays") ?: emptyList())
.filter { it in 1..7 }
Log.d(tag, "alarm.channel scheduleAlarm id=$id triggerAtMillis=$triggerAtMillis preNoticeAtMillis=$preNoticeAtMillis")
if (id == null || triggerAtMillis == null) {
Log.w(tag, "alarm.channel scheduleAlarm invalid id=$id triggerAtMillis=$triggerAtMillis")
result.error("INVALID_ALARM", "Missing alarm id or trigger time", null)
} else {
val scheduled = alarmScheduler.scheduleAlarm(
id,
title,
triggerAtMillis,
preNoticeAtMillis,
stationName,
stationUrl,
fallbackSound,
volume,
hour = call.argument<Int>("hour"),
minute = call.argument<Int>("minute"),
scheduleType = call.argument<String>("scheduleType"),
weekdays = weekdays,
oneShotDateMillis = call.argument<Long>("oneShotDateMillis"),
snoozeUntilMillis = call.argument<Long>("snoozeUntilMillis"),
snoozeOriginMillis = call.argument<Long>("snoozeOriginMillis"),
lastHandledAtMillis = call.argument<Long>("lastHandledAtMillis"),
soundOnVacation = call.argument<Boolean>("soundOnVacation") ?: true,
snoozeMinutes = call.argument<Int>("snoozeMinutes") ?: 5
)
result.success(scheduled)
}
}
"cancelAlarm" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel cancelAlarm id=$id")
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
alarmScheduler.cancelAlarm(id)
result.success(null)
}
}
"dismissAlarmNotification" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel dismissAlarmNotification id=$id")
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
PluriWaveAlarmService.stop(this, id)
alarmScheduler.dismissFireNotification(id)
result.success(null)
}
}
"stopNativeAlarmSound" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel stopNativeAlarmSound id=$id")
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
PluriWaveAlarmService.stop(this, id)
result.success(null)
}
}
"confirmFlutterAudio" -> {
val id = call.argument<String>("id")
Log.d(tag, "alarm.channel confirmFlutterAudio id=$id")
if (id == null) {
result.error("INVALID_ALARM", "Missing alarm id", null)
} else {
PluriWaveAlarmService.stop(this, id)
result.success(null)
}
}
"diagnostics" -> {
Log.d(tag, "alarm.channel diagnostics")
result.success(
mapOf(
"canScheduleExactAlarms" to alarmScheduler.canScheduleExactAlarms(),
"notificationsEnabled" to NotificationManagerCompat.from(this).areNotificationsEnabled(),
"canUseFullScreenIntent" to canUseFullScreenIntent(),
"isIgnoringBatteryOptimizations" to isIgnoringBatteryOptimizations(),
"nativePendingAlarmsCount" to alarmScheduler.pendingAlarmCount(),
"manufacturer" to Build.MANUFACTURER,
"sdkInt" to Build.VERSION.SDK_INT
)
)
}
"requestExactAlarmPermission" -> {
Log.d(tag, "alarm.channel requestExactAlarmPermission")
result.success(requestExactAlarmPermission())
}
"requestPostNotificationsPermission" -> {
Log.d(tag, "alarm.channel requestPostNotificationsPermission")
result.success(requestPostNotificationsPermission())
}
"requestFullScreenIntentPermission" -> {
Log.d(tag, "alarm.channel requestFullScreenIntentPermission")
result.success(requestFullScreenIntentPermission())
}
"getInitialAlarmIntent" -> {
val payload = alarmPayload(intent)
Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload")
result.success(payload)
intent?.removeExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION)
}
"getHandledAlarmOccurrences" -> {
Log.d(tag, "alarm.channel getHandledAlarmOccurrences")
result.success(alarmScheduler.handledOccurrences())
}
else -> result.notImplemented()
}
}
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
fileActionsChannel
).setMethodCallHandler { call, result ->
when (call.method) {
"openDirectory" -> {
val path = call.argument<String>("path")
Log.d(tag, "file_actions.openDirectory path=$path")
if (path.isNullOrBlank()) {
result.success(false)
} else {
result.success(openDirectory(path))
}
}
"viewDirectory" -> {
val path = call.argument<String>("path")
Log.d(tag, "file_actions.viewDirectory path=$path")
if (path.isNullOrBlank()) {
result.success(false)
} else {
result.success(viewDirectory(path))
}
}
"openFile" -> {
val path = call.argument<String>("path")
val mimeType = call.argument<String>("mimeType") ?: "audio/*"
Log.d(tag, "file_actions.openFile path=$path mimeType=$mimeType")
if (path.isNullOrBlank()) {
result.success(false)
} else {
result.success(openFile(path, mimeType))
}
}
else -> result.notImplemented()
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
val payload = alarmPayload(intent)
if (payload.isNotEmpty()) {
Log.d(tag, "alarm.channel onNewIntent payload=$payload")
alarmMethodChannel?.invokeMethod("alarmFired", payload)
}
}
private fun alarmPayload(intent: Intent?): Map<String, Any> {
if (intent == null) return emptyMap()
val action = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION)
?: return emptyMap()
val alarmId = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
?: return emptyMap()
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE)
?: "PluriWave"
return mapOf(
"alarmId" to alarmId,
"alarmTitle" to title,
"alarmAction" to action,
"triggerAtMillis" to intent.getLongExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, 0L),
"occurrenceAtMillis" to intent.getLongExtra(PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT, 0L),
"snoozeMinutes" to intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5)
)
}
private fun requestExactAlarmPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
if (alarmManager.canScheduleExactAlarms()) return true
return try {
startActivity(
Intent(android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
data = Uri.parse("package:$packageName")
}
)
true
} catch (error: Throwable) {
Log.e(tag, "alarm.channel requestExactAlarmPermission failed", error)
false
}
}
private fun requestPostNotificationsPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
return true
}
requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
notificationPermissionRequestCode
)
return true
}
private fun requestFullScreenIntentPermission(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
if (canUseFullScreenIntent()) return true
return try {
startActivity(
Intent(Settings.ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT).apply {
data = Uri.parse("package:$packageName")
}
)
true
} catch (error: Throwable) {
Log.e(tag, "alarm.channel requestFullScreenIntentPermission failed", error)
false
}
}
private fun canUseFullScreenIntent(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return true
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
return manager.canUseFullScreenIntent()
}
private fun isIgnoringBatteryOptimizations(): Boolean {
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
return powerManager.isIgnoringBatteryOptimizations(packageName)
}
private fun openDirectory(path: String): Boolean {
val folder = File(path)
if (!folder.exists()) {
Log.w(tag, "file_actions.openDirectory missing path=$path")
return false
}
if (!folder.isDirectory) {
Log.w(tag, "file_actions.openDirectory not directory path=$path")
return false
}
val fileProviderIntent = runCatching {
val uri = FileProvider.getUriForFile(
this,
"$packageName.fileprovider",
folder
)
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "resource/folder")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}.getOrNull()
val documentIntent = Intent(Intent.ACTION_VIEW).apply {
directoryTreeUri(path)?.let { uri ->
setDataAndType(uri, DocumentsContract.Document.MIME_TYPE_DIR)
}
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
val opened =
openIntentSafely(fileProviderIntent, "file_actions.openDirectory fileProvider", path) ||
openIntentSafely(documentIntent, "file_actions.openDirectory documents", path)
if (!opened) {
Log.w(tag, "file_actions.openDirectory unable to open path=$path")
}
return opened
}
private fun viewDirectory(path: String): Boolean {
val directory = File(path)
if (!directory.exists()) {
directory.mkdirs()
}
val candidates = mutableListOf<Intent>()
directoryDocumentUri(path)?.let { uri ->
candidates.add(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "vnd.android.document/directory")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
)
candidates.add(
Intent(Intent.ACTION_VIEW).apply {
setData(uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
)
}
try {
val uri = FileProvider.getUriForFile(this, "$packageName.fileprovider", directory)
candidates.add(
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "resource/folder")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
)
} catch (error: Throwable) {
Log.w(tag, "file_actions.viewDirectory fileprovider unavailable path=$path", error)
}
for (intent in candidates) {
try {
startActivity(Intent.createChooser(intent, "Abrir carpeta"))
Log.d(tag, "file_actions.viewDirectory launched path=$path")
return true
} catch (_: ActivityNotFoundException) {
Log.w(tag, "file_actions.viewDirectory no activity for candidate path=$path")
} catch (error: Throwable) {
Log.e(tag, "file_actions.viewDirectory candidate failed path=$path", error)
}
}
return false
}
private fun openIntentSafely(intent: Intent?, origin: String, path: String): Boolean {
if (intent == null || intent.data == null) return false
return try {
startActivity(intent)
Log.d(tag, "$origin launched path=$path")
true
} catch (_: ActivityNotFoundException) {
Log.w(tag, "$origin no activity for path=$path")
false
} catch (error: Throwable) {
Log.e(tag, "$origin failed path=$path", error)
false
}
}
private fun openFile(path: String, mimeType: String): Boolean {
val file = File(path)
if (!file.exists()) {
Log.w(tag, "file_actions.openFile missing path=$path")
return false
}
return try {
val uri = FileProvider.getUriForFile(
this,
"$packageName.fileprovider",
file
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
clipData = ClipData.newUri(contentResolver, "recording", uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(intent, "Abrir grabación"))
Log.d(tag, "file_actions.openFile launched path=$path")
true
} catch (_: ActivityNotFoundException) {
Log.w(tag, "file_actions.openFile no viewer path=$path; opening parent")
openDirectory(file.parentFile?.absolutePath ?: path)
} catch (error: Throwable) {
Log.e(tag, "file_actions.openFile failed path=$path; opening parent", error)
openDirectory(file.parentFile?.absolutePath ?: path)
}
}
private fun directoryTreeUri(path: String): Uri? {
val external = Environment.getExternalStorageDirectory()?.absolutePath ?: return null
if (!path.startsWith(external)) return null
val relative = path.removePrefix(external).trimStart('/')
val documentId = if (relative.isBlank()) "primary:" else "primary:$relative"
return DocumentsContract.buildTreeDocumentUri(
"com.android.externalstorage.documents",
documentId
)
}
private fun directoryDocumentUri(path: String): Uri? {
val external = Environment.getExternalStorageDirectory()?.absolutePath ?: return null
if (!path.startsWith(external)) return null
val relative = path.removePrefix(external).trimStart('/')
val documentId = if (relative.isBlank()) "primary:" else "primary:$relative"
return DocumentsContract.buildDocumentUri(
"com.android.externalstorage.documents",
documentId
)
}
private fun startVisualizerWhenAllowed() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M &&
checkSelfPermission(Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED
) {
requestPermissions(
arrayOf(Manifest.permission.RECORD_AUDIO),
visualizerPermissionRequestCode
)
return
}
startVisualizer()
}
private fun startVisualizer() {
val sink = pendingSink ?: return
val args = pendingArgs
val sessionId = (args?.get("sessionId") as? Number)?.toInt() ?: 0
val bands = ((args?.get("bands") as? Number)?.toInt() ?: 26).coerceIn(8, 96)
stopVisualizer()
try {
val captureSize = Visualizer.getCaptureSizeRange()[1]
visualizer = Visualizer(sessionId).apply {
enabled = false
setCaptureSize(captureSize)
setDataCaptureListener(
object : Visualizer.OnDataCaptureListener {
override fun onWaveFormDataCapture(
visualizer: Visualizer?,
waveform: ByteArray?,
samplingRate: Int
) {
val data = waveform ?: return
val values = downsample(data, bands)
mainHandler.post { sink.success(values) }
}
override fun onFftDataCapture(
visualizer: Visualizer?,
fft: ByteArray?,
samplingRate: Int
) = Unit
},
Visualizer.getMaxCaptureRate() / 2,
true,
false
)
enabled = true
}
} catch (error: Throwable) {
sink.error("VISUALIZER_UNAVAILABLE", error.message, null)
stopVisualizer()
}
}
private fun downsample(data: ByteArray, bands: Int): List<Double> {
if (data.isEmpty()) return emptyList()
val bucket = maxOf(1, data.size / bands)
val values = ArrayList<Double>(bands)
var index = 0
while (index < data.size && values.size < bands) {
var sum = 0.0
var count = 0
val end = minOf(index + bucket, data.size)
for (i in index until end) {
val centered = (data[i].toInt() and 0xFF) - 128
sum += kotlin.math.abs(centered) / 128.0
count++
}
values.add(if (count == 0) 0.0 else (sum / count).coerceIn(0.0, 1.0))
index = end
}
while (values.size < bands) values.add(0.0)
return values
}
private fun stopVisualizer() {
try {
visualizer?.enabled = false
visualizer?.release()
} catch (_: Throwable) {
} finally {
visualizer = null
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == notificationPermissionRequestCode) return
if (requestCode != visualizerPermissionRequestCode) return
if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) {
startVisualizer()
} else {
pendingSink?.error(
"RECORD_AUDIO_DENIED",
"Permiso de audio denegado para visualizar la onda real",
null
)
}
}
override fun onDestroy() {
stopVisualizer()
super.onDestroy()
}
}
@@ -0,0 +1,288 @@
package es.freetimelab.pluriwave
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
class PluriWaveAlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val alarmId = intent.getStringExtra(EXTRA_ALARM_ID) ?: run {
Log.w(TAG, "alarm.receiver missing alarmId action=${intent.action}")
return
}
val title = intent.getStringExtra(EXTRA_ALARM_TITLE) ?: "PluriWave"
val snoozeMinutes = sanitizeSnoozeMinutes(intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5))
Log.d(TAG, "alarm.receiver action=${intent.action} id=$alarmId title=$title")
when (intent.action) {
ACTION_FIRE -> {
AlarmScheduler(context).onAlarmFired(alarmId)
PluriWaveAlarmService.start(context, intent)
val launch = Intent(context, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_FIRE)
putExtra(EXTRA_TRIGGER_AT, intent.getLongExtra(EXTRA_TRIGGER_AT, 0L))
putExtra(EXTRA_OCCURRENCE_AT, intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L))
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
}
showFireNotification(context, alarmId, title, launch, snoozeMinutes)
try {
context.startActivity(launch)
Log.d(TAG, "alarm.receiver fire startActivity OK id=$alarmId")
} catch (error: Throwable) {
Log.e(TAG, "alarm.receiver fire startActivity ERROR id=$alarmId", error)
}
}
ACTION_PRE_NOTICE -> {
showPreNoticeNotification(
context,
alarmId,
title,
snoozeMinutes,
intent.getLongExtra(EXTRA_TRIGGER_AT, 0L),
intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
)
}
ACTION_POSTPONE_NEXT -> {
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
val occurrenceAt = AlarmScheduler(context).postponeNext(alarmId, snoozeMinutes)
?: intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)
val launch = Intent(context, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_POSTPONE_NEXT)
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
putExtra(EXTRA_OCCURRENCE_AT, occurrenceAt)
putExtra(EXTRA_TRIGGER_AT, intent.getLongExtra(EXTRA_TRIGGER_AT, 0L))
}
try {
context.startActivity(launch)
Log.d(TAG, "alarm.receiver postponeNext startActivity OK id=$alarmId")
} catch (error: Throwable) {
Log.e(TAG, "alarm.receiver postponeNext startActivity ERROR id=$alarmId", error)
}
}
ACTION_SKIP_NEXT -> {
NotificationManagerCompat.from(context).cancel(notificationIdForAlarm(alarmId))
AlarmScheduler(context).skipNext(alarmId)
val launch = Intent(context, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_SKIP_NEXT)
}
try {
context.startActivity(launch)
Log.d(TAG, "alarm.receiver skipNext startActivity OK id=$alarmId")
} catch (error: Throwable) {
Log.e(TAG, "alarm.receiver skipNext startActivity ERROR id=$alarmId", error)
}
}
else -> Log.w(TAG, "alarm.receiver unknown action=${intent.action} id=$alarmId")
}
}
private fun showFireNotification(
context: Context,
alarmId: String,
title: String,
launch: Intent,
snoozeMinutes: Int
) {
ensureFireChannel(context)
val fullScreenIntent = PendingIntent.getActivity(
context,
requestCode(alarmId, 10),
launch,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, FIRE_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle("Alarma PluriWave")
.setContentText(title)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setContentIntent(fullScreenIntent)
.setFullScreenIntent(fullScreenIntent, true)
.addAction(0, "Posponer", snoozePendingIntent(context, alarmId, snoozeMinutes))
.addAction(0, "Detener", stopPendingIntent(context, alarmId))
.build()
try {
NotificationManagerCompat.from(context).notify(
fireNotificationIdForAlarm(alarmId),
notification,
)
Log.d(TAG, "alarm.notification fire shown id=$alarmId")
} catch (error: SecurityException) {
Log.e(TAG, "alarm.notification fire SecurityException id=$alarmId", error)
}
}
private fun showPreNoticeNotification(
context: Context,
alarmId: String,
title: String,
snoozeMinutes: Int,
triggerAtMillis: Long,
occurrenceAtMillis: Long
) {
ensureChannel(context)
val openAppIntent = PendingIntent.getActivity(
context,
requestCode(alarmId, 1),
Intent(context, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_ALARM_ACTION, ACTION_PRE_NOTICE)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val skipNextIntent = PendingIntent.getBroadcast(
context,
requestCode(alarmId, 2),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
action = ACTION_SKIP_NEXT
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val postponeNextIntent = PendingIntent.getBroadcast(
context,
requestCode(alarmId, 3),
Intent(context, PluriWaveAlarmReceiver::class.java).apply {
action = ACTION_POSTPONE_NEXT
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_ALARM_TITLE, title)
putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes)
putExtra(EXTRA_TRIGGER_AT, triggerAtMillis)
putExtra(EXTRA_OCCURRENCE_AT, occurrenceAtMillis)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText("Empieza en 30 minutos")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setCategory(NotificationCompat.CATEGORY_REMINDER)
.setSilent(true)
.setAutoCancel(true)
.setContentIntent(openAppIntent)
.addAction(0, "Posponer", postponeNextIntent)
.addAction(0, "Omitir esta vez", skipNextIntent)
.build()
try {
NotificationManagerCompat.from(context).notify(notificationIdForAlarm(alarmId), notification)
Log.d(TAG, "alarm.notification preNotice shown id=$alarmId")
} catch (error: SecurityException) {
Log.e(TAG, "alarm.notification preNotice SecurityException id=$alarmId", error)
}
}
private fun ensureFireChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val existing = manager.getNotificationChannel(FIRE_CHANNEL_ID)
if (existing != null) return
val channel = NotificationChannel(
FIRE_CHANNEL_ID,
"Alarmas sonando",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Pantalla urgente cuando una alarma musical debe sonar"
enableVibration(true)
}
manager.createNotificationChannel(channel)
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val existing = manager.getNotificationChannel(CHANNEL_ID)
if (existing != null) return
val channel = NotificationChannel(
CHANNEL_ID,
"Preavisos de alarmas",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Notificaciones silenciosas 30 minutos antes de la alarma"
setSound(null, null)
enableVibration(false)
}
manager.createNotificationChannel(channel)
}
private fun requestCode(id: String, slot: Int): Int = 47 * id.hashCode() + slot
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
private fun snoozePendingIntent(context: Context, alarmId: String, minutes: Int): PendingIntent =
PendingIntent.getService(
context,
requestCode(alarmId, 20 + minutes),
Intent(context, PluriWaveAlarmService::class.java).apply {
action = PluriWaveAlarmService.ACTION_SNOOZE
putExtra(EXTRA_ALARM_ID, alarmId)
putExtra(PluriWaveAlarmService.EXTRA_SNOOZE_MINUTES, minutes)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun stopPendingIntent(context: Context, alarmId: String): PendingIntent =
PendingIntent.getService(
context,
requestCode(alarmId, 40),
Intent(context, PluriWaveAlarmService::class.java).apply {
action = PluriWaveAlarmService.ACTION_STOP
putExtra(EXTRA_ALARM_ID, alarmId)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
companion object {
const val TAG = "PluriWave"
const val CHANNEL_ID = "pluriwave_alarm_pre_notice"
const val FIRE_CHANNEL_ID = "pluriwave_alarm_fire"
const val ACTION_FIRE = "es.freetimelab.pluriwave.alarm.FIRE"
const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE"
const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT"
const val ACTION_POSTPONE_NEXT = "es.freetimelab.pluriwave.alarm.POSTPONE_NEXT"
const val EXTRA_ALARM_ID = "alarmId"
const val EXTRA_ALARM_TITLE = "alarmTitle"
const val EXTRA_ALARM_ACTION = "alarmAction"
const val EXTRA_STATION_NAME = "stationName"
const val EXTRA_STATION_URL = "stationUrl"
const val EXTRA_FALLBACK_SOUND = "fallbackSound"
const val EXTRA_VOLUME = "volume"
const val EXTRA_TRIGGER_AT = "triggerAtMillis"
const val EXTRA_OCCURRENCE_AT = "occurrenceAtMillis"
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
fun notificationIdForAlarm(alarmId: String): Int = 53 * alarmId.hashCode() + 7
fun fireNotificationIdForAlarm(alarmId: String): Int = 59 * alarmId.hashCode() + 9
}
}
@@ -0,0 +1,430 @@
package es.freetimelab.pluriwave
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.MediaPlayer
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.PowerManager
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import java.io.File
class PluriWaveAlarmService : Service() {
private var player: MediaPlayer? = null
private var wakeLock: PowerManager.WakeLock? = null
private var activeAlarmId: String? = null
private val mainHandler = Handler(Looper.getMainLooper())
private var stationFallbackRunnable: Runnable? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val action = intent?.action
val requestedId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
Log.d(TAG, "alarm.service onStartCommand action=$action id=$requestedId active=$activeAlarmId")
when (action) {
ACTION_STOP -> {
stopAlarm(requestedId)
return START_NOT_STICKY
}
ACTION_SNOOZE -> {
val minutes = intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5)
if (requestedId != null) {
AlarmScheduler(this).snooze(requestedId, minutes)
}
stopAlarm(requestedId)
return START_NOT_STICKY
}
PluriWaveAlarmReceiver.ACTION_FIRE, null -> startAlarm(intent)
else -> Log.w(TAG, "alarm.service unknown action=$action id=$requestedId")
}
return START_NOT_STICKY
}
private fun startAlarm(intent: Intent?) {
val alarmId = intent?.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID) ?: return
if (activeAlarmId != null) {
Log.w(TAG, "alarm.service ignored id=$alarmId because active=$activeAlarmId")
return
}
activeAlarmId = alarmId
val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave"
val stationName = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME)
val stationUrl = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL)
val fallbackSound = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND)
val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f)
val snoozeMinutes = sanitizeSnoozeMinutes(
intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5)
)
acquireWakeLock()
try {
startForeground(NOTIFICATION_ID, buildNotification(alarmId, title, stationName, snoozeMinutes))
} catch (error: Throwable) {
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
releaseWakeLock()
activeAlarmId = null
stopSelf()
return
}
startAudio(alarmId, stationName, stationUrl, fallbackSound, volume)
}
private fun startAudio(
alarmId: String,
stationName: String?,
stationUrl: String?,
fallbackSound: String?,
volume: Float
) {
player?.release()
player = null
if (!stationUrl.isNullOrBlank()) {
startStationAudio(
alarmId,
stationName,
stationUrl.trim(),
fallbackSound,
volume
)
return
}
startFallbackAudio(alarmId, fallbackSound, volume, "station url missing")
}
private fun startStationAudio(
alarmId: String,
stationName: String?,
stationUrl: String,
fallbackSound: String?,
volume: Float
) {
scheduleStationFallback(alarmId, fallbackSound, volume)
try {
player = MediaPlayer().apply {
setAudioAttributes(alarmAudioAttributes())
isLooping = false
setVolume(volume, volume)
setDataSource(
this@PluriWaveAlarmService,
Uri.parse(stationUrl),
mapOf("User-Agent" to "PluriWave/0.1.0 (native alarm)")
)
setOnPreparedListener {
if (activeAlarmId != alarmId) return@setOnPreparedListener
cancelStationFallback()
it.start()
Log.d(
TAG,
"alarm.service station started id=$alarmId station=$stationName url=$stationUrl"
)
}
setOnCompletionListener {
if (activeAlarmId != alarmId) return@setOnCompletionListener
Log.w(TAG, "alarm.service station completed id=$alarmId url=$stationUrl")
startFallbackAudio(alarmId, fallbackSound, volume, "station completed")
}
setOnErrorListener { mp, what, extra ->
Log.e(
TAG,
"alarm.service station error id=$alarmId what=$what extra=$extra url=$stationUrl"
)
runCatching { mp.reset() }
if (activeAlarmId == alarmId) {
startFallbackAudio(alarmId, fallbackSound, volume, "station error")
}
true
}
prepareAsync()
}
Log.d(TAG, "alarm.service station preparing id=$alarmId station=$stationName url=$stationUrl")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service station prepare failed id=$alarmId url=$stationUrl", error)
startFallbackAudio(alarmId, fallbackSound, volume, "station prepare failed")
}
}
private fun startFallbackAudio(
alarmId: String,
fallbackSound: String?,
volume: Float,
reason: String
) {
cancelStationFallback()
player?.release()
player = null
val source = fallbackAssetPath(fallbackSound)
try {
player = MediaPlayer().apply {
setAudioAttributes(alarmAudioAttributes())
isLooping = true
setVolume(volume, volume)
setFallbackAssetDataSource(this, fallbackSound)
setOnPreparedListener {
if (activeAlarmId != alarmId) return@setOnPreparedListener
it.start()
Log.d(TAG, "alarm.service fallback started id=$alarmId source=$source reason=$reason")
}
setOnErrorListener { mp, what, extra ->
Log.e(TAG, "alarm.service fallback error id=$alarmId what=$what extra=$extra source=$source")
mp.reset()
true
}
prepareAsync()
}
Log.d(TAG, "alarm.service fallback preparing id=$alarmId source=$source reason=$reason")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service fallback prepare failed id=$alarmId source=$source", error)
}
}
private fun scheduleStationFallback(
alarmId: String,
fallbackSound: String?,
volume: Float
) {
cancelStationFallback()
val runnable = Runnable {
if (activeAlarmId == alarmId) {
Log.w(TAG, "alarm.service station timeout id=$alarmId; using fallback")
startFallbackAudio(alarmId, fallbackSound, volume, "station timeout")
}
}
stationFallbackRunnable = runnable
mainHandler.postDelayed(runnable, STATION_START_TIMEOUT_MILLIS)
}
private fun cancelStationFallback() {
stationFallbackRunnable?.let { mainHandler.removeCallbacks(it) }
stationFallbackRunnable = null
}
private fun alarmAudioAttributes(): AudioAttributes =
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
private fun stopAlarm(alarmId: String?) {
Log.d(TAG, "alarm.service stop id=$alarmId active=$activeAlarmId")
cancelStationFallback()
try {
player?.stop()
} catch (error: Throwable) {
Log.w(TAG, "alarm.service stop player failed", error)
}
player?.release()
player = null
activeAlarmId = null
releaseWakeLock()
if (alarmId != null) {
NotificationManagerCompat.from(this).cancel(
PluriWaveAlarmReceiver.fireNotificationIdForAlarm(alarmId)
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
stopSelf()
}
private fun buildNotification(
alarmId: String,
title: String,
stationName: String?,
snoozeMinutes: Int
) =
NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_lock_idle_alarm)
.setContentTitle("Alarma PluriWave")
.setContentText(
if (stationName.isNullOrBlank()) title else "$title - $stationName"
)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setOngoing(true)
.setAutoCancel(false)
.setFullScreenIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes), true)
.setContentIntent(openAlarmPendingIntent(alarmId, title, snoozeMinutes))
.addAction(0, "Posponer", snoozePendingIntent(alarmId, snoozeMinutes))
.addAction(0, "Detener", stopPendingIntent(alarmId))
.build()
private fun openAlarmPendingIntent(
alarmId: String,
title: String,
snoozeMinutes: Int
): PendingIntent =
PendingIntent.getActivity(
this,
requestCode(alarmId, 20),
Intent(this, MainActivity::class.java).apply {
this.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, title)
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ACTION, PluriWaveAlarmReceiver.ACTION_FIRE)
putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, snoozeMinutes)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun stopPendingIntent(alarmId: String): PendingIntent =
PendingIntent.getService(
this,
requestCode(alarmId, 21),
Intent(this, PluriWaveAlarmService::class.java).apply {
action = ACTION_STOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun snoozePendingIntent(alarmId: String, minutes: Int): PendingIntent =
PendingIntent.getService(
this,
requestCode(alarmId, 30 + minutes),
Intent(this, PluriWaveAlarmService::class.java).apply {
action = ACTION_SNOOZE
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
putExtra(EXTRA_SNOOZE_MINUTES, minutes)
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"PluriWave:AlarmWakeLock"
).apply {
setReferenceCounted(false)
acquire(10 * 60 * 1000L)
}
}
private fun releaseWakeLock() {
try {
if (wakeLock?.isHeld == true) wakeLock?.release()
} catch (error: Throwable) {
Log.w(TAG, "alarm.service wakeLock release failed", error)
}
wakeLock = null
}
private fun setFallbackAssetDataSource(mediaPlayer: MediaPlayer, sound: String?) {
val path = fallbackAssetPath(sound)
try {
val descriptor = assets.openFd(path)
mediaPlayer.setDataSource(
descriptor.fileDescriptor,
descriptor.startOffset,
descriptor.length
)
descriptor.close()
} catch (error: Throwable) {
Log.w(TAG, "alarm.service asset descriptor failed path=$path; copying to cache", error)
val cached = File(cacheDir, path.substringAfterLast('/'))
assets.open(path).use { input ->
cached.outputStream().use { output -> input.copyTo(output) }
}
mediaPlayer.setDataSource(cached.absolutePath)
}
}
private fun fallbackAssetPath(sound: String?): String {
val fileName = when (sound) {
"campanaSuave" -> "alarm_campana_suave.wav"
"pulsoDigital" -> "alarm_pulso_digital.wav"
else -> "alarm_amanecer.wav"
}
return "flutter_assets/assets/audio/$fileName"
}
private fun sanitizeSnoozeMinutes(minutes: Int): Int =
if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5
override fun onDestroy() {
stopAlarm(activeAlarmId)
super.onDestroy()
}
companion object {
private const val TAG = "PluriWave"
private const val CHANNEL_ID = "pluriwave_alarm_native"
private const val NOTIFICATION_ID = 92841
const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE"
const val ACTION_SNOOZE = "es.freetimelab.pluriwave.alarm.SNOOZE_NATIVE"
const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes"
private const val STATION_START_TIMEOUT_MILLIS = 15_000L
fun start(context: Context, source: Intent) {
ensureChannel(context)
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE
putExtras(source)
}
try {
ContextCompat.startForegroundService(context, intent)
Log.d(TAG, "alarm.service start requested")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service start failed", error)
}
}
fun stop(context: Context, alarmId: String) {
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
action = ACTION_STOP
putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID, alarmId)
}
try {
context.startService(intent)
Log.d(TAG, "alarm.service stop action requested id=$alarmId")
} catch (error: Throwable) {
Log.e(TAG, "alarm.service stop request failed id=$alarmId", error)
try {
context.stopService(intent)
} catch (fallbackError: Throwable) {
Log.e(TAG, "alarm.service stop fallback failed id=$alarmId", fallbackError)
}
}
}
private fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
val channel = NotificationChannel(
CHANNEL_ID,
"Alarma musical",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Sonido de alarma musical con pantalla apagada"
enableVibration(true)
}
manager.createNotificationChannel(channel)
}
private fun requestCode(id: String, slot: Int): Int = 67 * id.hashCode() + slot
}
}
@@ -0,0 +1,28 @@
package es.freetimelab.pluriwave
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
class PluriWaveBootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_LOCKED_BOOT_COMPLETED,
Intent.ACTION_BOOT_COMPLETED,
Intent.ACTION_USER_UNLOCKED,
Intent.ACTION_MY_PACKAGE_REPLACED,
Intent.ACTION_TIME_CHANGED,
Intent.ACTION_TIMEZONE_CHANGED,
"android.app.action.SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED" -> {
Log.d(TAG, "alarm.bootReceiver action=${intent.action}")
AlarmScheduler(context).reschedulePersistedAlarms()
}
else -> Log.w(TAG, "alarm.bootReceiver unknown action=${intent.action}")
}
}
companion object {
private const val TAG = "PluriWave"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 656 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 452 B

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 838 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 838 B

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 84 KiB

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
network_security_config.xml
Permite tráfico HTTP cleartext para streams de radio que no soporten HTTPS.
Fix para: "Cleartext HTTP traffic to [host] not permitted" en ExoPlayer.
-->
<network-security-config>
<!-- Permitir HTTP cleartext para streams de radio -->
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<!-- Certificados del sistema (CA reconocidas) -->
<certificates src="system"/>
<!-- Certificados de usuario (para desarrollo) -->
<certificates src="user"/>
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path
name="files"
path="." />
<cache-path
name="cache"
path="." />
<external-files-path
name="external_files"
path="." />
<external-cache-path
name="external_cache"
path="." />
</paths>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
# أهلاً بك في PluriWave
PluriWave هو راديوك العالمي المميز: محطات مباشرة، مفضلات منظمة، تسجيلات، معادل صوت ومنبّهات موسيقية ضمن تجربة مصممة بعناية.
## راديو مباشر
- ابحث عن المحطات حسب الاسم والبلد واللغة والجودة.
- استكشف المحطات القريبة واكتشف محطات جديدة.
- رتّب القوائم حسب الاسم أو الجودة.
## موسيقى بطريقتك
- احفظ المفضلات ونظّمها في مجموعات.
- اضبط المعادل العام أو إعدادات كل محطة.
- استخدم مؤقّت النوم بمدد مخصّصة.
## التسجيلات
- سجّل الراديو بدون إعادة ضغط البث الأصلي.
- حدّد الحجم الأقصى للملف لتبقى بأمان.
- افتح مجلد التسجيلات للمشاركة أو النقل أو التعديل.
## منبّهات موسيقية
- أنشئ منبّهات لمرة واحدة أو يومية أو لأيام العمل.
- اختر محطة مفضلة وصوتاً داخلياً آمناً.
- استخدم العطلات وتخطي التنفيذ التالي والغفوة.
+27
View File
@@ -0,0 +1,27 @@
# PluriWave-এ স্বাগতম
PluriWave আপনার প্রিমিয়াম বিশ্ব রেডিও: লাইভ স্টেশন, গোছানো ফেভারিট, রেকর্ডিং, ইকুয়ালাইজার এবং মিউজিক অ্যালার্ম—সবই যত্নসহ তৈরি এক অভিজ্ঞতায়।
## লাইভ রেডিও
- নাম, দেশ, ভাষা ও মান অনুযায়ী স্টেশন খুঁজুন।
- কাছাকাছি স্টেশন দেখুন এবং নতুন রেডিও আবিষ্কার করুন।
- তালিকা নাম বা মান অনুযায়ী সাজান।
## আপনার মতো করে সঙ্গীত
- ফেভারিট সংরক্ষণ করুন এবং গ্রুপে সাজান।
- গ্লোবাল ইকুয়ালাইজার বা স্টেশনভিত্তিক প্রিসেট ঠিক করুন।
- নিজের মতো সময় দিয়ে স্লিপ টাইমার ব্যবহার করুন।
## রেকর্ডিং
- মূল স্ট্রিম রিকমপ্রেস না করে রেডিও রেকর্ড করুন।
- নিরাপদ থাকতে সর্বোচ্চ ফাইল সাইজ সীমা দিন।
- শেয়ার, সরানো বা সম্পাদনার জন্য রেকর্ডিং ফোল্ডার খুলুন।
## মিউজিক অ্যালার্ম
- একবার, প্রতিদিন বা কর্মদিবসের অ্যালার্ম তৈরি করুন।
- প্রিয় স্টেশন ও নিরাপদ অভ্যন্তরীণ সাউন্ড বেছে নিন।
- ছুটি, পরের রান স্কিপ এবং স্নুজ ব্যবহার করুন।
+27
View File
@@ -0,0 +1,27 @@
# Willkommen bei PluriWave
PluriWave ist Ihr Premium-Weltradio: Live-Sender, organisierte Favoriten, Aufnahmen, Equalizer und Musikalarme in einer sorgfältig gestalteten Erfahrung.
## Live-Radio
- Suche nach Sendern nach Name, Land, Sprache und Qualität.
- Entdecke Sender in der Nähe und finde neue Radios.
- Sortiere Listen nach Name oder Qualität.
## Musik auf deine Art
- Speichere Favoriten und organisiere sie in Gruppen.
- Stelle den globalen Equalizer oder Sender-Presets ein.
- Nutze den Sleep-Timer mit eigenen Laufzeiten.
## Aufnahmen
- Nimm Radio auf, ohne den Original-Stream neu zu komprimieren.
- Begrenze die maximale Dateigröße für mehr Sicherheit.
- Öffne den Aufnahmeordner zum Teilen, Verschieben oder Bearbeiten von Dateien.
## Musikalarme
- Erstelle einmalige, tägliche oder Wochentags-Alarme.
- Wähle einen Lieblingssender und einen sicheren internen Ton.
- Nutze Feiertage, "nächste Ausführung überspringen" und Snooze.
+27
View File
@@ -0,0 +1,27 @@
# Welcome to PluriWave
PluriWave is your premium world radio: live stations, organized favorites, recordings, equalizer and musical alarms in a carefully crafted experience.
## Live radio
- Search stations by name, country, language and quality.
- Explore nearby stations and discover new radio.
- Sort lists by name or quality.
## Music your way
- Save favorites and organize them into groups.
- Tune the global equalizer or per-station presets.
- Use the sleep timer with custom durations.
## Recordings
- Record radio without recompressing the original stream.
- Limit maximum file size to stay safe.
- Open the recordings folder to share, move or edit files.
## Musical alarms
- Create one-time, daily or weekday alarms.
- Choose a favorite station and a safe internal sound.
- Use holidays, skip-next execution and snooze.
+27
View File
@@ -0,0 +1,27 @@
# Bienvenido a PluriWave
PluriWave es tu radio mundial premium: emisoras en directo, favoritos organizados, grabaciones, ecualizador y alarmas musicales en una experiencia cuidada.
## Radio en vivo
- Buscá emisoras por nombre, país, idioma y calidad.
- Explorá emisoras cercanas y descubrí radios nuevas.
- Ordená listas por nombre o calidad.
## Música a tu manera
- Guardá favoritos y organizalos en grupos.
- Ajustá el ecualizador global o los presets por emisora.
- Usá el temporizador de sueño con duraciones personalizadas.
## Grabaciones
- Grabá radio sin recomprimir el stream original.
- Limitá el tamaño máximo del archivo para evitar sustos.
- Abrí la carpeta de grabaciones para compartir, mover o editar archivos.
## Alarmas musicales
- Creá alarmas únicas, diarias o por días de semana.
- Elegí una emisora favorita y un sonido interno seguro.
- Usá vacaciones, omitir la próxima ejecución y posponer.
+27
View File
@@ -0,0 +1,27 @@
# Bienvenue dans PluriWave
PluriWave est votre radio mondiale premium : stations en direct, favoris organisés, enregistrements, égaliseur et alarmes musicales dans une expérience soignée.
## Radio en direct
- Recherchez des stations par nom, pays, langue et qualité.
- Explorez les stations proches et découvrez de nouvelles radios.
- Triez les listes par nom ou qualité.
## Votre musique, votre style
- Enregistrez vos favoris et organisez-les en groupes.
- Réglez l'égaliseur global ou des préréglages par station.
- Utilisez le minuteur de sommeil avec des durées personnalisées.
## Enregistrements
- Enregistrez la radio sans recompresser le flux d'origine.
- Limitez la taille maximale des fichiers pour rester serein.
- Ouvrez le dossier des enregistrements pour partager, déplacer ou modifier des fichiers.
## Alarmes musicales
- Créez des alarmes uniques, quotidiennes ou en semaine.
- Choisissez une station favorite et un son interne sûr.
- Utilisez les vacances, le saut de la prochaine exécution et le snooze.
+27
View File
@@ -0,0 +1,27 @@
# PluriWave में आपका स्वागत है
PluriWave आपका प्रीमियम विश्व रेडियो है: लाइव स्टेशन, व्यवस्थित पसंदीदा, रिकॉर्डिंग, इक्वलाइज़र और संगीत अलार्म एक सधे हुए अनुभव में।
## लाइव रेडियो
- स्टेशन को नाम, देश, भाषा और गुणवत्ता से खोजें।
- पास के स्टेशन देखें और नए रेडियो खोजें।
- सूचियों को नाम या गुणवत्ता के अनुसार क्रमित करें।
## संगीत आपके तरीके से
- पसंदीदा सहेजें और उन्हें समूहों में व्यवस्थित करें।
- ग्लोबल इक्वलाइज़र या स्टेशन-विशिष्ट प्रीसेट समायोजित करें।
- अपनी पसंद की अवधि वाला स्लीप टाइमर इस्तेमाल करें।
## रिकॉर्डिंग
- मूल स्ट्रीम को फिर से कंप्रेस किए बिना रेडियो रिकॉर्ड करें।
- सुरक्षित रहने के लिए अधिकतम फ़ाइल आकार सीमित करें।
- फ़ाइलें साझा करने, स्थानांतरित करने या संपादित करने के लिए रिकॉर्डिंग फ़ोल्डर खोलें।
## संगीत अलार्म
- एक बार, रोज़ाना या कार्यदिवस अलार्म बनाएँ।
- पसंदीदा स्टेशन और सुरक्षित आंतरिक ध्वनि चुनें।
- छुट्टियाँ, अगला निष्पादन छोड़ना और स्नूज़ का उपयोग करें।
+27
View File
@@ -0,0 +1,27 @@
# Selamat datang di PluriWave
PluriWave adalah radio dunia premium Anda: stasiun langsung, favorit terorganisir, rekaman, equalizer, dan alarm musik dalam pengalaman yang dirancang rapi.
## Radio langsung
- Cari stasiun berdasarkan nama, negara, bahasa, dan kualitas.
- Jelajahi stasiun terdekat dan temukan radio baru.
- Urutkan daftar berdasarkan nama atau kualitas.
## Musik sesuai cara Anda
- Simpan favorit dan atur ke dalam grup.
- Atur equalizer global atau preset per stasiun.
- Gunakan sleep timer dengan durasi kustom.
## Rekaman
- Rekam radio tanpa mengompresi ulang stream asli.
- Batasi ukuran file maksimum agar tetap aman.
- Buka folder rekaman untuk berbagi, memindahkan, atau mengedit file.
## Alarm musik
- Buat alarm sekali, harian, atau hari kerja.
- Pilih stasiun favorit dan suara internal yang aman.
- Gunakan hari libur, lewati eksekusi berikutnya, dan snooze.
+27
View File
@@ -0,0 +1,27 @@
# Benvenuto in PluriWave
PluriWave è la tua radio mondiale premium: stazioni live, preferiti organizzati, registrazioni, equalizzatore e sveglie musicali in un'esperienza curata.
## Radio live
- Cerca stazioni per nome, paese, lingua e qualità.
- Esplora le stazioni vicine e scopri nuove radio.
- Ordina le liste per nome o qualità.
## Musica a modo tuo
- Salva i preferiti e organizzali in gruppi.
- Regola l'equalizzatore globale o i preset per stazione.
- Usa il timer di spegnimento con durate personalizzate.
## Registrazioni
- Registra la radio senza ricomprimere il flusso originale.
- Limita la dimensione massima dei file per stare tranquillo.
- Apri la cartella registrazioni per condividere, spostare o modificare i file.
## Sveglie musicali
- Crea sveglie singole, giornaliere o nei giorni feriali.
- Scegli una stazione preferita e un suono interno sicuro.
- Usa ferie, salto della prossima esecuzione e snooze.
+27
View File
@@ -0,0 +1,27 @@
# PluriWave へようこそ
PluriWave は、ライブ局、お気に入り整理、録音、イコライザー、音楽アラームを備えた高品質なワールドラジオです。
## ライブラジオ
- 名前、国、言語、音質で局を検索できます。
- 近くの局を探して新しいラジオを見つけられます。
- リストを名前または音質で並べ替えできます。
## あなた好みの音楽体験
- お気に入りを保存してグループで整理できます。
- 全体イコライザーや局ごとのプリセットを調整できます。
- 時間を指定できるスリープタイマーを使えます。
## 録音
- 元のストリームを再圧縮せずに録音できます。
- 最大ファイルサイズを制限して安全に使えます。
- 録音フォルダーを開いて共有・移動・編集できます。
## 音楽アラーム
- 1回のみ、毎日、平日のアラームを作成できます。
- お気に入り局と安全な内蔵サウンドを選べます。
- 休日設定、次回スキップ、スヌーズに対応しています。
+27
View File
@@ -0,0 +1,27 @@
# Bem-vindo ao PluriWave
PluriWave é seu rádio mundial premium: estações ao vivo, favoritos organizados, gravações, equalizador e alarmes musicais em uma experiência caprichada.
## Rádio ao vivo
- Procure estações por nome, país, idioma e qualidade.
- Explore estações próximas e descubra novas rádios.
- Ordene listas por nome ou qualidade.
## Música do seu jeito
- Salve favoritos e organize em grupos.
- Ajuste o equalizador global ou presets por estação.
- Use o timer de sono com durações personalizadas.
## Gravações
- Grave rádio sem recomprimir o stream original.
- Limite o tamanho máximo dos arquivos para evitar problemas.
- Abra a pasta de gravações para compartilhar, mover ou editar arquivos.
## Alarmes musicais
- Crie alarmes únicos, diários ou de dias úteis.
- Escolha uma estação favorita e um som interno seguro.
- Use feriados, pular próxima execução e soneca.
+27
View File
@@ -0,0 +1,27 @@
# Добро пожаловать в PluriWave
PluriWave — ваше премиальное мировое радио: прямые станции, организованные избранные, записи, эквалайзер и музыкальные будильники в продуманном интерфейсе.
## Прямое радио
- Ищите станции по названию, стране, языку и качеству.
- Изучайте ближайшие станции и открывайте новое радио.
- Сортируйте списки по названию или качеству.
## Музыка по-вашему
- Сохраняйте избранное и организуйте его по группам.
- Настраивайте глобальный эквалайзер или пресеты для станций.
- Используйте таймер сна с нужной длительностью.
## Записи
- Записывайте радио без повторного сжатия исходного потока.
- Ограничивайте максимальный размер файла для безопасности.
- Открывайте папку записей, чтобы делиться, перемещать и редактировать файлы.
## Музыкальные будильники
- Создавайте разовые, ежедневные или будничные будильники.
- Выбирайте любимую станцию и безопасный встроенный звук.
- Используйте праздники, пропуск следующего запуска и отложенный сигнал.
+27
View File
@@ -0,0 +1,27 @@
# 欢迎使用 PluriWave
PluriWave 是你的高品质全球电台:直播电台、分组收藏、录音、均衡器和音乐闹钟,体验精致流畅。
## 直播电台
- 按名称、国家、语言和音质搜索电台。
- 探索附近电台,发现新的广播内容。
- 按名称或音质排序列表。
## 按你的方式听音乐
- 保存收藏并按分组整理。
- 调整全局均衡器或单电台预设。
- 使用可自定义时长的睡眠定时器。
## 录音
- 录制电台时不重新压缩原始流。
- 限制最大文件大小,更安全省心。
- 打开录音文件夹以分享、移动或编辑文件。
## 音乐闹钟
- 创建一次性、每日或工作日闹钟。
- 选择喜爱的电台和安全的内置提示音。
- 支持假期、跳过下次执行和贪睡。
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · منبّهات وملفات أكثر موثوقية
الملخّص: عززنا أساس منبّهات Android وفصلنا بوضوح بين فتح المجلد وتغيير مساره.
## التحسينات
- أساس أصلي جديد للمنبّهات مع صوت داخلي آمن.
- تشخيص أفضل لأذونات Android الخاصة بالمنبّهات الدقيقة.
- المنبّهات التي تُنشأ في الدقيقة نفسها لم تعد تُستبعد بسبب الثواني.
- لوحة المنبّهات تميّز بين المنبّهات النشطة والمنبّهات بلا تنفيذ تالٍ صالح.
- فتح المجلد يحاول الآن فتح المسار المحفوظ؛ تغيير المسار أصبح منفصلاً.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · আরও নির্ভরযোগ্য অ্যালার্ম ও ফাইল
সারাংশ: আমরা Android অ্যালার্মের ভিত্তি শক্ত করেছি এবং ফোল্ডার খোলা ও পথ পরিবর্তনকে স্পষ্টভাবে আলাদা করেছি।
## উন্নতি
- নিরাপদ অভ্যন্তরীণ সাউন্ডসহ অ্যালার্মের জন্য নতুন নেটিভ ভিত্তি।
- Android exact-alarm অনুমতির উন্নত ডায়াগনস্টিক।
- একই মিনিটে তৈরি অ্যালার্ম এখন সেকেন্ডের কারণে বাদ পড়ে না।
- অ্যালার্ম প্যানেল সক্রিয় অ্যালার্ম ও বৈধ পরের রানবিহীন অ্যালার্ম আলাদা করে।
- ফোল্ডার খোলা এখন সংরক্ষিত পথ খোলার চেষ্টা করে; পথ বদল আলাদা করা হয়েছে।
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Zuverlässigere Alarme und Dateien
Zusammenfassung: Wir haben die Android-Alarmbasis verstärkt und das Öffnen eines Ordners klar vom Ändern seines Pfads getrennt.
## Verbesserungen
- Neue native Grundlage für Alarme mit sicherem internem Ton.
- Bessere Diagnose der Android-Berechtigung für exakte Alarme.
- Alarme, die in derselben Minute erstellt werden, werden wegen Sekunden nicht mehr verworfen.
- Das Alarmpanel unterscheidet aktive Alarme von Alarmen ohne gültige nächste Ausführung.
- Ordner öffnen versucht jetzt den gespeicherten Pfad zu öffnen; Pfad ändern ist separat.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · More reliable alarms and files
Summary: we reinforced the Android alarm foundation and clearly separated opening a folder from changing its path.
## Improvements
- New native foundation for alarms with a safe internal sound.
- Better Android exact-alarm permission diagnostics.
- Alarms created in the same minute are no longer discarded because of seconds.
- The alarms panel distinguishes active alarms from alarms without a valid next execution.
- Open folder now tries to open the saved path; change path is separate.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Alarmas y archivos más fiables
Resumen: reforzamos la base de alarmas Android y separamos claramente abrir carpeta de cambiar ruta.
## Mejoras
- Nueva base nativa para alarmas con sonido interno seguro.
- Mejor diagnóstico de permisos Android para alarmas exactas.
- Las alarmas creadas en el mismo minuto ya no se descartan por segundos.
- El panel de alarmas distingue entre alarmas activas y alarmas sin próxima ejecución válida.
- Abrir carpeta ahora intenta abrir la ruta guardada; cambiar ruta queda separado.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Alarmes et fichiers plus fiables
Résumé : nous avons renforcé la base des alarmes Android et séparé clairement l'ouverture d'un dossier du changement de chemin.
## Améliorations
- Nouvelle base native pour les alarmes avec un son interne sûr.
- Meilleur diagnostic des permissions Android pour les alarmes exactes.
- Les alarmes créées dans la même minute ne sont plus ignorées à cause des secondes.
- Le panneau d'alarmes distingue les alarmes actives de celles sans prochaine exécution valide.
- Ouvrir le dossier tente désormais d'ouvrir le chemin enregistré ; changer le chemin est séparé.
+12
View File
@@ -0,0 +1,12 @@
# v0.1.47 · अधिक भरोसेमंद अलार्म और फ़ाइलें
सारांश: हमने Android अलार्म की बुनियाद मजबूत की और फ़ोल्डर खोलने को उसका पथ बदलने से स्पष्ट रूप से अलग किया।
## सुधार
- सुरक्षित आंतरिक ध्वनि के साथ अलार्म के लिए नई नेटिव बुनियाद।
- Android exact-alarm अनुमति के बेहतर निदान।
- एक ही मिनट में बने अलार्म अब सेकंड की वजह से हटाए नहीं जाते।
- अलार्म पैनल सक्रिय अलार्म और बिना वैध अगली निष्पादन के अलार्म में अंतर करता है।
- फ़ोल्डर खोलना अब सहेजा गया पथ खोलने की कोशिश करता है; पथ बदलना अलग है।
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Alarm dan file lebih andal
Ringkasan: kami memperkuat fondasi alarm Android dan memisahkan dengan jelas antara membuka folder dan mengubah jalurnya.
## Peningkatan
- Fondasi native baru untuk alarm dengan suara internal yang aman.
- Diagnostik izin exact-alarm Android yang lebih baik.
- Alarm yang dibuat pada menit yang sama tidak lagi dibuang karena detik.
- Panel alarm membedakan alarm aktif dari alarm tanpa eksekusi berikutnya yang valid.
- Buka folder sekarang mencoba membuka jalur tersimpan; ubah jalur dipisahkan.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Allarmi e file più affidabili
Riepilogo: abbiamo rafforzato la base degli allarmi Android e separato chiaramente l'apertura di una cartella dalla modifica del suo percorso.
## Miglioramenti
- Nuova base nativa per gli allarmi con suono interno sicuro.
- Diagnostica migliore dei permessi Android per gli allarmi esatti.
- Gli allarmi creati nello stesso minuto non vengono più scartati a causa dei secondi.
- Il pannello allarmi distingue gli allarmi attivi da quelli senza prossima esecuzione valida.
- Apri cartella ora prova ad aprire il percorso salvato; cambia percorso è separato.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · より信頼できるアラームとファイル
概要: Android のアラーム基盤を強化し、フォルダーを開く操作とパス変更を明確に分離しました。
## 改善点
- 安全な内部サウンドを備えた、新しいネイティブアラーム基盤を導入。
- Android の正確なアラーム権限診断を改善。
- 同じ分に作成したアラームが秒の違いで破棄されなくなりました。
- アラームパネルで、有効な次回実行があるアラームとないアラームを区別。
- フォルダーを開くは保存済みパスを開くようになり、パス変更は別操作になりました。
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Alarmes e arquivos mais confiáveis
Resumo: reforçamos a base de alarmes do Android e separamos claramente abrir pasta de mudar caminho.
## Melhorias
- Nova base nativa para alarmes com som interno seguro.
- Melhor diagnóstico de permissões Android para alarmes exatos.
- Alarmes criados no mesmo minuto não são mais descartados por causa dos segundos.
- O painel de alarmes distingue alarmes ativos de alarmes sem próxima execução válida.
- Abrir pasta agora tenta abrir o caminho salvo; mudar caminho fica separado.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · Более надежные будильники и файлы
Кратко: мы усилили основу будильников Android и четко разделили открытие папки и изменение её пути.
## Улучшения
- Новая нативная основа будильников с безопасным встроенным звуком.
- Улучшена диагностика разрешений Android для точных будильников.
- Будильники, созданные в ту же минуту, больше не отбрасываются из-за секунд.
- Панель будильников различает активные будильники и будильники без валидного следующего запуска.
- Открыть папку теперь пытается открыть сохраненный путь; изменение пути вынесено отдельно.
+11
View File
@@ -0,0 +1,11 @@
# v0.1.47 · 更可靠的闹钟与文件
摘要:我们强化了 Android 闹钟基础,并清晰区分了“打开文件夹”和“更改路径”。
## 改进
- 闹钟采用新的原生基础,配有安全的内置提示音。
- 改进 Android 精确闹钟权限诊断。
- 同一分钟创建的闹钟不再因秒数被丢弃。
- 闹钟面板可区分活跃闹钟与无有效下次执行的闹钟。
- “打开文件夹”现在会尝试打开已保存路径;“更改路径”独立处理。
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

@@ -0,0 +1,5 @@
# PluriWave Night Ocean asset sheet prompt
Generated with built-in image_gen for the Night Ocean Broadcast redesign.
Contents: app mark, station fallback artworks, aurora/waveform banner, and navigation glyph assets using teal/amber/cream over navy with no purple/magenta dominance.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 466 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,3 @@
PluriWave AAA mockup generated with image_gen.
Visual direction: midnight-ocean glass, teal/cyan audio waves, coral sunrise accents, warm gold broadcast particles, accessible high contrast, no purple-dominant palette.
Launcher/app icon intentionally preserved.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1,5 @@
# PluriWave award mockup prompt
Generated with built-in image_gen as the visual target for the premium redesign.
Focus: five mobile screens, dark aurora glassmorphism, cyan/violet/magenta gradients, premium iconography, accessible hierarchy, Home/Search/Favorites/Now Playing/Settings.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

@@ -0,0 +1,5 @@
# PluriWave Night Ocean mockup prompt
Generated with built-in image_gen after user feedback rejecting purple-heavy futuristic UI.
Direction: Night Ocean Broadcast ? midnight navy and petrol teal base, mint action states, warm amber live/accent, cream text/surfaces, practical radio streaming UX with immediate Now Playing flow.
+27
View File
@@ -0,0 +1,27 @@
# Alarmas Android en PluriWave
PluriWave programa las alarmas con `AlarmManager.setAlarmClock`, porque es el camino Android pensado para despertadores visibles y de alta fiabilidad. Flutter conserva la configuración, la UI, la emisora y los fallbacks; Android se encarga de despertar la app en el momento exacto.
## Flujo
1. Flutter calcula la próxima ejecución según tipo, días, vacaciones y omisiones.
2. `ServicioAlarmasAndroid` envía la programación al `MethodChannel pluriwave/alarm_scheduler`.
3. `AlarmScheduler` registra:
- alarma principal con `setAlarmClock`;
- preaviso silencioso 30 minutos antes con `setExactAndAllowWhileIdle`.
4. `PluriWaveAlarmReceiver` abre la app cuando suena la alarma.
5. Flutter muestra `PantallaAlarmaSonando`, intenta reproducir la emisora y activa audio interno si la radio falla o tarda demasiado.
## Permisos
- `SCHEDULE_EXACT_ALARM`: necesario en Android 12+ para exactitud.
- `POST_NOTIFICATIONS`: necesario en Android 13+ para el preaviso silencioso.
- `WAKE_LOCK` y foreground media playback ya están declarados para la reproducción.
## Fallbacks
Si la emisora no existe, falla o no empieza a reproducir en unos segundos, la pantalla usa sonidos internos incluidos en `assets/audio/`. Esto evita una alarma silenciosa por problemas de red o de radio.
## Vacaciones y omisiones
Las vacaciones se guardan en Flutter. Las alarmas configuradas para pausar en vacaciones saltan automáticamente esos rangos y muestran la próxima fecha válida. El preaviso permite omitir la siguiente ejecución abriendo la app y aplicando la misma lógica de omisión persistente.
+30
View File
@@ -0,0 +1,30 @@
# Arquitectura de alarmas con pantalla apagada
## Diagnóstico
El flujo anterior hacía que Android recibiese la alarma con `AlarmManager`, pero el sonido real dependía de que se abriese `MainActivity` y de que Flutter llegase a pintar `PantallaAlarmaSonando`. Con pantalla apagada, Doze o restricciones del fabricante, ese arranque de UI puede retrasarse hasta que el usuario enciende la pantalla.
## Decisión
La alarma debe sonar desde Android nativo en cuanto llega `ACTION_FIRE`. Flutter pasa a ser la interfaz de control para detener, posponer y hacer handoff a la radio de la app, pero no el único origen del sonido.
## Flujo recomendado
1. `AlarmScheduler` programa la alarma con `setAlarmClock` y fallback exact/inexact.
2. `PluriWaveAlarmReceiver` recibe `ACTION_FIRE`.
3. El receiver arranca `PluriWaveAlarmService` como foreground service.
4. El servicio toma un `PARTIAL_WAKE_LOCK`, muestra notificación foreground y reproduce audio con `USAGE_ALARM`.
5. La UI Flutter se abre por full-screen intent si Android lo permite.
6. Al detener/posponer desde Flutter, se manda comando nativo para parar el servicio.
## Referencias
- Android alarms: https://developer.android.com/develop/background-work/services/alarms
- Foreground service restrictions: https://developer.android.com/develop/background-work/services/fgs/restrictions-bg-start
- AOSP DeskClock AlarmService: https://android.googlesource.com/platform/packages/apps/DeskClock/+/ac260c0096605526f772af7eec73d6a51dc6de32/src/com/android/deskclock/alarms/AlarmService.java
## Notas
- El audio local interno es el fallback más fiable para pantalla apagada.
- La radio remota puede fallar por red, DNS, TLS o timeout; por eso debe existir fallback interno.
- Si un fabricante bloquea incluso servicios arrancados desde alarma, habrá que guiar al usuario con permisos de batería/autostart.
+32
View File
@@ -0,0 +1,32 @@
# Notas de grabación y visualización real de audio
Referencia interna: este archivo vive en `docs/` y no está listado en
`flutter.assets`, así que no se compila dentro de la aplicación.
## Decisiones aplicadas
- La grabación de radio se hace leyendo el stream HTTP original de la emisora y
escribiendo sus bytes a disco. No se graba micrófono ni salida del sistema.
- La ventaja es que se conserva la calidad original del stream y se evita
recomprimir audio.
- La forma de onda real se intenta capturar en Android con
`android.media.audiofx.Visualizer` usando el `androidAudioSessionId` expuesto
por `just_audio`.
- Si Android deniega permisos o el dispositivo no permite capturar esa sesión,
la UI cae al visualizador animado anterior para no bloquear el reproductor.
## Fuentes consultadas
- `just_audio` expone `androidAudioSessionIdStream` para enlazar efectos o
visualizadores Android a la sesión activa:
https://pub.dev/packages/just_audio/versions/0.10.4
- Android `Visualizer` permite capturar waveform de contenido en reproducción y
requiere permiso `RECORD_AUDIO`:
https://www.android-doc.com/reference/android/media/audiofx/Visualizer.html
- Radio Browser permite ordenar búsquedas por `bitrate` y expone campos
`codec`/`bitrate`:
https://stations.radioss.app/
- El paquete `audio_visualizer` existe, pero se descartó como dependencia
inmediata porque duplicaría reproducción con su propio player; PluriWave ya
usa `audio_service` + `just_audio` y acabamos de estabilizar ese flujo:
https://pub.dev/packages/audio_visualizer
+31
View File
@@ -0,0 +1,31 @@
# Notas de reproducción de radio con just_audio/audio_service
Referencia interna para futuras correcciones del reproductor de PluriWave. Este archivo está en `docs/` y no se incluye en `flutter.assets`, por lo que no compila dentro de la app.
## Hallazgos útiles
- `AudioPlayer.play()` completa cuando la reproducción termina, se pausa o se detiene. En radio en vivo no representa simplemente “ya empezó a sonar”.
- Las versiones antiguas de PluriWave que sí cambiaban de emisora usaban el flujo simple de `audio_service` + `just_audio`: `stop() -> setUrl() -> play()` dentro de `playMediaItem`.
- La regresión apareció cuando mezclamos dos responsabilidades: usar `handler.emisoraActual` como estado técnico de audio y también como estado visual inmediato para mostrar el mini reproductor.
- El logcat de 2026-05-21 mostró la media session de PluriWave atascada en `CONNECTING` sin `PlayerException`, con metadata de la emisora anterior (`Track FM`). Eso apunta a un player/ExoPlayer reutilizado que queda colgado entre `stop/setUrl/play`, no a un error HTTP visible.
## Decisión aplicada en PluriWave
- Mantener la selección visual inmediata en `EstadoRadio` mediante una emisora seleccionada propia, separada del `emisoraActual` interno del handler.
- No usar `setAudioSource(..., preload: false)` como reemplazo de `setUrl(...)`: en esta app rompió incluso la primera conexión.
- No esperar `play()` como operación de finalización para radio en vivo.
- Al cambiar emisora, recrear el `AudioPlayer`/ExoPlayer para matar completamente la reproducción anterior antes de `setUrl(...)`.
- Proteger `EstadoRadio.reproducir` con revisión para que una operación vieja no aplique presets/clicks encima de una nueva.
## Intentos descartados
- `setAudioSource(..., preload: false)`: teóricamente razonable, pero en PluriWave rompió la primera conexión.
- Hacer que el handler publique `emisoraActual` antes de que el flujo histórico de audio avance: arregla el mini reproductor, pero cambia la semántica que tenían las versiones viejas.
- Reutilizar siempre el mismo `AudioPlayer` con `stop()`: logcat mostró estado `CONNECTING` persistente sin excepción al cambiar/reintentar.
## Fuentes consultadas
- just_audio `AudioPlayer.play()` API: https://pub.dev/documentation/just_audio/latest/just_audio/AudioPlayer/play.html
- just_audio `AudioPlayer` API general: https://pub.dev/documentation/just_audio/latest/just_audio/AudioPlayer-class.html
- Ejemplo de radio en vivo de just_audio: https://gist.github.com/scysys/7f700cd49f09ba788021504e8d3477aa
- Discusión sobre cambiar fuente con audio_service + just_audio: https://stackoverflow.com/questions/70526156/changing-audio-source-in-audio-service-and-just-audio-flutter
+110
View File
@@ -0,0 +1,110 @@
# PluriWave · Guía de publicación automática en Google Play
> Estado: en preparación
> Última revisión: 2026-05-27
## Objetivo
Dejar **PluriWave** con un flujo de publicación lo más automático posible:
- `main` → desarrollo diario, pruebas y artefactos internos
- `PRO` → publicación automática a **Google Play Internal Testing**
## Estrategia acordada
### Ramas
- **`main`**
- desarrollo diario
- análisis, tests y builds internos
- NO publica en Google Play
- **`PRO`**
- rama de release permanente
- al subir cambios aquí, se genera el **AAB release firmado**
- publica automáticamente en **Google Play · Prueba interna**
### Publicación
1. Bootstrap manual inicial en Play Console
2. Configuración correcta del keystore de subida
3. Integración con Google Play Developer API
4. Automatización desde Gitea Actions
## Estado actual del proyecto
### Verificado en el repositorio
- Existe workflow en `.gitea/workflows/build.yml`
- Actualmente compila y firma correctamente en CI
- Genera:
- APK release
- AAB release
- Publica artefactos internos en `ftl-builds`
- Ya existe soporte para keystore release desde `android/key.properties`
### Verificado en Play Console
- La app ya está creada
- Nombre: `PluriWave`
- Package: `es.freetimelab.pluriwave`
- Ya se ha subido manualmente un **AAB** al canal de **prueba interna**
- Producción sigue bloqueada por el requisito de:
- prueba cerrada
- 12 testers
- 14 días
## Automatización prevista en CI
### `main`
- `flutter pub get`
- `flutter analyze`
- build release
- publicación de APK/AAB en infraestructura interna
### `PRO`
- `flutter pub get`
- `flutter analyze`
- build release firmado
- publicación de APK/AAB en infraestructura interna
- subida automática del `.aab` a Google Play **track internal**
## Secretos necesarios en Gitea
### Ya usados por firma
- `PLURIWAVE_KEYSTORE_PASSWORD`
- `GITEA_TOKEN`
### Necesarios para Play Store
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`
> Debe contener el JSON completo de una **Service Account** con acceso concedido en Play Console a esta aplicación.
## Ficheros implicados
- `.gitea/workflows/build.yml`
- `fastlane/Fastfile`
- `fastlane/Appfile`
- `android/app/build.gradle.kts`
## Siguiente validación manual
Cuando la automatización quede desplegada:
1. crear la rama `PRO` en remoto
2. configurar `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON`
3. hacer push a `PRO`
4. comprobar que:
- compila
- firma
- genera AAB
- sube a Google Play Internal Testing
## Notas importantes
- El canal automatizado inicial será **internal testing**, no producción
- La primera publicación manual en Play Console ya quedó hecha
- La automatización NO elimina el requisito posterior de closed testing antes de producción
+1
View File
@@ -0,0 +1 @@
package_name(ENV["PLAY_PACKAGE_NAME"] || "es.freetimelab.pluriwave")
+25
View File
@@ -0,0 +1,25 @@
default_platform(:android)
platform :android do
desc "Sube el AAB actual al track internal de Google Play"
lane :upload_internal do
json_key_path = ENV["PLAY_JSON_KEY_PATH"]
aab_path = ENV["PLAY_AAB_PATH"] || "build/app/outputs/bundle/release/app-release.aab"
package_name = ENV["PLAY_PACKAGE_NAME"] || "es.freetimelab.pluriwave"
UI.user_error!("Falta PLAY_JSON_KEY_PATH") if json_key_path.to_s.empty?
UI.user_error!("No existe el AAB en #{aab_path}") unless File.exist?(aab_path)
upload_to_play_store(
json_key: json_key_path,
package_name: package_name,
aab: aab_path,
track: ENV["PLAY_TRACK"] || "internal",
release_status: ENV["PLAY_RELEASE_STATUS"] || "completed",
skip_upload_metadata: true,
skip_upload_images: true,
skip_upload_screenshots: true,
skip_upload_changelogs: true
)
end
end
+2
View File
@@ -66,5 +66,7 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSLocationWhenInUseUsageDescription</key>
<string>PluriWave usa tu ubicacion aproximada para sugerirte emisoras cercanas.</string>
</dict>
</plist>
+6
View File
@@ -0,0 +1,6 @@
arb-dir: lib/l10n
template-arb-file: app_es.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
output-dir: lib/l10n/gen
nullable-getter: false
+512 -130
View File
@@ -1,50 +1,51 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'estado/estado_radio.dart';
import 'estado/estado_alarmas.dart';
import 'estado/estado_idioma.dart';
import 'l10n/display_names.dart';
import 'l10n/gen/app_localizations.dart';
import 'modelos/alarma_musical.dart';
import 'pantallas/pantalla_alarmas.dart';
import 'pantallas/pantalla_alarma_sonando.dart';
import 'pantallas/pantalla_inicio.dart';
import 'pantallas/pantalla_buscar.dart';
import 'pantallas/pantalla_favoritos.dart';
import 'pantallas/pantalla_ajustes.dart';
import 'widgets/mini_reproductor.dart';
import 'tema/pluriwave_theme.dart';
import 'widgets/pluri_bottom_navigation.dart';
import 'widgets/pluri_icon.dart';
import 'widgets/pluri_layout.dart';
import 'widgets/pluri_onboarding_dialog.dart';
import 'widgets/pluri_wave_scaffold.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'servicios/servicio_alarmas_android.dart';
class PluriWaveApp extends StatelessWidget {
const PluriWaveApp({super.key});
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => EstadoRadio(),
child: MaterialApp(
title: 'PluriWave',
debugShowCheckedModeBanner: false,
theme: _buildTheme(Brightness.dark),
darkTheme: _buildTheme(Brightness.dark),
themeMode: ThemeMode.dark,
home: const _PaginaPrincipal(),
),
);
}
ThemeData _buildTheme(Brightness brightness) {
final colorScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF6750A4),
brightness: brightness,
);
return ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
textTheme: GoogleFonts.interTextTheme(
ThemeData(brightness: brightness).textTheme,
),
cardTheme: CardThemeData(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
color: colorScheme.surfaceContainerLow,
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => EstadoRadio()),
ChangeNotifierProvider(create: (_) => EstadoAlarmas()),
ChangeNotifierProvider(create: (_) => EstadoIdioma()),
],
child: Consumer<EstadoIdioma>(
builder:
(context, estadoIdioma, _) => MaterialApp(
title: 'PluriWave',
debugShowCheckedModeBanner: false,
theme: PluriWaveTheme.dark(),
darkTheme: PluriWaveTheme.dark(),
themeMode: ThemeMode.dark,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: estadoIdioma.localeSeleccionado,
home: const _PaginaPrincipal(),
),
),
);
}
@@ -58,139 +59,520 @@ class _PaginaPrincipal extends StatefulWidget {
}
class _PaginaPrincipalState extends State<_PaginaPrincipal> {
static const _volumenInicialFadeInAlarmas = 0.05;
int _indice = 0;
StreamSubscription<String>? _errorSubscription;
StreamSubscription<EventoAlarmaAndroid>? _alarmaSubscription;
StreamSubscription<AlarmaMusical>? _alarmaVencidaSubscription;
EstadoRadio? _estadoSuscrito;
bool _alarmaInicialProcesada = false;
bool _alarmaSonandoActiva = false;
bool _onboardingInicialSolicitado = false;
String? _alarmaSonandoId;
static const _paginas = [
PantallaInicio(),
PantallaBuscar(),
PantallaFavoritos(),
PantallaAlarmas(),
PantallaAjustes(),
];
static const _destinos = [
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: 'Inicio',
),
NavigationDestination(
icon: Icon(Icons.search_outlined),
selectedIcon: Icon(Icons.search),
label: 'Buscar',
),
NavigationDestination(
icon: Icon(Icons.favorite_outline),
selectedIcon: Icon(Icons.favorite),
label: 'Favoritos',
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: 'Ajustes',
),
List<PluriNavItem> _navItems(AppLocalizations l10n) => [
PluriNavItem(glyph: PluriIconGlyph.home, label: l10n.navHome),
PluriNavItem(glyph: PluriIconGlyph.search, label: l10n.navSearch),
PluriNavItem(glyph: PluriIconGlyph.favorites, label: l10n.navFavorites),
PluriNavItem(glyph: PluriIconGlyph.alarm, label: l10n.navAlarms),
PluriNavItem(glyph: PluriIconGlyph.settings, label: l10n.navSettings),
];
@override
void didChangeDependencies() {
super.didChangeDependencies();
context.read<EstadoRadio>().errorStream.listen((msg) {
final estado = context.read<EstadoRadio>();
if (identical(_estadoSuscrito, estado) && _errorSubscription != null) {
return;
}
_errorSubscription?.cancel();
_estadoSuscrito = estado;
_errorSubscription = estado.errorStream.listen((msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
duration: const Duration(seconds: 3),
action: SnackBarAction(label: 'OK', onPressed: () {}),
action: SnackBarAction(
label: AppLocalizations.of(context).actionOk,
onPressed: () {},
),
),
);
});
final alarmas = context.read<EstadoAlarmas>();
_alarmaSubscription ??= alarmas.android.eventosAlarma.listen((evento) {
if (!mounted) return;
_abrirAlarmaSonando(evento);
});
_alarmaVencidaSubscription ??= alarmas.alarmasVencidasStream.listen((
alarma,
) {
if (!mounted) return;
_abrirAlarmaDirecta(alarma);
});
if (!_alarmaInicialProcesada) {
_alarmaInicialProcesada = true;
unawaited(_procesarAlarmaInicial(alarmas));
}
if (!_onboardingInicialSolicitado) {
_onboardingInicialSolicitado = true;
unawaited(_mostrarOnboardingInicial());
}
}
@override
void dispose() {
_errorSubscription?.cancel();
_alarmaSubscription?.cancel();
_alarmaVencidaSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _indice == 3
? null // PantallaAjustes tiene su propio AppBar
: AppBar(
title: const Text('PluriWave'),
actions: [
IconButton(
icon: const Icon(Icons.bedtime_outlined),
tooltip: 'Timer de sueño',
onPressed: () => _mostrarTimerDialog(context),
),
],
),
body: _paginas[_indice],
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
const MiniReproductor(),
NavigationBar(
selectedIndex: _indice,
onDestinationSelected: (i) => setState(() => _indice = i),
destinations: _destinos,
final l10n = AppLocalizations.of(context);
return PluriWaveScaffold(
appBar: AppBar(
title: Text(l10n.appTitle),
actions: [
IconButton(
icon: const Icon(Icons.bedtime_outlined),
tooltip: l10n.sleepTimer,
onPressed: () => _mostrarTimerDialog(context),
),
],
),
);
}
void _mostrarTimerDialog(BuildContext context) {
final estado = context.read<EstadoRadio>();
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
body: SafeArea(
top: false,
child: AnimatedSwitcher(
duration: context.pluriMotion.normal,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder:
(child, animation) => FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.035, 0),
end: Offset.zero,
).animate(animation),
child: child,
),
),
child: KeyedSubtree(
key: ValueKey<int>(_indice),
child: _paginas[_indice],
),
),
),
bottomNavigationBar: SafeArea(
top: false,
minimum: const EdgeInsets.only(bottom: PluriLayout.compactGap),
child: Padding(
padding: const EdgeInsets.all(24),
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Timer de sueño', style: Theme.of(ctx).textTheme.titleLarge),
const SizedBox(height: 16),
if (estado.timer.activo)
StreamBuilder<Duration>(
stream: estado.timer.tiempoRestanteStream,
builder: (ctx, snap) {
final t = snap.data ?? Duration.zero;
final h = t.inHours;
final m = t.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = t.inSeconds.remainder(60).toString().padLeft(2, '0');
return Column(
children: [
Text(
'${h > 0 ? "${h}h " : ""}${m}m ${s}s',
style: Theme.of(ctx).textTheme.headlineMedium,
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: () {
estado.cancelarTimer();
Navigator.pop(ctx);
},
child: const Text('Cancelar timer'),
),
],
);
},
)
else
Wrap(
spacing: 8,
children: [15, 30, 60, 90]
.map((min) => ActionChip(
label: Text('$min min'),
onPressed: () {
estado.iniciarTimer(min);
Navigator.pop(ctx);
},
))
.toList(),
),
const MiniReproductor(),
PluriBottomNavigation(
items: _navItems(l10n),
selectedIndex: _indice,
onSelected: (i) => setState(() => _indice = i),
),
],
),
),
),
);
}
Future<void> _procesarAlarmaInicial(EstadoAlarmas alarmas) async {
final evento = await alarmas.android.obtenerEventoInicial();
if (evento != null && mounted) {
await _abrirAlarmaSonando(evento);
}
}
Future<void> _mostrarOnboardingInicial() async {
await Future<void>.delayed(const Duration(milliseconds: 900));
if (!mounted || _alarmaSonandoActiva) return;
await PluriOnboardingDialog.mostrarSiProcede(context);
}
Future<void> _abrirAlarmaSonando(EventoAlarmaAndroid evento) async {
final estado = context.read<EstadoAlarmas>();
if (estado.alarmas.isEmpty) {
await estado.cargarPersistidasSinRecalcular();
}
AlarmaMusical? alarma;
for (final item in estado.alarmas) {
if (item.id == evento.alarmaId) {
alarma = item;
break;
}
}
if (alarma == null || !mounted) {
debugPrint(
'[PluriWave][alarmas] evento sin alarma persistida id=${evento.alarmaId} accion=${evento.accion}',
);
return;
}
if (evento.accion.endsWith('.SKIP_NEXT')) {
await estado.saltarProxima(alarma.id);
if (!mounted) return;
setState(() => _indice = 3);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
).skipCurrentAlarmExecution(
localizedAlarmName(AppLocalizations.of(context), alarma.nombre),
),
),
),
);
return;
}
if (evento.accion.endsWith('.POSTPONE_NEXT')) {
final ejecucion =
evento.occurrenceAtMillis > 0
? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis)
: alarma.proximaEjecucion ?? DateTime.now();
await estado.posponerProximaDesdePreaviso(
alarma,
evento.snoozeMinutes,
ejecucion,
);
if (!mounted) return;
setState(() => _indice = 3);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).alarmPostponedCurrentExecution,
),
),
);
return;
}
if (evento.accion.endsWith('.PRE_NOTICE')) {
setState(() => _indice = 3);
return;
}
await _mostrarAlarmaSonando(alarma);
}
Future<void> _abrirAlarmaDirecta(AlarmaMusical alarma) async {
await _mostrarAlarmaSonando(alarma);
}
Future<void> _mostrarAlarmaSonando(AlarmaMusical alarma) async {
final alarmas = context.read<EstadoAlarmas>();
alarmas.marcarEjecucionGestionada(alarma);
if (_alarmaSonandoActiva) {
debugPrint(
'[PluriWave][alarmas] alarma ignorada porque ya hay una activa id=${alarma.id} activa=$_alarmaSonandoId',
);
await alarmas.android.ocultarNotificacionAlarma(alarma.id);
return;
}
_alarmaSonandoActiva = true;
_alarmaSonandoId = alarma.id;
try {
await _prearrancarAudioAlarma(alarma);
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder:
(_) => PantallaAlarmaSonando(
alarma: alarma,
audioPrearrancado: alarma.emisora != null,
),
fullscreenDialog: true,
),
);
} finally {
if (_alarmaSonandoId == alarma.id) {
_alarmaSonandoActiva = false;
_alarmaSonandoId = null;
}
}
}
Future<void> _prearrancarAudioAlarma(AlarmaMusical alarma) async {
final emisora = alarma.emisora;
if (emisora == null) return;
final radio = context.read<EstadoRadio>();
debugPrint(
'[PluriWave][alarmas] prearrancar emisora alarma id=${alarma.id} emisora=${emisora.nombre}',
);
await radio.audio.setVolumen(_volumenInicialFadeInAlarmas);
unawaited(radio.reproducir(emisora));
}
void _mostrarTimerDialog(BuildContext context) {
showModalBottomSheet(
context: context,
showDragHandle: true,
builder:
(ctx) => Consumer<EstadoRadio>(
builder:
(ctx, estado, _) => SafeArea(
child: Padding(
padding: PluriLayout.sheetPadding,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(ctx).sleepTimer,
style: Theme.of(ctx).textTheme.titleLarge,
),
const SizedBox(height: PluriLayout.sectionGap),
Text(
AppLocalizations.of(ctx).sleepTimerDescription,
style: Theme.of(ctx).textTheme.bodySmall,
),
const SizedBox(height: PluriLayout.panelGap),
if (estado.timer.activo)
StreamBuilder<Duration>(
stream: estado.timer.tiempoRestanteStream,
builder: (ctx, snap) {
final restante =
snap.data ?? estado.timer.tiempoRestante;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_formatearDuracionTimer(
AppLocalizations.of(ctx),
restante,
),
style:
Theme.of(ctx).textTheme.headlineMedium,
),
const SizedBox(
height: PluriLayout.compactGap,
),
FilledButton.tonal(
onPressed: () {
estado.cancelarTimer();
Navigator.pop(ctx);
},
child: Text(
AppLocalizations.of(ctx).cancelTimer,
),
),
],
);
},
)
else
Wrap(
spacing: PluriLayout.compactGap,
runSpacing: PluriLayout.compactGap,
children: [
for (final segundos
in estado.timerSuenoPresetsSegundos)
ActionChip(
label: Text(
_formatearDuracionTimer(
AppLocalizations.of(ctx),
Duration(seconds: segundos),
),
),
onPressed: () {
estado.iniciarTimerDuracion(
Duration(seconds: segundos),
);
Navigator.pop(ctx);
},
),
ActionChip(
avatar: const Icon(
Icons.tune_rounded,
size: 18,
),
label: Text(
AppLocalizations.of(ctx).optionOther,
),
onPressed: () async {
final duracion =
await _pedirDuracionPersonalizada(ctx);
if (duracion == null || !ctx.mounted) return;
estado.iniciarTimerDuracion(duracion);
Navigator.pop(ctx);
},
),
],
),
],
),
),
),
),
);
}
Future<Duration?> _pedirDuracionPersonalizada(BuildContext context) {
return showModalBottomSheet<Duration>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (ctx) => const _TimerPersonalizadoSheet(),
);
}
}
String _formatearDuracionTimer(
AppLocalizations l10n,
Duration duracion,
) {
final horas = duracion.inHours;
final minutos = duracion.inMinutes.remainder(60);
final segundos = duracion.inSeconds.remainder(60);
if (horas > 0) {
return l10n.durationHoursMinutesSeconds(
horas,
minutos.toString().padLeft(2, '0'),
segundos.toString().padLeft(2, '0'),
);
}
if (minutos > 0) {
return segundos == 0
? l10n.durationMinutesOnly(minutos)
: l10n.durationMinutesSeconds(
minutos,
segundos.toString().padLeft(2, '0'),
);
}
return l10n.durationSecondsOnly(segundos);
}
class _TimerPersonalizadoSheet extends StatefulWidget {
const _TimerPersonalizadoSheet();
@override
State<_TimerPersonalizadoSheet> createState() =>
_TimerPersonalizadoSheetState();
}
class _TimerPersonalizadoSheetState extends State<_TimerPersonalizadoSheet> {
final _horasCtrl = TextEditingController();
final _minutosCtrl = TextEditingController(text: '15');
final _segundosCtrl = TextEditingController();
bool _guardarPreset = true;
@override
void dispose() {
_horasCtrl.dispose();
_minutosCtrl.dispose();
_segundosCtrl.dispose();
super.dispose();
}
int _leer(TextEditingController ctrl) => int.tryParse(ctrl.text.trim()) ?? 0;
Future<void> _confirmar() async {
final duracion = Duration(
hours: _leer(_horasCtrl),
minutes: _leer(_minutosCtrl),
seconds: _leer(_segundosCtrl),
);
if (duracion <= Duration.zero) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).durationGreaterThanZero),
),
);
return;
}
if (_guardarPreset) {
await context.read<EstadoRadio>().agregarTimerSuenoPreset(duracion);
}
if (mounted) Navigator.pop(context, duracion);
}
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return SafeArea(
child: Padding(
padding: EdgeInsets.fromLTRB(18, 0, 18, 18 + bottom),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
AppLocalizations.of(context).customDurationTitle,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: PluriLayout.sectionGap),
Row(
children: [
Expanded(
child: _campoTiempo(
_horasCtrl,
AppLocalizations.of(context).hoursLabel,
),
),
const SizedBox(width: PluriLayout.compactGap),
Expanded(
child: _campoTiempo(
_minutosCtrl,
AppLocalizations.of(context).minutesLabel,
),
),
const SizedBox(width: PluriLayout.compactGap),
Expanded(
child: _campoTiempo(
_segundosCtrl,
AppLocalizations.of(context).secondsLabel,
),
),
],
),
const SizedBox(height: PluriLayout.compactGap),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(AppLocalizations.of(context).saveQuickAccess),
value: _guardarPreset,
onChanged: (value) => setState(() => _guardarPreset = value),
),
const SizedBox(height: PluriLayout.sectionGap),
FilledButton.icon(
icon: const Icon(Icons.bedtime_rounded),
label: Text(AppLocalizations.of(context).startTimer),
onPressed: _confirmar,
),
],
),
),
);
}
Widget _campoTiempo(TextEditingController controller, String label) {
return TextField(
controller: controller,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
);
}
}
+357
View File
@@ -0,0 +1,357 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../modelos/alarma_musical.dart';
import '../servicios/servicio_alarmas.dart';
import '../servicios/servicio_alarmas_android.dart';
class EstadoAlarmas extends ChangeNotifier {
EstadoAlarmas({
ServicioAlarmas? servicio,
PuertoAlarmasAndroid? android,
bool iniciarAutomaticamente = true,
}) : servicio = servicio ?? ServicioAlarmas(),
android = android ?? ServicioAlarmasAndroid() {
if (iniciarAutomaticamente) {
inicializar();
}
}
final ServicioAlarmas servicio;
final PuertoAlarmasAndroid android;
List<AlarmaMusical> _alarmas = [];
List<RangoVacaciones> _vacaciones = [];
List<ExcepcionAlarma> _excepciones = [];
DiagnosticoAlarmasAndroid? _diagnostico;
Timer? _refresco;
Timer? _vigilancia;
final _alarmasVencidasController =
StreamController<AlarmaMusical>.broadcast();
final Set<String> _ejecucionesEmitidas = {};
static const _margenDisparoLocal = Duration(seconds: 45);
bool _cargando = false;
String? _error;
List<AlarmaMusical> get alarmas => List.unmodifiable(_alarmas);
List<RangoVacaciones> get vacaciones => List.unmodifiable(_vacaciones);
List<ExcepcionAlarma> get excepciones => List.unmodifiable(_excepciones);
DiagnosticoAlarmasAndroid? get diagnostico => _diagnostico;
bool get cargando => _cargando;
String? get error => _error;
Stream<AlarmaMusical> get alarmasVencidasStream =>
_alarmasVencidasController.stream;
AlarmaMusical? get proximaAlarma {
final candidatas =
_alarmas.where((a) => a.activa && a.proximaProgramable != null).toList()
..sort(
(a, b) => a.proximaProgramable!.compareTo(b.proximaProgramable!),
);
return candidatas.isEmpty ? null : candidatas.first;
}
Future<void> inicializar() async {
debugPrint('[PluriWave][alarmas] inicializar');
_cargando = true;
_error = null;
notifyListeners();
try {
await _sincronizarEjecucionesGestionadasPorAndroid();
final config = await servicio.recalcularTodas();
_aplicar(config);
debugPrint(
'[PluriWave][alarmas] cargadas=${_alarmas.length} vacaciones=${_vacaciones.length} excepciones=${_excepciones.length}',
);
await _sincronizarTodas();
await cargarDiagnostico();
_activarRefresco();
} catch (e) {
_error = 'No se pudieron cargar las alarmas: $e';
debugPrint('[PluriWave][alarmas] inicializar ERROR $e');
} finally {
_cargando = false;
notifyListeners();
}
}
Future<void> guardarAlarma(AlarmaMusical alarma) async {
debugPrint(
'[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}',
);
final config = await servicio.guardarAlarma(alarma);
_aplicar(config);
try {
final guardada = _alarmas.firstWhere((a) => a.id == alarma.id);
await _solicitarPermisosNecesariosParaAlarma();
debugPrint(
'[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}',
);
await android.programar(guardada);
} catch (e) {
_error = 'Alarma guardada, pero Android no pudo programarla todavía: $e';
}
notifyListeners();
}
Future<void> refrescarProgramacion() async {
debugPrint('[PluriWave][alarmas] refrescar programacion');
final config = await servicio.recalcularTodas();
_aplicar(config);
debugPrint(
'[PluriWave][alarmas] proxima tras refrescar=${proximaAlarma?.id} ${proximaAlarma?.proximaEjecucion?.toIso8601String()}',
);
await _sincronizarTodas();
notifyListeners();
}
Future<void> cargarPersistidasSinRecalcular() async {
final config = await servicio.cargar();
_aplicar(config);
notifyListeners();
}
void marcarEjecucionGestionada(AlarmaMusical alarma) {
final proxima = alarma.proximaProgramable;
if (proxima == null) return;
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
_ejecucionesEmitidas.add(key);
debugPrint(
'[PluriWave][alarmas] ejecucion gestionada id=${alarma.id} proxima=${proxima.toIso8601String()}',
);
}
Future<void> eliminarAlarma(String id) async {
debugPrint('[PluriWave][alarmas] eliminar id=$id');
final config = await servicio.eliminarAlarma(id);
_aplicar(config);
await android.detenerSonidoNativo(id);
await android.cancelar(id);
notifyListeners();
}
Future<void> cambiarActiva(AlarmaMusical alarma, bool activa) async {
await guardarAlarma(alarma.copyWith(activa: activa));
}
Future<void> saltarProxima(String alarmaId) async {
debugPrint('[PluriWave][alarmas] saltar proxima id=$alarmaId');
final config = await servicio.saltarProxima(alarmaId);
_aplicar(config);
AlarmaMusical? alarma;
for (final item in _alarmas) {
if (item.id == alarmaId) {
alarma = item;
break;
}
}
if (alarma != null) {
await android.programar(alarma);
}
notifyListeners();
}
Future<void> guardarVacaciones(List<RangoVacaciones> vacaciones) async {
debugPrint(
'[PluriWave][alarmas] guardar vacaciones count=${vacaciones.length}',
);
final config = await servicio.guardarVacaciones(vacaciones);
_aplicar(config);
await _sincronizarTodas();
notifyListeners();
}
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
final ejecucion =
alarma.snoozeOrigen ?? alarma.proximaEjecucion ?? DateTime.now();
debugPrint(
'[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos ejecucion=${ejecucion.toIso8601String()}',
);
await android.ocultarNotificacionAlarma(alarma.id);
final config = await servicio.posponerEjecucion(
alarma.id,
ejecucion,
minutos,
);
_aplicar(config);
final actualizada = _buscarAlarma(alarma.id);
if (actualizada != null) {
await android.programar(actualizada);
}
notifyListeners();
}
Future<void> posponerProximaDesdePreaviso(
AlarmaMusical alarma,
int minutos,
DateTime ejecucion,
) async {
final seguros = _snoozeSeguro(minutos);
final snoozeHasta = ejecucion.add(Duration(minutes: seguros));
debugPrint(
'[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros ejecucion=${ejecucion.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}',
);
await android.ocultarNotificacionAlarma(alarma.id);
final config = await servicio.posponerEjecucionHasta(
alarma.id,
ejecucion,
snoozeHasta,
);
_aplicar(config);
final actualizada = _buscarAlarma(alarma.id);
if (actualizada != null) {
await android.programar(actualizada);
}
notifyListeners();
}
Future<void> finalizarEjecucion(String alarmaId) async {
debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId');
final alarma = _buscarAlarma(alarmaId);
final ejecucion =
alarma?.snoozeOrigen ??
alarma?.proximaEjecucion ??
alarma?.snoozeHasta ??
DateTime.now();
await android.ocultarNotificacionAlarma(alarmaId);
final config = await servicio.completarEjecucion(alarmaId, ejecucion);
_aplicar(config);
await _sincronizarTodas();
notifyListeners();
}
Future<void> crearRangoVacaciones(RangoVacaciones rango) async {
final nuevos = [..._vacaciones, rango];
await guardarVacaciones(nuevos);
}
Future<void> eliminarRangoVacaciones(String id) async {
final nuevos = _vacaciones.where((v) => v.id != id).toList();
await guardarVacaciones(nuevos);
}
ExcepcionAlarma? ultimaExcepcionPara(String alarmaId) {
final candidatas =
_excepciones.where((e) => e.alarmaId == alarmaId).toList()
..sort((a, b) => b.ejecucion.compareTo(a.ejecucion));
return candidatas.isEmpty ? null : candidatas.first;
}
Future<void> cargarDiagnostico() async {
try {
_diagnostico = await android.diagnostico();
} catch (e) {
debugPrint('[PluriWave][alarmas] diagnostico ERROR $e');
_diagnostico = null;
}
notifyListeners();
}
Future<void> _sincronizarEjecucionesGestionadasPorAndroid() async {
try {
final ejecuciones = await android.obtenerEjecucionesNativasGestionadas();
if (ejecuciones.isEmpty) return;
final config = await servicio.sincronizarEjecucionesNativas({
for (final ejecucion in ejecuciones)
ejecucion.alarmaId: ejecucion.gestionadaEn,
});
_aplicar(config);
debugPrint(
'[PluriWave][alarmas] sincronizadas ejecuciones nativas count=${ejecuciones.length}',
);
} catch (e) {
debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e');
}
}
Future<void> _solicitarPermisosNecesariosParaAlarma() async {
try {
final diag = await android.diagnostico();
_diagnostico = diag;
if (!diag.puedeProgramarExactas) {
await android.solicitarPermisoAlarmasExactas();
}
if (!diag.notificacionesPermitidas) {
await android.solicitarPermisoNotificaciones();
}
if (!diag.puedeUsarPantallaCompleta) {
await android.solicitarPermisoPantallaCompleta();
}
} catch (e) {
debugPrint('[PluriWave][alarmas] permisos android ERROR $e');
}
}
Future<void> _sincronizarTodas() async {
debugPrint(
'[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}',
);
if (_alarmas.any((alarma) => alarma.activa)) {
await _solicitarPermisosNecesariosParaAlarma();
}
for (final alarma in _alarmas) {
await android.programar(alarma);
}
}
AlarmaMusical? _buscarAlarma(String id) {
for (final alarma in _alarmas) {
if (alarma.id == id) return alarma;
}
return null;
}
int _snoozeSeguro(int minutos) =>
minutos == 3 || minutos == 5 || minutos == 10 ? minutos : 5;
void _aplicar(ConfiguracionAlarmas config) {
_alarmas = config.alarmas;
_vacaciones = config.vacaciones;
_excepciones = config.excepciones;
}
void _activarRefresco() {
_refresco?.cancel();
_refresco = Timer.periodic(const Duration(minutes: 1), (_) {
refrescarProgramacion();
});
_vigilarAlarmasVencidas();
_vigilancia?.cancel();
_vigilancia = Timer.periodic(const Duration(seconds: 10), (_) {
_vigilarAlarmasVencidas();
});
}
void _vigilarAlarmasVencidas() {
final ahora = DateTime.now();
for (final alarma in _alarmas) {
final proxima = alarma.proximaProgramable;
if (!alarma.activa || proxima == null) continue;
if (proxima.isAfter(ahora)) continue;
final key = '${alarma.id}:${proxima.millisecondsSinceEpoch}';
final retraso = ahora.difference(proxima);
if (retraso > _margenDisparoLocal) {
_ejecucionesEmitidas.add(key);
debugPrint(
'[PluriWave][alarmas] vencida local ignorada por antigua id=${alarma.id} proxima=${proxima.toIso8601String()} retraso=${retraso.inSeconds}s',
);
continue;
}
if (_ejecucionesEmitidas.add(key)) {
debugPrint(
'[PluriWave][alarmas] vencida local id=${alarma.id} proxima=${proxima.toIso8601String()}',
);
_alarmasVencidasController.add(alarma);
}
}
}
@override
void dispose() {
_refresco?.cancel();
_vigilancia?.cancel();
_alarmasVencidasController.close();
super.dispose();
}
}
+68
View File
@@ -0,0 +1,68 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class EstadoIdioma extends ChangeNotifier {
EstadoIdioma({SharedPreferences? sharedPreferences})
: _sharedPreferences = sharedPreferences {
_cargar();
}
static const String _keyLocale = 'idioma_manual_v1';
final SharedPreferences? _sharedPreferences;
Locale? _localeSeleccionado;
Locale? get localeSeleccionado => _localeSeleccionado;
bool get usaSistema => _localeSeleccionado == null;
Future<void> seleccionarSistema() async {
_localeSeleccionado = null;
notifyListeners();
final prefs = await _resolverPrefs();
await prefs.remove(_keyLocale);
}
Future<void> seleccionarLocale(Locale locale) async {
final tag = _serializarLocale(locale);
_localeSeleccionado = locale;
notifyListeners();
final prefs = await _resolverPrefs();
await prefs.setString(_keyLocale, tag);
}
Future<void> _cargar() async {
final prefs = await _resolverPrefs();
final localeGuardado = prefs.getString(_keyLocale);
_localeSeleccionado = _parsearLocale(localeGuardado);
notifyListeners();
}
Future<SharedPreferences> _resolverPrefs() async {
return _sharedPreferences ?? SharedPreferences.getInstance();
}
Locale? _parsearLocale(String? value) {
if (value == null || value.trim().isEmpty) return null;
final partes = value.split('_');
final languageCode = partes.first;
if (languageCode.isEmpty) return null;
final countryCode = partes.length > 1 && partes[1].isNotEmpty
? partes[1]
: null;
return Locale.fromSubtags(
languageCode: languageCode,
countryCode: countryCode,
);
}
String _serializarLocale(Locale locale) {
final countryCode = locale.countryCode;
if (countryCode == null || countryCode.isEmpty) {
return locale.languageCode;
}
return '${locale.languageCode}_$countryCode';
}
}
File diff suppressed because it is too large Load Diff

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