Author SHA1 Message Date
FreeTLab 9efa6d8937 merge: bring the freemium/IAP release line into main
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m13s
main had drifted behind PRO by the whole 1.3.0 line: the freemium/IAP
feature, the code-review fixes, the real AdMob ids, the equalizer
cross-surface resync and the closed-testing ad switch all shipped through
PRO only. This reconciles main so day-to-day work no longer branches from
a stale base.

# Conflicts:
#	pubspec.yaml
2026-08-28 19:59:12 +02:00
FreeTLab 55fe50d07d merge: equalizer cross-surface sync + test ads for the closed-testing phase [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 3m9s
Brings in two independent fixes that both need to reach testers:
- e9f47d4 resyncs EstadoEcualizador with car/notification-initiated changes
  and closes the persistence gap that lost them on restart.
- 2e15d05 forces Google test ad units in release builds while
  usarAnunciosDePruebaEnRelease is true, so no tester can generate invalid
  traffic against the AdMob account during closed testing.

[version set] keeps the 1.3.0 name; CI advances the build number.
2026-08-28 19:56:26 +02:00
FreeTLab 2e15d05431 fix(ads): force test ad units in release during closed testing
Closed-testing human testers cannot be registered as AdMob test
devices, so release builds serving real ad units risked invalid
traffic against an AdMob account that currently earns essentially
nothing. Add usarAnunciosDePruebaEnRelease, a single boolean switch
defaulted to true, that keeps bannerAdUnitId/interstitialAdUnitId on
Google's official test ids even in kReleaseMode. Flipping it to false
is the only change needed to go live. AndroidManifest's AdMob
application id is untouched, as it only initializes the SDK.
2026-08-28 19:49:12 +02:00
FreeTLab e9f47d47c2 fix(eq): resync EstadoEcualizador with car/notification-initiated changes
A toggle from the Android Auto notification or a preset picked from the
car's EQ folder mutated PluriWaveAudioHandler state directly, leaving
EstadoEcualizador (and therefore the phone UI) unaware and never
persisting the change, so it was lost on the next app restart.

Forward the handler's ecualizadorActivo flag through ServicioAudio and,
mirroring EstadoRadio's existing playFromMediaId resync, diff it plus
presetActual against the cached values on every estadoStream tick,
adopting and persisting a divergence via ServicioEcualizador.
2026-08-28 17:48:52 +02:00
ShanaiaBot 080d342de0 chore: bump version to 1.3.0+153 [ci skip] 2026-08-28 15:41:41 +02:00
FreeTLab 689f3e8123 chore(release): rebuild to get a fresh version code [version set]
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 4m4s
Play Console already holds version code 152 (name 1.3.0, uploaded 16 Aug),
so the bundle this pipeline produced was rejected as a duplicate code.

This empty commit re-triggers the build. The bump step advances the code to
153 (the first free one) while [version set] keeps the 1.3.0 version name,
which is the release name this freemium/IAP work is shipping under.
2026-08-28 15:23:13 +02:00
ShanaiaBot 70ee13d540 chore: bump version to 1.3.0+152 [ci skip] 2026-08-16 00:13:12 +02:00
FreeTLab 9cfa5ac17d fix(iap): address code review defects in freemium/IAP change
Build & Deploy PluriWave / Análisis de código (push) Successful in 42s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 4m23s
Fixes 9 of 10 review findings (10th requires a manual Play Console
step, no code change):

1. app.dart/banner_anuncio_superior.dart: move the top SafeArea inside
   BannerAnuncioSuperior so it only reserves status-bar height when an
   ad actually renders, restoring edge-to-edge layout for premium and
   free-unloaded users.
2. servicio_anuncios.dart: bound every interstitial await (load,
   presentation, and the injected implementation itself) with
   injectable timeouts so a callback that never fires can no longer
   hang a caller.
3. estado_entitlement.dart/hoja_premium.dart: expose a typed
   resultadoUsuario signal for purchase/restore failures and
   restore-found-nothing, with dedicated localized messages
   (compraError, restauracionSinCompras) across all 13 locales --
   never the raw developer/exception string.
4. main.dart/servicio_consentimiento.dart: add a GDPR/UMP consent flow
   (ConsentInformation/ConsentForm) that gates Mobile Ads SDK init on
   canRequestAds(); premium users never see a consent form; failures
   degrade to no ads instead of crashing or blocking startup.
6. servicio_anuncios.dart: track real ad presentation
   (onAdShowedFullScreenContent) so a failed-to-show interstitial no
   longer consumes a session cap slot.
7. banner_anuncio_superior.dart: add an explicit load-attempted guard
   so repeated didChangeDependencies (e.g. entitlement notifyListeners
   during a purchase) can only ever trigger one banner load attempt.
8. servicio_anuncios.dart: make esPremium a required constructor
   parameter, matching the hardened contract already applied to
   EstadoAlarmas/EstadoGrabacion/EstadoRadio.
9. hoja_premium.dart: add a dedicated premiumActivo localized string
   instead of reusing the equalizer's equalizerActive translation,
   across all 13 locales.

All fixes implemented RED-first (failing test before production
code). Full suite: 1261 passed, 2 pre-existing skips, 0 failures.
flutter analyze: 5 pre-existing issues only, 0 new.

[version set]
2026-08-12 16:10:46 +02:00
FreeTLab 94f354a7c1 feat(iap): wire real AdMob app id, banner and interstitial units
App id always uses the real value (SDK init only, no ad-serving risk).
Banner/interstitial pick the real unit id in release builds and Google's
test unit id everywhere else, so debug/profile builds can never serve
(or accidentally tap) a real ad.
2026-08-12 12:53:34 +02:00
FreeTLab d81fabbe27 refactor(iap): make esPremium a required constructor parameter
EstadoAlarmas, EstadoGrabacion and EstadoRadio defaulted `esPremium` to
`() => true`, so any construction site that forgot to wire entitlement
compiled fine and silently ran ungated — failing OPEN to premium and
disabling the paywall with no test able to catch it.

The parameter is now required with no default. Production wiring in
app.dart was already correct and is unchanged; the 184 pre-existing test
call sites now pass `() => true` explicitly, which is exactly the old
implicit default, so every assertion is untouched.

EstadoRadio has no gate of its own but constructs EstadoGrabacion, so it
inherits the same contract.

The one test that existed to pin the old default is renamed to describe
what it still covers (the premium path through iniciar() with no
duracion); its assertions are unchanged.
2026-08-10 22:06:36 +02:00
FreeTLab aa0b242374 feat(iap): add freemium unlock via one-time in-app purchase
Adds a permanent, non-consumable premium unlock (EstadoEntitlement +
PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks
alarm vacations, alarms past a 5-alarm free cap, recording start, and
full Android Auto browsing. The phone equalizer stays free for everyone.

- Entitlement is prefs-backed (compra_premium_v1), fail-open, and
  resolvable headlessly via esPremiumPersistido() for the Android Auto
  audio handler, which registers before runApp.
- Android Auto reduced mode keeps the real root folder labels for free
  users; browsing into any of them (and playFromMediaId/playFromSearch/
  skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio
  choke points, with a locked "Función Premium" item as the backstop.
  Current-station play/pause/stop stays untouched. A free -> premium
  transition actively invalidates the head unit's cached browse tree.
- Ads (top banner + capped interstitial before adding a station or an
  alarm) are gated behind entitlement via ServicioAnuncios, using
  official Google test ad unit IDs pending AdMob provisioning.
- Alarm cap UX shows an explanatory message with a secondary unlock
  action rather than a bare paywall jump; existing data is grandfathered.
- 4 new localization keys translated across all 13 supported locales.

Co-located tests use strict TDD (RED test before implementation) for
every new pure-logic unit; full existing suite passes unchanged.
2026-08-10 20:37:07 +02:00
ShanaiaBot 186ff45105 chore: bump version to 1.2.29+151 [ci skip] 2026-08-07 17:18:26 +02:00
FreeTLab f4a1fac45a merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
2026-08-07 17:17:49 +02:00
FreeTLab ea005434d2 merge: local skips stay local, failed stations keep their metadata 2026-08-07 17:17:48 +02:00
FreeTLab d754e28ddf fix(audio): keep local skips local, and stop a failed station blanking Auto
Three reported suspicions. Two confirmed by reading, one not.

1. CONFIRMED, self-inflicted. Playing a song from the phone and pressing
NEXT jumped to a radio station.

3398d02 taught skipToNext/skipToPrevious to fall back to station skipping
when there is no local queue, so the car's buttons would not be dead for
radio. But queue-less does not mean radio: tapping ONE track goes through
reproducirPistaLocal, which never builds a queue -- only folder playback
sets _colaLocal. That is exactly why the report said "at least the first
time".

emisoraActual cannot tell them apart either: _cambiarFuente fills it in for
every source, so a local MP3 arrives as an Emisora whose url is its
content:// document URI. The media id's scheme is the real discriminator,
the same test that already keeps the recorder off local files. A local
track now skips nowhere, which is the correct behaviour for a single item.

2. CONFIRMED mechanism. A failed station made the app disappear from the
Android Auto pane.

The error path published STATE_ERROR and then cleared everything:
`emisoraActual = null; mediaItem.add(null)`. That leaves the session in an
error state with no metadata at all, and Auto drops a session with nothing
to show -- reported as "if a station fails it seems to crash, and going to
1/3 it fails".

Both are kept now. Nothing outside servicio_audio.dart consumes mediaItem
(verified), so the phone is unaffected, and the car gains two things: the
screen can still name the station that failed instead of going blank, and
previous/next stay usable, so a driver can skip out of a dead station
instead of being stranded -- _saltarEmisora needs emisoraActual to know
where it is in the list. The error state itself is unchanged.

3. NOT CONFIRMED. A local track occasionally jumping to another one mid-play.

An advance requires a genuine `completed` from just_audio, so either the
player reports the end early -- plausible for a content:// SAF source,
whose duration is not always exact -- or something else moved the track.
Reading the code cannot separate those, so nothing was changed on a guess.
The advance now logs the decision with the processing state, position and
duration that caused it, so the next occurrence arrives with its reason
attached.

Tests: 1192 -> 1195.
2026-08-07 17:17:48 +02:00
ShanaiaBot 1da417fdf5 chore: bump version to 1.2.28+150 [ci skip] 2026-08-07 13:06:41 +02:00
FreeTLab 950c9fda58 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m41s
2026-08-07 13:06:01 +02:00
FreeTLab 0949525859 merge: ship the onboarding and release-notes content 2026-08-07 13:06:00 +02:00
FreeTLab 0ef6ce35b4 fix(assets): declare the content subdirectories so onboarding ships
Audit of the same failure family as the shrunk drawables: references by
NAME that nothing validates at compile time.

The whole onboarding and release-notes feature had never shipped. Reading
the installed APK: ZERO entries under assets/content/, while
assets/icons/alarmas/* was present. pubspec declared `assets/content/`, and
Flutter does not recurse -- naming a directory includes the files sitting
directly in it, never its subdirectories. Every content file lives in one
(onboarding/, updates/<locale>/), so none of them were packaged.

On the device that surfaced on every single launch:

  Unable to load asset: "assets/content/onboarding/en.md"

with the file plainly present on disk. That is why it never looked like a
packaging problem. The tell was already in the pubspec: assets/icons/alarmas/
is listed explicitly, so the rule was known once and not applied here.

All 14 content directories are now declared: onboarding/ plus updates/ for
each of the 13 locales.

The guard is a test that loads every file under assets/content/ through
rootBundle, because that is the only thing that proves an asset is declared
and will ship. A test asserting File.existsSync would have stayed green
through all of this -- the files were never missing, only unpackaged. Run
against the unfixed pubspec it fails 26 of 27; with the fix it passes.

Tests: 1165 -> 1192.
2026-08-07 13:05:59 +02:00
ShanaiaBot dc62ef6adc chore: bump version to 1.2.27+149 [ci skip] 2026-08-07 12:53:35 +02:00
FreeTLab 28f47d6340 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m39s
2026-08-07 12:52:56 +02:00
FreeTLab ea0c6c8a9c merge: keep Dart-named drawables from the resource shrinker 2026-08-07 12:52:55 +02:00
FreeTLab c3cc4120c0 fix(android): stop the resource shrinker from deleting Dart-named drawables
Root cause found, and it is not the stale build cache I claimed earlier.
flutter clean was good hygiene and changed nothing here, because nothing
was cached: the resources were being deliberately removed.

Flutter's own Gradle plugin enables shrinking on every release build --
FlutterPlugin.kt, `releaseBuildType.isMinifyEnabled = true` and
`isShrinkResources = true` -- no matter what app/build.gradle.kts says. The
shrinker keeps what it can see referenced, and it cannot see
`MediaControl.custom(androidIcon: 'drawable/ic_auto_eq_on')`: that is a
string inside Dart, resolved at runtime through getIdentifier. So both
equalizer icons were stripped from every release APK ever built.

The evidence that pins it, from the APK pulled off the device:

  ic_stat_pluriwave   present   <- referenced as R.drawable from Kotlin,
                                   4 call sites in the alarm notifications
  ic_auto_eq_on       absent    <- named only in a Dart string
  ic_auto_eq_off      absent    <- named only in a Dart string

Same folder, same file shape, same commit range. The only difference is
whether a real R.drawable reference exists, which is exactly what the
shrinker looks for.

The consequence was never a blank button. getResourceId returns 0 for an
unresolvable name, PlaybackStateCompat.CustomAction.Builder throws on a 0
icon, and that throw aborts AudioService.setState before the media session
is activated -- so Android Auto held a frozen, inactive session. Dead
playback screen, play that never became pause, the app losing its pane to
any app with a live session, audio playing "as if it were not the app".
One shrunk file, four symptoms, since 31 July (2540556).

Two protections, because they fail differently:
- res/raw/keep.xml with tools:keep is the official mechanism for
  dynamically resolved resources and is what actually binds the shrinker;
- RecursosResueltosPorNombre.kt gives them genuine R.drawable references,
  the same thing that kept ic_stat_pluriwave alive all along.

station_art_* are kept too. They are reached the same way, through
android.resource:// URIs built in Dart, and survived only by luck.

Tests: 1165, unchanged -- this is a build-configuration fix, and no Dart
test can see it. The CI resource guard is what verifies it now.
2026-08-07 12:52:55 +02:00
ShanaiaBot 7a29026992 chore: bump version to 1.2.26+148 [ci skip] 2026-08-07 12:41:03 +02:00
FreeTLab 9914aced92 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 1m19s
2026-08-07 12:40:26 +02:00
FreeTLab 4cc42af9d1 merge: resource guard inspects the resource table and self-checks 2026-08-07 12:40:25 +02:00
FreeTLab e0fa2d695a fix(ci): inspect the resource table, not zip paths, and self-check first
The previous guard reported "Drawables en el APK: (ninguno)" for a 105MB
release APK. Zero drawables is impossible -- AndroidX alone contributes
dozens -- so the check was wrong, not the build. Release APKs shorten and
rename resource file paths, so `res/drawable/...` simply is not how they
are stored there. The 45MB base.apk taken off the device kept readable
paths because it came from an AAB through bundletool; the CI builds a fat
APK through a different pipeline. Same app, different layout.

Resource NAMES survive in resources.arsc regardless of path shortening, so
that is what gets inspected now.

And the guard checks itself before judging. It looks for a sentinel
resource known to be present (station_art_nova); if the sentinel is not
found, the inspection method is unreliable and the step says so instead of
declaring anything absent. This guard has already lied once, reporting
ic_stat_pluriwave missing when it was verified present, and that lie was
about to send us hunting a build problem that did not exist. A check with
no way to detect its own failure has no business failing a build.

Verified before pushing, all three extracted verbatim from the parsed YAML
and run against real inputs:
  1. real 45MB base.apk    -> sentinel found, ic_stat_pluriwave OK,
                              ic_auto_eq_on/off missing, exit 1
  2. APK absent            -> reports the path and lists what is there,
                              exit 1, no resource accusations
  3. zip without arsc      -> "inspection impossible", exit 1 (checked
                              without a pipe, so the code is the script's)

Scenario 3 is the one the old guard got wrong: it turned an inspection
failure into three false "FALTA" lines.
2026-08-07 12:40:25 +02:00
ShanaiaBot 9d8f426fc8 chore: bump version to 1.2.25+147 [ci skip] 2026-08-07 12:35:43 +02:00
FreeTLab ca5f243524 merge: repair the workflow YAML
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 1m29s
2026-08-07 12:35:02 +02:00
FreeTLab 1e97a94602 fix(ci): repair the workflow YAML broken by an unindented heredoc
The previous commit made build.yml unparseable and no job ran at all:

  yaml: line 183: could not find expected ':'

A `run: |` block is a YAML literal scalar, so every line has to keep the
block's indentation. The python3 fallback I added used a heredoc whose
body sat at column 0, which terminates the scalar -- YAML then tried to
read `import zipfile, sys` as a mapping and gave up. Worse than a broken
check: a broken pipeline.

The fallback is gone rather than re-indented. unzip is present on this
runner, a second code path existed only to guard against a case that was
never observed, and its only contribution was an escaping hazard inside
YAML inside shell.

Verified before pushing this time, which is the actual lesson:
- build.yml now parses (yaml.safe_load), 6 + 15 steps;
- the guard's `run` script was extracted from the parsed YAML and executed
  verbatim against the real 45MB base.apk pulled off the device. It prints
  the drawable inventory, reports ic_stat_pluriwave OK and ic_auto_eq_on /
  ic_auto_eq_off missing, and exits 1 -- matching an independent zipfile
  inspection of the same file.

Two commits in a row shipped a CI change that had never been run. Both
were caught by the user rather than by me.
2026-08-07 12:35:02 +02:00
FreeTLab 107739caa3 merge: incorporate the CI version bump 2026-08-07 12:27:41 +02:00
FreeTLab b3bd71be84 merge: make the APK resource guard trustworthy 2026-08-07 12:27:40 +02:00
FreeTLab 0a47c327f1 fix(ci): stop the resource guard from lying when it cannot inspect the APK
The guard added in the previous commit reported all three drawables as
missing on its first run, including ic_stat_pluriwave -- which is
verifiably present: it was read out of the base.apk pulled off the device
byte by byte. The step also finished in 0s, so it never opened the file at
all. Either the APK is not at the assumed path on this runner or unzip is
unavailable, and the failing pipeline silently produced an empty listing
that every grep then "failed" against.

A guard that lies is worse than no guard: it sends you hunting ghosts,
which is exactly the failure mode this whole episode has been about.

It now verifies its own preconditions before judging anything:
- the APK must exist, and if it does not the step prints where the APKs
  actually are (find over build/app/outputs) instead of guessing;
- it needs unzip or python3, and says so plainly if neither is there;
- an empty listing is treated as "inspection unreliable", not as
  "everything is missing";
- it dumps the real res/drawable inventory before the verdict, so a
  future failure is readable without another round trip.

Matching is now exact (grep -qx) rather than substring.

The logic was run locally against the real 45MB base.apk taken off the
device: ic_stat_pluriwave OK, ic_auto_eq_on and ic_auto_eq_off missing --
which is precisely what an independent zipfile inspection of the same APK
reported yesterday. The check agrees with reality before shipping.
2026-08-07 12:27:40 +02:00
ShanaiaBot 53126bdbe7 chore: bump version to 1.2.24+146 [ci skip] 2026-08-07 11:39:06 +02:00
FreeTLab ec6ccb2db8 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Failing after 2m0s
2026-08-07 11:38:27 +02:00
FreeTLab 8e00dc0c7c merge: resource guard in CI and group-aware station skipping 2026-08-07 11:38:27 +02:00
FreeTLab f01c0911f7 fix(auto): guard shipped resources, walk favourite groups when skipping
History review requested by the owner: when and why did the Android Auto
UI stop working.

ANSWER: 31 July, commit 2540556, "give the equalizer actions distinct,
state-aware icons".

  9eff760 (31-07)  androidIcon: 'drawable/ic_stat_pluriwave'  -> in the APK
  2540556 (31-07)  androidIcon: 'drawable/ic_auto_eq_on'      -> NEVER in it

That commit swapped a drawable that shipped for two that the stale CI
resource cache never included. From that moment getResourceId returned 0,
PlaybackStateCompat.CustomAction.Builder threw, and the throw aborted
AudioService.setState before the session was published -- so every Android
Auto symptom chased since is one line of that commit. The bitter part is
that 2540556 was itself a fix for a report about two identical icons.

Three changes.

1. CI guard. The build now unzips the release APK and fails if a drawable
resolved by NAME at runtime is missing. Resolution by name cannot fail at
compile time -- it fails in the car, silently, with id 0. This class of
bug shipped undetected for a week; it cannot ship again.

2. Skipping stations now walks the favourites GROUP first, as requested:
group -> all favourites -> my stations -> catalogue. Two deliberate
exclusions, both tested: `sinAsignarId` is the ABSENCE of a group, not a
group, so those walk all favourites; and a one-member group falls through
too, or both buttons would be dead ends. The group is read from the
FAVOURITE record, never from the playing station -- that one is rebuilt by
emisoraDesdeMediaItem, which carries no group id and would always report
"unfiled".

3. Diagnostics on the station skip. It was reported as doing nothing for
radio, and every early return in that method is silent: an empty list and
a single-entry list look identical from outside. The log now names which
one fired, so the next capture answers it instead of another hypothesis.

Tests: 1161 -> 1165.
2026-08-07 11:38:27 +02:00
ShanaiaBot 62f7804d6d chore: bump version to 1.2.23+145 [ci skip] 2026-08-07 00:25:00 +02:00
FreeTLab 57f89c130f merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m39s
2026-08-07 00:24:18 +02:00
FreeTLab 02cfd48992 merge: clean the CI build so new Android resources ship 2026-08-07 00:24:18 +02:00
FreeTLab 72a291d0c0 fix(ci): clean before building so new Android resources reach the APK
The equalizer drawables were never in the shipped binary. Verified by
pulling base.apk off the device and reading it:

  res/drawable/ic_stat_pluriwave.xml   PRESENT  (added 02-07)
  res/drawable/ic_auto_eq_on.xml       ABSENT   (added 31-07, 2540556)
  res/drawable/ic_auto_eq_off.xml      ABSENT

Neither as a zip entry nor as a name in resources.arsc. Both files are in
git with content and on disk; the older sibling in the same folder is in
the APK. The difference is when they were added.

This runner is self-hosted and the workflow never cleaned, so build/
survives between runs and Gradle's incremental resource merge went stale:
resources present when the cache was built kept working, resources added
afterwards silently never made it in.

The cost was weeks of wrong diagnosis. getResourceId returned 0 for that
icon, PlaybackStateCompat.CustomAction.Builder throws on a 0 icon, and
that throw aborts AudioService.setState BEFORE mediaSession.setActive --
so Android Auto held a frozen, inactive session. On the device that
surfaced as a dead playback screen, a play button that never became
pause, PluriWave losing its pane to whichever app did have an active
session, and audio that played "as if it were not the app". One cause,
four symptoms.

Dart changes always shipped because Dart is recompiled every build, which
is exactly why this hid for so long: every fix appeared to land and
nothing behaved differently.

flutter clean costs build time. It buys the guarantee that what is in git
is what is in the binary, which this project just spent weeks not having.
2026-08-07 00:24:18 +02:00
ShanaiaBot adb2a1d1bc chore: bump version to 1.2.22+144 [ci skip] 2026-08-07 00:19:27 +02:00
FreeTLab 346cd2b6b9 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m37s
2026-08-07 00:18:49 +02:00
FreeTLab e337f6166c merge: reject a local track as a recording source 2026-08-07 00:18:48 +02:00
FreeTLab 54d87190fe fix(grabacion): reject a local track as a recording source
Reported, with this on screen:

  No se pudo iniciar la grabación: Invalid argument(s): Unsupported scheme
  'content' in URI content://com.android.externalstorage.documents/tree/
  primary%3AMusic/document/primary%3AMusic%2F...%2FNew Limit - Smile.mp3

The URI in that message is a local MP3, not a station.
PluriWaveAudioHandler._cambiarFuente sets `emisoraActual` for EVERY source
it plays, so a local track surfaces as an Emisora whose `url` is the SAF
content:// document URI it was opened from. EstadoGrabacion.iniciar only
checked for null, handed that straight to the recorder, and the HTTP
client failed with a message no user can act on.

"It used to work" is exactly right: before local music playback existed,
whatever was playing was always a real station, so the case could not
arise. The recorder never changed.

iniciar() now also requires a real network stream (esEmisoraGrabable) and
falls back to the existing "select a station first" message, which is the
correct guidance here -- recording a local file makes no sense anyway,
it is already on the device. No new l10n key, so no 13-locale churn for a
message that already says the right thing.

Tests: 1158 -> 1161.
2026-08-07 00:18:48 +02:00
ShanaiaBot 968377f1c7 chore: bump version to 1.2.21+143 [ci skip] 2026-08-06 21:48:59 +02:00
FreeTLab e3638ea4a7 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 29s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m38s
2026-08-06 21:48:21 +02:00
FreeTLab a93e5b192b merge: make audio diagnostics visible in release builds 2026-08-06 21:48:20 +02:00
FreeTLab 1d5332453f fix(audio): make the audio diagnostics visible in release builds
Every diagnostic line in the audio path used `dart:developer`'s `log()`.
That function writes to the VM service, which a RELEASE build does not
have — so in the only build that ever runs in a car, all eleven of them
went nowhere. `debugPrint`/`print` do reach logcat in release; `log()`
does not.

That includes the two channels built specifically to end the guessing:
- `registrarErrorAudioService`, which subscribes to
  `AudioService.asyncError` so the plugin's swallowed platform exceptions
  stop vanishing (b0271fa). It moved them from a dropped PublishSubject
  to a dropped log call.
- `_trazarEstadoPublicado`, the published-state trace added in 7054a4c to
  settle why the car's play button never becomes pause.

So "no evidence" was never a quiet app. It was an app writing its
evidence somewhere release builds discard. Several rounds of hypotheses
were argued without data that the app was already producing.

All eleven now use `debugPrint` with a `[PluriWave][Tag]` prefix, so one
filter catches the audio path and the existing alarm lines together:

  adb logcat | grep PluriWave

No behaviour changes. Tests: 1158, unchanged.
2026-08-06 21:48:20 +02:00
ShanaiaBot 42bb2b4a54 chore: bump version to 1.2.20+142 [ci skip] 2026-08-06 19:50:46 +02:00
FreeTLab 490ae29bd4 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 27s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m47s
2026-08-06 19:50:07 +02:00
FreeTLab 7d86ddfae9 merge: duck instead of pause, keep the service foreground, restore local music 2026-08-06 19:50:07 +02:00
FreeTLab 3398d02a43 fix(auto): keep the service alive through interruptions, restore local music
Four car reports, two root causes.

1. Local music vanished from the Android Auto menu. Self-inflicted, by
c1afe72 yesterday.

That commit moved registrarFuenteNavegacion above every await to keep a
headless engine from dying before it ran -- but left
registrarFuenteMusicaLocal below `await SharedPreferences.getInstance()`.
The root menu decides whether to offer "Música Local" with
`fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`, so
the car could now get a root response in the window between the two
registrations, find a null source, and be told there is no local music.
Android Auto caches the browse root, so it stayed missing for the whole
session. Before the reorder both registrations sat together after the
await and the window did not exist.

FuenteMusicaLocalAutoImpl never needed prefs to be CONSTRUCTED -- it
resolves them lazily per call, the same convention ServicioAlarmas uses
-- so it now registers beside the station source, above every await, and
the window is gone rather than narrowed.

2. PluriWave disappeared from the Auto pane mid-drive, the playback
screen sat frozen, and the equalizer was lost on every navigation
prompt. One cause for all three.

androidWillPauseWhenDucked: true made audio_session translate Android's
AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK into a full PAUSE. In a car that
fires constantly: every navigation instruction, every speed-camera
warning, every voice assistant. And a pause publishes playing:false,
which AudioService.setState turns into exitPlayingState() and, with
androidStopForegroundOnPause: true, into stopForeground(...). The
plugin's own doc for that flag says what follows: "while in this lower
priority state, the operating system will also be able to kill your
service at any time to reclaim resources". A killed service is a media
session that vanishes from the car pane -- and another media app takes
the slot.

Now the app ducks instead of pausing, so playing stays true and session,
notification and pane all survive an interruption; and the service stays
foreground even on a real pause, so a genuine one is not a death
sentence either. androidNotificationOngoing goes to false because the
plugin asserts it implies stopForegroundOnPause, and nothing is lost: a
foreground service already forces the notification to be ongoing.

A real, non-duckable focus loss (a phone call) still pauses and still
auto-resumes -- asserted, so the duck change cannot silently turn a call
into a station playing over it.

3. Previous/next on the car playback screen, for stations too.

skipToPrevious/skipToNext are now advertised unconditionally, since
Android Auto only draws those buttons when the app declares support.
They are no longer inert without a local queue: they walk the narrowest
list the current station belongs to -- favourites, then my stations,
then the catalogue -- wrapping at both ends, because a button that goes
dead at the end of a list reads as broken on a screen with no visible
list position. Matching is by uuid so a refreshed snapshot still
resolves, and a station in no list leaves playback untouched.

The equalizer toggle still fits alongside them: prev/next take their two
reserved slots and the equalizer claims the remaining custom-action room
because construirControlesTransporte places it before MediaControl.stop.

The phone notification is deliberately untouched: `controls` still gates
skip on an active queue, so nativeActions and
androidCompactActionIndices are byte-identical. Only systemActions
changed, and only the car reads those.

Tests: 1146 -> 1158.
2026-08-06 19:49:57 +02:00
ShanaiaBot 2480c57bfc chore: bump version to 1.2.19+141 [ci skip] 2026-08-06 17:19:36 +02:00
FreeTLab 1d8c9c57bc merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 29s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m31s
2026-08-06 17:18:54 +02:00
FreeTLab c1afe72aec fix(arranque): stop a headless engine from dying before runApp
Reported: with Android Auto connected, the car screen sometimes came up
completely BLACK, and opening the app on the phone then showed a
completely WHITE screen until the app was force-killed and reopened.
Never without Android Auto.

The user guessed portrait-only plus a landscape phone made the app "go a
bit crazy". Right file and right trigger, different mechanism -- a broken
layout renders overflow stripes or a red error box, never white. White
means nothing was ever built, so runApp had not run.

Verified in the plugin source: AudioServiceActivity.provideFlutterEngine
returns AudioServicePlugin.getFlutterEngine(context), which CREATES the
engine and executes the Dart entrypoint the first time it is asked. When
the car binds the MediaBrowserService before the app is opened, that
first ask is the service -- so main() runs HEADLESS, with no Activity.

SystemChrome.setPreferredOrientations travels the flutter/platform
channel, whose handler (PlatformPlugin) is installed by the Activity.
Headless there is nobody to answer it, so the call throws
MissingPluginException or never settles. It was the FIRST await in
main(), which made it fatal twice over: registrarFuenteNavegacion sits
below it and never ran, leaving getChildren with no source (black car
screen), and runApp was never reached. Opening the app then reused that
same cached, already-dead engine -- white screen. Only a force-kill,
which disposes the cached engine, recovered it. That is exactly the
workaround that was reported, and it is what makes the diagnosis fit
every detail rather than most of them.

Three changes, smallest first:
- The Android Auto browse registration moves above every await. It
  depends on nothing, and anything before it is a place to get stuck.
- The orientation call is no longer awaited. It is a display preference,
  never a prerequisite for runApp, and _OrientacionResponsiveApp already
  re-applies it in didChangeDependencies -- the only moment it can take
  effect anyway.
- aplicarPoliticaOrientacion swallows everything and logs, so the
  headless failure can never propagate again.

The policy itself is unchanged and now pure and tested
(orientacionesPara): phones portrait, >=600dp everything.

Tests: 1141 -> 1146.
2026-08-06 17:18:53 +02:00
ShanaiaBot 809b4c6eb4 chore: bump version to 1.2.18+140 [ci skip] 2026-08-06 01:29:21 +02:00
FreeTLab 14985f6417 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m48s
2026-08-06 01:28:43 +02:00
FreeTLab a6cdf0e72c fix(auto): advertise the transport actions Android for Cars requires
Reported: on the Android Auto playback screen the play/pause button stays
on PLAY while audio is audibly playing, and "it used to work, in the
latest versions it doesn't".

Previous rounds looked for a regression in this repo's audio commits and
found none: every playbackState.add site publishes playing:true with a
ready processingState, and AudioService.getPlaybackState maps that to
STATE_PLAYING. That search was aimed at the wrong thing.

The Android for Cars guide ("Enable playback control") is explicit:
"Android Auto and AAOS display playback controls based on the actions
that are enabled in the PlaybackStateCompat object. By default, your app
must support the following actions: ACTION_PLAY, ACTION_PAUSE,
ACTION_STOP, ACTION_PLAY_FROM_MEDIA_ID, ACTION_PLAY_FROM_SEARCH."

systemActions has carried only `seek` + `stop` since e9d1f67, the first
commit of the project -- git log -S confirms it was never once edited. So
the required actions have never been advertised, and no audio commit can
explain a change in behaviour. Android Auto ships as its own app and
updates itself, which is how a working screen breaks with a clean repo
history. That fits the report better than any commit here does.

The phone notification was never affected: it builds its play/pause
button from `controls`, not from these bits, which is exactly why the
symptom is car-only.

Skip actions stay conditional on an active queue on purpose -- the same
guide notes Auto reserves the prev/next slots for them and gives the
space to custom actions when the app does not support them, and that is
the space the equalizer toggle needs.

ACTION_PLAY_FROM_SEARCH is now implemented rather than merely claimed:
advertising it unimplemented would have the car's assistant accept "play
Radio X" and silently do nothing. emisoraParaBusqueda ranks exact name,
then prefix, then substring, then country, accent- and case-insensitive
because voice transcription rarely gets diacritics right; favourites are
searched first so they win a name tie, and a miss plays nothing rather
than something arbitrary.

Still a hypothesis for the play/pause symptom, not a confirmed fix -- it
is documentation-backed and cheap, but only a head unit can confirm it.

Tests: 1132 -> 1141.
2026-08-06 01:28:42 +02:00
ShanaiaBot 924a5cab21 chore: bump version to 1.2.17+139 [ci skip] 2026-08-05 23:08:06 +02:00
FreeTLab 1e7c0daa90 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 30s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
2026-08-05 23:07:20 +02:00
FreeTLab bca0a9bbb7 merge: dart-side snooze anchor guard, Auto progress bar, drop EQ folder 2026-08-05 23:07:19 +02:00
FreeTLab 80538900db fix(alarmas,auto): guard the last unguarded snooze path, surface car progress
Continuation of 7054a4c: the native anchor guard alone did not fix the
reported ~1444-minute snooze, because Dart runs AFTERWARDS on the
pre-notice path and had no guard at all.

1. Snooze from the pre-notice notification, root cause.

app.dart dispatches AFTER the receiver's postponeNext already ran and
after startActivity, and EstadoAlarmas.posponerProximaDesdePreaviso took
whatever occurrence it was handed on faith, then persisted and
rescheduled from it -- the last snooze path in the codebase with no
occurrence guard. The occurrence itself is not trustworthy either:
app.dart falls back to alarma.proximaEjecucion when the native event
carries none, and that field can already point at tomorrow.

_ocurrenciaSonando is generalized into _ocurrenciaValida with a caller-
supplied forward allowance and an externally-proposed occurrence that
still has to survive the same check. The pre-notice path gets a
ventanaPreaviso (30 min, matching AlarmScheduler.PRE_NOTICE_MILLIS) --
unlike the ringing-screen guard, this occurrence legitimately has not
happened yet, which is exactly why the existing helper could not just be
reused here.

Also heals state already poisoned by the missing guard: a snoozeHasta
parked past a 3-hour ceiling (posponerEjecucion clamps to 120 minutes,
so anything beyond that is corruption, not a long real snooze) is
dropped on recalculation. Without it, an alarm poisoned on a build
before this fix keeps reporting tomorrow after updating, and the user
reasonably concludes nothing changed.

2. Android Auto: no progress bar or time labels on a local track.

updatePosition was never set anywhere in the handler, so it sat at its
Duration.zero default while copyWith refreshed updateTime to now on every
push -- the car was told "position 0, as of right now" on every event, a
bar pinned at the start regardless of what was actually playing. Now set
from _player.position on both the player-state and buffered-position
listeners (the latter ticks ~2/s, which is what keeps the car's bar
smooth between player-state events). Also stream the MediaItem's
duration once the source reports it -- Auto draws no bar at all without
one, and radio streams correctly keep reporting none (live audio has no
length).

3. Android Auto: drop the Ecualizador browsable folder.

Owner decision after driving with it: a browsable six-preset list is
more interaction than a driver wants, and on/off from all three player
views (already fixed in 7054a4c to win the custom-action slot) is the
only equalizer control that belongs in the car. Preset selection stays on
the phone. This lands back on the redesign mockup's original rule ("sin
carpeta de ecualizador"), now for a road-tested reason. getChildren keeps
answering the folder's id transitionally, since a head unit can have the
old tree cached for a session or two.

The two "raiz always includes/ends with Ecualizador" tests are replaced,
not regressed -- same move the codebase already made once in the other
direction for the same folder.

Tests: 1127 -> 1132.
2026-08-05 23:07:10 +02:00
ShanaiaBot 61c78b5497 chore: bump version to 1.2.16+138 [ci skip] 2026-08-05 11:56:12 +02:00
ShanaiaBot 41d637b890 chore: bump version to 1.2.15+137 [ci skip] 2026-08-05 10:18:35 +02:00
FreeTLab 2bafc7e5ac merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m44s
2026-08-05 10:17:50 +02:00
FreeTLab c4a39ee8ed merge: native snooze anchor guard and Android Auto EQ slot 2026-08-05 10:17:48 +02:00
FreeTLab 7054a4c871 fix(alarmas,auto): guard the native snooze anchor, surface the EQ in the car
Three reported issues, two fixed and one instrumented.

1. Posponer left the alarm snoozed ~1444 minutes (24h04m).

Traced end to end in Kotlin. onAlarmFired runs from the receiver BEFORE the
ringing notification exists, and persists snoozeOriginMillis = null plus a
triggerAtMillis already advanced to TOMORROW by computeNextTriggerMillis.
snooze() then anchored on `spec.snoozeOriginMillis ?: spec.triggerAtMillis`
and picked up tomorrow. The existing clamp could not catch it: it only
rescues anchors in the PAST, so an anchor +24h out sails through. The
countdown text is honest -- ceilMinutes(snoozeUntil - now) over Dart's own
template -- the corrupt value is snoozeUntil. With N=5 and a tap at T+1min
the arithmetic lands on 1444 exactly.

This is the defect a9da855 fixed on the Dart side with
EstadoAlarmas._ocurrenciaSonando, after 9c7cf4e had fixed only one of two
adjacent callers. The native lane never got that guard. Now it has
anchorOccurrenceMillis, with a per-surface forward allowance: none for
snooze() (the ringing notification closes an occurrence that has arrived)
and a full PRE_NOTICE_MILLIS for postponeNext() (the pre-notice
notification's occurrence legitimately has not).

No Kotlin test source set exists in this project, so CI cannot verify this
and no Dart test sees it (all use FakePuertoAlarmasAndroid). Verified by
reading; needs an on-device pass.

2. The equalizer toggle stayed invisible on the Android Auto playback
screen even on v1.2.14+136, which does contain it.

On Android 13+ createCustomAction (AudioService.java:466-469) turns
MediaControl.stop into a custom action too, so the car receives TWO in list
order and stop was first -- a head unit exposing one custom-action slot
shows that and buries the rest in an overflow menu. The equalizer now
precedes stop and wins the slot; it is the better occupant, since the car
has its own path to stop playback while the equalizer is reachable no other
way from that screen.

No platform detection needed, and the phone notification is untouched on
every API level: nativeActions comes out [prev?, play/pause, stop, next?]
below 13 and [prev?, play/pause, next?] on 13+, exactly as before. Both are
now asserted.

The list also moves to a public construirControlesTransporte. The guard
test used to re-declare its own copy of the shape, so it stayed green while
asserting a list lib/ no longer produced. It calls the real builder now.

3. Android Auto shows PLAY while a station is audibly playing: NOT fixed,
deliberately.

The car takes that icon from PlaybackStateCompat.getState()
(AudioService.java:601-611), not from controls -- so none of the recent
controls work can be the cause. All eight playbackState.add sites were
audited and none publishes playing:false while audio runs, which leaves no
traced input to fix. A proposed resync off bufferedPositionStream was
rejected: it can publish a spurious idle, which AudioService.java:565-567
turns into stop() and tears down the foreground service -- the exact
regression abc6b47 fixed, on the highest-frequency listener in the handler.

Added instead a change-gated trace of the state actually published, with
eqDisponible alongside it (that flag gates the equalizer action and is
otherwise unobservable). One car session with `adb logcat -s ServicioAudio`
settles both this and issue 2.

Tests: 1124 -> 1127.
2026-08-05 10:17:41 +02:00
ShanaiaBot ddb15623a0 chore: bump version to 1.2.14+136 [ci skip] 2026-08-03 22:05:22 +02:00
FreeTLab 3a4dc3b4b1 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m37s
2026-08-03 22:04:45 +02:00
FreeTLab 04300592e0 fix(alarmas): heal alarms already poisoned by the old Detener anchor
The anchor fix stops NEW damage, but devices that ran the buggy build
still carry a future occurrence in ultimaEjecucionGestionada in
SharedPreferences. _esValida rejects any candidate matching it, so the
affected alarm would keep skipping that day with nothing in the UI to
explain it -- which reads as "still broken" rather than "fixed".

_recalcular now drops an ultimaEjecucionGestionada that is meaningfully
in the future. An occurrence cannot have been handled before it happens,
so such a value is corrupt by definition, and dropping it can only ever
restore a real future ring: the double-fire guard it also feeds needs a
PAST occurrence to do its job, and those are untouched.

Placed in the recalculation that every load and every mutation already
funnels through, so an affected alarm heals on the next app open with no
user action -- no delete-and-recreate.

Tests: 1122 -> 1124, including one proving a genuine past occurrence is
still preserved.
2026-08-03 22:04:37 +02:00
ShanaiaBot cb76f09257 chore: bump version to 1.2.13+135 [ci skip] 2026-08-03 22:00:09 +02:00
FreeTLab df9252a621 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m45s
2026-08-03 21:59:30 +02:00
FreeTLab ae3f96c0d1 merge: fix Detener consuming a future alarm occurrence 2026-08-03 21:59:30 +02:00
FreeTLab a9da855601 fix(alarmas): stop Detener from consuming an occurrence that never rang
Reported: an alarm set for Monday 16:20 never rang, and the "next alarm"
banner showed a different alarm (the next morning's) instead. No
vacation range involved, both alarms active.

finalizarEjecucion anchored the completed occurrence to proximaEjecucion
with no check that it was the one actually ringing. On the native-fire
path the fire-time sync advances proximaEjecucion to the NEXT occurrence
before the user can reach the ring screen, so tapping Detener recorded a
FUTURE occurrence in ultimaEjecucionGestionada.
ServicioProgramacionAlarmas._esValida then rejects that occurrence for
real: a Monday-only alarm stopped today simply never rings next Monday,
and every sibling outranks it in the banner because its own
proximaEjecucion is a week out.

Reproduced at its purest in the second test: with nothing ringing at
09:01 on Monday, Detener pushed a 16:20 alarm to the FOLLOWING Monday.

posponerAlarma already had exactly this guard -- 9c7cf4e, "anchor snooze
to the ringing occurrence, never a future one", written after the same
failure showed up as a snooze armed a day out. It was applied to the
snooze path and never to the stop path, which sat ten lines below it in
the same file with the identical hazard.

Both paths now share one _ocurrenciaSonando helper so they cannot drift
apart again, and the reason lives in its doc comment rather than in a
comment on one of the two callers.

Also drops snoozeHasta from the stop path's candidate chain: a pending
snooze target is in the future by definition, and snoozeOrigen already
covers a ring that follows a snooze.

Tests: 1120 -> 1122.
2026-08-03 21:59:19 +02:00
ShanaiaBot 1c2b8e0e15 chore: bump version to 1.2.12+134 [ci skip] 2026-08-03 21:34:48 +02:00
FreeTLab 34d04bbc1e merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 31s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m24s
2026-08-03 21:34:09 +02:00
FreeTLab e962b8ac58 merge: restore the Android Auto equalizer toggle and add user presets 2026-08-03 21:33:55 +02:00
FreeTLab f2f706b342 fix(auto): restore the equalizer toggle and list the user's own presets
Two Android Auto regressions reported from the car.

1. The on/off equalizer action disappeared from the playback screen.

That was self-inflicted: commit cacd3ec removed it on the theory that a
custom action in `controls` aborts `AudioService.setState` and kills the
media notification. Reading the plugin source refutes it. setState
(AudioService.java:513-520) SPLITS the list -- a control carrying a
customAction goes to `customActions` (PlaybackStateCompat, i.e. the car),
everything else becomes a NotificationCompat.Action in `nativeActions`
(the phone notification). The two never mix. And the throw the theory
depended on cannot happen here: ic_auto_eq_on/ic_auto_eq_off both exist
under res/drawable, and the labels are non-empty in all 13 locales.

The notification outage was already fixed by abc6b47 (transient idle on
a source change, which setState turns into a full stop() at :557).

The action is back, with both state-aware icons. The real invariant --
a custom action's icon must resolve and its label must be non-empty --
is now a test that reads res/drawable and fails on a missing file,
instead of a comment claiming custom actions are forbidden outright.

2. The Ecualizador folder never listed the user's saved presets.

itemsEcualizadorAuto iterated PresetEcualizador.presets, so only the six
factory presets appeared -- the user's own were unreachable from the
car, the surface where a preset picker matters most. They now arrive
through a registered read function (same seam as stations and local
music, re-read per browse so a preset saved on the phone shows up
without an app restart).

presetsEcualizadorAuto is the single source of truth for the ordered
universe, used to BUILD the items and to RESOLVE a tap, so the folder
cannot show an item that resolution then refuses -- which is what the
factory-only default in seleccionarPresetEqPorMediaId would have caused.
A custom preset whose name collides with a factory one is dropped: the
media id is the raw name, so it could only ever resolve to the factory
entry, and an item that applies a preset other than the one it names is
worse than an absent one.

Tests: 1108 -> 1120.
2026-08-03 21:32:09 +02:00
ShanaiaBot 9581ec97d7 chore: bump version to 1.2.11+133 [ci skip] 2026-08-01 20:37:43 +02:00
FreeTLab d3999a20fb fix(audio): restore the media notification by keeping custom actions out of controls
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s
Reported: the media notification vanished entirely -- no shade widget, no
lock-screen controls, not even the small icon beside the clock -- while
audio kept playing and nothing was logged. Working three days earlier.

The equalizer toggle added on 30-31 July was appended to the transport
controls list. That list feeds BOTH the phone notification and the car
playback screen, and AudioService.setState walks every control through
createCustomAction (AudioService.java:513-520) BEFORE it reaches
mediaSession.setPlaybackState (:552) and enterPlayingState (:559), which
is the only place the notification is ever posted.

createCustomAction resolves the icon by name through getIdentifier
(:415-420) -- 0 on a miss -- and hands it to
PlaybackStateCompat.CustomAction.Builder, which throws on a 0 icon or an
empty label. A throw there aborts setState before the session is ever
published. ExoPlayer is independent, so audio continues; and until
asyncError got its first subscriber the exception was dropped silently.
That accounts for every detail of the report.

The car keeps its equalizer: the Ecualizador browse folder already lists
Desactivar plus every preset by name.

Tests: 1103 -> 1108.
2026-08-01 20:33:51 +02:00
FreeTLab f2d7e98813 merge: incorporate the CI version bump 2026-08-01 20:33:51 +02:00
FreeTLab cacd3ece57 fix(audio): keep custom actions out of the media notification controls
The equalizer toggle appended to the transport controls was aborting the
whole notification. controls feeds BOTH the phone notification and the
car playback screen, and AudioService.setState walks every control
through createCustomAction (AudioService.java:513-520) BEFORE reaching
mediaSession.setPlaybackState (:552) and enterPlayingState (:559) -- the
only place the notification is ever posted.

createCustomAction resolves the icon by name via getIdentifier (:415-420),
which returns 0 on a miss, and passes it to
PlaybackStateCompat.CustomAction.Builder, which throws on a 0 icon or an
empty label. That throw aborts setState, so the media session is never
published: no shade widget, no lock-screen controls, not even the small
status-bar icon. ExoPlayer runs independently so audio keeps playing, and
until asyncError got a subscriber the exception was dropped silently.

Nothing is lost in the car: the Ecualizador browse folder already lists
Desactivar plus every preset by name, which is Auto's own idiom for
choosing among options.
2026-08-01 20:33:34 +02:00
ShanaiaBot eac4528141 chore: bump version to 1.2.10+132 [ci skip] 2026-08-01 19:28:38 +02:00
FreeTLab abc6b47ffb fix(audio): stop tearing down the media notification on every station change
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m16s
The app was cancelling its own foreground service each time the source
changed. _recrearPlayer builds a fresh AudioPlayer, which emits idle
first; audio_service treats any non-idle to idle transition as a stop
and cancels the notification. Recovery then depends on
startForegroundService, which throws on API 31+ when the process is not
foreground -- screen off, lock screen, or an Android Auto start.

- Suppress the transient idle only while a source change is in flight,
  via a pure mapearEstadoProceso seam so both directions are unit-tested
- Publish idle explicitly from stop(): just_audio's playerStateStream is
  .distinct() over a value-equal PlayerState, so stopping an
  already-idle player emits nothing, which would have left the state
  stuck at loading and the notification unkillable
- Subscribe to AudioService.asyncError, which had zero listeners and was
  silently swallowing the exception that identifies this class of failure

This removes a real self-inflicted teardown on every API level. It does
NOT prove the reported symptom is fixed: the audio path is byte-identical
across the releases where the symptom appeared, so the trigger is
environmental and still unidentified.

Tests: 1084 -> 1103.
2026-08-01 19:24:48 +02:00
FreeTLab dca19cd1ab merge: incorporate the CI version bump 2026-08-01 19:20:24 +02:00
FreeTLab 1b126d5147 fix(audio): publish idle from stop() instead of trusting the player
just_audio's playerStateStream is .distinct() over a value-equal
PlayerState, so stopping an already-idle player emits nothing. Paired
with the source-change mask -- which writes loading into playbackState
rather than filtering at read time -- a stop landing before native init
completed would leave the state at loading forever.

audio_service only tears the foreground service down on a non-idle to
idle transition, so that window produced an unkillable notification
stuck on "cargando" with a dead Stop button: strictly worse than the
teardown this branch removes.

Additive and idempotent -- when the player does emit its own idle, this
just lands first.
2026-08-01 19:19:30 +02:00
FreeTLab 6da3e69f7e fix(audio): stop emitting a transient idle during a source change
Root cause of the disappearing media notification, and it is self-inflicted
on EVERY Android version — no plugin patch involved.

`audio_service`'s `_observePlaybackState` (audio_service.dart:1131-1136) calls
`AudioService._stop()` on ANY transition into `idle` from a non-idle state.
That reaches `stopService()` -> `deactivateMediaSession()` ->
`notificationManager.cancel(NOTIFICATION_ID)`. The notification is re-posted
at exactly one place, `internalStartForeground()`, reachable only from the
`!wasPlaying && playing` edge in `setState()`, and its FIRST statement is
`ContextCompat.startForegroundService(...)` — which on API 31+ throws
`ForegroundServiceStartNotAllowedException` whenever the process is not in a
foreground state.

Every station change walked straight into that. `_cambiarFuente` pushes
`loading`, then `_recrearPlayer` disposes the old `AudioPlayer` and builds a
FRESH one; a fresh player's first `playerStateStream` event is always `idle`,
and the listener forwarded it verbatim. So `loading -> idle` tore the
foreground service down mid-source-change, and recovery depended on the
following `playing: true` edge restarting it. Screen off, lock screen, or an
Android Auto / Bluetooth-initiated start is precisely where the platform
refuses that restart: audio keeps playing, the notification never returns.
That is exactly what the user reports.

The mapping decision moves out of the private `_mapProcState` into a pure
top-level `mapearEstadoProceso(proc, {required bool cambiandoFuente})`, so the
one line that decides whether the foreground service dies is unit-testable
without instantiating the handler (which needs MethodChannels). It is
byte-for-byte identical to the old switch in every case except `idle` while a
source change is in flight, which now maps to `loading`. The test asserts the
full ProcessingState x cambiandoFuente matrix against a literal transcription
of the previous mapping, and asserts both directions explicitly: a real stop
still yields `idle`, a source-change idle yields `loading`, and `idle` is the
only case where the two branches differ at all.

The only risk this introduces is a `_cambiandoFuente` stuck at `true`: a real
user stop would be masked away from `idle`, the service would never stop, and
the notification would become unkillable. So the flag is cleared by four
independent mechanisms rather than one audited path:

  - a `finally` around the whole body of `_cambiarFuente`, which covers normal
    completion, BOTH `revision != _revisionFuente` early returns, every
    `rethrow` out of a catch clause, and any non-`Exception` `Error` that none
    of the three clauses matches;
  - eagerly at the top of each of the three catch clauses — needed on top of
    the `finally` because `_gestionarErrorReproduccion` calls `_player.stop()`
    WITHOUT awaiting it, so that `idle` could otherwise land while the mask
    was still up;
  - right after `setUrl` resolves, before anything below can await, since the
    fresh player's transient `idle` is already behind us at that point;
  - at the start of `stop()` — before `_player.stop()` — and at the start of
    `_gestionarErrorReproduccion`, which makes the invariant total: the flag
    is `false` before every single `_player.stop()` call in this class.

`stop()` matters most: `BaseAudioHandler.stop()` is empty, so the handler
never pushes `idle` itself — teardown is driven entirely by the player's
emission. A stop landing while a station change was still in flight would
otherwise be masked and the notification would survive the stop.

Audited: two `_player.stop()` call sites exist and both are preceded by a
clear; `_recrearPlayer` has exactly one caller and it is guarded; the old
player cannot emit during `_recrearPlayer` because its subscriptions are
cancelled first.
2026-08-01 19:15:53 +02:00
FreeTLab b0271fa953 feat(audio): log AudioService.asyncError instead of swallowing it
`AudioService.asyncError` had ZERO subscribers app-wide. The plugin funnels
every asynchronous failure of its own observers into that stream and nowhere
else — `_observePlaybackState`, `_observeMediaItem` and `_observeQueue` each
wrap their whole body in `catch (e) { _asyncError.add(e); }`, and the artwork
path uses `.catchError(_asyncError.add)` — and a `PublishSubject` with no
listeners simply drops what it is given. The platform-side exception behind
"the media playback notification disappeared" was therefore being discarded
without a single log line, which is why that report arrives with no evidence
attached.

`observarErroresAudio` is a pure, injectable seam in `arranque_audio.dart`
(stream in, logger callback out), matching the seam convention this codebase
already uses for `esperarArranqueAudio`, `decidirAvanceCola` and
`debeReaplicarEcualizador`: the unit tests exercise the wiring with a plain
`StreamController`, never the real plugin. The default logger emits one
`[PluriWave]`-prefixed `developer.log` line at `level: 900`, the same level
and prefix `servicio_audio.dart` already uses, so one logcat filter catches
both.

Wired from `lib/main.dart`, not from `arranque_audio.dart`: main.dart is the
module that genuinely owns handler lifecycle — it is the only caller of
`AudioService.init`, `registrarHandler` and `ServicioAudioSession`, and both
the on-time and the degraded/timeout startup branches converge on its
`conectarHandler` closure. `arranque_audio.dart` owns only the timeout race
and the degraded loading shell; it never creates or registers a handler
(`alListo` is injected into it from main.dart), so it has no lifecycle to
hang a subscription on. Subscribing happens before `AudioService.init` — the
getter only touches a static subject — so nothing reported during the
MediaBrowser handshake is missed, and one subscription covers both paths.

The subscription is cancellable and its `cancel` is registered into the
handler via `registrarLimpiezaArranque`, mirroring the existing
`registrarHandler` / `registrarFuenteNavegacion` / `registrarFuenteMusicaLocal`
registration convention. `onTaskRemoved` — the only handler teardown in this
app — runs it, so the subscription cannot outlive what it instruments. The
dependency points bootstrap -> service, so `servicio_audio.dart` never has to
import the bootstrap module or the plugin's static stream.

Zero behaviour change: nothing but log output is added.
2026-08-01 19:15:53 +02:00
ShanaiaBot 6bb16da449 chore: bump version to 1.2.9+131 [ci skip] 2026-08-01 13:04:25 +02:00
FreeTLab 61c97858a1 merge: incorporate the CI version bump
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m50s
2026-08-01 13:00:26 +02:00
FreeTLab 802b62f578 feat(tutorial): add 9-screen help/tutorial carousel
Reusable first-launch onboarding and manual reference under
Ajustes > Información > Ayuda y tutorial.

- 9-page PageView walking favorites/groups, the two-level equalizer,
  live recording, adaptive alarms, Android Auto, auto-reconnect, snooze
  duration and custom stations, closing with where to find it again.
- Shown once via a plain persisted flag, so it fires on a fresh install
  AND on the first launch after an existing install updates to this
  version -- inserted between the welcome screen and the unrelated
  what's-new dialog in the boot sequence.
- The existing 'Ayuda y tutorial' Settings tile now opens this carousel
  instead of the what's-new dialog, which loses its only manual entry
  point but keeps its own auto-show cadence unchanged.

Monetization-free, matching the welcome screen's binding constraint.

Tests: 1064 -> 1084.
2026-08-01 12:57:07 +02:00
ShanaiaBot 6ecd503aae chore: bump version to 1.2.8+130 [ci skip] 2026-08-01 12:55:19 +02:00
FreeTLab b09d644a2c merge: incorporate main's safearea/auto-order/vacaciones fixes 2026-08-01 12:54:53 +02:00
FreeTLab 344af83e16 fix(ui,auto,alarmas): safe area, Android Auto order, vacation delete
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m23s
Three unrelated reports fixed together.

- PluriRootHeader ignored the top system inset, so every root screen's
  own title row sat under the status bar / notch. Now pads for
  MediaQuery top inset without touching app.dart's deliberate edge-to-edge
  SafeArea(top:false) background bleed.
- Android Auto pushed the global-sort-derived favorites list instead of
  the phone's own manual order, and the tree builder then force-sorted
  everything by a hardcoded criterion regardless of what arrived --
  incoming order is now preserved, and Todas/Mis emisoras follow the
  same ordenListas setting the phone itself uses.
- The vacation range edit sheet could save but not delete; it now offers
  both, reusing the existing confirm dialog and delete path.

Tests: 1051 -> 1064.
2026-08-01 12:52:15 +02:00
FreeTLab 257f1bbc68 merge: incorporate the CI version bump 2026-08-01 12:49:51 +02:00
FreeTLab f4f9e87970 docs(alarmas): fix helper name typo in vacation delete comment 2026-08-01 12:08:14 +02:00
FreeTLab 597701f497 fix(alarmas): add a delete action to the vacation range edit sheet
The vacation edit sheet could save changes to an existing range but had
no way to remove it, forcing users back to the swipe-to-delete gesture
on the list. When editing (not creating) a range, the sheet now shows
an outlined delete action next to Save; it reuses the existing
confirmation dialog and EstadoAlarmas.eliminarRangoVacaciones exactly
as the swipe gesture already does, then pops on success.
2026-08-01 12:06:00 +02:00
FreeTLab 4d54908be6 fix(ui): add top-inset awareness to PluriRootHeader
PluriRootHeader rendered its 56px title/actions row flush at y=0 on
every device, since app.dart's root SafeArea(top: false) deliberately
excludes the top inset (so each root's full-bleed background paints
edge-to-edge behind the status bar) but the header itself never added
MediaQuery.paddingOf(context).top anywhere. The header now wraps its
existing 56px content row in an outer top padding equal to that inset,
so total rendered height is height + topInset while `height` keeps
meaning the content row's own height (verified no call site did
total-height math against the old fixed constant).
2026-08-01 12:00:48 +02:00
FreeTLab a949b4503d feat(tutorial): repoint Ajustes "Ayuda y tutorial" to the carousel
Point the existing Info tile at PantallaTutorialAyuda (with
primerArranque: false, so its last page reads "Close") instead of
PluriOnboardingDialog's "what's new" modal.

Trade-off: PluriOnboardingDialog loses its only manual entry point --
it keeps auto-showing on its own existing cadence from app.dart, but
is no longer reachable by tapping this tile. This matches the mockup's
Info screen, which has no separate "what's new" row.
2026-08-01 11:49:11 +02:00
FreeTLab e297413145 feat(tutorial): wire tutorial carousel into the first-launch flow
Insert PantallaTutorialAyuda.mostrarSiProcede between the welcome
screen and the recurring what's-new dialog in
_mostrarFlujoPrimerLanzamiento, so the carousel shows once on every
install -- fresh AND existing installs upgrading to this version --
via its own independent one-time flag, without racing either
surface.
2026-08-01 11:44:25 +02:00
FreeTLab 015a20a823 feat(tutorial): add 9-screen help/tutorial carousel
Add PantallaTutorialAyuda, a PageView-based carousel covering saved
stations/groups, per-station equalizer, recording, adaptive alarms,
Android Auto favorites, auto-reconnect, snooze duration, custom
stations, and a closing summary with a "watch it again" reminder.

ServicioTutorialAyuda persists a one-time seen flag so the carousel
shows once via mostrarSiProcede, independent of entry point; the
final page's CTA label depends on the primerArranque constructor
parameter ("Empezar a escuchar" vs "Cerrar").

Translate the new copy into all 13 supported locales and update
helpSubtitle to describe the new entry point.
2026-08-01 11:34:53 +02:00
FreeTLab cfd8bc9e6a fix(auto): preserve phone-chosen station order in Android Auto folders
Android Auto's Favoritos/Todas/Mis emisoras folders always re-sorted by
a hardcoded quality criterion in ConstructorArbolAuto.hijos/hijosGrupo,
discarding whatever order the caller passed in. EstadoRadio now pushes
already-ordered snapshots (listaFavoritosManual for Favoritos, and the
ordenListas-sorted populares/emisorasCustom getters for Todas/Mis
emisoras, re-pushed immediately on cambiarOrdenListas), and hijos/
hijosGrupo stop re-sorting so that order survives into the car.
2026-08-01 11:26:24 +02:00
ShanaiaBot 445e4518f7 chore: bump version to 1.2.7+129 [ci skip] 2026-07-31 23:30:53 +02:00
FreeTLab d945e1a313 fix(alarmas): stop scheduling failures from being silent
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m24s
Reported: on a Redmi C55 the alarm never rang, no full-screen window, no
pre-notice -- "as if there were no alarm at all". Same build works on a
Poco X7 Pro.

Not device-specific. EstadoAlarmas already recorded per-alarm scheduling
failures and exposed ultimaExcepcionPara, but no screen ever called it,
so a failed alarm rendered identically to a working one.

- Scheduling failures now mark their own card
- The three native paths that only logged -- an unarmed pre-notice, a
  refused foreground-service start, a per-alarm reschedule failing after
  boot -- report to Dart and become per-alarm exceptions
- After a save, the native pending-alarm count is cross-checked, so an
  alarm that never reached the OS is caught immediately

Additive throughout: successful scheduling behaves identically and no
logic branches on manufacturer.

Tests: 1029 -> 1051.
2026-07-31 23:27:58 +02:00
FreeTLab c507218462 merge: incorporate the CI version bump 2026-07-31 23:27:57 +02:00
FreeTLab f2528c930b fix(alarmas): decode native failures with the real channel key names
The first pass read 'alarmaId'/'tipo' from the channel payload while the
native side sends 'alarmId'/'type'/'atMillis' (AlarmScheduler.kt:1389).
Every entry would have been dropped silently in production.

The tests passed because the fake was seeded with the same guessed keys,
so they confirmed the mistake instead of catching it. Decoding now goes
through FalloProgramacionNativo.fromMap -- the single place native key
names appear -- and the fixtures build through that same constructor.
2026-07-31 23:27:45 +02:00
FreeTLab a8dca83cd9 feat(alarmas): surface the three native scheduling failures in Dart
Completes the bridge the native side already exposed. AlarmScheduler and
PluriWaveAlarmService record a pre-notice that could not be armed, a
refused foreground-service start, and a per-alarm reschedule that failed
after a reboot -- but nothing read them, so all three still ended at
logcat.

EstadoAlarmas now drains them at startup and turns each into a per-alarm
exception, which the card already knows how to mark. An alarm that never
reached the OS stops looking identical to one that did.

The read is deliberately tolerant: a failure to read is logged and
swallowed, never surfaced as an alarm error, so a diagnostics gap cannot
masquerade as a scheduling problem.
2026-07-31 23:24:01 +02:00
FreeTLab 7722f204ca feat(alarmas): verify native registration after a successful save
android.programar() returning without throwing was treated as proof
the OS registered the alarm -- this is exactly the gap the reported
case fell through. guardarAlarma now cross-checks a fresh native
pending-alarm count against how many alarms Dart believes are
active-with-a-next-run right after a successful schedule call, and
records a failure for the just-saved alarm when the native count
falls short.

FakePuertoAlarmasAndroid.alarmasNativasPendientes now defaults to a
count derived from programar()/cancelar() calls (mirroring the real
native scheduler's own registry) instead of a frozen 0, while any
test that explicitly assigns the field keeps getting exactly that
value regardless of what programar/cancelar do afterward -- verified
against the full suite, no regressions.
2026-07-31 21:26:41 +02:00
FreeTLab c107c0e18a feat(alarmas): surface scheduling failures on the alarm card
Wires EstadoAlarmas.ultimaExcepcionPara into PantallaAlarmas: an
alarm with an outstanding scheduling-failure exception now shows a
calm warning line (distinguishing a pre-notice-only failure from the
alarm itself not being registered) with a tap target into the
reliability diagnostics screen. The warning is its own small tap
target nested inside the existing card InkWell, so tap-to-edit,
swipe-to-delete and the hero "Saltar" chip are untouched.

Adds alarmCardSchedulingFailedMessage/alarmCardPreNoticeFailedMessage
to all 13 ARB locales with real per-language translations (verified
against arb_parity_test and arb_anti_copy_test).
2026-07-31 21:19:57 +02:00
FreeTLab fd1b91fe9e fix(alarmas): wire scheduling failures into per-alarm exceptions
guardarAlarma/posponerAlarma/posponerProximaDesdePreaviso now record
a scheduling failure via ServicioAlarmas.registrarFalloProgramacion
on catch and clear it on a successful (re)schedule, in addition to
the existing transient EstadoAlarmas.error string. This makes the
failure visible per-alarm via ultimaExcepcionPara instead of only a
generic app-wide message.

Also fixes _sincronizarTodas: a single alarm's android.programar
throw used to abort the whole loop, silently skipping every sibling
alarm scheduled AFTER it on that pass (including on every app launch,
via inicializar). Each alarm's outcome is now independent.
2026-07-31 21:08:21 +02:00
FreeTLab 47d0b8a053 feat(alarmas): record and clear per-alarm scheduling failures
Adds ServicioAlarmas.registrarFalloProgramacion/limpiarFalloProgramacion,
persisting a scheduling-reliability failure through the same
ExcepcionAlarma model saltarProxima already uses. Only one failure
record is kept per alarm (latest attempt wins) and skipNext entries
for any alarm are never touched. EstadoAlarmas wiring follows next.
2026-07-31 21:00:51 +02:00
FreeTLab 88bd251eba fix(alarmas): scope schedule-skip exceptions to skipNext only
ExcepcionAlarma._esValida matched ANY exception tipo against an
occurrence, treating it as a user skip. Only the 'skipNext' tipo
existed until now, but the next commits reuse the same model to
record scheduling-reliability failures per alarm (so the alarms list
can surface them via ultimaExcepcionPara) -- without this guard, a
recorded failure would be silently treated as if the user asked to
skip that occurrence, corrupting scheduling. Adds tipo constants to
ExcepcionAlarma for the upcoming failure kinds.
2026-07-31 20:59:02 +02:00
ShanaiaBot cd7f73056e chore: bump version to 1.2.6+128 [ci skip] 2026-07-31 19:38:01 +02:00
FreeTLab 3f80291e78 feat(auto): equalizer folder in the browse tree, one toggle on playback
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m14s
On-device feedback: two identical icons on the car's now-playing screen,
one of which looked dead. It worked -- but head units render custom
actions icon-first, so cycling six presets behind one static glyph was
invisible.

A monochrome icon cannot encode which of six presets is active. Android
Auto separates the idioms deliberately: custom actions for stateless
toggles, browsable lists for choosing among options.

- Playback screen keeps one action: equalizer on/off, state-aware icons
- New Ecualizador folder lists Desactivar plus the six presets by name,
  active one marked
- The preset-cycling action and its drawable are removed

Supersedes the redesign's no-equalizer-folder rule, which predated
knowing custom actions do not surface state in a car.

# Conflicts:
#	lib/l10n/app_ar.arb
#	lib/l10n/app_bn.arb
#	lib/l10n/app_de.arb
#	lib/l10n/app_en.arb
#	lib/l10n/app_es.arb
#	lib/l10n/app_fr.arb
#	lib/l10n/app_hi.arb
#	lib/l10n/app_id.arb
#	lib/l10n/app_it.arb
#	lib/l10n/app_ja.arb
#	lib/l10n/app_pt.arb
#	lib/l10n/app_ru.arb
#	lib/l10n/app_zh.arb
2026-07-31 19:37:14 +02:00
FreeTLab 88818cf88c feat(alarmas): full Android reliability diagnostics with actionable fixes
Reported: on a Redmi C55 the alarm never rang and the pre-notice never
appeared, while the same build works on a Poco X7 Pro.

The app was never device-specific -- every permission is declared. The
gap was visibility: six diagnostic signals were collected and only three
shown. Battery-optimisation exemption and the count of alarms actually
registered with Android, the two most diagnostic for this failure, were
gathered and discarded.

- Full diagnostics screen, one row per signal, each with a Fix button
  wired to the right system settings intent and guarded by SDK level
- Manufacturer guidance for Xiaomi/Huawei/Oppo/Vivo/OnePlus/Samsung
  explaining Autostart must be enabled by hand -- there is no API for it
- Unresolvable intents surface a message instead of a dead tap

Tests: 993 -> 1014.
2026-07-31 19:30:36 +02:00
FreeTLab f19666508d fix(auto): remove the preset-cycling custom action, superseded by folder
The equalizer's preset-cycling custom action (eq_preset_siguiente) and
its ic_auto_eq_preset drawable are no longer needed now that the
"Ecualizador" folder lists all six presets directly: the folder replaces
what the cycle action did, and this frees a scarce Android Auto custom
action slot.

The on/off toggle is now the equalizer's only custom action.
2026-07-31 19:15:31 +02:00
FreeTLab 8423ccdd0c feat(auto): add an Ecualizador browsable folder with preset selection
On-device feedback showed the equalizer's preset-cycling custom action
looked dead: many head units render custom actions icon-first, and a
monochrome icon cannot legibly encode "which of six presets" the way a
browsable list's text rows can.

This adds an "Ecualizador" folder to the car's browse tree, listing
"Desactivar" first, then the six factory presets by name, with the
currently-active one marked. Selecting a preset routes through the same
playFromMediaId seam every other browse-tree leaf already uses; picking
a preset while the equalizer is off turns it on and applies that preset.

Supersedes the earlier "no equalizer folder" rule (commit 2403da3),
which predated this feedback -- see decision auto/ecualizador-diseno.

The preset-cycling custom action still coexists with the folder in this
commit; it is removed in the next one.
2026-07-31 19:11:01 +02:00
FreeTLab 049ab78acb feat(alarmas): replace one-line reliability button with full diagnostics screen
Surface all six DiagnosticoAlarmasAndroid fields instead of three: the
battery-optimization exemption and native pending-alarm count were
already collected but silently dropped by the old widget. Each failing
signal now offers a "Fix this" action that opens the right system
settings screen (exact alarms, notifications, full-screen intent,
battery optimization), guarded by SDK level and never crashing when a
ROM lacks that screen. Manufacturers known for aggressive background
killing (Xiaomi/Redmi/POCO, Huawei, Oppo, Vivo, OnePlus, Samsung) get
an honest explanation that Autostart must be enabled manually, since
there is no API to detect or grant it.

Notifications now deep-links straight to
ACTION_APP_NOTIFICATION_SETTINGS via a new openNotificationSettings
native method, instead of reusing the runtime permission popup meant
for first-time alarm creation.

New copy is added to all 13 ARB locales with real per-language
translations (not Spanish copies), verified by the ARB parity and
anti-copy tests plus the corruption scanner.
2026-07-31 19:10:52 +02:00
FreeTLab ef9705a30e feat(alarmas): add pure diagnostic mapping and autostart-guidance logic
DiagnosticoAlarmasAndroid already collected six raw reliability fields
but only three ever reached the UI. Add a pure-Dart mapping that turns
the raw snapshot into five ordered signals with a clear ok/needs-
attention state (exact alarms, notifications, full-screen intent,
battery-optimization exemption, native pending-alarm count), plus a
manufacturer check for vendors known to require manually enabling
Autostart (Xiaomi/Redmi/POCO, Huawei, Oppo, Vivo, OnePlus, Samsung),
since there is no public API to detect or grant that setting.
2026-07-31 18:32:42 +02:00
FreeTLab 25405564ee fix(auto): give the equalizer actions distinct, state-aware icons
On a car head unit the custom actions render icon-first, so two actions
sharing ic_stat_pluriwave were indistinguishable and the toggle gave no
sign of whether the equalizer was on.

Each action now has its own drawable, and the toggle swaps between
ic_auto_eq_on and ic_auto_eq_off so its state is legible at a glance.
2026-07-31 18:11:57 +02:00
ShanaiaBot dd463cf2bb chore: bump version to 1.2.5+127 [ci skip] 2026-07-31 01:14:44 +02:00
FreeTLab c8b2c4d2d6 feat(auto,eq,alarmas): address the second round of on-device feedback
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m37s
- Local-music subfolders sort before files, so they no longer fall past
  the 50-item page boundary and vanish from the car
- Playing a folder now plays its subfolders too, bounded at depth 4 and
  500 tracks to cap native SAF round-trips
- Stations and tracks with no artwork fall back to on-brand art instead
  of an empty tile
- Equalizer on/off and preset cycling are reachable from the car's
  now-playing screen as two custom actions
- The equalizer is re-applied after an audio-focus interruption, not
  only when the audio session id changes -- a nav-app prompt keeps the
  same session, which is why the earlier fix missed this case
- The alarm list shows which days an alarm actually fires on

Tests: 933 -> 991.
2026-07-31 01:12:13 +02:00
FreeTLab eba4eba397 merge: incorporate the CI version bump 2026-07-31 01:12:13 +02:00
FreeTLab 4168dc5019 fix(alarmas): show which days a weekday alarm actually fires on
The alarms list showed a generic "Días" label for a diasSemana alarm
instead of its actual configured days. Render the real recurrence (e.g.
"Lun, Mié, Vie") by reusing the SAME per-day abbreviation the editor's own
day-picker circles already use -- no new formatting scheme, no new ARB
keys for the days themselves.

Also surface fade/volume/vacation-pause state on the card, each only when
it is a genuinely useful deviation from the common case: a fade badge when
fadeInSegundos > 0 (reusing the existing alarmFadeInLabel key), a volume
percentage when it differs from the 85% default, and a vacation-paused
badge when the alarm is both configured to pause and a vacation range is
currently active (mirrors the exact predicate ServicioProgramacionAlarmas
already uses). One compact line, not a badge per field.

Fixes a text-collision regression in pantalla_alarmas_editor_test.dart:
opening the editor for an alarm whose own day now renders on its card
(e.g. "Lun") made a bare find.text(weekday) ambiguous against the editor's
day-picker circle with the same label -- scoped that finder to the
BottomSheet subtree.
2026-07-31 01:05:46 +02:00
FreeTLab 491585ad12 fix(eq): re-apply the equalizer after an audio-focus interruption
The equalizer stopped applying after another app interrupted audio (e.g. a
navigation app's voice prompt): play a station with EQ working, let the
prompt speak, resume -- the audio sounds flat until the station is
re-tapped.

debeReaplicarEcualizador only re-attaches the equalizer when the native
player session id actually changes. A short transient interruption keeps
the SAME session (no id rotation), so that trigger never fires, while
Android's AudioEffect framework can let a higher-priority client silently
disable this app's effect instance in the meantime.

Add reaplicarEcualizador() to ObjetivoAudioInterrumpible, implemented as a
thin delegate to the existing _activarEcualizador() (already the correct
idempotent setEnabled + re-push-gains path). ServicioAudioSession calls it
on resume-from-pause (after reanudar()) and on un-duck (after
setAtenuado(false)) -- additive to the existing session-id trigger, not a
replacement. The method takes no argument, so it can only re-assert
whatever enabled/disabled state the handler already holds -- an
interruption cycle with the equalizer OFF stays OFF.
2026-07-31 00:56:37 +02:00
FreeTLab 9eff760462 feat(auto): equalizer enable/disable and preset cycling from the car
Expose the equalizer's on/off toggle and preset choice as PlaybackStateCompat
custom actions on the now-playing screen. The redesign's removal of the
in-car equalizer FOLDER from the browse tree stays as-is (2403da3) -- this
is a different surface (playback screen custom actions, not a browse
folder) and does not reintroduce it.

Deliberately just 2 actions -- an on/off toggle plus a cycling preset
action, not one action per preset -- since Android Auto only surfaces a
limited number of custom actions. Both reuse the existing
setEcualizadorActivo/aplicarPreset entry points (the same ones
EstadoEcualizador's phone settings screen uses), so a car tap and a phone
tap behave identically and both keep the action labels in sync. Reuses the
bundled ic_stat_pluriwave drawable (the notification's own equalizer-bars
icon) -- zero new native assets. The 5-band constraint is untouched.

New pure, unit-tested functions in servicio_audio.dart: presetSiguiente,
nombrePresetVisible, controlesEcualizadorPersonalizados. New ARB keys
(eqCustomActionEnableLabel/DisableLabel/PresetLabel) across all 13 locales,
regenerated via flutter gen-l10n.
2026-07-31 00:54:05 +02:00
FreeTLab 1b0bea5492 fix(auto): fall back to on-brand artwork when a station or track has none
Stations and tracks with no artwork showed empty tiles in the car. The
browse tree's itemEmisora/_itemLocal already fell back to the rotating
station_art_* drawable via artUriPara/artUriLocal, but the "now playing"
MediaItem built when actually playing something (car tap, phone-initiated
play, folder-queue advance, direct local-track tap) did not, so the car's
now-playing screen still went blank.

Reuse the SAME artUriPara/artUriLocal fallback (already the project's one
selection scheme, mirroring PluriStationArtFallback) at every "now playing"
construction site: reproducirPorMediaId, ServicioAudio.reproducir (now via
the extracted, unit-tested mediaItemParaEmisora), construirMediaItemColaLocal
and reproducirPistaLocal.

Guard the reverse direction too: emisoraDesdeMediaItem (extracted from the
handler's private method, now unit-tested) only reflects artUri back into
Emisora.favicon when it passes faviconUsable, so the phone UI's
CachedNetworkImage widgets never attempt a doomed fetch of the car's
android.resource:// fallback URI -- they keep falling back to
PluriStationArtFallback exactly as before.
2026-07-31 00:47:26 +02:00
FreeTLab 6822432a51 feat(auto): play a local-music folder's subfolders recursively too
totalPistas counted only DIRECT audio children, so "Reproducir carpeta"/
"Aleatorio" were hidden for a folder that contains only subfolders, and
playing a folder queued only its direct tracks.

Add a bounded recursive walk (pistasRecursivas) that collects every track
beneath a folder, depth-first, sorted by name at each level. Bounded on
two independent axes to keep a single tap's native SAF round-trips and
in-memory list size predictable on a deep or wide library:
- depth: 4 levels below the tapped folder (profundidadMaximaRecursivaLocal)
- count: 500 tracks total (limitePistasRecursivasLocal)

The folder-play/shuffle actions are now offered whenever the recursive
count is > 0, and "Reproducir carpeta"/"Aleatorio" queue everything found,
not just direct children.
2026-07-31 00:43:20 +02:00
FreeTLab eea8ec31e6 fix(auto): sort local-music subfolders before files
itemsLocales sorted a folder's children by name only, mixing directories
and files. A subfolder whose name sorted after enough tracks (e.g. "Live"
behind 80 numbered tracks) landed on a later "Más..." page, making it
unreachable without paging through every track first.

Sort directories before files, then by name within each group -- the
standard file-browser convention. Subfolders now always land on page 0.
2026-07-31 00:38:39 +02:00
ShanaiaBot 55636f7c74 chore: bump version to 1.2.4+126 [ci skip] 2026-07-30 22:25:08 +02:00
FreeTLab 4b89c9af07 fix(espaciados): complete the spacing review across every screen
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s
Finishes the review the user asked for after on-device testing; Buscar
was fixed earlier in fdb7eb1.

- Favoritos' header sat 36px from the edge instead of 20 -- the list's
  own padding stacked on top of PluriRootHeader's inset, and it was the
  one root that did not match Alarmas and Ajustes
- Rows were card-tier (16) where the prototype uses row-tier (12)
- Settings group-to-group gap 12 -> 16; Grabaciones storage card -> rows
  12 -> 16; Paises language -> country list 16 -> 14
- Alarma sonando's snooze block had a non-uniform 10/14 gap pair
- The date line moved below the hero time, where the prototype puts it

Escuchar, Alarmas, Vacaciones, the 12 settings detail screens,
Reproductor and Bienvenida were checked and were already correct.

Tests: 926 -> 933.
2026-07-30 22:23:04 +02:00
FreeTLab f2b02c3ce2 merge: incorporate the CI version bump 2026-07-30 22:23:03 +02:00
FreeTLab db6f4a3a11 fix(alarma-sonando): put the date line below the hero time
The prototype's order is pill (t4:415-416), then 7:30 at 88px (t4:417),
then "Lunes, 3 de agosto" at 14px (t4:419). An earlier pass rendered the
date between the pill and the time and cited "t4 line 419" as its
justification -- but that line number is where the date SITS in the
source, which is exactly why it comes last.

Both the code and the test encoded the same misreading, so the test
passed while the screen was wrong.
2026-07-30 22:22:50 +02:00
FreeTLab 3bb92c0536 fix(grabaciones): correct the gap between the storage card and rows
Issue 3 (feedback-pruebas): t4:617 draws a 16px gap between the
storage usage card and the recordings list below it, not 12.
2026-07-30 22:14:51 +02:00
FreeTLab 9a75027d57 fix(paises): correct the gap between the language and country lists
Issue 3 (feedback-pruebas): t4:260 draws a 14px gap between "Tus
idiomas" and "Todos", not 16.
2026-07-30 22:13:43 +02:00
FreeTLab 93b7ec2af9 fix(alarma-sonando): make the snooze block's vertical gaps uniform
Issue 3 (feedback-pruebas): t4:427 wraps the POSPONER eyebrow, the
snooze tiles and the Stop button in a single flex column with a
uniform gap:12 -- this screen carried a 10/14 pair instead, matching
neither the prototype nor each other.

The dismiss-guard test (protected, untouched) only asserts behaviour
via find.text/find.byType, so this pure value change is safe against
it -- re-verified empty diff after this commit.
2026-07-30 22:12:24 +02:00
FreeTLab 5bbf750b63 fix(ajustes): correct the gap between stacked settings groups
Issue 3 (feedback-pruebas): the prototype (t4:523/534/541) draws a
16px gap between the AUDIO/STATIONS/RECORDINGS/APPLICATION cards, not
12 -- a plain unwired literal that happened to collide with the
sectionGap/panelGap tokens' own value without actually citing the
prototype.
2026-07-30 22:08:40 +02:00
FreeTLab 7faf56900f fix(favoritos): stop the header padding from doubling up
Issue 3 (feedback-pruebas): ReorderableListView.padding wrapped
header/rows/footer with a single horizontal value (16), which doubled
up on top of PluriRootHeader's own internal inset -- landing the
title at 36px instead of the 20px every other root uses -- while also
applying card-tier padding to the flat FilaEmisoraPlana rows (row
tier, matching the same widget's fix on Buscar) and leaving the
populated-state top gap at an unwired 4 that didn't match this same
screen's own empty state (0) or the footer CTA's prototype value (8).

Zeroes the list-level padding and gives the header, chip strip, rows,
and footer CTA their own correctly-tiered insets instead.
2026-07-30 22:06:36 +02:00
ShanaiaBot ec93e45310 chore: bump version to 1.2.3+125 [ci skip] 2026-07-30 21:48:14 +02:00
FreeTLab fdb7eb1d51 fix: address the issues found in on-device testing
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m18s
Seven of the eight points reported after the first real build.

- Favourites overflow menu was clipped to one letter per item by a
  constraints property that sizes the popup, not the button
- Bottom bar painted a square ink splash over the icon, and its lift,
  dim, icon size and label snapped while the balloon slid
- Station artwork fallback is now shared by every surface instead of the
  flat rows painting a plain coloured square
- Vacation ranges can be edited and deleted
- Settings row titles no longer wrap into cut lines
- Sleep timer sheet shows the live countdown
- The last-played station survives a restart, shown stopped

Spacing review is done for Buscar only; the rest of the app is still
outstanding.

Tests: 903 -> 926.
2026-07-30 21:47:13 +02:00
FreeTLab 431f13063d merge: incorporate the CI version bump to 1.2.2+124 2026-07-30 21:45:42 +02:00
FreeTLab fc866d7ec9 fix(buscar): correct the gaps between the filter row and the results
Issue 3 (partial): the results area had no top gap against the filter
row in one state and reused the horizontal constant for a vertical axis
in another. Applies the 3-tier scale properly -- row tier for
background-less placeholders, card tier for card states.

The rest of the app's spacing review is still outstanding.
2026-07-30 20:18:59 +02:00
FreeTLab 727e18737a fix(radio): persist the last-played station across restarts
EstadoRadio.emisoraActual only ever reflected in-memory state
(_emisoraSeleccionada or the live audio service), so stopping playback
and reopening the app left the Escuchar hero empty even though the
user had a station selected right before closing it.

Persist the station whenever it changes (reproducir(), and the
Android-Auto out-of-band reconciliation path) and restore it as
_emisoraSeleccionada on the next cold start, only when nothing is
already selected. This never touches the audio service directly: no
playback starts and estadoStream/estaSonando stay at their stopped
default, matching how every consumer already gates "is it playing" on
the playback-status stream rather than on emisoraActual itself.
2026-07-30 19:18:14 +02:00
FreeTLab c6ab295c54 fix(timer): show the live countdown in the sleep timer sheet
showPluriSleepTimerSheet already had a working countdown branch
(ServicioTimer.tiempoRestanteStream), but every preset and the custom
duration flow popped the sheet immediately after starting the timer --
so the countdown never rendered in the primary flow, only if the user
happened to reopen the sheet afterwards.

Stop popping the sheet on start; the existing Consumer<EstadoRadio>
already reacts to iniciarTimerDuracion's notifyListeners and swaps to
the countdown view live. Also make the sheet scroll-controlled: at a
realistic phone width the countdown's title + description + headline-
sized remaining-time text overflowed the default half-screen cap that
never mattered while the sheet always closed before that view could
render.
2026-07-30 19:14:15 +02:00
FreeTLab e75f010b98 fix(ajustes): stop settings row titles from wrapping and cutting off
FilaAjuste's title Text had no maxLines/overflow, and neither did its
trailing current-value Text. An unbounded value (e.g. a real station
name in "Emisora preferida") let the trailing Row claim unbounded
width, squeezing the title down until it wrapped across several lines
that the row's fixed height then cut short.

Constrain the title to a single ellipsized line and cap the trailing
value's width the same way. FilaAjuste backs all 12 settings rows, so
every row is protected, not just the one that happened to expose it.
2026-07-30 19:08:06 +02:00
FreeTLab c7e1a212ca fix(vacaciones): edit and delete vacation ranges
Vacaciones ranges could be created but never edited or removed --
EstadoAlarmas already had crearRangoVacaciones/eliminarRangoVacaciones
with no UI affordance reaching them, and no update path at all.

Add EstadoAlarmas.editarRangoVacaciones and wire tap-to-edit /
swipe-to-delete (with confirmation) onto every range card, mirroring
the alarm list's own Dismissible + confirm-dialog pattern exactly. This
covers the active-range hero too: a freshly created range is active
immediately and only ever renders there, never in the
scheduled/past lists, so it needed the same affordances or a user's
very first range could never be fixed.
2026-07-30 19:05:28 +02:00
FreeTLab d1a911e587 fix(widgets): share station-art fallback across every surface
TarjetaEmisora had the only good fallback for a station with no artwork --
a deterministic pick from 4 bundled illustrations with a gradient/glyph
last resort. FilaEmisoraPlana's flat rows, the Escuchar hero, the "Tus
emisoras" grid cell, the mini player and the full player each had their
own, separate, flat primaryContainer square instead.

Extract the good fallback into PluriStationArtFallback and use it from
every one of those call sites. The selection formula (asset order,
codeUnits-sum modulo) is preserved exactly, since navegacion_auto.dart
mirrors the same formula independently for Android Auto's own drawable
rotation.
2026-07-30 18:54:31 +02:00
FreeTLab 4be2156e58 fix(nav,favoritos): unclip the overflow menu and smooth the tab transition
Two user-reported bugs from on-device testing.

The favourites overflow menu carried `constraints: tightFor(38x42)`,
which sizes the POPUP rather than the button -- every item was clipped to
its first letter, so users saw "M" and "E" instead of the labels. The
existing test passed throughout because find.text matches a Text widget
whether or not it is visually clipped; the new guard measures the laid-out
width instead.

The bottom bar's ink splash had no shape, painting a hard square over the
icon, and the active tab's lift, dim, icon size and label all changed
instantly while the balloon slid -- the balloon glided and its contents
teleported. All four now share the balloon's duration and curve.
2026-07-30 18:26:25 +02:00
ShanaiaBot b365035e10 chore: bump version to 1.2.2+124 [ci skip] 2026-07-30 17:56:28 +02:00
FreeTLab f24be19e4f feat: visual fidelity pass against the approved prototype
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m22s
Closes the gap between the functional redesign and the design handoff.
An audit found 126 deviations; this pass fixes the large majority and
records the rest as deliberate rejections with reasons.

- Bottom bar rebuilt as the prototype's balloon bar (tabs and icons kept)
- Surfaces are opaque #102532 by default; glass is now opt-in and used
  only on the chrome and the active card, per the prototype's own rule
- Global AppBar dropped; each root draws its own header
- Settings rows show their current value; 3-tier padding scale
- Equalizer, player, alarms, vacations, countries, search, recordings
  and welcome all brought to spec, each with a dimension/colour guard

Tests: 748 -> 903. Analyzer unchanged at 4 infos, 0 errors.
2026-07-30 17:55:44 +02:00
FreeTLab 7343071fca merge: incorporate the CI version bump to 1.2.1+123 2026-07-30 17:53:47 +02:00
ShanaiaBot 34388d364b chore: bump version to 1.2.1+123 [ci skip] 2026-07-29 18:35:47 +02:00
192 changed files with 22130 additions and 2721 deletions
+122
View File
@@ -109,9 +109,131 @@ jobs:
- name: Obtener dependencias
run: flutter pub get
# OBLIGATORIO en este runner autoalojado, no es higiene opcional.
#
# El directorio build/ sobrevive entre ejecuciones y el merge
# incremental de recursos de Gradle se queda rancio: los drawables
# ic_auto_eq_on/ic_auto_eq_off (anadidos el 31-07 en 2540556) NUNCA
# llegaron a entrar en el APK, mientras que ic_stat_pluriwave -- misma
# carpeta, anadido el 02-07 -- si estaba. Verificado extrayendo el
# base.apk instalado en el dispositivo: los ficheros no existen ni como
# entrada del zip ni en resources.arsc.
#
# El coste fue semanas de diagnostico equivocado. Cada setState
# publicaba una CustomAction cuyo icono resolvia a 0, y
# PlaybackStateCompat.CustomAction.Builder lanza en ese caso, abortando
# setState antes de activar la sesion de medios: Android Auto se
# quedaba con la sesion congelada e inactiva. El codigo Dart siempre
# llegaba porque se recompila; el recurso Android no.
- name: Limpiar artefactos de compilacion
run: flutter clean
- name: Reinstalar dependencias tras limpiar
run: flutter pub get
- name: Build APK release
run: flutter build apk --release
# Guardian de recursos: el APK debe contener los drawables que el codigo
# resuelve POR NOMBRE en runtime (getResources().getIdentifier).
#
# Un nombre que no resuelve devuelve id 0, y eso no falla la
# compilacion: falla en el coche. Concretamente
# PlaybackStateCompat.CustomAction.Builder lanza con icono 0, ese throw
# aborta AudioService.setState antes de activar la sesion de medios, y
# Android Auto se queda con la interfaz congelada. Paso exactamente eso
# entre el 31-07 (commit 2540556) y el 07-08 sin que nada lo detectara.
#
# Anadir un drawable nuevo referenciado por nombre => anadirlo aqui.
# Guardian de recursos: el APK debe contener los drawables que el codigo
# resuelve POR NOMBRE en runtime (getResources().getIdentifier).
#
# Un nombre que no resuelve devuelve id 0, y eso no falla la
# compilacion: falla en el coche. PlaybackStateCompat.CustomAction
# .Builder lanza con icono 0, ese throw aborta AudioService.setState
# antes de activar la sesion de medios, y Android Auto se queda con la
# interfaz congelada. Paso exactamente eso desde el 31-07 (commit
# 2540556) sin que nada lo detectara.
#
# La primera version de este paso daba FALSOS POSITIVOS: no comprobaba
# que el APK existiera ni que unzip estuviera disponible, asi que
# cualquier fallo de la tuberia se reportaba como "faltan todos los
# recursos". Un guardian que miente es peor que no tener guardian:
# manda a buscar fantasmas. De ahi que ahora verifique primero sus
# propias herramientas y vuelque el inventario real antes de juzgar.
#
# Anadir un drawable nuevo referenciado por nombre => anadirlo aqui.
# Guardian de recursos: el APK debe contener los drawables que el codigo
# resuelve POR NOMBRE en runtime (getResources().getIdentifier).
#
# Un nombre que no resuelve devuelve id 0. Eso no falla la compilacion:
# falla en el coche. PlaybackStateCompat.CustomAction.Builder lanza con
# icono 0, ese throw aborta AudioService.setState antes de activar la
# sesion de medios, y Android Auto se queda con la interfaz congelada.
# Paso exactamente eso desde el 31-07 (commit 2540556) sin deteccion.
#
# Se inspecciona resources.arsc, NO las rutas del zip: el APK release
# acorta/renombra las rutas de recursos (una version anterior de este
# paso listo "ningun drawable" en un APK de 105MB, que es imposible).
# Los NOMBRES de recurso siguen en la tabla pase lo que pase.
#
# El centinela existe porque este guardian ya mintio una vez: al no
# validar su propio metodo, reporto como ausente hasta un recurso que
# estaba verificado presente. Si el centinela no aparece, la inspeccion
# no es fiable y NO tenemos derecho a declarar nada ausente.
#
# Anadir un drawable nuevo referenciado por nombre => anadirlo aqui.
- name: Verificar recursos criticos en el APK
run: |
set -u
APK=build/app/outputs/flutter-apk/app-release.apk
CENTINELA=station_art_nova
if [ ! -f "$APK" ]; then
echo "El APK no esta donde se esperaba: $APK"
find build/app/outputs -name '*.apk' 2>/dev/null || echo " (nada)"
exit 1
fi
echo "APK: $APK ($(wc -c < "$APK") bytes)"
if ! command -v unzip >/dev/null 2>&1; then
echo "unzip no esta disponible: no se puede inspeccionar el APK."
exit 1
fi
ARSC=$(mktemp)
unzip -p "$APK" resources.arsc > "$ARSC" 2>/dev/null || true
if [ ! -s "$ARSC" ]; then
echo "No se pudo extraer resources.arsc del APK."
exit 1
fi
echo "resources.arsc: $(wc -c < "$ARSC") bytes"
if ! grep -a -q "$CENTINELA" "$ARSC"; then
echo "El centinela '$CENTINELA' no aparece en la tabla de recursos."
echo "La inspeccion no es fiable; no se declara nada ausente."
exit 1
fi
echo "Centinela '$CENTINELA' localizado: la inspeccion es fiable."
FALTAN=0
for RECURSO in ic_auto_eq_on ic_auto_eq_off ic_stat_pluriwave; do
if grep -a -q "$RECURSO" "$ARSC"; then
echo "OK $RECURSO"
else
echo "FALTA $RECURSO"
FALTAN=$((FALTAN + 1))
fi
done
if [ "$FALTAN" -ne 0 ]; then
echo ""
echo "$FALTAN drawable(s) resueltos por nombre NO estan en el APK."
echo "En runtime resolveran a id 0 y tumbaran la sesion de medios."
exit 1
fi
echo "Todos los recursos criticos viajan en el APK."
- name: Build AAB release
run: flutter build appbundle --release
+9
View File
@@ -137,6 +137,15 @@
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" />
<!-- AdMob application id (iap-freemium-unlock). Real id, provisioned
in the AdMob console. Safe to use in all build modes — this id
only initializes the SDK; it never serves an ad by itself, so it
carries none of the "don't tap your own ads" risk that ad unit
ids do. -->
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-6038935671414339~4085536467" />
</application>
<queries>
<intent>
@@ -202,8 +202,22 @@ class AlarmScheduler(private val context: Context) {
)
)
Log.d(tag, "alarm.schedule preNotice OK id=${spec.id}")
NativeSchedulingFailures.clear(
appContext,
spec.id,
NativeSchedulingFailures.TYPE_PRE_NOTICE
)
} catch (_: SecurityException) {
// Silent before this fix: the main alarm can still arm via
// setAlarmClock (exempt from the exact-alarm permission), so
// the alarm itself rings while its 30-minute reminder simply
// never appears, with nothing surfaced anywhere but logcat.
Log.w(tag, "alarm.schedule preNotice SecurityException id=${spec.id}")
NativeSchedulingFailures.record(
appContext,
spec.id,
NativeSchedulingFailures.TYPE_PRE_NOTICE
)
}
} else if (spec.triggerAtMillis > now) {
appContext.sendBroadcast(
@@ -322,19 +336,64 @@ class AlarmScheduler(private val context: Context) {
}
}
/**
* The occurrence a "close this one" action is really acting on, never a
* future one.
*
* Reported on-device: pressing Posponer left the alarm snoozed for ~1444
* minutes (24h04m) instead of the configured few. The chain, all inside
* this file: [onAlarmFired] runs from the receiver BEFORE the ringing
* notification exists, and it persists `snoozeOriginMillis = null` plus a
* `triggerAtMillis` already advanced to TOMORROW by
* [computeNextTriggerMillis]. The snooze anchor was then plain
* `spec.snoozeOriginMillis ?: spec.triggerAtMillis`, so it picked up
* tomorrow. The old clamp (`if (target > now) target else now + minutes`)
* could not catch it: it only rescues anchors in the PAST, and an anchor
* +24h out sails straight through.
*
* [maxAheadMillis] is how far ahead an occurrence may legitimately sit for
* the calling surface: ~0 (just the shared imminence tolerance) for the
* ringing notification, but a full [PRE_NOTICE_MILLIS] for the pre-notice
* notification, whose occurrence has genuinely not happened yet.
*
* Mirrors `EstadoAlarmas._ocurrenciaSonando` on the Dart side, which was
* added in a9da855 for the exact same defect after 9c7cf4e had fixed only
* one of two adjacent callers. The native lane never got that guard.
* `lastHandledAtMillis` is the last fallback because [onAlarmFired] sets
* it to the occurrence that just rang -- note it is NOT purely native
* state (scheduleAlarm takes it from the Dart channel), so `now` has to
* remain the floor.
*/
private fun anchorOccurrenceMillis(
spec: NativeAlarmSpec,
now: Long,
maxAheadMillis: Long = 0L
): Long {
val limit = now + maxAheadMillis + IMMINENT_TOLERANCE_MILLIS
fun usable(candidate: Long?): Long? = candidate?.takeIf { it <= limit }
return usable(spec.snoozeOriginMillis)
?: usable(spec.triggerAtMillis)
?: usable(spec.lastHandledAtMillis)
?: now
}
/**
* Snoozes using the SAME anchor as [postponeNext] (Design 2.2): the
* occurrence time + minutes, clamped to now + minutes when the target is
* already past. Returns the resulting snooze so the caller can report it
* back to Flutter (single source of truth), or null if the spec is gone.
*
* The occurrence comes from [anchorOccurrenceMillis] with no forward
* allowance: this is the RINGING notification's button, so the occurrence
* it closes has already arrived.
*/
fun snooze(id: String, minutes: Int): NativeSnoozeResult? {
cancelAutoSilence(id)
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 occurrenceAt = anchorOccurrenceMillis(spec, now)
val target = occurrenceAt + safeMinutes * 60_000L
val snoozeUntil = if (target > now) target else now + safeMinutes * 60_000L
Log.d(
tag,
@@ -355,12 +414,24 @@ class AlarmScheduler(private val context: Context) {
)
}
/**
* Postpones from the PRE-NOTICE notification, whose occurrence has
* legitimately not arrived yet -- it is armed [PRE_NOTICE_MILLIS] ahead.
* So unlike [snooze] this allows an anchor that far forward, but no
* further: an anchor beyond that window is a spec already advanced to a
* later day, which is exactly the state that produced the reported ~24h
* snooze. See [anchorOccurrenceMillis].
*/
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 occurrenceAt = anchorOccurrenceMillis(
spec,
now,
maxAheadMillis = PRE_NOTICE_MILLIS
)
val target = occurrenceAt + safeMinutes * 60_000L
val snoozeUntil = if (target > now) target else now + safeMinutes * 60_000L
Log.d(
tag,
@@ -846,8 +917,22 @@ class AlarmScheduler(private val context: Context) {
// the native recompute inside scheduleSpec.
scheduleSpec(spec, persistOnSuccess = true, trustDartTrigger = true)
Log.d(tag, "alarm.reschedule OK id=$id")
NativeSchedulingFailures.clear(
appContext,
id,
NativeSchedulingFailures.TYPE_RESCHEDULE
)
} catch (error: Throwable) {
// Silent before this fix: one alarm's reschedule failure used
// to just log and move to the next id, leaving that ONE
// alarm unscheduled after a reboot/unlock/app-update with no
// signal anywhere but logcat.
Log.e(tag, "alarm.reschedule failed id=$id", error)
NativeSchedulingFailures.record(
appContext,
id,
NativeSchedulingFailures.TYPE_RESCHEDULE
)
}
}
}
@@ -855,6 +940,18 @@ class AlarmScheduler(private val context: Context) {
fun pendingAlarmCount(): Int =
prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size
/**
* Scheduling-reliability failures the native side recorded on its own
* (fix/alarmas-fallos-silenciosos, item 2): pre-notice, foreground-
* service start, and post-boot/unlock reschedule failures never go
* through a Dart method-channel call that could throw, so they are
* persisted here instead and synced by Flutter on the next app launch
* -- mirroring [handledOccurrences]/[nativeSnoozeStates]'s own
* cold-start-sync shape.
*/
fun scheduleFailures(): List<Map<String, Any>> =
NativeSchedulingFailures.all(appContext)
fun handledOccurrences(): List<Map<String, Any>> =
prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty()
.mapNotNull { id ->
@@ -1265,3 +1362,91 @@ class AlarmScheduler(private val context: Context) {
private fun JSONObject.optNullableLong(name: String): Long? =
if (has(name) && !isNull(name)) optLong(name) else null
/**
* Persisted store for scheduling-reliability failures the native side
* catches and previously only logged (fix/alarmas-fallos-silenciosos, item
* 2): the pre-notice `SecurityException` (AlarmScheduler.schedulePreNotice),
* a refused foreground-service start (PluriWaveAlarmService.start), and a
* per-alarm reschedule failure after boot/unlock
* (AlarmScheduler.reschedulePersistedAlarms). A separate top-level object
* (not nested in [AlarmScheduler]'s own instance state) so
* [PluriWaveAlarmService]'s companion object -- which has no [AlarmScheduler]
* instance of its own -- can record a failure too, using the exact same
* [Context]-scoped, device-protected-storage `SharedPreferences` file
* [AlarmScheduler] itself reads/writes (same `PREFS` name, kept in sync by
* hand since Kotlin constants cannot be shared across files without a third
* file).
*
* Only the LATEST failure per alarm is kept (mirrors the Dart-side
* `ExcepcionAlarma` "latest wins" semantics) -- this is a reliability
* signal, not an audit log.
*/
object NativeSchedulingFailures {
private const val PREFS = "pluriwave_alarm_scheduler"
private const val KEY_FAILURE_IDS = "schedule_failure_alarm_ids"
private const val KEY_FAILURE_TYPE_PREFIX = "schedule_failure_type_"
private const val KEY_FAILURE_AT_PREFIX = "schedule_failure_at_"
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloPreaviso`. */
const val TYPE_PRE_NOTICE = "preNoticeFailed"
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloServicioSonido`. */
const val TYPE_FOREGROUND_SERVICE = "foregroundServiceFailed"
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloReprogramacionArranque`. */
const val TYPE_RESCHEDULE = "rescheduleAfterBootFailed"
private fun prefs(context: Context) =
context.applicationContext.createDeviceProtectedStorageContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun record(context: Context, id: String, type: String) {
if (id.isBlank()) return
val store = prefs(context)
val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet()
ids.add(id)
store.edit()
.putStringSet(KEY_FAILURE_IDS, ids)
.putString("$KEY_FAILURE_TYPE_PREFIX$id", type)
.putLong("$KEY_FAILURE_AT_PREFIX$id", System.currentTimeMillis())
.apply()
Log.w("PluriWave", "alarm.scheduleFailure recorded id=$id type=$type")
}
/**
* Clears the failure recorded for [id] ONLY when its current type is
* [type] -- type-scoped on purpose (mirrors the Dart-side
* `limpiarFalloProgramacion`), so a foreground-service success never
* erases an unrelated, still-outstanding pre-notice failure for the
* same alarm.
*/
fun clear(context: Context, id: String, type: String) {
val store = prefs(context)
val storedType = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null)
if (storedType != type) return
val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet()
if (!ids.remove(id)) return
store.edit()
.putStringSet(KEY_FAILURE_IDS, ids)
.remove("$KEY_FAILURE_TYPE_PREFIX$id")
.remove("$KEY_FAILURE_AT_PREFIX$id")
.apply()
}
fun all(context: Context): List<Map<String, Any>> {
val store = prefs(context)
return store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().mapNotNull { id ->
val type = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null)
?: return@mapNotNull null
val at = store.getLong("$KEY_FAILURE_AT_PREFIX$id", 0L)
.takeIf { it > 0L }
?: return@mapNotNull null
mapOf(
"alarmId" to id,
"type" to type,
"atMillis" to at
)
}
}
}
@@ -232,6 +232,10 @@ class MainActivity : AudioServiceActivity() {
Log.d(tag, "alarm.channel requestIgnoreBatteryOptimizations")
result.success(requestIgnoreBatteryOptimizations())
}
"openNotificationSettings" -> {
Log.d(tag, "alarm.channel openNotificationSettings")
result.success(openNotificationSettings())
}
"getInitialAlarmIntent" -> {
val payload = alarmPayload(intent)
Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload")
@@ -246,6 +250,10 @@ class MainActivity : AudioServiceActivity() {
Log.d(tag, "alarm.channel getNativeSnoozeState")
result.success(alarmScheduler.nativeSnoozeStates())
}
"getNativeSchedulingFailures" -> {
Log.d(tag, "alarm.channel getNativeSchedulingFailures")
result.success(alarmScheduler.scheduleFailures())
}
"setNotificationStrings" -> {
val args = call.arguments as? Map<*, *>
if (args != null) {
@@ -751,6 +759,39 @@ class MainActivity : AudioServiceActivity() {
}
}
/**
* Opens the system's per-app notification settings screen directly
* (diagnostics screen, fix/alarmas-fiabilidad). Unlike
* [requestPostNotificationsPermission] -- which shows the runtime
* permission popup and is meant for the FIRST time an alarm is created
* -- this is meant for a user troubleshooting an alarm that already
* failed, where the OS may no longer show that popup at all after a
* prior denial. `ACTION_APP_NOTIFICATION_SETTINGS` only exists from API
* 26; older devices fall back to the app's own details screen, which
* still surfaces the notification toggle. Never throws across the
* channel boundary -- an unresolvable intent on some ROM is caught and
* reported as `false`, same shape as every other `request*`/`open*`
* helper in this class.
*/
private fun openNotificationSettings(): Boolean {
return try {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:$packageName")
}
}
startActivity(intent)
true
} catch (error: Throwable) {
Log.e(tag, "alarm.channel openNotificationSettings failed", error)
false
}
}
private fun openDirectory(path: String): Boolean {
val folder = File(path)
if (!folder.exists()) {
@@ -183,7 +183,16 @@ class PluriWaveAlarmService : Service() {
startForeground(NOTIFICATION_ID, notification)
}
} catch (error: Throwable) {
// Silent before this fix: same user-visible symptom as a refused
// startForegroundService (the ring never actually starts) --
// recorded under the SAME tipo so the alarms list surfaces it
// regardless of which of the two calls the OS refused.
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
NativeSchedulingFailures.record(
this,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
releaseWakeLock()
// Second documented clear site (feedback item, READ-5): this
// branch never reaches stopEverything(), so without the same
@@ -197,6 +206,11 @@ class PluriWaveAlarmService : Service() {
stopSelf()
return
}
NativeSchedulingFailures.clear(
this,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
startAudio(
alarmId,
stationName,
@@ -754,6 +768,7 @@ class PluriWaveAlarmService : Service() {
fun start(context: Context, source: Intent) {
ensureChannel(context)
val alarmId = source.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE
putExtras(source)
@@ -761,8 +776,27 @@ class PluriWaveAlarmService : Service() {
try {
ContextCompat.startForegroundService(context, intent)
Log.d(TAG, "alarm.service start requested")
if (!alarmId.isNullOrBlank()) {
NativeSchedulingFailures.clear(
context,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
}
} catch (error: Throwable) {
// Silent before this fix: a fire-and-forget call from the
// receiver's ACTION_FIRE branch -- if the OS refuses the
// foreground-service start (background-restricted app), the
// ring never happens and nothing surfaced it anywhere but
// logcat, "as if there were no alarm at all".
Log.e(TAG, "alarm.service start failed", error)
if (!alarmId.isNullOrBlank()) {
NativeSchedulingFailures.record(
context,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
}
}
}
@@ -0,0 +1,44 @@
package es.freetimelab.pluriwave
/**
* Anchors the drawables that only Dart names, so the Android build cannot
* decide they are unused.
*
* These icons are handed to `audio_service` as plain strings
* (`MediaControl.custom(androidIcon: 'drawable/ic_auto_eq_on')`) and resolved
* at runtime through `getResources().getIdentifier(...)`. Nothing on the
* Android side of the build ever mentions them, so as far as the resource
* pipeline is concerned they are dead weight — and they were dropped from
* every release APK.
*
* The damage was not a missing icon. `getResourceId` returns 0 for a name it
* cannot find, `PlaybackStateCompat.CustomAction.Builder` throws on a 0 icon,
* and that throw aborts `AudioService.setState` before the media session is
* ever activated. Android Auto was left holding a frozen, inactive session:
* dead playback screen, a play button that never became pause, the app losing
* its pane to whichever app did have a live session, and audio that played
* "as if it were not the app". One absent file, four symptoms, from 31 July
* (commit 2540556) until this.
*
* Verified rather than assumed. Pulling the installed APK off the device and
* reading its resource table showed `ic_stat_pluriwave` present and both
* equalizer icons absent — and `ic_stat_pluriwave` is the one drawable of the
* three that Kotlin references directly (`R.drawable.ic_stat_pluriwave`, four
* call sites across the alarm notifications). That contrast is the whole
* diagnosis: a real `R.drawable` reference survives, a name that exists only
* inside a Dart string does not.
*
* So this object is not defensive tidiness — it is the reference that was
* missing. Any future drawable that Dart resolves by name must be added here
* AND to the resource guard in `.gitea/workflows/build.yml`, which reads the
* built APK's resource table and fails the build if one of them is gone.
*/
@Suppress("unused")
internal object RecursosResueltosPorNombre {
val anclados: IntArray =
intArrayOf(
R.drawable.ic_auto_eq_on,
R.drawable.ic_auto_eq_off,
R.drawable.ic_stat_pluriwave,
)
}
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M3,5L21,5L21,7L3,7Z M14,3L17,3L17,9L14,9Z M3,11L21,11L21,13L3,13Z M7,9L10,9L10,15L7,15Z M3,17L21,17L21,19L3,19Z M17,15L20,15L20,21L17,21Z M2,20L4,22L22,4L20,2Z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M3,5L21,5L21,7L3,7Z M14,3L17,3L17,9L14,9Z M3,11L21,11L21,13L3,13Z M7,9L10,9L10,15L7,15Z M3,17L21,17L21,19L3,19Z M17,15L20,15L20,21L17,21Z" />
</vector>
+33
View File
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Protects the drawables that only Dart names from the resource shrinker.
Flutter's own Gradle plugin turns shrinking on for every release build
(FlutterPlugin.kt: `releaseBuildType.isMinifyEnabled = true` and
`isShrinkResources = true`), regardless of what app/build.gradle.kts says.
The shrinker keeps what it can see referenced — and it cannot see
`MediaControl.custom(androidIcon: 'drawable/ic_auto_eq_on')`, because that
is a string inside Dart, resolved at runtime via
`getResources().getIdentifier(...)`. So it removed both equalizer icons
from every release APK.
The consequence was not a blank button. `getResourceId` returns 0 for a
name it cannot resolve, `PlaybackStateCompat.CustomAction.Builder` throws
on a 0 icon, and that throw aborts `AudioService.setState` before the media
session is activated — leaving Android Auto with a frozen, inactive
session. Dead playback screen, play that never became pause, the app losing
its pane to any app with a live session, and audio playing "as if it were
not the app". One shrunk file, four symptoms, from 31 July (commit 2540556).
Proven, not assumed: the installed APK was pulled off the device and its
resource table read. `ic_stat_pluriwave` was present, both equalizer icons
were not — and `ic_stat_pluriwave` is the only one of the three that Kotlin
references as a real `R.drawable`, from the alarm notifications. A genuine
reference survives shrinking; a name living in a Dart string does not.
ANY new drawable that Dart resolves by name must be listed here, and in the
resource guard in .gitea/workflows/build.yml, which reads the built APK's
resource table and fails the build if one of them went missing.
-->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/ic_auto_eq_on,@drawable/ic_auto_eq_off,@drawable/ic_stat_pluriwave,@drawable/station_art_*" />
+83 -6
View File
@@ -4,11 +4,15 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'estado/estado_busqueda.dart';
import 'estado/estado_ecualizador.dart';
import 'estado/estado_entitlement.dart';
import 'estado/estado_grabacion.dart';
import 'estado/estado_radio.dart';
import 'estado/estado_alarmas.dart';
import 'estado/estado_idioma.dart';
import 'estado/estado_navegacion.dart';
import 'servicios/servicio_anuncios.dart';
import 'servicios/servicio_compras.dart';
import 'widgets/banner_anuncio_superior.dart';
import 'l10n/display_names.dart';
import 'l10n/gen/app_localizations.dart';
import 'modelos/alarma_musical.dart';
@@ -16,6 +20,7 @@ import 'pantallas/pantalla_alarmas.dart';
import 'pantallas/pantalla_alarma_sonando.dart';
import 'pantallas/pantalla_bienvenida.dart';
import 'pantallas/pantalla_inicio.dart';
import 'pantallas/pantalla_tutorial_ayuda.dart';
import 'pantallas/pantalla_buscar.dart';
import 'pantallas/pantalla_favoritos.dart';
import 'pantallas/pantalla_ajustes.dart';
@@ -30,8 +35,32 @@ import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_alarmas_android.dart';
import 'servicios/servicio_dispositivo_audio.dart';
/// Extracted out of `_PaginaPrincipalState.build` (FIX 1, code review) so
/// the banner + status-bar-inset composition is unit-testable in isolation
/// — `_PaginaPrincipal` itself is library-private and constructs real
/// platform-backed services (see `app_test.dart`'s own comments), so it
/// cannot be safely widget-tested directly. Mirrors this file's existing
/// `@visibleForTesting` top-level extraction convention
/// (`main.dart`'s `orientacionesPara`/`aplicarPoliticaOrientacion`).
///
/// `BannerAnuncioSuperior` owns its OWN top `SafeArea` internally now (see
/// `banner_anuncio_superior.dart`) — this function deliberately does NOT
/// wrap it in one, since `SafeArea` reserves `MediaQuery.padding.top` even
/// around a zero-size collapsed child, which used to leave a permanent
/// blank status-bar-height strip for premium users and for free users
/// before the first ad finished loading.
@visibleForTesting
Widget construirCuerpoPrincipal({required Widget contenido}) {
return Column(
children: [
const BannerAnuncioSuperior(),
Expanded(child: SafeArea(top: false, child: contenido)),
],
);
}
class PluriWaveApp extends StatelessWidget {
const PluriWaveApp({super.key, this.prefs, this.fuenteAuto});
const PluriWaveApp({super.key, this.prefs, this.fuenteAuto, this.compras});
/// Single SharedPreferences instance resolved in main() (S3-R4) and
/// injected into every state/service.
@@ -43,16 +72,31 @@ class PluriWaveApp extends StatelessWidget {
/// [PluriWaveApp] without it.
final FuenteEmisorasAuto? fuenteAuto;
/// Purchase I/O port (iap-freemium-unlock, Design ADR-2). Optional and
/// `null` by default — mirrors [fuenteAuto]'s injection shape, so every
/// pre-existing test that constructs [PluriWaveApp] without it never
/// touches the real `in_app_purchase` plugin channel. `main.dart` wires
/// the real [ServicioComprasPlayBilling].
final PuertoCompras? compras;
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
// iap-freemium-unlock (Design ADR-3): registered FIRST so every
// provider below can read it via `context.read` inside a lazy
// `esPremium` closure — `MultiProvider` nests top-to-bottom, so only
// a provider ABOVE a given one is reachable from its own `create`.
ChangeNotifierProvider(
create: (_) => EstadoEntitlement(prefs: prefs, compras: compras),
),
ChangeNotifierProvider(
create:
(_) => EstadoRadio(
(context) => EstadoRadio(
prefs: prefs,
dispositivoAudio: ServicioDispositivoAudioReal(),
fuenteAuto: fuenteAuto,
esPremium: () => context.read<EstadoEntitlement>().esPremium,
),
),
// Domain notifiers (S4-R1/R2/R3). Created and disposed by EstadoRadio
@@ -68,13 +112,28 @@ class PluriWaveApp extends StatelessWidget {
ListenableProvider<EstadoBusqueda>(
create: (context) => context.read<EstadoRadio>().busqueda,
),
ChangeNotifierProvider(create: (_) => EstadoAlarmas(prefs: prefs)),
ChangeNotifierProvider(
create:
(context) => EstadoAlarmas(
prefs: prefs,
esPremium: () => context.read<EstadoEntitlement>().esPremium,
),
),
ChangeNotifierProvider(
create: (_) => EstadoIdioma(sharedPreferences: prefs),
),
// Design ADR-8: root-to-root navigation state. `_PaginaPrincipal`
// watches this instead of owning `_indice` locally.
ChangeNotifierProvider(create: (_) => EstadoNavegacionRaiz()),
// iap-freemium-unlock (Design "Interfaces / Contracts", ADR-6): a
// plain (non-notifier) `Provider` — session-scoped ad state, never
// rebuilds the widget tree itself.
Provider<ServicioAnuncios>(
create:
(context) => ServicioAnuncios(
esPremium: () => context.read<EstadoEntitlement>().esPremium,
),
),
],
child: Consumer<EstadoIdioma>(
builder:
@@ -217,9 +276,17 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
final indice = navegacion.indice;
return PluriWaveScaffold(
body: SafeArea(
top: false,
child: AnimatedSwitcher(
// ad-display spec "Persistent Top Banner, Never Overlapping Content"
// (design.md ADR-6): a `Column` sibling, NEVER a `Stack`/overlay — the
// banner RESERVES its own space above the existing body instead of
// covering any of it. `BannerAnuncioSuperior` itself collapses to
// `SizedBox.shrink()` (zero layout impact) for premium/unloaded, and
// (FIX 1, code review) owns its OWN top `SafeArea` internally — this
// level no longer wraps it in an unconditional `SafeArea`, which used
// to reserve `MediaQuery.padding.top` even for a zero-size collapsed
// child, leaving a permanent blank status-bar-height strip.
body: construirCuerpoPrincipal(
contenido: AnimatedSwitcher(
duration: context.pluriMotion.normal,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
@@ -281,10 +348,20 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal>
// pre-existing PluriOnboardingDialog is an unrelated "what's new"/help
// modal that keeps its own independent per-version due-or-not logic,
// completely unchanged by this sequencing.
//
// The 9-screen help/tutorial carousel (PantallaTutorialAyuda) runs
// BETWEEN the two: after the welcome screen (fresh installs only) and
// before the what's-new dialog. Unlike the welcome screen, the tutorial
// shows once to EVERY install -- fresh AND existing -- via its own plain
// one-time flag (ServicioTutorialAyuda), which is what makes an
// already-installed app show it once after updating to this version.
Future<void> _mostrarFlujoPrimerLanzamiento() async {
if (mounted) {
await PantallaBienvenida.mostrarSiProcede(context);
}
if (mounted) {
await PantallaTutorialAyuda.mostrarSiProcede(context);
}
await _mostrarOnboardingInicial();
}
+356 -29
View File
@@ -9,15 +9,31 @@ import '../servicios/servicio_alarmas.dart';
import '../servicios/servicio_alarmas_android.dart';
import '../servicios/servicio_programacion_alarmas.dart';
/// Distinct "limit reached" signal (Design ADR-5, freemium-gating spec
/// "Alarm Count Cap At 5"): kept SEPARATE from [EstadoAlarmas.error], which
/// stays reserved for native scheduling failures — overloading it would
/// surface a free-tier limit as a scheduling failure in `app.dart`'s global
/// snackbar path.
enum ResultadoGuardarAlarma { guardada, limiteAlcanzado }
class EstadoAlarmas extends ChangeNotifier {
EstadoAlarmas({
ServicioAlarmas? servicio,
PuertoAlarmasAndroid? android,
SharedPreferences? prefs,
bool iniciarAutomaticamente = true,
// iap-freemium-unlock (Design ADR-3): entitlement query, mirroring
// `EstadoGrabacion`'s `emisoraActual` callback-injection shape rather
// than a direct `EstadoEntitlement` dependency (this notifier must stay
// constructible with zero widget-tree/Provider context). REQUIRED on
// purpose: an optional parameter with any default lets a forgotten
// wiring compile and silently pick a tier, and no test can catch that.
// Callers must state the entitlement source explicitly.
required bool Function() esPremium,
}) : servicio = servicio ?? ServicioAlarmas(prefs: prefs),
android = android ?? ServicioAlarmasAndroid(),
_prefs = prefs {
_prefs = prefs,
_esPremium = esPremium {
// Decision 2.1 (snooze sync): the native layer reports its own snoozes
// back through alarmFired/snoozed; record them here so the Flutter
// config stays the single source of truth.
@@ -32,8 +48,12 @@ class EstadoAlarmas extends ChangeNotifier {
final ServicioAlarmas servicio;
final PuertoAlarmasAndroid android;
final SharedPreferences? _prefs;
final bool Function() _esPremium;
static const _keyExencionBateriaSolicitada = 'bateria_exencion_solicitada';
/// Free-tier alarm cap (freemium-gating spec "Alarm Count Cap At 5").
static const maxAlarmasFree = 5;
List<AlarmaMusical> _alarmas = [];
List<RangoVacaciones> _vacaciones = [];
List<ExcepcionAlarma> _excepciones = [];
@@ -90,6 +110,7 @@ class EstadoAlarmas extends ChangeNotifier {
);
await _sincronizarTodas();
await cargarDiagnostico();
await cargarFallosNativos();
_activarRefresco();
} catch (e) {
_error = 'No se pudieron cargar las alarmas: $e';
@@ -100,7 +121,26 @@ class EstadoAlarmas extends ChangeNotifier {
}
}
Future<void> guardarAlarma(AlarmaMusical alarma) async {
/// Pure query (freemium-gating spec "Alarm Count Cap At 5"): whether a NEW
/// alarm may be created right now. Counts ALL alarms regardless of
/// `activa` (Spec "6th alarm creation is blocked" — "any enabled state").
/// Always `true` for premium (no cap). Editing an existing id is never
/// subject to this — see [guardarAlarma]'s own new-vs-edit check.
bool puedeCrearAlarma() => _esPremium() || _alarmas.length < maxAlarmasFree;
Future<ResultadoGuardarAlarma> guardarAlarma(AlarmaMusical alarma) async {
// Gate BEFORE any native scheduling attempt (freemium-gating spec "6th
// alarm creation is blocked": "no native scheduling is attempted").
// Editing an alarm that already exists (by id) is NEVER capped — only
// genuinely NEW creation counts against the limit (Spec "Editing an
// existing alarm is unaffected", grandfathering).
final esAlarmaNueva = !_alarmas.any((a) => a.id == alarma.id);
if (esAlarmaNueva && !puedeCrearAlarma()) {
debugPrint(
'[PluriWave][alarmas] guardar bloqueado por limite free id=${alarma.id}',
);
return ResultadoGuardarAlarma.limiteAlcanzado;
}
debugPrint(
'[PluriWave][alarmas] guardar id=${alarma.id} activa=${alarma.activa} hora=${alarma.hora}:${alarma.minuto} tipo=${alarma.tipoProgramacion.name}',
);
@@ -117,10 +157,14 @@ class EstadoAlarmas extends ChangeNotifier {
'[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}',
);
await android.programar(guardada);
await _limpiarFalloProgramacion(guardada.id);
await _verificarRegistroNativo(guardada.id);
} catch (e) {
_error = 'Alarma guardada, pero Android no pudo programarla todavía: $e';
await _registrarFalloProgramacion(alarma.id);
}
notifyListeners();
return ResultadoGuardarAlarma.guardada;
}
Future<void> refrescarProgramacion() async {
@@ -198,6 +242,89 @@ class EstadoAlarmas extends ChangeNotifier {
}
}
/// Records a main-alarm scheduling failure per-alarm (fix/alarmas-fallos-
/// silenciosos): before this, a failed `android.programar` call only set
/// the transient, alarm-agnostic [_error] string — the alarms list had no
/// way to mark the SPECIFIC card affected, so a failed alarm rendered
/// exactly like a working one. Never rethrows: a failure recording its own
/// failure must not mask the ORIGINAL scheduling error already captured in
/// [_error].
Future<void> _registrarFalloProgramacion(
String alarmaId, {
String tipo = ExcepcionAlarma.tipoFalloProgramacion,
}) async {
try {
final alarma = _buscarAlarma(alarmaId);
final ejecucion = alarma?.proximaProgramable ?? servicio.ahora();
final config = await servicio.registrarFalloProgramacion(
alarmaId,
ejecucion,
tipo,
);
_aplicar(config);
} catch (e) {
debugPrint('[PluriWave][alarmas] registrar fallo programacion ERROR $e');
}
}
/// Clears a previously recorded scheduling failure once a later attempt
/// for the same alarm succeeds (D5-style recovery, mirroring how [_error]
/// itself already clears on a successful retry). Type-scoped: a
/// successful `android.programar` call only proves the MAIN alarm
/// registration (and, transitively, that any stale post-boot reschedule
/// failure no longer applies) -- it says nothing about the pre-notice or
/// foreground-service subsystems, so those are left untouched here.
Future<void> _limpiarFalloProgramacion(String alarmaId) async {
try {
var config = await servicio.limpiarFalloProgramacion(
alarmaId,
ExcepcionAlarma.tipoFalloProgramacion,
);
_aplicar(config);
config = await servicio.limpiarFalloProgramacion(
alarmaId,
ExcepcionAlarma.tipoFalloReprogramacionArranque,
);
_aplicar(config);
} catch (e) {
debugPrint('[PluriWave][alarmas] limpiar fallo programacion ERROR $e');
}
}
/// Verifies the OS genuinely registered [alarmaId] after a successful
/// `android.programar` call (fix/alarmas-fallos-silenciosos, item 3): a
/// scheduling call that returns without throwing is not proof enough by
/// itself -- this cross-check against the native pending-alarm count is
/// exactly what would have caught the reported "alarm never rings, no
/// exception anywhere" case. Compares a FRESH native count against how
/// many alarms Dart believes are currently active-with-a-next-run; a
/// native count that falls short is recorded as a failure for the alarm
/// the user just interacted with. Never overrides an already-caught
/// programar() exception (this only runs on ITS success path).
Future<void> _verificarRegistroNativo(String alarmaId) async {
try {
final alarma = _buscarAlarma(alarmaId);
if (alarma == null ||
!alarma.activa ||
alarma.proximaProgramable == null) {
return;
}
final diag = await android.diagnostico();
_diagnostico = diag;
final esperadas =
_alarmas
.where((a) => a.activa && a.proximaProgramable != null)
.length;
if (diag.alarmasNativasPendientes < esperadas) {
_error =
'Alarma guardada, pero el sistema no confirma que quedó registrada.';
await _registrarFalloProgramacion(alarmaId);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] verificar registro nativo ERROR $e');
}
}
Future<void> cambiarActiva(AlarmaMusical alarma, bool activa) async {
await guardarAlarma(alarma.copyWith(activa: activa));
}
@@ -229,29 +356,74 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners();
}
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
_error = null;
// The snooze anchors to the occurrence that is RINGING — never a future
// one. When the native fire works, the fire-time sync advances
// proximaEjecucion to the NEXT day before the user can even tap snooze,
// so anchoring to proximaEjecucion re-armed "posponer 3" a full day out
// (observed on-device: snooze armed for tomorrow 23:02). The ringing
// occurrence is the newest candidate not meaningfully in the future:
// snoozeOrigen (a re-snooze keeps the original anchor), then
// proximaEjecucion (watchdog path: still today's just-due occurrence),
// then ultimaEjecucionGestionada (native-fire path: the sync recorded
// the ringing occurrence there), then now.
/// The occurrence that is ACTUALLY ringing right now — the anchor both
/// ring-screen actions (Posponer and Detener) must close.
///
/// It is NEVER a future occurrence. When the native fire works, the
/// fire-time sync advances `proximaEjecucion` to the next one before the
/// user can even reach the ring screen, so taking `proximaEjecucion`
/// unguarded closes an occurrence that has not happened yet. For snooze
/// that showed up as "posponer 3" arming a full day out (observed
/// on-device: tomorrow 23:02). For stop it was worse and silent: the
/// future occurrence was recorded in `ultimaEjecucionGestionada`, which
/// `ServicioProgramacionAlarmas._esValida` then rejects for real — so a
/// Monday-only alarm stopped today simply never rang next Monday, and
/// every sibling alarm outranked it in the "next alarm" banner.
///
/// The candidates, newest first, each gated on "not meaningfully in the
/// future": [AlarmaMusical.snoozeOrigen] (a re-snooze keeps the original
/// anchor), then [AlarmaMusical.proximaEjecucion] (watchdog path: still
/// today's just-due occurrence), then
/// [AlarmaMusical.ultimaEjecucionGestionada] (native-fire path: the sync
/// recorded the ringing occurrence there), then now.
///
/// ONE helper for BOTH callers on purpose. This guard was written for
/// `posponerAlarma` alone (`9c7cf4e`) while `finalizarEjecucion` sat ten
/// lines below with the identical hazard and no guard, and it stayed that
/// way until a user lost a whole week of alarms. Do not re-inline it.
DateTime _ocurrenciaSonando(AlarmaMusical? alarma) =>
_ocurrenciaValida(alarma);
/// How far ahead the PRE-NOTICE notification's occurrence may legitimately
/// sit: it is armed exactly this far before the alarm, so between the
/// reminder appearing and the user tapping it, the occurrence has not
/// happened yet and rejecting it would be wrong.
///
/// Mirrors `AlarmScheduler.PRE_NOTICE_MILLIS` (30 min). Both sides must
/// agree or one of them starts discarding perfectly good anchors.
static const ventanaPreaviso = Duration(minutes: 30);
/// [_ocurrenciaSonando] generalized with a forward allowance, and with an
/// externally-supplied [propuesta] taking priority when it survives the
/// same check.
///
/// [propuesta] is what the NATIVE side reported as the occurrence its
/// notification was about. It is trusted first — it is better evidence than
/// anything reconstructed here — but only after being validated, because it
/// can arrive as a fallback the caller invented (`app.dart` substitutes
/// `alarma.proximaEjecucion` when the native event carries no occurrence,
/// and that field may already point at tomorrow).
DateTime _ocurrenciaValida(
AlarmaMusical? alarma, {
DateTime? propuesta,
Duration margen = Duration.zero,
}) {
final ahora = servicio.ahora();
final limite = ahora.add(
ServicioProgramacionAlarmas.toleranciaDisparoInminente,
margen + ServicioProgramacionAlarmas.toleranciaDisparoInminente,
);
DateTime? sonando(DateTime? candidata) =>
candidata != null && !candidata.isAfter(limite) ? candidata : null;
final ejecucion =
sonando(alarma.snoozeOrigen) ??
sonando(alarma.proximaEjecucion) ??
sonando(alarma.ultimaEjecucionGestionada) ??
return sonando(propuesta) ??
sonando(alarma?.snoozeOrigen) ??
sonando(alarma?.proximaEjecucion) ??
sonando(alarma?.ultimaEjecucionGestionada) ??
ahora;
}
Future<void> posponerAlarma(AlarmaMusical alarma, int minutos) async {
_error = null;
final ejecucion = _ocurrenciaSonando(alarma);
debugPrint(
'[PluriWave][alarmas] posponer id=${alarma.id} minutos=$minutos ejecucion=${ejecucion.toIso8601String()}',
);
@@ -267,14 +439,34 @@ class EstadoAlarmas extends ChangeNotifier {
if (actualizada != null) {
await _solicitarPermisosNecesariosParaAlarma();
await android.programar(actualizada);
await _limpiarFalloProgramacion(alarma.id);
}
} catch (e) {
_error =
'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e';
await _registrarFalloProgramacion(alarma.id);
}
notifyListeners();
}
/// "Posponer" on the PRE-NOTICE notification.
///
/// Reported on-device: this left the alarm snoozed for 1400+ minutes — a
/// whole day — instead of the configured few. The native lane got its guard
/// in 7054a4c, but Dart runs AFTERWARDS on this path (the receiver's
/// `postponeNext` fires, then `startActivity`, then this) and persists +
/// reschedules, so whatever it computes is the value that survives. It was
/// the last snooze path in the codebase with NO occurrence guard at all:
/// it took [ejecucion] on faith and turned it straight into the next alarm.
///
/// And [ejecucion] is not trustworthy: `app.dart` falls back to
/// `alarma.proximaEjecucion` whenever the native event carries no
/// occurrence, and that field can already point at tomorrow.
///
/// Validated through [_ocurrenciaValida] with a [ventanaPreaviso]
/// allowance — unlike the ringing-screen paths this occurrence legitimately
/// has NOT arrived yet, which is exactly why `_ocurrenciaSonando` could not
/// simply be reused here.
Future<void> posponerProximaDesdePreaviso(
AlarmaMusical alarma,
int minutos,
@@ -282,14 +474,23 @@ class EstadoAlarmas extends ChangeNotifier {
) async {
_error = null;
final seguros = _snoozeSeguro(minutos);
final snoozeHasta = ejecucion.add(Duration(minutes: seguros));
final ocurrencia = _ocurrenciaValida(
alarma,
propuesta: ejecucion,
margen: ventanaPreaviso,
);
final snoozeHasta = ocurrencia.add(Duration(minutes: seguros));
debugPrint(
'[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros ejecucion=${ejecucion.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}',
'[PluriWave][alarmas] posponer desde preaviso id=${alarma.id} minutos=$seguros propuesta=${ejecucion.toIso8601String()} ocurrencia=${ocurrencia.toIso8601String()} hasta=${snoozeHasta.toIso8601String()}',
);
await android.ocultarNotificacionAlarma(alarma.id);
final config = await servicio.posponerEjecucionHasta(
alarma.id,
ejecucion,
// The VALIDATED occurrence, not the raw parameter: this becomes both
// `snoozeOrigen` and `ultimaEjecucionGestionada`, so passing the
// unchecked value here would poison the very state a9da855/0430059
// exist to keep clean.
ocurrencia,
snoozeHasta,
);
_aplicar(config);
@@ -298,10 +499,12 @@ class EstadoAlarmas extends ChangeNotifier {
if (actualizada != null) {
await _solicitarPermisosNecesariosParaAlarma();
await android.programar(actualizada);
await _limpiarFalloProgramacion(alarma.id);
}
} catch (e) {
_error =
'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e';
await _registrarFalloProgramacion(alarma.id);
}
notifyListeners();
}
@@ -310,11 +513,11 @@ class EstadoAlarmas extends ChangeNotifier {
debugPrint('[PluriWave][alarmas] finalizar ejecucion id=$alarmaId');
_error = null;
final alarma = _buscarAlarma(alarmaId);
final ejecucion =
alarma?.snoozeOrigen ??
alarma?.proximaEjecucion ??
alarma?.snoozeHasta ??
DateTime.now();
// Same anchor as posponerAlarma, through the same helper: closing a
// future occurrence here marks it handled, and _esValida then skips it
// for real -- the alarm silently never rings that day. See
// [_ocurrenciaSonando].
final ejecucion = _ocurrenciaSonando(alarma);
await android.ocultarNotificacionAlarma(alarmaId);
// Stop/Snooze Result Verification (SS-2a/SS-2b): the Stop path calls the
// id-agnostic fail-safe stop directly (it always targets whatever is
@@ -344,9 +547,20 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners();
}
Future<void> crearRangoVacaciones(RangoVacaciones rango) async {
/// Full premium gate (freemium-gating spec "Gated Feature Set (Exactly
/// 4)" — alarm vacations, unlike the alarm cap above, are gated entirely,
/// not counted): returns `false` without persisting anything when the
/// caller is free tier.
Future<bool> crearRangoVacaciones(RangoVacaciones rango) async {
if (!_esPremium()) {
debugPrint(
'[PluriWave][alarmas] crear vacaciones bloqueado (free) id=${rango.id}',
);
return false;
}
final nuevos = [..._vacaciones, rango];
await guardarVacaciones(nuevos);
return true;
}
Future<void> eliminarRangoVacaciones(String id) async {
@@ -354,6 +568,19 @@ class EstadoAlarmas extends ChangeNotifier {
await guardarVacaciones(nuevos);
}
/// Issue 1 (feedback-pruebas): replaces the range with the same [id] in
/// place -- the counterpart `crearRangoVacaciones`/`eliminarRangoVacaciones`
/// were missing before this fix, leaving no way to fix a mistake in an
/// already-saved range (including the currently ACTIVE one, since a
/// freshly created range starts active immediately).
Future<void> editarRangoVacaciones(RangoVacaciones rango) async {
final nuevos = [
for (final actual in _vacaciones)
if (actual.id == rango.id) rango else actual,
];
await guardarVacaciones(nuevos);
}
// ── Vacation queries (design ADR-6, WU9) ──────────────────────────────
// Four PURE queries: none writes, none reschedules, none touches the
// native bridge. Read-only over `_alarmas`/`_vacaciones`. Every method
@@ -434,6 +661,36 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners();
}
/// Drains the failures the NATIVE side recorded on its own and turns each
/// into a per-alarm exception, so the card can mark it.
///
/// These three paths used to log to logcat and stop there: a pre-notice
/// that could not be armed, a refused foreground-service start when the
/// alarm should have rung, and a per-alarm reschedule that failed after a
/// reboot. None of them run inside a Dart call, so nothing on this side
/// ever learned they happened — an alarm could sit switched on in the
/// list having never reached the OS. Reading them at startup is what
/// makes the reported "as if there were no alarm" visible.
///
/// Deliberately tolerant: a failed read is logged and swallowed, never
/// surfaced as an alarm error, because a diagnostics gap must not look
/// like a scheduling problem.
Future<void> cargarFallosNativos() async {
try {
final fallos = await android.fallosNativosProgramacion();
for (final fallo in fallos) {
await _registrarFalloProgramacion(fallo.alarmaId, tipo: fallo.tipo);
}
if (fallos.isNotEmpty) {
debugPrint(
'[PluriWave][alarmas] fallos nativos recogidos=${fallos.length}',
);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] cargar fallos nativos ERROR $e');
}
}
/// Records a snooze the native layer performed by itself (Decision 2.1).
/// The native scheduler already re-registered setAlarmClock, so this only
/// persists the canonical state — it MUST NOT call android.programar again.
@@ -533,6 +790,60 @@ class EstadoAlarmas extends ChangeNotifier {
debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e');
}
await _importarSnoozesNativosActivos();
await _importarFallosProgramacionNativos();
}
/// Cold-start sync (fix/alarmas-fallos-silenciosos, item 2): imports
/// scheduling-reliability failures the NATIVE side recorded on its own --
/// a pre-notice `SecurityException`, a refused foreground-service start,
/// or a per-alarm reschedule failure after boot/unlock -- none of which
/// ever go through a Dart method-channel call that could throw. Without
/// this sync, these three failures stayed invisible forever (only
/// logcat), even after this app-launch fix reads them.
Future<void> _importarFallosProgramacionNativos() async {
try {
final fallos = await android.obtenerFallosProgramacionNativos();
final reportadoPorAlarma = {
for (final fallo in fallos) fallo.alarmaId: fallo,
};
// Reconcile stale copies: the native side clears its OWN record the
// next time that specific subsystem succeeds (pre-notice/foreground-
// service), so an alarm previously imported with one of those tipos
// that is no longer reported here means it already recovered --
// without this, the card would keep showing a problem that fixed
// itself. `tipoFalloProgramacion`/`tipoFalloReprogramacionArranque`
// are NOT reconciled here -- those already clear on the Dart side's
// own successful `android.programar` calls.
for (final alarma in _alarmas) {
final actual = ultimaExcepcionPara(alarma.id);
final esTipoReconciliable =
actual != null &&
(actual.tipo == ExcepcionAlarma.tipoFalloPreaviso ||
actual.tipo == ExcepcionAlarma.tipoFalloServicioSonido);
if (esTipoReconciliable && !reportadoPorAlarma.containsKey(alarma.id)) {
final config = await servicio.limpiarFalloProgramacion(
alarma.id,
actual.tipo,
);
_aplicar(config);
}
}
for (final fallo in fallos) {
final config = await servicio.registrarFalloProgramacion(
fallo.alarmaId,
fallo.ocurridoEn,
fallo.tipo,
);
_aplicar(config);
}
if (fallos.isNotEmpty) {
debugPrint(
'[PluriWave][alarmas] fallos nativos importados count=${fallos.length}',
);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] importar fallos nativos ERROR $e');
}
}
/// Cold-start half of Decision 2.1: imports snoozes the native scheduler
@@ -609,8 +920,24 @@ class EstadoAlarmas extends ChangeNotifier {
if (_alarmas.any((alarma) => alarma.activa)) {
await _solicitarPermisosNecesariosParaAlarma();
}
// Per-alarm try/catch (fix/alarmas-fallos-silenciosos): before this, a
// SINGLE alarm's `programar` throw aborted the whole loop, so every
// sibling AFTER the failing one in `_alarmas` silently never reached
// `android.programar` on this pass -- on a fresh launch (`inicializar`)
// that meant some active alarms were never (re)armed with the OS at all,
// with nothing to show for it beyond a generic load error. Each alarm
// now gets its own outcome recorded, and one failure never blocks its
// siblings.
for (final alarma in _alarmas) {
await android.programar(alarma);
try {
await android.programar(alarma);
await _limpiarFalloProgramacion(alarma.id);
} catch (e) {
debugPrint(
'[PluriWave][alarmas] sincronizar todas ERROR id=${alarma.id} $e',
);
await _registrarFalloProgramacion(alarma.id);
}
}
}
+70 -1
View File
@@ -37,7 +37,9 @@ class EstadoEcualizador extends ChangeNotifier {
_presetsPersonalizadosService =
presetsPersonalizadosService ?? ServicioPresetsPersonalizados(),
_dispositivoAudio = dispositivoAudio,
_emisoraActualUuid = emisoraActualUuid ?? (() => null);
_emisoraActualUuid = emisoraActualUuid ?? (() => null) {
_escucharCambiosEqDesdeHandler();
}
final ServicioAudio audio;
final ServicioEcualizador servicio;
@@ -84,6 +86,20 @@ class EstadoEcualizador extends ChangeNotifier {
StreamSubscription<DispositivoAudio>? _deviceSub;
Future<void>? _refrescoEnCurso;
/// Catches a car/notification-initiated EQ change that bypasses this
/// class entirely (eq-sync-superficies): `accionEqToggle` calls
/// `PluriWaveAudioHandler.setEcualizadorActivo` directly, and
/// `seleccionarPresetEqPorMediaId` calls `aplicarPreset` directly — both
/// mutate ONLY the handler's own `_ecualizadorActivo`/`_presetActual`
/// fields, never [audio]'s owner ([EstadoEcualizador]). Mirrors the exact
/// shape `EstadoRadio._escucharErroresReproduccion` already uses for the
/// equivalent `playFromMediaId` gap: on every [ServicioAudio.estadoStream]
/// tick (which the handler already re-emits on any EQ change via
/// `_actualizarControlesEq()`, regardless of who triggered it), compare
/// the handler's current EQ state against our cached copy and adopt it on
/// divergence.
StreamSubscription<EstadoReproduccion>? _suscripcionEstadoAudioEq;
PresetEcualizador get presetActual => _presetActual;
PresetEcualizador get presetPrincipal => _presetPrincipal;
bool get activo => _activo;
@@ -337,6 +353,58 @@ class EstadoEcualizador extends ChangeNotifier {
notifyListeners();
}
/// Subscribes to [ServicioAudio.estadoStream] to catch a
/// car/notification-initiated EQ change (see [_suscripcionEstadoAudioEq]
/// doc for the full rationale).
void _escucharCambiosEqDesdeHandler() {
_suscripcionEstadoAudioEq = audio.estadoStream.listen((_) {
unawaited(_resincronizarConHandler());
});
}
/// Compares the handler's live EQ state ([ServicioAudio.ecualizadorActivo],
/// [ServicioAudio.presetActual]) against our cached [_activo]/
/// [_presetActual] and adopts the handler's value on divergence.
///
/// Deliberately never calls back into [audio] here (no
/// `setEcualizadorActivo`/`aplicarPreset`): doing so would re-trigger the
/// handler's own `_actualizarControlesEq()` re-push, which would tick
/// [ServicioAudio.estadoStream] again and re-enter this method forever.
/// Only a local field write, [servicio] persistence and [notifyListeners]
/// happen here, so a divergence is resolved in a single pass.
///
/// Wrapped in try/catch like every other handler-facing read in this
/// class (e.g. [_sembrarDispositivoActual]): a test double or an
/// unexpected platform state that makes [audio]'s EQ getters unavailable
/// must never crash the stream subscription — it just skips this tick.
Future<void> _resincronizarConHandler() async {
try {
final activoHandler = audio.ecualizadorActivo;
final presetHandler = audio.presetActual;
final activoDiverge = activoHandler != _activo;
final presetDiverge = presetHandler != _presetActual;
if (!activoDiverge && !presetDiverge) return;
if (activoDiverge) {
_activo = activoHandler;
// Closes the persistence gap: `PluriWaveAudioHandler` never
// persists anything itself (it must stay headless-constructible,
// with zero SharedPreferences/Provider access) — [servicio] is the
// only owner of EQ persistence, so a car/notification toggle must
// be saved HERE or it is lost on the next process restart.
await servicio.guardarActivo(activoHandler);
}
if (presetDiverge) {
_presetActual = presetHandler;
}
notifyListeners();
} catch (_) {
// See doc above — never let a resync failure crash the app.
}
}
/// Applies [preset] to the audio engine and tracks it as current
/// WITHOUT persisting it (used when switching stations).
Future<void> aplicarPresetActivo(PresetEcualizador preset) async {
@@ -673,6 +741,7 @@ class EstadoEcualizador extends ChangeNotifier {
@override
void dispose() {
_deviceSub?.cancel();
_suscripcionEstadoAudioEq?.cancel();
super.dispose();
}
}
+193
View File
@@ -0,0 +1,193 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../servicios/servicio_audio.dart' show notificarDesbloqueoAuto;
import '../servicios/servicio_compras.dart';
/// Versioned persistence key (Design ADR-1) for the permanent, non-consumable
/// premium unlock. Older builds that predate this key simply never read it —
/// no migration needed (Rollout "Versioned key ... is ignored by older
/// builds").
const _keyPremium = 'compra_premium_v1';
/// Headless-safe entitlement read (Design ADR-1, Spec "Headless-Safe
/// Entitlement Read"): resolves the persisted premium flag directly from
/// prefs, with NO `BuildContext`/`Provider` dependency. Mirrors
/// `FuenteMusicaLocalAutoImpl._resolverPrefs()`'s
/// inject-or-`getInstance()` convention (`musica_local_auto.dart:163`) —
/// this is what `PluriWaveAudioHandler` calls, since it registers before
/// `runApp` and no widget tree (therefore no `Provider`) exists yet.
///
/// Absent key = free tier (Rollout "Additive and prefs-backed; absent key =
/// free"). Never throws — a `SharedPreferences.getInstance()` failure would
/// propagate here exactly like the persisted read failing, which the caller
/// (Design ADR-2 "fail-open") must treat as "trust the last known state",
/// not this function's job to catch.
Future<bool> esPremiumPersistido({SharedPreferences? prefs}) async {
final resueltas = prefs ?? await SharedPreferences.getInstance();
return resueltas.getBool(_keyPremium) ?? false;
}
/// User-facing, non-error-text outcomes [EstadoEntitlement] can expose (FIX
/// 3, code review): the UI layer (`hoja_premium.dart`) has no BuildContext
/// here, so this file never carries localized/user-facing STRINGS itself —
/// only this typed signal, mapped to a localized message by the widget.
/// Cleared back to `null` once consumed ([EstadoEntitlement.consumirResultadoUsuario]).
enum ResultadoEntitlementUsuario {
/// A purchase or restore attempt failed (network, billing error, product
/// not yet available in the store, etc). This NEVER carries the raw
/// exception/developer string from [EventoCompra.mensaje] — the UI maps
/// this enum value to ONE generic localized message, never the internal
/// diagnostic text.
error,
/// [EstadoEntitlement.restaurar] completed successfully but found nothing
/// to restore. Distinct from [error]: an expected, non-error outcome
/// (Spec "Restore Purchases" — "finds nothing -> stays free tier with a
/// clear non-error result").
restauracionSinCompras,
}
/// Cross-cutting entitlement notifier (Design ADR-1), idiomatic
/// `EstadoIdioma`-shaped `ChangeNotifier`: UI layers `context.watch`/`read`
/// this; headless callers (Android Auto) use [esPremiumPersistido] instead,
/// since no `Provider` exists on that path.
class EstadoEntitlement extends ChangeNotifier {
EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras})
: _prefs = prefs,
_compras = compras {
final flujo = _compras;
if (flujo != null) {
_comprasSub = flujo.eventos.listen(_alRecibirEvento);
}
_cargar();
}
/// The single non-consumable product id (Design "Interfaces / Contracts"),
/// re-exported here so UI/paywall code depends on ONE canonical constant
/// rather than reaching into `servicio_compras.dart` for it.
static const idProducto = ServicioComprasPlayBilling.idProducto;
final SharedPreferences? _prefs;
final PuertoCompras? _compras;
StreamSubscription<EventoCompra>? _comprasSub;
bool _esPremium = false;
bool _compraEnCurso = false;
ResultadoEntitlementUsuario? _resultadoUsuario;
bool get esPremium => _esPremium;
bool get compraEnCurso => _compraEnCurso;
/// FIX 3 (code review): the user-facing signal for a failed purchase/
/// restore, or a restore that found nothing. `null` when there is nothing
/// to show — see [consumirResultadoUsuario].
ResultadoEntitlementUsuario? get resultadoUsuario => _resultadoUsuario;
/// Clears [resultadoUsuario] once the UI has consumed/displayed it.
/// A no-op (no extra notification) if there is nothing to clear.
void consumirResultadoUsuario() {
if (_resultadoUsuario == null) return;
_resultadoUsuario = null;
notifyListeners();
}
Future<void> _cargar() async {
final prefs = await _resolverPrefs();
final premium = prefs.getBool(_keyPremium) ?? false;
if (premium != _esPremium) {
_esPremium = premium;
}
notifyListeners();
}
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
/// Starts the purchase flow (Spec "Successful purchase"). A no-op when
/// already premium (Spec "Already-purchased attempt is idempotent") — no
/// duplicate charge is even attempted.
Future<void> comprar() async {
if (_esPremium) return;
final compras = _compras;
if (compras == null) return;
_compraEnCurso = true;
// FIX 3 (code review): a fresh attempt clears any stale result left over
// from a previous failed attempt, so the UI never shows an outdated
// error/confirmation across two unrelated attempts.
_resultadoUsuario = null;
notifyListeners();
await compras.comprar();
}
/// Re-queries Play Billing for a prior purchase (Spec "Restore Purchases").
Future<void> restaurar() async {
final compras = _compras;
if (compras == null) return;
_compraEnCurso = true;
_resultadoUsuario = null;
notifyListeners();
await compras.restaurar();
}
Future<void> _alRecibirEvento(EventoCompra evento) async {
switch (evento.tipo) {
case TipoEventoCompra.comprada:
case TipoEventoCompra.restaurada:
await _desbloquear();
case TipoEventoCompra.cancelada:
// Spec "Purchase cancelled or failed": a user-INITIATED cancel
// stays free tier with no error surfaced — just stop the in-flight
// spinner. Not a failure, so no [resultadoUsuario] either.
_compraEnCurso = false;
notifyListeners();
case TipoEventoCompra.noEncontrada:
// FIX 3 (code review): "Restore finds nothing" is an expected,
// NON-error outcome (Spec "Restore Purchases") but `hoja_premium.dart`
// had zero feedback for it — the spinner just stopped with no
// confirmation. Distinct signal from [TipoEventoCompra.error].
_compraEnCurso = false;
_resultadoUsuario = ResultadoEntitlementUsuario.restauracionSinCompras;
notifyListeners();
case TipoEventoCompra.error:
// Fail-open (Design ADR-2): an error NEVER writes `false` over an
// already-premium flag, and never invents a `true` for a free user
// either — the persisted flag from `_cargar()` is left untouched.
//
// FIX 3 (code review): [EventoCompra.mensaje] (raw exception/
// developer text, e.g. "Producto no encontrado en Play Console") is
// DELIBERATELY discarded here — only the typed enum crosses into
// [resultadoUsuario], never the raw string. `hoja_premium.dart` maps
// it to ONE generic localized message.
_compraEnCurso = false;
_resultadoUsuario = ResultadoEntitlementUsuario.error;
notifyListeners();
case TipoEventoCompra.pendiente:
_compraEnCurso = true;
notifyListeners();
}
}
Future<void> _desbloquear() async {
final yaEraPremium = _esPremium;
_esPremium = true;
_compraEnCurso = false;
final prefs = await _resolverPrefs();
await prefs.setBool(_keyPremium, true);
notifyListeners();
if (!yaEraPremium) {
// Orchestrator-resolved open question (design.md): actively
// invalidate the Android Auto browse cache on the free -> premium
// transition, rather than waiting for the head unit's own re-bind.
notificarDesbloqueoAuto();
}
}
@override
void dispose() {
_comprasSub?.cancel();
super.dispose();
}
}
+51 -4
View File
@@ -18,14 +18,44 @@ import '../servicios/servicio_grabacion_radio.dart';
/// `EstadoRadio` consumers (S4-R5). Playback orchestration (stop recording on
/// pause/stop/station switch) stays in `EstadoRadio`, which keeps a reference
/// to this notifier.
/// Whether [emisora] is something the recorder can actually capture: a live
/// network stream.
///
/// The recorder opens the URL as an HTTP stream and writes the bytes to disk,
/// so anything else fails inside the HTTP client with a message no user can
/// act on ("Unsupported scheme 'content' in URI content://...").
///
/// This is not hypothetical tidiness. `PluriWaveAudioHandler._cambiarFuente`
/// sets `emisoraActual` for EVERY source it plays, so a local MP3 shows up
/// here as an `Emisora` whose `url` is the `content://` document URI it was
/// opened from. Recording a local file makes no sense anyway — it is already
/// on the device.
bool esEmisoraGrabable(Emisora emisora) {
final esquema = Uri.tryParse(emisora.url)?.scheme.toLowerCase();
return esquema == 'http' || esquema == 'https';
}
/// Distinct outcome signal for [EstadoGrabacion.iniciar] (freemium-gating
/// spec "Recording Start Gated"): [requierePremium] is NOT surfaced through
/// [EstadoGrabacion.iniciar]'s existing `alError` sink — the caller must
/// react by opening the paywall, a different UI than a plain error snackbar.
enum ResultadoIniciarGrabacion { iniciada, requierePremium, error }
class EstadoGrabacion extends ChangeNotifier {
EstadoGrabacion({
ServicioGrabacionRadio? servicio,
Emisora? Function()? emisoraActual,
void Function(String mensaje)? alError,
// iap-freemium-unlock (Design ADR-3): entitlement query, mirroring
// [_emisoraActual]'s callback-injection shape. REQUIRED on purpose: an
// optional parameter with any default lets a forgotten wiring compile
// and silently pick a tier, and no test can catch that. Callers must
// state the entitlement source explicitly.
required bool Function() esPremium,
}) : servicio = servicio ?? ServicioGrabacionRadio(),
_emisoraActual = emisoraActual ?? (() => null),
_alError = alError {
_alError = alError,
_esPremium = esPremium {
_suscripcion = this.servicio.estadoStream.listen((estado) {
if (estado.tipo == EstadoGrabacionRadioTipo.error &&
estado.error != null) {
@@ -48,6 +78,8 @@ class EstadoGrabacion extends ChangeNotifier {
/// User-visible error sink (EstadoRadio routes it to its snackbar stream).
final void Function(String mensaje)? _alError;
final bool Function() _esPremium;
StreamSubscription<EstadoGrabacionRadio>? _suscripcion;
AppLocalizations? _l10n;
@@ -70,16 +102,31 @@ class EstadoGrabacion extends ChangeNotifier {
int get maxBytes => servicio.maxBytes;
File? get ultimoArchivo => servicio.ultimoArchivo;
Future<void> iniciar({Duration? duracion}) async {
Future<ResultadoIniciarGrabacion> iniciar({Duration? duracion}) async {
// Freemium gate (freemium-gating spec "Free user starts a new
// recording"): the AUTHORITATIVE check, before touching the service at
// all. Management of already-existing recordings is untouched — this
// method only governs STARTING a new one.
if (!_esPremium()) {
return ResultadoIniciarGrabacion.requierePremium;
}
final actual = _emisoraActual();
if (actual == null) {
// `emisoraActual` is set by `_cambiarFuente` for EVERY source, local
// tracks included -- a local file becomes an `Emisora` whose `url` is the
// SAF `content://` URI it was opened from. Handing that to the recorder
// produced "Unsupported scheme 'content' in URI content://..." on screen,
// and it started happening only once local music playback existed: before
// that, whatever was playing was always a real station.
if (actual == null || !esEmisoraGrabable(actual)) {
_alError?.call(_textos.recordingSelectStationFirst);
return;
return ResultadoIniciarGrabacion.error;
}
try {
await servicio.iniciar(actual, duracion: duracion);
return ResultadoIniciarGrabacion.iniciada;
} catch (e) {
_alError?.call(_textos.recordingStartError(e.toString()));
return ResultadoIniciarGrabacion.error;
}
}
+95 -6
View File
@@ -47,6 +47,11 @@ class EstadoRadio extends ChangeNotifier {
Future<File> Function()? resolverArchivoCustom,
FuenteEmisorasAuto? fuenteAuto,
bool iniciarAutomaticamente = true,
// iap-freemium-unlock (Design ADR-3): threaded straight through to the
// internal `EstadoGrabacion` below — `EstadoRadio` itself has no gated
// behavior of its own, but it owns that notifier's construction, so it
// inherits the same "required, never defaulted" entitlement contract.
required bool Function() esPremium,
}) : audio = audio ?? ServicioAudio(),
favoritos = favoritos ?? ServicioFavoritos(),
radio = radio ?? ServicioRadio(),
@@ -66,6 +71,7 @@ class EstadoRadio extends ChangeNotifier {
servicio: servicioGrabacion ?? ServicioGrabacionRadio(prefs: prefs),
emisoraActual: () => emisoraActual,
alError: _errorController.add,
esPremium: esPremium,
);
busqueda = EstadoBusqueda(
radio: this.radio,
@@ -173,6 +179,10 @@ class EstadoRadio extends ChangeNotifier {
static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1';
static const _keyOrdenListas = 'orden_listas_emisoras_v1';
static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1';
// Issue 4 (feedback-pruebas): last-played station, so the Escuchar hero
// keeps showing "what I was listening to" (stopped, not playing) after a
// full app restart instead of going empty.
static const _keyUltimaEmisora = 'ultima_emisora_v1';
static const _timerSuenoPresetsDefecto = <int>[
180,
300,
@@ -300,6 +310,50 @@ class EstadoRadio extends ChangeNotifier {
_cargarEmisorasCustom(),
]);
await _normalizarEmisoraPreferida();
await _restaurarUltimaEmisora();
}
/// Issue 4 (feedback-pruebas): restores the last-played station as a
/// STOPPED `emisoraActual` on a cold start. Only fills the gap — if
/// something is ALREADY selected (a real play already ran concurrently),
/// this is a no-op. Never touches `audio`: no playback starts, no network
/// request is made, `estadoStream`/`estaSonando` stay at their fresh
/// "detenido" default, exactly like every other consumer of
/// `emisoraActual` already expects (they gate "is it playing" on the
/// separate playback-status stream, never on `emisoraActual != null`).
Future<void> _restaurarUltimaEmisora() async {
if (_emisoraSeleccionada != null || audio.emisoraActual != null) return;
try {
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyUltimaEmisora);
if (raw == null) return;
final mapa = jsonDecode(raw) as Map<String, dynamic>;
_emisoraSeleccionada = Emisora.fromMap(mapa);
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ultima_emisora',
detalle: 'restaurar',
razon: e.toString(),
);
}
}
/// Best-effort remembers [emisora] as the last used station (issue 4) so
/// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and-
/// forget, same treatment [reproducir] already gives other non-critical
/// side effects (e.g. `radio.registrarClick`) — a failed write here must
/// never block or fail actual playback.
Future<void> _persistirUltimaEmisora(Emisora emisora) async {
try {
final prefs = await _resolverPrefs();
await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap()));
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ultima_emisora',
detalle: 'persistir ${emisora.uuid}',
razon: e.toString(),
);
}
}
/// Escucha el stream de estado del audio y gestiona errores de reproducción.
@@ -321,6 +375,9 @@ class EstadoRadio extends ChangeNotifier {
final actual = audio.emisoraActual;
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
_emisoraSeleccionada = actual;
// Issue 4: an Android-Auto-initiated selection is a real station
// change too — remember it the same way `reproducir` does.
unawaited(_persistirUltimaEmisora(actual));
}
notifyListeners();
});
@@ -343,7 +400,14 @@ class EstadoRadio extends ChangeNotifier {
_cargandoPopulares = false;
// Design "live snapshot the source prefers": Android Auto's `Todas`
// folder mirrors the same populares list the phone just loaded.
_fuenteAuto?.actualizarSnapshot(todas: _populares);
//
// Fix `android-auto-orden`: pushes the SORTED [populares] getter, not
// the raw [_populares] field — the same [_ordenListas] setting the
// phone's own discovery lists (e.g. Buscar's `tendencias`) already
// sort by must also govern this folder's order, not the API's raw
// arrival order. `navegacion_auto.dart`'s `hijos()` no longer
// re-sorts, so whatever order arrives here IS what the driver sees.
_fuenteAuto?.actualizarSnapshot(todas: populares);
notifyListeners();
}
}
@@ -351,7 +415,13 @@ class EstadoRadio extends ChangeNotifier {
Future<void> cargarFavoritos() async {
_listaFavoritos = await favoritos.obtenerTodos();
await _normalizarEmisoraPreferida();
_fuenteAuto?.actualizarSnapshot(favoritos: _listaFavoritos);
// Fix `android-auto-orden`: pushes the documented manual-order accessor
// explicitly. [listaFavoritosManual] is backed by the same list as
// [_listaFavoritos] today (obtenerTodos() already returns the persisted
// manual order), but naming the intent here — "the exact order the
// Favoritos screen shows and reorders" — keeps this call from silently
// drifting onto a re-sorted list in a future refactor.
_fuenteAuto?.actualizarSnapshot(favoritos: listaFavoritosManual);
notifyListeners();
}
@@ -469,6 +539,16 @@ class EstadoRadio extends ChangeNotifier {
await prefs.setString(_keyOrdenListas, orden.name);
// Search owns its own listeners (S4-R3) but sorts with this preference.
busqueda.notificarCambioOrden();
// Fix `android-auto-orden`: Todas/Mis emisoras' Android Auto order is
// derived from this same setting (see cargarPopulares/
// _cargarEmisorasCustom above) — without an immediate re-push, a live
// car session would keep showing the OLD order until the next full
// reload instead of updating right away, same as the phone does via
// this method's own memoized getters.
_fuenteAuto?.actualizarSnapshot(
todas: populares,
misEmisoras: emisorasCustom,
);
notifyListeners();
}
@@ -508,6 +588,10 @@ class EstadoRadio extends ChangeNotifier {
}
_emisoraSeleccionada = emisora;
notifyListeners();
// Issue 4: remembers the station the user just picked so it survives a
// restart — fire-and-forget, same treatment as `radio.registrarClick`
// below (a persistence failure here must never block playback).
unawaited(_persistirUltimaEmisora(emisora));
try {
await audio.reproducir(emisora);
if (revision != _revisionReproduccion) return;
@@ -604,7 +688,9 @@ class EstadoRadio extends ChangeNotifier {
detalle: 'resolucion de ruta',
razon: e.toString(),
);
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
// Fix `android-auto-orden`: pushes the SORTED [emisorasCustom] getter
// (see the doc on this method's other 3 identical call sites below).
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners();
return;
}
@@ -630,7 +716,8 @@ class EstadoRadio extends ChangeNotifier {
razon: e.toString(),
);
}
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
// Fix `android-auto-orden`: sorted getter, not the raw field.
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners();
}
@@ -646,7 +733,8 @@ class EstadoRadio extends ChangeNotifier {
if (!await archivo.exists()) {
_emisorasCustom = [];
_customDegradado = false;
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
// Fix `android-auto-orden`: sorted getter, not the raw field.
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners();
return null;
}
@@ -659,7 +747,8 @@ class EstadoRadio extends ChangeNotifier {
detalle: archivo.path,
razon: e.toString(),
);
_fuenteAuto?.actualizarSnapshot(misEmisoras: _emisorasCustom);
// Fix `android-auto-orden`: sorted getter, not the raw field.
_fuenteAuto?.actualizarSnapshot(misEmisoras: emisorasCustom);
notifyListeners();
return null;
}
+79 -2
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "حذف النطاق",
"vacationsDefaultName": "إجازات",
"newVacationRangeTitle": "نطاق إجازة جديد",
"editVacationRangeTitle": "تعديل نطاق الإجازة",
"vacationDeleteConfirmTitle": "هل تريد حذف نطاق الإجازة؟",
"vacationDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.",
"startField": "البداية",
"endField": "النهاية",
"saveRangeAction": "حفظ النطاق",
@@ -601,7 +604,27 @@
"endLabel": "النهاية",
"equalizerDisable": "تعطيل المعادل",
"helpTitle": "المساعدة والشرح",
"helpSubtitle": "راجع ميزات PluriWave والنصائح والمستجدات.",
"helpSubtitle": "9 شاشات · شاهده مجددًا متى شئت",
"tutorialSkipAction": "تخطي",
"tutorialNextAction": "التالي",
"tutorialPage1Headline": "احفظ محطاتك ونظّمها في مجموعات",
"tutorialPage1Body": "اضغط على أيقونة القلب لحفظ محطة. في «محطاتك» يمكنك إنشاء مجموعات مثل «كل صباح» أو «السيارة» وإعادة ترتيبها بالسحب.",
"tutorialPage2Headline": "معادل صوت عام وآخر لكل محطة",
"tutorialPage2Body": "من الإعدادات تحدد المعادل الصوتي العام. ومن تشغيل محطة معينة يمكنك ضبط إعداد خاص بها، له الأولوية على الإعداد العام.",
"tutorialPage3Headline": "سجّل ما تستمع إليه",
"tutorialPage3Body": "من شريط أدوات المشغل، يحفظ زر «تسجيل» البث الأصلي. ستجد تسجيلاتك في الإعدادات › التسجيلات.",
"tutorialPage4Headline": "منبهات تتكيّف معك",
"tutorialPage4Body": "استيقظ على محطتك المفضلة، وأجّل المنبه 3 أو 5 أو 10 دقائق، وأضف فترات إجازة ليتخطى بعض المنبهات نفسه تلقائيًا.",
"tutorialPage5Headline": "محطاتك المفضلة، في السيارة أيضًا",
"tutorialPage5Body": "اربط هاتفك بـ Android Auto لتجد المفضلة وجميع المحطات ومحطاتك والموسيقى المحلية، بأزرار كبيرة مصممة للقيادة.",
"tutorialPage6Headline": "يعيد الاتصال تلقائيًا",
"tutorialPage6Body": "إذا انقطعت الإشارة، تعيد PluriWave المحاولة تلقائيًا وتستمر في عرض محطاتك المفضلة المحفوظة حتى بدون اتصال.",
"tutorialPage7Headline": "اختر مدة التأجيل",
"tutorialPage7Body": "عند رنين المنبه، لا يوجد خيار تأجيل واحد فقط: تختار 3 أو 5 أو 10 دقائق حسب ما تحتاجه في تلك اللحظة.",
"tutorialPage8Headline": "لم تجدها؟ أضفها بنفسك",
"tutorialPage8Body": "من «محطاتك» ← إضافة محطة مخصصة، الصق رابط بث محطة غير موجودة في نتائج البحث. تُحفظ في «محطاتك»، وتكون متاحة في السيارة أيضًا.",
"tutorialPage9Headline": "تمّ، أصبحت تعرف الأساسيات",
"tutorialPage9BannerBody": "لمشاهدة هذا الشرح مرة أخرى في أي وقت: الإعدادات ← المعلومات ← المساعدة والشرح.",
"indefiniteOption": "غير محدد",
"invalidNumber": "رقم غير صالح",
"nameLabel": "الاسم",
@@ -827,5 +850,59 @@
"welcomeHeadline": "عالمك، على الهواء مباشرة",
"yourStationsTitle": "محطاتك",
"nowListeningLabel": "الاستماع الآن",
"popularNowTitle": "الأكثر شيوعًا الآن"
"popularNowTitle": "الأكثر شيوعًا الآن",
"eqCustomActionEnableLabel": "تفعيل الموازن",
"eqCustomActionDisableLabel": "إيقاف الموازن",
"eqCustomActionPresetLabel": "الإعداد المسبق: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "متوقفة مؤقتًا بسبب الإجازة",
"alarmCardSchedulingFailedMessage": "لم يتم تسجيل هذا المنبه في النظام، لذا قد لا يرن.",
"alarmCardPreNoticeFailedMessage": "تمت جدولة هذا المنبه، لكن تعذّر ضبط تذكيره المسبق.",
"alarmDiagnosticsExactAlarmsTitle": "جدولة المنبه بدقة",
"alarmDiagnosticsExactAlarmsHint": "يتيح رنين المنبه في الدقيقة المحددة تمامًا، حتى عندما يكون الهاتف في وضع السكون.",
"alarmDiagnosticsNotificationsTitle": "الإشعارات",
"alarmDiagnosticsNotificationsHint": "ضرورية لعرض المنبه والتنبيه المسبق.",
"alarmDiagnosticsFullScreenTitle": "عرض المنبه بملء الشاشة",
"alarmDiagnosticsFullScreenHint": "يتيح ظهور شاشة الرنين تلقائيًا، حتى عندما يكون الهاتف مقفلاً.",
"alarmDiagnosticsBatteryTitle": "تحسين استهلاك البطارية",
"alarmDiagnosticsBatteryHint": "يمنع النظام من إغلاق PluriWave في الخلفية حتى يتمكن المنبه من الرنين.",
"alarmDiagnosticsNativeCountTitle": "المنبهات المسجَّلة لدى Android",
"alarmDiagnosticsNativeCountValue": "المسجَّل حاليًا: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "لديك منبه مفعَّل، لكن لا يوجد أي منبه مسجَّل في النظام بعد. أعد فتح PluriWave، أو عالج النقاط أعلاه أولاً.",
"alarmDiagnosticsManufacturerLabel": "الشركة المصنِّعة",
"alarmDiagnosticsSdkLabel": "إصدار Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "يحتاج إلى انتباه",
"alarmDiagnosticsAutostartTitle": "خطوة يدوية إضافية على هذا الهاتف",
"alarmDiagnosticsAutostartBody": "غالبًا ما تُغلق هواتف {manufacturer} التطبيقات العاملة في الخلفية لتوفير البطارية. لا يوجد إعداد يمكن لـ PluriWave تفعيله بنفسه — عليك أن تُفعِّل بنفسك خاصية التشغيل التلقائي (تُعرف أحيانًا باسم \"Autostart\" أو \"النشاط في الخلفية\") لتطبيق PluriWave. ابحث عنها في الإعدادات، ضمن التطبيقات أو البطارية، أو في تطبيق الأمان الخاص بالهاتف.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "إصلاح",
"alarmDiagnosticsIntentUnavailable": "تعذّر فتح شاشة الإعدادات هذه على هذا الهاتف. حاول البحث عنها يدويًا في الإعدادات.",
"alarmDiagnosticsUnavailableHint": "لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.",
"autoEqDisableOption": "تعطيل",
"funcionPremium": "ميزة مميزة",
"limiteAlarmasAlcanzado": "لقد وصلت إلى الحد المجاني وهو 5 منبهات.",
"desbloquearPremium": "فتح النسخة المميزة",
"restaurarCompras": "استعادة المشتريات",
"compraError": "تعذّر إتمام عملية الشراء. حاول مرة أخرى.",
"restauracionSinCompras": "لم نجد أي عملية شراء سابقة في هذا الحساب.",
"premiumActivo": "النسخة المميزة مفعّلة"
}
+79 -2
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "পরিসর মুছুন",
"vacationsDefaultName": "ছুটি",
"newVacationRangeTitle": "নতুন ছুটির পরিসর",
"editVacationRangeTitle": "ছুটির পরিসর সম্পাদনা করুন",
"vacationDeleteConfirmTitle": "ছুটির পরিসর মুছবেন?",
"vacationDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।",
"startField": "শুরু",
"endField": "শেষ",
"saveRangeAction": "পরিসর সংরক্ষণ করুন",
@@ -601,7 +604,27 @@
"endLabel": "শেষ",
"equalizerDisable": "ইকুয়ালাইজার বন্ধ করুন",
"helpTitle": "সহায়তা ও টিউটোরিয়াল",
"helpSubtitle": "PluriWave-এর ফিচার, টিপস ও নতুন বিষয়গুলো দেখুন।",
"helpSubtitle": "৯টি স্ক্রিন · যখন খুশি আবার দেখুন",
"tutorialSkipAction": "এড়িয়ে যান",
"tutorialNextAction": "পরবর্তী",
"tutorialPage1Headline": "আপনার স্টেশন সংরক্ষণ করুন এবং গ্রুপে ভাগ করুন",
"tutorialPage1Body": "একটি স্টেশন সংরক্ষণ করতে হার্ট আইকনে ট্যাপ করুন। «আপনার স্টেশন»-এ আপনি «প্রতিদিন সকালে» বা «গাড়ি»-এর মতো গ্রুপ তৈরি করতে পারেন এবং টেনে সেগুলোর ক্রম বদলাতে পারেন।",
"tutorialPage2Headline": "একটি সাধারণ ইকুয়ালাইজার, প্রতিটি স্টেশনের জন্য আরেকটি",
"tutorialPage2Body": "সেটিংসে আপনি সাধারণ ইকুয়ালাইজার ঠিক করেন। আর কোনো নির্দিষ্ট স্টেশন চালানোর সময় আপনি সেটির নিজস্ব সেটিং দিতে পারেন, যা সাধারণ সেটিংয়ের চেয়ে অগ্রাধিকার পায়।",
"tutorialPage3Headline": "যা শুনছেন তা রেকর্ড করুন",
"tutorialPage3Body": "প্লেয়ারের টুল ট্রে থেকে «রেকর্ড করুন» মূল স্ট্রিম সংরক্ষণ করে। আপনার রেকর্ডিং পাবেন সেটিংস › রেকর্ডিং-এ।",
"tutorialPage4Headline": "এমন অ্যালার্ম যা আপনার সাথে মানিয়ে নেয়",
"tutorialPage4Body": "আপনার প্রিয় স্টেশনে জেগে উঠুন, ৩, ৫ বা ১০ মিনিট স্নুজ করুন, এবং ছুটির সময়সীমা যোগ করুন যাতে কিছু অ্যালার্ম নিজে থেকেই বাদ পড়ে।",
"tutorialPage5Headline": "আপনার প্রিয়গুলো, গাড়িতেও",
"tutorialPage5Body": "আপনার ফোন Android Auto-এর সাথে সংযুক্ত করুন এবং পাবেন প্রিয়, সব স্টেশন, আপনার স্টেশন এবং আপনার লোকাল মিউজিক, গাড়ি চালানোর উপযোগী বড় বোতামসহ।",
"tutorialPage6Headline": "নিজে থেকেই আবার সংযুক্ত হয়",
"tutorialPage6Body": "সিগন্যাল কেটে গেলে, PluriWave স্বয়ংক্রিয়ভাবে আবার চেষ্টা করে এবং সংযোগ ছাড়াই আপনার সংরক্ষিত প্রিয়গুলো দেখাতে থাকে।",
"tutorialPage7Headline": "কতক্ষণ স্নুজ করবেন তা বেছে নিন",
"tutorialPage7Body": "অ্যালার্ম বাজলে, «স্নুজ»-এর একটিমাত্র বিকল্প নেই: আপনি সেই মুহূর্তে প্রয়োজন অনুযায়ী ৩, ৫ বা ১০ মিনিট বেছে নেন।",
"tutorialPage8Headline": "খুঁজে পাচ্ছেন না? নিজেই যোগ করুন",
"tutorialPage8Body": "«আপনার স্টেশন» → কাস্টম স্টেশন যোগ করুন-এ, খোঁজে না পাওয়া স্টেশনের স্ট্রিম URL পেস্ট করুন। এটি «আপনার স্টেশন»-এ সংরক্ষিত হয় এবং গাড়িতেও পাওয়া যায়।",
"tutorialPage9Headline": "শেষ, এখন আপনি মূল বিষয়গুলো জানেন",
"tutorialPage9BannerBody": "এই টিউটোরিয়ালটি আবার দেখতে: সেটিংস → তথ্য → সহায়তা ও টিউটোরিয়াল।",
"indefiniteOption": "অনির্দিষ্ট",
"invalidNumber": "অবৈধ সংখ্যা",
"nameLabel": "নাম",
@@ -827,5 +850,59 @@
"welcomeHeadline": "আপনার বিশ্ব, সরাসরি",
"yourStationsTitle": "আপনার স্টেশন",
"nowListeningLabel": "এখন শোনা হচ্ছে",
"popularNowTitle": "এখন জনপ্রিয়"
"popularNowTitle": "এখন জনপ্রিয়",
"eqCustomActionEnableLabel": "ইকুয়ালাইজার চালু করুন",
"eqCustomActionDisableLabel": "ইকুয়ালাইজার বন্ধ করুন",
"eqCustomActionPresetLabel": "প্রিসেট: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "ছুটির কারণে বিরত",
"alarmCardSchedulingFailedMessage": "এই অ্যালার্মটি সিস্টেমে নিবন্ধন করা যায়নি, তাই এটি নাও বাজতে পারে।",
"alarmCardPreNoticeFailedMessage": "এই অ্যালার্মটি নির্ধারিত হয়েছে, তবে এর আগাম রিমাইন্ডার সেট করা যায়নি।",
"alarmDiagnosticsExactAlarmsTitle": "নির্ভুল অ্যালার্ম শিডিউলিং",
"alarmDiagnosticsExactAlarmsHint": "ফোন ঘুমন্ত অবস্থায় থাকলেও অ্যালার্মকে ঠিক নির্ধারিত মিনিটে বাজতে দেয়।",
"alarmDiagnosticsNotificationsTitle": "বিজ্ঞপ্তি",
"alarmDiagnosticsNotificationsHint": "অ্যালার্ম এবং আগাম সতর্কবার্তা দেখানোর জন্য প্রয়োজনীয়।",
"alarmDiagnosticsFullScreenTitle": "অ্যালার্মের ফুল-স্ক্রিন প্রদর্শন",
"alarmDiagnosticsFullScreenHint": "ফোন লক থাকলেও রিং হওয়ার স্ক্রিনটি স্বয়ংক্রিয়ভাবে দেখা দিতে দেয়।",
"alarmDiagnosticsBatteryTitle": "ব্যাটারি অপ্টিমাইজেশন",
"alarmDiagnosticsBatteryHint": "সিস্টেমকে ব্যাকগ্রাউন্ডে PluriWave বন্ধ করা থেকে আটকায়, যাতে অ্যালার্মটি তবুও বাজতে পারে।",
"alarmDiagnosticsNativeCountTitle": "Android-এ নিবন্ধিত অ্যালার্ম",
"alarmDiagnosticsNativeCountValue": "বর্তমানে নিবন্ধিত: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "আপনার একটি অ্যালার্ম চালু আছে, কিন্তু এখনও কোনোটিই সিস্টেমে নিবন্ধিত হয়নি। PluriWave আবার খুলুন, অথবা প্রথমে উপরের বিষয়গুলো ঠিক করুন।",
"alarmDiagnosticsManufacturerLabel": "নির্মাতা",
"alarmDiagnosticsSdkLabel": "Android সংস্করণ (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "মনোযোগ প্রয়োজন",
"alarmDiagnosticsAutostartTitle": "এই ফোনে আরেকটি ম্যানুয়াল ধাপ",
"alarmDiagnosticsAutostartBody": "{manufacturer} ফোন ব্যাটারি বাঁচাতে প্রায়ই ব্যাকগ্রাউন্ডে চলা অ্যাপ বন্ধ করে দেয়। এমন কোনো সেটিং নেই যা PluriWave নিজে থেকে চালু করতে পারে — আপনাকে নিজে থেকেই PluriWave-এর জন্য অটোস্টার্ট (কখনও কখনও \"Auto-start\" বা \"ব্যাকগ্রাউন্ড অ্যাক্টিভিটি\" নামেও পরিচিত) চালু করতে হবে। সেটিংসে, অ্যাপস বা ব্যাটারির মধ্যে, অথবা ফোনের নিজস্ব সিকিউরিটি অ্যাপে এটি খুঁজুন।",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "সমাধান করুন",
"alarmDiagnosticsIntentUnavailable": "এই ফোনে সেই সেটিংস স্ক্রিনটি খোলা যায়নি। সেটিংসে ম্যানুয়ালি খুঁজে দেখার চেষ্টা করুন।",
"alarmDiagnosticsUnavailableHint": "আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।",
"autoEqDisableOption": "বন্ধ করুন",
"funcionPremium": "প্রিমিয়াম বৈশিষ্ট্য",
"limiteAlarmasAlcanzado": "আপনি বিনামূল্যে ৫টি অ্যালার্মের সীমায় পৌঁছেছেন।",
"desbloquearPremium": "প্রিমিয়াম আনলক করুন",
"restaurarCompras": "কেনাকাটা পুনরুদ্ধার করুন",
"compraError": "কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।",
"restauracionSinCompras": "এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।",
"premiumActivo": "প্রিমিয়াম সক্রিয়"
}
+79 -2
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "Zeitraum löschen",
"vacationsDefaultName": "Ferien",
"newVacationRangeTitle": "Neuer Ferienzeitraum",
"editVacationRangeTitle": "Ferienzeitraum bearbeiten",
"vacationDeleteConfirmTitle": "Ferienzeitraum löschen?",
"vacationDeleteConfirmMessage": "Dies kann nicht rückgängig gemacht werden.",
"startField": "Beginn",
"endField": "Ende",
"saveRangeAction": "Zeitraum speichern",
@@ -601,7 +604,27 @@
"endLabel": "Ende",
"equalizerDisable": "Equalizer deaktivieren",
"helpTitle": "Hilfe und Tutorial",
"helpSubtitle": "Funktionen, Tipps und Neuigkeiten von PluriWave ansehen.",
"helpSubtitle": "9 Bildschirme · jederzeit erneut ansehen",
"tutorialSkipAction": "Überspringen",
"tutorialNextAction": "Weiter",
"tutorialPage1Headline": "Speichere deine Sender und gruppiere sie",
"tutorialPage1Body": "Tippe auf das Herz, um einen Sender zu speichern. Unter „Deine Sender“ kannst du Gruppen wie „Jeden Morgen“ oder „Auto“ anlegen und sie per Ziehen neu anordnen.",
"tutorialPage2Headline": "Ein Basis-Equalizer und einer pro Sender",
"tutorialPage2Body": "In den Einstellungen legst du den allgemeinen Equalizer fest. Und während der Wiedergabe eines bestimmten Senders kannst du ihm eine eigene Einstellung geben, die Vorrang vor der allgemeinen hat.",
"tutorialPage3Headline": "Nimm auf, was du gerade hörst",
"tutorialPage3Body": "Über die Werkzeugleiste des Players speichert „Aufnehmen“ den Original-Stream. Deine Aufnahmen findest du unter Einstellungen › Aufnahmen.",
"tutorialPage4Headline": "Alarme, die sich dir anpassen",
"tutorialPage4Body": "Wach mit deinem Lieblingssender auf, schlummere 3, 5 oder 10 Minuten weiter und füge Urlaubszeiträume hinzu, damit manche Alarme von selbst ausgesetzt werden.",
"tutorialPage5Headline": "Deine Favoriten, auch im Auto",
"tutorialPage5Body": "Verbinde dein Handy mit Android Auto und finde Favoriten, Alle Sender, Deine Sender und deine lokale Musik, mit großen, fürs Fahren gemachten Tasten.",
"tutorialPage6Headline": "Verbindet sich von selbst neu",
"tutorialPage6Body": "Bricht das Signal ab, versucht PluriWave automatisch erneut und zeigt weiterhin deine gespeicherten Favoriten, auch ohne Verbindung.",
"tutorialPage7Headline": "Wähle, wie lange du schlummerst",
"tutorialPage7Body": "Wenn ein Alarm klingelt, gibt es nicht nur ein „Schlummern“: Du wählst 3, 5 oder 10 Minuten, je nachdem, was du gerade brauchst.",
"tutorialPage8Headline": "Findest du ihn nicht? Füge ihn selbst hinzu",
"tutorialPage8Body": "Füge unter „Deine Sender“ → Eigenen Sender hinzufügen die Stream-URL eines Senders ein, der nicht in der Suche auftaucht. Er wird unter „Deine Sender“ gespeichert und ist auch im Auto verfügbar.",
"tutorialPage9Headline": "Fertig, du kennst jetzt das Wichtigste",
"tutorialPage9BannerBody": "Um dieses Tutorial jederzeit erneut anzusehen: Einstellungen → Info → Hilfe und Tutorial.",
"indefiniteOption": "Unbegrenzt",
"invalidNumber": "Ungültige Zahl",
"nameLabel": "Name",
@@ -827,5 +850,59 @@
"welcomeHeadline": "Deine Welt, live",
"yourStationsTitle": "Deine Sender",
"nowListeningLabel": "Läuft gerade",
"popularNowTitle": "Jetzt beliebt"
"popularNowTitle": "Jetzt beliebt",
"eqCustomActionEnableLabel": "Equalizer aktivieren",
"eqCustomActionDisableLabel": "Equalizer deaktivieren",
"eqCustomActionPresetLabel": "Voreinstellung: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Wegen Urlaub pausiert",
"alarmCardSchedulingFailedMessage": "Dieser Alarm konnte nicht im System registriert werden, daher klingelt er möglicherweise nicht.",
"alarmCardPreNoticeFailedMessage": "Dieser Alarm ist geplant, aber seine Vorwarnung konnte nicht eingerichtet werden.",
"alarmDiagnosticsExactAlarmsTitle": "Genaue Alarmplanung",
"alarmDiagnosticsExactAlarmsHint": "Lässt den Alarm genau zur eingestellten Minute klingeln, auch wenn das Telefon im Ruhezustand ist.",
"alarmDiagnosticsNotificationsTitle": "Benachrichtigungen",
"alarmDiagnosticsNotificationsHint": "Nötig, um den Alarm und den Vorab-Hinweis anzuzeigen.",
"alarmDiagnosticsFullScreenTitle": "Alarmanzeige im Vollbildmodus",
"alarmDiagnosticsFullScreenHint": "Lässt den Klingelbildschirm automatisch erscheinen, auch wenn das Telefon gesperrt ist.",
"alarmDiagnosticsBatteryTitle": "Akku-Optimierung",
"alarmDiagnosticsBatteryHint": "Verhindert, dass das System PluriWave im Hintergrund beendet, damit der Alarm trotzdem klingeln kann.",
"alarmDiagnosticsNativeCountTitle": "Bei Android registrierte Alarme",
"alarmDiagnosticsNativeCountValue": "Aktuell registriert: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "Du hast einen Alarm aktiviert, aber es ist noch keiner beim System registriert. Öffne PluriWave erneut oder behebe zuerst die Punkte oben.",
"alarmDiagnosticsManufacturerLabel": "Hersteller",
"alarmDiagnosticsSdkLabel": "Android-Version (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Erfordert Aufmerksamkeit",
"alarmDiagnosticsAutostartTitle": "Noch ein manueller Schritt auf diesem Telefon",
"alarmDiagnosticsAutostartBody": "{manufacturer}-Telefone schließen oft Apps im Hintergrund, um Akku zu sparen. Es gibt keine Einstellung, die PluriWave selbst aktivieren kann — du musst den Autostart (manchmal auch \"Auto-Start\" oder \"Hintergrundaktivität\" genannt) für PluriWave selbst einschalten. Schau in den Einstellungen unter Apps oder Akku, oder in der Sicherheits-App des Telefons nach.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Beheben",
"alarmDiagnosticsIntentUnavailable": "Diese Einstellungsseite konnte auf diesem Telefon nicht geöffnet werden. Suche sie manuell in den Einstellungen.",
"alarmDiagnosticsUnavailableHint": "Wir konnten deine Alarmeinstellungen noch nicht prüfen.",
"autoEqDisableOption": "Deaktivieren",
"funcionPremium": "Premium-Funktion",
"limiteAlarmasAlcanzado": "Du hast das kostenlose Limit von 5 Weckern erreicht.",
"desbloquearPremium": "Premium freischalten",
"restaurarCompras": "Käufe wiederherstellen",
"compraError": "Der Kauf konnte nicht abgeschlossen werden. Bitte versuche es erneut.",
"restauracionSinCompras": "Wir haben auf diesem Konto keinen früheren Kauf gefunden.",
"premiumActivo": "Premium aktiv"
}
+79 -2
View File
@@ -580,6 +580,9 @@
"vacationNoActiveRangeHint": "No active vacation range right now.",
"vacationsDefaultName": "Vacation",
"newVacationRangeTitle": "New vacation range",
"editVacationRangeTitle": "Edit vacation range",
"vacationDeleteConfirmTitle": "Delete vacation range?",
"vacationDeleteConfirmMessage": "This can't be undone.",
"startField": "Start",
"endField": "End",
"saveRangeAction": "Save range",
@@ -713,7 +716,27 @@
"endLabel": "End",
"equalizerDisable": "Disable equalizer",
"helpTitle": "Help and tutorial",
"helpSubtitle": "Review PluriWave features, tips and what’s new.",
"helpSubtitle": "9 screens · watch it again anytime",
"tutorialSkipAction": "Skip",
"tutorialNextAction": "Next",
"tutorialPage1Headline": "Save your stations and group them",
"tutorialPage1Body": "Tap the heart to save a station. In \"Your stations\" you can create groups like \"Every morning\" or \"Car\" and drag to reorder them.",
"tutorialPage2Headline": "One base equalizer, plus one per station",
"tutorialPage2Body": "In Settings you set the general equalizer. And from a specific station's playback screen you can give it its own setting, which overrides the general one.",
"tutorialPage3Headline": "Record what you're listening to",
"tutorialPage3Body": "From the player's tool tray, \"Record\" saves the original stream. Find your recordings under Settings › Recordings.",
"tutorialPage4Headline": "Alarms that adapt to you",
"tutorialPage4Body": "Wake up to your favorite station, snooze for 3, 5, or 10 minutes, and add vacation ranges so some alarms skip themselves.",
"tutorialPage5Headline": "Your favorites, in the car too",
"tutorialPage5Body": "Connect your phone with Android Auto to find Favorites, All stations, Your stations, and your Local Music, with big buttons made for driving.",
"tutorialPage6Headline": "It reconnects on its own",
"tutorialPage6Body": "If the signal drops, PluriWave retries automatically and keeps showing your saved favorites even without a connection.",
"tutorialPage7Headline": "Choose how long to snooze",
"tutorialPage7Body": "When an alarm rings, there's no single \"snooze\": you choose 3, 5, or 10 minutes depending on what you need at that moment.",
"tutorialPage8Headline": "Can't find it? Add it yourself",
"tutorialPage8Body": "In \"Your stations\" → Add custom station, paste the stream URL of a station that isn't in the search results. It's saved under \"Your stations\", also available in the car.",
"tutorialPage9Headline": "Done — now you know the essentials",
"tutorialPage9BannerBody": "To watch this tutorial again anytime: Settings → Information → Help and tutorial.",
"indefiniteOption": "Indefinite",
"invalidNumber": "Invalid number",
"nameLabel": "Name",
@@ -827,5 +850,59 @@
"welcomeBullet2Subtitle": "Your favorites and local music in the car",
"welcomeBullet3Title": "Music alarms",
"welcomeBullet3Subtitle": "With gradual volume rise and vacation mode",
"welcomeCtaLabel": "Start listening"
"welcomeCtaLabel": "Start listening",
"eqCustomActionEnableLabel": "Enable equalizer",
"eqCustomActionDisableLabel": "Disable equalizer",
"eqCustomActionPresetLabel": "Preset: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Paused for vacation",
"alarmCardSchedulingFailedMessage": "This alarm could not be registered with the system, so it may not ring.",
"alarmCardPreNoticeFailedMessage": "This alarm is scheduled, but its early reminder could not be set.",
"alarmDiagnosticsExactAlarmsTitle": "Exact alarm scheduling",
"alarmDiagnosticsExactAlarmsHint": "Lets the alarm ring at the exact minute you set, even while the phone is asleep.",
"alarmDiagnosticsNotificationsTitle": "Notifications",
"alarmDiagnosticsNotificationsHint": "Needed to show the alarm and the advance-warning notice.",
"alarmDiagnosticsFullScreenTitle": "Full-screen alarm display",
"alarmDiagnosticsFullScreenHint": "Lets the ringing screen appear automatically, even with the phone locked.",
"alarmDiagnosticsBatteryTitle": "Battery optimization",
"alarmDiagnosticsBatteryHint": "Stops the system from closing PluriWave in the background so the alarm can still fire.",
"alarmDiagnosticsNativeCountTitle": "Alarms registered with Android",
"alarmDiagnosticsNativeCountValue": "Currently registered: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "You have an alarm turned on, but none are registered with the system yet. Reopen PluriWave, or fix the items above first.",
"alarmDiagnosticsManufacturerLabel": "Manufacturer",
"alarmDiagnosticsSdkLabel": "Android version (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Needs attention",
"alarmDiagnosticsAutostartTitle": "One more manual step on this phone",
"alarmDiagnosticsAutostartBody": "{manufacturer} phones often close apps running in the background to save battery. There is no setting PluriWave can switch on its own — you need to turn on Autostart (sometimes called \"Auto-start\" or \"Background activity\") for PluriWave yourself. Look in Settings, under Apps or Battery, or in the phone's own Security app.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Fix this",
"alarmDiagnosticsIntentUnavailable": "Couldn't open that settings screen on this phone. Try looking for it manually in Settings.",
"alarmDiagnosticsUnavailableHint": "We couldn't check your alarm settings yet.",
"autoEqDisableOption": "Disable",
"funcionPremium": "Premium Feature",
"limiteAlarmasAlcanzado": "You've reached the free 5-alarm limit.",
"desbloquearPremium": "Unlock Premium",
"restaurarCompras": "Restore purchases",
"compraError": "We couldn't complete the purchase. Please try again.",
"restauracionSinCompras": "We didn't find any previous purchase on this account.",
"premiumActivo": "Premium active"
}
+79 -2
View File
@@ -580,6 +580,9 @@
"vacationNoActiveRangeHint": "No hay un rango de vacaciones activo ahora mismo.",
"vacationsDefaultName": "Vacaciones",
"newVacationRangeTitle": "Nuevo rango de vacaciones",
"editVacationRangeTitle": "Editar rango de vacaciones",
"vacationDeleteConfirmTitle": "¿Eliminar rango de vacaciones?",
"vacationDeleteConfirmMessage": "Esta acción no se puede deshacer.",
"startField": "Inicio",
"endField": "Fin",
"saveRangeAction": "Guardar rango",
@@ -669,7 +672,27 @@
"endLabel": "Fin",
"equalizerDisable": "Desactivar ecualizador",
"helpTitle": "Ayuda y tutorial",
"helpSubtitle": "Repasá funciones, consejos y novedades de PluriWave.",
"helpSubtitle": "9 pantallas · vuelve a verlo cuando quieras",
"tutorialSkipAction": "Saltar",
"tutorialNextAction": "Siguiente",
"tutorialPage1Headline": "Guarda tus emisoras y agrúpalas",
"tutorialPage1Body": "Toca el corazón para guardar una emisora. En «Tus emisoras» puedes crear grupos como «Cada mañana» o «Coche» y reordenarlas arrastrando.",
"tutorialPage2Headline": "Un ecualizador base y otro por emisora",
"tutorialPage2Body": "En Ajustes defines el ecualizador general. Y desde la reproducción de una emisora concreta puedes darle su propio ajuste, que manda sobre el general.",
"tutorialPage3Headline": "Graba lo que estás escuchando",
"tutorialPage3Body": "Desde la bandeja de herramientas del reproductor, «Grabar» guarda el stream original. Encuentra tus grabaciones en Ajustes › Grabaciones.",
"tutorialPage4Headline": "Alarmas que se adaptan a ti",
"tutorialPage4Body": "Despiértate con tu emisora favorita, pospón 3, 5 o 10 minutos, y añade rangos de vacaciones para que algunas alarmas se salten solas.",
"tutorialPage5Headline": "Tus favoritas, también en el coche",
"tutorialPage5Body": "Conecta el móvil con Android Auto y encontrarás Favoritos, Todas las emisoras, Mis emisoras y tu Música Local, con botones grandes pensados para conducir.",
"tutorialPage6Headline": "Se reconecta sola",
"tutorialPage6Body": "Si se corta la señal, PluriWave reintenta automáticamente y sigue mostrando tus favoritas guardadas aunque no tengas conexión.",
"tutorialPage7Headline": "Elige cuánto posponer",
"tutorialPage7Body": "Cuando suene una alarma, no hay un único «posponer»: eliges 3, 5 o 10 minutos según lo que necesites en ese momento.",
"tutorialPage8Headline": "¿No la encuentras? Añádela tú",
"tutorialPage8Body": "En «Tus emisoras» → Añadir emisora personalizada pega la URL del stream de una emisora que no esté en el buscador. Se guarda en «Mis emisoras», también disponible en el coche.",
"tutorialPage9Headline": "Listo, ya conoces lo esencial",
"tutorialPage9BannerBody": "Para volver a ver este tutorial cuando quieras: Ajustes → Información → Ayuda y tutorial.",
"indefiniteOption": "Indefinida",
"invalidNumber": "Número inválido",
"nameLabel": "Nombre",
@@ -786,5 +809,59 @@
"welcomeBullet2Subtitle": "Tus favoritas y tu música local en el auto",
"welcomeBullet3Title": "Alarmas musicales",
"welcomeBullet3Subtitle": "Con subida progresiva y modo vacaciones",
"welcomeCtaLabel": "Empezar a escuchar"
"welcomeCtaLabel": "Empezar a escuchar",
"eqCustomActionEnableLabel": "Activar ecualizador",
"eqCustomActionDisableLabel": "Desactivar ecualizador",
"eqCustomActionPresetLabel": "Preset: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Pausada por vacaciones",
"alarmCardSchedulingFailedMessage": "Esta alarma no se pudo registrar en el sistema, así que podría no sonar.",
"alarmCardPreNoticeFailedMessage": "Esta alarma está programada, pero no se pudo activar su aviso previo.",
"alarmDiagnosticsExactAlarmsTitle": "Programación de alarma exacta",
"alarmDiagnosticsExactAlarmsHint": "Permite que la alarma suene en el minuto exacto que elegiste, incluso con el teléfono en reposo.",
"alarmDiagnosticsNotificationsTitle": "Notificaciones",
"alarmDiagnosticsNotificationsHint": "Necesarias para mostrar la alarma y el aviso previo.",
"alarmDiagnosticsFullScreenTitle": "Pantalla completa de la alarma",
"alarmDiagnosticsFullScreenHint": "Permite que la pantalla de la alarma aparezca automáticamente, incluso con el teléfono bloqueado.",
"alarmDiagnosticsBatteryTitle": "Optimización de batería",
"alarmDiagnosticsBatteryHint": "Evita que el sistema cierre PluriWave en segundo plano para que la alarma pueda sonar.",
"alarmDiagnosticsNativeCountTitle": "Alarmas registradas en Android",
"alarmDiagnosticsNativeCountValue": "Registradas ahora mismo: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "Tenés una alarma activada, pero todavía ninguna está registrada en el sistema. Volvé a abrir PluriWave o solucioná primero los puntos de arriba.",
"alarmDiagnosticsManufacturerLabel": "Fabricante",
"alarmDiagnosticsSdkLabel": "Versión de Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Necesita atención",
"alarmDiagnosticsAutostartTitle": "Un paso manual más en este teléfono",
"alarmDiagnosticsAutostartBody": "Los teléfonos {manufacturer} suelen cerrar las apps que están en segundo plano para ahorrar batería. No hay ningún ajuste que PluriWave pueda activar por su cuenta: tenés que activar vos mismo el Inicio automático (a veces llamado \"Autostart\" o \"Actividad en segundo plano\"). Buscalo en Ajustes, dentro de Apps o Batería, o en la app de Seguridad del teléfono.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Solucionar",
"alarmDiagnosticsIntentUnavailable": "No se pudo abrir esa pantalla de ajustes en este teléfono. Buscala manualmente en Ajustes.",
"alarmDiagnosticsUnavailableHint": "Todavía no pudimos revisar tus ajustes de alarma.",
"autoEqDisableOption": "Desactivar",
"funcionPremium": "Función Premium",
"limiteAlarmasAlcanzado": "Has alcanzado el límite de 5 alarmas gratuitas.",
"desbloquearPremium": "Desbloquear Premium",
"restaurarCompras": "Restaurar compras",
"compraError": "No se ha podido completar la compra. Inténtalo de nuevo.",
"restauracionSinCompras": "No hemos encontrado ninguna compra anterior en esta cuenta.",
"premiumActivo": "Premium activo"
}
+79 -2
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "Supprimer la période",
"vacationsDefaultName": "Vacances",
"newVacationRangeTitle": "Nouvelle période de vacances",
"editVacationRangeTitle": "Modifier la période de vacances",
"vacationDeleteConfirmTitle": "Supprimer la période de vacances ?",
"vacationDeleteConfirmMessage": "Cette action est irréversible.",
"startField": "Début",
"endField": "Fin",
"saveRangeAction": "Enregistrer la période",
@@ -601,7 +604,27 @@
"endLabel": "Fin",
"equalizerDisable": "Désactiver l’égaliseur",
"helpTitle": "Aide et tutoriel",
"helpSubtitle": "Revoyez les fonctions, conseils et nouveautés de PluriWave.",
"helpSubtitle": "9 écrans · à revoir quand vous voulez",
"tutorialSkipAction": "Passer",
"tutorialNextAction": "Suivant",
"tutorialPage1Headline": "Enregistrez vos stations et regroupez-les",
"tutorialPage1Body": "Touchez le cœur pour enregistrer une station. Dans « Vos stations », vous pouvez créer des groupes comme « Chaque matin » ou « Voiture » et les réorganiser en les faisant glisser.",
"tutorialPage2Headline": "Un égaliseur général et un autre par station",
"tutorialPage2Body": "Dans Réglages, vous définissez l'égaliseur général. Et depuis la lecture d'une station précise, vous pouvez lui donner son propre réglage, qui prévaut sur le général.",
"tutorialPage3Headline": "Enregistrez ce que vous écoutez",
"tutorialPage3Body": "Depuis la barre d'outils du lecteur, « Enregistrer » sauvegarde le flux original. Retrouvez vos enregistrements dans Réglages › Enregistrements.",
"tutorialPage4Headline": "Des alarmes qui s'adaptent à vous",
"tutorialPage4Body": "Réveillez-vous avec votre station préférée, reportez de 3, 5 ou 10 minutes, et ajoutez des périodes de vacances pour que certaines alarmes se sautent d'elles-mêmes.",
"tutorialPage5Headline": "Vos favoris, aussi en voiture",
"tutorialPage5Body": "Connectez votre téléphone avec Android Auto et retrouvez Favoris, Toutes les stations, Vos stations et votre Musique locale, avec de grands boutons pensés pour la conduite.",
"tutorialPage6Headline": "Se reconnecte toute seule",
"tutorialPage6Body": "Si le signal se coupe, PluriWave retente automatiquement et continue d'afficher vos favoris enregistrés même sans connexion.",
"tutorialPage7Headline": "Choisissez combien de temps reporter",
"tutorialPage7Body": "Quand une alarme sonne, il n'y a pas un seul « reporter » : vous choisissez 3, 5 ou 10 minutes selon ce dont vous avez besoin à ce moment-là.",
"tutorialPage8Headline": "Vous ne la trouvez pas ? Ajoutez-la vous-même",
"tutorialPage8Body": "Dans « Vos stations » → Ajouter une station personnalisée, collez l'URL du flux d'une station absente des résultats de recherche. Elle est enregistrée dans « Vos stations », aussi disponible en voiture.",
"tutorialPage9Headline": "Voilà, vous connaissez l'essentiel",
"tutorialPage9BannerBody": "Pour revoir ce tutoriel quand vous le souhaitez : Réglages → Informations → Aide et tutoriel.",
"indefiniteOption": "Indéfinie",
"invalidNumber": "Nombre invalide",
"nameLabel": "Nom",
@@ -827,5 +850,59 @@
"welcomeHeadline": "Votre monde, en direct",
"yourStationsTitle": "Vos stations",
"nowListeningLabel": "En cours d'écoute",
"popularNowTitle": "Populaire maintenant"
"popularNowTitle": "Populaire maintenant",
"eqCustomActionEnableLabel": "Activer l'égaliseur",
"eqCustomActionDisableLabel": "Désactiver l'égaliseur",
"eqCustomActionPresetLabel": "Préréglage : {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "En pause pour les vacances",
"alarmCardSchedulingFailedMessage": "Cette alarme n'a pas pu être enregistrée dans le système ; elle risque donc de ne pas sonner.",
"alarmCardPreNoticeFailedMessage": "Cette alarme est programmée, mais son rappel anticipé n'a pas pu être activé.",
"alarmDiagnosticsExactAlarmsTitle": "Programmation d'alarme précise",
"alarmDiagnosticsExactAlarmsHint": "Permet à l'alarme de sonner à la minute exacte choisie, même si le téléphone est en veille.",
"alarmDiagnosticsNotificationsTitle": "Notifications",
"alarmDiagnosticsNotificationsHint": "Nécessaires pour afficher l'alarme et l'avis anticipé.",
"alarmDiagnosticsFullScreenTitle": "Affichage plein écran de l'alarme",
"alarmDiagnosticsFullScreenHint": "Permet à l'écran de sonnerie de s'afficher automatiquement, même si le téléphone est verrouillé.",
"alarmDiagnosticsBatteryTitle": "Optimisation de la batterie",
"alarmDiagnosticsBatteryHint": "Empêche le système de fermer PluriWave en arrière-plan pour que l'alarme puisse quand même sonner.",
"alarmDiagnosticsNativeCountTitle": "Alarmes enregistrées auprès d'Android",
"alarmDiagnosticsNativeCountValue": "Actuellement enregistrées : {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "Une alarme est activée, mais aucune n'est encore enregistrée auprès du système. Rouvrez PluriWave, ou corrigez d'abord les points ci-dessus.",
"alarmDiagnosticsManufacturerLabel": "Fabricant",
"alarmDiagnosticsSdkLabel": "Version d'Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Nécessite votre attention",
"alarmDiagnosticsAutostartTitle": "Encore une étape manuelle sur ce téléphone",
"alarmDiagnosticsAutostartBody": "Les téléphones {manufacturer} ferment souvent les applications en arrière-plan pour économiser la batterie. Aucun réglage ne permet à PluriWave de s'activer lui-même : vous devez activer vous-même le démarrage automatique (parfois appelé \"Autostart\" ou \"Activité en arrière-plan\") pour PluriWave. Cherchez dans les Paramètres, sous Applications ou Batterie, ou dans l'application Sécurité du téléphone.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Corriger",
"alarmDiagnosticsIntentUnavailable": "Impossible d'ouvrir cet écran de paramètres sur ce téléphone. Essayez de le trouver manuellement dans les Paramètres.",
"alarmDiagnosticsUnavailableHint": "Nous n'avons pas encore pu vérifier vos paramètres d'alarme.",
"autoEqDisableOption": "Désactiver",
"funcionPremium": "Fonctionnalité Premium",
"limiteAlarmasAlcanzado": "Vous avez atteint la limite gratuite de 5 alarmes.",
"desbloquearPremium": "Débloquer Premium",
"restaurarCompras": "Restaurer les achats",
"compraError": "Impossible de finaliser l'achat. Veuillez réessayer.",
"restauracionSinCompras": "Nous n'avons trouvé aucun achat antérieur sur ce compte.",
"premiumActivo": "Premium actif"
}
+79 -2
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "अवधि हटाएँ",
"vacationsDefaultName": "छुट्टियाँ",
"newVacationRangeTitle": "नई छुट्टी अवधि",
"editVacationRangeTitle": "छुट्टी अवधि संपादित करें",
"vacationDeleteConfirmTitle": "छुट्टी अवधि हटाएं?",
"vacationDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।",
"startField": "शुरुआत",
"endField": "समाप्ति",
"saveRangeAction": "अवधि सहेजें",
@@ -601,7 +604,27 @@
"endLabel": "समाप्ति",
"equalizerDisable": "इक्वलाइज़र बंद करें",
"helpTitle": "मदद और ट्यूटोरियल",
"helpSubtitle": "PluriWave की सुविधाएँ, सुझाव और नया क्या है देखें।",
"helpSubtitle": "9 स्क्रीन · जब चाहें दोबारा देखें",
"tutorialSkipAction": "छोड़ें",
"tutorialNextAction": "अगला",
"tutorialPage1Headline": "अपने स्टेशन सहेजें और उन्हें समूहों में बाँटें",
"tutorialPage1Body": "किसी स्टेशन को सहेजने के लिए दिल के आइकॉन पर टैप करें। «आपके स्टेशन» में आप «हर सुबह» या «कार» जैसे समूह बना सकते हैं और उन्हें खींचकर पुनः क्रमबद्ध कर सकते हैं।",
"tutorialPage2Headline": "एक सामान्य इक्वलाइज़र, और हर स्टेशन के लिए एक अलग",
"tutorialPage2Body": "सेटिंग्स में आप सामान्य इक्वलाइज़र सेट करते हैं। और किसी खास स्टेशन को चलाते समय आप उसे अपनी खुद की सेटिंग दे सकते हैं, जो सामान्य सेटिंग पर प्राथमिकता रखती है।",
"tutorialPage3Headline": "जो सुन रहे हैं उसे रिकॉर्ड करें",
"tutorialPage3Body": "प्लेयर की टूल ट्रे से, «रिकॉर्ड करें» मूल स्ट्रीम को सहेजता है। अपनी रिकॉर्डिंग सेटिंग्स › रिकॉर्डिंग में पाएँ।",
"tutorialPage4Headline": "अलार्म जो आपके अनुसार ढलते हैं",
"tutorialPage4Body": "अपने पसंदीदा स्टेशन के साथ जागें, 3, 5 या 10 मिनट के लिए स्नूज़ करें, और छुट्टियों की अवधि जोड़ें ताकि कुछ अलार्म खुद-ब-खुद छूट जाएँ।",
"tutorialPage5Headline": "आपके पसंदीदा, कार में भी",
"tutorialPage5Body": "अपने फ़ोन को Android Auto से जोड़ें और पाएँ पसंदीदा, सभी स्टेशन, आपके स्टेशन और आपका लोकल म्यूज़िक, ड्राइविंग के लिए बने बड़े बटनों के साथ।",
"tutorialPage6Headline": "खुद ही फिर से जुड़ जाता है",
"tutorialPage6Body": "अगर सिग्नल कट जाए, तो PluriWave अपने आप फिर कोशिश करता है और बिना कनेक्शन के भी आपके सहेजे गए पसंदीदा दिखाता रहता है।",
"tutorialPage7Headline": "चुनें कितनी देर के लिए स्नूज़ करना है",
"tutorialPage7Body": "जब कोई अलार्म बजता है, तो «स्नूज़» का कोई एक ही विकल्प नहीं होता: आप उस पल की ज़रूरत के अनुसार 3, 5 या 10 मिनट चुनते हैं।",
"tutorialPage8Headline": "नहीं मिल रहा? खुद जोड़ें",
"tutorialPage8Body": "«आपके स्टेशन» → कस्टम स्टेशन जोड़ें में, खोज में न मिलने वाले स्टेशन का स्ट्रीम URL पेस्ट करें। यह «आपके स्टेशन» में सहेजा जाता है और कार में भी उपलब्ध रहता है।",
"tutorialPage9Headline": "हो गया, अब आप ज़रूरी बातें जानते हैं",
"tutorialPage9BannerBody": "इस ट्यूटोरियल को दोबारा देखने के लिए: सेटिंग्स → जानकारी → मदद और ट्यूटोरियल।",
"indefiniteOption": "अनिश्चित",
"invalidNumber": "अमान्य संख्या",
"nameLabel": "नाम",
@@ -827,5 +850,59 @@
"welcomeHeadline": "आपकी दुनिया, लाइव",
"yourStationsTitle": "आपके स्टेशन",
"nowListeningLabel": "अभी सुन रहे हैं",
"popularNowTitle": "अभी लोकप्रिय"
"popularNowTitle": "अभी लोकप्रिय",
"eqCustomActionEnableLabel": "इक्वलाइज़र चालू करें",
"eqCustomActionDisableLabel": "इक्वलाइज़र बंद करें",
"eqCustomActionPresetLabel": "प्रीसेट: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "छुट्टी के कारण रोका गया",
"alarmCardSchedulingFailedMessage": "यह अलार्म सिस्टम में दर्ज नहीं हो सका, इसलिए यह शायद न बजे।",
"alarmCardPreNoticeFailedMessage": "यह अलार्म शेड्यूल किया गया है, लेकिन इसकी पूर्व-चेतावनी सेट नहीं हो सकी।",
"alarmDiagnosticsExactAlarmsTitle": "सटीक अलार्म शेड्यूलिंग",
"alarmDiagnosticsExactAlarmsHint": "फ़ोन के सुप्त मोड में होने पर भी अलार्म को ठीक तय किए गए मिनट पर बजने देता है।",
"alarmDiagnosticsNotificationsTitle": "सूचनाएं",
"alarmDiagnosticsNotificationsHint": "अलार्म और पूर्व-चेतावनी सूचना दिखाने के लिए ज़रूरी।",
"alarmDiagnosticsFullScreenTitle": "अलार्म की फ़ुल-स्क्रीन डिस्प्ले",
"alarmDiagnosticsFullScreenHint": "फ़ोन लॉक होने पर भी अलार्म स्क्रीन को अपने आप दिखने देता है।",
"alarmDiagnosticsBatteryTitle": "बैटरी ऑप्टिमाइज़ेशन",
"alarmDiagnosticsBatteryHint": "सिस्टम को बैकग्राउंड में PluriWave बंद करने से रोकता है, ताकि अलार्म फिर भी बज सके।",
"alarmDiagnosticsNativeCountTitle": "Android में दर्ज अलार्म",
"alarmDiagnosticsNativeCountValue": "अभी दर्ज: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "आपने एक अलार्म चालू किया है, लेकिन अभी तक कोई भी सिस्टम में दर्ज नहीं हुआ है। PluriWave को दोबारा खोलें, या पहले ऊपर दी गई बातों को ठीक करें।",
"alarmDiagnosticsManufacturerLabel": "निर्माता",
"alarmDiagnosticsSdkLabel": "Android वर्शन (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "ध्यान देने की ज़रूरत है",
"alarmDiagnosticsAutostartTitle": "इस फ़ोन पर एक और मैन्युअल चरण",
"alarmDiagnosticsAutostartBody": "{manufacturer} फ़ोन बैटरी बचाने के लिए अक्सर बैकग्राउंड में चल रहे ऐप बंद कर देते हैं। ऐसी कोई सेटिंग नहीं है जिसे PluriWave खुद चालू कर सके — आपको PluriWave के लिए खुद ऑटोस्टार्ट (कभी-कभी \"Auto-start\" या \"बैकग्राउंड एक्टिविटी\" भी कहा जाता है) चालू करना होगा। इसे सेटिंग्स में, ऐप्स या बैटरी के अंदर, या फ़ोन के अपने सिक्योरिटी ऐप में देखें।",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "ठीक करें",
"alarmDiagnosticsIntentUnavailable": "इस फ़ोन पर वह सेटिंग्स स्क्रीन नहीं खोली जा सकी। कृपया सेटिंग्स में इसे खुद ढूंढने की कोशिश करें।",
"alarmDiagnosticsUnavailableHint": "हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।",
"autoEqDisableOption": "बंद करें",
"funcionPremium": "प्रीमियम सुविधा",
"limiteAlarmasAlcanzado": "आप मुफ़्त 5 अलार्म की सीमा तक पहुँच गए हैं।",
"desbloquearPremium": "प्रीमियम अनलॉक करें",
"restaurarCompras": "खरीदारी पुनर्स्थापित करें",
"compraError": "खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।",
"restauracionSinCompras": "इस खाते में हमें कोई पिछली खरीद नहीं मिली।",
"premiumActivo": "प्रीमियम सक्रिय"
}
+79 -2
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "Hapus rentang",
"vacationsDefaultName": "Liburan",
"newVacationRangeTitle": "Rentang liburan baru",
"editVacationRangeTitle": "Edit rentang liburan",
"vacationDeleteConfirmTitle": "Hapus rentang liburan?",
"vacationDeleteConfirmMessage": "Tindakan ini tidak dapat dibatalkan.",
"startField": "Mulai",
"endField": "Akhir",
"saveRangeAction": "Simpan rentang",
@@ -601,7 +604,27 @@
"endLabel": "Selesai",
"equalizerDisable": "Nonaktifkan equalizer",
"helpTitle": "Bantuan dan tutorial",
"helpSubtitle": "Tinjau fitur, tips, dan hal baru di PluriWave.",
"helpSubtitle": "9 layar · lihat lagi kapan saja",
"tutorialSkipAction": "Lewati",
"tutorialNextAction": "Berikutnya",
"tutorialPage1Headline": "Simpan stasiun Anda dan kelompokkan",
"tutorialPage1Body": "Ketuk ikon hati untuk menyimpan stasiun. Di «Stasiun Anda» Anda bisa membuat grup seperti «Setiap pagi» atau «Mobil» dan mengurutkannya ulang dengan menyeret.",
"tutorialPage2Headline": "Satu equalizer umum, dan satu lagi per stasiun",
"tutorialPage2Body": "Di Pengaturan, Anda menentukan equalizer umum. Dan dari pemutaran stasiun tertentu, Anda bisa memberinya pengaturan sendiri, yang lebih diutamakan daripada pengaturan umum.",
"tutorialPage3Headline": "Rekam apa yang sedang Anda dengarkan",
"tutorialPage3Body": "Dari baki alat pemutar, «Rekam» menyimpan stream asli. Temukan rekaman Anda di Pengaturan › Rekaman.",
"tutorialPage4Headline": "Alarm yang menyesuaikan diri dengan Anda",
"tutorialPage4Body": "Bangun dengan stasiun favorit Anda, tunda 3, 5, atau 10 menit, dan tambahkan rentang liburan agar sebagian alarm melewati dirinya sendiri.",
"tutorialPage5Headline": "Favorit Anda, juga di mobil",
"tutorialPage5Body": "Sambungkan ponsel Anda dengan Android Auto dan temukan Favorit, Semua stasiun, Stasiun Anda, dan Musik Lokal Anda, dengan tombol besar yang dirancang untuk berkendara.",
"tutorialPage6Headline": "Tersambung ulang dengan sendirinya",
"tutorialPage6Body": "Jika sinyal terputus, PluriWave mencoba lagi secara otomatis dan tetap menampilkan favorit tersimpan Anda meski tanpa koneksi.",
"tutorialPage7Headline": "Pilih berapa lama menunda",
"tutorialPage7Body": "Saat alarm berbunyi, tidak ada satu «tunda» saja: Anda memilih 3, 5, atau 10 menit sesuai yang Anda butuhkan saat itu.",
"tutorialPage8Headline": "Tidak menemukannya? Tambahkan sendiri",
"tutorialPage8Body": "Di «Stasiun Anda» → Tambah stasiun khusus, tempel URL stream stasiun yang tidak ada di hasil pencarian. Stasiun ini disimpan di «Stasiun Anda», juga tersedia di mobil.",
"tutorialPage9Headline": "Selesai, Anda sudah tahu yang penting",
"tutorialPage9BannerBody": "Untuk melihat tutorial ini lagi kapan saja: Pengaturan → Informasi → Bantuan dan tutorial.",
"indefiniteOption": "Tidak terbatas",
"invalidNumber": "Nomor tidak valid",
"nameLabel": "Nama",
@@ -827,5 +850,59 @@
"welcomeHeadline": "Duniamu, secara langsung",
"yourStationsTitle": "Stasiun Anda",
"nowListeningLabel": "Sedang mendengarkan",
"popularNowTitle": "Populer sekarang"
"popularNowTitle": "Populer sekarang",
"eqCustomActionEnableLabel": "Aktifkan equalizer",
"eqCustomActionDisableLabel": "Nonaktifkan equalizer",
"eqCustomActionPresetLabel": "Prasetel: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Dijeda karena liburan",
"alarmCardSchedulingFailedMessage": "Alarm ini tidak dapat didaftarkan ke sistem, sehingga mungkin tidak berbunyi.",
"alarmCardPreNoticeFailedMessage": "Alarm ini sudah dijadwalkan, tetapi pengingat awalnya tidak dapat diatur.",
"alarmDiagnosticsExactAlarmsTitle": "Penjadwalan alarm presisi",
"alarmDiagnosticsExactAlarmsHint": "Membuat alarm berbunyi tepat pada menit yang kamu atur, meski ponsel dalam mode tidur.",
"alarmDiagnosticsNotificationsTitle": "Notifikasi",
"alarmDiagnosticsNotificationsHint": "Diperlukan untuk menampilkan alarm dan pemberitahuan dini.",
"alarmDiagnosticsFullScreenTitle": "Tampilan alarm layar penuh",
"alarmDiagnosticsFullScreenHint": "Membuat layar alarm muncul otomatis, meski ponsel terkunci.",
"alarmDiagnosticsBatteryTitle": "Optimisasi baterai",
"alarmDiagnosticsBatteryHint": "Mencegah sistem menutup PluriWave di latar belakang, sehingga alarm tetap bisa berbunyi.",
"alarmDiagnosticsNativeCountTitle": "Alarm yang terdaftar di Android",
"alarmDiagnosticsNativeCountValue": "Terdaftar saat ini: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "Kamu punya alarm yang aktif, tapi belum ada yang terdaftar di sistem. Buka lagi PluriWave, atau perbaiki dulu poin-poin di atas.",
"alarmDiagnosticsManufacturerLabel": "Produsen",
"alarmDiagnosticsSdkLabel": "Versi Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Perlu perhatian",
"alarmDiagnosticsAutostartTitle": "Satu langkah manual lagi di ponsel ini",
"alarmDiagnosticsAutostartBody": "Ponsel {manufacturer} sering menutup aplikasi yang berjalan di latar belakang untuk menghemat baterai. Tidak ada pengaturan yang bisa diaktifkan PluriWave sendiri — kamu perlu mengaktifkan sendiri Autostart (kadang disebut \"Mulai otomatis\" atau \"Aktivitas latar belakang\") untuk PluriWave. Cari di Pengaturan, di bagian Aplikasi atau Baterai, atau di aplikasi Keamanan bawaan ponsel.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Perbaiki",
"alarmDiagnosticsIntentUnavailable": "Tidak bisa membuka layar pengaturan itu di ponsel ini. Coba cari secara manual di Pengaturan.",
"alarmDiagnosticsUnavailableHint": "Kami belum bisa memeriksa pengaturan alarmmu.",
"autoEqDisableOption": "Nonaktifkan",
"funcionPremium": "Fitur Premium",
"limiteAlarmasAlcanzado": "Anda telah mencapai batas gratis 5 alarm.",
"desbloquearPremium": "Buka Premium",
"restaurarCompras": "Pulihkan pembelian",
"compraError": "Pembelian tidak dapat diselesaikan. Silakan coba lagi.",
"restauracionSinCompras": "Kami tidak menemukan pembelian sebelumnya di akun ini.",
"premiumActivo": "Premium aktif"
}
+79 -2
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "Elimina periodo",
"vacationsDefaultName": "Vacanze",
"newVacationRangeTitle": "Nuovo periodo di vacanza",
"editVacationRangeTitle": "Modifica periodo di vacanza",
"vacationDeleteConfirmTitle": "Eliminare il periodo di vacanza?",
"vacationDeleteConfirmMessage": "Questa azione non può essere annullata.",
"startField": "Inizio",
"endField": "Fine",
"saveRangeAction": "Salva periodo",
@@ -601,7 +604,27 @@
"endLabel": "Fine",
"equalizerDisable": "Disattiva equalizzatore",
"helpTitle": "Aiuto e tutorial",
"helpSubtitle": "Rivedi funzioni, consigli e novità di PluriWave.",
"helpSubtitle": "9 schermate · rivedilo quando vuoi",
"tutorialSkipAction": "Salta",
"tutorialNextAction": "Avanti",
"tutorialPage1Headline": "Salva le tue emittenti e raggruppale",
"tutorialPage1Body": "Tocca il cuore per salvare un'emittente. In «Le tue emittenti» puoi creare gruppi come «Ogni mattina» o «Auto» e riordinarle trascinandole.",
"tutorialPage2Headline": "Un equalizzatore di base e uno per emittente",
"tutorialPage2Body": "Nelle Impostazioni definisci l'equalizzatore generale. E dalla riproduzione di un'emittente specifica puoi darle un'impostazione propria, che ha la priorità su quella generale.",
"tutorialPage3Headline": "Registra ciò che stai ascoltando",
"tutorialPage3Body": "Dal vassoio degli strumenti del player, «Registra» salva lo stream originale. Trova le tue registrazioni in Impostazioni › Registrazioni.",
"tutorialPage4Headline": "Sveglie che si adattano a te",
"tutorialPage4Body": "Svegliati con la tua emittente preferita, posticipa di 3, 5 o 10 minuti, e aggiungi intervalli di vacanza in modo che alcune sveglie si saltino da sole.",
"tutorialPage5Headline": "I tuoi preferiti, anche in auto",
"tutorialPage5Body": "Collega il telefono con Android Auto e troverai Preferiti, Tutte le emittenti, Le tue emittenti e la tua Musica locale, con pulsanti grandi pensati per la guida.",
"tutorialPage6Headline": "Si riconnette da sola",
"tutorialPage6Body": "Se il segnale cade, PluriWave riprova automaticamente e continua a mostrare i tuoi preferiti salvati anche senza connessione.",
"tutorialPage7Headline": "Scegli quanto posticipare",
"tutorialPage7Body": "Quando suona una sveglia, non c'è un solo «posticipa»: scegli 3, 5 o 10 minuti a seconda di cosa ti serve in quel momento.",
"tutorialPage8Headline": "Non la trovi? Aggiungila tu",
"tutorialPage8Body": "In «Le tue emittenti» → Aggiungi emittente personalizzata incolla l'URL dello stream di un'emittente non presente nella ricerca. Viene salvata in «Le tue emittenti», disponibile anche in auto.",
"tutorialPage9Headline": "Fatto, ora conosci l'essenziale",
"tutorialPage9BannerBody": "Per rivedere questo tutorial quando vuoi: Impostazioni → Informazioni → Aiuto e tutorial.",
"indefiniteOption": "Indefinita",
"invalidNumber": "Numero non valido",
"nameLabel": "Nome",
@@ -827,5 +850,59 @@
"welcomeHeadline": "Il tuo mondo, in diretta",
"yourStationsTitle": "Le tue emittenti",
"nowListeningLabel": "In ascolto ora",
"popularNowTitle": "Popolari ora"
"popularNowTitle": "Popolari ora",
"eqCustomActionEnableLabel": "Attiva equalizzatore",
"eqCustomActionDisableLabel": "Disattiva equalizzatore",
"eqCustomActionPresetLabel": "Preset attivo: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "In pausa per le vacanze",
"alarmCardSchedulingFailedMessage": "Questa sveglia non è stata registrata nel sistema, quindi potrebbe non suonare.",
"alarmCardPreNoticeFailedMessage": "Questa sveglia è programmata, ma non è stato possibile impostare il promemoria anticipato.",
"alarmDiagnosticsExactAlarmsTitle": "Programmazione sveglia esatta",
"alarmDiagnosticsExactAlarmsHint": "Permette alla sveglia di suonare esattamente al minuto impostato, anche a telefono in stand-by.",
"alarmDiagnosticsNotificationsTitle": "Notifiche",
"alarmDiagnosticsNotificationsHint": "Necessarie per mostrare la sveglia e l'avviso anticipato.",
"alarmDiagnosticsFullScreenTitle": "Visualizzazione a schermo intero della sveglia",
"alarmDiagnosticsFullScreenHint": "Permette alla schermata della sveglia di apparire automaticamente, anche a telefono bloccato.",
"alarmDiagnosticsBatteryTitle": "Ottimizzazione della batteria",
"alarmDiagnosticsBatteryHint": "Impedisce al sistema di chiudere PluriWave in background, così la sveglia può comunque suonare.",
"alarmDiagnosticsNativeCountTitle": "Sveglie registrate su Android",
"alarmDiagnosticsNativeCountValue": "Attualmente registrate: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "Hai una sveglia attiva, ma nessuna è ancora registrata nel sistema. Riapri PluriWave, oppure risolvi prima i punti sopra.",
"alarmDiagnosticsManufacturerLabel": "Produttore",
"alarmDiagnosticsSdkLabel": "Versione di Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Richiede attenzione",
"alarmDiagnosticsAutostartTitle": "Un altro passaggio manuale su questo telefono",
"alarmDiagnosticsAutostartBody": "I telefoni {manufacturer} spesso chiudono le app in background per risparmiare batteria. Non esiste un'impostazione che PluriWave possa attivare da solo: devi attivare tu stesso l'Avvio automatico (a volte chiamato \"Autostart\" o \"Attività in background\") per PluriWave. Cercalo nelle Impostazioni, sotto App o Batteria, oppure nell'app Sicurezza del telefono.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Risolvi",
"alarmDiagnosticsIntentUnavailable": "Non è stato possibile aprire questa schermata delle impostazioni su questo telefono. Prova a cercarla manualmente nelle Impostazioni.",
"alarmDiagnosticsUnavailableHint": "Non abbiamo ancora potuto controllare le impostazioni della sveglia.",
"autoEqDisableOption": "Disattiva",
"funcionPremium": "Funzione Premium",
"limiteAlarmasAlcanzado": "Hai raggiunto il limite gratuito di 5 sveglie.",
"desbloquearPremium": "Sblocca Premium",
"restaurarCompras": "Ripristina acquisti",
"compraError": "Non è stato possibile completare l'acquisto. Riprova.",
"restauracionSinCompras": "Non abbiamo trovato acquisti precedenti su questo account.",
"premiumActivo": "Premium attivo"
}
+79 -2
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "期間を削除",
"vacationsDefaultName": "休暇",
"newVacationRangeTitle": "新しい休暇期間",
"editVacationRangeTitle": "休暇期間を編集",
"vacationDeleteConfirmTitle": "休暇期間を削除しますか?",
"vacationDeleteConfirmMessage": "この操作は元に戻せません。",
"startField": "開始",
"endField": "終了",
"saveRangeAction": "期間を保存",
@@ -601,7 +604,27 @@
"endLabel": "終了",
"equalizerDisable": "イコライザーを無効化",
"helpTitle": "ヘルプとチュートリアル",
"helpSubtitle": "PluriWaveの機能、ヒント、新着情報を確認できます。",
"helpSubtitle": "全9画面・いつでも見返せます",
"tutorialSkipAction": "スキップ",
"tutorialNextAction": "次へ",
"tutorialPage1Headline": "お気に入りの局を保存してグループ分け",
"tutorialPage1Body": "ハートをタップして局を保存しましょう。「あなたの局」では「毎朝」や「車」などのグループを作り、ドラッグして並べ替えられます。",
"tutorialPage2Headline": "基本のイコライザーと局ごとのイコライザー",
"tutorialPage2Body": "設定で全体のイコライザーを決められます。特定の局を再生中は、その局専用の設定を適用でき、全体設定より優先されます。",
"tutorialPage3Headline": "聴いている番組を録音",
"tutorialPage3Body": "プレーヤーのツールトレイから「録音」をタップすると、元のストリームが保存されます。録音は設定›録音で確認できます。",
"tutorialPage4Headline": "あなたに合わせてくれるアラーム",
"tutorialPage4Body": "お気に入りの局で目覚め、3分・5分・10分でスヌーズでき、休暇期間を追加すれば一部のアラームを自動でスキップできます。",
"tutorialPage5Headline": "お気に入りは車でも",
"tutorialPage5Body": "スマートフォンをAndroid Autoに接続すると、お気に入り、すべての局、あなたの局、ローカルミュージックが、運転向けの大きなボタンで使えます。",
"tutorialPage6Headline": "自動で再接続",
"tutorialPage6Body": "電波が途切れても、PluriWaveは自動的に再試行し、接続がなくても保存済みのお気に入りを表示し続けます。",
"tutorialPage7Headline": "スヌーズ時間を選べる",
"tutorialPage7Body": "アラームが鳴ったとき、「スヌーズ」は一つだけではありません。3分・5分・10分から、その時必要な時間を選べます。",
"tutorialPage8Headline": "見つからない?自分で追加",
"tutorialPage8Body": "「あなたの局」→カスタム局を追加で、検索にない局のストリームURLを貼り付けられます。「あなたの局」に保存され、車でも利用できます。",
"tutorialPage9Headline": "完了、基本はこれで押さえました",
"tutorialPage9BannerBody": "このチュートリアルはいつでも再確認できます:設定 → 情報 → ヘルプとチュートリアル。",
"indefiniteOption": "無期限",
"invalidNumber": "無効な数値",
"nameLabel": "名前",
@@ -827,5 +850,59 @@
"welcomeHeadline": "あなたの世界を、ライブで",
"yourStationsTitle": "あなたの局",
"nowListeningLabel": "再生中",
"popularNowTitle": "今人気"
"popularNowTitle": "今人気",
"eqCustomActionEnableLabel": "イコライザーをオンにする",
"eqCustomActionDisableLabel": "イコライザーをオフにする",
"eqCustomActionPresetLabel": "プリセット: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "休暇のため一時停止中",
"alarmCardSchedulingFailedMessage": "このアラームはシステムに登録できなかったため、鳴らない可能性があります。",
"alarmCardPreNoticeFailedMessage": "このアラームは設定されていますが、事前通知を設定できませんでした。",
"alarmDiagnosticsExactAlarmsTitle": "正確なアラームのスケジュール設定",
"alarmDiagnosticsExactAlarmsHint": "スマートフォンがスリープ中でも、設定した時刻ちょうどにアラームを鳴らせるようにします。",
"alarmDiagnosticsNotificationsTitle": "通知",
"alarmDiagnosticsNotificationsHint": "アラームと事前通知を表示するために必要です。",
"alarmDiagnosticsFullScreenTitle": "アラームのフルスクリーン表示",
"alarmDiagnosticsFullScreenHint": "画面がロックされていても、アラーム画面が自動的に表示されるようにします。",
"alarmDiagnosticsBatteryTitle": "バッテリーの最適化",
"alarmDiagnosticsBatteryHint": "システムがPluriWaveをバックグラウンドで終了しないようにし、アラームが確実に鳴るようにします。",
"alarmDiagnosticsNativeCountTitle": "Androidに登録されているアラーム",
"alarmDiagnosticsNativeCountValue": "現在登録されている数: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "アラームは有効になっていますが、まだシステムに登録されていません。PluriWaveを開き直すか、まず上の項目を確認してください。",
"alarmDiagnosticsManufacturerLabel": "製造元",
"alarmDiagnosticsSdkLabel": "Androidのバージョン(SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "確認が必要です",
"alarmDiagnosticsAutostartTitle": "この端末でのもう一つの手動設定",
"alarmDiagnosticsAutostartBody": "{manufacturer}のスマートフォンは、バッテリーを節約するためにバックグラウンドのアプリを終了させることがよくあります。PluriWaveが自動でオンにできる設定はありません。PluriWaveの自動起動(「オートスタート」や「バックグラウンド動作」と呼ばれることもあります)を、自分で有効にする必要があります。設定内のアプリまたはバッテリーの項目、あるいは端末のセキュリティアプリを確認してください。",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "修正する",
"alarmDiagnosticsIntentUnavailable": "この端末では設定画面を開けませんでした。設定アプリ内で手動で探してみてください。",
"alarmDiagnosticsUnavailableHint": "アラームの設定をまだ確認できていません。",
"autoEqDisableOption": "無効化",
"funcionPremium": "プレミアム機能",
"limiteAlarmasAlcanzado": "無料プランのアラーム上限(5件)に達しました。",
"desbloquearPremium": "プレミアムを解除",
"restaurarCompras": "購入を復元",
"compraError": "購入を完了できませんでした。もう一度お試しください。",
"restauracionSinCompras": "このアカウントでは以前の購入が見つかりませんでした。",
"premiumActivo": "プレミアム有効"
}
+79 -2
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "Excluir período",
"vacationsDefaultName": "Férias",
"newVacationRangeTitle": "Novo período de férias",
"editVacationRangeTitle": "Editar período de férias",
"vacationDeleteConfirmTitle": "Excluir período de férias?",
"vacationDeleteConfirmMessage": "Esta ação não pode ser desfeita.",
"startField": "Início",
"endField": "Fim",
"saveRangeAction": "Salvar período",
@@ -601,7 +604,27 @@
"endLabel": "Fim",
"equalizerDisable": "Desativar equalizador",
"helpTitle": "Ajuda e tutorial",
"helpSubtitle": "Revê funções, dicas e novidades do PluriWave.",
"helpSubtitle": "9 ecrãs · reveja quando quiser",
"tutorialSkipAction": "Pular",
"tutorialNextAction": "Avançar",
"tutorialPage1Headline": "Guarde as suas estações e agrupe-as",
"tutorialPage1Body": "Toque no coração para guardar uma estação. Em «Suas estações» pode criar grupos como «Todas as manhãs» ou «Carro» e reordená-las arrastando.",
"tutorialPage2Headline": "Um equalizador geral e outro por estação",
"tutorialPage2Body": "Em Definições define o equalizador geral. E, ao reproduzir uma estação específica, pode dar-lhe o seu próprio ajuste, que prevalece sobre o geral.",
"tutorialPage3Headline": "Grave o que está a ouvir",
"tutorialPage3Body": "Na bandeja de ferramentas do leitor, «Gravar» guarda o stream original. Encontre as suas gravações em Definições › Gravações.",
"tutorialPage4Headline": "Alarmes que se adaptam a si",
"tutorialPage4Body": "Acorde com a sua estação favorita, adie 3, 5 ou 10 minutos, e adicione períodos de férias para que alguns alarmes se saltem sozinhos.",
"tutorialPage5Headline": "Os seus favoritos, também no carro",
"tutorialPage5Body": "Ligue o telemóvel ao Android Auto e encontre Favoritos, Todas as estações, Suas estações e a sua Música local, com botões grandes pensados para conduzir.",
"tutorialPage6Headline": "Reconecta-se sozinho",
"tutorialPage6Body": "Se o sinal cair, o PluriWave tenta novamente de forma automática e continua a mostrar os seus favoritos guardados mesmo sem ligação.",
"tutorialPage7Headline": "Escolha quanto tempo adiar",
"tutorialPage7Body": "Quando um alarme toca, não existe um único «adiar»: escolhe 3, 5 ou 10 minutos conforme o que precisar nesse momento.",
"tutorialPage8Headline": "Não a encontra? Adicione-a você mesmo",
"tutorialPage8Body": "Em «Suas estações» → Adicionar estação personalizada, cole o URL do stream de uma estação que não esteja na pesquisa. Fica guardada em «Suas estações», também disponível no carro.",
"tutorialPage9Headline": "Pronto, já conhece o essencial",
"tutorialPage9BannerBody": "Para rever este tutorial quando quiser: Definições → Informação → Ajuda e tutorial.",
"indefiniteOption": "Indefinida",
"invalidNumber": "Número inválido",
"nameLabel": "Nome",
@@ -827,5 +850,59 @@
"welcomeHeadline": "Seu mundo, ao vivo",
"yourStationsTitle": "Suas estações",
"nowListeningLabel": "Ouvindo agora",
"popularNowTitle": "Populares agora"
"popularNowTitle": "Populares agora",
"eqCustomActionEnableLabel": "Ativar equalizador",
"eqCustomActionDisableLabel": "Desativar equalizador",
"eqCustomActionPresetLabel": "Predefinição: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Pausada por férias",
"alarmCardSchedulingFailedMessage": "Este alarme não pôde ser registrado no sistema, por isso pode não tocar.",
"alarmCardPreNoticeFailedMessage": "Este alarme está agendado, mas seu aviso antecipado não pôde ser configurado.",
"alarmDiagnosticsExactAlarmsTitle": "Agendamento exato do alarme",
"alarmDiagnosticsExactAlarmsHint": "Permite que o alarme toque no minuto exato definido, mesmo com o telefone em repouso.",
"alarmDiagnosticsNotificationsTitle": "Notificações",
"alarmDiagnosticsNotificationsHint": "Necessárias para mostrar o alarme e o aviso prévio.",
"alarmDiagnosticsFullScreenTitle": "Exibição em tela cheia do alarme",
"alarmDiagnosticsFullScreenHint": "Permite que a tela do alarme apareça automaticamente, mesmo com o telefone bloqueado.",
"alarmDiagnosticsBatteryTitle": "Otimização de bateria",
"alarmDiagnosticsBatteryHint": "Evita que o sistema feche o PluriWave em segundo plano, para que o alarme ainda possa tocar.",
"alarmDiagnosticsNativeCountTitle": "Alarmes registrados no Android",
"alarmDiagnosticsNativeCountValue": "Registrados agora: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "Você tem um alarme ativado, mas nenhum está registrado no sistema ainda. Reabra o PluriWave ou resolva primeiro os itens acima.",
"alarmDiagnosticsManufacturerLabel": "Fabricante",
"alarmDiagnosticsSdkLabel": "Versão do Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Precisa de atenção",
"alarmDiagnosticsAutostartTitle": "Mais uma etapa manual neste telefone",
"alarmDiagnosticsAutostartBody": "Telefones {manufacturer} costumam fechar apps em segundo plano para economizar bateria. Não existe uma configuração que o PluriWave possa ativar sozinho: você precisa ativar por conta própria o Início automático (às vezes chamado de \"Autostart\" ou \"Atividade em segundo plano\") para o PluriWave. Procure em Configurações, em Apps ou Bateria, ou no próprio app de Segurança do telefone.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Resolver",
"alarmDiagnosticsIntentUnavailable": "Não foi possível abrir essa tela de configurações neste telefone. Tente procurá-la manualmente nas Configurações.",
"alarmDiagnosticsUnavailableHint": "Ainda não conseguimos verificar as configurações do seu alarme.",
"autoEqDisableOption": "Desativar",
"funcionPremium": "Recurso Premium",
"limiteAlarmasAlcanzado": "Você atingiu o limite gratuito de 5 alarmes.",
"desbloquearPremium": "Desbloquear Premium",
"restaurarCompras": "Restaurar compras",
"compraError": "Não foi possível concluir a compra. Tente novamente.",
"restauracionSinCompras": "Não encontramos nenhuma compra anterior nesta conta.",
"premiumActivo": "Premium ativo"
}
+79 -2
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "Удалить период",
"vacationsDefaultName": "Отпуск",
"newVacationRangeTitle": "Новый период отпуска",
"editVacationRangeTitle": "Изменить период отпуска",
"vacationDeleteConfirmTitle": "Удалить период отпуска?",
"vacationDeleteConfirmMessage": "Это действие нельзя отменить.",
"startField": "Начало",
"endField": "Конец",
"saveRangeAction": "Сохранить период",
@@ -601,7 +604,27 @@
"endLabel": "Конец",
"equalizerDisable": "Отключить эквалайзер",
"helpTitle": "Помощь и руководство",
"helpSubtitle": "Посмотрите функции, советы и новости PluriWave.",
"helpSubtitle": "9 экранов · смотрите снова в любое время",
"tutorialSkipAction": "Пропустить",
"tutorialNextAction": "Далее",
"tutorialPage1Headline": "Сохраняйте станции и группируйте их",
"tutorialPage1Body": "Нажмите на сердечко, чтобы сохранить станцию. В разделе «Ваши станции» можно создавать группы, например «Каждое утро» или «Машина», и менять порядок перетаскиванием.",
"tutorialPage2Headline": "Общий эквалайзер и отдельный для каждой станции",
"tutorialPage2Body": "В настройках вы задаёте общий эквалайзер. А во время воспроизведения конкретной станции можно задать для неё собственную настройку — она будет иметь приоритет над общей.",
"tutorialPage3Headline": "Записывайте то, что слушаете",
"tutorialPage3Body": "На панели инструментов плеера кнопка «Запись» сохраняет исходный поток. Ваши записи хранятся в разделе Настройки › Записи.",
"tutorialPage4Headline": "Будильники, которые подстраиваются под вас",
"tutorialPage4Body": "Просыпайтесь под любимую станцию, откладывайте на 3, 5 или 10 минут и добавляйте периоды отпуска, чтобы некоторые будильники пропускались сами.",
"tutorialPage5Headline": "Ваши избранные станции — и в машине тоже",
"tutorialPage5Body": "Подключите телефон через Android Auto, и вы найдёте Избранное, Все станции, Ваши станции и вашу локальную музыку с крупными кнопками, удобными за рулём.",
"tutorialPage6Headline": "Переподключается сам",
"tutorialPage6Body": "Если сигнал пропадает, PluriWave автоматически повторяет попытку и продолжает показывать сохранённые избранные станции даже без подключения.",
"tutorialPage7Headline": "Выбирайте, на сколько отложить",
"tutorialPage7Body": "Когда звонит будильник, нет единственного варианта «отложить»: выбирайте 3, 5 или 10 минут — в зависимости от того, что нужно именно сейчас.",
"tutorialPage8Headline": "Не нашли станцию? Добавьте её сами",
"tutorialPage8Body": "В разделе «Ваши станции» → Добавить свою станцию вставьте URL потока станции, которой нет в поиске. Она сохранится в «Ваши станции» и будет доступна и в машине.",
"tutorialPage9Headline": "Готово, теперь вы знаете самое важное",
"tutorialPage9BannerBody": "Чтобы посмотреть этот урок ещё раз: Настройки → Информация → Помощь и руководство.",
"indefiniteOption": "Без ограничения",
"invalidNumber": "Недопустимое число",
"nameLabel": "Название",
@@ -827,5 +850,59 @@
"welcomeHeadline": "Ваш мир, в прямом эфире",
"yourStationsTitle": "Ваши станции",
"nowListeningLabel": "Сейчас слушаете",
"popularNowTitle": "Популярно сейчас"
"popularNowTitle": "Популярно сейчас",
"eqCustomActionEnableLabel": "Включить эквалайзер",
"eqCustomActionDisableLabel": "Выключить эквалайзер",
"eqCustomActionPresetLabel": "Пресет: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Приостановлено на время отпуска",
"alarmCardSchedulingFailedMessage": "Этот будильник не удалось зарегистрировать в системе, поэтому он может не сработать.",
"alarmCardPreNoticeFailedMessage": "Этот будильник запланирован, но не удалось настроить предварительное напоминание.",
"alarmDiagnosticsExactAlarmsTitle": "Точное планирование будильника",
"alarmDiagnosticsExactAlarmsHint": "Будильник звонит ровно в заданную минуту, даже если телефон находится в режиме сна.",
"alarmDiagnosticsNotificationsTitle": "Уведомления",
"alarmDiagnosticsNotificationsHint": "Нужны, чтобы показать будильник и заблаговременное напоминание.",
"alarmDiagnosticsFullScreenTitle": "Полноэкранный показ будильника",
"alarmDiagnosticsFullScreenHint": "Экран будильника появляется автоматически, даже если телефон заблокирован.",
"alarmDiagnosticsBatteryTitle": "Оптимизация батареи",
"alarmDiagnosticsBatteryHint": "Не позволяет системе закрывать PluriWave в фоновом режиме, чтобы будильник мог сработать.",
"alarmDiagnosticsNativeCountTitle": "Будильники, зарегистрированные в Android",
"alarmDiagnosticsNativeCountValue": "Сейчас зарегистрировано: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "У вас включён будильник, но пока ни один не зарегистрирован в системе. Откройте PluriWave заново или сначала устраните пункты выше.",
"alarmDiagnosticsManufacturerLabel": "Производитель",
"alarmDiagnosticsSdkLabel": "Версия Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Требует внимания",
"alarmDiagnosticsAutostartTitle": "Ещё один ручной шаг на этом телефоне",
"alarmDiagnosticsAutostartBody": "Телефоны {manufacturer} часто закрывают приложения в фоновом режиме для экономии батареи. PluriWave не может включить это самостоятельно — вам нужно вручную включить автозапуск (иногда называется \"Autostart\" или \"Фоновая активность\") для PluriWave. Ищите в Настройках, в разделе Приложения или Батарея, либо в приложении безопасности телефона.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Исправить",
"alarmDiagnosticsIntentUnavailable": "Не удалось открыть этот экран настроек на этом телефоне. Попробуйте найти его вручную в Настройках.",
"alarmDiagnosticsUnavailableHint": "Мы пока не смогли проверить настройки вашего будильника.",
"autoEqDisableOption": "Отключить",
"funcionPremium": "Премиум-функция",
"limiteAlarmasAlcanzado": "Вы достигли бесплатного лимита в 5 будильников.",
"desbloquearPremium": "Разблокировать Премиум",
"restaurarCompras": "Восстановить покупки",
"compraError": "Не удалось завершить покупку. Попробуйте ещё раз.",
"restauracionSinCompras": "Мы не нашли предыдущих покупок на этом аккаунте.",
"premiumActivo": "Премиум активен"
}
+79 -2
View File
@@ -468,6 +468,9 @@
"deleteRangeTooltip": "删除范围",
"vacationsDefaultName": "假期",
"newVacationRangeTitle": "新的假期范围",
"editVacationRangeTitle": "编辑假期范围",
"vacationDeleteConfirmTitle": "删除假期范围?",
"vacationDeleteConfirmMessage": "此操作无法撤销。",
"startField": "开始",
"endField": "结束",
"saveRangeAction": "保存范围",
@@ -601,7 +604,27 @@
"endLabel": "结束",
"equalizerDisable": "关闭均衡器",
"helpTitle": "帮助和教程",
"helpSubtitle": "查看 PluriWave 的功能、技巧和新内容。",
"helpSubtitle": "共9屏 · 随时可重新查看",
"tutorialSkipAction": "跳过",
"tutorialNextAction": "下一步",
"tutorialPage1Headline": "保存并整理你的电台",
"tutorialPage1Body": "点击心形图标即可保存电台。在“你的电台”中,你可以创建“每天早上”或“车载”等分组,并通过拖动重新排序。",
"tutorialPage2Headline": "一个通用均衡器,外加每个电台专属的均衡器",
"tutorialPage2Body": "在设置中可以调整通用均衡器。播放某个电台时,你还可以为它单独设置均衡器,该设置优先于通用设置。",
"tutorialPage3Headline": "录制正在收听的内容",
"tutorialPage3Body": "在播放器工具栏中点击“录制”,即可保存原始音频流。录音内容可在 设置 › 录音 中查看。",
"tutorialPage4Headline": "会迁就你的闹钟",
"tutorialPage4Body": "用喜欢的电台唤醒自己,可以推迟3分钟、5分钟或10分钟,还能添加假期时间段,让部分闹钟自动跳过。",
"tutorialPage5Headline": "收藏的电台,车载也能用",
"tutorialPage5Body": "将手机连接到Android Auto,即可看到收藏、全部电台、你的电台和本地音乐,大按钮专为驾驶设计。",
"tutorialPage6Headline": "自动重新连接",
"tutorialPage6Body": "信号中断时,PluriWave会自动重试连接,即使暂时没有网络也会继续显示已保存的收藏电台。",
"tutorialPage7Headline": "自选推迟时长",
"tutorialPage7Body": "闹钟响起时,“推迟”并非只有一种选择:你可以根据当下需要选择推迟3分钟、5分钟或10分钟。",
"tutorialPage8Headline": "找不到?自己添加",
"tutorialPage8Body": "在“你的电台”→添加自定义电台中,粘贴搜索结果里没有的电台的流媒体URL即可。它会保存在“你的电台”中,车载模式下同样可用。",
"tutorialPage9Headline": "完成,你已经了解核心功能",
"tutorialPage9BannerBody": "想随时重新观看本教程:设置 → 信息 → 帮助与教程。",
"indefiniteOption": "不限时",
"invalidNumber": "数字无效",
"nameLabel": "名称",
@@ -827,5 +850,59 @@
"welcomeHeadline": "你的世界,直播中",
"yourStationsTitle": "你的电台",
"nowListeningLabel": "正在收听",
"popularNowTitle": "当前热门"
"popularNowTitle": "当前热门",
"eqCustomActionEnableLabel": "启用均衡器",
"eqCustomActionDisableLabel": "关闭均衡器",
"eqCustomActionPresetLabel": "预设:{preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "因假期已暂停",
"alarmCardSchedulingFailedMessage": "该闹钟未能在系统中注册,因此可能不会响铃。",
"alarmCardPreNoticeFailedMessage": "该闹钟已设置,但其提前提醒未能设置成功。",
"alarmDiagnosticsExactAlarmsTitle": "精确闹钟排程",
"alarmDiagnosticsExactAlarmsHint": "即使手机处于休眠状态,也能让闹钟在设定的准确时间响起。",
"alarmDiagnosticsNotificationsTitle": "通知",
"alarmDiagnosticsNotificationsHint": "显示闹钟和提前提醒需要用到。",
"alarmDiagnosticsFullScreenTitle": "闹钟全屏显示",
"alarmDiagnosticsFullScreenHint": "即使手机已锁屏,也能让响铃界面自动出现。",
"alarmDiagnosticsBatteryTitle": "电池优化",
"alarmDiagnosticsBatteryHint": "防止系统在后台关闭PluriWave,让闹钟仍然可以响起。",
"alarmDiagnosticsNativeCountTitle": "已在Android系统注册的闹钟",
"alarmDiagnosticsNativeCountValue": "当前已注册:{count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "你已开启一个闹钟,但目前还没有闹钟在系统中注册。请重新打开PluriWave,或先解决上面列出的问题。",
"alarmDiagnosticsManufacturerLabel": "制造商",
"alarmDiagnosticsSdkLabel": "Android版本(SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "需要注意",
"alarmDiagnosticsAutostartTitle": "此手机还需要一步手动设置",
"alarmDiagnosticsAutostartBody": "{manufacturer}手机经常会关闭后台运行的应用以节省电量。没有任何设置可以让PluriWave自行开启——你需要自己为PluriWave开启自启动(有时也叫\"Autostart\"或\"后台活动\")。请在设置中查找应用或电池选项,或者查看手机自带的安全应用。",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "解决",
"alarmDiagnosticsIntentUnavailable": "无法在此手机上打开该设置界面。请尝试在设置中手动查找。",
"alarmDiagnosticsUnavailableHint": "我们还无法检查你的闹钟设置。",
"autoEqDisableOption": "关闭",
"funcionPremium": "高级功能",
"limiteAlarmasAlcanzado": "您已达到免费版 5 个闹钟的上限。",
"desbloquearPremium": "解锁高级版",
"restaurarCompras": "恢复购买",
"compraError": "无法完成购买,请重试。",
"restauracionSinCompras": "未在此账户中找到以前的购买记录。",
"premiumActivo": "高级版已解锁"
}
+337 -1
View File
@@ -2078,6 +2078,24 @@ abstract class AppLocalizations {
/// **'Nuevo rango de vacaciones'**
String get newVacationRangeTitle;
/// No description provided for @editVacationRangeTitle.
///
/// In es, this message translates to:
/// **'Editar rango de vacaciones'**
String get editVacationRangeTitle;
/// No description provided for @vacationDeleteConfirmTitle.
///
/// In es, this message translates to:
/// **'¿Eliminar rango de vacaciones?'**
String get vacationDeleteConfirmTitle;
/// No description provided for @vacationDeleteConfirmMessage.
///
/// In es, this message translates to:
/// **'Esta acción no se puede deshacer.'**
String get vacationDeleteConfirmMessage;
/// No description provided for @startField.
///
/// In es, this message translates to:
@@ -2549,9 +2567,129 @@ abstract class AppLocalizations {
/// No description provided for @helpSubtitle.
///
/// In es, this message translates to:
/// **'Repasá funciones, consejos y novedades de PluriWave.'**
/// **'9 pantallas · vuelve a verlo cuando quieras'**
String get helpSubtitle;
/// No description provided for @tutorialSkipAction.
///
/// In es, this message translates to:
/// **'Saltar'**
String get tutorialSkipAction;
/// No description provided for @tutorialNextAction.
///
/// In es, this message translates to:
/// **'Siguiente'**
String get tutorialNextAction;
/// No description provided for @tutorialPage1Headline.
///
/// In es, this message translates to:
/// **'Guarda tus emisoras y agrúpalas'**
String get tutorialPage1Headline;
/// No description provided for @tutorialPage1Body.
///
/// In es, this message translates to:
/// **'Toca el corazón para guardar una emisora. En «Tus emisoras» puedes crear grupos como «Cada mañana» o «Coche» y reordenarlas arrastrando.'**
String get tutorialPage1Body;
/// No description provided for @tutorialPage2Headline.
///
/// In es, this message translates to:
/// **'Un ecualizador base y otro por emisora'**
String get tutorialPage2Headline;
/// No description provided for @tutorialPage2Body.
///
/// In es, this message translates to:
/// **'En Ajustes defines el ecualizador general. Y desde la reproducción de una emisora concreta puedes darle su propio ajuste, que manda sobre el general.'**
String get tutorialPage2Body;
/// No description provided for @tutorialPage3Headline.
///
/// In es, this message translates to:
/// **'Graba lo que estás escuchando'**
String get tutorialPage3Headline;
/// No description provided for @tutorialPage3Body.
///
/// In es, this message translates to:
/// **'Desde la bandeja de herramientas del reproductor, «Grabar» guarda el stream original. Encuentra tus grabaciones en Ajustes › Grabaciones.'**
String get tutorialPage3Body;
/// No description provided for @tutorialPage4Headline.
///
/// In es, this message translates to:
/// **'Alarmas que se adaptan a ti'**
String get tutorialPage4Headline;
/// No description provided for @tutorialPage4Body.
///
/// In es, this message translates to:
/// **'Despiértate con tu emisora favorita, pospón 3, 5 o 10 minutos, y añade rangos de vacaciones para que algunas alarmas se salten solas.'**
String get tutorialPage4Body;
/// No description provided for @tutorialPage5Headline.
///
/// In es, this message translates to:
/// **'Tus favoritas, también en el coche'**
String get tutorialPage5Headline;
/// No description provided for @tutorialPage5Body.
///
/// In es, this message translates to:
/// **'Conecta el móvil con Android Auto y encontrarás Favoritos, Todas las emisoras, Mis emisoras y tu Música Local, con botones grandes pensados para conducir.'**
String get tutorialPage5Body;
/// No description provided for @tutorialPage6Headline.
///
/// In es, this message translates to:
/// **'Se reconecta sola'**
String get tutorialPage6Headline;
/// No description provided for @tutorialPage6Body.
///
/// In es, this message translates to:
/// **'Si se corta la señal, PluriWave reintenta automáticamente y sigue mostrando tus favoritas guardadas aunque no tengas conexión.'**
String get tutorialPage6Body;
/// No description provided for @tutorialPage7Headline.
///
/// In es, this message translates to:
/// **'Elige cuánto posponer'**
String get tutorialPage7Headline;
/// No description provided for @tutorialPage7Body.
///
/// In es, this message translates to:
/// **'Cuando suene una alarma, no hay un único «posponer»: eliges 3, 5 o 10 minutos según lo que necesites en ese momento.'**
String get tutorialPage7Body;
/// No description provided for @tutorialPage8Headline.
///
/// In es, this message translates to:
/// **'¿No la encuentras? Añádela tú'**
String get tutorialPage8Headline;
/// No description provided for @tutorialPage8Body.
///
/// In es, this message translates to:
/// **'En «Tus emisoras» → Añadir emisora personalizada pega la URL del stream de una emisora que no esté en el buscador. Se guarda en «Mis emisoras», también disponible en el coche.'**
String get tutorialPage8Body;
/// No description provided for @tutorialPage9Headline.
///
/// In es, this message translates to:
/// **'Listo, ya conoces lo esencial'**
String get tutorialPage9Headline;
/// No description provided for @tutorialPage9BannerBody.
///
/// In es, this message translates to:
/// **'Para volver a ver este tutorial cuando quieras: Ajustes → Información → Ayuda y tutorial.'**
String get tutorialPage9BannerBody;
/// No description provided for @indefiniteOption.
///
/// In es, this message translates to:
@@ -3031,6 +3169,204 @@ abstract class AppLocalizations {
/// In es, this message translates to:
/// **'Empezar a escuchar'**
String get welcomeCtaLabel;
/// No description provided for @eqCustomActionEnableLabel.
///
/// In es, this message translates to:
/// **'Activar ecualizador'**
String get eqCustomActionEnableLabel;
/// No description provided for @eqCustomActionDisableLabel.
///
/// In es, this message translates to:
/// **'Desactivar ecualizador'**
String get eqCustomActionDisableLabel;
/// No description provided for @eqCustomActionPresetLabel.
///
/// In es, this message translates to:
/// **'Preset: {preset}'**
String eqCustomActionPresetLabel(String preset);
/// No description provided for @alarmCardVacationPausedBadge.
///
/// In es, this message translates to:
/// **'Pausada por vacaciones'**
String get alarmCardVacationPausedBadge;
/// No description provided for @alarmCardSchedulingFailedMessage.
///
/// In es, this message translates to:
/// **'Esta alarma no se pudo registrar en el sistema, así que podría no sonar.'**
String get alarmCardSchedulingFailedMessage;
/// No description provided for @alarmCardPreNoticeFailedMessage.
///
/// In es, this message translates to:
/// **'Esta alarma está programada, pero no se pudo activar su aviso previo.'**
String get alarmCardPreNoticeFailedMessage;
/// No description provided for @alarmDiagnosticsExactAlarmsTitle.
///
/// In es, this message translates to:
/// **'Programación de alarma exacta'**
String get alarmDiagnosticsExactAlarmsTitle;
/// No description provided for @alarmDiagnosticsExactAlarmsHint.
///
/// In es, this message translates to:
/// **'Permite que la alarma suene en el minuto exacto que elegiste, incluso con el teléfono en reposo.'**
String get alarmDiagnosticsExactAlarmsHint;
/// No description provided for @alarmDiagnosticsNotificationsTitle.
///
/// In es, this message translates to:
/// **'Notificaciones'**
String get alarmDiagnosticsNotificationsTitle;
/// No description provided for @alarmDiagnosticsNotificationsHint.
///
/// In es, this message translates to:
/// **'Necesarias para mostrar la alarma y el aviso previo.'**
String get alarmDiagnosticsNotificationsHint;
/// No description provided for @alarmDiagnosticsFullScreenTitle.
///
/// In es, this message translates to:
/// **'Pantalla completa de la alarma'**
String get alarmDiagnosticsFullScreenTitle;
/// No description provided for @alarmDiagnosticsFullScreenHint.
///
/// In es, this message translates to:
/// **'Permite que la pantalla de la alarma aparezca automáticamente, incluso con el teléfono bloqueado.'**
String get alarmDiagnosticsFullScreenHint;
/// No description provided for @alarmDiagnosticsBatteryTitle.
///
/// In es, this message translates to:
/// **'Optimización de batería'**
String get alarmDiagnosticsBatteryTitle;
/// No description provided for @alarmDiagnosticsBatteryHint.
///
/// In es, this message translates to:
/// **'Evita que el sistema cierre PluriWave en segundo plano para que la alarma pueda sonar.'**
String get alarmDiagnosticsBatteryHint;
/// No description provided for @alarmDiagnosticsNativeCountTitle.
///
/// In es, this message translates to:
/// **'Alarmas registradas en Android'**
String get alarmDiagnosticsNativeCountTitle;
/// No description provided for @alarmDiagnosticsNativeCountValue.
///
/// In es, this message translates to:
/// **'Registradas ahora mismo: {count}'**
String alarmDiagnosticsNativeCountValue(int count);
/// No description provided for @alarmDiagnosticsNativeCountAttentionHint.
///
/// In es, this message translates to:
/// **'Tenés una alarma activada, pero todavía ninguna está registrada en el sistema. Volvé a abrir PluriWave o solucioná primero los puntos de arriba.'**
String get alarmDiagnosticsNativeCountAttentionHint;
/// No description provided for @alarmDiagnosticsManufacturerLabel.
///
/// In es, this message translates to:
/// **'Fabricante'**
String get alarmDiagnosticsManufacturerLabel;
/// No description provided for @alarmDiagnosticsSdkLabel.
///
/// In es, this message translates to:
/// **'Versión de Android (SDK)'**
String get alarmDiagnosticsSdkLabel;
/// No description provided for @alarmDiagnosticsNeedsAttentionStatus.
///
/// In es, this message translates to:
/// **'Necesita atención'**
String get alarmDiagnosticsNeedsAttentionStatus;
/// No description provided for @alarmDiagnosticsAutostartTitle.
///
/// In es, this message translates to:
/// **'Un paso manual más en este teléfono'**
String get alarmDiagnosticsAutostartTitle;
/// No description provided for @alarmDiagnosticsAutostartBody.
///
/// In es, this message translates to:
/// **'Los teléfonos {manufacturer} suelen cerrar las apps que están en segundo plano para ahorrar batería. No hay ningún ajuste que PluriWave pueda activar por su cuenta: tenés que activar vos mismo el Inicio automático (a veces llamado \"Autostart\" o \"Actividad en segundo plano\"). Buscalo en Ajustes, dentro de Apps o Batería, o en la app de Seguridad del teléfono.'**
String alarmDiagnosticsAutostartBody(String manufacturer);
/// No description provided for @alarmDiagnosticsFixAction.
///
/// In es, this message translates to:
/// **'Solucionar'**
String get alarmDiagnosticsFixAction;
/// No description provided for @alarmDiagnosticsIntentUnavailable.
///
/// In es, this message translates to:
/// **'No se pudo abrir esa pantalla de ajustes en este teléfono. Buscala manualmente en Ajustes.'**
String get alarmDiagnosticsIntentUnavailable;
/// No description provided for @alarmDiagnosticsUnavailableHint.
///
/// In es, this message translates to:
/// **'Todavía no pudimos revisar tus ajustes de alarma.'**
String get alarmDiagnosticsUnavailableHint;
/// No description provided for @autoEqDisableOption.
///
/// In es, this message translates to:
/// **'Desactivar'**
String get autoEqDisableOption;
/// No description provided for @funcionPremium.
///
/// In es, this message translates to:
/// **'Función Premium'**
String get funcionPremium;
/// No description provided for @limiteAlarmasAlcanzado.
///
/// In es, this message translates to:
/// **'Has alcanzado el límite de 5 alarmas gratuitas.'**
String get limiteAlarmasAlcanzado;
/// No description provided for @desbloquearPremium.
///
/// In es, this message translates to:
/// **'Desbloquear Premium'**
String get desbloquearPremium;
/// No description provided for @restaurarCompras.
///
/// In es, this message translates to:
/// **'Restaurar compras'**
String get restaurarCompras;
/// No description provided for @compraError.
///
/// In es, this message translates to:
/// **'No se ha podido completar la compra. Inténtalo de nuevo.'**
String get compraError;
/// No description provided for @restauracionSinCompras.
///
/// In es, this message translates to:
/// **'No hemos encontrado ninguna compra anterior en esta cuenta.'**
String get restauracionSinCompras;
/// No description provided for @premiumActivo.
///
/// In es, this message translates to:
/// **'Premium activo'**
String get premiumActivo;
}
class _AppLocalizationsDelegate
+197 -1
View File
@@ -1133,6 +1133,15 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get newVacationRangeTitle => 'نطاق إجازة جديد';
@override
String get editVacationRangeTitle => 'تعديل نطاق الإجازة';
@override
String get vacationDeleteConfirmTitle => 'هل تريد حذف نطاق الإجازة؟';
@override
String get vacationDeleteConfirmMessage => 'لا يمكن التراجع عن هذا الإجراء.';
@override
String get startField => 'البداية';
@@ -1398,7 +1407,76 @@ class AppLocalizationsAr extends AppLocalizations {
String get helpTitle => 'المساعدة والشرح';
@override
String get helpSubtitle => 'راجع ميزات PluriWave والنصائح والمستجدات.';
String get helpSubtitle => '9 شاشات · شاهده مجددًا متى شئت';
@override
String get tutorialSkipAction => 'تخطي';
@override
String get tutorialNextAction => 'التالي';
@override
String get tutorialPage1Headline => 'احفظ محطاتك ونظّمها في مجموعات';
@override
String get tutorialPage1Body =>
'اضغط على أيقونة القلب لحفظ محطة. في «محطاتك» يمكنك إنشاء مجموعات مثل «كل صباح» أو «السيارة» وإعادة ترتيبها بالسحب.';
@override
String get tutorialPage2Headline => 'معادل صوت عام وآخر لكل محطة';
@override
String get tutorialPage2Body =>
'من الإعدادات تحدد المعادل الصوتي العام. ومن تشغيل محطة معينة يمكنك ضبط إعداد خاص بها، له الأولوية على الإعداد العام.';
@override
String get tutorialPage3Headline => 'سجّل ما تستمع إليه';
@override
String get tutorialPage3Body =>
'من شريط أدوات المشغل، يحفظ زر «تسجيل» البث الأصلي. ستجد تسجيلاتك في الإعدادات › التسجيلات.';
@override
String get tutorialPage4Headline => 'منبهات تتكيّف معك';
@override
String get tutorialPage4Body =>
'استيقظ على محطتك المفضلة، وأجّل المنبه 3 أو 5 أو 10 دقائق، وأضف فترات إجازة ليتخطى بعض المنبهات نفسه تلقائيًا.';
@override
String get tutorialPage5Headline => 'محطاتك المفضلة، في السيارة أيضًا';
@override
String get tutorialPage5Body =>
'اربط هاتفك بـ Android Auto لتجد المفضلة وجميع المحطات ومحطاتك والموسيقى المحلية، بأزرار كبيرة مصممة للقيادة.';
@override
String get tutorialPage6Headline => 'يعيد الاتصال تلقائيًا';
@override
String get tutorialPage6Body =>
'إذا انقطعت الإشارة، تعيد PluriWave المحاولة تلقائيًا وتستمر في عرض محطاتك المفضلة المحفوظة حتى بدون اتصال.';
@override
String get tutorialPage7Headline => 'اختر مدة التأجيل';
@override
String get tutorialPage7Body =>
'عند رنين المنبه، لا يوجد خيار تأجيل واحد فقط: تختار 3 أو 5 أو 10 دقائق حسب ما تحتاجه في تلك اللحظة.';
@override
String get tutorialPage8Headline => 'لم تجدها؟ أضفها بنفسك';
@override
String get tutorialPage8Body =>
'من «محطاتك» ← إضافة محطة مخصصة، الصق رابط بث محطة غير موجودة في نتائج البحث. تُحفظ في «محطاتك»، وتكون متاحة في السيارة أيضًا.';
@override
String get tutorialPage9Headline => 'تمّ، أصبحت تعرف الأساسيات';
@override
String get tutorialPage9BannerBody =>
'لمشاهدة هذا الشرح مرة أخرى في أي وقت: الإعدادات ← المعلومات ← المساعدة والشرح.';
@override
String get indefiniteOption => 'غير محدد';
@@ -1667,4 +1745,122 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get welcomeCtaLabel => 'ابدأ الاستماع';
@override
String get eqCustomActionEnableLabel => 'تفعيل الموازن';
@override
String get eqCustomActionDisableLabel => 'إيقاف الموازن';
@override
String eqCustomActionPresetLabel(String preset) {
return 'الإعداد المسبق: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'متوقفة مؤقتًا بسبب الإجازة';
@override
String get alarmCardSchedulingFailedMessage =>
'لم يتم تسجيل هذا المنبه في النظام، لذا قد لا يرن.';
@override
String get alarmCardPreNoticeFailedMessage =>
'تمت جدولة هذا المنبه، لكن تعذّر ضبط تذكيره المسبق.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'جدولة المنبه بدقة';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'يتيح رنين المنبه في الدقيقة المحددة تمامًا، حتى عندما يكون الهاتف في وضع السكون.';
@override
String get alarmDiagnosticsNotificationsTitle => 'الإشعارات';
@override
String get alarmDiagnosticsNotificationsHint =>
'ضرورية لعرض المنبه والتنبيه المسبق.';
@override
String get alarmDiagnosticsFullScreenTitle => 'عرض المنبه بملء الشاشة';
@override
String get alarmDiagnosticsFullScreenHint =>
'يتيح ظهور شاشة الرنين تلقائيًا، حتى عندما يكون الهاتف مقفلاً.';
@override
String get alarmDiagnosticsBatteryTitle => 'تحسين استهلاك البطارية';
@override
String get alarmDiagnosticsBatteryHint =>
'يمنع النظام من إغلاق PluriWave في الخلفية حتى يتمكن المنبه من الرنين.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'المنبهات المسجَّلة لدى Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'المسجَّل حاليًا: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'لديك منبه مفعَّل، لكن لا يوجد أي منبه مسجَّل في النظام بعد. أعد فتح PluriWave، أو عالج النقاط أعلاه أولاً.';
@override
String get alarmDiagnosticsManufacturerLabel => 'الشركة المصنِّعة';
@override
String get alarmDiagnosticsSdkLabel => 'إصدار Android (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'يحتاج إلى انتباه';
@override
String get alarmDiagnosticsAutostartTitle =>
'خطوة يدوية إضافية على هذا الهاتف';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return 'غالبًا ما تُغلق هواتف $manufacturer التطبيقات العاملة في الخلفية لتوفير البطارية. لا يوجد إعداد يمكن لـ PluriWave تفعيله بنفسه — عليك أن تُفعِّل بنفسك خاصية التشغيل التلقائي (تُعرف أحيانًا باسم \"Autostart\" أو \"النشاط في الخلفية\") لتطبيق PluriWave. ابحث عنها في الإعدادات، ضمن التطبيقات أو البطارية، أو في تطبيق الأمان الخاص بالهاتف.';
}
@override
String get alarmDiagnosticsFixAction => 'إصلاح';
@override
String get alarmDiagnosticsIntentUnavailable =>
'تعذّر فتح شاشة الإعدادات هذه على هذا الهاتف. حاول البحث عنها يدويًا في الإعدادات.';
@override
String get alarmDiagnosticsUnavailableHint =>
'لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.';
@override
String get autoEqDisableOption => 'تعطيل';
@override
String get funcionPremium => 'ميزة مميزة';
@override
String get limiteAlarmasAlcanzado =>
'لقد وصلت إلى الحد المجاني وهو 5 منبهات.';
@override
String get desbloquearPremium => 'فتح النسخة المميزة';
@override
String get restaurarCompras => 'استعادة المشتريات';
@override
String get compraError => 'تعذّر إتمام عملية الشراء. حاول مرة أخرى.';
@override
String get restauracionSinCompras =>
'لم نجد أي عملية شراء سابقة في هذا الحساب.';
@override
String get premiumActivo => 'النسخة المميزة مفعّلة';
}
+200 -1
View File
@@ -1140,6 +1140,16 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get newVacationRangeTitle => 'নতুন ছুটির পরিসর';
@override
String get editVacationRangeTitle => 'ছুটির পরিসর সম্পাদনা করুন';
@override
String get vacationDeleteConfirmTitle => 'ছুটির পরিসর মুছবেন?';
@override
String get vacationDeleteConfirmMessage =>
'এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।';
@override
String get startField => 'শুরু';
@@ -1407,7 +1417,79 @@ class AppLocalizationsBn extends AppLocalizations {
String get helpTitle => 'সহায়তা ও টিউটোরিয়াল';
@override
String get helpSubtitle => 'PluriWave-এর ফিচার, টিপস ও নতুন বিষয়গুলো দেখুন।';
String get helpSubtitle => '৯টি স্ক্রিন · যখন খুশি আবার দেখুন';
@override
String get tutorialSkipAction => 'এড়িয়ে যান';
@override
String get tutorialNextAction => 'পরবর্তী';
@override
String get tutorialPage1Headline =>
'আপনার স্টেশন সংরক্ষণ করুন এবং গ্রুপে ভাগ করুন';
@override
String get tutorialPage1Body =>
'একটি স্টেশন সংরক্ষণ করতে হার্ট আইকনে ট্যাপ করুন। «আপনার স্টেশন»-এ আপনি «প্রতিদিন সকালে» বা «গাড়ি»-এর মতো গ্রুপ তৈরি করতে পারেন এবং টেনে সেগুলোর ক্রম বদলাতে পারেন।';
@override
String get tutorialPage2Headline =>
'একটি সাধারণ ইকুয়ালাইজার, প্রতিটি স্টেশনের জন্য আরেকটি';
@override
String get tutorialPage2Body =>
'সেটিংসে আপনি সাধারণ ইকুয়ালাইজার ঠিক করেন। আর কোনো নির্দিষ্ট স্টেশন চালানোর সময় আপনি সেটির নিজস্ব সেটিং দিতে পারেন, যা সাধারণ সেটিংয়ের চেয়ে অগ্রাধিকার পায়।';
@override
String get tutorialPage3Headline => 'যা শুনছেন তা রেকর্ড করুন';
@override
String get tutorialPage3Body =>
'প্লেয়ারের টুল ট্রে থেকে «রেকর্ড করুন» মূল স্ট্রিম সংরক্ষণ করে। আপনার রেকর্ডিং পাবেন সেটিংস › রেকর্ডিং-এ।';
@override
String get tutorialPage4Headline =>
'এমন অ্যালার্ম যা আপনার সাথে মানিয়ে নেয়';
@override
String get tutorialPage4Body =>
'আপনার প্রিয় স্টেশনে জেগে উঠুন, ৩, ৫ বা ১০ মিনিট স্নুজ করুন, এবং ছুটির সময়সীমা যোগ করুন যাতে কিছু অ্যালার্ম নিজে থেকেই বাদ পড়ে।';
@override
String get tutorialPage5Headline => 'আপনার প্রিয়গুলো, গাড়িতেও';
@override
String get tutorialPage5Body =>
'আপনার ফোন Android Auto-এর সাথে সংযুক্ত করুন এবং পাবেন প্রিয়, সব স্টেশন, আপনার স্টেশন এবং আপনার লোকাল মিউজিক, গাড়ি চালানোর উপযোগী বড় বোতামসহ।';
@override
String get tutorialPage6Headline => 'নিজে থেকেই আবার সংযুক্ত হয়';
@override
String get tutorialPage6Body =>
'সিগন্যাল কেটে গেলে, PluriWave স্বয়ংক্রিয়ভাবে আবার চেষ্টা করে এবং সংযোগ ছাড়াই আপনার সংরক্ষিত প্রিয়গুলো দেখাতে থাকে।';
@override
String get tutorialPage7Headline => 'কতক্ষণ স্নুজ করবেন তা বেছে নিন';
@override
String get tutorialPage7Body =>
'অ্যালার্ম বাজলে, «স্নুজ»-এর একটিমাত্র বিকল্প নেই: আপনি সেই মুহূর্তে প্রয়োজন অনুযায়ী ৩, ৫ বা ১০ মিনিট বেছে নেন।';
@override
String get tutorialPage8Headline => 'খুঁজে পাচ্ছেন না? নিজেই যোগ করুন';
@override
String get tutorialPage8Body =>
'«আপনার স্টেশন» → কাস্টম স্টেশন যোগ করুন-এ, খোঁজে না পাওয়া স্টেশনের স্ট্রিম URL পেস্ট করুন। এটি «আপনার স্টেশন»-এ সংরক্ষিত হয় এবং গাড়িতেও পাওয়া যায়।';
@override
String get tutorialPage9Headline => 'শেষ, এখন আপনি মূল বিষয়গুলো জানেন';
@override
String get tutorialPage9BannerBody =>
'এই টিউটোরিয়ালটি আবার দেখতে: সেটিংস → তথ্য → সহায়তা ও টিউটোরিয়াল।';
@override
String get indefiniteOption => 'অনির্দিষ্ট';
@@ -1675,4 +1757,121 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get welcomeCtaLabel => 'শোনা শুরু করুন';
@override
String get eqCustomActionEnableLabel => 'ইকুয়ালাইজার চালু করুন';
@override
String get eqCustomActionDisableLabel => 'ইকুয়ালাইজার বন্ধ করুন';
@override
String eqCustomActionPresetLabel(String preset) {
return 'প্রিসেট: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'ছুটির কারণে বিরত';
@override
String get alarmCardSchedulingFailedMessage =>
'এই অ্যালার্মটি সিস্টেমে নিবন্ধন করা যায়নি, তাই এটি নাও বাজতে পারে।';
@override
String get alarmCardPreNoticeFailedMessage =>
'এই অ্যালার্মটি নির্ধারিত হয়েছে, তবে এর আগাম রিমাইন্ডার সেট করা যায়নি।';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'নির্ভুল অ্যালার্ম শিডিউলিং';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'ফোন ঘুমন্ত অবস্থায় থাকলেও অ্যালার্মকে ঠিক নির্ধারিত মিনিটে বাজতে দেয়।';
@override
String get alarmDiagnosticsNotificationsTitle => 'বিজ্ঞপ্তি';
@override
String get alarmDiagnosticsNotificationsHint =>
'অ্যালার্ম এবং আগাম সতর্কবার্তা দেখানোর জন্য প্রয়োজনীয়।';
@override
String get alarmDiagnosticsFullScreenTitle =>
'অ্যালার্মের ফুল-স্ক্রিন প্রদর্শন';
@override
String get alarmDiagnosticsFullScreenHint =>
'ফোন লক থাকলেও রিং হওয়ার স্ক্রিনটি স্বয়ংক্রিয়ভাবে দেখা দিতে দেয়।';
@override
String get alarmDiagnosticsBatteryTitle => 'ব্যাটারি অপ্টিমাইজেশন';
@override
String get alarmDiagnosticsBatteryHint =>
'সিস্টেমকে ব্যাকগ্রাউন্ডে PluriWave বন্ধ করা থেকে আটকায়, যাতে অ্যালার্মটি তবুও বাজতে পারে।';
@override
String get alarmDiagnosticsNativeCountTitle => 'Android-এ নিবন্ধিত অ্যালার্ম';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'বর্তমানে নিবন্ধিত: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'আপনার একটি অ্যালার্ম চালু আছে, কিন্তু এখনও কোনোটিই সিস্টেমে নিবন্ধিত হয়নি। PluriWave আবার খুলুন, অথবা প্রথমে উপরের বিষয়গুলো ঠিক করুন।';
@override
String get alarmDiagnosticsManufacturerLabel => 'নির্মাতা';
@override
String get alarmDiagnosticsSdkLabel => 'Android সংস্করণ (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'মনোযোগ প্রয়োজন';
@override
String get alarmDiagnosticsAutostartTitle => 'এই ফোনে আরেকটি ম্যানুয়াল ধাপ';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return '$manufacturer ফোন ব্যাটারি বাঁচাতে প্রায়ই ব্যাকগ্রাউন্ডে চলা অ্যাপ বন্ধ করে দেয়। এমন কোনো সেটিং নেই যা PluriWave নিজে থেকে চালু করতে পারে — আপনাকে নিজে থেকেই PluriWave-এর জন্য অটোস্টার্ট (কখনও কখনও \"Auto-start\" বা \"ব্যাকগ্রাউন্ড অ্যাক্টিভিটি\" নামেও পরিচিত) চালু করতে হবে। সেটিংসে, অ্যাপস বা ব্যাটারির মধ্যে, অথবা ফোনের নিজস্ব সিকিউরিটি অ্যাপে এটি খুঁজুন।';
}
@override
String get alarmDiagnosticsFixAction => 'সমাধান করুন';
@override
String get alarmDiagnosticsIntentUnavailable =>
'এই ফোনে সেই সেটিংস স্ক্রিনটি খোলা যায়নি। সেটিংসে ম্যানুয়ালি খুঁজে দেখার চেষ্টা করুন।';
@override
String get alarmDiagnosticsUnavailableHint =>
'আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।';
@override
String get autoEqDisableOption => 'বন্ধ করুন';
@override
String get funcionPremium => 'প্রিমিয়াম বৈশিষ্ট্য';
@override
String get limiteAlarmasAlcanzado =>
'আপনি বিনামূল্যে ৫টি অ্যালার্মের সীমায় পৌঁছেছেন।';
@override
String get desbloquearPremium => 'প্রিমিয়াম আনলক করুন';
@override
String get restaurarCompras => 'কেনাকাটা পুনরুদ্ধার করুন';
@override
String get compraError => 'কেনাকাটা সম্পূর্ণ করা যায়নি। আবার চেষ্টা করুন।';
@override
String get restauracionSinCompras =>
'এই অ্যাকাউন্টে আমরা আগের কোনো কেনাকাটা খুঁজে পাইনি।';
@override
String get premiumActivo => 'প্রিমিয়াম সক্রিয়';
}
+202 -2
View File
@@ -1142,6 +1142,16 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get newVacationRangeTitle => 'Neuer Ferienzeitraum';
@override
String get editVacationRangeTitle => 'Ferienzeitraum bearbeiten';
@override
String get vacationDeleteConfirmTitle => 'Ferienzeitraum löschen?';
@override
String get vacationDeleteConfirmMessage =>
'Dies kann nicht rückgängig gemacht werden.';
@override
String get startField => 'Beginn';
@@ -1415,8 +1425,79 @@ class AppLocalizationsDe extends AppLocalizations {
String get helpTitle => 'Hilfe und Tutorial';
@override
String get helpSubtitle =>
'Funktionen, Tipps und Neuigkeiten von PluriWave ansehen.';
String get helpSubtitle => '9 Bildschirme · jederzeit erneut ansehen';
@override
String get tutorialSkipAction => 'Überspringen';
@override
String get tutorialNextAction => 'Weiter';
@override
String get tutorialPage1Headline =>
'Speichere deine Sender und gruppiere sie';
@override
String get tutorialPage1Body =>
'Tippe auf das Herz, um einen Sender zu speichern. Unter „Deine Sender“ kannst du Gruppen wie „Jeden Morgen“ oder „Auto“ anlegen und sie per Ziehen neu anordnen.';
@override
String get tutorialPage2Headline =>
'Ein Basis-Equalizer und einer pro Sender';
@override
String get tutorialPage2Body =>
'In den Einstellungen legst du den allgemeinen Equalizer fest. Und während der Wiedergabe eines bestimmten Senders kannst du ihm eine eigene Einstellung geben, die Vorrang vor der allgemeinen hat.';
@override
String get tutorialPage3Headline => 'Nimm auf, was du gerade hörst';
@override
String get tutorialPage3Body =>
'Über die Werkzeugleiste des Players speichert „Aufnehmen“ den Original-Stream. Deine Aufnahmen findest du unter Einstellungen › Aufnahmen.';
@override
String get tutorialPage4Headline => 'Alarme, die sich dir anpassen';
@override
String get tutorialPage4Body =>
'Wach mit deinem Lieblingssender auf, schlummere 3, 5 oder 10 Minuten weiter und füge Urlaubszeiträume hinzu, damit manche Alarme von selbst ausgesetzt werden.';
@override
String get tutorialPage5Headline => 'Deine Favoriten, auch im Auto';
@override
String get tutorialPage5Body =>
'Verbinde dein Handy mit Android Auto und finde Favoriten, Alle Sender, Deine Sender und deine lokale Musik, mit großen, fürs Fahren gemachten Tasten.';
@override
String get tutorialPage6Headline => 'Verbindet sich von selbst neu';
@override
String get tutorialPage6Body =>
'Bricht das Signal ab, versucht PluriWave automatisch erneut und zeigt weiterhin deine gespeicherten Favoriten, auch ohne Verbindung.';
@override
String get tutorialPage7Headline => 'Wähle, wie lange du schlummerst';
@override
String get tutorialPage7Body =>
'Wenn ein Alarm klingelt, gibt es nicht nur ein „Schlummern“: Du wählst 3, 5 oder 10 Minuten, je nachdem, was du gerade brauchst.';
@override
String get tutorialPage8Headline =>
'Findest du ihn nicht? Füge ihn selbst hinzu';
@override
String get tutorialPage8Body =>
'Füge unter „Deine Sender“ → Eigenen Sender hinzufügen die Stream-URL eines Senders ein, der nicht in der Suche auftaucht. Er wird unter „Deine Sender“ gespeichert und ist auch im Auto verfügbar.';
@override
String get tutorialPage9Headline => 'Fertig, du kennst jetzt das Wichtigste';
@override
String get tutorialPage9BannerBody =>
'Um dieses Tutorial jederzeit erneut anzusehen: Einstellungen → Info → Hilfe und Tutorial.';
@override
String get indefiniteOption => 'Unbegrenzt';
@@ -1688,4 +1769,123 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Jetzt hören';
@override
String get eqCustomActionEnableLabel => 'Equalizer aktivieren';
@override
String get eqCustomActionDisableLabel => 'Equalizer deaktivieren';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Voreinstellung: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Wegen Urlaub pausiert';
@override
String get alarmCardSchedulingFailedMessage =>
'Dieser Alarm konnte nicht im System registriert werden, daher klingelt er möglicherweise nicht.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Dieser Alarm ist geplant, aber seine Vorwarnung konnte nicht eingerichtet werden.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'Genaue Alarmplanung';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Lässt den Alarm genau zur eingestellten Minute klingeln, auch wenn das Telefon im Ruhezustand ist.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Benachrichtigungen';
@override
String get alarmDiagnosticsNotificationsHint =>
'Nötig, um den Alarm und den Vorab-Hinweis anzuzeigen.';
@override
String get alarmDiagnosticsFullScreenTitle => 'Alarmanzeige im Vollbildmodus';
@override
String get alarmDiagnosticsFullScreenHint =>
'Lässt den Klingelbildschirm automatisch erscheinen, auch wenn das Telefon gesperrt ist.';
@override
String get alarmDiagnosticsBatteryTitle => 'Akku-Optimierung';
@override
String get alarmDiagnosticsBatteryHint =>
'Verhindert, dass das System PluriWave im Hintergrund beendet, damit der Alarm trotzdem klingeln kann.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Bei Android registrierte Alarme';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Aktuell registriert: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'Du hast einen Alarm aktiviert, aber es ist noch keiner beim System registriert. Öffne PluriWave erneut oder behebe zuerst die Punkte oben.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Hersteller';
@override
String get alarmDiagnosticsSdkLabel => 'Android-Version (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'Erfordert Aufmerksamkeit';
@override
String get alarmDiagnosticsAutostartTitle =>
'Noch ein manueller Schritt auf diesem Telefon';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return '$manufacturer-Telefone schließen oft Apps im Hintergrund, um Akku zu sparen. Es gibt keine Einstellung, die PluriWave selbst aktivieren kann — du musst den Autostart (manchmal auch \"Auto-Start\" oder \"Hintergrundaktivität\" genannt) für PluriWave selbst einschalten. Schau in den Einstellungen unter Apps oder Akku, oder in der Sicherheits-App des Telefons nach.';
}
@override
String get alarmDiagnosticsFixAction => 'Beheben';
@override
String get alarmDiagnosticsIntentUnavailable =>
'Diese Einstellungsseite konnte auf diesem Telefon nicht geöffnet werden. Suche sie manuell in den Einstellungen.';
@override
String get alarmDiagnosticsUnavailableHint =>
'Wir konnten deine Alarmeinstellungen noch nicht prüfen.';
@override
String get autoEqDisableOption => 'Deaktivieren';
@override
String get funcionPremium => 'Premium-Funktion';
@override
String get limiteAlarmasAlcanzado =>
'Du hast das kostenlose Limit von 5 Weckern erreicht.';
@override
String get desbloquearPremium => 'Premium freischalten';
@override
String get restaurarCompras => 'Käufe wiederherstellen';
@override
String get compraError =>
'Der Kauf konnte nicht abgeschlossen werden. Bitte versuche es erneut.';
@override
String get restauracionSinCompras =>
'Wir haben auf diesem Konto keinen früheren Kauf gefunden.';
@override
String get premiumActivo => 'Premium aktiv';
}
+199 -1
View File
@@ -1133,6 +1133,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get newVacationRangeTitle => 'New vacation range';
@override
String get editVacationRangeTitle => 'Edit vacation range';
@override
String get vacationDeleteConfirmTitle => 'Delete vacation range?';
@override
String get vacationDeleteConfirmMessage => 'This can\'t be undone.';
@override
String get startField => 'Start';
@@ -1399,7 +1408,77 @@ class AppLocalizationsEn extends AppLocalizations {
String get helpTitle => 'Help and tutorial';
@override
String get helpSubtitle => 'Review PluriWave features, tips and what’s new.';
String get helpSubtitle => '9 screens · watch it again anytime';
@override
String get tutorialSkipAction => 'Skip';
@override
String get tutorialNextAction => 'Next';
@override
String get tutorialPage1Headline => 'Save your stations and group them';
@override
String get tutorialPage1Body =>
'Tap the heart to save a station. In \"Your stations\" you can create groups like \"Every morning\" or \"Car\" and drag to reorder them.';
@override
String get tutorialPage2Headline =>
'One base equalizer, plus one per station';
@override
String get tutorialPage2Body =>
'In Settings you set the general equalizer. And from a specific station\'s playback screen you can give it its own setting, which overrides the general one.';
@override
String get tutorialPage3Headline => 'Record what you\'re listening to';
@override
String get tutorialPage3Body =>
'From the player\'s tool tray, \"Record\" saves the original stream. Find your recordings under Settings › Recordings.';
@override
String get tutorialPage4Headline => 'Alarms that adapt to you';
@override
String get tutorialPage4Body =>
'Wake up to your favorite station, snooze for 3, 5, or 10 minutes, and add vacation ranges so some alarms skip themselves.';
@override
String get tutorialPage5Headline => 'Your favorites, in the car too';
@override
String get tutorialPage5Body =>
'Connect your phone with Android Auto to find Favorites, All stations, Your stations, and your Local Music, with big buttons made for driving.';
@override
String get tutorialPage6Headline => 'It reconnects on its own';
@override
String get tutorialPage6Body =>
'If the signal drops, PluriWave retries automatically and keeps showing your saved favorites even without a connection.';
@override
String get tutorialPage7Headline => 'Choose how long to snooze';
@override
String get tutorialPage7Body =>
'When an alarm rings, there\'s no single \"snooze\": you choose 3, 5, or 10 minutes depending on what you need at that moment.';
@override
String get tutorialPage8Headline => 'Can\'t find it? Add it yourself';
@override
String get tutorialPage8Body =>
'In \"Your stations\" → Add custom station, paste the stream URL of a station that isn\'t in the search results. It\'s saved under \"Your stations\", also available in the car.';
@override
String get tutorialPage9Headline => 'Done — now you know the essentials';
@override
String get tutorialPage9BannerBody =>
'To watch this tutorial again anytime: Settings → Information → Help and tutorial.';
@override
String get indefiniteOption => 'Indefinite';
@@ -1669,4 +1748,123 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Start listening';
@override
String get eqCustomActionEnableLabel => 'Enable equalizer';
@override
String get eqCustomActionDisableLabel => 'Disable equalizer';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Preset: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Paused for vacation';
@override
String get alarmCardSchedulingFailedMessage =>
'This alarm could not be registered with the system, so it may not ring.';
@override
String get alarmCardPreNoticeFailedMessage =>
'This alarm is scheduled, but its early reminder could not be set.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'Exact alarm scheduling';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Lets the alarm ring at the exact minute you set, even while the phone is asleep.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Notifications';
@override
String get alarmDiagnosticsNotificationsHint =>
'Needed to show the alarm and the advance-warning notice.';
@override
String get alarmDiagnosticsFullScreenTitle => 'Full-screen alarm display';
@override
String get alarmDiagnosticsFullScreenHint =>
'Lets the ringing screen appear automatically, even with the phone locked.';
@override
String get alarmDiagnosticsBatteryTitle => 'Battery optimization';
@override
String get alarmDiagnosticsBatteryHint =>
'Stops the system from closing PluriWave in the background so the alarm can still fire.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Alarms registered with Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Currently registered: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'You have an alarm turned on, but none are registered with the system yet. Reopen PluriWave, or fix the items above first.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Manufacturer';
@override
String get alarmDiagnosticsSdkLabel => 'Android version (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'Needs attention';
@override
String get alarmDiagnosticsAutostartTitle =>
'One more manual step on this phone';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return '$manufacturer phones often close apps running in the background to save battery. There is no setting PluriWave can switch on its own — you need to turn on Autostart (sometimes called \"Auto-start\" or \"Background activity\") for PluriWave yourself. Look in Settings, under Apps or Battery, or in the phone\'s own Security app.';
}
@override
String get alarmDiagnosticsFixAction => 'Fix this';
@override
String get alarmDiagnosticsIntentUnavailable =>
'Couldn\'t open that settings screen on this phone. Try looking for it manually in Settings.';
@override
String get alarmDiagnosticsUnavailableHint =>
'We couldn\'t check your alarm settings yet.';
@override
String get autoEqDisableOption => 'Disable';
@override
String get funcionPremium => 'Premium Feature';
@override
String get limiteAlarmasAlcanzado =>
'You\'ve reached the free 5-alarm limit.';
@override
String get desbloquearPremium => 'Unlock Premium';
@override
String get restaurarCompras => 'Restore purchases';
@override
String get compraError =>
'We couldn\'t complete the purchase. Please try again.';
@override
String get restauracionSinCompras =>
'We didn\'t find any previous purchase on this account.';
@override
String get premiumActivo => 'Premium active';
}
+201 -2
View File
@@ -1139,6 +1139,16 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get newVacationRangeTitle => 'Nuevo rango de vacaciones';
@override
String get editVacationRangeTitle => 'Editar rango de vacaciones';
@override
String get vacationDeleteConfirmTitle => '¿Eliminar rango de vacaciones?';
@override
String get vacationDeleteConfirmMessage =>
'Esta acción no se puede deshacer.';
@override
String get startField => 'Inicio';
@@ -1409,8 +1419,76 @@ class AppLocalizationsEs extends AppLocalizations {
String get helpTitle => 'Ayuda y tutorial';
@override
String get helpSubtitle =>
'Repasá funciones, consejos y novedades de PluriWave.';
String get helpSubtitle => '9 pantallas · vuelve a verlo cuando quieras';
@override
String get tutorialSkipAction => 'Saltar';
@override
String get tutorialNextAction => 'Siguiente';
@override
String get tutorialPage1Headline => 'Guarda tus emisoras y agrúpalas';
@override
String get tutorialPage1Body =>
'Toca el corazón para guardar una emisora. En «Tus emisoras» puedes crear grupos como «Cada mañana» o «Coche» y reordenarlas arrastrando.';
@override
String get tutorialPage2Headline => 'Un ecualizador base y otro por emisora';
@override
String get tutorialPage2Body =>
'En Ajustes defines el ecualizador general. Y desde la reproducción de una emisora concreta puedes darle su propio ajuste, que manda sobre el general.';
@override
String get tutorialPage3Headline => 'Graba lo que estás escuchando';
@override
String get tutorialPage3Body =>
'Desde la bandeja de herramientas del reproductor, «Grabar» guarda el stream original. Encuentra tus grabaciones en Ajustes › Grabaciones.';
@override
String get tutorialPage4Headline => 'Alarmas que se adaptan a ti';
@override
String get tutorialPage4Body =>
'Despiértate con tu emisora favorita, pospón 3, 5 o 10 minutos, y añade rangos de vacaciones para que algunas alarmas se salten solas.';
@override
String get tutorialPage5Headline => 'Tus favoritas, también en el coche';
@override
String get tutorialPage5Body =>
'Conecta el móvil con Android Auto y encontrarás Favoritos, Todas las emisoras, Mis emisoras y tu Música Local, con botones grandes pensados para conducir.';
@override
String get tutorialPage6Headline => 'Se reconecta sola';
@override
String get tutorialPage6Body =>
'Si se corta la señal, PluriWave reintenta automáticamente y sigue mostrando tus favoritas guardadas aunque no tengas conexión.';
@override
String get tutorialPage7Headline => 'Elige cuánto posponer';
@override
String get tutorialPage7Body =>
'Cuando suene una alarma, no hay un único «posponer»: eliges 3, 5 o 10 minutos según lo que necesites en ese momento.';
@override
String get tutorialPage8Headline => '¿No la encuentras? Añádela tú';
@override
String get tutorialPage8Body =>
'En «Tus emisoras» → Añadir emisora personalizada pega la URL del stream de una emisora que no esté en el buscador. Se guarda en «Mis emisoras», también disponible en el coche.';
@override
String get tutorialPage9Headline => 'Listo, ya conoces lo esencial';
@override
String get tutorialPage9BannerBody =>
'Para volver a ver este tutorial cuando quieras: Ajustes → Información → Ayuda y tutorial.';
@override
String get indefiniteOption => 'Indefinida';
@@ -1682,4 +1760,125 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
@override
String get eqCustomActionEnableLabel => 'Activar ecualizador';
@override
String get eqCustomActionDisableLabel => 'Desactivar ecualizador';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Preset: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Pausada por vacaciones';
@override
String get alarmCardSchedulingFailedMessage =>
'Esta alarma no se pudo registrar en el sistema, así que podría no sonar.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Esta alarma está programada, pero no se pudo activar su aviso previo.';
@override
String get alarmDiagnosticsExactAlarmsTitle =>
'Programación de alarma exacta';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Permite que la alarma suene en el minuto exacto que elegiste, incluso con el teléfono en reposo.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Notificaciones';
@override
String get alarmDiagnosticsNotificationsHint =>
'Necesarias para mostrar la alarma y el aviso previo.';
@override
String get alarmDiagnosticsFullScreenTitle =>
'Pantalla completa de la alarma';
@override
String get alarmDiagnosticsFullScreenHint =>
'Permite que la pantalla de la alarma aparezca automáticamente, incluso con el teléfono bloqueado.';
@override
String get alarmDiagnosticsBatteryTitle => 'Optimización de batería';
@override
String get alarmDiagnosticsBatteryHint =>
'Evita que el sistema cierre PluriWave en segundo plano para que la alarma pueda sonar.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Alarmas registradas en Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Registradas ahora mismo: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'Tenés una alarma activada, pero todavía ninguna está registrada en el sistema. Volvé a abrir PluriWave o solucioná primero los puntos de arriba.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Fabricante';
@override
String get alarmDiagnosticsSdkLabel => 'Versión de Android (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'Necesita atención';
@override
String get alarmDiagnosticsAutostartTitle =>
'Un paso manual más en este teléfono';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return 'Los teléfonos $manufacturer suelen cerrar las apps que están en segundo plano para ahorrar batería. No hay ningún ajuste que PluriWave pueda activar por su cuenta: tenés que activar vos mismo el Inicio automático (a veces llamado \"Autostart\" o \"Actividad en segundo plano\"). Buscalo en Ajustes, dentro de Apps o Batería, o en la app de Seguridad del teléfono.';
}
@override
String get alarmDiagnosticsFixAction => 'Solucionar';
@override
String get alarmDiagnosticsIntentUnavailable =>
'No se pudo abrir esa pantalla de ajustes en este teléfono. Buscala manualmente en Ajustes.';
@override
String get alarmDiagnosticsUnavailableHint =>
'Todavía no pudimos revisar tus ajustes de alarma.';
@override
String get autoEqDisableOption => 'Desactivar';
@override
String get funcionPremium => 'Función Premium';
@override
String get limiteAlarmasAlcanzado =>
'Has alcanzado el límite de 5 alarmas gratuitas.';
@override
String get desbloquearPremium => 'Desbloquear Premium';
@override
String get restaurarCompras => 'Restaurar compras';
@override
String get compraError =>
'No se ha podido completar la compra. Inténtalo de nuevo.';
@override
String get restauracionSinCompras =>
'No hemos encontrado ninguna compra anterior en esta cuenta.';
@override
String get premiumActivo => 'Premium activo';
}
+204 -2
View File
@@ -1145,6 +1145,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get newVacationRangeTitle => 'Nouvelle période de vacances';
@override
String get editVacationRangeTitle => 'Modifier la période de vacances';
@override
String get vacationDeleteConfirmTitle => 'Supprimer la période de vacances ?';
@override
String get vacationDeleteConfirmMessage => 'Cette action est irréversible.';
@override
String get startField => 'Début';
@@ -1419,8 +1428,79 @@ class AppLocalizationsFr extends AppLocalizations {
String get helpTitle => 'Aide et tutoriel';
@override
String get helpSubtitle =>
'Revoyez les fonctions, conseils et nouveautés de PluriWave.';
String get helpSubtitle => '9 écrans · à revoir quand vous voulez';
@override
String get tutorialSkipAction => 'Passer';
@override
String get tutorialNextAction => 'Suivant';
@override
String get tutorialPage1Headline =>
'Enregistrez vos stations et regroupez-les';
@override
String get tutorialPage1Body =>
'Touchez le cœur pour enregistrer une station. Dans « Vos stations », vous pouvez créer des groupes comme « Chaque matin » ou « Voiture » et les réorganiser en les faisant glisser.';
@override
String get tutorialPage2Headline =>
'Un égaliseur général et un autre par station';
@override
String get tutorialPage2Body =>
'Dans Réglages, vous définissez l\'égaliseur général. Et depuis la lecture d\'une station précise, vous pouvez lui donner son propre réglage, qui prévaut sur le général.';
@override
String get tutorialPage3Headline => 'Enregistrez ce que vous écoutez';
@override
String get tutorialPage3Body =>
'Depuis la barre d\'outils du lecteur, « Enregistrer » sauvegarde le flux original. Retrouvez vos enregistrements dans Réglages › Enregistrements.';
@override
String get tutorialPage4Headline => 'Des alarmes qui s\'adaptent à vous';
@override
String get tutorialPage4Body =>
'Réveillez-vous avec votre station préférée, reportez de 3, 5 ou 10 minutes, et ajoutez des périodes de vacances pour que certaines alarmes se sautent d\'elles-mêmes.';
@override
String get tutorialPage5Headline => 'Vos favoris, aussi en voiture';
@override
String get tutorialPage5Body =>
'Connectez votre téléphone avec Android Auto et retrouvez Favoris, Toutes les stations, Vos stations et votre Musique locale, avec de grands boutons pensés pour la conduite.';
@override
String get tutorialPage6Headline => 'Se reconnecte toute seule';
@override
String get tutorialPage6Body =>
'Si le signal se coupe, PluriWave retente automatiquement et continue d\'afficher vos favoris enregistrés même sans connexion.';
@override
String get tutorialPage7Headline => 'Choisissez combien de temps reporter';
@override
String get tutorialPage7Body =>
'Quand une alarme sonne, il n\'y a pas un seul « reporter » : vous choisissez 3, 5 ou 10 minutes selon ce dont vous avez besoin à ce moment-là.';
@override
String get tutorialPage8Headline =>
'Vous ne la trouvez pas ? Ajoutez-la vous-même';
@override
String get tutorialPage8Body =>
'Dans « Vos stations » → Ajouter une station personnalisée, collez l\'URL du flux d\'une station absente des résultats de recherche. Elle est enregistrée dans « Vos stations », aussi disponible en voiture.';
@override
String get tutorialPage9Headline => 'Voilà, vous connaissez l\'essentiel';
@override
String get tutorialPage9BannerBody =>
'Pour revoir ce tutoriel quand vous le souhaitez : Réglages → Informations → Aide et tutoriel.';
@override
String get indefiniteOption => 'Indéfinie';
@@ -1692,4 +1772,126 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Commencer à écouter';
@override
String get eqCustomActionEnableLabel => 'Activer l\'égaliseur';
@override
String get eqCustomActionDisableLabel => 'Désactiver l\'égaliseur';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Préréglage : $preset';
}
@override
String get alarmCardVacationPausedBadge => 'En pause pour les vacances';
@override
String get alarmCardSchedulingFailedMessage =>
'Cette alarme n\'a pas pu être enregistrée dans le système ; elle risque donc de ne pas sonner.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Cette alarme est programmée, mais son rappel anticipé n\'a pas pu être activé.';
@override
String get alarmDiagnosticsExactAlarmsTitle =>
'Programmation d\'alarme précise';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Permet à l\'alarme de sonner à la minute exacte choisie, même si le téléphone est en veille.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Notifications';
@override
String get alarmDiagnosticsNotificationsHint =>
'Nécessaires pour afficher l\'alarme et l\'avis anticipé.';
@override
String get alarmDiagnosticsFullScreenTitle =>
'Affichage plein écran de l\'alarme';
@override
String get alarmDiagnosticsFullScreenHint =>
'Permet à l\'écran de sonnerie de s\'afficher automatiquement, même si le téléphone est verrouillé.';
@override
String get alarmDiagnosticsBatteryTitle => 'Optimisation de la batterie';
@override
String get alarmDiagnosticsBatteryHint =>
'Empêche le système de fermer PluriWave en arrière-plan pour que l\'alarme puisse quand même sonner.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Alarmes enregistrées auprès d\'Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Actuellement enregistrées : $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'Une alarme est activée, mais aucune n\'est encore enregistrée auprès du système. Rouvrez PluriWave, ou corrigez d\'abord les points ci-dessus.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Fabricant';
@override
String get alarmDiagnosticsSdkLabel => 'Version d\'Android (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus =>
'Nécessite votre attention';
@override
String get alarmDiagnosticsAutostartTitle =>
'Encore une étape manuelle sur ce téléphone';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return 'Les téléphones $manufacturer ferment souvent les applications en arrière-plan pour économiser la batterie. Aucun réglage ne permet à PluriWave de s\'activer lui-même : vous devez activer vous-même le démarrage automatique (parfois appelé \"Autostart\" ou \"Activité en arrière-plan\") pour PluriWave. Cherchez dans les Paramètres, sous Applications ou Batterie, ou dans l\'application Sécurité du téléphone.';
}
@override
String get alarmDiagnosticsFixAction => 'Corriger';
@override
String get alarmDiagnosticsIntentUnavailable =>
'Impossible d\'ouvrir cet écran de paramètres sur ce téléphone. Essayez de le trouver manuellement dans les Paramètres.';
@override
String get alarmDiagnosticsUnavailableHint =>
'Nous n\'avons pas encore pu vérifier vos paramètres d\'alarme.';
@override
String get autoEqDisableOption => 'Désactiver';
@override
String get funcionPremium => 'Fonctionnalité Premium';
@override
String get limiteAlarmasAlcanzado =>
'Vous avez atteint la limite gratuite de 5 alarmes.';
@override
String get desbloquearPremium => 'Débloquer Premium';
@override
String get restaurarCompras => 'Restaurer les achats';
@override
String get compraError =>
'Impossible de finaliser l\'achat. Veuillez réessayer.';
@override
String get restauracionSinCompras =>
'Nous n\'avons trouvé aucun achat antérieur sur ce compte.';
@override
String get premiumActivo => 'Premium actif';
}
+198 -2
View File
@@ -1134,6 +1134,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get newVacationRangeTitle => 'नई छुट्टी अवधि';
@override
String get editVacationRangeTitle => 'छुट्टी अवधि संपादित करें';
@override
String get vacationDeleteConfirmTitle => 'छुट्टी अवधि हटाएं?';
@override
String get vacationDeleteConfirmMessage => 'इसे वापस नहीं लिया जा सकता।';
@override
String get startField => 'शुरुआत';
@@ -1402,8 +1411,78 @@ class AppLocalizationsHi extends AppLocalizations {
String get helpTitle => 'मदद और ट्यूटोरियल';
@override
String get helpSubtitle =>
'PluriWave की सुविधाएँ, सुझाव और नया क्या है देखें।';
String get helpSubtitle => '9 स्क्रीन · जब चाहें दोबारा देखें';
@override
String get tutorialSkipAction => 'छोड़ें';
@override
String get tutorialNextAction => 'अगला';
@override
String get tutorialPage1Headline =>
'अपने स्टेशन सहेजें और उन्हें समूहों में बाँटें';
@override
String get tutorialPage1Body =>
'किसी स्टेशन को सहेजने के लिए दिल के आइकॉन पर टैप करें। «आपके स्टेशन» में आप «हर सुबह» या «कार» जैसे समूह बना सकते हैं और उन्हें खींचकर पुनः क्रमबद्ध कर सकते हैं।';
@override
String get tutorialPage2Headline =>
'एक सामान्य इक्वलाइज़र, और हर स्टेशन के लिए एक अलग';
@override
String get tutorialPage2Body =>
'सेटिंग्स में आप सामान्य इक्वलाइज़र सेट करते हैं। और किसी खास स्टेशन को चलाते समय आप उसे अपनी खुद की सेटिंग दे सकते हैं, जो सामान्य सेटिंग पर प्राथमिकता रखती है।';
@override
String get tutorialPage3Headline => 'जो सुन रहे हैं उसे रिकॉर्ड करें';
@override
String get tutorialPage3Body =>
'प्लेयर की टूल ट्रे से, «रिकॉर्ड करें» मूल स्ट्रीम को सहेजता है। अपनी रिकॉर्डिंग सेटिंग्स › रिकॉर्डिंग में पाएँ।';
@override
String get tutorialPage4Headline => 'अलार्म जो आपके अनुसार ढलते हैं';
@override
String get tutorialPage4Body =>
'अपने पसंदीदा स्टेशन के साथ जागें, 3, 5 या 10 मिनट के लिए स्नूज़ करें, और छुट्टियों की अवधि जोड़ें ताकि कुछ अलार्म खुद-ब-खुद छूट जाएँ।';
@override
String get tutorialPage5Headline => 'आपके पसंदीदा, कार में भी';
@override
String get tutorialPage5Body =>
'अपने फ़ोन को Android Auto से जोड़ें और पाएँ पसंदीदा, सभी स्टेशन, आपके स्टेशन और आपका लोकल म्यूज़िक, ड्राइविंग के लिए बने बड़े बटनों के साथ।';
@override
String get tutorialPage6Headline => 'खुद ही फिर से जुड़ जाता है';
@override
String get tutorialPage6Body =>
'अगर सिग्नल कट जाए, तो PluriWave अपने आप फिर कोशिश करता है और बिना कनेक्शन के भी आपके सहेजे गए पसंदीदा दिखाता रहता है।';
@override
String get tutorialPage7Headline => 'चुनें कितनी देर के लिए स्नूज़ करना है';
@override
String get tutorialPage7Body =>
'जब कोई अलार्म बजता है, तो «स्नूज़» का कोई एक ही विकल्प नहीं होता: आप उस पल की ज़रूरत के अनुसार 3, 5 या 10 मिनट चुनते हैं।';
@override
String get tutorialPage8Headline => 'नहीं मिल रहा? खुद जोड़ें';
@override
String get tutorialPage8Body =>
'«आपके स्टेशन» → कस्टम स्टेशन जोड़ें में, खोज में न मिलने वाले स्टेशन का स्ट्रीम URL पेस्ट करें। यह «आपके स्टेशन» में सहेजा जाता है और कार में भी उपलब्ध रहता है।';
@override
String get tutorialPage9Headline => 'हो गया, अब आप ज़रूरी बातें जानते हैं';
@override
String get tutorialPage9BannerBody =>
'इस ट्यूटोरियल को दोबारा देखने के लिए: सेटिंग्स → जानकारी → मदद और ट्यूटोरियल।';
@override
String get indefiniteOption => 'अनिश्चित';
@@ -1671,4 +1750,121 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get welcomeCtaLabel => 'सुनना शुरू करें';
@override
String get eqCustomActionEnableLabel => 'इक्वलाइज़र चालू करें';
@override
String get eqCustomActionDisableLabel => 'इक्वलाइज़र बंद करें';
@override
String eqCustomActionPresetLabel(String preset) {
return 'प्रीसेट: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'छुट्टी के कारण रोका गया';
@override
String get alarmCardSchedulingFailedMessage =>
'यह अलार्म सिस्टम में दर्ज नहीं हो सका, इसलिए यह शायद न बजे।';
@override
String get alarmCardPreNoticeFailedMessage =>
'यह अलार्म शेड्यूल किया गया है, लेकिन इसकी पूर्व-चेतावनी सेट नहीं हो सकी।';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'सटीक अलार्म शेड्यूलिंग';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'फ़ोन के सुप्त मोड में होने पर भी अलार्म को ठीक तय किए गए मिनट पर बजने देता है।';
@override
String get alarmDiagnosticsNotificationsTitle => 'सूचनाएं';
@override
String get alarmDiagnosticsNotificationsHint =>
'अलार्म और पूर्व-चेतावनी सूचना दिखाने के लिए ज़रूरी।';
@override
String get alarmDiagnosticsFullScreenTitle =>
'अलार्म की फ़ुल-स्क्रीन डिस्प्ले';
@override
String get alarmDiagnosticsFullScreenHint =>
'फ़ोन लॉक होने पर भी अलार्म स्क्रीन को अपने आप दिखने देता है।';
@override
String get alarmDiagnosticsBatteryTitle => 'बैटरी ऑप्टिमाइज़ेशन';
@override
String get alarmDiagnosticsBatteryHint =>
'सिस्टम को बैकग्राउंड में PluriWave बंद करने से रोकता है, ताकि अलार्म फिर भी बज सके।';
@override
String get alarmDiagnosticsNativeCountTitle => 'Android में दर्ज अलार्म';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'अभी दर्ज: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'आपने एक अलार्म चालू किया है, लेकिन अभी तक कोई भी सिस्टम में दर्ज नहीं हुआ है। PluriWave को दोबारा खोलें, या पहले ऊपर दी गई बातों को ठीक करें।';
@override
String get alarmDiagnosticsManufacturerLabel => 'निर्माता';
@override
String get alarmDiagnosticsSdkLabel => 'Android वर्शन (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'ध्यान देने की ज़रूरत है';
@override
String get alarmDiagnosticsAutostartTitle => 'इस फ़ोन पर एक और मैन्युअल चरण';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return '$manufacturer फ़ोन बैटरी बचाने के लिए अक्सर बैकग्राउंड में चल रहे ऐप बंद कर देते हैं। ऐसी कोई सेटिंग नहीं है जिसे PluriWave खुद चालू कर सके — आपको PluriWave के लिए खुद ऑटोस्टार्ट (कभी-कभी \"Auto-start\" या \"बैकग्राउंड एक्टिविटी\" भी कहा जाता है) चालू करना होगा। इसे सेटिंग्स में, ऐप्स या बैटरी के अंदर, या फ़ोन के अपने सिक्योरिटी ऐप में देखें।';
}
@override
String get alarmDiagnosticsFixAction => 'ठीक करें';
@override
String get alarmDiagnosticsIntentUnavailable =>
'इस फ़ोन पर वह सेटिंग्स स्क्रीन नहीं खोली जा सकी। कृपया सेटिंग्स में इसे खुद ढूंढने की कोशिश करें।';
@override
String get alarmDiagnosticsUnavailableHint =>
'हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।';
@override
String get autoEqDisableOption => 'बंद करें';
@override
String get funcionPremium => 'प्रीमियम सुविधा';
@override
String get limiteAlarmasAlcanzado =>
'आप मुफ़्त 5 अलार्म की सीमा तक पहुँच गए हैं।';
@override
String get desbloquearPremium => 'प्रीमियम अनलॉक करें';
@override
String get restaurarCompras => 'खरीदारी पुनर्स्थापित करें';
@override
String get compraError => 'खरीद पूरी नहीं हो सकी। कृपया फिर से प्रयास करें।';
@override
String get restauracionSinCompras =>
'इस खाते में हमें कोई पिछली खरीद नहीं मिली।';
@override
String get premiumActivo => 'प्रीमियम सक्रिय';
}
+201 -1
View File
@@ -1139,6 +1139,16 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get newVacationRangeTitle => 'Rentang liburan baru';
@override
String get editVacationRangeTitle => 'Edit rentang liburan';
@override
String get vacationDeleteConfirmTitle => 'Hapus rentang liburan?';
@override
String get vacationDeleteConfirmMessage =>
'Tindakan ini tidak dapat dibatalkan.';
@override
String get startField => 'Mulai';
@@ -1410,7 +1420,78 @@ class AppLocalizationsId extends AppLocalizations {
String get helpTitle => 'Bantuan dan tutorial';
@override
String get helpSubtitle => 'Tinjau fitur, tips, dan hal baru di PluriWave.';
String get helpSubtitle => '9 layar · lihat lagi kapan saja';
@override
String get tutorialSkipAction => 'Lewati';
@override
String get tutorialNextAction => 'Berikutnya';
@override
String get tutorialPage1Headline => 'Simpan stasiun Anda dan kelompokkan';
@override
String get tutorialPage1Body =>
'Ketuk ikon hati untuk menyimpan stasiun. Di «Stasiun Anda» Anda bisa membuat grup seperti «Setiap pagi» atau «Mobil» dan mengurutkannya ulang dengan menyeret.';
@override
String get tutorialPage2Headline =>
'Satu equalizer umum, dan satu lagi per stasiun';
@override
String get tutorialPage2Body =>
'Di Pengaturan, Anda menentukan equalizer umum. Dan dari pemutaran stasiun tertentu, Anda bisa memberinya pengaturan sendiri, yang lebih diutamakan daripada pengaturan umum.';
@override
String get tutorialPage3Headline => 'Rekam apa yang sedang Anda dengarkan';
@override
String get tutorialPage3Body =>
'Dari baki alat pemutar, «Rekam» menyimpan stream asli. Temukan rekaman Anda di Pengaturan › Rekaman.';
@override
String get tutorialPage4Headline =>
'Alarm yang menyesuaikan diri dengan Anda';
@override
String get tutorialPage4Body =>
'Bangun dengan stasiun favorit Anda, tunda 3, 5, atau 10 menit, dan tambahkan rentang liburan agar sebagian alarm melewati dirinya sendiri.';
@override
String get tutorialPage5Headline => 'Favorit Anda, juga di mobil';
@override
String get tutorialPage5Body =>
'Sambungkan ponsel Anda dengan Android Auto dan temukan Favorit, Semua stasiun, Stasiun Anda, dan Musik Lokal Anda, dengan tombol besar yang dirancang untuk berkendara.';
@override
String get tutorialPage6Headline => 'Tersambung ulang dengan sendirinya';
@override
String get tutorialPage6Body =>
'Jika sinyal terputus, PluriWave mencoba lagi secara otomatis dan tetap menampilkan favorit tersimpan Anda meski tanpa koneksi.';
@override
String get tutorialPage7Headline => 'Pilih berapa lama menunda';
@override
String get tutorialPage7Body =>
'Saat alarm berbunyi, tidak ada satu «tunda» saja: Anda memilih 3, 5, atau 10 menit sesuai yang Anda butuhkan saat itu.';
@override
String get tutorialPage8Headline => 'Tidak menemukannya? Tambahkan sendiri';
@override
String get tutorialPage8Body =>
'Di «Stasiun Anda» → Tambah stasiun khusus, tempel URL stream stasiun yang tidak ada di hasil pencarian. Stasiun ini disimpan di «Stasiun Anda», juga tersedia di mobil.';
@override
String get tutorialPage9Headline => 'Selesai, Anda sudah tahu yang penting';
@override
String get tutorialPage9BannerBody =>
'Untuk melihat tutorial ini lagi kapan saja: Pengaturan → Informasi → Bantuan dan tutorial.';
@override
String get indefiniteOption => 'Tidak terbatas';
@@ -1678,4 +1759,123 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Mulai mendengarkan';
@override
String get eqCustomActionEnableLabel => 'Aktifkan equalizer';
@override
String get eqCustomActionDisableLabel => 'Nonaktifkan equalizer';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Prasetel: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Dijeda karena liburan';
@override
String get alarmCardSchedulingFailedMessage =>
'Alarm ini tidak dapat didaftarkan ke sistem, sehingga mungkin tidak berbunyi.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Alarm ini sudah dijadwalkan, tetapi pengingat awalnya tidak dapat diatur.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'Penjadwalan alarm presisi';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Membuat alarm berbunyi tepat pada menit yang kamu atur, meski ponsel dalam mode tidur.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Notifikasi';
@override
String get alarmDiagnosticsNotificationsHint =>
'Diperlukan untuk menampilkan alarm dan pemberitahuan dini.';
@override
String get alarmDiagnosticsFullScreenTitle => 'Tampilan alarm layar penuh';
@override
String get alarmDiagnosticsFullScreenHint =>
'Membuat layar alarm muncul otomatis, meski ponsel terkunci.';
@override
String get alarmDiagnosticsBatteryTitle => 'Optimisasi baterai';
@override
String get alarmDiagnosticsBatteryHint =>
'Mencegah sistem menutup PluriWave di latar belakang, sehingga alarm tetap bisa berbunyi.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Alarm yang terdaftar di Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Terdaftar saat ini: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'Kamu punya alarm yang aktif, tapi belum ada yang terdaftar di sistem. Buka lagi PluriWave, atau perbaiki dulu poin-poin di atas.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Produsen';
@override
String get alarmDiagnosticsSdkLabel => 'Versi Android (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'Perlu perhatian';
@override
String get alarmDiagnosticsAutostartTitle =>
'Satu langkah manual lagi di ponsel ini';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return 'Ponsel $manufacturer sering menutup aplikasi yang berjalan di latar belakang untuk menghemat baterai. Tidak ada pengaturan yang bisa diaktifkan PluriWave sendiri — kamu perlu mengaktifkan sendiri Autostart (kadang disebut \"Mulai otomatis\" atau \"Aktivitas latar belakang\") untuk PluriWave. Cari di Pengaturan, di bagian Aplikasi atau Baterai, atau di aplikasi Keamanan bawaan ponsel.';
}
@override
String get alarmDiagnosticsFixAction => 'Perbaiki';
@override
String get alarmDiagnosticsIntentUnavailable =>
'Tidak bisa membuka layar pengaturan itu di ponsel ini. Coba cari secara manual di Pengaturan.';
@override
String get alarmDiagnosticsUnavailableHint =>
'Kami belum bisa memeriksa pengaturan alarmmu.';
@override
String get autoEqDisableOption => 'Nonaktifkan';
@override
String get funcionPremium => 'Fitur Premium';
@override
String get limiteAlarmasAlcanzado =>
'Anda telah mencapai batas gratis 5 alarm.';
@override
String get desbloquearPremium => 'Buka Premium';
@override
String get restaurarCompras => 'Pulihkan pembelian';
@override
String get compraError =>
'Pembelian tidak dapat diselesaikan. Silakan coba lagi.';
@override
String get restauracionSinCompras =>
'Kami tidak menemukan pembelian sebelumnya di akun ini.';
@override
String get premiumActivo => 'Premium aktif';
}
+202 -1
View File
@@ -1144,6 +1144,16 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get newVacationRangeTitle => 'Nuovo periodo di vacanza';
@override
String get editVacationRangeTitle => 'Modifica periodo di vacanza';
@override
String get vacationDeleteConfirmTitle => 'Eliminare il periodo di vacanza?';
@override
String get vacationDeleteConfirmMessage =>
'Questa azione non può essere annullata.';
@override
String get startField => 'Inizio';
@@ -1418,7 +1428,77 @@ class AppLocalizationsIt extends AppLocalizations {
String get helpTitle => 'Aiuto e tutorial';
@override
String get helpSubtitle => 'Rivedi funzioni, consigli e novità di PluriWave.';
String get helpSubtitle => '9 schermate · rivedilo quando vuoi';
@override
String get tutorialSkipAction => 'Salta';
@override
String get tutorialNextAction => 'Avanti';
@override
String get tutorialPage1Headline => 'Salva le tue emittenti e raggruppale';
@override
String get tutorialPage1Body =>
'Tocca il cuore per salvare un\'emittente. In «Le tue emittenti» puoi creare gruppi come «Ogni mattina» o «Auto» e riordinarle trascinandole.';
@override
String get tutorialPage2Headline =>
'Un equalizzatore di base e uno per emittente';
@override
String get tutorialPage2Body =>
'Nelle Impostazioni definisci l\'equalizzatore generale. E dalla riproduzione di un\'emittente specifica puoi darle un\'impostazione propria, che ha la priorità su quella generale.';
@override
String get tutorialPage3Headline => 'Registra ciò che stai ascoltando';
@override
String get tutorialPage3Body =>
'Dal vassoio degli strumenti del player, «Registra» salva lo stream originale. Trova le tue registrazioni in Impostazioni › Registrazioni.';
@override
String get tutorialPage4Headline => 'Sveglie che si adattano a te';
@override
String get tutorialPage4Body =>
'Svegliati con la tua emittente preferita, posticipa di 3, 5 o 10 minuti, e aggiungi intervalli di vacanza in modo che alcune sveglie si saltino da sole.';
@override
String get tutorialPage5Headline => 'I tuoi preferiti, anche in auto';
@override
String get tutorialPage5Body =>
'Collega il telefono con Android Auto e troverai Preferiti, Tutte le emittenti, Le tue emittenti e la tua Musica locale, con pulsanti grandi pensati per la guida.';
@override
String get tutorialPage6Headline => 'Si riconnette da sola';
@override
String get tutorialPage6Body =>
'Se il segnale cade, PluriWave riprova automaticamente e continua a mostrare i tuoi preferiti salvati anche senza connessione.';
@override
String get tutorialPage7Headline => 'Scegli quanto posticipare';
@override
String get tutorialPage7Body =>
'Quando suona una sveglia, non c\'è un solo «posticipa»: scegli 3, 5 o 10 minuti a seconda di cosa ti serve in quel momento.';
@override
String get tutorialPage8Headline => 'Non la trovi? Aggiungila tu';
@override
String get tutorialPage8Body =>
'In «Le tue emittenti» → Aggiungi emittente personalizzata incolla l\'URL dello stream di un\'emittente non presente nella ricerca. Viene salvata in «Le tue emittenti», disponibile anche in auto.';
@override
String get tutorialPage9Headline => 'Fatto, ora conosci l\'essenziale';
@override
String get tutorialPage9BannerBody =>
'Per rivedere questo tutorial quando vuoi: Impostazioni → Informazioni → Aiuto e tutorial.';
@override
String get indefiniteOption => 'Indefinita';
@@ -1690,4 +1770,125 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Inizia ad ascoltare';
@override
String get eqCustomActionEnableLabel => 'Attiva equalizzatore';
@override
String get eqCustomActionDisableLabel => 'Disattiva equalizzatore';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Preset attivo: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'In pausa per le vacanze';
@override
String get alarmCardSchedulingFailedMessage =>
'Questa sveglia non è stata registrata nel sistema, quindi potrebbe non suonare.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Questa sveglia è programmata, ma non è stato possibile impostare il promemoria anticipato.';
@override
String get alarmDiagnosticsExactAlarmsTitle =>
'Programmazione sveglia esatta';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Permette alla sveglia di suonare esattamente al minuto impostato, anche a telefono in stand-by.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Notifiche';
@override
String get alarmDiagnosticsNotificationsHint =>
'Necessarie per mostrare la sveglia e l\'avviso anticipato.';
@override
String get alarmDiagnosticsFullScreenTitle =>
'Visualizzazione a schermo intero della sveglia';
@override
String get alarmDiagnosticsFullScreenHint =>
'Permette alla schermata della sveglia di apparire automaticamente, anche a telefono bloccato.';
@override
String get alarmDiagnosticsBatteryTitle => 'Ottimizzazione della batteria';
@override
String get alarmDiagnosticsBatteryHint =>
'Impedisce al sistema di chiudere PluriWave in background, così la sveglia può comunque suonare.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Sveglie registrate su Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Attualmente registrate: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'Hai una sveglia attiva, ma nessuna è ancora registrata nel sistema. Riapri PluriWave, oppure risolvi prima i punti sopra.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Produttore';
@override
String get alarmDiagnosticsSdkLabel => 'Versione di Android (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'Richiede attenzione';
@override
String get alarmDiagnosticsAutostartTitle =>
'Un altro passaggio manuale su questo telefono';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return 'I telefoni $manufacturer spesso chiudono le app in background per risparmiare batteria. Non esiste un\'impostazione che PluriWave possa attivare da solo: devi attivare tu stesso l\'Avvio automatico (a volte chiamato \"Autostart\" o \"Attività in background\") per PluriWave. Cercalo nelle Impostazioni, sotto App o Batteria, oppure nell\'app Sicurezza del telefono.';
}
@override
String get alarmDiagnosticsFixAction => 'Risolvi';
@override
String get alarmDiagnosticsIntentUnavailable =>
'Non è stato possibile aprire questa schermata delle impostazioni su questo telefono. Prova a cercarla manualmente nelle Impostazioni.';
@override
String get alarmDiagnosticsUnavailableHint =>
'Non abbiamo ancora potuto controllare le impostazioni della sveglia.';
@override
String get autoEqDisableOption => 'Disattiva';
@override
String get funcionPremium => 'Funzione Premium';
@override
String get limiteAlarmasAlcanzado =>
'Hai raggiunto il limite gratuito di 5 sveglie.';
@override
String get desbloquearPremium => 'Sblocca Premium';
@override
String get restaurarCompras => 'Ripristina acquisti';
@override
String get compraError =>
'Non è stato possibile completare l\'acquisto. Riprova.';
@override
String get restauracionSinCompras =>
'Non abbiamo trovato acquisti precedenti su questo account.';
@override
String get premiumActivo => 'Premium attivo';
}
+191 -1
View File
@@ -1101,6 +1101,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get newVacationRangeTitle => '新しい休暇期間';
@override
String get editVacationRangeTitle => '休暇期間を編集';
@override
String get vacationDeleteConfirmTitle => '休暇期間を削除しますか?';
@override
String get vacationDeleteConfirmMessage => 'この操作は元に戻せません。';
@override
String get startField => '開始';
@@ -1359,7 +1368,76 @@ class AppLocalizationsJa extends AppLocalizations {
String get helpTitle => 'ヘルプとチュートリアル';
@override
String get helpSubtitle => 'PluriWaveの機能、ヒント、新着情報を確認できます。';
String get helpSubtitle => '全9画面・いつでも見返せます';
@override
String get tutorialSkipAction => 'スキップ';
@override
String get tutorialNextAction => '次へ';
@override
String get tutorialPage1Headline => 'お気に入りの局を保存してグループ分け';
@override
String get tutorialPage1Body =>
'ハートをタップして局を保存しましょう。「あなたの局」では「毎朝」や「車」などのグループを作り、ドラッグして並べ替えられます。';
@override
String get tutorialPage2Headline => '基本のイコライザーと局ごとのイコライザー';
@override
String get tutorialPage2Body =>
'設定で全体のイコライザーを決められます。特定の局を再生中は、その局専用の設定を適用でき、全体設定より優先されます。';
@override
String get tutorialPage3Headline => '聴いている番組を録音';
@override
String get tutorialPage3Body =>
'プレーヤーのツールトレイから「録音」をタップすると、元のストリームが保存されます。録音は設定›録音で確認できます。';
@override
String get tutorialPage4Headline => 'あなたに合わせてくれるアラーム';
@override
String get tutorialPage4Body =>
'お気に入りの局で目覚め、3分・5分・10分でスヌーズでき、休暇期間を追加すれば一部のアラームを自動でスキップできます。';
@override
String get tutorialPage5Headline => 'お気に入りは車でも';
@override
String get tutorialPage5Body =>
'スマートフォンをAndroid Autoに接続すると、お気に入り、すべての局、あなたの局、ローカルミュージックが、運転向けの大きなボタンで使えます。';
@override
String get tutorialPage6Headline => '自動で再接続';
@override
String get tutorialPage6Body =>
'電波が途切れても、PluriWaveは自動的に再試行し、接続がなくても保存済みのお気に入りを表示し続けます。';
@override
String get tutorialPage7Headline => 'スヌーズ時間を選べる';
@override
String get tutorialPage7Body =>
'アラームが鳴ったとき、「スヌーズ」は一つだけではありません。3分・5分・10分から、その時必要な時間を選べます。';
@override
String get tutorialPage8Headline => '見つからない?自分で追加';
@override
String get tutorialPage8Body =>
'「あなたの局」→カスタム局を追加で、検索にない局のストリームURLを貼り付けられます。「あなたの局」に保存され、車でも利用できます。';
@override
String get tutorialPage9Headline => '完了、基本はこれで押さえました';
@override
String get tutorialPage9BannerBody =>
'このチュートリアルはいつでも再確認できます:設定 → 情報 → ヘルプとチュートリアル。';
@override
String get indefiniteOption => '無期限';
@@ -1622,4 +1700,116 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get welcomeCtaLabel => '聴き始める';
@override
String get eqCustomActionEnableLabel => 'イコライザーをオンにする';
@override
String get eqCustomActionDisableLabel => 'イコライザーをオフにする';
@override
String eqCustomActionPresetLabel(String preset) {
return 'プリセット: $preset';
}
@override
String get alarmCardVacationPausedBadge => '休暇のため一時停止中';
@override
String get alarmCardSchedulingFailedMessage =>
'このアラームはシステムに登録できなかったため、鳴らない可能性があります。';
@override
String get alarmCardPreNoticeFailedMessage =>
'このアラームは設定されていますが、事前通知を設定できませんでした。';
@override
String get alarmDiagnosticsExactAlarmsTitle => '正確なアラームのスケジュール設定';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'スマートフォンがスリープ中でも、設定した時刻ちょうどにアラームを鳴らせるようにします。';
@override
String get alarmDiagnosticsNotificationsTitle => '通知';
@override
String get alarmDiagnosticsNotificationsHint => 'アラームと事前通知を表示するために必要です。';
@override
String get alarmDiagnosticsFullScreenTitle => 'アラームのフルスクリーン表示';
@override
String get alarmDiagnosticsFullScreenHint =>
'画面がロックされていても、アラーム画面が自動的に表示されるようにします。';
@override
String get alarmDiagnosticsBatteryTitle => 'バッテリーの最適化';
@override
String get alarmDiagnosticsBatteryHint =>
'システムがPluriWaveをバックグラウンドで終了しないようにし、アラームが確実に鳴るようにします。';
@override
String get alarmDiagnosticsNativeCountTitle => 'Androidに登録されているアラーム';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return '現在登録されている数: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'アラームは有効になっていますが、まだシステムに登録されていません。PluriWaveを開き直すか、まず上の項目を確認してください。';
@override
String get alarmDiagnosticsManufacturerLabel => '製造元';
@override
String get alarmDiagnosticsSdkLabel => 'Androidのバージョン(SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => '確認が必要です';
@override
String get alarmDiagnosticsAutostartTitle => 'この端末でのもう一つの手動設定';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return '$manufacturerのスマートフォンは、バッテリーを節約するためにバックグラウンドのアプリを終了させることがよくあります。PluriWaveが自動でオンにできる設定はありません。PluriWaveの自動起動(「オートスタート」や「バックグラウンド動作」と呼ばれることもあります)を、自分で有効にする必要があります。設定内のアプリまたはバッテリーの項目、あるいは端末のセキュリティアプリを確認してください。';
}
@override
String get alarmDiagnosticsFixAction => '修正する';
@override
String get alarmDiagnosticsIntentUnavailable =>
'この端末では設定画面を開けませんでした。設定アプリ内で手動で探してみてください。';
@override
String get alarmDiagnosticsUnavailableHint => 'アラームの設定をまだ確認できていません。';
@override
String get autoEqDisableOption => '無効化';
@override
String get funcionPremium => 'プレミアム機能';
@override
String get limiteAlarmasAlcanzado => '無料プランのアラーム上限(5件)に達しました。';
@override
String get desbloquearPremium => 'プレミアムを解除';
@override
String get restaurarCompras => '購入を復元';
@override
String get compraError => '購入を完了できませんでした。もう一度お試しください。';
@override
String get restauracionSinCompras => 'このアカウントでは以前の購入が見つかりませんでした。';
@override
String get premiumActivo => 'プレミアム有効';
}
+200 -1
View File
@@ -1138,6 +1138,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get newVacationRangeTitle => 'Novo período de férias';
@override
String get editVacationRangeTitle => 'Editar período de férias';
@override
String get vacationDeleteConfirmTitle => 'Excluir período de férias?';
@override
String get vacationDeleteConfirmMessage => 'Esta ação não pode ser desfeita.';
@override
String get startField => 'Início';
@@ -1407,7 +1416,77 @@ class AppLocalizationsPt extends AppLocalizations {
String get helpTitle => 'Ajuda e tutorial';
@override
String get helpSubtitle => 'Revê funções, dicas e novidades do PluriWave.';
String get helpSubtitle => '9 ecrãs · reveja quando quiser';
@override
String get tutorialSkipAction => 'Pular';
@override
String get tutorialNextAction => 'Avançar';
@override
String get tutorialPage1Headline => 'Guarde as suas estações e agrupe-as';
@override
String get tutorialPage1Body =>
'Toque no coração para guardar uma estação. Em «Suas estações» pode criar grupos como «Todas as manhãs» ou «Carro» e reordená-las arrastando.';
@override
String get tutorialPage2Headline =>
'Um equalizador geral e outro por estação';
@override
String get tutorialPage2Body =>
'Em Definições define o equalizador geral. E, ao reproduzir uma estação específica, pode dar-lhe o seu próprio ajuste, que prevalece sobre o geral.';
@override
String get tutorialPage3Headline => 'Grave o que está a ouvir';
@override
String get tutorialPage3Body =>
'Na bandeja de ferramentas do leitor, «Gravar» guarda o stream original. Encontre as suas gravações em Definições › Gravações.';
@override
String get tutorialPage4Headline => 'Alarmes que se adaptam a si';
@override
String get tutorialPage4Body =>
'Acorde com a sua estação favorita, adie 3, 5 ou 10 minutos, e adicione períodos de férias para que alguns alarmes se saltem sozinhos.';
@override
String get tutorialPage5Headline => 'Os seus favoritos, também no carro';
@override
String get tutorialPage5Body =>
'Ligue o telemóvel ao Android Auto e encontre Favoritos, Todas as estações, Suas estações e a sua Música local, com botões grandes pensados para conduzir.';
@override
String get tutorialPage6Headline => 'Reconecta-se sozinho';
@override
String get tutorialPage6Body =>
'Se o sinal cair, o PluriWave tenta novamente de forma automática e continua a mostrar os seus favoritos guardados mesmo sem ligação.';
@override
String get tutorialPage7Headline => 'Escolha quanto tempo adiar';
@override
String get tutorialPage7Body =>
'Quando um alarme toca, não existe um único «adiar»: escolhe 3, 5 ou 10 minutos conforme o que precisar nesse momento.';
@override
String get tutorialPage8Headline => 'Não a encontra? Adicione-a você mesmo';
@override
String get tutorialPage8Body =>
'Em «Suas estações» → Adicionar estação personalizada, cole o URL do stream de uma estação que não esteja na pesquisa. Fica guardada em «Suas estações», também disponível no carro.';
@override
String get tutorialPage9Headline => 'Pronto, já conhece o essencial';
@override
String get tutorialPage9BannerBody =>
'Para rever este tutorial quando quiser: Definições → Informação → Ajuda e tutorial.';
@override
String get indefiniteOption => 'Indefinida';
@@ -1679,4 +1758,124 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Começar a ouvir';
@override
String get eqCustomActionEnableLabel => 'Ativar equalizador';
@override
String get eqCustomActionDisableLabel => 'Desativar equalizador';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Predefinição: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Pausada por férias';
@override
String get alarmCardSchedulingFailedMessage =>
'Este alarme não pôde ser registrado no sistema, por isso pode não tocar.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Este alarme está agendado, mas seu aviso antecipado não pôde ser configurado.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'Agendamento exato do alarme';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Permite que o alarme toque no minuto exato definido, mesmo com o telefone em repouso.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Notificações';
@override
String get alarmDiagnosticsNotificationsHint =>
'Necessárias para mostrar o alarme e o aviso prévio.';
@override
String get alarmDiagnosticsFullScreenTitle =>
'Exibição em tela cheia do alarme';
@override
String get alarmDiagnosticsFullScreenHint =>
'Permite que a tela do alarme apareça automaticamente, mesmo com o telefone bloqueado.';
@override
String get alarmDiagnosticsBatteryTitle => 'Otimização de bateria';
@override
String get alarmDiagnosticsBatteryHint =>
'Evita que o sistema feche o PluriWave em segundo plano, para que o alarme ainda possa tocar.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Alarmes registrados no Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Registrados agora: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'Você tem um alarme ativado, mas nenhum está registrado no sistema ainda. Reabra o PluriWave ou resolva primeiro os itens acima.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Fabricante';
@override
String get alarmDiagnosticsSdkLabel => 'Versão do Android (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'Precisa de atenção';
@override
String get alarmDiagnosticsAutostartTitle =>
'Mais uma etapa manual neste telefone';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return 'Telefones $manufacturer costumam fechar apps em segundo plano para economizar bateria. Não existe uma configuração que o PluriWave possa ativar sozinho: você precisa ativar por conta própria o Início automático (às vezes chamado de \"Autostart\" ou \"Atividade em segundo plano\") para o PluriWave. Procure em Configurações, em Apps ou Bateria, ou no próprio app de Segurança do telefone.';
}
@override
String get alarmDiagnosticsFixAction => 'Resolver';
@override
String get alarmDiagnosticsIntentUnavailable =>
'Não foi possível abrir essa tela de configurações neste telefone. Tente procurá-la manualmente nas Configurações.';
@override
String get alarmDiagnosticsUnavailableHint =>
'Ainda não conseguimos verificar as configurações do seu alarme.';
@override
String get autoEqDisableOption => 'Desativar';
@override
String get funcionPremium => 'Recurso Premium';
@override
String get limiteAlarmasAlcanzado =>
'Você atingiu o limite gratuito de 5 alarmes.';
@override
String get desbloquearPremium => 'Desbloquear Premium';
@override
String get restaurarCompras => 'Restaurar compras';
@override
String get compraError =>
'Não foi possível concluir a compra. Tente novamente.';
@override
String get restauracionSinCompras =>
'Não encontramos nenhuma compra anterior nesta conta.';
@override
String get premiumActivo => 'Premium ativo';
}
+202 -1
View File
@@ -1140,6 +1140,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get newVacationRangeTitle => 'Новый период отпуска';
@override
String get editVacationRangeTitle => 'Изменить период отпуска';
@override
String get vacationDeleteConfirmTitle => 'Удалить период отпуска?';
@override
String get vacationDeleteConfirmMessage => 'Это действие нельзя отменить.';
@override
String get startField => 'Начало';
@@ -1411,7 +1420,79 @@ class AppLocalizationsRu extends AppLocalizations {
String get helpTitle => 'Помощь и руководство';
@override
String get helpSubtitle => 'Посмотрите функции, советы и новости PluriWave.';
String get helpSubtitle => '9 экранов · смотрите снова в любое время';
@override
String get tutorialSkipAction => 'Пропустить';
@override
String get tutorialNextAction => 'Далее';
@override
String get tutorialPage1Headline => 'Сохраняйте станции и группируйте их';
@override
String get tutorialPage1Body =>
'Нажмите на сердечко, чтобы сохранить станцию. В разделе «Ваши станции» можно создавать группы, например «Каждое утро» или «Машина», и менять порядок перетаскиванием.';
@override
String get tutorialPage2Headline =>
'Общий эквалайзер и отдельный для каждой станции';
@override
String get tutorialPage2Body =>
'В настройках вы задаёте общий эквалайзер. А во время воспроизведения конкретной станции можно задать для неё собственную настройку — она будет иметь приоритет над общей.';
@override
String get tutorialPage3Headline => 'Записывайте то, что слушаете';
@override
String get tutorialPage3Body =>
'На панели инструментов плеера кнопка «Запись» сохраняет исходный поток. Ваши записи хранятся в разделе Настройки › Записи.';
@override
String get tutorialPage4Headline =>
'Будильники, которые подстраиваются под вас';
@override
String get tutorialPage4Body =>
'Просыпайтесь под любимую станцию, откладывайте на 3, 5 или 10 минут и добавляйте периоды отпуска, чтобы некоторые будильники пропускались сами.';
@override
String get tutorialPage5Headline =>
'Ваши избранные станции — и в машине тоже';
@override
String get tutorialPage5Body =>
'Подключите телефон через Android Auto, и вы найдёте Избранное, Все станции, Ваши станции и вашу локальную музыку с крупными кнопками, удобными за рулём.';
@override
String get tutorialPage6Headline => 'Переподключается сам';
@override
String get tutorialPage6Body =>
'Если сигнал пропадает, PluriWave автоматически повторяет попытку и продолжает показывать сохранённые избранные станции даже без подключения.';
@override
String get tutorialPage7Headline => 'Выбирайте, на сколько отложить';
@override
String get tutorialPage7Body =>
'Когда звонит будильник, нет единственного варианта «отложить»: выбирайте 3, 5 или 10 минут — в зависимости от того, что нужно именно сейчас.';
@override
String get tutorialPage8Headline => 'Не нашли станцию? Добавьте её сами';
@override
String get tutorialPage8Body =>
'В разделе «Ваши станции» → Добавить свою станцию вставьте URL потока станции, которой нет в поиске. Она сохранится в «Ваши станции» и будет доступна и в машине.';
@override
String get tutorialPage9Headline => 'Готово, теперь вы знаете самое важное';
@override
String get tutorialPage9BannerBody =>
'Чтобы посмотреть этот урок ещё раз: Настройки → Информация → Помощь и руководство.';
@override
String get indefiniteOption => 'Без ограничения';
@@ -1683,4 +1764,124 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Начать слушать';
@override
String get eqCustomActionEnableLabel => 'Включить эквалайзер';
@override
String get eqCustomActionDisableLabel => 'Выключить эквалайзер';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Пресет: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Приостановлено на время отпуска';
@override
String get alarmCardSchedulingFailedMessage =>
'Этот будильник не удалось зарегистрировать в системе, поэтому он может не сработать.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Этот будильник запланирован, но не удалось настроить предварительное напоминание.';
@override
String get alarmDiagnosticsExactAlarmsTitle =>
'Точное планирование будильника';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Будильник звонит ровно в заданную минуту, даже если телефон находится в режиме сна.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Уведомления';
@override
String get alarmDiagnosticsNotificationsHint =>
'Нужны, чтобы показать будильник и заблаговременное напоминание.';
@override
String get alarmDiagnosticsFullScreenTitle =>
'Полноэкранный показ будильника';
@override
String get alarmDiagnosticsFullScreenHint =>
'Экран будильника появляется автоматически, даже если телефон заблокирован.';
@override
String get alarmDiagnosticsBatteryTitle => 'Оптимизация батареи';
@override
String get alarmDiagnosticsBatteryHint =>
'Не позволяет системе закрывать PluriWave в фоновом режиме, чтобы будильник мог сработать.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Будильники, зарегистрированные в Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Сейчас зарегистрировано: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'У вас включён будильник, но пока ни один не зарегистрирован в системе. Откройте PluriWave заново или сначала устраните пункты выше.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Производитель';
@override
String get alarmDiagnosticsSdkLabel => 'Версия Android (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'Требует внимания';
@override
String get alarmDiagnosticsAutostartTitle =>
'Ещё один ручной шаг на этом телефоне';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return 'Телефоны $manufacturer часто закрывают приложения в фоновом режиме для экономии батареи. PluriWave не может включить это самостоятельно — вам нужно вручную включить автозапуск (иногда называется \"Autostart\" или \"Фоновая активность\") для PluriWave. Ищите в Настройках, в разделе Приложения или Батарея, либо в приложении безопасности телефона.';
}
@override
String get alarmDiagnosticsFixAction => 'Исправить';
@override
String get alarmDiagnosticsIntentUnavailable =>
'Не удалось открыть этот экран настроек на этом телефоне. Попробуйте найти его вручную в Настройках.';
@override
String get alarmDiagnosticsUnavailableHint =>
'Мы пока не смогли проверить настройки вашего будильника.';
@override
String get autoEqDisableOption => 'Отключить';
@override
String get funcionPremium => 'Премиум-функция';
@override
String get limiteAlarmasAlcanzado =>
'Вы достигли бесплатного лимита в 5 будильников.';
@override
String get desbloquearPremium => 'Разблокировать Премиум';
@override
String get restaurarCompras => 'Восстановить покупки';
@override
String get compraError => 'Не удалось завершить покупку. Попробуйте ещё раз.';
@override
String get restauracionSinCompras =>
'Мы не нашли предыдущих покупок на этом аккаунте.';
@override
String get premiumActivo => 'Премиум активен';
}
+184 -1
View File
@@ -1097,6 +1097,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get newVacationRangeTitle => '新的假期范围';
@override
String get editVacationRangeTitle => '编辑假期范围';
@override
String get vacationDeleteConfirmTitle => '删除假期范围?';
@override
String get vacationDeleteConfirmMessage => '此操作无法撤销。';
@override
String get startField => '开始';
@@ -1354,7 +1363,75 @@ class AppLocalizationsZh extends AppLocalizations {
String get helpTitle => '帮助和教程';
@override
String get helpSubtitle => '查看 PluriWave 的功能、技巧和新内容。';
String get helpSubtitle => '共9屏 · 随时可重新查看';
@override
String get tutorialSkipAction => '跳过';
@override
String get tutorialNextAction => '下一步';
@override
String get tutorialPage1Headline => '保存并整理你的电台';
@override
String get tutorialPage1Body =>
'点击心形图标即可保存电台。在“你的电台”中,你可以创建“每天早上”或“车载”等分组,并通过拖动重新排序。';
@override
String get tutorialPage2Headline => '一个通用均衡器,外加每个电台专属的均衡器';
@override
String get tutorialPage2Body =>
'在设置中可以调整通用均衡器。播放某个电台时,你还可以为它单独设置均衡器,该设置优先于通用设置。';
@override
String get tutorialPage3Headline => '录制正在收听的内容';
@override
String get tutorialPage3Body =>
'在播放器工具栏中点击“录制”,即可保存原始音频流。录音内容可在 设置 › 录音 中查看。';
@override
String get tutorialPage4Headline => '会迁就你的闹钟';
@override
String get tutorialPage4Body =>
'用喜欢的电台唤醒自己,可以推迟3分钟、5分钟或10分钟,还能添加假期时间段,让部分闹钟自动跳过。';
@override
String get tutorialPage5Headline => '收藏的电台,车载也能用';
@override
String get tutorialPage5Body =>
'将手机连接到Android Auto,即可看到收藏、全部电台、你的电台和本地音乐,大按钮专为驾驶设计。';
@override
String get tutorialPage6Headline => '自动重新连接';
@override
String get tutorialPage6Body =>
'信号中断时,PluriWave会自动重试连接,即使暂时没有网络也会继续显示已保存的收藏电台。';
@override
String get tutorialPage7Headline => '自选推迟时长';
@override
String get tutorialPage7Body =>
'闹钟响起时,“推迟”并非只有一种选择:你可以根据当下需要选择推迟3分钟、5分钟或10分钟。';
@override
String get tutorialPage8Headline => '找不到?自己添加';
@override
String get tutorialPage8Body =>
'在“你的电台”→添加自定义电台中,粘贴搜索结果里没有的电台的流媒体URL即可。它会保存在“你的电台”中,车载模式下同样可用。';
@override
String get tutorialPage9Headline => '完成,你已经了解核心功能';
@override
String get tutorialPage9BannerBody => '想随时重新观看本教程:设置 → 信息 → 帮助与教程。';
@override
String get indefiniteOption => '不限时';
@@ -1614,4 +1691,110 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get welcomeCtaLabel => '开始收听';
@override
String get eqCustomActionEnableLabel => '启用均衡器';
@override
String get eqCustomActionDisableLabel => '关闭均衡器';
@override
String eqCustomActionPresetLabel(String preset) {
return '预设:$preset';
}
@override
String get alarmCardVacationPausedBadge => '因假期已暂停';
@override
String get alarmCardSchedulingFailedMessage => '该闹钟未能在系统中注册,因此可能不会响铃。';
@override
String get alarmCardPreNoticeFailedMessage => '该闹钟已设置,但其提前提醒未能设置成功。';
@override
String get alarmDiagnosticsExactAlarmsTitle => '精确闹钟排程';
@override
String get alarmDiagnosticsExactAlarmsHint => '即使手机处于休眠状态,也能让闹钟在设定的准确时间响起。';
@override
String get alarmDiagnosticsNotificationsTitle => '通知';
@override
String get alarmDiagnosticsNotificationsHint => '显示闹钟和提前提醒需要用到。';
@override
String get alarmDiagnosticsFullScreenTitle => '闹钟全屏显示';
@override
String get alarmDiagnosticsFullScreenHint => '即使手机已锁屏,也能让响铃界面自动出现。';
@override
String get alarmDiagnosticsBatteryTitle => '电池优化';
@override
String get alarmDiagnosticsBatteryHint => '防止系统在后台关闭PluriWave,让闹钟仍然可以响起。';
@override
String get alarmDiagnosticsNativeCountTitle => '已在Android系统注册的闹钟';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return '当前已注册:$count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'你已开启一个闹钟,但目前还没有闹钟在系统中注册。请重新打开PluriWave,或先解决上面列出的问题。';
@override
String get alarmDiagnosticsManufacturerLabel => '制造商';
@override
String get alarmDiagnosticsSdkLabel => 'Android版本(SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => '需要注意';
@override
String get alarmDiagnosticsAutostartTitle => '此手机还需要一步手动设置';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return '$manufacturer手机经常会关闭后台运行的应用以节省电量。没有任何设置可以让PluriWave自行开启——你需要自己为PluriWave开启自启动(有时也叫\"Autostart\"或\"后台活动\")。请在设置中查找应用或电池选项,或者查看手机自带的安全应用。';
}
@override
String get alarmDiagnosticsFixAction => '解决';
@override
String get alarmDiagnosticsIntentUnavailable => '无法在此手机上打开该设置界面。请尝试在设置中手动查找。';
@override
String get alarmDiagnosticsUnavailableHint => '我们还无法检查你的闹钟设置。';
@override
String get autoEqDisableOption => '关闭';
@override
String get funcionPremium => '高级功能';
@override
String get limiteAlarmasAlcanzado => '您已达到免费版 5 个闹钟的上限。';
@override
String get desbloquearPremium => '解锁高级版';
@override
String get restaurarCompras => '恢复购买';
@override
String get compraError => '无法完成购买,请重试。';
@override
String get restauracionSinCompras => '未在此账户中找到以前的购买记录。';
@override
String get premiumActivo => '高级版已解锁';
}
+183 -34
View File
@@ -1,16 +1,22 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:ui' as ui;
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'app.dart';
import 'estado/estado_entitlement.dart';
import 'servicios/arranque_audio.dart';
import 'servicios/musica_local_auto.dart';
import 'servicios/navegacion_auto.dart';
import 'servicios/servicio_audio.dart';
import 'servicios/servicio_audio_session.dart';
import 'servicios/servicio_compras.dart';
import 'servicios/servicio_consentimiento.dart';
import 'servicios/servicio_presets_personalizados.dart';
import 'tema/pluriwave_tokens.dart';
const _anchoMinimoLandscape = 600.0;
@@ -24,36 +30,141 @@ const androidNotificationIconResource = 'drawable/ic_stat_pluriwave';
const configuracionAudioService = AudioServiceConfig(
androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
androidNotificationChannelName: 'PluriWave Radio',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
// Paired with `androidStopForegroundOnPause: false` below, and required to
// be: the plugin asserts `androidNotificationOngoing` implies
// `androidStopForegroundOnPause`. Nothing is lost by turning it off —
// while the service is in the foreground the OS forces the notification to
// be ongoing anyway, which is now the whole time playback is alive.
androidNotificationOngoing: false,
// The service stays in the FOREGROUND while paused.
//
// With `true`, a pause called `stopForeground(...)`, and a service that is
// not in the foreground is a service Android may kill at will. In the car
// that is exactly what happened: an interruption paused playback, the
// service dropped out of the foreground, Android reclaimed it, and
// PluriWave disappeared from the Android Auto pane — another media app
// took the slot. Ducking (see `ServicioAudioSession.configurar`) removes
// most pauses, but a real pause must not be a death sentence either.
//
// The plugin's own doc for this flag says it outright: «while in this
// lower priority state, the operating system will also be able to kill
// your service at any time to reclaim resources».
//
// Cost of `false`: the notification is not swipe-dismissible while paused,
// only after Stop. That is how every serious media app behaves, and Stop
// still tears everything down.
androidStopForegroundOnPause: false,
notificationColor: PluriWaveTokens.brand,
androidNotificationIcon: androidNotificationIconResource,
);
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await _aplicarPoliticaOrientacion();
// Android Auto browse source: registered FIRST, before any await at all.
// It depends on nothing, and everything below it is a potential place to
// get stuck — so nothing may sit between engine start and this line.
//
// Reported: with Android Auto connected, the car screen sometimes came up
// black and the app then opened WHITE on the phone until it was
// force-killed. `AudioServiceActivity.provideFlutterEngine` returns the
// engine from `AudioServicePlugin.getFlutterEngine`, which CREATES the
// engine and runs `main()` headlessly the first time — with no Activity —
// when the car binds the MediaBrowserService before the app is opened.
// `_aplicarPoliticaOrientacion` used to be the first `await` here, and
// `SystemChrome.setPreferredOrientations` travels the `flutter/platform`
// channel, whose handler (`PlatformPlugin`) is installed by the Activity.
// Headless there is nobody to answer it, so `main()` died or hung on line
// one: the browse source below was never registered (`getChildren` had no
// source -> black car screen) and `runApp` was never reached. Opening the
// app then REUSED that same cached, already-dead engine -> white screen,
// and only a force-kill (which disposes the engine) recovered it.
final fuenteAuto = FuenteEmisorasAutoLocal();
registrarFuenteNavegacion(fuenteAuto);
// Local music registers HERE, above every await, alongside the station
// source — not after `SharedPreferences.getInstance()` where it used to
// sit.
//
// Regression this fixes, self-inflicted by the reordering above: the root
// menu decides whether to offer "Música Local" with
// `fuenteLocal != null && await fuenteLocal.hayCarpetaConfigurada()`.
// Moving ONLY the station source above the awaits meant the car could get
// a root response in the window before this line ran, find a null source,
// and be told there is no local music — and Android Auto caches the browse
// root, so it stayed missing for the whole session. Before the reorder
// both registrations sat together after the await, so the window did not
// exist.
//
// `FuenteMusicaLocalAutoImpl` needs no prefs to be CONSTRUCTED: it
// resolves them lazily per call (`_resolverPrefs`, falling back to
// `getInstance()`), the same convention `ServicioAlarmas` uses. So there
// was never a reason for it to wait on that await.
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl());
// Cosmetic, and deliberately NOT awaited: a display preference must never
// gate `runApp`. `_OrientacionResponsiveApp.didChangeDependencies` applies
// it again as soon as a real view exists, which is the only moment it can
// actually take effect anyway.
unawaited(aplicarPoliticaOrientacion());
// iap-freemium-unlock: neither SDK init call blocks `runApp` — a purchase
// stream subscription and an ad-SDK warm-up are both safe to finish late
// (mirrors `aplicarPoliticaOrientacion`'s "cosmetic, never gates startup"
// rule immediately above).
//
// FIX 4 (code review): the Mobile Ads SDK is only initialized AFTER the
// GDPR/UMP consent flow resolves that ads may actually be requested
// (`ConsentInformation.canRequestAds()`) — serving personalized ads to
// EEA/UK users with no CMP violates Google's EU User Consent Policy.
// Premium users never even reach the consent form (`resolverConsentimientoAnuncios`
// short-circuits for them — they get zero ads regardless). This whole
// chain is deliberately `unawaited`: consent/ads are exactly as
// "cosmetic, never gates startup" as `aplicarPoliticaOrientacion` above,
// and any failure inside it degrades to "no ads", never a crash or a
// blocked UI.
unawaited(
esPremiumPersistido()
.then(
(premium) => resolverConsentimientoAnuncios(
esPremium: premium,
consentimiento: ServicioConsentimientoUmp(),
),
)
.then((puedeSolicitarAnuncios) async {
if (puedeSolicitarAnuncios) {
await MobileAds.instance.initialize();
}
}),
);
final compras = ServicioComprasPlayBilling();
// S3-R4: single SharedPreferences instance resolved once at startup and
// injected into every state/service below.
final prefs = await SharedPreferences.getInstance();
// Android Auto browse source (Design "getChildren data source, cold-start
// safe") — registered BEFORE the AudioService.init await below (Design
// "Reorder handler-independent startup work before the init await"):
// neither this nor the local-music registration depends on the
// AudioHandler, so browse sources exist for the car even while the
// MediaBrowser handshake (no native timeout, see arranque_audio.dart) is
// still pending.
final fuenteAuto = FuenteEmisorasAutoLocal();
registrarFuenteNavegacion(fuenteAuto);
// User-saved EQ presets for the car's Ecualizador folder, same
// injectable-prefs DI convention and same pre-init placement as the two
// registrations above (neither depends on the AudioHandler). Passed as a
// read function, not the service, so the folder re-reads on every browse:
// a preset saved on the phone appears in the car without an app restart.
final presetsPersonalizados = ServicioPresetsPersonalizados(prefs: prefs);
registrarFuentePresetsPersonalizados(presetsPersonalizados.listar);
// Local-music browse source (Design "getChildren data source
// registration"), same injectable-prefs DI convention as every other
// startup service — required so `_fuenteMusicaLocalGlobal` is ever
// non-null; without this registration the local-music root would stay
// permanently hidden (`hayCarpetaConfigurada()` has no fuente to ask).
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl(prefs: prefs));
// Silent-error channel (fix/notificacion-media): `AudioService.asyncError`
// had ZERO subscribers app-wide, and a `PublishSubject` with no listeners
// drops what it is given — so every exception `audio_service` catches
// internally was discarded without a trace, which is exactly why the
// "media notification disappeared" report came with no evidence attached.
// Subscribed BEFORE `AudioService.init` below (the getter only touches a
// static subject, so it needs no initialisation) so nothing reported
// during the MediaBrowser handshake is missed, and placed here rather than
// in `conectarHandler` so ONE subscription covers both the on-time and the
// degraded/timeout startup paths.
final subErroresAudio = observarErroresAudio(
AudioService.asyncError,
registrar: registrarErrorAudioService,
);
// Design "Timeout without re-init": AudioService.init is started exactly
// ONCE here and `handlerFuturo` is the only future ever awaited for it —
@@ -69,12 +180,17 @@ Future<void> main() async {
// degraded/late-completion paths below.
void conectarHandler(PluriWaveAudioHandler handler) {
registrarHandler(handler);
// The handler is the only thing this app ever tears down
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
// with it and can never leak — same "register from main.dart" convention
// as `registrarHandler` itself.
registrarLimpiezaArranque(subErroresAudio.cancel);
final sesionAudio = ServicioAudioSession(objetivo: handler);
unawaited(sesionAudio.configurar());
}
Widget construirApp() => _OrientacionResponsiveApp(
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto),
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras),
);
final resultado = await esperarArranqueAudio(handlerFuturo);
@@ -102,21 +218,54 @@ Future<void> main() async {
}
}
Future<void> _aplicarPoliticaOrientacion([ui.Display? display]) async {
final vista =
WidgetsBinding.instance.platformDispatcher.views.isNotEmpty
? WidgetsBinding.instance.platformDispatcher.views.first
: null;
final displayActivo = display ?? vista?.display;
if (displayActivo == null) return;
/// Which orientations a display [anchoLogico] dp wide may use: phones stay
/// portrait, tablets get everything. Pure, so the policy is testable without
/// a platform channel.
@visibleForTesting
List<DeviceOrientation> orientacionesPara(double anchoLogico) =>
anchoLogico < _anchoMinimoLandscape
? const [DeviceOrientation.portraitUp]
: DeviceOrientation.values;
final anchoLogico = displayActivo.size.width / displayActivo.devicePixelRatio;
if (anchoLogico < _anchoMinimoLandscape) {
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
return;
/// Applies [orientacionesPara] to the active display.
///
/// NEVER throws and never blocks a caller that matters. This runs on the
/// headless engine Android Auto starts (see `main`), where the
/// `flutter/platform` channel has no handler because there is no Activity to
/// install `PlatformPlugin` — so the call can fail with a
/// `MissingPluginException` or simply never be answered. Before this guard
/// that outcome killed `main()` outright, taking the Android Auto browse
/// registration and `runApp` with it.
///
/// [aplicar] is injectable so the swallow-everything contract is testable
/// without a real platform channel.
@visibleForTesting
Future<void> aplicarPoliticaOrientacion({
ui.Display? display,
Future<void> Function(List<DeviceOrientation>)? aplicar,
}) async {
try {
final vista =
WidgetsBinding.instance.platformDispatcher.views.isNotEmpty
? WidgetsBinding.instance.platformDispatcher.views.first
: null;
final displayActivo = display ?? vista?.display;
if (displayActivo == null) return;
final anchoLogico =
displayActivo.size.width / displayActivo.devicePixelRatio;
await (aplicar ?? SystemChrome.setPreferredOrientations)(
orientacionesPara(anchoLogico),
);
} catch (e) {
// Deliberately broad: a cosmetic preference is never worth a failed
// startup, and headless is exactly where this fails.
developer.log(
'[PluriWave] no se pudo aplicar la política de orientación: $e',
name: 'Arranque',
level: 900,
);
}
await SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
class _OrientacionResponsiveApp extends StatefulWidget {
@@ -143,12 +292,12 @@ class _OrientacionResponsiveAppState extends State<_OrientacionResponsiveApp>
void didChangeDependencies() {
super.didChangeDependencies();
_display = View.maybeOf(context)?.display;
unawaited(_aplicarPoliticaOrientacion(_display));
unawaited(aplicarPoliticaOrientacion(display: _display));
}
@override
void didChangeMetrics() {
unawaited(_aplicarPoliticaOrientacion(_display));
unawaited(aplicarPoliticaOrientacion(display: _display));
}
@override
+37
View File
@@ -302,6 +302,43 @@ class ExcepcionAlarma {
final DateTime ejecucion;
final String tipo;
/// User-requested skip of the next occurrence (the only [tipo] this model
/// originally supported). `ServicioProgramacionAlarmas._esValida` only
/// treats THIS tipo as an actual schedule skip -- every tipo below records
/// a scheduling-reliability failure and must never affect which occurrence
/// fires next.
static const tipoSaltoSiguiente = 'skipNext';
/// The main alarm registration with the OS failed (`android.programar`
/// threw). Recorded per-alarm so the alarms list can mark the exact card
/// affected instead of only a transient, alarm-agnostic app-wide message.
static const tipoFalloProgramacion = 'schedulingFailed';
/// The main alarm registered successfully but its 30-minute pre-notice
/// reminder did not (native `SecurityException` scheduling the pre-notice
/// alone) -- distinguished from [tipoFalloProgramacion] because the alarm
/// itself will still ring; only the early warning is missing.
static const tipoFalloPreaviso = 'preNoticeFailed';
/// The OS refused to start the foreground ringing service when the alarm
/// fired (e.g. a background-restricted app), so the alarm never actually
/// rang even though it was armed.
static const tipoFalloServicioSonido = 'foregroundServiceFailed';
/// A per-alarm reschedule after boot/unlock failed while sibling alarms
/// succeeded, leaving this one specific alarm unscheduled.
static const tipoFalloReprogramacionArranque = 'rescheduleAfterBootFailed';
/// Every tipo above that represents a reliability FAILURE rather than a
/// deliberate user action -- used by the UI to decide whether to mark a
/// card, and by [ServicioAlarmas] to know which prior record to replace.
static const tiposFallo = {
tipoFalloProgramacion,
tipoFalloPreaviso,
tipoFalloServicioSonido,
tipoFalloReprogramacionArranque,
};
Map<String, dynamic> toJson() => {
'alarmaId': alarmaId,
'ejecucion': ejecucion.toIso8601String(),
@@ -6,6 +6,7 @@ import '../../estado/estado_radio.dart';
import '../../l10n/display_names.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../modelos/emisora.dart';
import '../../servicios/servicio_anuncios.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_push_scaffold.dart';
@@ -105,6 +106,11 @@ class _CuerpoEmisorasPersonalizadas extends StatelessWidget {
}
Future<void> _mostrarFormularioAnadir(BuildContext context) async {
// ad-display spec "Interstitial Before Manual Station Add" (design.md
// ADR-6): fires on the CTA tap, before the form even opens — a no-op
// for premium (ServicioAnuncios' own entitlement gate).
await context.read<ServicioAnuncios>().intentarInterstitial();
if (!context.mounted) return;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -7,8 +7,8 @@ import '../../l10n/gen/app_localizations.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_icon.dart';
import '../../widgets/pluri_layout.dart';
import '../../widgets/pluri_onboarding_dialog.dart';
import '../../widgets/pluri_push_scaffold.dart';
import '../pantalla_tutorial_ayuda.dart';
/// APLICACIÓN group · "Info" (design ADR-3). Body moved verbatim from the
/// former `_SeccionInfo` in `pantalla_ajustes.dart`. Unlike the other four
@@ -86,7 +86,15 @@ class _CuerpoInfo extends StatelessWidget {
title: Text(AppLocalizations.of(ctx).helpTitle),
subtitle: Text(AppLocalizations.of(ctx).helpSubtitle),
trailing: const Icon(Icons.chevron_right_rounded),
onTap: () => PluriOnboardingDialog.mostrar(ctx),
onTap:
() => Navigator.of(ctx).push(
MaterialPageRoute<void>(
builder:
(_) => const PantallaTutorialAyuda(
primerArranque: false,
),
),
),
),
ListTile(
contentPadding: EdgeInsets.zero,
+30 -7
View File
@@ -93,6 +93,14 @@ class FilaAjuste extends StatelessWidget {
/// "no accent" — the icon renders exactly as before.
final Color? iconColor;
/// Issue 5 (feedback-pruebas): caps how much width the trailing current
/// value can claim. `ListTile` gives `trailing` as much width as it wants
/// before handing the title whatever is left — an unbounded value (e.g. a
/// real, arbitrarily long station name for "Emisora preferida") could
/// squeeze the title down to almost nothing, forcing it to wrap across
/// several lines that then get cut short by the row's fixed height.
static const _anchoMaximoValor = 108.0;
@override
Widget build(BuildContext context) {
final type = context.pluriType;
@@ -106,17 +114,32 @@ class FilaAjuste extends StatelessWidget {
// 14px/w700 (t4 line 514); cardTitle is 14.5/w700 — a one-off
// override, not a new PluriWaveTypography style (mirrors the
// precedent set for the ringing screen's station name, audit 9.7).
title: Text(titulo, style: type.cardTitle.copyWith(fontSize: 14)),
// Issue 5: constrained to one line, ellipsizing instead of wrapping —
// labels must wrap as little as possible and never render visibly
// truncated (a multi-line wrap inside this fixed-height row cuts the
// last line short, which reads as broken, not as intentional).
title: Text(
titulo,
style: type.cardTitle.copyWith(fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (valorActual != null) ...[
Text(
valorActual,
// bodyStrong is already 13/w600, matching the prototype's row
// value spec exactly — only the colour needs overriding.
style: type.bodyStrong.copyWith(
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _anchoMaximoValor),
child: Text(
valorActual,
// bodyStrong is already 13/w600, matching the prototype's row
// value spec exactly — only the colour needs overriding.
style: type.bodyStrong.copyWith(
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
),
),
const SizedBox(width: 6),
+22 -3
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_ecualizador.dart';
import '../estado/estado_entitlement.dart';
import '../estado/estado_grabacion.dart';
import '../estado/estado_idioma.dart';
import '../estado/estado_radio.dart';
@@ -10,6 +11,7 @@ import '../l10n/gen/app_localizations.dart';
import '../modelos/archivo_grabacion.dart';
import '../modelos/emisora.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/hoja_premium.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
import '../widgets/pluri_root_header.dart';
@@ -99,6 +101,9 @@ class _AjustesContent extends StatelessWidget {
final idioma = context.select<EstadoIdioma, Locale?>(
(e) => e.localeSeleccionado,
);
final esPremium = context.select<EstadoEntitlement, bool>(
(e) => e.esPremium,
);
return Column(
children: [
@@ -147,7 +152,9 @@ class _AjustesContent extends StatelessWidget {
),
],
),
const SizedBox(height: 12),
// Issue 3 (feedback-pruebas): t4:523/534/541 all draw a 16px gap
// between these opaque, stacked settings groups -- not 12.
const SizedBox(height: 16, key: ValueKey('ajustes-group-gap-1')),
GrupoAjustes(
titulo: l10n.settingsGroupStationsTitle,
filas: [
@@ -202,7 +209,7 @@ class _AjustesContent extends StatelessWidget {
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 16, key: ValueKey('ajustes-group-gap-2')),
GrupoAjustes(
titulo: l10n.settingsGroupRecordingsTitle,
filas: [
@@ -250,10 +257,22 @@ class _AjustesContent extends StatelessWidget {
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 16, key: ValueKey('ajustes-group-gap-3')),
GrupoAjustes(
titulo: l10n.settingsGroupApplicationTitle,
filas: [
// freemium-gating spec "Settings always shows a premium row":
// a persistent buy row (free tier) or a premium-active state
// with restore access (premium tier) — both open the same
// paywall sheet, which adapts its own body to the tier.
FilaAjuste(
key: const ValueKey('ajustes-fila-premium'),
icon: Icons.workspace_premium_rounded,
iconColor: PluriWaveTokens.brand,
titulo: l10n.funcionPremium,
valor: esPremium ? l10n.equalizerActive : null,
onTap: () => mostrarHojaPremium(context),
),
FilaAjuste(
icon: Icons.language_rounded,
titulo: l10n.languageSectionTitle,
+28 -21
View File
@@ -225,25 +225,6 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
tokens: tokens,
),
const SizedBox(height: 22),
// Audit 9.4 (t4 line 419): "Lunes, 3 de agosto" between
// the pill and the hero time -- never rendered before.
// Purely additive: a new sibling Text, touching neither
// the pill above nor the hero time below.
Text(
fechaLargaConDiaSemana(
Localizations.localeOf(context).toString(),
DateTime.now(),
),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 6),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
@@ -261,6 +242,28 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
),
),
const SizedBox(height: 6),
// Audit 9.4: the date line goes BELOW the hero time. The
// prototype's order is pill (t4:415-416) -> 7:30 at 88px
// (t4:417) -> "Lunes, 3 de agosto" at 14px (t4:419). An
// earlier pass placed it between the pill and the time
// and cited "t4 line 419" for it — that line number is
// where the date SITS in the source, which is precisely
// why it comes last, not first.
Text(
fechaLargaConDiaSemana(
Localizations.localeOf(context).toString(),
DateTime.now(),
),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 6),
Text(
localizedAlarmName(l10n, alarma.nombre),
textAlign: TextAlign.center,
@@ -339,14 +342,18 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
],
),
),
const SizedBox(height: 10),
// Issue 3 (feedback-pruebas): t4:427 wraps POSPONER's
// eyebrow, the snooze tiles and Stop in a `gap:12` flex
// column -- the same 12 on both sides, not the 10/14 pair
// this used to carry.
const SizedBox(height: 12),
_FilaSnoozeFija(
alarma: alarma,
l10n: l10n,
tokens: tokens,
onPosponer: _posponer,
),
const SizedBox(height: 14),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
+282 -54
View File
@@ -9,15 +9,18 @@ import '../l10n/app_localizations_ext.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/alarma_musical.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_anuncios.dart';
import '../servicios/servicio_programacion_alarmas.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/editor_hora_inline.dart';
import '../widgets/hoja_premium.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
import '../widgets/pluri_root_header.dart';
import '../widgets/pluri_sleep_timer_sheet.dart';
import 'pantalla_diagnostico_alarmas.dart';
import 'pantalla_vacaciones.dart';
class PantallaAlarmas extends StatelessWidget {
@@ -104,6 +107,21 @@ class PantallaAlarmas extends StatelessWidget {
BuildContext context, {
AlarmaMusical? alarma,
}) async {
// ADR-6 ordering (design.md): for a genuinely NEW alarm (no [alarma]),
// the cap-check + maybe-interstitial happen HERE, before the editor
// ever opens — "puedeCrearAlarma -> if false, show the limit message
// and no ad; if true, maybe-interstitial, then open the editor".
// Editing an existing alarm skips both checks entirely: it is never
// capped and never triggers the interstitial.
if (alarma == null) {
final estado = context.read<EstadoAlarmas>();
if (!estado.puedeCrearAlarma()) {
_mostrarLimiteAlarmas(context);
return;
}
await context.read<ServicioAnuncios>().intentarInterstitial();
if (!context.mounted) return;
}
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
@@ -112,6 +130,22 @@ class PantallaAlarmas extends StatelessWidget {
builder: (_) => _EditorAlarmaSheet(alarma: alarma),
);
}
/// Freemium-gating spec "Alarm Cap UX Never Bare-Jumps To Paywall": an
/// explanatory message with a SECONDARY unlock action — never a direct
/// paywall navigation as the sole response to hitting the cap.
void _mostrarLimiteAlarmas(BuildContext context) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.limiteAlarmasAlcanzado),
action: SnackBarAction(
label: l10n.desbloquearPremium,
onPressed: () => mostrarHojaPremium(context),
),
),
);
}
}
class _PanelProximaAlarma extends StatelessWidget {
@@ -265,6 +299,40 @@ class _TarjetaAlarma extends StatelessWidget {
? l10n.noStationUseInternalSound
: localizedStationName(l10n, alarma.emisora!.nombre);
// Item 5: surfaces the genuinely useful fields that already exist on
// the model, WITHOUT turning the row into clutter -- each is shown
// only when it is a meaningful deviation from the common case.
// Mirrors EXACTLY the pause predicate `impactoDeRango`/
// `ServicioProgramacionAlarmas` already use
// (`!sonarEnVacaciones` while `activa`), gated by whether a vacation
// range is CURRENTLY active -- an alarm configured to pause but with
// no active range right now is not actually paused by anything yet.
final pausadaPorVacaciones =
alarma.activa &&
!alarma.sonarEnVacaciones &&
estado.rangoVacacionesActivo() != null;
final detalles = <String>[
if (alarma.fadeInSegundos > 0)
l10n.alarmFadeInLabel(alarma.fadeInSegundos),
if ((alarma.volumen * 100).round() != 85)
'${(alarma.volumen * 100).round()}%',
if (pausadaPorVacaciones) l10n.alarmCardVacationPausedBadge,
];
// fix/alarmas-fallos-silenciosos: `ultimaExcepcionPara` existed but was
// never read from any screen, so a failed native scheduling attempt (main
// alarm, pre-notice, foreground service, or a post-boot reschedule)
// rendered exactly like a healthy alarm -- switched on, no visible sign
// anything was wrong. `_esValida` only ever treats `tipoSaltoSiguiente`
// as a real skip, so any OTHER tipo found here is a reliability failure,
// never a deliberate user action.
final ultimaExcepcion = estado.ultimaExcepcionPara(alarma.id);
final fallo =
ultimaExcepcion != null &&
ExcepcionAlarma.tiposFallo.contains(ultimaExcepcion.tipo)
? ultimaExcepcion
: null;
return Dismissible(
key: ValueKey('tarjeta-alarma-${alarma.id}'),
direction: DismissDirection.horizontal,
@@ -309,14 +377,20 @@ class _TarjetaAlarma extends StatelessWidget {
),
),
const SizedBox(width: 8),
Text(
_recurrenciaCorta(l10n, alarma),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
// Item 5: real day list can run longer than the
// old generic "Días" label -- Flexible+ellipsis
// keeps a long selection from overflowing the
// Row instead of clipping visibly.
Flexible(
child: Text(
_recurrenciaCorta(l10n, alarma),
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.5),
),
),
),
],
@@ -369,6 +443,35 @@ class _TarjetaAlarma extends StatelessWidget {
),
],
),
// Item 5: fade/volume/vacation-pause state, only
// when each is a genuinely useful deviation from
// the common case (see `detalles` above) -- a
// single compact line, not a badge per field.
if (detalles.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
detalles.join(' · '),
key: ValueKey(
'tarjeta-alarma-detalles-${alarma.id}',
),
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.55),
),
),
],
if (fallo != null) ...[
const SizedBox(height: 6),
_AvisoFalloProgramacion(
alarmaId: alarma.id,
esSoloPreaviso:
fallo.tipo == ExcepcionAlarma.tipoFalloPreaviso,
),
],
],
),
),
@@ -422,6 +525,82 @@ class _TarjetaAlarma extends StatelessWidget {
}
}
/// Per-alarm scheduling-failure notice (fix/alarmas-fallos-silenciosos):
/// renders INSIDE the card's own content, in its own small tap target --
/// the surrounding card `InkWell` (tap = edit) and `Dismissible` (swipe =
/// delete) are untouched; this inner `InkWell` only claims its own region
/// and pushes the diagnostics screen instead of opening the editor.
class _AvisoFalloProgramacion extends StatelessWidget {
const _AvisoFalloProgramacion({
required this.alarmaId,
required this.esSoloPreaviso,
});
final String alarmaId;
/// True when only the pre-notice reminder failed (the alarm itself is
/// still scheduled) -- the user's reported symptom explicitly called out
/// a missing pre-notice as distinct from the alarm never ringing at all,
/// so the message must not conflate the two.
final bool esSoloPreaviso;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final color = Theme.of(context).colorScheme.error;
return Material(
type: MaterialType.transparency,
child: InkWell(
key: ValueKey('tarjeta-alarma-fallo-$alarmaId'),
borderRadius: BorderRadius.circular(8),
onTap: () => _abrirDiagnostico(context),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 15, color: color),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
esSoloPreaviso
? l10n.alarmCardPreNoticeFailedMessage
: l10n.alarmCardSchedulingFailedMessage,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
),
const SizedBox(height: 2),
Text(
l10n.androidReliabilityReview,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: color,
decoration: TextDecoration.underline,
),
),
],
),
),
],
),
),
),
);
}
void _abrirDiagnostico(BuildContext context) {
PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas());
}
}
/// Swipe-to-delete reveal, shown on both sides so either swipe direction
/// works regardless of locale text direction.
class _FondoSwipeEliminarAlarma extends StatelessWidget {
@@ -1040,8 +1219,34 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
sonidoInterno: _sonidoInterno,
activa: true,
);
await estado.guardarAlarma(alarma);
if (mounted) Navigator.pop(context);
// The cap-check + interstitial already ran in `PantallaAlarmas
// ._abrirEditor` BEFORE this sheet ever opened (ADR-6 ordering: "then
// open the editor"). This is only the defense-in-depth backstop against
// the state-layer choke point — e.g. a 2nd device created alarms while
// this sheet was open — the true authority is `guardarAlarma` itself.
final resultado = await estado.guardarAlarma(alarma);
if (!mounted) return;
if (resultado == ResultadoGuardarAlarma.limiteAlcanzado) {
_mostrarLimiteAlarmas(context);
return;
}
Navigator.pop(context);
}
/// Freemium-gating spec "Alarm Cap UX Never Bare-Jumps To Paywall": an
/// explanatory message with a SECONDARY unlock action — never a direct
/// paywall navigation as the sole response to hitting the cap.
void _mostrarLimiteAlarmas(BuildContext context) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.limiteAlarmasAlcanzado),
action: SnackBarAction(
label: l10n.desbloquearPremium,
onPressed: () => mostrarHojaPremium(context),
),
),
);
}
List<Emisora> _favoritasConSeleccion(List<Emisora> favoritas) {
@@ -1291,6 +1496,16 @@ class _SelectorEmisoraSheetState extends State<_SelectorEmisoraSheet> {
}
}
/// Entry point into the full Android alarm-reliability diagnostics screen
/// (fix/alarmas-fiabilidad). Was a one-line `TextButton.icon` that only ever
/// surfaced 3 of the 6 fields `DiagnosticoAlarmasAndroid` collects (exact
/// alarms, notifications, full-screen intent) and cycled all three
/// permission requests on a single tap; the two most diagnostic fields --
/// battery-optimization exemption and the native pending-alarm count, which
/// tells the user whether the alarm ever reached the OS at all -- were
/// gathered and never shown. Now a tap target row (mirrors
/// `_PanelVacaciones`'s shape) pushing `PantallaDiagnosticoAlarmas`, which
/// shows every signal individually with its own fix action.
class _AccesoDiagnostico extends StatelessWidget {
const _AccesoDiagnostico({required this.estado});
@@ -1299,47 +1514,37 @@ class _AccesoDiagnostico extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final diag = estado.diagnostico;
final exactStatus =
diag?.puedeProgramarExactas == true
? l10n.statusOk
: l10n.statusPending;
final notificationStatus =
diag?.notificacionesPermitidas == true
? l10n.statusOk
: l10n.statusPending;
final screenStatus =
diag?.puedeUsarPantallaCompleta == true
? l10n.statusOk
: l10n.statusPending;
return TextButton.icon(
icon: const _AssetIcon(
'assets/icons/alarmas/android_reliability.png',
size: 28,
),
label: Text(
diag == null
? l10n.androidReliabilityTitle
: l10n.androidReliabilityStatus(
exactStatus,
notificationStatus,
screenStatus,
final tokens = context.pluriTokens;
return PluriGlassSurface(
padding: EdgeInsets.zero,
child: Material(
type: MaterialType.transparency,
child: InkWell(
key: const ValueKey('diagnostico-alarmas-resumen'),
borderRadius: BorderRadius.circular(tokens.radiusMd),
onTap: () => _abrirDiagnostico(context),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const _AssetIcon(
'assets/icons/alarmas/android_reliability.png',
size: 28,
),
const SizedBox(width: 10),
Expanded(child: Text(l10n.androidReliabilityReview)),
const Icon(Icons.chevron_right_rounded),
],
),
),
),
),
onPressed: () async {
if (diag != null && !diag.puedeProgramarExactas) {
await estado.android.solicitarPermisoAlarmasExactas();
}
if (diag != null && !diag.notificacionesPermitidas) {
await estado.android.solicitarPermisoNotificaciones();
}
if (diag != null && !diag.puedeUsarPantallaCompleta) {
await estado.android.solicitarPermisoPantallaCompleta();
}
await estado.cargarDiagnostico();
},
);
}
void _abrirDiagnostico(BuildContext context) {
PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas());
}
}
/// Vacation summary row (alarm-vacation-ranges delta, WU8): replaces the old
@@ -1668,16 +1873,39 @@ String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
String _fechaCorta(AppLocalizations l10n, DateTime fecha) =>
fechaCortaLocalizada(l10n.localeName, fecha);
/// Audit 7.4 (t4:339): a compact recurrence label next to the alarm card's
/// giant time. Reuses the SAME generic labels the editor's own
/// `TipoProgramacionAlarma` `SegmentedButton` already shows (`oneTimeOption`
/// / `dailyOption` / `weekdaysOption`) rather than inventing a new, more
/// specific ARB string -- honest given the space (12px, next to a 34px
/// time) genuinely only fits a short word, not a full weekday list.
/// Audit 7.4 (t4:339) / item 5: a compact recurrence label next to the
/// alarm card's giant time. `diaria`/`unica` still show the SAME generic
/// labels the editor's own `TipoProgramacionAlarma` `SegmentedButton`
/// already uses (`dailyOption`/`oneTimeOption`) -- both are already fully
/// specific (there is nothing more concrete to say than "every day"/"just
/// once"). `diasSemana` now renders the alarm's ACTUAL configured days
/// (e.g. "Lun, Mié, Vie") instead of the generic `weekdaysOption` ("Días"),
/// reusing [_weekdayShort] (the SAME per-day abbreviation the editor's own
/// day-picker circles already use) -- no new ARB keys, no second
/// formatting scheme, and the resulting Text is wrapped in a
/// `Flexible`+ellipsis at the call site so a long selection never
/// overflows the row.
String _recurrenciaCorta(AppLocalizations l10n, AlarmaMusical alarma) {
return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.diaria => l10n.dailyOption,
TipoProgramacionAlarma.diasSemana => l10n.weekdaysOption,
TipoProgramacionAlarma.diasSemana => _diasSemanaCorto(
l10n,
alarma.diasSemana,
),
TipoProgramacionAlarma.unica => l10n.oneTimeOption,
};
}
/// The real, ordered day abbreviations for a `diasSemana` alarm (item 5),
/// e.g. "Lun, Mié, Vie". [diasSemana] is re-sorted defensively (the editor
/// always persists it sorted, but this does not rely on that). Falls back
/// to the generic [AppLocalizations.weekdaysOption] label when
/// [diasSemana] is empty -- the editor already blocks saving an empty
/// selection in this mode, but a corrupt/legacy persisted record could
/// still reach here, and showing nothing would be worse than the old
/// generic label.
String _diasSemanaCorto(AppLocalizations l10n, List<int> diasSemana) {
if (diasSemana.isEmpty) return l10n.weekdaysOption;
final ordenados = [...diasSemana]..sort();
return ordenados.map((dia) => _weekdayShort(l10n, dia)).join(', ');
}
+31 -3
View File
@@ -543,8 +543,17 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
if (estado.cargando) {
// S5-R6: shimmer placeholders instead of a bare spinner, consistent
// with the loading pattern used by the home grid.
// Issue 3 (feedback-pruebas): row tier (12), not card tier (16) --
// these are background-less row placeholders, same tier as the real
// results below; the top inset is the standard section gap rather
// than the horizontal constant reused for a vertical axis.
return Padding(
padding: const EdgeInsets.all(PluriLayout.horizontal),
padding: const EdgeInsets.fromLTRB(
PluriLayout.rowHorizontal,
PluriLayout.sectionGap,
PluriLayout.rowHorizontal,
PluriLayout.rowHorizontal,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -579,8 +588,17 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
// across the app -- favorites, the discovery grid -- which this
// item does not touch).
final query = _controller.text.trim();
// Issue 3 (feedback-pruebas): this card-tier state had NO top gap at
// all against the filter row above it -- the standard section gap
// now matches the other two mutually-exclusive results-area states
// (loading, populated) above.
return Padding(
padding: const EdgeInsets.symmetric(horizontal: PluriLayout.horizontal),
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
PluriLayout.sectionGap,
PluriLayout.horizontal,
0,
),
child: _TarjetaSinResultados(
titulo:
sinFiltros
@@ -616,7 +634,17 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(PluriLayout.horizontal),
// Issue 3 (feedback-pruebas): row tier (12), not card tier (16) --
// `FilaEmisoraPlana` rows are documented as "flat, background-less"
// (audit 6.5) but this padding never got updated to match when Tier 1
// introduced the 3-tier scale. The top inset is the standard section
// gap, not the horizontal constant reused for a vertical axis.
padding: const EdgeInsets.fromLTRB(
PluriLayout.rowHorizontal,
PluriLayout.sectionGap,
PluriLayout.rowHorizontal,
PluriLayout.rowHorizontal,
),
itemCount: total,
itemBuilder: (context, i) {
if (i >= resultados.length) {
@@ -0,0 +1,293 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
import '../l10n/gen/app_localizations.dart';
import '../servicios/diagnostico_alarmas.dart';
import '../servicios/servicio_alarmas_android.dart';
import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
/// Full Android alarm-reliability diagnostics screen (fix/alarmas-fiabilidad).
///
/// Replaces the old one-line `_AccesoDiagnostico` button in
/// `pantalla_alarmas.dart`, which only ever surfaced 3 of the 6 fields
/// `DiagnosticoAlarmasAndroid` collects. This screen shows all five
/// diagnosable signals with a clear ok/needs-attention state, a "Fix this"
/// action that opens the right system settings screen for each failing one,
/// plus manufacturer-specific guidance for vendors known to require manually
/// enabling Autostart.
class PantallaDiagnosticoAlarmas extends StatelessWidget {
const PantallaDiagnosticoAlarmas({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoAlarmas>();
final diag = estado.diagnostico;
return PluriPushScaffold(
title: l10n.androidReliabilityTitle,
body:
diag == null
? ListView(
padding: PluriLayout.pageContentPadding,
children: [
PluriGlassSurface(
child: Text(l10n.alarmDiagnosticsUnavailableHint),
),
],
)
: _CuerpoDiagnostico(estado: estado, diag: diag),
);
}
}
class _CuerpoDiagnostico extends StatelessWidget {
const _CuerpoDiagnostico({required this.estado, required this.diag});
final EstadoAlarmas estado;
final DiagnosticoAlarmasAndroid diag;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final items = construirItemsDiagnosticoAlarmas(
diagnostico: diag,
hayAlarmasActivas: estado.alarmas.any((alarma) => alarma.activa),
);
final mostrarAutostart = fabricanteRequiereGuiaAutostart(diag.fabricante);
return ListView(
padding: PluriLayout.pageContentPadding,
children: [
for (final item in items) ...[
_FilaDiagnostico(item: item, estado: estado, diag: diag),
const SizedBox(height: 10),
],
const SizedBox(height: 6),
PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_FilaInformativa(
titulo: l10n.alarmDiagnosticsManufacturerLabel,
valor: diag.fabricante,
),
const SizedBox(height: 10),
_FilaInformativa(
titulo: l10n.alarmDiagnosticsSdkLabel,
valor: diag.versionSdk.toString(),
),
],
),
),
if (mostrarAutostart) ...[
const SizedBox(height: 16),
_GuiaAutostart(fabricante: diag.fabricante),
],
],
);
}
}
class _FilaDiagnostico extends StatelessWidget {
const _FilaDiagnostico({
required this.item,
required this.estado,
required this.diag,
});
final ItemDiagnosticoAlarma item;
final EstadoAlarmas estado;
final DiagnosticoAlarmasAndroid diag;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final tokens = context.pluriTokens;
final ok = item.estado == EstadoSenalDiagnostico.ok;
final color = ok ? tokens.liveGreen : Theme.of(context).colorScheme.error;
final esConteoNativo =
item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes;
return PluriGlassSurface(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
ok ? Icons.check_circle_rounded : Icons.warning_amber_rounded,
color: color,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
_tituloSenal(l10n, item.senal),
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
Text(
ok
? l10n.statusOk
: l10n.alarmDiagnosticsNeedsAttentionStatus,
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 4),
if (esConteoNativo) ...[
Text(
l10n.alarmDiagnosticsNativeCountValue(
diag.alarmasNativasPendientes,
),
),
if (!ok) ...[
const SizedBox(height: 4),
Text(l10n.alarmDiagnosticsNativeCountAttentionHint),
],
] else
Text(_hintSenal(l10n, item.senal)),
if (!ok && item.accion != AccionDiagnosticoAlarma.ninguna) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton(
onPressed: () => _ejecutarAccion(context, l10n),
child: Text(l10n.alarmDiagnosticsFixAction),
),
),
],
],
),
),
],
),
);
}
/// Runs the system action for [item.accion] and reloads the diagnostic
/// snapshot. Never throws across the widget boundary: every underlying
/// `PuertoAlarmasAndroid` call already reports `false` instead (native side
/// catches any intent-resolution failure), and a `false` here surfaces a
/// calm SnackBar instead of leaving the tap looking like a no-op.
Future<void> _ejecutarAccion(
BuildContext context,
AppLocalizations l10n,
) async {
final resuelto = switch (item.accion) {
AccionDiagnosticoAlarma.abrirAlarmasExactas =>
await estado.android.solicitarPermisoAlarmasExactas(),
AccionDiagnosticoAlarma.abrirNotificaciones =>
await estado.android.abrirConfiguracionNotificaciones(),
AccionDiagnosticoAlarma.abrirOptimizacionBateria =>
await estado.android.solicitarExencionBateria(),
AccionDiagnosticoAlarma.abrirPantallaCompleta =>
await estado.android.solicitarPermisoPantallaCompleta(),
AccionDiagnosticoAlarma.ninguna => true,
};
await estado.cargarDiagnostico();
if (!resuelto && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.alarmDiagnosticsIntentUnavailable)),
);
}
}
}
String _tituloSenal(AppLocalizations l10n, SenalDiagnosticoAlarma senal) =>
switch (senal) {
SenalDiagnosticoAlarma.alarmasExactas =>
l10n.alarmDiagnosticsExactAlarmsTitle,
SenalDiagnosticoAlarma.notificaciones =>
l10n.alarmDiagnosticsNotificationsTitle,
SenalDiagnosticoAlarma.pantallaCompleta =>
l10n.alarmDiagnosticsFullScreenTitle,
SenalDiagnosticoAlarma.optimizacionBateria =>
l10n.alarmDiagnosticsBatteryTitle,
SenalDiagnosticoAlarma.alarmasNativasPendientes =>
l10n.alarmDiagnosticsNativeCountTitle,
};
/// Static one-line explanation per signal. `alarmasNativasPendientes` builds
/// its own dynamic body in [_FilaDiagnostico] instead (count + conditional
/// attention hint), so this branch is never actually rendered for it -- kept
/// only so the switch stays exhaustive over the enum.
String _hintSenal(
AppLocalizations l10n,
SenalDiagnosticoAlarma senal,
) => switch (senal) {
SenalDiagnosticoAlarma.alarmasExactas => l10n.alarmDiagnosticsExactAlarmsHint,
SenalDiagnosticoAlarma.notificaciones =>
l10n.alarmDiagnosticsNotificationsHint,
SenalDiagnosticoAlarma.pantallaCompleta =>
l10n.alarmDiagnosticsFullScreenHint,
SenalDiagnosticoAlarma.optimizacionBateria =>
l10n.alarmDiagnosticsBatteryHint,
SenalDiagnosticoAlarma.alarmasNativasPendientes => '',
};
class _FilaInformativa extends StatelessWidget {
const _FilaInformativa({required this.titulo, required this.valor});
final String titulo;
final String valor;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(titulo),
Text(valor, style: const TextStyle(fontWeight: FontWeight.w700)),
],
);
}
}
/// Manufacturer-specific autostart explanation (fix/alarmas-fiabilidad item
/// 3). Deliberately never claims the app can detect or grant this setting --
/// there is no public API for it, so this is explanation only, never an
/// action button.
class _GuiaAutostart extends StatelessWidget {
const _GuiaAutostart({required this.fabricante});
final String fabricante;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final tokens = context.pluriTokens;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline_rounded, color: tokens.warmCoral),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.alarmDiagnosticsAutostartTitle,
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
],
),
const SizedBox(height: 8),
Text(l10n.alarmDiagnosticsAutostartBody(fabricante)),
],
),
);
}
}
+70 -21
View File
@@ -6,6 +6,7 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../servicios/servicio_anuncios.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/fila_emisora_plana.dart';
import '../widgets/pluri_icon.dart';
@@ -38,6 +39,11 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
String? _grupoSeleccionadoId;
Future<void> _abrirFormularioEmisoraPersonalizada() async {
// ad-display spec "Interstitial Before Manual Station Add" (design.md
// ADR-6): fires on the CTA tap, before the form even opens — a no-op
// for premium (ServicioAnuncios' own entitlement gate).
await context.read<ServicioAnuncios>().intentarInterstitial();
if (!mounted) return;
await showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -174,12 +180,18 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
return ReorderableListView(
buildDefaultDragHandles: false,
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
4,
PluriLayout.horizontal,
PluriLayout.bottomChromeInset,
),
// Issue 3 (feedback-pruebas): zero horizontal here, matching every
// other root's PluriLayout.pageListPadding convention (Alarmas,
// Ajustes, and this screen's OWN empty-state branch above).
// ReorderableListView.padding wraps header/children/footer UNIFORMLY,
// so a single horizontal value here can never be simultaneously right
// for PluriRootHeader (self-padded, wants none), the reorderable rows
// (want row tier, applied per item below) and the footer CTA (wants
// card tier, applied on the footer's own Padding below). The previous
// `PluriLayout.horizontal` doubled up on top of PluriRootHeader's own
// internal inset, pushing "Favorites" in by 36px instead of the 20px
// every other root uses for its title.
padding: const EdgeInsets.only(bottom: PluriLayout.bottomChromeInset),
header: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
@@ -224,17 +236,39 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
],
),
const SizedBox(height: 12),
_FilaChipsGrupos(
grupos: gruposVisibles,
favoritos: favoritos,
seleccionado: seleccionEfectiva,
onSeleccionar: (id) => setState(() => _grupoSeleccionadoId = id),
// Issue 3 (feedback-pruebas): t4:218 draws this chip strip at
// title-tier (20px) horizontal inset, directly on the page
// background -- it now needs its OWN inset since the list's
// padding no longer supplies one.
Padding(
padding: const EdgeInsets.symmetric(
horizontal: PluriLayout.titleHorizontal,
),
child: _FilaChipsGrupos(
grupos: gruposVisibles,
favoritos: favoritos,
seleccionado: seleccionEfectiva,
onSeleccionar:
(id) => setState(() => _grupoSeleccionadoId = id),
),
),
],
),
),
footer: Padding(
padding: const EdgeInsets.only(top: 4),
// Issue 3 (feedback-pruebas): card tier (16, matching every other
// screen's dashed CTA) now that the list's own padding no longer
// supplies it, plus t4:234's 8px gap above the CTA
// (PluriLayout.compactGap) instead of the previous unwired literal
// 4 -- the ONLY state of this screen with a nonzero top gap before
// its own content used a value that matched neither this screen's
// own empty-state branch nor the prototype.
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
PluriLayout.compactGap,
PluriLayout.horizontal,
0,
),
child: _CtaEmisoraPersonalizada(
onTap: _abrirFormularioEmisoraPersonalizada,
),
@@ -244,14 +278,24 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
_onReorder(filtrados, favoritos, oldIndex, newIndex),
children: [
for (var i = 0; i < filtrados.length; i++)
_FilaFavorito(
// Issue 3 (feedback-pruebas): row tier (12), not card tier -- the
// key moves to this wrapper (ReorderableListView identifies each
// child by its own top-level key) since FilaEmisoraPlana rows are
// documented (audit 4.3) as flat, background-less rows, the same
// tier Buscar's results list already uses for the same widget.
Padding(
key: ValueKey(filtrados[i].uuid),
index: i,
emisora: filtrados[i],
grupos: gruposVisibles,
grupoActual: gruposVisibles.firstWhere(
(g) => g.id == filtrados[i].grupoFavoritosId,
orElse: () => gruposVisibles.first,
padding: const EdgeInsets.symmetric(
horizontal: PluriLayout.rowHorizontal,
),
child: _FilaFavorito(
index: i,
emisora: filtrados[i],
grupos: gruposVisibles,
grupoActual: gruposVisibles.firstWhere(
(g) => g.id == filtrados[i].grupoFavoritosId,
orElse: () => gruposVisibles.first,
),
),
),
],
@@ -343,7 +387,6 @@ class _FilaChipsGrupos extends StatelessWidget {
class _FilaFavorito extends StatelessWidget {
const _FilaFavorito({
super.key,
required this.index,
required this.emisora,
required this.grupos,
@@ -465,7 +508,13 @@ class _FilaFavorito extends StatelessWidget {
context,
).colorScheme.onSurface.withValues(alpha: 0.45),
),
constraints: const BoxConstraints.tightFor(width: 38, height: 42),
// NO `constraints:` here. That property sizes the POPUP MENU, not
// the button — a tightFor(38x42) clipped every menu item down to
// its first letter ("M" for "Mover a lista", "E" for "Eliminar de
// favoritos"), which is what users actually saw. Constrain the
// tap target instead.
padding: EdgeInsets.zero,
iconSize: 20,
onSelected: (accion) {
if (accion == 'assign') _asignar(context);
if (accion == 'remove') _eliminar(context);
+6 -1
View File
@@ -251,7 +251,12 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
padding: PluriLayout.pageContentPadding,
children: [
_BarraDeAlmacenamiento(archivos: archivos),
const SizedBox(height: 12),
// Issue 3 (feedback-pruebas): t4:617 draws a 16px gap between
// the storage card and the rows below it, not 12.
const SizedBox(
height: 16,
key: ValueKey('grabaciones-storage-gap'),
),
if (snap.connectionState == ConnectionState.done &&
archivos.isEmpty)
PluriEmptyState(
+15 -20
View File
@@ -15,6 +15,7 @@ import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_sleep_timer_sheet.dart';
import '../widgets/pluri_station_art_fallback.dart';
import '../widgets/visualizador_audio.dart';
import 'pantalla_reproductor.dart';
@@ -568,9 +569,9 @@ class _ArteEscuchar extends StatelessWidget {
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmerCuadrado(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
errorWidget: (_, __, ___) => _iconoFallback(),
)
: _iconoFallback(theme),
: _iconoFallback(),
),
),
);
@@ -582,14 +583,11 @@ class _ArteEscuchar extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 36,
color: theme.colorScheme.onPrimaryContainer,
),
);
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
// bare `radio_rounded` icon — now the same shared fallback every other
// surface uses.
Widget _iconoFallback() =>
PluriStationArtFallback(seed: emisora.uuid, iconSize: 36);
}
/// The hero's transport row — favorite / EQ toggle / stop / play-pause
@@ -909,10 +907,10 @@ class _CeldaTusEmisoras extends StatelessWidget {
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
errorWidget: (_, __, ___) => _iconoFallback(),
);
}
return _iconoFallback(theme);
return _iconoFallback();
}
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
@@ -921,12 +919,9 @@ class _CeldaTusEmisoras extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 20,
color: theme.colorScheme.onPrimaryContainer,
),
);
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
// bare `radio_rounded` icon — now the same shared fallback every other
// surface uses.
Widget _iconoFallback() =>
PluriStationArtFallback(seed: emisora.uuid, iconSize: 20);
}
+6 -1
View File
@@ -134,7 +134,12 @@ class _PantallaPaisesState extends State<PantallaPaises> {
),
if (query.isEmpty) ...[
_seccionTusIdiomas(context, estado.paises, l10n),
const SizedBox(height: 16),
// Issue 3 (feedback-pruebas): t4:260 draws a 14px gap
// between "Tus idiomas" and "Todos", not 16.
const SizedBox(
height: 14,
key: ValueKey('paises-seccion-gap'),
),
_seccionTodos(context, estado.paises, l10n),
] else
_seccionTodos(context, paisesFiltrados, l10n),
+50 -20
View File
@@ -17,9 +17,11 @@ import '../tema/pluri_animate.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/ecualizador_widget.dart';
import '../widgets/hoja_premium.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_push_scaffold.dart';
import '../widgets/pluri_station_art_fallback.dart';
import '../widgets/visualizador_audio.dart';
/// WU14: restructured onto [PluriPushScaffold] (design ADR-2) — this screen
@@ -291,10 +293,10 @@ class _ArteReproductor extends StatelessWidget {
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
errorWidget: (_, __, ___) => _iconoFallback(),
)
else
_iconoFallback(theme),
_iconoFallback(),
if (cargando)
Container(
color: Colors.black45,
@@ -333,14 +335,11 @@ class _ArteReproductor extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 80,
color: theme.colorScheme.onPrimaryContainer,
),
);
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
// bare `radio_rounded` icon — now the same shared fallback every other
// surface uses.
Widget _iconoFallback() =>
PluriStationArtFallback(seed: emisora.uuid, iconSize: 80);
}
/// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the
@@ -599,6 +598,28 @@ class _GrabacionWidget extends StatelessWidget {
}
}
/// Freemium gate choke point at the UI layer (freemium-gating spec "Free
/// user starts a new recording"): all 3 record-start call sites route
/// through here. [ctx] is the picker sheet/dialog's own (short-lived)
/// context — closed FIRST (matching the pre-existing pop-then-done shape).
/// [contextExterno] is the screen's own longer-lived context, used ONLY to
/// react to the AUTHORITATIVE [EstadoGrabacion.iniciar] result: a
/// free-tier block opens the paywall there instead of a plain error, since
/// [ctx] is already gone by then.
Future<void> _iniciarGrabacionYCerrar(
BuildContext ctx,
BuildContext contextExterno,
EstadoGrabacion grabacion, {
Duration? duracion,
}) async {
final resultado = await grabacion.iniciar(duracion: duracion);
if (ctx.mounted) Navigator.pop(ctx);
if (resultado == ResultadoIniciarGrabacion.requierePremium &&
contextExterno.mounted) {
await mostrarHojaPremium(contextExterno);
}
}
void _mostrarDialogoGrabacion(BuildContext context) {
final grabacion = context.read<EstadoGrabacion>();
showModalBottomSheet(
@@ -628,10 +649,12 @@ class _GrabacionWidget extends StatelessWidget {
size: 18,
),
label: Text(AppLocalizations.of(ctx).indefiniteOption),
onPressed: () {
grabacion.iniciar();
Navigator.pop(ctx);
},
onPressed:
() => _iniciarGrabacionYCerrar(
ctx,
context,
grabacion,
),
),
for (final opcion in _opciones)
ActionChip(
@@ -644,10 +667,13 @@ class _GrabacionWidget extends StatelessWidget {
opcion.duracion.inSeconds,
),
),
onPressed: () {
grabacion.iniciar(duracion: opcion.duracion);
Navigator.pop(ctx);
},
onPressed:
() => _iniciarGrabacionYCerrar(
ctx,
context,
grabacion,
duracion: opcion.duracion,
),
),
ActionChip(
avatar: const Icon(Icons.tune_rounded, size: 18),
@@ -720,8 +746,12 @@ class _GrabacionWidget extends StatelessWidget {
seconds: segundos,
);
if (duracion <= Duration.zero) return;
grabacion.iniciar(duracion: duracion);
Navigator.pop(ctx);
_iniciarGrabacionYCerrar(
ctx,
context,
grabacion,
duracion: duracion,
);
},
child: Text(AppLocalizations.of(ctx).recordAction),
),
+363
View File
@@ -0,0 +1,363 @@
import 'package:flutter/material.dart';
import '../l10n/gen/app_localizations.dart';
import '../servicios/servicio_tutorial_ayuda.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
/// 9-screen help/tutorial carousel (mockup screens 5b..5h, reading order
/// 1..9). Reachable two ways:
/// - [mostrarSiProcede]: the genuine first-launch sequence in `app.dart`,
/// run once ever (both fresh installs AND existing installs upgrading to
/// this version), via [ServicioTutorialAyuda]'s plain one-time flag.
/// - Manually from Ajustes > Info > "Ayuda y tutorial", constructed directly
/// with `primerArranque: false`.
///
/// [primerArranque] only changes the LAST page's CTA label -- "Empezar a
/// escuchar" on a first-launch entry, "Cerrar" otherwise. Every other
/// behaviour (Saltar pops immediately, Siguiente advances) is identical
/// regardless of entry point; both cases simply pop the route when
/// finished, letting whatever screen is already mounted underneath show.
class PantallaTutorialAyuda extends StatefulWidget {
const PantallaTutorialAyuda({super.key, required this.primerArranque});
final bool primerArranque;
static final ServicioTutorialAyuda _servicio = ServicioTutorialAyuda();
static const int cantidadPaginas = 9;
/// Shows this carousel once, on the genuine first-launch sequence, then
/// never again. Mirrors `PantallaBienvenida.mostrarSiProcede`'s shape
/// (check-then-show-then-mark-seen).
static Future<void> mostrarSiProcede(BuildContext context) async {
if (!await _servicio.debeMostrarTutorial()) return;
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const PantallaTutorialAyuda(primerArranque: true),
),
);
await _servicio.marcarTutorialVisto();
}
@override
State<PantallaTutorialAyuda> createState() => _PantallaTutorialAyudaState();
}
class _PantallaTutorialAyudaState extends State<PantallaTutorialAyuda> {
final _controller = PageController();
int _pagina = 0;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
bool get _esUltimaPagina =>
_pagina == PantallaTutorialAyuda.cantidadPaginas - 1;
void _saltar() => Navigator.of(context).pop();
void _siguiente() {
if (_esUltimaPagina) {
Navigator.of(context).pop();
return;
}
_controller.nextPage(
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final paginas = _construirPaginas(l10n);
return Scaffold(
body: SafeArea(
child: Column(
children: [
SizedBox(
height: 48,
child: Align(
alignment: Alignment.centerRight,
child:
_esUltimaPagina
? null
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: TextButton(
onPressed: _saltar,
child: Text(l10n.tutorialSkipAction),
),
),
),
),
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: PantallaTutorialAyuda.cantidadPaginas,
onPageChanged: (indice) => setState(() => _pagina = indice),
itemBuilder:
(context, indice) =>
TarjetaPaginaTutorial(datos: paginas[indice]),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (
var i = 0;
i < PantallaTutorialAyuda.cantidadPaginas;
i++
)
PuntoIndicadorTutorial(activo: i == _pagina),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
child: SizedBox(
height: 58,
width: double.infinity,
child: FilledButton(
onPressed: _siguiente,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
child: Text(
_esUltimaPagina
? (widget.primerArranque
? l10n.welcomeCtaLabel
: l10n.closeAction)
: l10n.tutorialNextAction,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
),
),
),
],
),
),
);
}
List<DatosPaginaTutorial> _construirPaginas(AppLocalizations l10n) {
final t = PluriWaveTokens.dark;
return [
DatosPaginaTutorial(
icono: Icons.favorite_rounded,
color: t.electricMagenta,
titulo: l10n.tutorialPage1Headline,
cuerpo: l10n.tutorialPage1Body,
),
DatosPaginaTutorial(
icono: Icons.equalizer_rounded,
color: t.liveGreen,
titulo: l10n.tutorialPage2Headline,
cuerpo: l10n.tutorialPage2Body,
),
DatosPaginaTutorial(
icono: Icons.mic_rounded,
color: t.warmCoral,
titulo: l10n.tutorialPage3Headline,
cuerpo: l10n.tutorialPage3Body,
),
DatosPaginaTutorial(
icono: Icons.alarm_rounded,
color: t.offlineAccent,
titulo: l10n.tutorialPage4Headline,
cuerpo: l10n.tutorialPage4Body,
),
DatosPaginaTutorial(
icono: Icons.directions_car_rounded,
color: PluriWaveTokens.skyBlue,
titulo: l10n.tutorialPage5Headline,
cuerpo: l10n.tutorialPage5Body,
),
DatosPaginaTutorial(
icono: Icons.wifi_tethering_rounded,
color: t.liveGreen,
titulo: l10n.tutorialPage6Headline,
cuerpo: l10n.tutorialPage6Body,
),
DatosPaginaTutorial(
icono: Icons.snooze_rounded,
color: t.warmCoral,
titulo: l10n.tutorialPage7Headline,
cuerpo: l10n.tutorialPage7Body,
),
DatosPaginaTutorial(
icono: Icons.add_link_rounded,
color: PluriWaveTokens.skyBlue,
titulo: l10n.tutorialPage8Headline,
cuerpo: l10n.tutorialPage8Body,
),
DatosPaginaTutorial(
icono: Icons.check_circle_rounded,
color: t.electricMagenta,
titulo: l10n.tutorialPage9Headline,
// Last page only: the "watch it again" reminder banner (design
// ADR text, mockup screen 5h). No progress-dot advancement beyond
// this page -- it is the final one.
bannerCuerpo: l10n.tutorialPage9BannerBody,
),
];
}
}
/// Content for a single carousel page: icon badge, headline, body, and --
/// only on the last page -- the "watch it again" reminder banner.
class DatosPaginaTutorial {
const DatosPaginaTutorial({
required this.icono,
required this.color,
required this.titulo,
this.cuerpo,
this.bannerCuerpo,
});
final IconData icono;
final Color color;
final String titulo;
/// Body copy below the headline. `null` on the last page (mockup screen
/// 5h), which shows only the headline plus [bannerCuerpo] -- no separate
/// body paragraph.
final String? cuerpo;
final String? bannerCuerpo;
}
/// One carousel page: a 150x150 rounded-square icon badge, headline, body,
/// and -- when [DatosPaginaTutorial.bannerCuerpo] is set -- the reminder
/// banner. Public (not `_TarjetaPagina`) so tests can target pages by type,
/// same reason `FilaCaracteristicaBienvenida` is public.
class TarjetaPaginaTutorial extends StatelessWidget {
const TarjetaPaginaTutorial({super.key, required this.datos});
final DatosPaginaTutorial datos;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 150,
height: 150,
decoration: BoxDecoration(
color: datos.color.withValues(alpha: 0.13),
borderRadius: BorderRadius.circular(32),
),
child: Icon(datos.icono, size: 72, color: datos.color),
),
const SizedBox(height: 32),
Text(
datos.titulo,
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.4,
height: 1.15,
),
),
if (datos.cuerpo case final cuerpo?) ...[
const SizedBox(height: 12),
Text(
cuerpo,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 14,
height: 1.5,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
if (datos.bannerCuerpo case final bannerCuerpo?) ...[
const SizedBox(height: 24),
_BannerRecordatorio(texto: bannerCuerpo),
],
],
),
);
}
}
class _BannerRecordatorio extends StatelessWidget {
const _BannerRecordatorio({required this.texto});
final String texto;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white.withValues(alpha: 0.1)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.info_outline_rounded,
size: 20,
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
const SizedBox(width: 10),
Expanded(
child: Text(
texto,
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 12.5,
height: 1.4,
color: theme.colorScheme.onSurface.withValues(alpha: 0.72),
),
),
),
],
),
);
}
}
/// One dot in the 9-dot progress indicator: wider and teal when [activo],
/// small and translucent otherwise. Public so tests can assert "exactly 9
/// dots" via `find.byType`.
class PuntoIndicadorTutorial extends StatelessWidget {
const PuntoIndicadorTutorial({super.key, required this.activo});
final bool activo;
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.symmetric(horizontal: 3),
width: activo ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color:
activo ? t.electricMagenta : Colors.white.withValues(alpha: 0.24),
borderRadius: BorderRadius.circular(4),
),
);
}
}
+333 -115
View File
@@ -8,6 +8,7 @@ import '../l10n/gen/app_localizations.dart';
import '../modelos/alarma_musical.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/hoja_premium.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
@@ -82,13 +83,78 @@ class PantallaVacaciones extends StatelessWidget {
);
}
Future<void> _abrirAlta(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => const _EditorVacacionesSheet(),
Future<void> _abrirAlta(BuildContext context) =>
_abrirEditorVacaciones(context);
}
/// Issue 1 (feedback-pruebas): the ONE sheet-opener both the header/CTA
/// "create" entry points and every range's own "tap to edit" affordance call
/// -- passing [rango] switches the sheet from create to edit mode (mirrors
/// `pantalla_alarmas.dart`'s `_abrirEditor`/`_EditorAlarmaSheet` split).
Future<void> _abrirEditorVacaciones(
BuildContext context, {
RangoVacaciones? rango,
}) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => _EditorVacacionesSheet(rango: rango),
);
}
/// Issue 1 (feedback-pruebas): mirrors `pantalla_alarmas.dart`'s
/// `_confirmarEliminarAlarma` exactly -- same AlertDialog shape, same
/// generic delete/cancel actions, only the copy is vacation-specific.
Future<bool> _confirmarEliminarRango(
BuildContext context,
AppLocalizations l10n,
) async {
final confirmado = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
title: Text(l10n.vacationDeleteConfirmTitle),
content: Text(l10n.vacationDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l10n.deleteAction),
),
],
),
);
return confirmado ?? false;
}
/// Swipe-to-delete reveal shown on both sides, mirroring
/// `pantalla_alarmas.dart`'s `_FondoSwipeEliminarAlarma` -- duplicated
/// rather than shared, matching this codebase's own precedent for tiny
/// per-screen chrome (see this file's `_DashedBorderPainter` doc comment).
class _FondoSwipeEliminarRango extends StatelessWidget {
const _FondoSwipeEliminarRango({required this.alignment});
final Alignment alignment;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
return Container(
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(tokens.radiusMd),
),
child: Icon(
Icons.delete_outline_rounded,
color: Theme.of(context).colorScheme.onError,
),
);
}
}
@@ -242,46 +308,81 @@ class _HeroRangoActivo extends StatelessWidget {
final diasRestantes = rango.finDia.difference(hoyDia).inDays;
final impacto = estado.impactoDeRango(rango);
final type = context.pluriType;
final tokens = context.pluriTokens;
return PluriGlassSurface(
glowColor: context.pluriTokens.electricMagenta.withValues(alpha: 0.24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedVacationName(l10n, rango.nombre),
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 4),
// Item 21 / audit 9b.4 (t4:454): the "active now" caption is a
// teal eyebrow, not default body text.
Text(
l10n.vacationSummaryActiveCountdown(diasRestantes),
style: type.eyebrowLabel.copyWith(color: PluriWaveTokens.brand),
),
const SizedBox(height: 12),
// Item 21 / audit 9b.4 (t4:451-462): the screen's signature
// element is a start/end date pair joined by a gradient rule —
// the prototype never draws a determinate progress bar here.
_ParFechasVacaciones(
inicio: rango.inicioDia,
fin: rango.finDia,
destacado: true,
reglaKey: const ValueKey('vacaciones-regla-activo'),
),
if (impacto.pausadas.isNotEmpty) ...[
const SizedBox(height: 12),
Text(l10n.vacationImpactPausedLabel(_horas(impacto.pausadas))),
],
if (impacto.noAfectadas.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
l10n.vacationImpactContinuesLabel(_horas(impacto.noAfectadas)),
// Issue 1 (feedback-pruebas): a range starts ACTIVE the instant it's
// created (today .. today+2), so this hero is the ONLY place a
// brand-new range ever renders until it either becomes "programado" in
// the future or "pasado" once it ends. Without tap/swipe here, the
// very first range a user creates could never be fixed or removed.
return Dismissible(
key: ValueKey('vacaciones-tarjeta-${rango.id}'),
direction: DismissDirection.horizontal,
background: const _FondoSwipeEliminarRango(
alignment: Alignment.centerLeft,
),
secondaryBackground: const _FondoSwipeEliminarRango(
alignment: Alignment.centerRight,
),
confirmDismiss: (_) => _confirmarEliminarRango(context, l10n),
onDismissed: (_) => estado.eliminarRangoVacaciones(rango.id),
child: PluriGlassSurface(
glowColor: tokens.electricMagenta.withValues(alpha: 0.24),
padding: EdgeInsets.zero,
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(tokens.radiusMd),
onTap: () => _abrirEditorVacaciones(context, rango: rango),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedVacationName(l10n, rango.nombre),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
// Item 21 / audit 9b.4 (t4:454): the "active now" caption is a
// teal eyebrow, not default body text.
Text(
l10n.vacationSummaryActiveCountdown(diasRestantes),
style: type.eyebrowLabel.copyWith(
color: PluriWaveTokens.brand,
),
),
const SizedBox(height: 12),
// Item 21 / audit 9b.4 (t4:451-462): the screen's signature
// element is a start/end date pair joined by a gradient rule —
// the prototype never draws a determinate progress bar here.
_ParFechasVacaciones(
inicio: rango.inicioDia,
fin: rango.finDia,
destacado: true,
reglaKey: const ValueKey('vacaciones-regla-activo'),
),
if (impacto.pausadas.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
l10n.vacationImpactPausedLabel(_horas(impacto.pausadas)),
),
],
if (impacto.noAfectadas.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
l10n.vacationImpactContinuesLabel(
_horas(impacto.noAfectadas),
),
),
],
],
),
),
],
],
),
),
),
);
}
@@ -452,61 +553,87 @@ class _TarjetaRangoVacaciones extends StatelessWidget {
final l10n = AppLocalizations.of(context);
final t = context.pluriTokens;
final type = context.pluriType;
return DecoratedBox(
decoration: BoxDecoration(
color: t.listSurface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
final estado = context.read<EstadoAlarmas>();
// Issue 1 (feedback-pruebas): tap = edit, swipe = delete (with
// confirmation) — same interaction `pantalla_alarmas.dart`'s
// `_TarjetaAlarma` already uses for the same concept, applied here to
// BOTH the "programados" and "pasados" sections (this card backs both).
return Dismissible(
key: ValueKey('vacaciones-tarjeta-${rango.id}'),
direction: DismissDirection.horizontal,
background: const _FondoSwipeEliminarRango(
alignment: Alignment.centerLeft,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (encabezado != null) ...[
Text(
encabezado!,
style: type.eyebrowLabel.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(height: 11),
],
_ParFechasVacaciones(
inicio: rango.inicioDia,
fin: rango.finDia,
destacado: false,
reglaKey: ValueKey('vacaciones-regla-${rango.id}'),
),
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.label_outline_rounded,
size: 17,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
const SizedBox(width: 8),
Expanded(
child: Text(
localizedVacationName(l10n, rango.nombre),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
secondaryBackground: const _FondoSwipeEliminarRango(
alignment: Alignment.centerRight,
),
confirmDismiss: (_) => _confirmarEliminarRango(context, l10n),
onDismissed: (_) => estado.eliminarRangoVacaciones(rango.id),
child: DecoratedBox(
decoration: BoxDecoration(
color: t.listSurface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => _abrirEditorVacaciones(context, rango: rango),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (encabezado != null) ...[
Text(
encabezado!,
style: type.eyebrowLabel.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
const SizedBox(height: 11),
],
_ParFechasVacaciones(
inicio: rango.inicioDia,
fin: rango.finDia,
destacado: false,
reglaKey: ValueKey('vacaciones-regla-${rango.id}'),
),
),
],
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.label_outline_rounded,
size: 17,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
const SizedBox(width: 8),
Expanded(
child: Text(
localizedVacationName(l10n, rango.nombre),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
],
),
),
),
);
@@ -609,11 +736,17 @@ class _BloqueFecha extends StatelessWidget {
}
}
/// Add-range form. Moved verbatim from `pantalla_alarmas.dart` (WU8's
/// Add/edit-range form. Moved verbatim from `pantalla_alarmas.dart` (WU8's
/// `_PantallaVacacionesTemporal` used it as a placeholder push target; now
/// this screen is the one real consumer). Behaviour unchanged.
/// this screen is the one real consumer). Create behaviour unchanged; issue
/// 1 (feedback-pruebas) adds the edit half via the optional [rango] — the
/// SAME sheet, mirroring `pantalla_alarmas.dart`'s `_EditorAlarmaSheet`
/// (`alarma == null` -> create, non-null -> edit; one shared save button
/// either way).
class _EditorVacacionesSheet extends StatefulWidget {
const _EditorVacacionesSheet();
const _EditorVacacionesSheet({this.rango});
final RangoVacaciones? rango;
@override
State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState();
@@ -629,16 +762,29 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
@override
void initState() {
super.initState();
final hoy = DateTime.now();
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
_fin = _inicio.add(const Duration(days: 2));
final rango = widget.rango;
if (rango != null) {
_inicio = rango.inicioDia;
_fin = rango.finDia;
} else {
final hoy = DateTime.now();
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
_fin = _inicio.add(const Duration(days: 2));
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final rango = widget.rango;
_nombreController ??= TextEditingController(
text: AppLocalizations.of(context).vacationsDefaultName,
text:
rango != null
? localizedVacationName(
AppLocalizations.of(context),
rango.nombre,
)
: AppLocalizations.of(context).vacationsDefaultName,
);
}
@@ -662,7 +808,9 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.newVacationRangeTitle,
widget.rango != null
? l10n.editVacationRangeTitle
: l10n.newVacationRangeTitle,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
@@ -695,10 +843,38 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
],
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.check_rounded),
label: Text(l10n.saveRangeAction),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.check_rounded),
label: Text(l10n.saveRangeAction),
),
),
// Fix `vacaciones-delete`: only when EDITING an existing
// range (never when creating one -- there is nothing to
// delete yet). Reuses the exact same confirmation dialog
// (`_confirmarEliminarRango`) and deletion method
// (`eliminarRangoVacaciones`) the swipe-to-delete gesture
// already uses on both `_HeroRangoActivo` and
// `_TarjetaRangoVacaciones` -- no new deletion path.
if (widget.rango != null) ...[
const SizedBox(width: 10),
OutlinedButton.icon(
key: const ValueKey('vacation-delete-button'),
style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
side: BorderSide(
color: Theme.of(context).colorScheme.error,
),
),
onPressed: _eliminar,
icon: const Icon(Icons.delete_outline_rounded),
label: Text(l10n.deleteAction),
),
],
],
),
],
),
@@ -709,10 +885,16 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
Future<void> _elegirFecha({required bool esInicio}) async {
final actual = esInicio ? _inicio : _fin;
final hoy = DateTime.now();
final hoyDia = DateTime(hoy.year, hoy.month, hoy.day);
// Issue 1 (feedback-pruebas): editing a PAST range (reachable from the
// "Rangos pasados" section) must not force its dates into the future —
// `firstDate` only floors at today for a range that starts there or
// later; an already-past range keeps its own start as the floor.
final primerDiaPermitido = _inicio.isBefore(hoyDia) ? _inicio : hoyDia;
final seleccion = await showDatePicker(
context: context,
initialDate: actual,
firstDate: DateTime(hoy.year, hoy.month, hoy.day),
firstDate: primerDiaPermitido,
lastDate: hoy.add(const Duration(days: 1460)),
);
if (seleccion == null) return;
@@ -728,12 +910,48 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
Future<void> _guardar() async {
final estado = context.read<EstadoAlarmas>();
final rango = estado.servicio.crearRangoVacaciones(
inicio: _inicio,
fin: _fin,
nombre: _nombreController?.text.trim() ?? '',
);
await estado.crearRangoVacaciones(rango);
final nombre = _nombreController?.text.trim() ?? '';
final existente = widget.rango;
if (existente != null) {
await estado.editarRangoVacaciones(
RangoVacaciones(
id: existente.id,
nombre: nombre,
inicio: _inicio,
fin: _fin,
activo: existente.activo,
),
);
} else {
final rango = estado.servicio.crearRangoVacaciones(
inicio: _inicio,
fin: _fin,
nombre: nombre,
);
// freemium-gating spec "Gated Feature Set": vacation creation is
// fully gated (unlike the alarm cap, there is no free allowance) —
// `crearRangoVacaciones` is the authoritative choke point.
final creada = await estado.crearRangoVacaciones(rango);
if (!creada) {
if (mounted) await mostrarHojaPremium(context);
return;
}
}
if (mounted) Navigator.pop(context);
}
/// Fix `vacaciones-delete`: mirrors `_guardar`'s pop-on-success shape,
/// but confirms first (via the same `_confirmarEliminarRango` dialog the
/// swipe gesture uses) and calls `eliminarRangoVacaciones` instead of
/// saving. Only reachable when [widget.rango] is non-null (the delete
/// button itself is hidden otherwise).
Future<void> _eliminar() async {
final rango = widget.rango;
if (rango == null) return;
final l10n = AppLocalizations.of(context);
final confirmado = await _confirmarEliminarRango(context, l10n);
if (!confirmado || !mounted) return;
await context.read<EstadoAlarmas>().eliminarRangoVacaciones(rango.id);
if (mounted) Navigator.pop(context);
}
}
+49
View File
@@ -58,6 +58,55 @@ Future<ResultadoArranqueAudio<T>> esperarArranqueAudio<T>(
}
}
/// Subscribes to [errores] — in production `AudioService.asyncError` — and
/// hands every event to [registrar]. Returns the [StreamSubscription] so the
/// caller can cancel it when the handler is torn down.
///
/// Why this exists: `audio_service` funnels EVERY asynchronous failure of its
/// own observers into that stream and nothing else
/// (`_observePlaybackState`/`_observeMediaItem`/`_observeQueue` each wrap
/// their whole body in `catch (e) { _asyncError.add(e); }`, and the artwork
/// path uses `.catchError(_asyncError.add)`), yet this app had ZERO
/// subscribers on it. A `PublishSubject` with no listeners simply drops
/// events, so the platform-side exception behind "the media notification
/// disappeared" — a rejected `setState`, a failed `setMediaItem`, an
/// Android 12+ `ForegroundServiceStartNotAllowedException` surfacing through
/// the plugin — was being discarded without a single log line. This makes
/// that channel audible.
///
/// [errores] and [registrar] are both injected — this function never touches
/// the real `audio_service` plugin, so it is testable with a plain
/// [StreamController] (same seam convention as [esperarArranqueAudio] above,
/// and as `decidirAvanceCola`/`debeReaplicarEcualizador` elsewhere).
StreamSubscription<Object> observarErroresAudio(
Stream<Object> errores, {
required void Function(Object error) registrar,
}) {
return errores.listen(
registrar,
// The plugin only ever feeds this subject through `add`, never
// `addError`, so this branch is purely defensive: a stream-level error
// would otherwise escape as an unhandled zone error, which is strictly
// worse than one more log line.
onError: (Object error, StackTrace _) => registrar(error),
cancelOnError: false,
);
}
/// Default [observarErroresAudio] logger: one line per swallowed plugin
/// exception.
///
/// Uses [debugPrint], NOT `dart:developer`'s `log`. That distinction is the
/// whole reason this channel existed for weeks without ever producing a
/// single line of evidence: `log()` writes to the VM service, which a
/// RELEASE build does not have, so every exception this was built to catch
/// was still being thrown away — just one layer further down than before.
/// `debugPrint` reaches logcat in release, which is the only build that ever
/// runs in the car.
void registrarErrorAudioService(Object error) {
debugPrint('[PluriWave][ArranqueAudio] AudioService.asyncError: $error');
}
/// Minimal branded bootstrap widget for the degraded path (Design "still
/// call runApp, but with a minimal bootstrap widget that keeps waiting on
/// the SAME original future"). Shows [_CargandoArranqueAudio] while
+2 -4
View File
@@ -97,7 +97,5 @@ DecisionAvanceCola decidirAvanceCola({
/// distinct `ColaLocal` during the async URI-resolve gap is correctly
/// detected as stale and aborts the advance, instead of silently racing an
/// external play/stop.
bool avanceEsValido(
ColaLocal? colaLocalActual,
ColaLocal? siguienteEsperado,
) => identical(colaLocalActual, siguienteEsperado);
bool avanceEsValido(ColaLocal? colaLocalActual, ColaLocal? siguienteEsperado) =>
identical(colaLocalActual, siguienteEsperado);
+119
View File
@@ -0,0 +1,119 @@
import 'servicio_alarmas_android.dart';
/// One diagnosable Android alarm-reliability signal (fix/alarmas-fiabilidad).
///
/// [DiagnosticoAlarmasAndroid] already collects six raw fields, but only
/// three were ever surfaced in the UI -- the two most diagnostic ones
/// (battery-optimization exemption and the native pending-alarm count) were
/// gathered and thrown away. This module maps the raw snapshot into a
/// stable, ordered list of user-facing signals with a clear ok/needs-
/// attention state, decoupled from Flutter/localization so it stays a
/// trivial pure-Dart unit to test.
enum SenalDiagnosticoAlarma {
alarmasExactas,
notificaciones,
pantallaCompleta,
optimizacionBateria,
alarmasNativasPendientes,
}
enum EstadoSenalDiagnostico { ok, atencion }
/// The system screen a "fix this" action should open for a given signal.
/// `ninguna` marks signals with no actionable system screen of their own
/// (`alarmasNativasPendientes` is informational -- fixing the OTHER signals
/// above is what makes it recover).
enum AccionDiagnosticoAlarma {
abrirAlarmasExactas,
abrirNotificaciones,
abrirOptimizacionBateria,
abrirPantallaCompleta,
ninguna,
}
class ItemDiagnosticoAlarma {
const ItemDiagnosticoAlarma({
required this.senal,
required this.estado,
required this.accion,
});
final SenalDiagnosticoAlarma senal;
final EstadoSenalDiagnostico estado;
final AccionDiagnosticoAlarma accion;
bool get requiereAtencion => estado == EstadoSenalDiagnostico.atencion;
}
/// Builds the five diagnosable signals in a FIXED, stable order so the
/// screen renders them consistently every time.
///
/// [hayAlarmasActivas] contextualizes `alarmasNativasPendientes`: a fresh
/// install with zero alarms turned on has nothing to register with the OS,
/// so a `0` count there is only meaningful once the user actually has an
/// active alarm -- THAT combination is direct evidence the alarm never
/// reached the operating system at all, which is the single most useful
/// signal for the reported "alarm never rings" failure mode.
List<ItemDiagnosticoAlarma> construirItemsDiagnosticoAlarmas({
required DiagnosticoAlarmasAndroid diagnostico,
required bool hayAlarmasActivas,
}) {
EstadoSenalDiagnostico desde(bool ok) =>
ok ? EstadoSenalDiagnostico.ok : EstadoSenalDiagnostico.atencion;
final alarmasNativasOk =
!hayAlarmasActivas || diagnostico.alarmasNativasPendientes > 0;
return [
ItemDiagnosticoAlarma(
senal: SenalDiagnosticoAlarma.alarmasExactas,
estado: desde(diagnostico.puedeProgramarExactas),
accion: AccionDiagnosticoAlarma.abrirAlarmasExactas,
),
ItemDiagnosticoAlarma(
senal: SenalDiagnosticoAlarma.notificaciones,
estado: desde(diagnostico.notificacionesPermitidas),
accion: AccionDiagnosticoAlarma.abrirNotificaciones,
),
ItemDiagnosticoAlarma(
senal: SenalDiagnosticoAlarma.pantallaCompleta,
estado: desde(diagnostico.puedeUsarPantallaCompleta),
accion: AccionDiagnosticoAlarma.abrirPantallaCompleta,
),
ItemDiagnosticoAlarma(
senal: SenalDiagnosticoAlarma.optimizacionBateria,
estado: desde(diagnostico.ignoraOptimizacionBateria),
accion: AccionDiagnosticoAlarma.abrirOptimizacionBateria,
),
ItemDiagnosticoAlarma(
senal: SenalDiagnosticoAlarma.alarmasNativasPendientes,
estado: desde(alarmasNativasOk),
accion: AccionDiagnosticoAlarma.ninguna,
),
];
}
/// Manufacturers/sub-brands known for aggressive background-process killing
/// that requires the user to manually enable "Autostart" (or the vendor's
/// own equivalent toggle) -- there is NO public Android API to detect or
/// grant this setting programmatically, so the app can only explain it.
const _fabricantesConGuiaAutostart = [
'xiaomi',
'redmi',
'poco',
'huawei',
'oppo',
'vivo',
'oneplus',
'samsung',
];
/// Whether [fabricante] (`Build.MANUFACTURER`, e.g. "Xiaomi", "POCO") is a
/// known aggressive-background-killer vendor that needs the manual autostart
/// explanation. Case-insensitive substring match, since `Build.MANUFACTURER`
/// values are not fully standardized across sub-brands/regions/builds.
bool fabricanteRequiereGuiaAutostart(String fabricante) {
final normalizado = fabricante.trim().toLowerCase();
if (normalizado.isEmpty) return false;
return _fabricantesConGuiaAutostart.any(normalizado.contains);
}
+7 -7
View File
@@ -61,9 +61,10 @@ String nombreCarpetaDesdeUri(String treeUri, {required String nombreGenerico}) {
segmento = documentId.substring(ultimaBarra + 1);
} else {
final ultimosDosPuntos = documentId.lastIndexOf(':');
segmento = ultimosDosPuntos >= 0
? documentId.substring(ultimosDosPuntos + 1)
: documentId;
segmento =
ultimosDosPuntos >= 0
? documentId.substring(ultimosDosPuntos + 1)
: documentId;
}
final recortado = segmento.trim();
@@ -200,10 +201,9 @@ class FuenteMusicaLocalAutoImpl implements FuenteMusicaLocalAuto {
try {
final uri = await _uriPersistida();
if (uri == null || uri.isEmpty) return false;
final valido = await _canal.invokeMethod<bool>(
'hasPersistedPermission',
{'treeUri': uri},
);
final valido = await _canal.invokeMethod<bool>('hasPersistedPermission', {
'treeUri': uri,
});
return valido ?? false;
} catch (_) {
// Cold-start / revoked-permission safety (Spec "Permission revoked or
+577 -98
View File
@@ -6,10 +6,10 @@ import 'package:audio_service/audio_service.dart';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:path_provider/path_provider.dart';
import '../estado/orden_emisoras.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/pista_local.dart';
import '../modelos/preset_ecualizador.dart';
import 'musica_local_auto.dart';
import 'persistencia_tolerante.dart';
import 'servicio_favoritos.dart';
@@ -19,16 +19,37 @@ import 'servicio_favoritos.dart';
/// no [NodoLocal] coupling — so a future paged folder type can reuse the
/// slice arithmetic directly. An empty [items] or a [pagina] beyond the
/// list's range returns `[]`, never throws.
List<T> paginaDe<T>(List<T> items, {required int pagina, required int tamano}) =>
items.skip(pagina * tamano).take(tamano).toList();
List<T> paginaDe<T>(
List<T> items, {
required int pagina,
required int tamano,
}) => items.skip(pagina * tamano).take(tamano).toList();
/// Whether a page after [pagina] exists for a list of [total] elements
/// (Design ADR-6): `true` iff at least one element remains beyond the
/// current page's slice. The exact-boundary case
/// (`total == (pagina + 1) * tamano`) is `false` — nothing remains to
/// reveal.
bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) =>
total > (pagina + 1) * tamano;
bool hayPaginaSiguiente(
int total, {
required int pagina,
required int tamano,
}) => total > (pagina + 1) * tamano;
/// Browse-tree ordering comparator for a local-music folder's children
/// (Design "Directories before files", item 1): directories sort before
/// files regardless of name, and within each group, alphabetically by
/// [NodoLocal.nombre] -- the standard file-browser convention. Fixes a
/// driver-facing bug where a folder's subfolders could land on a later
/// "Más…" page whenever enough tracks sorted alphabetically ahead of them
/// (e.g. a "Live" subfolder behind 80 numbered tracks), making the
/// subfolder unreachable without paging through every track first.
int compararNodoLocalParaNavegacion(NodoLocal a, NodoLocal b) {
if (a.esDirectorio != b.esDirectorio) {
return a.esDirectorio ? -1 : 1;
}
return a.nombre.compareTo(b.nombre);
}
const _prefijoEmisora = 'emisora:';
@@ -68,8 +89,7 @@ bool faviconUsable(String? favicon) {
// even with an empty host (e.g. `Uri.parse('http://').hasAuthority` is
// `true`) — check `host.isNotEmpty` explicitly to actually require a
// non-empty authority host.
return (uri.scheme == 'http' || uri.scheme == 'https') &&
uri.host.isNotEmpty;
return (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty;
}
/// Deterministic rotation index over the 4 on-brand fallback arts, same
@@ -83,10 +103,11 @@ int indiceArtePara(String seed) =>
/// drawable URI selected via [indiceArtePara] over `e.uuid` — the same
/// on-brand art the phone UI would pick for this station (per-station
/// parity), never a launcher-icon lookalike.
String artUriPara(Emisora e) => faviconUsable(e.favicon)
? e.favicon!
: 'android.resource://es.freetimelab.pluriwave/drawable/'
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
String artUriPara(Emisora e) =>
faviconUsable(e.favicon)
? e.favicon!
: 'android.resource://es.freetimelab.pluriwave/drawable/'
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
/// Formats a human-readable audio-quality hint for the browse row's
/// `displaySubtitle` (Design Decision "`displaySubtitle` quality format"):
@@ -197,6 +218,17 @@ class ConstructorArbolAuto {
/// (Design "Local root hidden until a folder is configured").
static const idMusicaLocal = 'musica_local';
/// Root folder id for the "Ecualizador" browsable folder (decision
/// `auto/ecualizador-diseno`): lists "Desactivar" plus the six factory
/// presets, the currently-active one marked. Deliberately NOT added to
/// [_idsCarpetas] -- like [idMusicaLocal], it has its own dedicated
/// children, built by `itemsEcualizadorAuto` in `servicio_audio.dart`
/// (which needs `AppLocalizations` -- this pure builder class does not
/// depend on it), not the generic station-list [hijos] path. Unlike
/// [idMusicaLocal], it is ALWAYS present in [raiz], never conditionally
/// hidden.
static const idEcualizador = 'ecualizador';
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
static const _maxItemsPorCarpeta = 50;
@@ -280,24 +312,64 @@ class ConstructorArbolAuto {
};
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
/// optionally Música Local), all non-playable.
/// optionally Música Local, Ecualizador), all non-playable.
///
/// There is deliberately no equalizer folder: EQ is configured on the phone
/// only. The car still gets the right sound, because the per-device preset
/// is applied automatically when the output device changes — that lives in
/// `EstadoEcualizador`, not in this tree.
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
/// There is NO `Ecualizador` folder. The car's only equalizer control is
/// the on/off custom action on the playback screen
/// (`controlesEcualizadorPersonalizados` in `servicio_audio.dart`), which
/// the driver reaches from all three player views without leaving them.
///
/// The folder existed briefly (`8423ccd`) because custom actions were
/// thought unable to convey enough state for a six-preset choice. Owner
/// decision after driving with it: a browsable preset list is more
/// interaction than a driver wants, and on/off is the only equalizer
/// control that belongs in a car. Preset selection stays on the phone.
/// This lands back on the redesign mockup's original rule ("sin carpeta de
/// ecualizador", turn t4 line 40), now for a road-tested reason rather than
/// an assumed one.
///
/// `Música Local` is OMITTED entirely (not just empty) unless
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
/// is configured") — the caller passes `fuente.hayCarpetaConfigurada()`,
/// keeping this builder itself synchronous and side-effect free.
List<MediaItem> raiz({required bool incluirMusicaLocal}) => [
///
/// [premium] (iap-freemium-unlock, Design ADR-4): the ROOT keeps the exact
/// same visible folder labels for every tier — "keeps the same visible
/// folder labels for free users" is the explicit design choice, so a free
/// driver still sees a real, familiar menu rather than a wall of "Función
/// Premium" rows. The lock itself is enforced one level DOWN, at the
/// `getChildren` choke point (see [itemPremiumBloqueado] and
/// [respuestaBloqueadaPorEntitlement] below) — tapping any of these
/// folders as a free user reveals the lock there, never here.
List<MediaItem> raiz({
required bool incluirMusicaLocal,
required bool premium,
}) => [
_carpeta(idFavoritos, 'Favoritos'),
_carpeta(idTodas, 'Todas las emisoras'),
_carpeta(idMisEmisoras, 'Mis emisoras'),
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
];
/// Free-tier id prefix reserved id (iap-freemium-unlock, Design ADR-4):
/// the single non-playable item every non-root folder collapses to for a
/// free-tier user. Hardcoded Spanish label, matching every other car-tree
/// label in this file (never routed through `AppLocalizations` —
/// established convention, see [_tituloMasLocal]'s doc).
static const idPremiumInfo = 'premium:info';
/// The single locked item shown for ANY non-root folder when the browsing
/// user is free tier (Design ADR-4, android-auto-media spec "Free-Tier
/// Reduced Root Browse"). Non-playable — selecting it is a no-op, never a
/// crash (Spec "Free-tier user selects a locked item").
MediaItem itemPremiumBloqueado() => MediaItem(
id: idPremiumInfo,
title: 'Función Premium',
playable: false,
extras: _contentStyleLista,
);
MediaItem _carpeta(String id, String titulo) => MediaItem(
id: id,
title: titulo,
@@ -305,15 +377,23 @@ class ConstructorArbolAuto {
extras: _contentStyleLista,
);
/// Leaf items for [parentId], sorted via [ordenarEmisoras] and capped at
/// [_maxItemsPorCarpeta] (Design "which stations surface & ordering" —
/// avoids driver distraction and Auto list limits). Unknown [parentId]
/// (or an empty [emisoras]) returns an empty list instead of throwing.
/// Leaf items for [parentId], PRESERVING the incoming [emisoras] order and
/// capped at [_maxItemsPorCarpeta] (Design "which stations surface &
/// ordering" — avoids driver distraction and Auto list limits).
///
/// Fix `android-auto-orden`: this used to force
/// `ordenarEmisoras(emisoras, OrdenEmisoras.calidad)` unconditionally,
/// silently discarding whatever order the caller actually wanted —
/// Favoritos' manual drag-reorder order, or the global `ordenListas`
/// setting for Todas/Mis emisoras. Every caller (`EstadoRadio.
/// cargarFavoritos`/`cargarPopulares`/`_cargarEmisorasCustom`/
/// `cambiarOrdenListas`) now pushes an already-ordered snapshot, so this
/// only slices and maps — it must never re-sort. Unknown [parentId] (or
/// an empty [emisoras]) returns an empty list instead of throwing.
List<MediaItem> hijos(String parentId, {required List<Emisora> emisoras}) {
if (!_idsCarpetas.contains(parentId)) return const [];
if (emisoras.isEmpty) return const [];
final ordenadas = ordenarEmisoras(emisoras, OrdenEmisoras.calidad);
return ordenadas.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
return emisoras.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
}
/// Maps a single [Emisora] to a playable `MediaItem`: id `emisora:<uuid>`
@@ -432,14 +512,21 @@ class ConstructorArbolAuto {
int tamano = _maxItemsCarpetaLocal,
@visibleForTesting
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
// Item 2 (recursive folder play): optional so every pre-existing call
// site/test that has no need for the recursive gate keeps working
// unchanged. Only used on page 0, and only when [nodos] has zero
// DIRECT tracks (a direct track already makes the gate cheaply true
// without it) — see the `hayContenidoReproducible` computation below.
FuenteMusicaLocalAuto? fuente,
}) async {
final construir = construirItem ?? _itemLocal;
final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre));
final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion);
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final docIds = paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final docIds =
paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final metadatos = await metadatosDe(docIds);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
@@ -447,14 +534,25 @@ class ConstructorArbolAuto {
}
if (pagina == 0) {
final totalPistas = nodos.where((n) => !n.esDirectorio).length;
// Item 2: a folder plays everything beneath it, recursively -- so
// the play actions must be offered whenever the RECURSIVE count is
// > 0, not just the direct count. `totalPistas > 0` short-circuits
// the bounded recursive walk entirely for the common case (a direct
// track already answers the question); only a folder with ZERO
// direct tracks but at least one subfolder pays the recursive-check
// cost, and only up to [profundidadMaximaRecursivaLocal] levels.
final hayContenidoReproducible =
totalPistas > 0 ||
(fuente != null &&
await _haySubcarpetaConPistas(nodos, fuente: fuente));
final prepend = <MediaItem>[
// Folder-play actions (Design ADR-5, Phase 3): prepended BEFORE
// the sort/bucket nav entries, guarded the same shape as
// ofreceOrdenCalidad(totalPistas > 0) — present iff the folder has
// at least one direct audio child, absent for a folder with only
// subfolders (Spec "Folder has no tracks").
if (totalPistas > 0) _itemReproducirCarpeta(documentIdPadre),
if (totalPistas > 0) _itemReproducirAleatorio(documentIdPadre),
// Folder-play actions (Design ADR-5, Phase 3; recursive gate item
// 2): prepended BEFORE the sort/bucket nav entries, present iff
// the folder has at least one playable track anywhere beneath it
// (direct or nested), absent for a folder that is genuinely empty
// even recursively (Spec "Folder has no tracks").
if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre),
if (hayContenidoReproducible) _itemReproducirAleatorio(documentIdPadre),
if (ofreceOrdenCalidad(totalPistas))
_itemModoOrdenCalidad(documentIdPadre),
if (ofreceBuckets(totalPistas))
@@ -466,6 +564,32 @@ class ConstructorArbolAuto {
return items;
}
/// Whether at least one subfolder within [nodos] recursively contains a
/// playable track (Design "recursive folder play, gate", item 2): called
/// ONLY when the folder has zero DIRECT tracks (the caller already
/// checked that cheaply) — descends into each direct subfolder via
/// [pistasRecursivas] with `limite: 1`, stopping at the very first
/// match so a folder with an early hit costs as little as possible.
/// [nodos] is assumed already resolved by the caller (its own
/// `fuente.hijos(...)` result), so this folder's own children are never
/// re-fetched.
Future<bool> _haySubcarpetaConPistas(
List<NodoLocal> nodos, {
required FuenteMusicaLocalAuto fuente,
}) async {
for (final nodo in nodos) {
if (!nodo.esDirectorio) continue;
final encontradas = await pistasRecursivas(
nodo.documentId,
fuente: fuente,
profundidadMaxima: profundidadMaximaRecursivaLocal - 1,
limite: 1,
);
if (encontradas.isNotEmpty) return true;
}
return false;
}
/// Whether the "Ordenar por calidad" mode entry should be offered for a
/// folder with [totalPistas] audio files (Design ADR-3): present for
/// `0 < totalPistas <= 150`, omitted otherwise (empty folder or above the
@@ -666,10 +790,11 @@ class ConstructorArbolAuto {
final ordenados = [...buckets[idxBucket].nodos]
..sort((a, b) => a.nombre.compareTo(b.nombre));
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final docIds = paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final docIds =
paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final metadatos = await metadatosDe(docIds);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
@@ -684,20 +809,21 @@ class ConstructorArbolAuto {
}
final meta = metadatos[nodo.documentId];
final tituloMeta = meta?.titulo?.trim();
final titulo = (tituloMeta != null && tituloMeta.isNotEmpty)
? tituloMeta
: _tituloDesdeNombre(nodo.nombre);
final titulo =
(tituloMeta != null && tituloMeta.isNotEmpty)
? tituloMeta
: _tituloDesdeNombre(nodo.nombre);
final artUriMeta = meta?.artUri?.trim();
final artUri = (artUriMeta != null && artUriMeta.isNotEmpty)
? artUriMeta
: artUriLocal(nodo.documentId);
final artUri =
(artUriMeta != null && artUriMeta.isNotEmpty)
? artUriMeta
: artUriLocal(nodo.documentId);
final artistaMeta = meta?.artista?.trim();
return MediaItem(
id: '$_prefijoPista${nodo.documentId}',
title: titulo,
artist: (artistaMeta != null && artistaMeta.isNotEmpty)
? artistaMeta
: null,
artist:
(artistaMeta != null && artistaMeta.isNotEmpty) ? artistaMeta : null,
playable: true,
artUri: Uri.parse(artUri),
displaySubtitle: subtituloCalidadLocal(meta),
@@ -718,23 +844,27 @@ class ConstructorArbolAuto {
required List<GrupoFavoritos> grupos,
required List<Emisora> favoritos,
}) {
final carpetas = grupos
.where((g) => !g.esSinAsignar)
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
.take(_maxGruposPorFavoritos)
.map(itemGrupo)
.toList();
final sinAsignar = favoritos
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
.toList();
final carpetas =
grupos
.where((g) => !g.esSinAsignar)
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
.take(_maxGruposPorFavoritos)
.map(itemGrupo)
.toList();
final sinAsignar =
favoritos
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
.toList();
return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)];
}
/// Members of the favorite group identified by [grupoMediaId] (a
/// `grupo:<id>` id), sorted and capped like every other folder (Spec "Car
/// requests a group folder's stations"). An unknown/stale/malformed id
/// returns an empty list instead of throwing (Spec "Car requests an
/// unknown or stale group id").
/// `grupo:<id>` id), PRESERVING the incoming [favoritos] order (Favoritos'
/// manual order — see [hijos]' doc, fix `android-auto-orden`) and capped
/// like every other folder (Spec "Car requests a group folder's
/// stations"). An unknown/stale/malformed id returns an empty list
/// instead of throwing (Spec "Car requests an unknown or stale group
/// id").
List<MediaItem> hijosGrupo(
String grupoMediaId, {
required List<Emisora> favoritos,
@@ -742,13 +872,81 @@ class ConstructorArbolAuto {
if (!esCarpetaGrupo(grupoMediaId)) return const [];
final id = grupoMediaId.substring(_prefijoGrupo.length);
if (id.isEmpty) return const [];
final miembros = favoritos
.where((e) => e.grupoFavoritosId == id)
.toList();
final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList();
if (miembros.isEmpty) return const [];
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad);
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
return miembros.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
}
/// Equalizer preset-selection media-id prefix (decision
/// `auto/ecualizador-diseno`): `eq_preset:<rawPresetName>` for the six
/// factory presets, plus the reserved [_valorDesactivarEq] sentinel for
/// the "Desactivar" item ([idDesactivarEq]). Collision-free against every
/// other prefix/bare id in this class -- diverges from every sibling
/// prefix at the very first character ('e' vs 'g'/'c'/'p') and from every
/// bare folder id (none of which starts with "eq_preset:").
static const _prefijoPresetEq = 'eq_preset:';
/// Reserved sentinel raw value for the "Desactivar" item under
/// [_prefijoPresetEq] (decision `auto/ecualizador-diseno`) -- never
/// collides with a real [PresetEcualizador.nombre]; none of the six
/// factory presets is named this.
static const _valorDesactivarEq = '_off_';
/// The "Desactivar" item's media id: the reserved [_valorDesactivarEq]
/// sentinel under [_prefijoPresetEq].
static const idDesactivarEq = '$_prefijoPresetEq$_valorDesactivarEq';
/// Whether [id] identifies an item under the Ecualizador folder (a
/// factory preset OR "Desactivar").
bool esPresetEqMediaId(String id) => id.startsWith(_prefijoPresetEq);
/// Whether [id] is specifically the "Desactivar" item (not a factory
/// preset). Only meaningful alongside [esPresetEqMediaId].
bool esDesactivarEqMediaId(String id) => id == idDesactivarEq;
/// Builds a factory preset's selection media id, matched by raw
/// (untranslated) [PresetEcualizador.nombre] -- the SAME identity
/// [PresetEcualizador.presets] already uses for equality, so a locale
/// change never breaks resolution.
String idPresetEq(String nombrePreset) => '$_prefijoPresetEq$nombrePreset';
/// Resolves an `eq_preset:<nombre>` [id] to the matching factory
/// [PresetEcualizador] from [presets] (defaults to
/// [PresetEcualizador.presets]), comparing by raw `nombre`. Returns
/// `null` for the [_valorDesactivarEq] sentinel, an unresolvable name, or
/// any id that doesn't match [esPresetEqMediaId] -- never throws.
PresetEcualizador? resolverPresetEq(
String id, {
List<PresetEcualizador>? presets,
}) {
if (!esPresetEqMediaId(id) || esDesactivarEqMediaId(id)) return null;
final nombre = id.substring(_prefijoPresetEq.length);
final lista = presets ?? PresetEcualizador.presets;
for (final preset in lista) {
if (preset.nombre == nombre) return preset;
}
return null;
}
}
/// Pure Android Auto browse-gate decision (iap-freemium-unlock, Design
/// ADR-4): the AUTHORITATIVE `getChildren` choke point, called BEFORE any
/// other resolution. For the root itself this NEVER blocks (the root always
/// resolves through [ConstructorArbolAuto.raiz] instead, which stays
/// visible for every tier). For any non-root [parentMediaId] and a free-tier
/// [premium], it returns the single locked item regardless of what the id
/// actually is — a stale/deep-linked `emisora:<uuid>` or folder id from
/// before a downgrade is blocked exactly the same way as a legitimate
/// current folder id (android-auto-media spec "Free-Tier Browse Never
/// Leaks Real Content (Authoritative Backstop)"). Returns `null` when the
/// caller should proceed with its normal resolution (root, or premium).
List<MediaItem>? respuestaBloqueadaPorEntitlement({
required String parentMediaId,
required bool premium,
}) {
if (parentMediaId == AudioService.browsableRootId) return null;
if (premium) return null;
return [ConstructorArbolAuto().itemPremiumBloqueado()];
}
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
@@ -777,15 +975,195 @@ Future<void> reproducirPorMediaId(
title: emisora.nombre,
artist: emisora.pais ?? '',
album: 'PluriWave',
artUri:
emisora.favicon != null && emisora.favicon!.isNotEmpty
? Uri.tryParse(emisora.favicon!)
: null,
// Item 3: reuses [artUriPara] (the SAME fallback the browse tree's
// itemEmisora already applies) so the "now playing" media item never
// falls back to a blank tile — a real usable favicon still wins, a
// missing/unusable one gets the on-brand rotating drawable instead of
// `null`.
artUri: Uri.parse(artUriPara(emisora)),
extras: {'uuid': emisora.uuid},
);
await reproducir(item);
}
/// Which list previous/next should walk for [actual]: the NARROWEST context
/// the station belongs to.
///
/// Tightest first:
/// 1. its FAVOURITES GROUP, when it is a favourite filed under a real group,
/// 2. all favourites,
/// 3. my stations,
/// 4. the full catalogue.
///
/// The group tier is what the owner asked for: driving with a themed group,
/// "next" should stay inside that group rather than wander across every
/// favourite. And "next" from a favourite must never land on entry 4,318 of a
/// 50,000-station catalogue that happens to sit beside it alphabetically.
/// Falling through to [todas] only when the station is in neither curated
/// list keeps the button alive for a station reached by search.
///
/// [GrupoFavoritos.sinAsignarId] is deliberately NOT treated as a group: it
/// is the ABSENCE of one, so those stations walk all favourites instead of a
/// bucket that only means "unfiled". A group with a single member also falls
/// through to all favourites — otherwise both buttons would be dead ends.
///
/// Returns an empty list when [actual] is in none of them, which
/// [emisoraVecina] turns into "do nothing".
List<Emisora> listaParaSaltoEmisora({
required Emisora actual,
required List<Emisora> favoritos,
required List<Emisora> misEmisoras,
required List<Emisora> todas,
}) {
Emisora? enLista(List<Emisora> lista) {
for (final e in lista) {
if (e.uuid == actual.uuid) return e;
}
return null;
}
// The FAVOURITE record is the authority on the group, never `actual`: the
// playing station is rebuilt from a MediaItem by `emisoraDesdeMediaItem`,
// which carries no group id and would always report "sin asignar".
final favorita = enLista(favoritos);
if (favorita != null) {
final grupo = favorita.grupoFavoritosId;
if (grupo != GrupoFavoritos.sinAsignarId) {
final delGrupo =
favoritos.where((e) => e.grupoFavoritosId == grupo).toList();
if (delGrupo.length > 1) return delGrupo;
}
return favoritos;
}
if (enLista(misEmisoras) != null) return misEmisoras;
if (enLista(todas) != null) return todas;
return const [];
}
/// The station before or after [actual] in [lista], wrapping around at both
/// ends.
///
/// Wrapping is deliberate: on a car's transport row a button that goes dead
/// at the end of a list reads as a broken app, and there is no visible list
/// position to explain it. Matching is by `uuid`, the same identity the
/// browse tree uses, so a refreshed snapshot with different object instances
/// still resolves.
///
/// Returns `null` when [lista] has fewer than two entries, or when [actual]
/// is not in it — the caller must then leave playback alone rather than jump
/// somewhere arbitrary.
Emisora? emisoraVecina(
Emisora? actual,
List<Emisora> lista, {
required bool haciaAtras,
}) {
if (actual == null || lista.length < 2) return null;
final indice = lista.indexWhere((e) => e.uuid == actual.uuid);
if (indice < 0) return null;
final destino =
haciaAtras
? (indice - 1 + lista.length) % lista.length
: (indice + 1) % lista.length;
return lista[destino];
}
/// Picks the station a spoken query refers to ("pon Radio Clásica"), over the
/// stations the car can already browse.
///
/// Pure and source-agnostic so it is testable without a handler. Ranking, best
/// first:
/// 1. exact name match (case/accent-insensitive),
/// 2. name starts with the query,
/// 3. name contains the query,
/// 4. country contains the query.
/// Ties are broken by the order [candidatas] arrives in, which the caller
/// composes as favourites → my stations → all, so a favourite always wins over
/// a stranger with the same name.
///
/// Returns `null` for an empty query or no match — the caller must then do
/// nothing rather than play something arbitrary, since a driver who asked for
/// a specific station is worse served by a random one than by silence.
Emisora? emisoraParaBusqueda(String consulta, List<Emisora> candidatas) {
final q = _normalizarBusqueda(consulta);
if (q.isEmpty) return null;
Emisora? contiene;
Emisora? empieza;
Emisora? porPais;
for (final emisora in candidatas) {
final nombre = _normalizarBusqueda(emisora.nombre);
if (nombre == q) return emisora;
if (empieza == null && nombre.startsWith(q)) {
empieza = emisora;
} else if (contiene == null && nombre.contains(q)) {
contiene = emisora;
} else if (porPais == null &&
_normalizarBusqueda(emisora.pais ?? '').contains(q)) {
porPais = emisora;
}
}
return empieza ?? contiene ?? porPais;
}
/// Lowercase, accent-stripped, collapsed whitespace — a driver saying "radio
/// clasica" must match "Radio Clásica", and voice transcription rarely gets
/// diacritics right.
String _normalizarBusqueda(String texto) {
const conAcento = 'áàäâãéèëêíìïîóòöôõúùüûñç';
const sinAcento = 'aaaaaeeeeiiiiooooouuuunc';
final buffer = StringBuffer();
for (final rune in texto.toLowerCase().runes) {
final char = String.fromCharCode(rune);
final i = conAcento.indexOf(char);
buffer.write(i >= 0 ? sinAcento[i] : char);
}
return buffer.toString().replaceAll(RegExp(r'\s+'), ' ').trim();
}
/// Routing seam for a car-tapped `eq_preset:<...>` media id (decision
/// `auto/ecualizador-diseno`, mirrors [reproducirPorMediaId]'s seam
/// shape): dispatches "Desactivar" to [activarEcualizador]`(false)`, and a
/// resolved factory preset to [aplicarPreset] -- turning the equalizer
/// back ON via [activarEcualizador]`(true)` AFTERWARDS whenever [activo]
/// is currently `false`, so tapping a preset while the equalizer is off
/// both re-enables it AND applies the tapped preset's gains (Spec
/// "selecting a preset while disabled enables it and applies it"), never
/// silently just remembering the preset for later. [aplicarPreset] runs
/// BEFORE the enable check so the native engine only ever pushes gains
/// once, for the NEW preset -- never once for whatever was active before,
/// then again for the new one.
///
/// A stale/unresolvable id, or any id that doesn't match
/// [ConstructorArbolAuto.esPresetEqMediaId], is a no-op: neither callback
/// runs and no exception propagates.
///
/// [presets] is the universe the id is resolved against, and it MUST be the
/// same list the folder was rendered from (`presetsEcualizadorAuto` in
/// `servicio_audio.dart` — factory presets plus the user's saved ones).
/// Defaulting to the factory six alone is what made a tapped custom preset a
/// silent no-op: the item was listed, but nothing here could resolve it.
Future<void> seleccionarPresetEqPorMediaId(
String id, {
required bool activo,
required Future<void> Function(PresetEcualizador) aplicarPreset,
required Future<void> Function(bool) activarEcualizador,
List<PresetEcualizador>? presets,
}) async {
final constructor = ConstructorArbolAuto();
if (!constructor.esPresetEqMediaId(id)) return;
if (constructor.esDesactivarEqMediaId(id)) {
await activarEcualizador(false);
return;
}
final preset = constructor.resolverPresetEq(id, presets: presets);
if (preset == null) return;
await aplicarPreset(preset);
if (!activo) await activarEcualizador(true);
}
/// Fallback title (Design "Title = filename minus extension") for a blank
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
/// matching every other car-tree label in this file (`'Favoritos'`,
@@ -843,10 +1221,8 @@ List<NodoLocal> ordenarPorCalidadLocal(
) {
final ordenados = List<NodoLocal>.from(nodos);
ordenados.sort(
(a, b) => compararCalidadLocal(
metadatos[a.documentId],
metadatos[b.documentId],
),
(a, b) =>
compararCalidadLocal(metadatos[a.documentId], metadatos[b.documentId]),
);
return ordenados;
}
@@ -887,12 +1263,13 @@ List<BucketLocal> bucketsDe(List<NodoLocal> nodos) {
final pistas = nodos.where((n) => !n.esDirectorio).toList();
return _rangosBucket.map((rango) {
final (etiqueta, desde, hasta) = rango;
final coincidencias = pistas.where((n) {
final recortado = n.nombre.trim();
if (recortado.isEmpty) return false;
final letra = recortado[0].toLowerCase();
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
}).toList();
final coincidencias =
pistas.where((n) {
final recortado = n.nombre.trim();
if (recortado.isEmpty) return false;
final letra = recortado[0].toLowerCase();
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
}).toList();
return BucketLocal(etiqueta: etiqueta, nodos: coincidencias);
}).toList();
}
@@ -931,17 +1308,115 @@ List<NodoLocal> mezclarFisherYates(List<NodoLocal> nodos, Random rng) {
List<NodoLocal> pistasEnOrdenAleatorio(List<NodoLocal> nodos, Random rng) =>
mezclarFisherYates(pistasEnOrdenNombre(nodos), rng);
/// Maximum recursion depth for "play folder recursively" (Design "recursive
/// folder play, cost bound", item 2): SAF directory listing is a native
/// round-trip PER folder, so unbounded recursion could turn a single tap
/// into dozens of channel calls for a pathologically deep tree. 4 levels
/// below the tapped folder covers virtually every real music-library
/// layout (even `Artist/Album/Disc/track.mp3` is only 3 levels deep) while
/// keeping a worst-case tree's native-call count bounded. A subfolder
/// beyond this depth is simply never explored — its tracks are not
/// collected, exactly like content beyond the browse tree's own page cap
/// is never listed.
const profundidadMaximaRecursivaLocal = 4;
/// Maximum number of tracks collected by a recursive folder walk (Design
/// "recursive folder play, cost bound", item 2): a folder-play/shuffle
/// queue beyond a few hundred tracks has no practical benefit, and an
/// unbounded collection risks an extremely long queue AND an extremely
/// long recursive walk over a huge library. 500 is an order of magnitude
/// above the existing quality-sort cap
/// ([ConstructorArbolAuto._maxPistasParaOrdenCalidad], 150) — generous for
/// a "play everything" action, while still bounded.
const limitePistasRecursivasLocal = 500;
/// Recursively collects every audio-file [NodoLocal] reachable from
/// [documentId] (Design "recursive folder play", item 2): [documentId]'s
/// own direct audio children, plus — for every direct subfolder — that
/// subfolder's own recursive result. Walked depth-first, sorted by
/// [NodoLocal.nombre] at each level (the SAME comparator the sequential/
/// shuffle play actions already used pre-recursion), so the collected
/// order is deterministic and reproducible under a fixed shuffle seed.
///
/// Bounded on two independent axes so a pathological tree (very deep, or
/// very wide-and-deep) can never turn a single tap into an unbounded
/// number of native SAF round-trips or an unbounded in-memory list:
/// - [profundidadMaxima] caps how many folder levels BELOW [documentId]
/// are ever descended into (`0` = only [documentId]'s own direct
/// children, no descent at all).
/// - [limite] caps the TOTAL number of tracks collected across the whole
/// walk; collection stops (mid-folder if needed) the instant this many
/// have been gathered.
///
/// Never throws: a [fuente.hijos] failure on any one subfolder (revoked
/// permission, a race with the OS SAF layer) is swallowed for that
/// subfolder only — sibling folders already queued for traversal are
/// still visited — mirroring this file's existing no-throw contract
/// (Design "no-op on empty/unresolvable folder").
Future<List<NodoLocal>> pistasRecursivas(
String documentId, {
required FuenteMusicaLocalAuto fuente,
int profundidadMaxima = profundidadMaximaRecursivaLocal,
int limite = limitePistasRecursivasLocal,
}) async {
final resultado = <NodoLocal>[];
await _recolectarPistasRecursivas(
documentId,
fuente: fuente,
profundidadRestante: profundidadMaxima,
limite: limite,
resultado: resultado,
);
return resultado;
}
Future<void> _recolectarPistasRecursivas(
String documentId, {
required FuenteMusicaLocalAuto fuente,
required int profundidadRestante,
required int limite,
required List<NodoLocal> resultado,
}) async {
if (resultado.length >= limite) return;
final List<NodoLocal> hijos;
try {
hijos = await fuente.hijos(documentId);
} catch (_) {
return;
}
final ordenados = [...hijos]..sort((a, b) => a.nombre.compareTo(b.nombre));
for (final nodo in ordenados) {
if (resultado.length >= limite) return;
if (nodo.esDirectorio) {
if (profundidadRestante <= 0) continue;
await _recolectarPistasRecursivas(
nodo.documentId,
fuente: fuente,
profundidadRestante: profundidadRestante - 1,
limite: limite,
resultado: resultado,
);
} else {
resultado.add(nodo);
}
}
}
/// Orchestrates a "Reproducir carpeta"/"Reproducir aleatorio" tap (Design
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2): resolves whichever of the
/// two action prefixes matches [id] (ignoring [aleatorio] for the STRIP —
/// the prefix itself is authoritative), fetches [fuente]'s direct children
/// for that folder, filters to audio files, orders them ([aleatorio] picks
/// shuffled vs name order), and hands the resulting list to [iniciarCola].
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2; recursive collection item
/// 2): resolves whichever of the two action prefixes matches [id]
/// (ignoring [aleatorio] for the STRIP — the prefix itself is
/// authoritative), RECURSIVELY collects every track beneath that folder
/// via [pistasRecursivas] (direct children AND every nested subfolder, up
/// to its depth/count bounds), orders them ([aleatorio] picks shuffled vs
/// the recursive walk's own name-sorted order), and hands the resulting
/// list to [iniciarCola].
///
/// A no-op (never calls [iniciarCola]) when: [id] matches neither action
/// prefix; [fuente.hijos] throws or returns only directories (an
/// unresolvable/empty folder — Design "no-op on empty/unresolvable
/// folder").
/// prefix; the folder (or everything beneath it, within the recursion
/// bounds) is unresolvable/empty (Design "no-op on empty/unresolvable
/// folder") — [pistasRecursivas] never throws, so this never propagates an
/// exception either.
Future<void> reproducirCarpetaLocal(
String id, {
required bool aleatorio,
@@ -959,16 +1434,11 @@ Future<void> reproducirCarpetaLocal(
return;
}
final List<NodoLocal> nodos;
try {
nodos = await fuente.hijos(documentId);
} catch (_) {
return;
}
final pistas = aleatorio
? pistasEnOrdenAleatorio(nodos, rng ?? Random())
: pistasEnOrdenNombre(nodos);
final recolectadas = await pistasRecursivas(documentId, fuente: fuente);
final pistas =
aleatorio
? mezclarFisherYates(recolectadas, rng ?? Random())
: recolectadas;
if (pistas.isEmpty) return;
await iniciarCola(pistas);
@@ -992,6 +1462,11 @@ Future<MediaItem?> construirMediaItemColaLocal(
id: contentUri,
title: _tituloDesdeDocumentId(nodo.documentId),
album: 'PluriWave',
// Item 3: a queued local track had NO artUri at all before — reuses
// [artUriLocal] (the SAME on-brand rotation the browse tree's
// `_itemLocal` already falls back to) so the car's now-playing screen
// never shows a blank tile for a track with no embedded art.
artUri: Uri.parse(artUriLocal(nodo.documentId)),
extras: {'documentId': nodo.documentId},
);
}
@@ -1114,6 +1589,7 @@ Future<List<MediaItem>?> hijosMusicaLocal(
documentIdPadre: documentId,
pagina: pagina,
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
fuente: fuente,
);
} catch (_) {
return const [];
@@ -1170,6 +1646,9 @@ Future<void> reproducirPistaLocal(
id: pista.contentUri,
title: pista.titulo,
album: 'PluriWave',
// Item 3: same fallback as construirMediaItemColaLocal, for a track
// tapped directly (not via a folder-play queue).
artUri: Uri.parse(artUriLocal(pista.documentId)),
extras: {'documentId': pista.documentId},
);
await reproducir(item);
+104 -2
View File
@@ -350,6 +350,73 @@ class ServicioAlarmas {
return nuevo;
});
/// Records a scheduling-reliability failure for [alarmaId] (fix/alarmas-
/// fallos-silenciosos): reuses the SAME `ExcepcionAlarma` model
/// `saltarProxima` already persists, so `EstadoAlarmas.ultimaExcepcionPara`
/// surfaces it on the exact alarm card affected instead of only a
/// transient, alarm-agnostic message. A previous FAILURE record for the
/// same alarm is replaced (only the latest attempt's outcome matters) --
/// any `skipNext` exception for this or other alarms is left untouched.
/// Never affects scheduling: `ServicioProgramacionAlarmas._esValida` only
/// treats `tipoSaltoSiguiente` as an actual skip.
Future<ConfiguracionAlarmas> registrarFalloProgramacion(
String alarmaId,
DateTime ejecucion,
String tipo,
) => _enCola(() async {
final config = await _configActual();
final excepciones = [
..._sinFalloPrevio(config.excepciones, alarmaId),
ExcepcionAlarma(alarmaId: alarmaId, ejecucion: ejecucion, tipo: tipo),
];
final nuevo = ConfiguracionAlarmas(
alarmas: config.alarmas,
vacaciones: config.vacaciones,
excepciones: excepciones,
);
await _guardar(nuevo);
return nuevo;
});
/// Clears the outstanding failure record for [alarmaId] ONLY when its
/// current tipo is [tipo] (a subsequent attempt of THAT SPECIFIC kind
/// succeeded). Type-scoped on purpose: a successful main-alarm schedule
/// call proves nothing about the pre-notice or foreground-service
/// subsystems, so it must never clear a failure recorded for those. No-op
/// when there is nothing to clear or the recorded tipo does not match.
Future<ConfiguracionAlarmas> limpiarFalloProgramacion(
String alarmaId,
String tipo,
) => _enCola(() async {
final config = await _configActual();
final actual = config.excepciones.where((e) => e.alarmaId == alarmaId);
final tieneEseTipo = actual.any((e) => e.tipo == tipo);
if (!tieneEseTipo) return config;
final excepciones =
config.excepciones
.where((e) => !(e.alarmaId == alarmaId && e.tipo == tipo))
.toList();
final nuevo = ConfiguracionAlarmas(
alarmas: config.alarmas,
vacaciones: config.vacaciones,
excepciones: excepciones,
);
await _guardar(nuevo);
return nuevo;
});
List<ExcepcionAlarma> _sinFalloPrevio(
List<ExcepcionAlarma> excepciones,
String alarmaId,
) =>
excepciones
.where(
(e) =>
!(e.alarmaId == alarmaId &&
ExcepcionAlarma.tiposFallo.contains(e.tipo)),
)
.toList();
Future<ConfiguracionAlarmas> posponerEjecucion(
String alarmaId,
DateTime ejecucion,
@@ -497,17 +564,52 @@ class ServicioAlarmas {
final ahora = _reloj();
// S2-R5: a disabled alarm must not keep a pending snooze; clearing it
// here guarantees the snoozed occurrence dies with the alarm.
// Self-heal for a snooze target parked absurdly far out — the reported
// "posponer left it 1400+ minutes away". A legitimate snooze can never
// reach here: posponerEjecucion clamps to `minutos.clamp(1, 120)` and the
// anchor is now guarded on both the native and Dart sides, so anything
// past that ceiling is a leftover from a build that had neither guard.
// Without this, an alarm poisoned before the fix keeps showing tomorrow
// on every tick — the user reinstalls, sees no change, and reasonably
// concludes nothing was fixed. Generous margin over the 120-minute cap so
// a real long snooze is never mistaken for corruption.
const techoSnooze = Duration(hours: 3);
final snoozeCorrupto =
alarma.snoozeHasta != null &&
alarma.snoozeHasta!.isAfter(ahora.add(techoSnooze));
final snoozeActivo =
alarma.activa &&
!snoozeCorrupto &&
alarma.snoozeHasta != null &&
alarma.snoozeHasta!.isAfter(ahora);
// Self-heal for state poisoned before the Detener anchor fix: a stop
// that closed a FUTURE occurrence wrote it into
// ultimaEjecucionGestionada, and _esValida rejects any candidate
// matching it -- so the alarm silently skips that day forever after,
// with nothing in the UI to explain it. An occurrence cannot have been
// handled before it happens, so a value meaningfully in the future is
// corrupt by definition and safe to drop: it can only ever suppress a
// real future ring, never prevent a double-fire (which needs a PAST
// occurrence to guard). Placed here, in the recalculation every load and
// every mutation already funnels through, so an affected alarm heals on
// the next app open with no user action.
final gestionada = alarma.ultimaEjecucionGestionada;
final gestionadaCorrupta =
gestionada != null &&
gestionada.isAfter(
ahora.add(ServicioProgramacionAlarmas.toleranciaDisparoInminente),
);
final saneada =
gestionadaCorrupta
? alarma.copyWith(limpiarUltimaEjecucionGestionada: true)
: alarma;
final proxima = _programacion.calcularProxima(
alarma: alarma,
alarma: saneada,
desde: ahora,
vacaciones: vacaciones,
excepciones: excepciones,
);
return alarma.copyWith(
return saneada.copyWith(
proximaEjecucion: proxima,
limpiarProximaEjecucion: true,
limpiarSnooze: !snoozeActivo,
+103
View File
@@ -161,6 +161,35 @@ class EjecucionAlarmaNativa {
}
}
/// A scheduling-reliability failure the NATIVE side recorded on its own
/// (fix/alarmas-fallos-silenciosos, item 2): the pre-notice reminder, the
/// ringing foreground service, and a post-boot/unlock reschedule can each
/// fail without ever going through a Dart method-channel call that could
/// throw -- the native scheduler persists these instead (mirroring how
/// handled occurrences and snooze state already survive a killed engine),
/// and this is the cold-start sync so the Dart side finds out at all.
class FalloProgramacionNativo {
const FalloProgramacionNativo({
required this.alarmaId,
required this.tipo,
required this.ocurridoEn,
});
final String alarmaId;
final String tipo;
final DateTime ocurridoEn;
factory FalloProgramacionNativo.fromMap(Map<Object?, Object?> map) {
return FalloProgramacionNativo(
alarmaId: map['alarmId'] as String? ?? '',
tipo: map['type'] as String? ?? '',
ocurridoEn: DateTime.fromMillisecondsSinceEpoch(
(map['atMillis'] as num?)?.toInt() ?? 0,
),
);
}
}
abstract class PuertoAlarmasAndroid {
Stream<EventoAlarmaAndroid> get eventosAlarma;
@@ -170,6 +199,24 @@ abstract class PuertoAlarmasAndroid {
Future<void> programar(AlarmaMusical alarma);
Future<void> cancelar(String alarmaId);
/// Failures the NATIVE side recorded on its own, outside any Dart call:
/// a pre-notice that could not be armed, a refused foreground-service
/// start when the alarm should have rung, and a per-alarm reschedule that
/// failed after a reboot. Each entry carries the alarm id and one of
/// [ExcepcionAlarma]'s `tipoFallo*` constants.
///
/// Before this existed every one of those paths logged to logcat and
/// stopped there, so an alarm could sit switched on in the list having
/// never reached the OS at all — the user's "as if there were no alarm".
///
/// Returns the typed model rather than raw maps ON PURPOSE:
/// [FalloProgramacionNativo.fromMap] is the single place the native key
/// names (`alarmId`/`type`/`atMillis`) appear. Consuming raw maps here
/// once silently dropped every entry, because the caller guessed Spanish
/// key names and the fake was seeded with the same guess — the test
/// confirmed the mistake instead of catching it.
Future<List<FalloProgramacionNativo>> fallosNativosProgramacion();
Future<void> ocultarNotificacionAlarma(String alarmaId);
/// Notification-only dismissal (RES-1): hides the fire notification for
@@ -190,10 +237,24 @@ abstract class PuertoAlarmasAndroid {
Future<bool> solicitarPermisoPantallaCompleta();
Future<bool> solicitarExencionBateria();
/// Opens the system's per-app notification settings screen directly
/// (`ACTION_APP_NOTIFICATION_SETTINGS`), as opposed to
/// [solicitarPermisoNotificaciones]'s runtime permission popup. Used from
/// the reliability diagnostics screen: once a user is troubleshooting an
/// alarm that already failed, sending them straight to Settings is more
/// robust than a runtime dialog the OS may refuse to show again after a
/// prior denial.
Future<bool> abrirConfiguracionNotificaciones();
Future<DiagnosticoAlarmasAndroid> diagnostico();
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo();
/// Scheduling-reliability failures the native side recorded on its own
/// (pre-notice, foreground-service start, or post-boot reschedule) since
/// the last sync.
Future<List<FalloProgramacionNativo>> obtenerFallosProgramacionNativos();
}
class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
@@ -384,6 +445,26 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
}
}
@override
Future<List<FalloProgramacionNativo>> fallosNativosProgramacion() async {
try {
final raw = await _channel.invokeMethod<List<Object?>>(
'getNativeSchedulingFailures',
);
if (raw == null) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.map(FalloProgramacionNativo.fromMap)
.where((f) => f.alarmaId.isNotEmpty && f.tipo.isNotEmpty)
.toList();
} catch (e) {
// Never let a diagnostics read break alarm handling: an older build
// of the native side simply has no such channel method.
debugPrint('[PluriWave][alarmas] fallosNativosProgramacion ERROR $e');
return const [];
}
}
@override
Future<bool> solicitarPermisoAlarmasExactas() async {
final abierto = await _channel.invokeMethod<bool>(
@@ -416,6 +497,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
return abierto ?? false;
}
@override
Future<bool> abrirConfiguracionNotificaciones() async {
final abierto = await _channel.invokeMethod<bool>(
'openNotificationSettings',
);
return abierto ?? false;
}
@override
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
debugPrint('[PluriWave][alarmas] diagnostico android');
@@ -477,6 +566,20 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
.toList();
}
@override
Future<List<FalloProgramacionNativo>>
obtenerFallosProgramacionNativos() async {
final raw = await _channel.invokeMethod<List<Object?>>(
'getNativeSchedulingFailures',
);
if (raw == null || raw.isEmpty) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.map(FalloProgramacionNativo.fromMap)
.where((fallo) => fallo.alarmaId.isNotEmpty && fallo.tipo.isNotEmpty)
.toList();
}
Future<void> _logAndInvokeVoid(String method, Map<String, Object?> args) {
debugPrint('[PluriWave][alarmas] $method $args');
return _channel.invokeMethod<void>(method, args);
+224
View File
@@ -0,0 +1,224 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint, kReleaseMode;
import 'package:google_mobile_ads/google_mobile_ads.dart';
/// Official Google TEST ad unit ids. ALWAYS used outside release builds —
/// tapping your own real ad unit during development/testing is invalid
/// traffic and AdMob suspends accounts for it, so this is not optional.
const bannerAdUnitIdPrueba = 'ca-app-pub-3940256099942544/6300978111';
const interstitialAdUnitIdPrueba = 'ca-app-pub-3940256099942544/1033173712';
/// Real banner unit id, provisioned in the AdMob console (iap-freemium-unlock).
const _bannerAdUnitIdReal = 'ca-app-pub-6038935671414339/5658618378';
/// Real interstitial unit id, provisioned in the AdMob console (iap-freemium-unlock).
const _interstitialAdUnitIdReal = 'ca-app-pub-6038935671414339/4189478248';
/// TESTING-PHASE SWITCH. While `true`, release builds serve Google's official
/// TEST ad units instead of the real ones, so none of the closed-testing
/// human testers can generate invalid traffic against the AdMob account
/// (they cannot be registered as AdMob test devices). Flip to `false` for
/// the production release — that is the ONLY change needed to start serving
/// real ads. This does NOT affect the AdMob application id in
/// `AndroidManifest.xml`, which stays real in every build (it only
/// initializes the SDK and carries none of the click risk).
const usarAnunciosDePruebaEnRelease = true;
/// Real id in release builds only, and only once [usarAnunciosDePruebaEnRelease]
/// is flipped to `false`; test id everywhere else (debug/profile, including
/// internal-testing-track builds run via `flutter run --release` on a
/// personal device — see the "never tap your own ads" note above).
const bannerAdUnitId =
kReleaseMode && !usarAnunciosDePruebaEnRelease
? _bannerAdUnitIdReal
: bannerAdUnitIdPrueba;
const interstitialAdUnitId =
kReleaseMode && !usarAnunciosDePruebaEnRelease
? _interstitialAdUnitIdReal
: interstitialAdUnitIdPrueba;
/// Ads port + AdMob adapter (Design "Interfaces / Contracts", ADR-6): owns
/// the entitlement gate for both surfaces, the interstitial's session
/// frequency cap, and is the ONLY `google_mobile_ads` call site besides
/// `banner_anuncio_superior.dart`'s `BannerAd` widget wrapper. The frequency
/// cap and premium gating are pure/injectable (`ahora`,
/// `mostrarInterstitialImpl`) so they are unit-testable with a fake clock
/// and zero AdMob platform channels (Design Testing Strategy).
class ServicioAnuncios {
ServicioAnuncios({
required bool Function() esPremium,
DateTime Function()? ahora,
Future<bool> Function()? mostrarInterstitialImpl,
Duration? timeoutIntentoInterstitial,
}) : _esPremium = esPremium,
_ahora = ahora ?? DateTime.now,
_mostrarInterstitialImpl =
mostrarInterstitialImpl ?? _mostrarInterstitialAdMob,
_timeoutIntentoInterstitial =
timeoutIntentoInterstitial ?? timeoutIntentoInterstitialPorDefecto;
/// Session-scoped cap (ad-display spec "Interstitial Frequency Cap"): at
/// most 2 interstitials per process lifetime.
static const maxInterstitialsPorSesion = 2;
/// Minimum spacing between two interstitials (ad-display spec, same
/// requirement).
static const separacionMinima = Duration(minutes: 3);
/// FIX 2 (code review): bounds `InterstitialAd.load`'s callback wait
/// inside [_mostrarInterstitialAdMob] so a load callback that never fires
/// cannot hang a caller — every call site (`pantalla_alarmas.dart`,
/// `pantalla_favoritos.dart`,
/// `ajustes/pantalla_ajustes_emisoras_personalizadas.dart`) `await`s
/// [intentarInterstitial] before opening its form.
static const timeoutCargaInterstitialPorDefecto = Duration(seconds: 5);
/// FIX 2 (code review): bounds the wait for the ad to actually PRESENT
/// (`onAdShowedFullScreenContent`) or fail
/// (`onAdFailedToShowFullScreenContent`) after `show()`. This method
/// deliberately never waits for the ad to be DISMISSED — the caller is
/// not blocked on ad dismissal at all, only on the ad actually rendering.
static const timeoutPresentacionInterstitialPorDefecto = Duration(seconds: 5);
/// FIX 2 (code review): the overall bound applied around the INJECTED
/// [_mostrarInterstitialImpl] itself (production default: the sum of the
/// two timeouts above, plus headroom) — so ANY implementation, including
/// a future bug in an injected fake or a different ad SDK, can never hang
/// a caller indefinitely. Injectable so tests can use a short value.
static const timeoutIntentoInterstitialPorDefecto = Duration(seconds: 15);
final bool Function() _esPremium;
final DateTime Function() _ahora;
final Future<bool> Function() _mostrarInterstitialImpl;
final Duration _timeoutIntentoInterstitial;
int _mostrados = 0;
DateTime? _ultimoMostrado;
/// Ad-display spec "Persistent Top Banner": absent entirely for premium.
bool get debeMostrarBanner => !_esPremium();
bool _dentroDelCap() {
if (_esPremium()) return false;
if (_mostrados >= maxInterstitialsPorSesion) return false;
final ultimo = _ultimoMostrado;
if (ultimo != null && _ahora().difference(ultimo) < separacionMinima) {
return false;
}
return true;
}
/// Attempts to show an interstitial for one of the two allowed CTAs (add
/// station manually, add alarm). Callers are responsible for the ADR-6
/// ordering invariant themselves (cap-check-before-interstitial for
/// add-alarm, so a refusal is never preceded by an ad) — this method only
/// owns entitlement + frequency-cap gating, never the caller's own
/// business-rule ordering.
///
/// Returns whether an interstitial actually rendered. A failed/aborted ad
/// load (network, no fill) does NOT consume the session cap — only a
/// genuinely SHOWN ad does (Spec intent: the cap limits driver-facing
/// interruptions, not load attempts).
Future<bool> intentarInterstitial() async {
if (!_dentroDelCap()) return false;
// FIX 2 (code review): bound the injected implementation itself — no
// caller may ever await this indefinitely, regardless of what
// [_mostrarInterstitialImpl] does internally. A timeout is treated
// exactly like "no ad shown": `false`, cap not consumed.
final mostrado = await _mostrarInterstitialImpl().timeout(
_timeoutIntentoInterstitial,
onTimeout: () => false,
);
if (mostrado) {
_mostrados++;
_ultimoMostrado = _ahora();
}
return mostrado;
}
static Future<bool> _mostrarInterstitialAdMob() async {
try {
final cargaCompleter = Completer<InterstitialAd?>();
// FIX 2 (code review): a load callback that never fires used to hang
// this await forever. `expiradoCarga` guards a LATE callback that
// still arrives after the timeout — the ad is disposed instead of
// leaked, and never completes the already-abandoned completer.
var expiradoCarga = false;
await InterstitialAd.load(
adUnitId: interstitialAdUnitId,
request: const AdRequest(),
adLoadCallback: InterstitialAdLoadCallback(
onAdLoaded: (ad) {
if (expiradoCarga) {
ad.dispose();
return;
}
if (!cargaCompleter.isCompleted) cargaCompleter.complete(ad);
},
onAdFailedToLoad: (error) {
debugPrint('[PluriWave][anuncios] interstitial load ERROR $error');
if (!cargaCompleter.isCompleted) cargaCompleter.complete(null);
},
),
);
final InterstitialAd? cargado;
try {
cargado = await cargaCompleter.future.timeout(
timeoutCargaInterstitialPorDefecto,
);
} on TimeoutException {
expiradoCarga = true;
return false;
}
if (cargado == null) return false;
// FIX 6 (code review): only a genuinely PRESENTED ad may consume the
// session cap. `onAdFailedToShowFullScreenContent` used to complete
// the same completer as a real dismissal and the method returned
// `true` unconditionally — a failed-to-show ad silently burned one of
// only 2 session slots.
//
// FIX 2 (code review): this method no longer waits for the ad to be
// DISMISSED at all — only for it to PRESENT or fail to present — and
// that wait is itself bounded, so a `fullScreenContentCallback` that
// never fires cannot hang the caller either. `expiradoPresentacion`
// guards a late callback the same way `expiradoCarga` does above.
var expiradoPresentacion = false;
final presentacionCompleter = Completer<bool>();
cargado.fullScreenContentCallback = FullScreenContentCallback(
onAdShowedFullScreenContent: (ad) {
if (!presentacionCompleter.isCompleted) {
presentacionCompleter.complete(true);
}
},
onAdDismissedFullScreenContent: (ad) {
ad.dispose();
},
onAdFailedToShowFullScreenContent: (ad, error) {
if (expiradoPresentacion) {
ad.dispose();
return;
}
ad.dispose();
if (!presentacionCompleter.isCompleted) {
presentacionCompleter.complete(false);
}
},
);
await cargado.show();
try {
return await presentacionCompleter.future.timeout(
timeoutPresentacionInterstitialPorDefecto,
);
} on TimeoutException {
expiradoPresentacion = true;
await cargado.dispose();
return false;
}
} catch (e) {
debugPrint('[PluriWave][anuncios] interstitial ERROR $e');
return false;
}
}
}
File diff suppressed because it is too large Load Diff
+41 -6
View File
@@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart';
@@ -20,6 +19,20 @@ abstract class ObjetivoAudioInterrumpible {
/// Temporarily lowers ("ducks") the output volume without pausing.
Future<void> setAtenuado(bool atenuado);
/// Re-attaches the equalizer effect and re-pushes the current preset's
/// gains (fix "EQ Re-Apply After Audio-Focus Interruption"). Called after
/// resuming from a transient interruption pause and after un-ducking,
/// because Android's AudioEffect framework can let a higher-priority
/// client silently disable this app's effect instance while the
/// underlying player session id never changes — the existing session-id
/// rotation trigger (`ServicioAudio.debeReaplicarEcualizador`) therefore
/// never fires for a SHORT interruption (e.g. a nav-app voice prompt).
/// Idempotent and cheap (a `setEnabled` plus band `setGain` calls); takes
/// no argument by design — it re-asserts whatever enabled/disabled state
/// the handler ALREADY holds, so a caller here can never force the
/// equalizer on. Never restarts or repositions playback.
Future<void> reaplicarEcualizador();
}
/// Wrapper around `package:audio_session` (S3-R1): configures the session
@@ -44,9 +57,26 @@ class ServicioAudioSession {
Future<void> configurar() async {
try {
final sesion = await _obtenerSesion();
// DUCK, never pause, when another app asks for transient focus.
//
// `androidWillPauseWhenDucked: true` makes `audio_session` translate
// Android's AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK into a full PAUSE. In a
// car that fires constantly — every navigation instruction, every
// speed-camera warning, every "OK Google" — and each one used to stop
// the radio outright instead of dipping the volume for two seconds.
//
// Worse than the audio gap: a pause publishes `playing: false`, which
// `AudioService.setState` turns into `exitPlayingState()` and, with
// `androidStopForegroundOnPause`, into `stopForeground(...)`. A service
// that is no longer in the foreground is killable, and when Android
// took it the app vanished from the Android Auto pane mid-drive and
// another media app took its slot. Ducking keeps `playing: true`
// throughout, so the session, the notification and the car pane all
// survive an interruption — which is also what keeps the equalizer
// alive across it.
await sesion.configure(
const AudioSessionConfiguration.music().copyWith(
androidWillPauseWhenDucked: true,
androidWillPauseWhenDucked: false,
),
);
await _interrupcionesSub?.cancel();
@@ -58,10 +88,8 @@ class ServicioAudioSession {
(_) => unawaited(manejarDesconexionSalida()),
);
} catch (e) {
developer.log(
'[PluriWave] No se pudo configurar la sesion de audio: $e',
name: 'ServicioAudioSession',
level: 900,
debugPrint(
'[PluriWave][ServicioAudioSession] No se pudo configurar la sesion de audio: $e',
);
}
}
@@ -84,11 +112,18 @@ class ServicioAudioSession {
switch (evento.type) {
case AudioInterruptionType.duck:
await _objetivo.setAtenuado(false);
// Un-ducking never rotates the native player session id, so the
// session-id-change trigger never fires for this case — re-assert
// here too (belt-and-braces, additive to that trigger).
await _objetivo.reaplicarEcualizador();
case AudioInterruptionType.pause:
// Transient loss ended and the OS says we may resume.
if (_pausadoPorInterrupcion) {
_pausadoPorInterrupcion = false;
await _objetivo.reanudar();
// Same rationale as the duck branch above: a short transient
// interruption keeps the SAME player session id.
await _objetivo.reaplicarEcualizador();
}
case AudioInterruptionType.unknown:
// Permanent focus loss: never auto-resume.
+174
View File
@@ -0,0 +1,174 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:in_app_purchase/in_app_purchase.dart';
/// Outcome kinds the purchase stream can report (Design ADR-2). Mirrors
/// `in_app_purchase`'s [PurchaseStatus] but stays a PluriWave-owned type so
/// [EstadoEntitlement] never imports the plugin package directly — the SAME
/// port-boundary discipline `PuertoAlarmasAndroid` already applies.
enum TipoEventoCompra {
/// A fresh purchase completed successfully.
comprada,
/// [PuertoCompras.restaurar] found a prior purchase.
restaurada,
/// The user cancelled the purchase flow before it completed.
cancelada,
/// The purchase/restore flow failed (network, billing error, etc).
error,
/// [PuertoCompras.restaurar] completed with nothing to restore — NOT an
/// error (Spec "Restore finds nothing").
noEncontrada,
/// A purchase is in-flight (billing dialog shown, awaiting the user).
pendiente,
}
/// A single purchase-stream event (Design ADR-2). [mensaje] is populated
/// only for [TipoEventoCompra.error], for diagnostics/logging — never shown
/// to the user verbatim.
class EventoCompra {
const EventoCompra(this.tipo, {this.mensaje});
final TipoEventoCompra tipo;
final String? mensaje;
}
/// Purchase I/O abstraction (Design ADR-2): [EstadoEntitlement] depends on
/// this port, never on `in_app_purchase` directly — matches
/// `EstadoAlarmas(android: PuertoAlarmasAndroid)`'s injection shape, and
/// keeps Strict TDD viable with zero plugin channels in unit tests.
abstract class PuertoCompras {
/// Broadcasts every purchase-flow outcome (Design ADR-2) — [comprar] and
/// [restaurar] do not return the outcome directly because
/// `in_app_purchase`'s own API is stream-based (a purchase can complete
/// asynchronously well after the call that started it, e.g. after leaving
/// and returning to the app).
Stream<EventoCompra> get eventos;
/// Starts the one-time non-consumable purchase flow.
Future<void> comprar();
/// Re-queries Play Billing for a prior purchase on this account.
Future<void> restaurar();
}
/// The SOLE `in_app_purchase` call site (Design ADR-2) — every other file
/// depends on [PuertoCompras] instead.
class ServicioComprasPlayBilling implements PuertoCompras {
ServicioComprasPlayBilling({InAppPurchase? inAppPurchase})
: _iap = inAppPurchase ?? InAppPurchase.instance {
_sub = _iap.purchaseStream.listen(
_alRecibirCompras,
onError: (Object error) {
debugPrint('[PluriWave][compras] purchaseStream ERROR $error');
_eventos.add(
EventoCompra(TipoEventoCompra.error, mensaje: error.toString()),
);
},
);
}
/// The single non-consumable product id (Design "Interfaces / Contracts").
static const idProducto = 'pluriwave_premium';
final InAppPurchase _iap;
final _eventos = StreamController<EventoCompra>.broadcast();
StreamSubscription<List<PurchaseDetails>>? _sub;
@override
Stream<EventoCompra> get eventos => _eventos.stream;
@override
Future<void> comprar() async {
try {
final disponible = await _iap.isAvailable();
if (!disponible) {
_eventos.add(
const EventoCompra(
TipoEventoCompra.error,
mensaje: 'Play Billing no disponible',
),
);
return;
}
final respuesta = await _iap.queryProductDetails({idProducto});
final detalle = respuesta.productDetails.firstOrNull;
if (detalle == null) {
_eventos.add(
const EventoCompra(
TipoEventoCompra.error,
mensaje: 'Producto no encontrado en Play Console',
),
);
return;
}
final parametros = PurchaseParam(productDetails: detalle);
await _iap.buyNonConsumable(purchaseParam: parametros);
} catch (e) {
debugPrint('[PluriWave][compras] comprar ERROR $e');
_eventos.add(EventoCompra(TipoEventoCompra.error, mensaje: e.toString()));
}
}
@override
Future<void> restaurar() async {
try {
await _iap.restorePurchases();
} catch (e) {
debugPrint('[PluriWave][compras] restaurar ERROR $e');
_eventos.add(EventoCompra(TipoEventoCompra.error, mensaje: e.toString()));
}
}
void _alRecibirCompras(List<PurchaseDetails> compras) {
if (compras.isEmpty) {
// `restorePurchases()` with nothing to restore completes without ever
// pushing a PurchaseDetails (Spec "Restore finds nothing") — there is
// no per-call correlation in this stream, so this fires on ANY empty
// batch. In practice `queryPastPurchases`/`restorePurchases` on an
// account with nothing to restore is the only source of an empty
// batch this stream would ever emit.
return;
}
for (final compra in compras) {
_eventos.add(
eventoDesdeEstadoCompra(compra.status, mensaje: compra.error?.message),
);
if (compra.pendingCompletePurchase) {
unawaited(_iap.completePurchase(compra));
}
}
}
Future<void> dispose() async {
await _sub?.cancel();
await _eventos.close();
}
}
/// Pure mapping from `in_app_purchase`'s [PurchaseStatus] to the
/// PluriWave-owned [EventoCompra] (Design ADR-2's port boundary): pulled out
/// of [ServicioComprasPlayBilling] so it is unit-testable with zero plugin
/// channels, mirroring how `servicio_audio.dart` extracts its pure mapping
/// helpers (e.g. `mapearEstadoProceso`) out of the un-instantiable handler.
EventoCompra eventoDesdeEstadoCompra(PurchaseStatus status, {String? mensaje}) {
return switch (status) {
PurchaseStatus.pending => const EventoCompra(TipoEventoCompra.pendiente),
PurchaseStatus.purchased => const EventoCompra(TipoEventoCompra.comprada),
PurchaseStatus.restored => const EventoCompra(TipoEventoCompra.restaurada),
PurchaseStatus.error => EventoCompra(
TipoEventoCompra.error,
mensaje: mensaje,
),
PurchaseStatus.canceled => const EventoCompra(TipoEventoCompra.cancelada),
};
}
extension<T> on List<T> {
T? get firstOrNull => isEmpty ? null : first;
}
+106
View File
@@ -0,0 +1,106 @@
import 'dart:async';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:google_mobile_ads/google_mobile_ads.dart';
/// GDPR/UMP consent I/O abstraction (FIX 4, code review): every other file
/// depends on this port, never on the `google_mobile_ads` UMP classes
/// (`ConsentInformation`, `ConsentForm`) directly — matches
/// `PuertoCompras`'s injection shape, and keeps this testable with zero
/// AdMob/UMP platform channels in unit tests.
abstract class PuertoConsentimiento {
/// Requests consent info, loads-and-shows the consent form if required,
/// and resolves whether ads may be requested afterwards
/// (`ConsentInformation.canRequestAds()`). Implementations must NEVER
/// throw — any underlying failure degrades to `false` (no ads served),
/// never crashes or blocks the caller.
Future<bool> resolver();
}
/// The SOLE UMP call site (FIX 4) — every other file depends on
/// [PuertoConsentimiento] instead.
class ServicioConsentimientoUmp implements PuertoConsentimiento {
ServicioConsentimientoUmp({
ConsentRequestParameters? parametros,
Duration? timeoutActualizacion,
}) : _parametros = parametros ?? ConsentRequestParameters(),
_timeoutActualizacion =
timeoutActualizacion ?? const Duration(seconds: 10);
final ConsentRequestParameters _parametros;
final Duration _timeoutActualizacion;
@override
Future<bool> resolver() async {
try {
// 1. Request an up-to-date consent status. FIX 2's lesson applies
// here too: bound the callback-based wait so a callback that never
// fires cannot hang startup.
final actualizacionCompleter = Completer<void>();
ConsentInformation.instance.requestConsentInfoUpdate(
_parametros,
() {
if (!actualizacionCompleter.isCompleted) {
actualizacionCompleter.complete();
}
},
(error) {
debugPrint(
'[PluriWave][consentimiento] requestConsentInfoUpdate ERROR '
'${error.message}',
);
if (!actualizacionCompleter.isCompleted) {
actualizacionCompleter.complete();
}
},
);
await actualizacionCompleter.future.timeout(
_timeoutActualizacion,
onTimeout: () {},
);
// 2. Load-and-show the consent form ONLY IF the UMP SDK itself
// determines it is required (EEA/UK traffic, no prior valid
// consent) — this single call is a no-op everywhere else.
await ConsentForm.loadAndShowConsentFormIfRequired((formError) {
if (formError != null) {
debugPrint(
'[PluriWave][consentimiento] '
'loadAndShowConsentFormIfRequired ERROR ${formError.message}',
);
}
});
// 3. The only gate that matters for the caller: may ads be
// requested at all right now?
return await ConsentInformation.instance.canRequestAds();
} catch (e) {
debugPrint('[PluriWave][consentimiento] ERROR $e');
return false;
}
}
}
/// Orchestrates the whole gate (FIX 4): premium users NEVER see a consent
/// form at all — they get zero ads regardless of consent — so
/// [PuertoConsentimiento] is never even touched for them. Free-tier users
/// get the real flow, with any failure degrading silently to "ads not
/// allowed" rather than crashing or blocking `main()`.
Future<bool> resolverConsentimientoAnuncios({
required bool esPremium,
required PuertoConsentimiento consentimiento,
}) async {
if (esPremium) return false;
try {
return await consentimiento.resolver();
} catch (e) {
// Defense in depth: [PuertoConsentimiento.resolver] is documented to
// never throw, but a caller-provided implementation (fake or future
// adapter) failing to honor that contract still may not crash or block
// `main()`.
debugPrint(
'[PluriWave][consentimiento] resolverConsentimientoAnuncios ERROR $e',
);
return false;
}
}
@@ -157,7 +157,9 @@ class ServicioDispositivoAudioReal extends ServicioDispositivoAudio {
@override
Future<Map<String, String>> obtenerNombresEmparejados() async {
try {
final raw = await _methodChannel.invokeMethod<Map>('getBondedDeviceNames');
final raw = await _methodChannel.invokeMethod<Map>(
'getBondedDeviceNames',
);
if (raw == null) return const {};
return {
for (final entry in raw.entries)
+22 -17
View File
@@ -70,7 +70,10 @@ class ServicioEcualizador {
final porEmisora = _leerPresetsPorEmisora(prefs);
final presetsDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz);
final nombresDispositivos = _leerMapaStrings(prefs, _keyNombresDispositivos);
final nombresDispositivos = _leerMapaStrings(
prefs,
_keyNombresDispositivos,
);
return ConfiguracionEcualizador(
principal: principal,
porEmisora: porEmisora,
@@ -124,21 +127,22 @@ class ServicioEcualizador {
String prefijo,
) async {
final presetsPorDispositivo = _leerMapa(prefs, _keyPresetsPorDispositivo);
final dispositivos = presetsPorDispositivo.keys
.where((clave) => clave.startsWith(prefijo))
.toList();
final dispositivos =
presetsPorDispositivo.keys
.where((clave) => clave.startsWith(prefijo))
.toList();
final nombres = _leerMapaStrings(prefs, _keyNombresDispositivos);
final nombresAPurgar = nombres.keys
.where((clave) => clave.startsWith(prefijo))
.toList();
final nombresAPurgar =
nombres.keys.where((clave) => clave.startsWith(prefijo)).toList();
final matriz = _leerMapa(prefs, _keyPresetsMatriz);
final matrizAPurgar = matriz.keys.where((clave) {
final separador = clave.indexOf(':');
if (separador == -1) return false;
return clave.substring(separador + 1).startsWith(prefijo);
}).toList();
final matrizAPurgar =
matriz.keys.where((clave) {
final separador = clave.indexOf(':');
if (separador == -1) return false;
return clave.substring(separador + 1).startsWith(prefijo);
}).toList();
for (final clave in dispositivos) {
presetsPorDispositivo.remove(clave);
@@ -183,11 +187,12 @@ class ServicioEcualizador {
// (station UUIDs are RFC4122 and contain no colons — multi-device-eq
// ADR-3), since deviceId itself may contain colons (e.g. a MAC-based id).
final presetsMatriz = _leerMapa(prefs, _keyPresetsMatriz);
final clavesMatrizAPurgar = presetsMatriz.keys.where((clave) {
final separador = clave.indexOf(':');
if (separador == -1) return false;
return clave.substring(separador + 1) == deviceId;
}).toList();
final clavesMatrizAPurgar =
presetsMatriz.keys.where((clave) {
final separador = clave.indexOf(':');
if (separador == -1) return false;
return clave.substring(separador + 1) == deviceId;
}).toList();
if (clavesMatrizAPurgar.isNotEmpty) {
for (final clave in clavesMatrizAPurgar) {
presetsMatriz.remove(clave);
@@ -150,9 +150,15 @@ class ServicioProgramacionAlarmas {
if (!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)) {
return false;
}
// Only a deliberate user skip ever removes a candidate occurrence.
// Reliability-failure records share this same list/model (so the alarms
// list can surface them per-alarm via `ultimaExcepcionPara`), but they
// must never be mistaken for a skip — that would silently jump the
// alarm to its NEXT occurrence instead of just flagging the failed one.
return !excepciones.any(
(excepcion) =>
excepcion.alarmaId == alarma.id &&
excepcion.tipo == ExcepcionAlarma.tipoSaltoSiguiente &&
_mismaEjecucion(excepcion.ejecucion, candidato),
);
}
@@ -0,0 +1,32 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Persists whether the 9-screen help/tutorial carousel (`PantallaTutorialAyuda`)
/// has already been shown, so it renders once on the genuine first-run
/// sequence rather than on every launch.
///
/// Same injectable-`SharedPreferences`, versioned-key convention as
/// `ServicioBienvenida` -- a plain one-time boolean flag, not a per-app-version
/// "due again" scheme like `ServicioContenidoApp` uses: no existing user has
/// this flag set yet, regardless of whether they are a fresh install or an
/// upgrade, so a single flag already satisfies "show once, ever".
class ServicioTutorialAyuda {
ServicioTutorialAyuda({SharedPreferences? prefs}) : _prefs = prefs;
static const _keyTutorialVisto = 'pluri_tutorial_visto_v1';
final SharedPreferences? _prefs;
/// Injected startup instance (S3-R4); getInstance() is only a fallback.
Future<SharedPreferences> _resolverPrefs() async =>
_prefs ?? SharedPreferences.getInstance();
Future<bool> debeMostrarTutorial() async {
final prefs = await _resolverPrefs();
return !(prefs.getBool(_keyTutorialVisto) ?? false);
}
Future<void> marcarTutorialVisto() async {
final prefs = await _resolverPrefs();
await prefs.setBool(_keyTutorialVisto, true);
}
}
+122
View File
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:provider/provider.dart';
import '../estado/estado_entitlement.dart';
import '../servicios/servicio_anuncios.dart';
/// Entitlement-aware top-banner slot (Design ADR-6, ad-display spec
/// "Persistent Top Banner, Never Overlapping Content"). Collapses to
/// `SizedBox.shrink()` — zero reserved space, zero layout impact — whenever
/// the user is premium OR no ad has finished loading yet; only a
/// successfully loaded [BannerAd] renders a sized box around an [AdWidget].
/// Callers place this as a plain sibling in a `Column` ABOVE the existing
/// body (`app.dart`'s `_PaginaPrincipalState.build`) — this widget itself
/// never wraps its parent in a `Stack`/overlay.
class BannerAnuncioSuperior extends StatefulWidget {
const BannerAnuncioSuperior({super.key, this.alIntentarCargar});
/// Test-only hook (FIX 7, code review): fires exactly once per REAL load
/// ATTEMPT (`BannerAd(...).load()` call), independent of the load's
/// eventual outcome — lets a widget test count load attempts without a
/// real AdMob platform channel. Always `null` in production.
@visibleForTesting
final VoidCallback? alIntentarCargar;
@override
State<BannerAnuncioSuperior> createState() => _BannerAnuncioSuperiorState();
}
class _BannerAnuncioSuperiorState extends State<BannerAnuncioSuperior> {
BannerAd? _bannerAd;
bool _cargado = false;
/// FIX 7 (code review): explicit "load already attempted" flag. Before
/// this, the guard was `_bannerAd == null`, which stays `null` until a
/// load actually SUCCEEDS — so every `notifyListeners()` from ANY
/// provider this widget watches (`EstadoEntitlement` during a
/// purchase/restore in progress) plus theme/locale/`MediaQuery` changes
/// re-ran `didChangeDependencies` and spawned ANOTHER `BannerAd` +
/// `load()` call. Only the LAST loaded ad was ever disposed, leaking
/// every in-flight duplicate before it.
///
/// Retry policy (documented decision): a FAILED load is never retried
/// automatically — this flag is set once and never reset. Retrying on
/// every rebuild is exactly the bug this flag fixes; the next natural
/// retry opportunity is a fresh app session, which is an adequate cadence
/// for a non-critical, collapse-to-nothing UI element.
bool _cargaIntentada = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final servicio = context.read<ServicioAnuncios>();
if (!_cargaIntentada && servicio.debeMostrarBanner) {
_cargaIntentada = true;
_cargarBanner();
}
}
void _cargarBanner() {
widget.alIntentarCargar?.call();
// Fire-and-forget: a failure (no plugin channel in `flutter test`, no
// fill, offline) leaves `_bannerAd` `null` forever, which keeps this
// widget collapsed — exactly the same degrade-to-shrink path a genuine
// load failure takes in production. Never throws out of this method.
final anuncio = BannerAd(
size: AdSize.banner,
adUnitId: bannerAdUnitId,
request: const AdRequest(),
listener: BannerAdListener(
onAdLoaded: (ad) {
if (!mounted) {
ad.dispose();
return;
}
setState(() {
_bannerAd = ad as BannerAd;
_cargado = true;
});
},
onAdFailedToLoad: (ad, error) {
ad.dispose();
},
),
);
anuncio.load().catchError((_) {});
}
@override
void dispose() {
_bannerAd?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final entitlement = context.watch<EstadoEntitlement>();
if (entitlement.esPremium) return const SizedBox.shrink();
// Instant vanish-on-purchase (ad-display spec "Ads Vanish Immediately
// On Purchase"): even a banner that finished loading BEFORE this
// transition is dropped, never shown to a now-premium user.
if (!_cargado || _bannerAd == null) return const SizedBox.shrink();
final ad = _bannerAd!;
// FIX 1 (code review): the top-inset `SafeArea` now lives HERE, applied
// ONLY when an ad is actually about to render. `SafeArea` reserves
// `MediaQuery.padding.top` regardless of its child's own size — even a
// zero-size `SizedBox.shrink()` child — so the OLD unconditional
// `app.dart`-level `SafeArea(bottom: false, child: BannerAnuncioSuperior())`
// wrapper left a permanent blank status-bar-height strip both for
// premium users and for free users before the first ad finished
// loading. Collapsing (the two early returns above) now returns a
// TRULY zero-height widget, including no reserved padding.
return SafeArea(
bottom: false,
child: SizedBox(
width: ad.size.width.toDouble(),
height: ad.size.height.toDouble(),
child: AdWidget(ad: ad),
),
);
}
}
+8 -10
View File
@@ -8,6 +8,7 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../tema/pluriwave_tokens.dart';
import 'pluri_station_art_fallback.dart';
/// Item 23 / audit 4.3 + 6.5 (t4:226-232, 302-306): a flat, background-less
/// station row — square thumbnail, name, meta line, and a caller-supplied
@@ -234,10 +235,10 @@ class _ArteFilaEmisora extends StatelessWidget {
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
errorWidget: (_, __, ___) => _iconoFallback(),
);
}
return _iconoFallback(theme);
return _iconoFallback();
}
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
@@ -246,12 +247,9 @@ class _ArteFilaEmisora extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 22,
color: theme.colorScheme.onPrimaryContainer,
),
);
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
// bare `radio_rounded` icon — now the same shared fallback every other
// surface uses.
Widget _iconoFallback() =>
PluriStationArtFallback(seed: emisora.uuid, iconSize: 22);
}
+151
View File
@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_entitlement.dart';
import '../l10n/gen/app_localizations.dart';
import '../tema/pluriwave_tokens.dart';
import 'pluri_glass_surface.dart';
import 'pluri_layout.dart';
/// Reusable paywall sheet (Design "File Changes" — `hoja_premium.dart`),
/// opened from every gated entry point plus the Settings premium row
/// (freemium-gating spec "Purchase Entry Points At Every Gate Plus
/// Settings"). Mirrors `FormularioEmisoraPersonalizada`'s bottom-sheet
/// shape (`ajustes_emisoras_personalizadas.dart`).
Future<void> mostrarHojaPremium(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => const HojaPremium(),
);
}
class HojaPremium extends StatelessWidget {
const HojaPremium({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final entitlement = context.watch<EstadoEntitlement>();
final bottom = MediaQuery.of(context).viewInsets.bottom;
return Padding(
padding: EdgeInsets.fromLTRB(
PluriLayout.horizontal,
PluriLayout.horizontal,
PluriLayout.horizontal,
PluriLayout.horizontal + bottom,
),
child: PluriGlassSurface(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(
Icons.workspace_premium_rounded,
color: PluriWaveTokens.brand,
),
const SizedBox(width: 10),
Expanded(
child: Text(
l10n.funcionPremium,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
],
),
const SizedBox(height: 20),
// FIX 3 (code review): user-facing feedback for a failed
// purchase/restore, or a restore that found nothing — before
// this, `resultadoUsuario` had ZERO UI, so the spinner just
// stopped with no feedback at all. Never the raw
// `EventoCompra.mensaje` developer string — always the mapped,
// generic localized message.
if (entitlement.resultadoUsuario != null)
Padding(
key: const ValueKey('hoja-premium-resultado'),
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? Icons.error_outline_rounded
: Icons.info_outline_rounded,
size: 18,
color:
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? Theme.of(context).colorScheme.error
: Theme.of(context).textTheme.bodyMedium?.color,
),
const SizedBox(width: 8),
Expanded(
child: Text(
entitlement.resultadoUsuario ==
ResultadoEntitlementUsuario.error
? l10n.compraError
: l10n.restauracionSinCompras,
style: Theme.of(context).textTheme.bodyMedium,
),
),
IconButton(
key: const ValueKey('hoja-premium-resultado-descartar'),
icon: const Icon(Icons.close_rounded, size: 18),
onPressed: () => entitlement.consumirResultadoUsuario(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
),
],
),
),
if (entitlement.esPremium)
Padding(
key: const ValueKey('hoja-premium-activo'),
padding: const EdgeInsets.only(bottom: 12),
child: Text(
l10n.premiumActivo,
style: Theme.of(context).textTheme.bodyMedium,
),
)
else
FilledButton.icon(
key: const ValueKey('hoja-premium-comprar'),
onPressed:
entitlement.compraEnCurso
? null
: () => entitlement.comprar(),
icon:
entitlement.compraEnCurso
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.lock_open_rounded),
label: Text(l10n.desbloquearPremium),
),
const SizedBox(height: 10),
OutlinedButton(
key: const ValueKey('hoja-premium-restaurar'),
onPressed:
entitlement.compraEnCurso
? null
: () => entitlement.restaurar(),
child: Text(l10n.restaurarCompras),
),
],
),
),
);
}
}
+8 -10
View File
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
import '../pantallas/pantalla_reproductor.dart';
import '../servicios/servicio_audio.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_station_art_fallback.dart';
/// Barra inferior persistente con controles básicos de reproducción.
/// Toca la barra para abrir PantallaReproductor completa.
@@ -318,9 +319,9 @@ class _ArteMiniReproductor extends StatelessWidget {
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
errorWidget: (_, __, ___) => _iconoFallback(),
)
: _iconoFallback(theme),
: _iconoFallback(),
),
);
}
@@ -331,12 +332,9 @@ class _ArteMiniReproductor extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 20,
color: theme.colorScheme.onPrimaryContainer,
),
);
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
// bare `radio_rounded` icon — now the same shared fallback every other
// surface uses.
Widget _iconoFallback() =>
PluriStationArtFallback(seed: emisora.uuid, iconSize: 20);
}
+76 -46
View File
@@ -192,7 +192,10 @@ class PluriBottomNavigation extends StatelessWidget {
/// the balloon's opaque fill already covers the seam where the bar's own
/// shadow would otherwise show through.
List<BoxShadow> get _shellShadows => [
BoxShadow(color: Colors.white.withValues(alpha: 0.17), offset: const Offset(0, -1.5)),
BoxShadow(
color: Colors.white.withValues(alpha: 0.17),
offset: const Offset(0, -1.5),
),
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
offset: const Offset(0, 14),
@@ -214,6 +217,7 @@ class _PluriNavButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
final motion = context.pluriMotion;
return Semantics(
button: true,
selected: selected,
@@ -222,6 +226,11 @@ class _PluriNavButton extends StatelessWidget {
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
// Without a shape the ink splash and hover highlight paint as a
// full-bleed RECTANGLE over the cell — a hard square sitting on
// top of the icon, which is what users reported. The whole bar is
// built on 999-radius pills; the splash has to follow.
borderRadius: BorderRadius.circular(999),
child: Align(
alignment: Alignment.bottomCenter,
// t4/4a spec: the items row itself is 52px tall — this inner
@@ -238,54 +247,75 @@ class _PluriNavButton extends StatelessWidget {
// Flutter joins merged labels with `\n`, so screen readers
// would announce "Alarmas\nAlarmas" instead of "Alarmas".
child: ExcludeSemantics(
child: Transform.translate(
// t4/4a spec: active item lift `translateY(-15px)`.
offset: Offset(0, selected ? -15 : 0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Opacity(
// t4/4a spec: active icon full colour; inactive
// `rgba(242,247,250,.46)` — .46 applied here as
// uniform opacity dims both the fallback Icon
// (already `onSurface` from
// PluriIconVariant.filled) and the real raster
// badge asset identically.
opacity: selected ? 1 : 0.46,
child: PluriIcon(
glyph: item.glyph,
variant: PluriIconVariant.filled,
// t4/4a spec: icon `font-size:25px`/`23px`.
size: selected ? 25 : 23,
color: selected ? t.electricMagenta : null,
// Same ARB string the outer Semantics already
// uses — passing it explicitly skips
// PluriIcon's own AppLocalizations.of lookup
// (excluded from the tree above regardless).
semanticLabel: item.label,
),
),
if (selected) ...[
const SizedBox(height: 3),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
// t4/4a spec: label `font-size:11px;
// font-weight:800; line-height:1.25`, brand
// colour.
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(
fontSize: 11,
fontWeight: FontWeight.w800,
height: 1.25,
letterSpacing: 0,
color: t.electricMagenta,
// t4/4a spec: active item lift `translateY(-15px)`. Every
// property here used to change INSTANTLY while the balloon
// behind it slid with AnimatedPositioned — the balloon
// glided and its contents teleported, which read as a
// broken transition. All four now share the balloon's own
// duration and curve so the whole tab moves as one.
child: AnimatedSlide(
duration: motion.normal,
curve: Curves.easeOutCubic,
// Slide is expressed in fractions of the child's size;
// the icon column is ~40 tall, so -15px is about -0.375.
offset: Offset(0, selected ? -0.375 : 0),
child: AnimatedSize(
duration: motion.normal,
curve: Curves.easeOutCubic,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedOpacity(
duration: motion.normal,
curve: Curves.easeOutCubic,
// t4/4a spec: active icon full colour; inactive
// `rgba(242,247,250,.46)` — .46 applied here as
// uniform opacity dims both the fallback Icon
// (already `onSurface` from
// PluriIconVariant.filled) and the real raster
// badge asset identically.
opacity: selected ? 1 : 0.46,
child: TweenAnimationBuilder<double>(
duration: motion.normal,
curve: Curves.easeOutCubic,
tween: Tween<double>(end: selected ? 25 : 23),
builder:
(context, size, _) => PluriIcon(
glyph: item.glyph,
variant: PluriIconVariant.filled,
// t4/4a spec: `font-size:25px`/`23px`.
size: size,
color: selected ? t.electricMagenta : null,
// Same ARB string the outer Semantics
// already uses — passing it explicitly
// skips PluriIcon's own
// AppLocalizations.of lookup.
semanticLabel: item.label,
),
),
),
if (selected) ...[
const SizedBox(height: 3),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
// t4/4a spec: label `font-size:11px;
// font-weight:800; line-height:1.25`, brand
// colour.
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(
fontSize: 11,
fontWeight: FontWeight.w800,
height: 1.25,
letterSpacing: 0,
color: t.electricMagenta,
),
),
],
],
],
),
),
),
),
+46 -28
View File
@@ -29,41 +29,59 @@ class PluriRootHeader extends StatelessWidget {
/// needs nothing extra here).
final List<Widget> actions;
/// Fix `safearea-top-inset`: this is the CONTENT row's height only —
/// NOT this widget's total rendered height. `app.dart`'s root
/// `SafeArea(top: false, ...)` deliberately excludes the top inset (so
/// each root's own full-bleed background paints genuinely edge-to-edge
/// behind the status bar), which left this header's title/actions row
/// with zero top-inset awareness — flush at y=0 under the status bar /
/// camera cutout on every device. This widget now adds
/// `MediaQuery.paddingOf(context).top` ABOVE this content height itself
/// (see [build]), so the total rendered height is
/// `height + MediaQuery.paddingOf(context).top`. Callers doing
/// total-height math (none currently do — checked every `PluriRootHeader`
/// call site) must add that inset separately; this constant's MEANING
/// (content height) is unchanged.
static const double height = 56;
@override
Widget build(BuildContext context) {
final type = context.pluriType;
final l10n = AppLocalizations.of(context);
return SizedBox(
height: height,
child: Padding(
// S5: the prototype's own header padding is title-tier on the
// left, row-tier on the right (t4 e.g. Alarmas
// `padding:0 12px 0 20px`).
padding: const EdgeInsets.fromLTRB(
PluriLayout.titleHorizontal,
0,
PluriLayout.rowHorizontal,
0,
),
child: Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: type.sectionTitle,
final topInset = MediaQuery.paddingOf(context).top;
return Padding(
padding: EdgeInsets.only(top: topInset),
child: SizedBox(
height: height,
child: Padding(
key: const ValueKey('pluri-root-header-content'),
// S5: the prototype's own header padding is title-tier on the
// left, row-tier on the right (t4 e.g. Alarmas
// `padding:0 12px 0 20px`).
padding: const EdgeInsets.fromLTRB(
PluriLayout.titleHorizontal,
0,
PluriLayout.rowHorizontal,
0,
),
child: Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: type.sectionTitle,
),
),
),
...actions,
IconButton(
icon: const Icon(Icons.bedtime_outlined),
tooltip: l10n.sleepTimer,
onPressed: onSleepTimer,
),
],
...actions,
IconButton(
icon: const Icon(Icons.bedtime_outlined),
tooltip: l10n.sleepTimer,
onPressed: onSleepTimer,
),
],
),
),
),
);
+20 -7
View File
@@ -14,6 +14,13 @@ import 'pluri_layout.dart';
void showPluriSleepTimerSheet(BuildContext context) {
showModalBottomSheet(
context: context,
// Issue 2 (feedback-pruebas): without this, the sheet is capped to a
// FRACTION of the screen height. That never mattered while the sheet
// always closed immediately after picking a duration (see the removed
// `Navigator.pop` calls below) -- now that the countdown view actually
// stays open, its title + description + headline-sized remaining-time
// text can overflow that capped height on a real phone width.
isScrollControlled: true,
showDragHandle: true,
builder:
(ctx) => Consumer<EstadoRadio>(
@@ -79,12 +86,17 @@ void showPluriSleepTimerSheet(BuildContext context) {
Duration(seconds: segundos),
),
),
onPressed: () {
estado.iniciarTimerDuracion(
Duration(seconds: segundos),
);
Navigator.pop(ctx);
},
// Issue 2 (feedback-pruebas): no longer pops
// the sheet -- `estado.iniciarTimerDuracion`
// notifies this `Consumer<EstadoRadio>`,
// which swaps straight to the countdown
// view above so the user actually SEES the
// remaining time instead of the sheet just
// closing with no feedback.
onPressed:
() => estado.iniciarTimerDuracion(
Duration(seconds: segundos),
),
),
ActionChip(
avatar: const Icon(Icons.tune_rounded, size: 18),
@@ -93,8 +105,9 @@ void showPluriSleepTimerSheet(BuildContext context) {
final duracion =
await _pedirDuracionPersonalizada(ctx);
if (duracion == null || !ctx.mounted) return;
// Issue 2: same as above -- stays open on
// the countdown view rather than closing.
estado.iniciarTimerDuracion(duracion);
Navigator.pop(ctx);
},
),
],
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import '../l10n/gen/app_localizations.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_icon.dart';
/// Shared station-art fallback (feedback-pruebas issue 6).
///
/// Before this widget existed, `TarjetaEmisora` had the only good fallback —
/// a deterministic pick from 4 bundled illustrations (`_fallbackArtFor`),
/// with a gradient + player glyph as a last resort if the asset itself ever
/// fails to decode. Every OTHER surface that can render a station without
/// artwork (`FilaEmisoraPlana`'s flat rows, the Escuchar hero, the "Tus
/// emisoras" grid cell, the mini player, the full player) had its own,
/// separate, much poorer copy: a flat `primaryContainer`-coloured square
/// with a bare `radio_rounded` icon. This widget is the ONE shared
/// implementation every one of those call sites now uses instead.
///
/// The selection formula is pinned EXACTLY as `TarjetaEmisora` originally
/// had it (`seed.codeUnits.fold<int>(0, (a, b) => a + b) % 4`, asset order
/// aurora/cosmic/pulse/nova) — `lib/servicios/navegacion_auto.dart` (a
/// protected, empty-diff file) independently mirrors this SAME formula for
/// Android Auto's own drawable-resource rotation
/// (`test/servicios/navegacion_auto_test.dart`'s `indiceArtePara`/
/// `artUriPara` tests assert it inline, not by importing this widget) —
/// changing the order or the modulo here would silently desync the two
/// without either test suite noticing until an actual device compared them
/// side by side.
class PluriStationArtFallback extends StatelessWidget {
const PluriStationArtFallback({
super.key,
required this.seed,
this.iconSize = 22,
});
/// Typically the station's `uuid` — the seed that deterministically picks
/// one of the 4 bundled arts below.
final String seed;
/// Size of the centred player glyph drawn over the art.
final double iconSize;
static const _arts = [
'assets/images/station_art_aurora.png',
'assets/images/station_art_cosmic.png',
'assets/images/station_art_pulse.png',
'assets/images/station_art_nova.png',
];
/// Exposed so other call sites (and tests) can assert which asset a given
/// seed resolves to without needing to render the widget.
static String artFor(String seed) {
final index = seed.codeUnits.fold<int>(0, (a, b) => a + b) % _arts.length;
return _arts[index];
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
Image.asset(
artFor(seed),
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
context.pluriTokens.deepViolet,
context.pluriTokens.electricMagenta.withValues(
alpha: 0.8,
),
],
),
),
),
),
Center(
child: PluriIcon(
glyph: PluriIconGlyph.player,
variant: PluriIconVariant.activeGlow,
size: iconSize,
semanticLabel: AppLocalizations.of(context).stationIconLabel,
),
),
],
);
}
}
+6 -44
View File
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_glass_surface.dart';
import 'pluri_icon.dart';
import 'pluri_station_art_fallback.dart';
/// Tarjeta compacta para mostrar una emisora en listas y grids.
/// Incluye botón de favorito visible en ambos modos.
@@ -302,51 +303,12 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
);
}
// Issue 6 (feedback-pruebas): extracted into the shared
// `PluriStationArtFallback` so every surface that can render a station
// without artwork shows the SAME fallback — see that widget's own doc
// comment for why the selection formula must never drift.
Widget _iconoFallback(double size) {
final art = _fallbackArtFor(widget.emisora.uuid);
return Stack(
fit: StackFit.expand,
children: [
Image.asset(
art,
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
context.pluriTokens.deepViolet,
context.pluriTokens.electricMagenta.withValues(
alpha: 0.8,
),
],
),
),
),
),
Center(
child: PluriIcon(
glyph: PluriIconGlyph.player,
variant: PluriIconVariant.activeGlow,
size: size,
semanticLabel: AppLocalizations.of(context).stationIconLabel,
),
),
],
);
}
String _fallbackArtFor(String seed) {
const arts = [
'assets/images/station_art_aurora.png',
'assets/images/station_art_cosmic.png',
'assets/images/station_art_pulse.png',
'assets/images/station_art_nova.png',
];
final index = seed.codeUnits.fold<int>(0, (a, b) => a + b) % arts.length;
return arts[index];
return PluriStationArtFallback(seed: widget.emisora.uuid, iconSize: size);
}
}
@@ -0,0 +1,100 @@
# Apply Progress: iap-freemium-unlock
Mode: Strict TDD. Delivery: single-pr with `size:exception` (user-approved, single commit).
## Status: ALL 9 PHASES COMPLETE — 27/27 TASKS DONE
## TDD Cycle Evidence
| Task(s) | RED | GREEN | REFACTOR | Test file(s) |
|---|---|---|---|---|
| 0.1/0.2 | N/A (config) | pubspec.yaml + AndroidManifest.xml | N/A | N/A |
| 1.1-1.3 | `estado_entitlement_test.dart` written first, failed (no impl) | `estado_entitlement.dart` (`EstadoEntitlement`, `esPremiumPersistido`) | shared `_keyPremium` const, fail-open documented in doc comments | test/estado/estado_entitlement_test.dart |
| 2.1-2.2 | `servicio_compras_test.dart` (pure mapping) written first, failed | `servicio_compras.dart` (`PuertoCompras`, `ServicioComprasPlayBilling`, `eventoDesdeEstadoCompra` extracted for testability) | N/A | test/servicios/servicio_compras_test.dart |
| 3.1-3.2 | `estado_alarmas_gating_test.dart` written first, failed | `ResultadoGuardarAlarma` enum + `puedeCrearAlarma` + gated `guardarAlarma`/`crearRangoVacaciones` | N/A | test/estado/estado_alarmas_gating_test.dart |
| 3.3 | N/A (UI wiring, no new pure logic) | `pantalla_alarmas.dart` (cap-check+interstitial at the "+" CTA tap per ADR-6, snackbar+CTA on block) + `pantalla_vacaciones.dart` (paywall on block) | Corrected mid-run: interstitial originally placed at save time, moved to the CTA tap per design.md's literal "then open the editor" wording | Regression: pantalla_alarmas_editor_test.dart, pantalla_alarmas_fecha_test.dart, pantalla_vacaciones_test.dart |
| 4.1-4.2 | `estado_grabacion_gating_test.dart` written first, failed | `ResultadoIniciarGrabacion` enum + gated `iniciar()` | N/A | test/estado/estado_grabacion_gating_test.dart |
| 5.1 | `navegacion_auto_gating_test.dart` written first, failed | `raiz(premium:)`, `itemPremiumBloqueado()`, `respuestaBloqueadaPorEntitlement()` | N/A | test/servicios/navegacion_auto_gating_test.dart |
| 5.2 | `servicio_audio_gating_test.dart` written first, failed | `debeBloquearCambioDeEmisora()` wired into `playFromMediaId`/`playFromSearch`/`skipToNext`/`skipToPrevious` | N/A | test/servicios/servicio_audio_gating_test.dart |
| 5.3 | same file, `notificarDesbloqueoAuto`/`registrarNotificacionDesbloqueoAuto` cases | Discovered mid-implementation that `AudioService.notifyChildrenChanged` is deprecated in this `audio_service` version — implemented via `subscribeToChildren` override + per-id `BehaviorSubject` + `notificarHijosCambiaron`, which is what the plugin's own internal listener now forwards to the platform | Wired `registrarHandler` to push to all root-level ids on the hook | test/servicios/servicio_audio_gating_test.dart |
| 5.4 | (covered above) | `getChildren` checks `respuestaBloqueadaPorEntitlement` before any other resolution | N/A | (covered above) + regression: navegacion_auto_test.dart |
| 6.1-6.2 | `servicio_anuncios_test.dart` (fake clock) written first, failed | `ServicioAnuncios` cap/gating logic + AdMob adapter (test ad unit IDs, TODO-marked) | N/A | test/servicios/servicio_anuncios_test.dart |
| 6.3 | `banner_anuncio_superior_test.dart` written first, failed | `BannerAnuncioSuperior` widget + `app.dart` `Column[banner, Expanded(body)]` | N/A | test/widgets/banner_anuncio_superior_test.dart |
| 7.1 | N/A (wiring) | `hoja_premium.dart` + `EstadoEntitlement`/`ServicioAnuncios` registered in `app.dart`'s provider list (EstadoEntitlement FIRST so later `create` closures can `context.read` it) | N/A | Regression: app_test.dart, widget_test.dart |
| 7.2 | N/A (wiring) | Settings premium row (`pantalla_ajustes.dart`); interstitial-before-open at both station-add CTAs (`pantalla_favoritos.dart`, `ajustes_emisoras_personalizadas.dart`) | N/A | Regression: pantalla_ajustes_test.dart, pantalla_favoritos_test.dart, ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart |
| 8.1-8.3 | N/A (content) | 4 keys × 13 locales added to `app_*.arb`; `flutter gen-l10n` regenerated | N/A | literal-encoding scan clean |
| 9.1-9.3 | N/A (verification) | Full suite run in batches, equalizer grep-verified ungated, proposal.md checkboxes updated with verification notes | N/A | See Work Unit Evidence below |
## Files Changed
| File | Action | What Was Done |
|---|---|---|
| `pubspec.yaml` | Modified | Uncommented `in_app_purchase`, `google_mobile_ads` |
| `android/app/src/main/AndroidManifest.xml` | Modified | AdMob test app id meta-data (TODO to swap for real) |
| `lib/estado/estado_entitlement.dart` | Created | `EstadoEntitlement` ChangeNotifier + `esPremiumPersistido()` |
| `lib/servicios/servicio_compras.dart` | Created | `PuertoCompras` + `ServicioComprasPlayBilling` (sole `in_app_purchase` site) |
| `lib/servicios/servicio_anuncios.dart` | Created | `ServicioAnuncios` — banner/interstitial gating + frequency cap + AdMob adapter |
| `lib/widgets/banner_anuncio_superior.dart` | Created | Entitlement-aware top banner slot |
| `lib/widgets/hoja_premium.dart` | Created | Reusable paywall bottom sheet |
| `lib/estado/estado_alarmas.dart` | Modified | `ResultadoGuardarAlarma` enum, `puedeCrearAlarma()`, gated `guardarAlarma`/`crearRangoVacaciones`, `esPremium` injection (default `() => true`) |
| `lib/estado/estado_grabacion.dart` | Modified | `ResultadoIniciarGrabacion` enum, gated `iniciar()`, `esPremium` injection |
| `lib/estado/estado_radio.dart` | Modified | Threaded `esPremium` through to internal `EstadoGrabacion` |
| `lib/servicios/navegacion_auto.dart` | Modified | `raiz(premium:)`, `itemPremiumBloqueado()`, `respuestaBloqueadaPorEntitlement()` |
| `lib/servicios/servicio_audio.dart` | Modified | `getChildren`/`playFromMediaId`/`playFromSearch`/`skipToNext`/`skipToPrevious` gated; `subscribeToChildren` override + `notificarHijosCambiaron`; `registrarNotificacionDesbloqueoAuto`/`notificarDesbloqueoAuto` hook |
| `lib/pantallas/pantalla_alarmas.dart` | Modified | Cap-check + interstitial at the "+" CTA tap; cap snackbar + "Desbloquear Premium" CTA |
| `lib/pantallas/pantalla_vacaciones.dart` | Modified | Paywall sheet on gate block |
| `lib/pantallas/pantalla_reproductor.dart` | Modified | 3 record-start call sites route through the gate, open paywall on block |
| `lib/pantallas/pantalla_ajustes.dart` | Modified | Premium row (buy/restore/active) in APLICACIÓN group |
| `lib/pantallas/pantalla_favoritos.dart` | Modified | Interstitial before opening the add-station form |
| `lib/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas.dart` | Modified | Interstitial before opening the add-station form |
| `lib/app.dart` | Modified | `EstadoEntitlement`/`ServicioAnuncios` providers; `compras` injection param; banner `Column` wiring |
| `lib/main.dart` | Modified | `MobileAds.instance.initialize()`, `ServicioComprasPlayBilling` wiring |
| `lib/l10n/app_*.arb` (13 files) + `lib/l10n/gen/*` (regenerated) | Modified | `funcionPremium`, `limiteAlarmasAlcanzado`, `desbloquearPremium`, `restaurarCompras` |
| `openspec/changes/iap-freemium-unlock/proposal.md` | Modified | Success Criteria checked off with verification notes |
## Test Files Added
- test/estado/estado_entitlement_test.dart
- test/estado/estado_alarmas_gating_test.dart
- test/estado/estado_grabacion_gating_test.dart
- test/servicios/servicio_compras_test.dart
- test/servicios/servicio_anuncios_test.dart
- test/servicios/navegacion_auto_gating_test.dart
- test/servicios/servicio_audio_gating_test.dart
- test/widgets/banner_anuncio_superior_test.dart
## Test Files Modified (harness fixes — added `ServicioAnuncios`/`EstadoEntitlement` providers so pre-existing widget tests keep working against the new gated call sites)
- test/servicios/navegacion_auto_test.dart (3 `raiz()` call sites get `premium: true`)
- test/pantallas/pantalla_alarmas_fecha_test.dart
- test/pantallas/pantalla_ajustes_test.dart
- test/pantallas/pantalla_ajustes_row_values_test.dart
- test/pantallas/pantalla_favoritos_test.dart
- test/pantallas/ajustes/pantalla_ajustes_emisoras_personalizadas_test.dart
- test/pantallas/pluri_screen_header_retired_test.dart
- test/pantallas/root_header_wiring_test.dart
- test/widgets/pluri_push_scaffold_test.dart
## Deviations from Design (reported honestly)
1. **ADR-4 root/non-root reconciliation**: design.md's ADR-4 prose ("keeps the same visible folder labels for free users") and the android-auto-media spec's literal "rendered as ... explicitly locked item labeled as a premium feature" (for the ROOT) point in slightly different directions. Followed design.md/the orchestrator's own constraint summary: ROOT keeps real folder labels for every tier (regression-safe, byte-identical to today for premium); the lock is enforced one level down, at `getChildren`'s `respuestaBloqueadaPorEntitlement` choke point, which returns exactly one `itemPremiumBloqueado()` for ANY non-root id when free (including stale/deep-linked ids — the mandatory backstop).
2. **`notifyChildrenChanged` deprecated**: `audio_service` 0.18.18 deprecated the static `AudioService.notifyChildrenChanged` helper in favor of a `subscribeToChildren`-stream-based mechanism. Implemented `PluriWaveAudioHandler.subscribeToChildren` (a `BehaviorSubject` per parent id) + `notificarHijosCambiaron(id)`, which is what the plugin's own internal listener forwards to the platform. Functionally equivalent to the design's intent; the public hook name (`registrarNotificacionDesbloqueoAuto`/`notificarDesbloqueoAuto`) is unchanged.
3. **ADR-6 interstitial ordering — corrected mid-run**: initially implemented the alarm interstitial at SAVE time; corrected to fire at the "+" CTA tap (before the editor sheet even opens), matching design.md's literal "puedeCrearAlarma -> ... maybe-interstitial, then open the editor" and mirroring the add-station CTA's identical ordering.
4. **Default `esPremium` callbacks** in `EstadoAlarmas`/`EstadoGrabacion`/`EstadoRadio` default to `() => true` (ungated) when the caller doesn't inject one. This was necessary because 30+ pre-existing test files construct these classes with zero entitlement awareness and expect unrestricted (today's) behavior; production `app.dart` always wires the real `EstadoEntitlement`-backed callback. This is a deliberate, documented DI default, not a security gap — no production code path can reach the default.
5. **`crearRangoVacaciones` returns `bool`**, not `ResultadoGuardarAlarma` — vacations are a full premium gate (no free allowance), semantically distinct from the alarm cap's count-based enum, which design.md's Interfaces/Contracts scoped to `guardarAlarma` specifically.
6. **`PluriWaveApp` gained an optional `compras` constructor param** mirroring the existing `fuenteAuto` injection convention, so no pre-existing widget test ever touches the real `in_app_purchase` plugin channel; `main.dart` wires the real `ServicioComprasPlayBilling`.
7. **Paywall sheet copy stays minimal**: `HojaPremium` reuses the existing `l10n.equalizerActive` string for "active" state (an established codebase pattern for reusable generic labels) rather than inventing new arb keys beyond the 4 explicitly scoped in tasks.md, to keep the 13-locale translation surface bounded.
## Issues Found
- `dart format lib/ test/` (broad invocation) reformatted several pre-existing test files that were untouched semantically. These formatting-only diffs were identified via `git diff --stat` and reverted with `git checkout --` to keep this change scoped to the feature (avoiding an unrelated multi-hundred-line formatting diff riding along in the single-commit delivery).
- None outstanding beyond the above.
## Work Unit Evidence (cumulative, final)
- **Focused test command and result**: `flutter test test/estado/estado_entitlement_test.dart test/estado/estado_alarmas_gating_test.dart test/estado/estado_grabacion_gating_test.dart test/servicios/servicio_compras_test.dart test/servicios/servicio_anuncios_test.dart test/servicios/navegacion_auto_gating_test.dart test/servicios/servicio_audio_gating_test.dart test/widgets/banner_anuncio_superior_test.dart` → **48/48 passed**.
- **Runtime harness**: full regression suite run in batches — `test/estado/` (207 passed), `test/servicios/` (512 passed), `test/widgets/` (96 passed), `test/pantallas/` (~248+ across all 30 files, run in multiple batches, all passed after harness fixes), top-level (`app_test.dart`, `arranque_orientacion_test.dart`, `assets_contenido_declarados_test.dart`, `widget_test.dart` — 38 passed). A single `flutter test` full-suite invocation exceeds this environment's command timeout (~10 min); batched runs are the practical substitute and cover 100% of files. Manual on-device QA (Play Billing sandbox purchase, real AdMob rendering, car head-unit browse) is explicitly out of reach of this environment and remains outstanding — noted in `proposal.md`.
- **Rollback boundary**: every file in the "Files Changed" table above is independently revertable; `pubspec.yaml`/`AndroidManifest.xml` revert re-comments both plugins per `proposal.md`'s Rollback Plan (no migration, no schema change, versioned prefs key `compra_premium_v1` is ignored by older builds).
## Final Verification
- `flutter analyze`: clean (5 issues, all pre-existing/unrelated: 2 `deprecated_member_use` on `onReorder` predating this change, 1 pre-existing `unused_catch_stack`, 1 pre-existing `annotate_overrides` info in `estado_radio_test.dart`).
- `dart format`: applied to every file this change touches; unrelated pre-existing files swept up by a broad format invocation were reverted (see Issues Found).
- Literal-encoding scan (`Ã|Â|â€|<25FD>`) on all 13 touched `.arb` files: clean except one PRE-EXISTING false positive (`app_pt.arb`'s legitimate "REPETIÇÃO", unrelated to this change).
- Equalizer regression check: zero `esPremium`/`EstadoEntitlement`/`esPremiumPersistido` references in `estado_ecualizador.dart`, `servicio_ecualizador.dart`, `pantalla_ajustes_ecualizador.dart`, `ecualizador_widget.dart` — confirmed via `grep`.
@@ -0,0 +1,116 @@
# Design: Freemium unlock via one-time in-app purchase
## Technical Approach
One cross-cutting `EstadoEntitlement` notifier (idiomatic `EstadoIdioma` shape) plus a top-level prefs-lazy reader for headless callers. Gating is hybrid: UI CTAs open the paywall, state-layer choke points hold the authoritative check. Ads are a port + AdMob adapter; the banner is a layout sibling (never an overlay), the interstitial fires on a CTA's natural transition behind a frequency cap.
## Architecture Decisions
### ADR-1: Entitlement is a notifier plus a free function, not a singleton
**Choice**: `lib/estado/estado_entitlement.dart` exports `EstadoEntitlement extends ChangeNotifier` (optional injected `SharedPreferences`, key `compra_premium_v1`, `bool get esPremium`) **and** a top-level `Future<bool> esPremiumPersistido({SharedPreferences? prefs})` that reads the same key directly.
**Alternatives**: global singleton; passing the notifier into `PluriWaveAudioHandler`.
**Rationale**: `PluriWaveAudioHandler` registers before `runApp`, so no `BuildContext`/`Provider` exists. The free function mirrors `FuenteMusicaLocalAutoImpl._resolverPrefs()` (`musica_local_auto.dart:163`) — same convention, testable via `setMockInitialValues`, no lifecycle to leak.
### ADR-2: Purchase I/O behind a port
**Choice**: `PuertoCompras` abstraction (`comprar`, `restaurar`, `Stream<EventoCompra>`) with `ServicioComprasPlayBilling` as the only `in_app_purchase` call site; `EstadoEntitlement` takes `PuertoCompras?`.
**Alternatives**: calling `InAppPurchase.instance` from the notifier.
**Rationale**: matches `EstadoAlarmas(android: PuertoAlarmasAndroid)`; keeps Strict TDD viable with zero plugin channels in unit tests.
**Fail-open**: only `purchased`/`restored` writes `true`. Errors, timeouts and offline never write `false`; the persisted flag is the source of truth at cold start.
### ADR-3: Gate placement (4 gates)
| Gate | Authoritative check | UI paywall entry |
|---|---|---|
| Alarm cap > 5 | `EstadoAlarmas.guardarAlarma` (`estado_alarmas.dart:104`) | `_EditorAlarmaSheet` save + the add CTA in `pantalla_alarmas.dart` |
| Alarm vacations | `EstadoAlarmas.crearRangoVacaciones` (`:510`) | `pantalla_vacaciones.dart` — `vacation-add-header` + `_CtaAnadirRango` |
| Recording | `EstadoGrabacion.iniciar` (`estado_grabacion.dart:90`) | 3 call sites in `pantalla_reproductor.dart` |
| Android Auto | `getChildren` / `playFromMediaId` / `playFromSearch` / `skipToNext-Previous` in `servicio_audio.dart` | none (car never shows a purchase flow) |
The phone equalizer is **not** gated.
### ADR-4: Auto reduced mode = real root labels, locked children, locked switching
**Choice**: `ConstructorArbolAuto.raiz({required bool incluirMusicaLocal, required bool premium})` keeps the same visible folder labels for free users; `getChildren` resolves entitlement once via `esPremiumPersistido()` and, when free, returns exactly `[itemPremiumBloqueado()]` (non-playable, id `premium:info`, hardcoded Spanish label like every other car label) for **any** non-root `parentMediaId`. Station switching is additionally blocked at `playFromMediaId`, `playFromSearch`, `skipToNext`/`skipToPrevious` (no-op returns).
**Alternatives**: empty root; omitting the folders entirely.
**Rationale**: head units cache browse trees, so a stale `emisora:<uuid>` tap would bypass `getChildren` — the play-path gates are mandatory, not belt-and-braces. Keeping labels + one explicit locked item guarantees no blank list. Play/pause/stop of the already-playing station are untouched.
### ADR-5: Distinct alarm-limit signal
**Choice**: `guardarAlarma` returns `ResultadoGuardarAlarma { guardada, limiteAlcanzado }`; `_error` stays reserved for native scheduling failures. Pure query `bool puedeCrearAlarma` (count = `_alarmas.length`, enabled or not; edits of an existing id always pass).
**Rationale**: overloading `_error` would surface a limit as a scheduling failure in `app.dart`'s snackbar path. Grandfathering falls out for free — nothing is deleted, only new creation past 5 is refused.
### ADR-6: Banner reserves layout; interstitial is cap-checked first
**Choice**: In `_PaginaPrincipalState.build`, `body:` becomes `Column[ SafeArea(bottom:false, child: BannerAnuncioSuperior), Expanded(existing SafeArea+AnimatedSwitcher) ]`. Premium or unloaded ⇒ `SizedBox.shrink()` (zero layout impact). Never a `Stack`/overlay.
**Interstitial ordering (add-alarm)**: `puedeCrearAlarma` → if false, show the limit message and **no ad**; if true, maybe-interstitial, then open the editor. Add-station: interstitial on the CTA tap, before `FormularioEmisoraPersonalizada` opens.
**Frequency cap**: in-memory in `ServicioAnuncios` — max 2 interstitials per process lifetime and ≥3 min apart; over cap ⇒ silent no-op.
**Rationale**: an ad followed by "you can't create this" is both hostile and an AdMob disruptive-ad policy risk.
## Data Flow
Play Billing ──→ PuertoCompras ──→ EstadoEntitlement ──→ prefs(compra_premium_v1)
│ │
UI (Provider.watch)┘ │
▼
PluriWaveAudioHandler.getChildren ──→ esPremiumPersistido() ──────┘ (no Provider)
## File Changes
| File | Action | Description |
|---|---|---|
| `lib/estado/estado_entitlement.dart` | Create | Notifier + `esPremiumPersistido()` |
| `lib/servicios/servicio_compras.dart` | Create | `PuertoCompras` + Play Billing adapter |
| `lib/servicios/servicio_anuncios.dart` | Create | Banner/interstitial port + AdMob adapter + frequency cap |
| `lib/widgets/banner_anuncio_superior.dart` | Create | Entitlement-aware banner slot |
| `lib/widgets/hoja_premium.dart` | Create | Paywall sheet, reused by every gate |
| `lib/app.dart` | Modify | Provider registration + banner Column |
| `lib/estado/estado_alarmas.dart` | Modify | `puedeCrearAlarma`, `ResultadoGuardarAlarma`, vacation gate |
| `lib/estado/estado_grabacion.dart` | Modify | Recording gate in `iniciar` |
| `lib/servicios/navegacion_auto.dart` | Modify | `raiz(premium:)`, `itemPremiumBloqueado()` |
| `lib/servicios/servicio_audio.dart` | Modify | Entitlement gate in browse + play paths |
| `lib/pantallas/pantalla_ajustes.dart` | Modify | Purchase + restore rows |
| `lib/pantallas/pantalla_alarmas.dart`, `pantalla_vacaciones.dart`, `pantalla_reproductor.dart`, `pantalla_favoritos.dart`, `ajustes/pantalla_ajustes_emisoras_personalizadas.dart` | Modify | Contextual upsell / interstitial trigger |
| `pubspec.yaml` | Modify | Activate `in_app_purchase`, `google_mobile_ads` |
| `lib/l10n/app_*.arb` | Modify | Paywall, limit message, restore strings |
## Interfaces / Contracts
```dart
class EstadoEntitlement extends ChangeNotifier {
EstadoEntitlement({SharedPreferences? prefs, PuertoCompras? compras});
static const idProducto = 'pluriwave_premium';
bool get esPremium;
bool get compraEnCurso;
Future<void> comprar();
Future<void> restaurar();
}
Future<bool> esPremiumPersistido({SharedPreferences? prefs});
enum ResultadoGuardarAlarma { guardada, limiteAlcanzado }
```
## Testing Strategy
| Layer | What to Test | Approach |
|---|---|---|
| Unit | Entitlement persistence, fail-open on error, restore | Fake `PuertoCompras` + `setMockInitialValues` |
| Unit | `puedeCrearAlarma` at 4/5/6, edit-at-cap, vacations, recording | `EstadoAlarmas(prefs:)`/`EstadoGrabacion` directly |
| Unit | `raiz(premium:false)`, locked-child for every parent id, play-path no-ops | Pure `ConstructorArbolAuto` + handler fakes |
| Unit | Interstitial cap (2/session, 3 min) and cap-before-ad ordering | Fake clock in `ServicioAnuncios` |
| Widget | Banner absent when premium; no overlap on all 5 tabs | `pumpWidget(PluriWaveApp(prefs:))` + golden-free layout asserts |
| Widget | Limit message with secondary unlock action, paywall from each gate | Existing `pantalla_*_test.dart` conventions |
## Threat Matrix
N/A — no routing, shell, subprocess, VCS/PR automation, executable-file classification, or process-integration boundary. Android Auto media-id dispatch is pre-existing in-process routing, not shell/process execution.
## Migration / Rollout
No migration. Additive and prefs-backed; absent key = free. Revert by re-commenting both plugins and reverting the gate commits. Versioned key (`compra_premium_v1`) is ignored by older builds.
## Open Questions
- [ ] Price point (Play Console decision).
- [ ] AdMob ad unit IDs (banner + interstitial) not yet provisioned; test IDs until then.
- [x] ~~Should a cached head-unit tree be actively invalidated (`notifyChildrenChanged`) at purchase time, or is the next browse refresh enough?~~ **RESOLVED (orchestrator): actively invalidate.** On the entitlement transition to premium, call `notifyChildrenChanged` for the affected parent ids. Rationale: the same head-unit caching that forces the `playFromMediaId` guard in ADR-4 also means a purchaser would otherwise keep seeing the locked tree until the unit re-binds — plausibly the rest of the drive. A user who just paid and still sees "Premium feature" in the car reads that as a broken purchase, which is a refund and a one-star review. Relying on the next browse refresh trades a cheap, bounded call for a highly visible failure. The invalidation is one-directional and only fires on the free → premium transition; there is no premium → free transition to handle (the purchase is permanent and entitlement never writes `false`, per ADR-2).
@@ -0,0 +1,47 @@
# Exploration: iap-freemium-unlock
One-time non-consumable IAP that removes ads and unlocks 6 currently-free features. Free-tier users see ads (`google_mobile_ads`, commented out in pubspec.yaml, never activated). Purchasers get zero ads and full access forever from a single purchase (not a subscription).
## Current State
**State/persistence architecture.** `lib/app.dart` (`PluriWaveApp.build`) wires a `MultiProvider` at the app root: `ChangeNotifierProvider<EstadoRadio>`, three `ListenableProvider`s exposing `EstadoRadio`'s owned children (`EstadoEcualizador`, `EstadoGrabacion`, `EstadoBusqueda`), then independent siblings `ChangeNotifierProvider<EstadoAlarmas>`, `ChangeNotifierProvider<EstadoIdioma>`, `ChangeNotifierProvider<EstadoNavegacionRaiz>`. A single `SharedPreferences` instance is resolved once in `lib/main.dart` and injected as `prefs` into every top-level notifier.
Idiomatic per-domain notifier shape (cleanest example: `lib/estado/estado_idioma.dart`): `ChangeNotifier` subclass, optional injected `SharedPreferences?`, a `_resolverPrefs()` fallback to `SharedPreferences.getInstance()` (works from headless callers with no DI), a versioned key constant, `notifyListeners()` after every mutation+persist.
**No existing tier/limit/entitlement concept anywhere** — confirmed via grep across `lib/modelos/alarma_musical.dart`, `lib/estado/estado_alarmas.dart`, `lib/servicios/servicio_alarmas.dart`.
**pubspec.yaml** (version `1.3.0+151`): `google_mobile_ads` and `in_app_purchase` both commented out, lines ~52-56. Neither is an active dependency.
**Fastlane/CI**: `fastlane/Appfile` → `package_name` = `es.freetimelab.pluriwave`; `fastlane/Fastfile` has one lane (`upload_internal`) publishing to Play's `internal` track; `.gitea/workflows/build.yml` auto-bumps version and calls that lane. No in-app-product ID or billing config exists anywhere in CI/fastlane — that's Play Console-side config only, zero CI/fastlane code changes required for this change.
## Affected Areas (gating points per feature)
1. **Equalizer** — `lib/estado/estado_ecualizador.dart`, screen `lib/pantallas/ajustes/pantalla_ajustes_ecualizador.dart`. UI entry: `lib/pantallas/pantalla_ajustes.dart` ~L108-123 (`FilaAjuste.onTap` → push `PantallaAjustesEcualizador`). Second surface: Android Auto's always-present `idEcualizador` folder + on/off custom action in `servicio_audio.dart`/`navegacion_auto.dart` — closed automatically once Android Auto itself is gated.
2. **Android Auto** — `lib/servicios/navegacion_auto.dart`'s pure `ConstructorArbolAuto` feeds `lib/servicios/servicio_audio.dart:1667` `getChildren()` → `constructor.raiz(...)`, the single dispatch point for the whole car tree. `PluriWaveAudioHandler` is registered in `main.dart` before `runApp`, so any gate here must read entitlement via a prefs-lazy fallback, never `BuildContext`/`Provider`.
3. **Alarm vacations** — `lib/pantallas/pantalla_vacaciones.dart` (2 create CTAs: header button `'vacation-add-header'`, mid-page `_CtaAnadirRango`), `lib/estado/estado_alarmas.dart` (`crearRangoVacaciones`/`editarRangoVacaciones`/`eliminarRangoVacaciones`/`guardarVacaciones` + 4 pure queries), `lib/servicios/servicio_alarmas.dart`. Entry from Alarms root: `lib/pantallas/pantalla_alarmas.dart`'s `_PanelVacaciones` (L93).
4. **Station recording** — `lib/servicios/servicio_grabacion_radio.dart` (engine), `lib/estado/estado_grabacion.dart`'s `EstadoGrabacion.iniciar({Duration? duracion})` (L90) is the single choke point for ≥3 UI call sites (`pantalla_reproductor.dart`'s recording panel ~L489-560, duration-picker sheet ~L601-724, mini-player shortcut `'player-tool-record'` ~L1064). `pantalla_grabaciones.dart`/`pantalla_ajustes_grabaciones.dart` manage *existing* recordings and should probably stay accessible regardless of entitlement.
5. **Alarm count limit (new)** — `EstadoAlarmas.guardarAlarma` (L104) is the one save call for create+edit; UI create/edit distinction lives in `_EditorAlarmaSheet` (`pantalla_alarmas.dart`, `widget.alarma == null` checks, save call ~L1189). Today's only failure channel is a `String? _error` used for native scheduling failures — a limit rejection needs a distinct signal, not reuse of `_error`.
6. **Ads** — zero ad code exists anywhere yet. Best candidates: (a) one global anchor in `lib/app.dart`'s `_PaginaPrincipalState.build` bottom `Column` (alongside `MiniReproductor`), covering all 5 tabs with one wiring point; (b) a `SliverToBoxAdapter` row in `PantallaInicio`'s `CustomScrollView` (mirrors `_seccionTusEmisoras`).
## Recommended entitlement architecture
New `lib/estado/estado_entitlement.dart` `ChangeNotifier`, shaped like `EstadoIdioma` (injected optional `SharedPreferences`, versioned key e.g. `compra_premium_v1`, `bool get esPremium`, prefs-lazy fallback for the Android Auto path), registered as an independent sibling `ChangeNotifierProvider` in `app.dart` (not owned by `EstadoRadio` — it's cross-cutting).
## Approaches considered
1. **UI-entry-point gating only** (6 call sites) — small, reviewable diffs, matches idiomatic pattern; risk of a missed call site on future refactors. Effort: Medium.
2. **State-method-layer gating only** — unbypassable, but silent no-op UX unless paired with UI copy anyway (not a real alternative to #1). Effort: Medium-High.
3. **Hybrid (recommended)** — UI entries show the paywall (good UX) + state-layer choke points (`guardarAlarma`, `EstadoGrabacion.iniciar`, Android Auto `getChildren`) carry the authoritative check. Effort: Medium.
## Risks
- Grandfathering: devices with 6+ alarms already before ship — candidate: grandfather existing, block only future creates once count ≥ 5 (needs design sign-off).
- Restore-purchases flow for reinstalls/new devices — no UI placement decided yet.
- Offline/failed entitlement checks — candidate: fail-open (trust last-persisted local flag) over fail-closed.
- No backend exists in this codebase — entitlement will be client/Play-Billing-trusted only, an accepted risk unless design decides otherwise.
- Android Auto's headless cold-start path requires the same "resolve prefs lazily, no DI at construction" convention already used by `FuenteMusicaLocalAutoImpl`.
- Alarm-count rule (all alarms vs. only active/enabled) is undecided and affects UX.
## Ready for Proposal
Yes.
@@ -0,0 +1,102 @@
# Proposal: Freemium unlock via one-time in-app purchase
## Intent
PluriWave (1.3.0+151, Internal Testing) has no monetization. Add one non-consumable purchase that permanently removes ads and unlocks the premium feature set, keeping the free tier usable. Purchasers get everything forever, restorable after reinstall, with no renewal or expiry concept.
## Scope
### In Scope
- `EstadoEntitlement` ChangeNotifier (SharedPreferences, versioned key, prefs-lazy resolve for headless Android Auto), top-level provider in `app.dart`.
- Activate `in_app_purchase`: buy flow, purchase stream, `restorePurchases()` from Settings.
- Activate `google_mobile_ads`: persistent top banner anchored in `app.dart` (must not overlap or displace existing content), plus a full-screen interstitial before two specific actions — adding a station manually and adding an alarm. All ads absent when premium.
- Gate 4 features: Android Auto reduced mode, alarm vacations, starting recordings, creating alarms past 5.
- Paywall reachable from every gated entry point (Settings row + contextual upsell at each gate); distinct "limit reached" signal from `EstadoAlarmas.guardarAlarma` (not the existing `_error`).
### Out of Scope
- Price point and Play Console product setup (console-side, undecided).
- Server-side receipt validation — no backend exists; client + Play Billing trust accepted for v1.
- Subscriptions, trials, promo codes, iOS store setup, CI/fastlane changes (none needed).
- Deleting, hiding, or trimming content free users already created.
- **The equalizer on the phone**: explicitly stays free for all users (user decision). Only its Android Auto surface is affected, as a consequence of Auto reduced mode.
## Business Rules
| Rule | Decision |
|------|----------|
| Purchase | Non-consumable, permanent, per Play account |
| Alarm cap | Free tier = 5 alarms total, enabled or not |
| Alarm cap UX | 6th attempt shows an explanatory message with a secondary "unlock" action — never a bare paywall jump |
| Grandfathering | Existing alarms/vacations/recordings survive; only new creation past the cap is blocked |
| Entitlement failure | Fail-open: trust last persisted flag; never lock out a payer offline |
| Equalizer (phone) | Free for everyone — not a gated feature |
| Android Auto (free) | Reduced mode: current-station player only. No station browsing/switching, no local music. Every other car entry shows a "Premium feature" item |
| Ads — banner | Persistent top banner, laid out so it never overlaps or covers existing UI |
| Ads — interstitial | Full-screen ad before adding a station manually and before adding an alarm |
| Ads lifecycle | Vanish immediately on purchase, no restart |
| Purchase entry points | Settings row + contextual upsell at each gated feature |
| Existing content | Viewing/managing stays free; only new gated actions are blocked |
## Capabilities
### New Capabilities
- `premium-entitlement`: purchase, restore, persistence, offline policy.
- `freemium-gating`: gated features, limits, and how a free user is informed.
- `ad-display`: ad placement and lifecycle for free users only.
### Modified Capabilities
- `android-auto-media`: browse tree becomes entitlement-aware — free tier collapses to a current-station-player-only tree.
## Approach
Hybrid gating (exploration approach 3): UI entry points show the paywall; state-layer choke points (`guardarAlarma`, `EstadoGrabacion.iniciar`, Auto `getChildren`) hold the authoritative check.
## Affected Areas
| Area | Impact | Description |
|------|--------|-------------|
| `lib/estado/estado_entitlement.dart` | New | Entitlement, purchase, restore |
| `lib/app.dart` | Modified | Provider registration, top banner anchor |
| `lib/estado/estado_alarmas.dart`, `estado_grabacion.dart` | Modified | Cap, vacation gate, recording gate |
| `lib/servicios/servicio_audio.dart`, `navegacion_auto.dart` | Modified | Gate car tree |
| `lib/pantallas/` (ajustes, vacaciones, alarmas, reproductor) | Modified | Paywall on gated CTAs |
| `pubspec.yaml` | Modified | Uncomment both plugins |
## Risks
| Risk | Likelihood | Mitigation |
|------|------------|------------|
| Client-only entitlement is tamperable | Med | Accepted for v1; no backend exists |
| Cap feels like data loss | Med | Grandfather all data; explain at creation time |
| Headless Auto has no Provider | Med | Prefs-lazy resolve, mirror `FuenteMusicaLocalAutoImpl` |
| Missed gate on a call site | Low | State-layer choke points as backstop |
| Interstitial before add-alarm/add-station reads as punitive, or trips AdMob's disruptive-ad policy | Med | Interstitial fires on the action's natural transition, never mid-task; enforce a frequency cap so repeated adds in one session don't chain ads; never stack it with the alarm-cap message in the same tap |
| Auto reduced mode leaves a free driver with an empty-looking car UI | Med | Current-station player always present; every locked branch renders an explicit "Premium feature" item, never a blank list |
## Rollback Plan
Additive and prefs-backed. Revert by re-commenting both plugins in `pubspec.yaml` and reverting the gate commits; no migration, no schema change. The persisted key is versioned (`compra_premium_v1`) so older builds ignore it.
## Dependencies
- Play Console in-app product created and priced; AdMob ad unit IDs.
## Success Criteria
- [x] Purchase unlocks every gated item with no restart and survives restart. Verified at the unit level: `EstadoEntitlement.comprar()`/`restaurar()` flip `esPremium` and `notifyListeners()` immediately on a `comprada`/`restaurada` event (no restart needed by construction — every gate reads `esPremium`/`esPremiumPersistido()` live), and the flag persists under `compra_premium_v1`. Full on-device Play Billing QA is still outstanding (deferred — no sandbox purchase available in this environment).
- [x] `restorePurchases()` restores entitlement on a fresh install. Verified: `estado_entitlement_test.dart` covers found/not-found restore outcomes.
- [x] Free tier blocks the 4 gated features and caps alarms at 5 without destroying data. Verified: `estado_alarmas_gating_test.dart` (cap + grandfathering), `estado_grabacion_gating_test.dart` (recording), `navegacion_auto_gating_test.dart`/`servicio_audio_gating_test.dart` (Android Auto).
- [x] Equalizer remains fully usable on the phone for free users. Verified: zero `esPremium`/`EstadoEntitlement`/`esPremiumPersistido` references anywhere in `estado_ecualizador.dart`, `servicio_ecualizador.dart`, `pantalla_ajustes_ecualizador.dart`, `ecualizador_widget.dart`.
- [x] Free-tier Android Auto still plays the current station and never shows a blank list. Verified: `respuestaBloqueadaPorEntitlement` never returns an empty list, `raiz(premium:)` keeps the root non-blank for every tier, and `debeBloquearCambioDeEmisora` only gates `playFromMediaId`/`playFromSearch`/`skipToNext`/`skipToPrevious` — `play`/`pause`/`stop` are untouched.
- [x] Zero ads (banner and interstitial) for purchasers; offline cold start keeps a purchaser unlocked. Verified: `ServicioAnuncios.debeMostrarBanner`/`intentarInterstitial` gate on `esPremium` first; offline cold start is `esPremiumPersistido`'s fail-open persisted-flag read.
- [x] Top banner never overlaps, covers, or displaces existing UI on any tab. Verified: `banner_anuncio_superior_test.dart` + `app.dart`'s `Column[banner, Expanded(body)]` (never a `Stack`).
Real-device/Play Console/AdMob QA (purchase flow, restore on a fresh install, car head-unit browse, live ad rendering) remains outstanding per the Work Unit runtime-harness notes in `tasks.md` — none of it is exercisable from this environment.
## Open Questions
1. Price point (Play Console decision; 2.99–4.99 EUR was a benchmark, never confirmed).
@@ -0,0 +1,330 @@
# Spec: iap-freemium-unlock
Combined view of all domain specs for this change. Authoritative per-domain files live under `openspec/changes/iap-freemium-unlock/specs/{domain}/spec.md`.
---
## Domain: premium-entitlement (NEW)
# Premium Entitlement Specification
## Purpose
Track whether the current user holds the permanent, non-consumable premium unlock, and expose that flag to every gated surface (freemium-gating, ad-display, android-auto-media) both from the UI layer and headlessly (Android Auto, registered before `runApp`).
## Requirements
### Requirement: One-Time Non-Consumable Purchase
The system MUST let the user buy a single non-consumable product via `in_app_purchase` from the Settings row or any contextual upsell. On a successful purchase, entitlement MUST flip to premium immediately, app-wide, with no restart required.
#### Scenario: Successful purchase
- GIVEN a free-tier user taps "buy premium" from Settings or a contextual upsell
- WHEN the purchase completes successfully
- THEN entitlement becomes premium immediately, without restarting the app
#### Scenario: Purchase cancelled or failed
- GIVEN a free-tier user starts the purchase flow
- WHEN the user cancels or the purchase fails
- THEN entitlement remains free tier, and no charge or partial state is left behind
#### Scenario: Already-purchased attempt is idempotent
- GIVEN a user already holds premium entitlement
- WHEN they somehow re-trigger the buy flow
- THEN no duplicate charge occurs and entitlement stays premium
### Requirement: Restore Purchases
Settings MUST expose a "restore purchases" action that re-queries Play Billing and unlocks entitlement when a prior purchase is found.
#### Scenario: Restore finds a prior purchase
- GIVEN a reinstall or new device with no local entitlement flag
- WHEN the user taps "restore purchases" and a valid purchase exists on the Play account
- THEN entitlement becomes premium
#### Scenario: Restore finds nothing
- GIVEN a user with no prior purchase
- WHEN they tap "restore purchases"
- THEN the user stays on the free tier with a clear, non-error-looking result (not a crash or ambiguous failure)
### Requirement: Persisted, Fail-Open Entitlement
Entitlement MUST persist locally under a versioned key (e.g. `compra_premium_v1`) and MUST be readable offline. If an entitlement check cannot complete (no network, Play Billing unreachable), the system MUST fail-open: trust the last persisted flag rather than lock out a payer.
(Previously: no entitlement concept existed.)
#### Scenario: Offline cold start after purchase
- GIVEN a user purchased premium previously
- WHEN they open the app fully offline
- THEN premium entitlement is honored from the persisted flag
#### Scenario: Failed check does not falsely grant premium
- GIVEN a free-tier user with no persisted premium flag
- WHEN an entitlement check fails
- THEN the user remains free tier (fail-open trusts the last flag, it does not invent one)
### Requirement: Headless-Safe Entitlement Read
Entitlement MUST be resolvable via a prefs-lazy fallback (no `BuildContext`/`Provider` dependency), for callers such as `PluriWaveAudioHandler` that register before the widget tree exists.
#### Scenario: Android Auto cold start
- GIVEN the audio handler is constructed before `runApp`
- WHEN it needs to know the current entitlement to build the browse tree
- THEN it resolves entitlement via the prefs-lazy path without requiring a `Provider`
### Requirement: Instant Unlock Propagation
A successful purchase or restore MUST notify all listeners (top banner, gated screens, cached Android Auto entitlement) immediately, without an app restart.
#### Scenario: Banner disappears immediately on purchase
- GIVEN the ad banner is visible when the user completes a purchase
- WHEN the purchase confirms
- THEN the banner disappears immediately, with no restart
---
## Domain: freemium-gating (NEW)
# Freemium Gating Specification
## Purpose
Define which features require premium entitlement, the free-tier alarm cap, grandfathering of existing content, and the non-punitive UX for hitting a limit. The equalizer on the phone is explicitly out of scope — it MUST stay free.
## Requirements
### Requirement: Gated Feature Set (Exactly 4)
The system MUST require premium entitlement for exactly: (1) creating alarm vacations, (2) starting a new station recording, (3) creating an alarm beyond the 5-alarm cap, and (4) full Android Auto browsing (see `android-auto-media`). The phone equalizer MUST NOT be gated under any circumstance.
#### Scenario: Free user uses the phone equalizer
- GIVEN a free-tier user
- WHEN they open and use the equalizer screen on the phone
- THEN it works fully, with no entitlement check and no upsell
#### Scenario: Free user attempts a gated action
- GIVEN a free-tier user
- WHEN they tap "add vacation range" or "start recording"
- THEN they see the paywall/upsell instead of the action completing
### Requirement: Alarm Count Cap At 5 (Free Tier)
`EstadoAlarmas.guardarAlarma` MUST count all alarms, enabled or not, and MUST reject creating a 6th alarm for a free-tier user via a distinct "limit reached" signal, separate from the existing `_error` field used for native scheduling failures.
#### Scenario: 6th alarm creation is blocked
- GIVEN a free-tier user already has 5 alarms (any enabled state)
- WHEN they attempt to create a 6th
- THEN `guardarAlarma` rejects it via the distinct limit signal, and no native scheduling is attempted
#### Scenario: Editing an existing alarm is unaffected
- GIVEN a free-tier user has exactly 5 alarms
- WHEN they edit one of those 5 (not create a new one)
- THEN the edit succeeds normally
#### Scenario: Premium user has no cap
- GIVEN a premium user
- WHEN they create a 6th or later alarm
- THEN it succeeds with no limit check
### Requirement: Alarm Cap UX Never Bare-Jumps To Paywall
Hitting the alarm cap MUST show an explanatory message with a secondary "unlock" action; it MUST NOT navigate directly to the paywall as the sole response to the attempt.
#### Scenario: Cap message with secondary action
- GIVEN a free-tier user hits the 5-alarm cap
- WHEN the limit signal is raised
- THEN the UI shows an explanatory message (e.g. "Has alcanzado el límite de 5 alarmas gratuitas") with a secondary button (e.g. "Desbloquear Premium")
- AND only tapping that secondary button navigates to the paywall
### Requirement: Grandfathering Of Existing Content
Alarms, vacations, and recordings created before the gate existed, or already exceeding the cap, MUST remain visible, usable, and editable-in-place. Only NEW creation past a limit or gate is blocked.
(Previously: no cap or gate existed, so this distinction did not apply.)
#### Scenario: Pre-existing alarms above the cap keep working
- GIVEN a device already has 7 alarms before this change ships
- WHEN the free-tier gate is active
- THEN all 7 alarms keep ringing and can be toggled/edited, and only a new 8th creation is blocked
### Requirement: Recording Start Gated, Management Stays Free
`EstadoGrabacion.iniciar` MUST require premium entitlement. Screens that view, play, or delete already-existing recordings MUST remain accessible regardless of entitlement.
#### Scenario: Free user starts a new recording
- GIVEN a free-tier user
- WHEN they tap the record action
- THEN they see the paywall instead of recording starting
#### Scenario: Free user manages existing recordings
- GIVEN a free-tier user with previously recorded files
- WHEN they open the recordings list
- THEN they can view, play, and delete those recordings normally
### Requirement: Purchase Entry Points At Every Gate Plus Settings
Every gated entry point MUST show a contextual upsell. Settings MUST additionally expose a persistent purchase/restore row.
#### Scenario: Contextual upsell at a gate
- GIVEN a free-tier user reaches any of the 4 gated entry points
- WHEN the gate blocks the action
- THEN a contextual purchase CTA is shown at that point
#### Scenario: Settings always shows a premium row
- GIVEN any user opens Settings
- WHEN the screen renders
- THEN it shows either a "buy premium" row (free tier) or a "premium active" state with restore access (premium tier)
---
## Domain: ad-display (NEW)
# Ad Display Specification
## Purpose
Define where and when `google_mobile_ads` renders for free-tier users only: a persistent top banner, plus a capped interstitial before two specific add actions. Ads MUST be entirely absent for premium users.
## Requirements
### Requirement: Persistent Top Banner, Never Overlapping Content
Free-tier users MUST see a persistent banner anchored at the TOP of the app (not bottom), laid out so it reserves its own space and never overlaps, covers, or displaces existing UI on any of the 5 tabs. Premium users MUST see no banner and no reserved space for it.
#### Scenario: Free user on any tab
- GIVEN a free-tier user
- WHEN they view any of the 5 tabs
- THEN the top banner is visible and all existing content remains fully visible and reachable, none hidden behind it
#### Scenario: Premium user
- GIVEN a premium user
- WHEN they view any tab
- THEN no banner and no reserved banner space is shown
### Requirement: Interstitial Before Manual Station Add And Before Alarm Add
For free-tier users, a full-screen interstitial MUST show before completing exactly two actions: adding a station manually, and adding an alarm. It MUST fire on the action's natural transition (e.g. on confirm/save), never mid-form, and MUST NOT show for premium users.
#### Scenario: Free user adds a station manually
- GIVEN a free-tier user completes the "add station manually" form
- WHEN they confirm the add
- THEN a full-screen interstitial shows once before/around that transition
#### Scenario: Free user adds an alarm
- GIVEN a free-tier user under the 5-alarm cap completes the alarm-creation form
- WHEN they save the new alarm
- THEN a full-screen interstitial shows once before/around that transition
#### Scenario: Premium user performs either action
- GIVEN a premium user
- WHEN they add a station manually or add an alarm
- THEN no interstitial shows
### Requirement: Interstitial Frequency Cap
The system MUST enforce a session-scoped frequency cap on interstitials so that repeated adds in one session do not chain interstitials back-to-back on every single attempt.
#### Scenario: Rapid consecutive adds in one session
- GIVEN a free-tier user adds several stations or alarms in quick succession within the same session
- WHEN each add completes
- THEN not every single add triggers a fresh interstitial — the frequency cap suppresses some per its configured spacing/count rule
### Requirement: Interstitial Never Stacks With The Alarm-Cap Message
If an alarm-add attempt would trigger both the interstitial and the 5-alarm-cap message in the same tap, the alarm-cap message MUST take precedence and the interstitial MUST be suppressed for that attempt.
#### Scenario: Cap hit and interstitial would-be trigger collide
- GIVEN a free-tier user already has 5 alarms
- WHEN they tap "add" for a 6th alarm
- THEN only the alarm-cap explanatory message appears, and no interstitial is shown for that same tap
### Requirement: Ads Vanish Immediately On Purchase
Both the top banner and interstitial triggers MUST stop immediately upon successful purchase or restore, with no app restart required.
#### Scenario: Mid-session purchase
- GIVEN a free-tier user with the banner visible completes a purchase
- WHEN the purchase confirms
- THEN the banner disappears immediately and subsequent adds trigger no interstitial, without restarting the app
---
## Domain: android-auto-media (MODIFIED)
# Delta for Android Auto Media
## MODIFIED Requirements
### Requirement: Browsable Media Tree
For a user holding premium entitlement, `getChildren` MUST return a browsable tree rooted at `AudioService.browsableRootId`, organized into non-playable folders (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, and the local-music root) containing playable items. Playable station items SHOULD carry an audio-quality subtitle when known. The `Favoritos` folder additionally MAY contain non-playable favorite-group sub-folders (see "Favorite Group Sub-Folders"); `Todas las emisoras` and `Mis emisoras` remain flat. The `Ecualizador` folder is flat, non-playable, and contains only the 6 fixed EQ preset items (see "EQ Preset Browsable Folder"). The local-music root folder is non-playable and may itself be nested (see "Local Music Browsable Tree"). For a free-tier (non-premium) user, this full tree is NOT exposed; see "Free-Tier Reduced Root Browse" for the entitlement-aware equivalent.
(Previously: root contained exactly 3 folders — Favoritos, Todas las emisoras, Mis emisoras — with no EQ or local-music folder; Favoritos was a flat folder of playable station items only, with no sub-folder nesting; there was no entitlement distinction.)
#### Scenario: Car requests the root (premium)
- GIVEN the user holds premium entitlement and the car head unit connects and requests the root (`AudioService.browsableRootId`)
- WHEN `getChildren` is called with the root id
- THEN it returns five folder `MediaItem`s (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, and the local-music root), each with `playable: false`
#### Scenario: Car requests a folder with no stations (premium)
- GIVEN the user holds premium entitlement and has zero favorite stations
- WHEN `getChildren` is called with the Favoritos folder id
- THEN it returns an empty list, not an error
#### Scenario: Browse requested before app state is loaded (premium)
- GIVEN the user holds premium entitlement and the audio handler starts cold and station/favorites Provider state has not finished loading
- WHEN `getChildren` is called (root or any folder)
- THEN it returns a valid, possibly empty, list without throwing and without blocking or crashing the service
#### Scenario: Station has known codec and bitrate
- GIVEN a station's `Emisora.codec` and `Emisora.bitrate` are both known (non-null)
- WHEN it is mapped to a playable `MediaItem`
- THEN `displaySubtitle` SHALL contain a human-readable quality hint combining bitrate and codec (e.g. "128 kbps · MP3")
#### Scenario: Station has unknown codec or bitrate
- GIVEN a station's `Emisora.codec` or `Emisora.bitrate` (or both) is null/unknown
- WHEN it is mapped to a playable `MediaItem`
- THEN `displaySubtitle` SHALL omit the quality hint gracefully (no subtitle, or a subtitle with no quality fragment)
- AND the subtitle MUST NOT render literal placeholder text such as "null kbps" or "null · null"
#### Scenario: Ungrouped station appears exactly as before (regression guard)
- GIVEN a station's `Emisora.grupoFavoritosId` equals `GrupoFavoritos.sinAsignarId` (`'sin_asignar'`, the default when no group is assigned), and the browsing user holds premium entitlement
- WHEN the `Favoritos`, `Todas las emisoras`, or `Mis emisoras` folders are browsed
- THEN that station appears as a playable `emisora:<uuid>` item in exactly the same folder(s), position (subject to existing sort rules), title, art, and subtitle as it did before favorite-group folders were introduced
- AND its presence and shape are unaffected by the existence, emptiness, or content of any favorite group
## ADDED Requirements
### Requirement: Free-Tier Reduced Root Browse
For a free-tier (non-premium) user, `getChildren` at the root MUST NOT return the full folder tree. Instead it MUST return a non-blank list whose items each represent one of the normally-browsable folders (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, local-music root) rendered as a non-playable, explicitly locked item labeled as a premium feature (e.g. title "Función Premium"). A blank or empty root/folder response for a free-tier user is forbidden.
#### Scenario: Free-tier user requests the root
- GIVEN a free-tier (non-premium) user's car head unit requests the root
- WHEN `getChildren` is called with the root id
- THEN it returns non-playable locked items labeled as premium features, one per normally-browsable folder, and never an empty list
#### Scenario: Free-tier user selects a locked item
- GIVEN a free-tier user is shown a locked "Función Premium" item
- WHEN they select it
- THEN no real folder content or station list is returned, and no crash or unhandled exception occurs
### Requirement: Free-Tier Browse Never Leaks Real Content (Authoritative Backstop)
Even if `getChildren` receives a stale or deep-linked folder id that would resolve to real station or local-music content, for a free-tier (non-premium) user it MUST NOT return that real content. This check MUST be enforced at the `getChildren`/`navegacion_auto.dart` choke point itself, independent of which UI path reached it.
#### Scenario: Stale folder id bypass attempt
- GIVEN a free-tier user's car client holds a cached `emisora:<uuid>` or folder id from before downgrade or from another device
- WHEN `getChildren`/`playFromMediaId` is called with that id
- THEN the authoritative entitlement check at the choke point blocks real content or playback from being returned, regardless of the id's validity
### Requirement: Current-Station Playback Unaffected By Free Tier
Regardless of entitlement, transport controls (play/pause/stop) for whatever station is already loaded or playing MUST keep working for a free-tier user in the car. Only browsing/switching to a different station and local music are restricted by the free tier.
#### Scenario: Free-tier user controls the current station
- GIVEN a free-tier user already has a station loaded or playing when connecting to the car
- WHEN they use play/pause/stop from the car head unit
- THEN the command is honored exactly as for a premium user
#### Scenario: Free-tier user cannot switch stations via browse
- GIVEN a free-tier user is currently playing a station
- WHEN they attempt to browse to a different station via the root tree
- THEN they see only the locked "Función Premium" items, not a station list, and cannot switch stations that way
@@ -0,0 +1,67 @@
# Ad Display Specification
## Purpose
Define where and when `google_mobile_ads` renders for free-tier users only: a persistent top banner, plus a capped interstitial before two specific add actions. Ads MUST be entirely absent for premium users.
## Requirements
### Requirement: Persistent Top Banner, Never Overlapping Content
Free-tier users MUST see a persistent banner anchored at the TOP of the app (not bottom), laid out so it reserves its own space and never overlaps, covers, or displaces existing UI on any of the 5 tabs. Premium users MUST see no banner and no reserved space for it.
#### Scenario: Free user on any tab
- GIVEN a free-tier user
- WHEN they view any of the 5 tabs
- THEN the top banner is visible and all existing content remains fully visible and reachable, none hidden behind it
#### Scenario: Premium user
- GIVEN a premium user
- WHEN they view any tab
- THEN no banner and no reserved banner space is shown
### Requirement: Interstitial Before Manual Station Add And Before Alarm Add
For free-tier users, a full-screen interstitial MUST show before completing exactly two actions: adding a station manually, and adding an alarm. It MUST fire on the action's natural transition (e.g. on confirm/save), never mid-form, and MUST NOT show for premium users.
#### Scenario: Free user adds a station manually
- GIVEN a free-tier user completes the "add station manually" form
- WHEN they confirm the add
- THEN a full-screen interstitial shows once before/around that transition
#### Scenario: Free user adds an alarm
- GIVEN a free-tier user under the 5-alarm cap completes the alarm-creation form
- WHEN they save the new alarm
- THEN a full-screen interstitial shows once before/around that transition
#### Scenario: Premium user performs either action
- GIVEN a premium user
- WHEN they add a station manually or add an alarm
- THEN no interstitial shows
### Requirement: Interstitial Frequency Cap
The system MUST enforce a session-scoped frequency cap on interstitials so that repeated adds in one session do not chain interstitials back-to-back on every single attempt.
#### Scenario: Rapid consecutive adds in one session
- GIVEN a free-tier user adds several stations or alarms in quick succession within the same session
- WHEN each add completes
- THEN not every single add triggers a fresh interstitial — the frequency cap suppresses some per its configured spacing/count rule
### Requirement: Interstitial Never Stacks With The Alarm-Cap Message
If an alarm-add attempt would trigger both the interstitial and the 5-alarm-cap message in the same tap, the alarm-cap message MUST take precedence and the interstitial MUST be suppressed for that attempt.
#### Scenario: Cap hit and interstitial would-be trigger collide
- GIVEN a free-tier user already has 5 alarms
- WHEN they tap "add" for a 6th alarm
- THEN only the alarm-cap explanatory message appears, and no interstitial is shown for that same tap
### Requirement: Ads Vanish Immediately On Purchase
Both the top banner and interstitial triggers MUST stop immediately upon successful purchase or restore, with no app restart required.
#### Scenario: Mid-session purchase
- GIVEN a free-tier user with the banner visible completes a purchase
- WHEN the purchase confirms
- THEN the banner disappears immediately and subsequent adds trigger no interstitial, without restarting the app
@@ -0,0 +1,79 @@
# Delta for Android Auto Media
## MODIFIED Requirements
### Requirement: Browsable Media Tree
For a user holding premium entitlement, `getChildren` MUST return a browsable tree rooted at `AudioService.browsableRootId`, organized into non-playable folders (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, and the local-music root) containing playable items. Playable station items SHOULD carry an audio-quality subtitle when known. The `Favoritos` folder additionally MAY contain non-playable favorite-group sub-folders (see "Favorite Group Sub-Folders"); `Todas las emisoras` and `Mis emisoras` remain flat. The `Ecualizador` folder is flat, non-playable, and contains only the 6 fixed EQ preset items (see "EQ Preset Browsable Folder"). The local-music root folder is non-playable and may itself be nested (see "Local Music Browsable Tree"). For a free-tier (non-premium) user, this full tree is NOT exposed; see "Free-Tier Reduced Root Browse" for the entitlement-aware equivalent.
(Previously: root contained exactly 3 folders — Favoritos, Todas las emisoras, Mis emisoras — with no EQ or local-music folder; Favoritos was a flat folder of playable station items only, with no sub-folder nesting; there was no entitlement distinction.)
#### Scenario: Car requests the root (premium)
- GIVEN the user holds premium entitlement and the car head unit connects and requests the root (`AudioService.browsableRootId`)
- WHEN `getChildren` is called with the root id
- THEN it returns five folder `MediaItem`s (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, and the local-music root), each with `playable: false`
#### Scenario: Car requests a folder with no stations (premium)
- GIVEN the user holds premium entitlement and has zero favorite stations
- WHEN `getChildren` is called with the Favoritos folder id
- THEN it returns an empty list, not an error
#### Scenario: Browse requested before app state is loaded (premium)
- GIVEN the user holds premium entitlement and the audio handler starts cold and station/favorites Provider state has not finished loading
- WHEN `getChildren` is called (root or any folder)
- THEN it returns a valid, possibly empty, list without throwing and without blocking or crashing the service
#### Scenario: Station has known codec and bitrate
- GIVEN a station's `Emisora.codec` and `Emisora.bitrate` are both known (non-null)
- WHEN it is mapped to a playable `MediaItem`
- THEN `displaySubtitle` SHALL contain a human-readable quality hint combining bitrate and codec (e.g. "128 kbps · MP3")
#### Scenario: Station has unknown codec or bitrate
- GIVEN a station's `Emisora.codec` or `Emisora.bitrate` (or both) is null/unknown
- WHEN it is mapped to a playable `MediaItem`
- THEN `displaySubtitle` SHALL omit the quality hint gracefully (no subtitle, or a subtitle with no quality fragment)
- AND the subtitle MUST NOT render literal placeholder text such as "null kbps" or "null · null"
#### Scenario: Ungrouped station appears exactly as before (regression guard)
- GIVEN a station's `Emisora.grupoFavoritosId` equals `GrupoFavoritos.sinAsignarId` (`'sin_asignar'`, the default when no group is assigned), and the browsing user holds premium entitlement
- WHEN the `Favoritos`, `Todas las emisoras`, or `Mis emisoras` folders are browsed
- THEN that station appears as a playable `emisora:<uuid>` item in exactly the same folder(s), position (subject to existing sort rules), title, art, and subtitle as it did before favorite-group folders were introduced
- AND its presence and shape are unaffected by the existence, emptiness, or content of any favorite group
## ADDED Requirements
### Requirement: Free-Tier Reduced Root Browse
For a free-tier (non-premium) user, `getChildren` at the root MUST NOT return the full folder tree. Instead it MUST return a non-blank list whose items each represent one of the normally-browsable folders (Favoritos, Todas las emisoras, Mis emisoras, Ecualizador, local-music root) rendered as a non-playable, explicitly locked item labeled as a premium feature (e.g. title "Función Premium"). A blank or empty root/folder response for a free-tier user is forbidden.
#### Scenario: Free-tier user requests the root
- GIVEN a free-tier (non-premium) user's car head unit requests the root
- WHEN `getChildren` is called with the root id
- THEN it returns non-playable locked items labeled as premium features, one per normally-browsable folder, and never an empty list
#### Scenario: Free-tier user selects a locked item
- GIVEN a free-tier user is shown a locked "Función Premium" item
- WHEN they select it
- THEN no real folder content or station list is returned, and no crash or unhandled exception occurs
### Requirement: Free-Tier Browse Never Leaks Real Content (Authoritative Backstop)
Even if `getChildren` receives a stale or deep-linked folder id that would resolve to real station or local-music content, for a free-tier (non-premium) user it MUST NOT return that real content. This check MUST be enforced at the `getChildren`/`navegacion_auto.dart` choke point itself, independent of which UI path reached it.
#### Scenario: Stale folder id bypass attempt
- GIVEN a free-tier user's car client holds a cached `emisora:<uuid>` or folder id from before downgrade or from another device
- WHEN `getChildren`/`playFromMediaId` is called with that id
- THEN the authoritative entitlement check at the choke point blocks real content or playback from being returned, regardless of the id's validity
### Requirement: Current-Station Playback Unaffected By Free Tier
Regardless of entitlement, transport controls (play/pause/stop) for whatever station is already loaded or playing MUST keep working for a free-tier user in the car. Only browsing/switching to a different station and local music are restricted by the free tier.
#### Scenario: Free-tier user controls the current station
- GIVEN a free-tier user already has a station loaded or playing when connecting to the car
- WHEN they use play/pause/stop from the car head unit
- THEN the command is honored exactly as for a premium user
#### Scenario: Free-tier user cannot switch stations via browse
- GIVEN a free-tier user is currently playing a station
- WHEN they attempt to browse to a different station via the root tree
- THEN they see only the locked "Función Premium" items, not a station list, and cannot switch stations that way
@@ -0,0 +1,88 @@
# Freemium Gating Specification
## Purpose
Define which features require premium entitlement, the free-tier alarm cap, grandfathering of existing content, and the non-punitive UX for hitting a limit. The equalizer on the phone is explicitly out of scope — it MUST stay free.
## Requirements
### Requirement: Gated Feature Set (Exactly 4)
The system MUST require premium entitlement for exactly: (1) creating alarm vacations, (2) starting a new station recording, (3) creating an alarm beyond the 5-alarm cap, and (4) full Android Auto browsing (see `android-auto-media`). The phone equalizer MUST NOT be gated under any circumstance.
#### Scenario: Free user uses the phone equalizer
- GIVEN a free-tier user
- WHEN they open and use the equalizer screen on the phone
- THEN it works fully, with no entitlement check and no upsell
#### Scenario: Free user attempts a gated action
- GIVEN a free-tier user
- WHEN they tap "add vacation range" or "start recording"
- THEN they see the paywall/upsell instead of the action completing
### Requirement: Alarm Count Cap At 5 (Free Tier)
`EstadoAlarmas.guardarAlarma` MUST count all alarms, enabled or not, and MUST reject creating a 6th alarm for a free-tier user via a distinct "limit reached" signal, separate from the existing `_error` field used for native scheduling failures.
#### Scenario: 6th alarm creation is blocked
- GIVEN a free-tier user already has 5 alarms (any enabled state)
- WHEN they attempt to create a 6th
- THEN `guardarAlarma` rejects it via the distinct limit signal, and no native scheduling is attempted
#### Scenario: Editing an existing alarm is unaffected
- GIVEN a free-tier user has exactly 5 alarms
- WHEN they edit one of those 5 (not create a new one)
- THEN the edit succeeds normally
#### Scenario: Premium user has no cap
- GIVEN a premium user
- WHEN they create a 6th or later alarm
- THEN it succeeds with no limit check
### Requirement: Alarm Cap UX Never Bare-Jumps To Paywall
Hitting the alarm cap MUST show an explanatory message with a secondary "unlock" action; it MUST NOT navigate directly to the paywall as the sole response to the attempt.
#### Scenario: Cap message with secondary action
- GIVEN a free-tier user hits the 5-alarm cap
- WHEN the limit signal is raised
- THEN the UI shows an explanatory message (e.g. "Has alcanzado el límite de 5 alarmas gratuitas") with a secondary button (e.g. "Desbloquear Premium")
- AND only tapping that secondary button navigates to the paywall
### Requirement: Grandfathering Of Existing Content
Alarms, vacations, and recordings created before the gate existed, or already exceeding the cap, MUST remain visible, usable, and editable-in-place. Only NEW creation past a limit or gate is blocked.
(Previously: no cap or gate existed, so this distinction did not apply.)
#### Scenario: Pre-existing alarms above the cap keep working
- GIVEN a device already has 7 alarms before this change ships
- WHEN the free-tier gate is active
- THEN all 7 alarms keep ringing and can be toggled/edited, and only a new 8th creation is blocked
### Requirement: Recording Start Gated, Management Stays Free
`EstadoGrabacion.iniciar` MUST require premium entitlement. Screens that view, play, or delete already-existing recordings MUST remain accessible regardless of entitlement.
#### Scenario: Free user starts a new recording
- GIVEN a free-tier user
- WHEN they tap the record action
- THEN they see the paywall instead of recording starting
#### Scenario: Free user manages existing recordings
- GIVEN a free-tier user with previously recorded files
- WHEN they open the recordings list
- THEN they can view, play, and delete those recordings normally
### Requirement: Purchase Entry Points At Every Gate Plus Settings
Every gated entry point MUST show a contextual upsell. Settings MUST additionally expose a persistent purchase/restore row.
#### Scenario: Contextual upsell at a gate
- GIVEN a free-tier user reaches any of the 4 gated entry points
- WHEN the gate blocks the action
- THEN a contextual purchase CTA is shown at that point
#### Scenario: Settings always shows a premium row
- GIVEN any user opens Settings
- WHEN the screen renders
- THEN it shows either a "buy premium" row (free tier) or a "premium active" state with restore access (premium tier)
@@ -0,0 +1,73 @@
# Premium Entitlement Specification
## Purpose
Track whether the current user holds the permanent, non-consumable premium unlock, and expose that flag to every gated surface (freemium-gating, ad-display, android-auto-media) both from the UI layer and headlessly (Android Auto, registered before `runApp`).
## Requirements
### Requirement: One-Time Non-Consumable Purchase
The system MUST let the user buy a single non-consumable product via `in_app_purchase` from the Settings row or any contextual upsell. On a successful purchase, entitlement MUST flip to premium immediately, app-wide, with no restart required.
#### Scenario: Successful purchase
- GIVEN a free-tier user taps "buy premium" from Settings or a contextual upsell
- WHEN the purchase completes successfully
- THEN entitlement becomes premium immediately, without restarting the app
#### Scenario: Purchase cancelled or failed
- GIVEN a free-tier user starts the purchase flow
- WHEN the user cancels or the purchase fails
- THEN entitlement remains free tier, and no charge or partial state is left behind
#### Scenario: Already-purchased attempt is idempotent
- GIVEN a user already holds premium entitlement
- WHEN they somehow re-trigger the buy flow
- THEN no duplicate charge occurs and entitlement stays premium
### Requirement: Restore Purchases
Settings MUST expose a "restore purchases" action that re-queries Play Billing and unlocks entitlement when a prior purchase is found.
#### Scenario: Restore finds a prior purchase
- GIVEN a reinstall or new device with no local entitlement flag
- WHEN the user taps "restore purchases" and a valid purchase exists on the Play account
- THEN entitlement becomes premium
#### Scenario: Restore finds nothing
- GIVEN a user with no prior purchase
- WHEN they tap "restore purchases"
- THEN the user stays on the free tier with a clear, non-error-looking result (not a crash or ambiguous failure)
### Requirement: Persisted, Fail-Open Entitlement
Entitlement MUST persist locally under a versioned key (e.g. `compra_premium_v1`) and MUST be readable offline. If an entitlement check cannot complete (no network, Play Billing unreachable), the system MUST fail-open: trust the last persisted flag rather than lock out a payer.
(Previously: no entitlement concept existed.)
#### Scenario: Offline cold start after purchase
- GIVEN a user purchased premium previously
- WHEN they open the app fully offline
- THEN premium entitlement is honored from the persisted flag
#### Scenario: Failed check does not falsely grant premium
- GIVEN a free-tier user with no persisted premium flag
- WHEN an entitlement check fails
- THEN the user remains free tier (fail-open trusts the last flag, it does not invent one)
### Requirement: Headless-Safe Entitlement Read
Entitlement MUST be resolvable via a prefs-lazy fallback (no `BuildContext`/`Provider` dependency), for callers such as `PluriWaveAudioHandler` that register before the widget tree exists.
#### Scenario: Android Auto cold start
- GIVEN the audio handler is constructed before `runApp`
- WHEN it needs to know the current entitlement to build the browse tree
- THEN it resolves entitlement via the prefs-lazy path without requiring a `Provider`
### Requirement: Instant Unlock Propagation
A successful purchase or restore MUST notify all listeners (top banner, gated screens, cached Android Auto entitlement) immediately, without an app restart.
#### Scenario: Banner disappears immediately on purchase
- GIVEN the ad banner is visible when the user completes a purchase
- WHEN the purchase confirms
- THEN the banner disappears immediately, with no restart
@@ -0,0 +1,80 @@
# Tasks: Freemium unlock via one-time in-app purchase
## Review Workload Forecast
Estimated changed lines: 1200-2000+ (5 new, ~12 modified Dart, 13 `.arb` locales, pubspec.yaml, AndroidManifest.xml, plus tests).
Suggested split: single PR now (`single-pr`); Work Units below double as chained-PR slices if `size:exception` is declined.
Delivery strategy: single-pr.
Decision needed before apply: Yes
Chained PRs recommended: Yes
Chain strategy: size-exception
400-line budget risk: High
Deferred, non-blocking: price point (Play Console); AdMob ad unit IDs — use Google test IDs. Do not invent values.
### Suggested Work Units
| Unit | Goal | Focused test command | Runtime harness | Rollback boundary |
|---|---|---|---|---|
| 1 | Entitlement + purchase I/O | `flutter test test/estado/estado_entitlement_test.dart test/servicios/servicio_compras_test.dart` | Manual: Settings > Restaurar compras | `estado_entitlement.dart`, `servicio_compras.dart` |
| 2 | Alarm, recording, Auto gates + cache invalidation | `flutter test test/estado/estado_alarmas_test.dart test/estado/estado_grabacion_test.dart test/servicios/navegacion_auto_test.dart test/servicios/servicio_audio_test.dart` | Auto head-unit browse smoke | gate diffs in `estado_alarmas.dart`, `estado_grabacion.dart`, `navegacion_auto.dart`, `servicio_audio.dart` |
| 3 | Ads (banner + interstitial) | `flutter test test/servicios/servicio_anuncios_test.dart test/widgets/banner_anuncio_superior_test.dart` | Manual: banner/no-overlap 5 tabs | `servicio_anuncios.dart`, `banner_anuncio_superior.dart`, `app.dart` Column diff |
| 4 | Paywall UI + localization | `flutter test test/pantallas/pantalla_ajustes_test.dart && flutter gen-l10n` | Manual: tap each gate | `hoja_premium.dart`, screen CTA diffs, `app_*.arb` keys |
## Phase 0: Foundation
- [x] 0.1 Uncomment `in_app_purchase`/`google_mobile_ads` in `pubspec.yaml`; `flutter pub get`.
- [x] 0.2 Add AdMob test app ID to `AndroidManifest.xml`.
## Phase 1: Entitlement Core
- [x] 1.1 RED `estado_entitlement_test.dart`: default free; persisted true; fail-open on failure; `esPremiumPersistido()` headless, no `BuildContext`.
- [x] 1.2 GREEN `estado_entitlement.dart`: `EstadoEntitlement` `ChangeNotifier` (key `compra_premium_v1`) + `esPremiumPersistido()`.
- [x] 1.3 REFACTOR: shared prefs-key constant; document fail-open contract.
## Phase 2: Purchase I/O
- [x] 2.1 RED `servicio_compras_test.dart`: `comprar()` success/cancel/idempotent; `restaurar()` found/not-found, no error.
- [x] 2.2 GREEN `servicio_compras.dart`: `PuertoCompras` + `ServicioComprasPlayBilling` (sole `in_app_purchase` site); wire `comprar/restaurar`.
## Phase 3: Alarm Gating
- [x] 3.1 RED `estado_alarmas_gating_test.dart`: `puedeCrearAlarma` 4/5/6; 6th blocked pre-schedule; edit-at-cap ok; premium uncapped; 8 preexisting grandfathered, 9th blocked; vacations free-blocked/premium-ok.
- [x] 3.2 GREEN `estado_alarmas.dart`: `ResultadoGuardarAlarma` enum, `puedeCrearAlarma`, gate `guardarAlarma`(:104)+`crearRangoVacaciones`(:510).
- [x] 3.3 GREEN `pantalla_alarmas.dart`/`_EditorAlarmaSheet` + `pantalla_vacaciones.dart`: cap message + "Desbloquear Premium" CTA; vacation upsell.
## Phase 4: Recording Gating
- [x] 4.1 RED `estado_grabacion_gating_test.dart`: `iniciar()` blocked free/allowed premium; existing recordings stay free.
- [x] 4.2 GREEN `estado_grabacion.dart`: gate `iniciar()`(:90); upsell at 3 sites in `pantalla_reproductor.dart`.
## Phase 5: Android Auto Gating
- [x] 5.1 RED `navegacion_auto_gating_test.dart`: `raiz(premium:false)` non-blank tree with the real folder labels (design ADR-4: root labels stay visible for every tier, lock enforced one level down); `respuestaBloqueadaPorEntitlement(non-root,free)->[itemPremiumBloqueado()]`; premium unchanged (regression).
- [x] 5.2 RED `servicio_audio_gating_test.dart`: `debeBloquearCambioDeEmisora` free/premium; stale-id backstop wired into `playFromMediaId`/`playFromSearch`/`skipToNext`/`skipToPrevious`.
- [x] 5.3 RED: free->premium transition invokes the registered Auto-invalidation hook (`registrarNotificacionDesbloqueoAuto`/`notificarDesbloqueoAuto`), which pushes to `PluriWaveAudioHandler.subscribeToChildren`'s per-id `BehaviorSubject`s (the current non-deprecated `audio_service` API — the plugin's OWN internal listener forwards each push to the platform's `notifyChildrenChanged`).
- [x] 5.4 GREEN: `raiz(premium:)`+`itemPremiumBloqueado()`+`respuestaBloqueadaPorEntitlement` (`navegacion_auto.dart`); gate `getChildren`/`playFromMediaId`/`playFromSearch`/`skipToNext`/`skipToPrevious` + `subscribeToChildren`/`notificarHijosCambiaron` wiring (`servicio_audio.dart`).
## Phase 6: Ads
- [x] 6.1 RED `servicio_anuncios_test.dart`: cap 2/session >=3min (fake clock); over-cap no-op; suppressed with alarm-cap message; none when premium.
- [x] 6.2 GREEN `servicio_anuncios.dart`: banner/interstitial port + AdMob adapter (test ad unit IDs) + frequency cap.
- [x] 6.3 RED+GREEN `banner_anuncio_superior.dart` + `app.dart`: shrink when premium/unloaded, no overlap 5 tabs; `Column[banner, Expanded(body)]`, never `Stack`.
## Phase 7: Purchase UI Wiring
- [x] 7.1 GREEN `hoja_premium.dart` (paywall sheet) + `app.dart`: register `EstadoEntitlement` Provider.
- [x] 7.2 GREEN `pantalla_ajustes.dart`: buy/restore/premium-active row; `pantalla_favoritos.dart` + `ajustes_emisoras_personalizadas.dart`: interstitial before manual station add.
## Phase 8: Localization (13 locales, `app_es.arb` template)
- [x] 8.1 Add keys (`funcionPremium`, `limiteAlarmasAlcanzado`, `desbloquearPremium`, `restaurarCompras`) to `app_es.arb`; translate into 12 remaining locales.
- [x] 8.2 Run `flutter gen-l10n`; verify `AppLocalizations` getters generated.
- [x] 8.3 Run literal-encoding scan on `lib/l10n/app_*.arb` — zero mojibake (only pre-existing "REPETIÇÃO" false positive, unrelated to this change).
## Phase 9: Verification
- [x] 9.1 Run full suite; confirm every RED test above is GREEN.
- [x] 9.2 Regression-check: phone equalizer has zero entitlement checks.
- [x] 9.3 Update `proposal.md` Success Criteria checkboxes.
@@ -0,0 +1,292 @@
```yaml
schema: gentle-ai.verify-result/v1
evidence_revision: sha256:2c382e1b0ea0ead93ebb25ce741be99bc6005c20
verdict: fail
blockers: 2
critical_findings: 2
requirements: 20/20
scenarios: 39/39
test_command: flutter test
test_exit_code: 1
test_output_hash: sha256:3b2a1fcdb1436e77a8a883923ebeb01f7ebc675602162c38c1ca2b42a5acb0c1
build_command: flutter analyze
build_exit_code: 1
build_output_hash: sha256:cb2b64838a0c89a135b8a1b9bda36f57e6060c244129554060b00f6a7f5bcbd6
```
## Verification Report
Change: iap-freemium-unlock
Branch/Commit: feat/iap-freemium-unlock, single commit 2c382e1
Version: N/A (no versioned spec revisions)
Mode: Strict TDD
### Completeness
| Metric | Value |
|--------|-------|
| Tasks total | 27 |
| Tasks complete (checked) | 26 |
| Tasks incomplete (unchecked in tasks.md) | 1 (task 3.3) |
Discrepancy: openspec/changes/iap-freemium-unlock/tasks.md line 45 shows task 3.3
(GREEN pantalla_alarmas.dart/_EditorAlarmaSheet + pantalla_vacaciones.dart: cap message
plus Desbloquear Premium CTA; vacation upsell) as an unchecked box, despite
apply-progress.md's own summary table and both Engram apply-progress observations
(#2834, #2835) explicitly claiming ALL PHASES COMPLETE (27/27 tasks) and Phase 3 marked
complete for 3.1, 3.2 and 3.3. Source inspection confirms the underlying code for 3.3 IS
implemented and covered by regression tests (pantalla_alarmas.dart's _abrirEditor
cap-check-plus-interstitial wiring, _mostrarLimiteAlarmas snackbar and CTA, and
pantalla_vacaciones.dart's paywall-on-block via mostrarHojaPremium) -- this is a
tracking and documentation integrity failure, not a missing implementation. Per the
verify decision gate (an unchecked task always remains CRITICAL, even when other
artifacts are missing or warnings-only), this blocks a clean archive regardless of the
underlying code being present.
### Build and Tests Execution
Static analysis: flutter analyze -> exit 1, 5 issues (all confirmed pre-existing and
unrelated via git blame: 2x deprecated_member_use on onReorder in pantalla_favoritos.dart
and its test, predating this change; 1x unused_catch_stack in servicio_audio.dart:1310,
blamed to commit 0e18c822 dated 2026-05-21, predating this change; 1x annotate_overrides
in estado_radio_test.dart:865). Matches the apply-progress claim exactly. flutter analyze
exits 1 whenever any issue including info level is present -- this is expected repository
baseline behavior, not a regression.
Tests: FAILING -- 1242 passed / 2 skipped / 1 FAILED (1245 total), full flutter test run
completed in about 2 minutes 34 seconds (contrary to apply-progress's claim that a single
flutter test full-suite invocation exceeds this environment's command timeout of about 10
minutes -- it did not, in this run).
```text
$ flutter test
...
02:34 +1242 ~2 -1: Some tests failed.
Failing tests:
C:/Proyectos/pluriwave/test/l10n/arb_anti_copy_test.dart: every non-es value identical to
the Spanish template is a deliberately allowlisted exception, not an accidental untranslated
copy [E]
Expected: empty
Actual: [
pt/desbloquearPremium = "Desbloquear Premium",
pt/restaurarCompras = "Restaurar compras"
]
Found values identical to the Spanish template that are NOT in
identical_value_allowlist.dart -- this is very likely an untranslated copy-paste...
```
This directly contradicts the apply-progress claim of full suite green (719+ tests) and
all phases green. The failure is a genuine, reproducible regression against a pre-existing
guard test (test/l10n/arb_anti_copy_test.dart, not one of this change's own new test files),
caused by this change's own new content: 2 of the 4 new localization keys
(desbloquearPremium, restaurarCompras) were left byte-identical to the Spanish template for
the pt locale and were never added to identical_value_allowlist.dart nor genuinely
translated. The apply-progress literal-encoding scan and dart format checks would never
have caught this -- only arb_anti_copy_test.dart catches it, and it was never run: the
apply-progress's own batched regression run explicitly lists test/estado/, test/servicios/,
test/widgets/, test/pantallas/, and 4 top-level files -- test/l10n/ is absent from every
batch, so this defect went undetected until this verify pass ran the real full suite.
Coverage: not measured (no --coverage run performed; not requested by the phase gates and
project rules prohibit flutter build, and coverage instrumentation was judged non-essential
given the full-suite pass/fail evidence already gathered).
### Spec Compliance Matrix (by requirement; 20 requirements / 39 scenarios across 4 domains)
| Domain | Requirement | Covering test(s) | Result |
|---|---|---|---|
| premium-entitlement | One-Time Non-Consumable Purchase | estado_entitlement_test.dart (comprar success/cancel/idempotent) | COMPLIANT |
| premium-entitlement | Restore Purchases | estado_entitlement_test.dart (restaurar found/not-found) | COMPLIANT |
| premium-entitlement | Persisted, Fail-Open Entitlement | estado_entitlement_test.dart (loads persisted flag; error does not block payer) | COMPLIANT |
| premium-entitlement | Headless-Safe Entitlement Read | estado_entitlement_test.dart (esPremiumPersistido group, no BuildContext) | COMPLIANT |
| premium-entitlement | Instant Unlock Propagation | estado_entitlement_test.dart (ChangeNotifier notification count) plus servicio_audio_gating_test.dart (Auto invalidation hook) | COMPLIANT |
| freemium-gating | Gated Feature Set (exactly 4) | equalizer-zero-refs grep plus alarm/recording/vacation/Auto gating tests | COMPLIANT |
| freemium-gating | Alarm Count Cap At 5 | estado_alarmas_gating_test.dart (4/5/6, pre-schedule block, edit-at-cap, premium uncapped) | COMPLIANT |
| freemium-gating | Alarm Cap UX Never Bare-Jumps To Paywall | pantalla_alarmas.dart _mostrarLimiteAlarmas (source-verified; snackbar plus CTA, no direct nav) | COMPLIANT (source; no dedicated widget test asserts the exact snackbar text/CTA pair) |
| freemium-gating | Grandfathering Of Existing Content | estado_alarmas_gating_test.dart (8 preexisting alarms stay, only the 9th is blocked) | COMPLIANT |
| freemium-gating | Recording Start Gated, Management Stays Free | estado_grabacion_gating_test.dart (free blocked, premium allowed, compat default) | COMPLIANT |
| freemium-gating | Purchase Entry Points At Every Gate Plus Settings | source-verified across pantalla_alarmas.dart, pantalla_vacaciones.dart, pantalla_reproductor.dart, pantalla_ajustes.dart | COMPLIANT |
| ad-display | Persistent Top Banner, Never Overlapping Content | banner_anuncio_superior_test.dart (Column layout, zero-footprint collapse) | COMPLIANT |
| ad-display | Interstitial Before Manual Station Add And Before Alarm Add | source-verified (pantalla_alarmas.dart _abrirEditor, pantalla_favoritos.dart, ajustes_emisoras_personalizadas.dart) plus servicio_anuncios_test.dart cap logic | COMPLIANT |
| ad-display | Interstitial Frequency Cap | servicio_anuncios_test.dart (2 per session, 3-minute spacing, failed load does not consume cap) | COMPLIANT |
| ad-display | Interstitial Never Stacks With The Alarm-Cap Message | source-verified: _abrirEditor returns early on cap-block, before intentarInterstitial is ever called | COMPLIANT |
| ad-display | Ads Vanish Immediately On Purchase | servicio_anuncios_test.dart (premium never shows) plus banner_anuncio_superior_test.dart (premium never attempts) | COMPLIANT |
| android-auto-media | Browsable Media Tree (premium, regression) | navegacion_auto_gating_test.dart (premium identical to current tree) plus navegacion_auto_test.dart (updated call sites, premium true) | COMPLIANT |
| android-auto-media | Free-Tier Reduced Root Browse | navegacion_auto_gating_test.dart (free: same labels, non-blank, never playable; itemPremiumBloqueado non-crash) | COMPLIANT |
| android-auto-media | Free-Tier Browse Never Leaks Real Content (Authoritative Backstop) | navegacion_auto_gating_test.dart (stale/deep-linked id backstop) plus servicio_audio_gating_test.dart (debeBloquearCambioDeEmisora) plus source-verified in all 5 servicio_audio.dart call sites | COMPLIANT |
| android-auto-media | Current-Station Playback Unaffected By Free Tier | source-verified: play(), pause(), stop() in servicio_audio.dart contain no entitlement check | COMPLIANT |
Compliance summary: 20/20 requirements have runtime or source-verified covering evidence.
One requirement (Alarm Cap UX) is source-verified but lacks a dedicated widget test asserting
the exact snackbar/CTA pair -- downgraded to a WARNING below, not a blocker, since the logic
path is simple and exercised transitively by the passing regression suite.
### Orchestrator-Flagged Scrutiny Points
1. Fail-open entitlement default ("() => true" in estado_alarmas.dart:36,
estado_grabacion.dart:57) -- VERIFIED: exactly 2 production construction sites exist for
these classes (app.dart lines 71-76, EstadoRadio(esPremium: () =>
context.read<EstadoEntitlement>().esPremium), threaded internally to EstadoGrabacion at
estado_radio.dart:73; app.dart lines 93-96, EstadoAlarmas(esPremium: ...)), both correctly
wired, with EstadoEntitlement registered FIRST in the provider list specifically so these
context.read calls resolve. The headless Android Auto path (servicio_audio.dart) never
constructs EstadoAlarmas/EstadoGrabacion at all -- it calls esPremiumPersistido() directly,
a separate, unaffected function. No current production or headless path reaches the
fail-open default. See WARNING below for the latent-risk recommendation.
2. Android Auto gating completeness (ADR-4) -- VERIFIED COMPLIANT: playFromMediaId,
playFromSearch, skipToNext, skipToPrevious all call
debeBloquearCambioDeEmisora(premium: await esPremiumPersistido()) and no-op when blocked
(servicio_audio.dart lines approximately 1601, 1626, 1863, 1896). play(), pause(), stop()
contain no such check -- transport of the current station is untouched. getChildren never
returns blank for free tier: respuestaBloqueadaPorEntitlement returns exactly one
itemPremiumBloqueado() item for any non-root id, and the root itself always resolves
through raiz() (never blocked).
3. notifyChildrenChanged replacement -- VERIFIED FUNCTIONALLY EQUIVALENT: the deprecated
static helper is replaced by PluriWaveAudioHandler.subscribeToChildren (a per-parent-id
BehaviorSubject overriding the audio_service base class's stream-based extension point)
plus notificarHijosCambiaron(id), which pushes a fresh value into that subject.
EstadoEntitlement._desbloquear() calls notificarDesbloqueoAuto() on the free-to-premium
edge (only when the user was not already premium), which fires the hook registered in
registrarHandler() that pushes to the root plus all 4 folder ids. This is audio_service's
own documented replacement mechanism for the deprecated helper (the plugin's internal
listener subscribes to subscribeToChildren and forwards to the platform's
notifyChildrenChanged itself) -- not a workaround. Covered by
servicio_audio_gating_test.dart's registrarNotificacionDesbloqueoAuto group.
4. Deviation #5, crearRangoVacaciones returns bool -- VERIFIED ACCEPTABLE: the method has
exactly one failure mode today (entitlement block returns false); there is no other
throw/failure path in its body, so a caller cannot currently confuse "blocked by
entitlement" with any other failure. pantalla_vacaciones.dart's _guardar checks
"if (!creada) mostrarHojaPremium(context)", correctly routing to the paywall. This is a
sound simplification given the current single-failure-mode reality, though it is not
future-proof if crearRangoVacaciones ever grows a second failure mode (see SUGGESTION
below).
5. Interstitial ordering (cap-check before interstitial) -- VERIFIED COMPLIANT:
pantalla_alarmas.dart's _abrirEditor checks estado.puedeCrearAlarma() FIRST; on false it
calls _mostrarLimiteAlarmas(context) and returns immediately --
ServicioAnuncios.intentarInterstitial() is only reached on the true branch. A free user at
the 5-alarm cap can never see an interstitial followed by a refusal.
6. Equalizer NOT gated -- VERIFIED COMPLIANT: zero matches for
esPremium, EstadoEntitlement, esPremiumPersistido or ServicioAnuncios across
estado_ecualizador.dart, servicio_ecualizador.dart, pantalla_ajustes_ecualizador.dart and
ecualizador_widget.dart.
7. Encoding scan -- VERIFIED CLEAN across all 13 app_*.arb files for the mojibake pattern
(A-tilde, A-circumflex, a-euro-etc sequences): only the pre-existing, unrelated
app_pt.arb "REPETICAO" false positive. The 4 new keys are byte-clean in every locale.
Note: this scan does NOT catch the untranslated-copy defect found above -- that is a
semantic/content problem, not a mojibake/encoding problem, and is caught by a different
test, arb_anti_copy_test.dart.
8. Test-harness fixes -- VERIFIED LEGITIMATE: diffed all 9 modified harness files against the
commit. Every change is a strictly additive provider registration
(ChangeNotifierProvider<EstadoEntitlement> and/or Provider<ServicioAnuncios> added to each
test's widget tree) required because the new gated call sites now read those providers via
context.read/context.watch. Zero existing assertions were removed, weakened, or altered in
any of the 9 files (navegacion_auto_test.dart's 3 raiz() call sites gained a
"premium: true" argument, not a removed assertion).
### TDD Compliance
| Check | Result | Details |
|-------|--------|---------|
| TDD Evidence reported | Yes | Full RED/GREEN/REFACTOR table present in apply-progress.md |
| All tasks have tests | Yes | 8 new test files map to every pure-logic phase |
| RED confirmed (tests exist) | Yes | All 8 new test files verified present on disk with real assertions |
| GREEN confirmed (tests pass) | Partial | 7/8 new test files pass fully; none of the 8 NEW files is the failing one (arb_anti_copy_test.dart is pre-existing) |
| Triangulation adequate | Yes | Every gated behavior has 3 or more cases (free/premium/edge -- cap boundary, idempotency, stale-id backstop) |
| Safety Net for modified files | Yes | estado_alarmas.dart, estado_grabacion.dart, navegacion_auto.dart, servicio_audio.dart all have pre-existing regression suites re-run and green |
TDD Compliance: 6/6 checks passed (the one Partial is about the pre-existing, unrelated
l10n regression, not this change's own new tests).
### Test Layer Distribution
| Layer | Tests | Files | Tools |
|-------|-------|-------|-------|
| Unit (pure logic) | approx 40 | estado_entitlement_test.dart, estado_alarmas_gating_test.dart, estado_grabacion_gating_test.dart, servicio_compras_test.dart, servicio_anuncios_test.dart, navegacion_auto_gating_test.dart, servicio_audio_gating_test.dart | flutter_test |
| Widget | approx 8 new plus 9 harness files updated | banner_anuncio_superior_test.dart plus regression widget suites | flutter_test |
| E2E | 0 | none | not installed |
| Total (full suite) | 1245 | 1242 pass / 2 skip / 1 fail | |
### Assertion Quality
Audited all 8 new test files (estado_entitlement_test.dart, servicio_compras_test.dart,
estado_alarmas_gating_test.dart, estado_grabacion_gating_test.dart,
navegacion_auto_gating_test.dart, servicio_audio_gating_test.dart,
servicio_anuncios_test.dart, banner_anuncio_superior_test.dart) for banned patterns
(tautologies, ghost loops over possibly-empty collections, assertion-free production calls,
ratio of mocks to assertions). Loops over hardcoded non-empty literal lists (for example the
respuestaBloqueadaPorEntitlement test's loop over a literal id list) do not qualify as ghost
loops since the collection is a non-empty compile-time literal, not a runtime query result.
Assertion quality: All assertions verify real behavior -- 0 CRITICAL, 0 WARNING.
### Correctness (Static Evidence)
| Requirement area | Status | Notes |
|------------|--------|-------|
| Fail-open entitlement default | Implemented, no reachable bypass today | See WARNING (latent risk) |
| Android Auto gate choke points | Implemented | 5 of 5 dispatch methods gated, 3 of 3 transport methods left open |
| Vacations full gate | Implemented | bool return, single failure mode, correctly UI-routed |
| Ad ordering invariants | Implemented | Cap-check strictly precedes interstitial |
| Equalizer isolation | Implemented | Zero cross-references |
| l10n new keys | Partially implemented | 2 of 4 pt keys are untranslated copies (see CRITICAL) |
### Coherence (Design)
| Decision | Followed? | Notes |
|----------|-----------|-------|
| ADR-1 (versioned prefs key, fail-open) | Yes | compra_premium_v1, absent key equals free |
| ADR-2 (sole in_app_purchase call site) | Yes | ServicioComprasPlayBilling only |
| ADR-3 (callback-injection, not direct EstadoEntitlement dependency) | Yes | Mirrors existing emisoraActual pattern |
| ADR-4 (root labels visible, lock one level down) | Yes | Documented deviation from the spec's literal root-locking wording, resolved per orchestrator/design.md; regression-safe for premium |
| ADR-5 (distinct ResultadoGuardarAlarma enum, not overloaded error field) | Yes | |
| ADR-6 (interstitial ordering: cap-check then interstitial then editor) | Yes | Corrected mid-run per apply-progress's own honest disclosure; final state verified correct |
| notifyChildrenChanged deprecation workaround | Yes | Uses the plugin's own documented replacement mechanism |
### Issues Found
CRITICAL:
1. tasks.md task 3.3 is unchecked on the filesystem despite apply-progress and Engram
artifacts claiming full 27/27 completion. Tracking and documentation integrity failure --
blocks a clean archive per the verify decision gate, even though the underlying
implementation and tests for 3.3 are genuinely present and passing.
2. flutter test (full suite, 1245 tests) FAILS: test/l10n/arb_anti_copy_test.dart catches 2
of the 4 new localization keys (desbloquearPremium, restaurarCompras) left byte-identical
to the Spanish template for the pt locale -- a genuine untranslated-copy defect introduced
by this change, undetected because the apply agent's regression batches never included
test/l10n/. Directly contradicts the "full suite green (719+)" claim.
WARNING:
1. The fail-open entitlement default in EstadoAlarmas/EstadoGrabacion is a latent
monetization-bypass risk pattern: no current call site reaches it, but nothing
structurally prevents a future one from silently doing so with no test failure to catch
it (the default fabricates full premium access rather than failing safe). Recommend a
follow-up hardening task: make esPremium a required parameter (forcing every call site,
including the approximately 30 pre-existing tests, to be explicit), or flip the default to
"() => false" and update the tests that rely on implicit ungated construction.
2. "Alarm Cap UX Never Bare-Jumps To Paywall" requirement is source-verified but has no
dedicated widget test asserting the exact snackbar text plus secondary CTA pair in
isolation.
SUGGESTION:
1. crearRangoVacaciones's bool return (Deviation #5) works today because it has exactly one
failure mode. If a second failure mode is ever added (for example a validation error), the
caller will not be able to distinguish it from an entitlement block. Consider migrating to
a small result enum before that happens, matching the ResultadoGuardarAlarma and
ResultadoIniciarGrabacion precedent already established elsewhere in this same change.
2. "dart format --set-exit-if-changed lib/ test/" currently flags 18 pre-existing files
unrelated to this change (confirmed via diff against the Files Changed table) --
pre-existing repository drift, not a regression, but worth a separate cleanup pass.
### Verdict
FAIL -- 2 CRITICAL findings block a clean archive: (1) tasks.md task 3.3 tracking
discrepancy, and (2) a genuine, reproducible test failure in the full flutter test suite
caused by this change's own untranslated Portuguese localization content, which the apply
agent's own claims (full suite green, 27/27 tasks) did not disclose. Both are narrow and
mechanically fixable (check the box; translate 2 strings or add reviewed allowlist entries)
-- recommend routing back to sdd-apply for a small, targeted fix-and-reverify rather than a
full re-implementation. All 20 spec requirements are otherwise source/test-verified
compliant, and the 6 orchestrator-flagged scrutiny points (fail-open default, Android Auto
gating completeness, notifyChildrenChanged replacement, vacations bool gate, interstitial
ordering, equalizer isolation) all check out as implemented correctly.
+82 -2
View File
@@ -325,6 +325,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.3.3"
google_mobile_ads:
dependency: "direct main"
description:
name: google_mobile_ads
sha256: "0d4a3744b5e8ed1b8be6a1b452d309f811688855a497c6113fc4400f922db603"
url: "https://pub.dev"
source: hosted
version: "5.3.1"
hooks:
dependency: transitive
description:
@@ -349,6 +357,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
in_app_purchase:
dependency: "direct main"
description:
name: in_app_purchase
sha256: "0e9510b80b0074e89ab0a8e0fc901439b779dc9ae575ab8d419253c6e1627716"
url: "https://pub.dev"
source: hosted
version: "3.3.0"
in_app_purchase_android:
dependency: transitive
description:
name: in_app_purchase_android
sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905
url: "https://pub.dev"
source: hosted
version: "0.5.2"
in_app_purchase_platform_interface:
dependency: transitive
description:
name: in_app_purchase_platform_interface
sha256: "0b0076cac8ce4fa7048f01e76af8b123aeb6a7c4e0dea2a5206d6664454f3e36"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
in_app_purchase_storekit:
dependency: transitive
description:
name: in_app_purchase_storekit
sha256: "702a23c3d2ddc177b075d521d264900e82f01663881e4ef3ce17775de298c0e3"
url: "https://pub.dev"
source: hosted
version: "0.4.11"
intl:
dependency: "direct main"
description:
@@ -365,6 +405,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
just_audio:
dependency: "direct main"
description:
@@ -906,6 +954,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
webview_flutter:
dependency: transitive
description:
name: webview_flutter
sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111
url: "https://pub.dev"
source: hosted
version: "4.14.1"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: a97db7a44f8e71af2f3971c45550a08cce1fb60059c1b8e534251e6cfb753490
url: "https://pub.dev"
source: hosted
version: "4.13.0"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
url: "https://pub.dev"
source: hosted
version: "2.15.1"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d
url: "https://pub.dev"
source: hosted
version: "3.26.0"
win32:
dependency: transitive
description:
@@ -931,5 +1011,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.10.3 <4.0.0"
flutter: ">=3.38.4"
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"
+29 -6
View File
@@ -1,7 +1,7 @@
name: pluriwave
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
publish_to: 'none'
version: 1.2.0+122
version: 1.3.0+153
environment:
sdk: ^3.7.0
@@ -49,12 +49,13 @@ dependencies:
geocoding: ^3.0.0
package_info_plus: ^8.3.1
# Ads (activar cuando tengamos Ad Unit IDs)
# google_mobile_ads: ^5.3.0
# Ads — TODO: swap Google test ad unit IDs (servicio_anuncios.dart) for
# real AdMob unit IDs once provisioned (iap-freemium-unlock, Open Question).
google_mobile_ads: ^5.3.0
# In-app purchase
# in_app_purchase: ^3.2.0
in_app_purchase: ^3.2.0
# Song recognition (activar con AudD key)
# permission_handler: ^11.3.1
@@ -75,4 +76,26 @@ flutter:
- assets/audio/
- assets/mockups/
- assets/generated/
# Flutter NO recurse: declarar 'assets/content/' incluye solo los
# ficheros sueltos de esa carpeta, nunca los de sus subcarpetas. Todo
# el contenido vive en subcarpetas, asi que NADA de esto viajaba en el
# APK -- verificado abriendo el binario instalado: cero entradas de
# assets/content. El onboarding reventaba en cada arranque con
# 'Unable to load asset: assets/content/onboarding/en.md' aunque el
# fichero existe en disco. Mismo fallo de familia que los drawables
# resueltos por nombre: referencia sin validacion en compilacion.
- assets/content/
- assets/content/onboarding/
- assets/content/updates/ar/
- assets/content/updates/bn/
- assets/content/updates/de/
- assets/content/updates/en/
- assets/content/updates/es/
- assets/content/updates/fr/
- assets/content/updates/hi/
- assets/content/updates/id/
- assets/content/updates/it/
- assets/content/updates/ja/
- assets/content/updates/pt/
- assets/content/updates/ru/
- assets/content/updates/zh/
+115
View File
@@ -1,6 +1,12 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/app.dart';
import 'package:pluriwave/estado/estado_entitlement.dart';
import 'package:pluriwave/servicios/servicio_anuncios.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// S1 (Tier 1 visual fidelity): the prototype (`t4`) never draws a global
/// `AppBar` — every root owns its own 56px title row instead (see
@@ -20,4 +26,113 @@ void main() {
'PluriRootHeader inside its content instead',
);
});
test('the first-launch sequence runs the welcome screen, then the tutorial '
'carousel, then the recurring what-is-new dialog, in that order', () {
// `_PaginaPrincipal` is library-private and its
// `_mostrarFlujoPrimerLanzamiento` constructs real platform-backed
// services, so it cannot be safely widget-tested here (same
// constraint as the AppBar guard above). This is a fast source-level
// ordering guard instead: PantallaBienvenida.mostrarSiProcede must
// run before PantallaTutorialAyuda.mostrarSiProcede, which must run
// before the unrelated, pre-existing _mostrarOnboardingInicial() call
// -- so the tutorial shows once on every launch sequence (fresh
// installs AND existing installs upgrading to this version) without
// ever racing the welcome screen or the what's-new dialog.
final source = File('lib/app.dart').readAsStringSync();
final indiceBienvenida = source.indexOf(
'PantallaBienvenida.mostrarSiProcede',
);
final indiceTutorial = source.indexOf(
'PantallaTutorialAyuda.mostrarSiProcede',
);
final indiceOnboarding = source.indexOf('_mostrarOnboardingInicial()');
expect(
indiceBienvenida,
greaterThanOrEqualTo(0),
reason: 'PantallaBienvenida.mostrarSiProcede must still be called',
);
expect(
indiceTutorial,
greaterThanOrEqualTo(0),
reason: 'PantallaTutorialAyuda.mostrarSiProcede must be wired in',
);
expect(
indiceOnboarding,
greaterThanOrEqualTo(0),
reason: '_mostrarOnboardingInicial() must still be called',
);
expect(
indiceBienvenida,
lessThan(indiceTutorial),
reason: 'the welcome screen must run before the tutorial carousel',
);
expect(
indiceTutorial,
lessThan(indiceOnboarding),
reason: 'the tutorial carousel must run before the what-is-new dialog',
);
});
group(
'construirCuerpoPrincipal — banner y la status bar (FIX 1, code review)',
() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Future<void> bombear(WidgetTester tester, {required bool premium}) async {
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(padding: EdgeInsets.only(top: 44)),
child: MaterialApp(
home: MultiProvider(
providers: [
ChangeNotifierProvider<EstadoEntitlement>(
create: (_) => EstadoEntitlement(prefs: null),
),
Provider<ServicioAnuncios>(
create: (_) => ServicioAnuncios(esPremium: () => premium),
),
],
child: Scaffold(
body: construirCuerpoPrincipal(
contenido: const Align(
alignment: Alignment.topLeft,
child: Text('contenido'),
),
),
),
),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
}
testWidgets(
'usuario premium: el contenido arranca en y=0 -- edge-to-edge, sin '
'franja en blanco reservada para la status bar',
(tester) async {
await bombear(tester, premium: true);
expect(tester.getTopLeft(find.text('contenido')).dy, 0);
},
);
testWidgets(
'usuario free con el banner aún sin cargar: el contenido arranca '
'igualmente en y=0 -- misma posición edge-to-edge que antes del '
'cambio, no una franja reservada de 44px hasta que el ad cargue',
(tester) async {
await bombear(tester, premium: false);
expect(tester.getTopLeft(find.text('contenido')).dy, 0);
},
);
},
);
}
+96
View File
@@ -0,0 +1,96 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/main.dart';
/// Reported: with Android Auto connected, the car screen sometimes came up
/// completely BLACK, and opening the app on the phone then showed a
/// completely WHITE screen until it was force-killed and reopened. Never
/// without Android Auto.
///
/// Cause, verified in the plugin source:
/// `AudioServiceActivity.provideFlutterEngine` returns
/// `AudioServicePlugin.getFlutterEngine(context)`, which CREATES the engine
/// and runs `main()` the first time it is asked — and the car asks first,
/// when it binds the MediaBrowserService, so `main()` runs HEADLESS with no
/// Activity. `SystemChrome.setPreferredOrientations` travels the
/// `flutter/platform` channel, whose handler (`PlatformPlugin`) is installed
/// by the Activity. Headless, nobody answers it.
///
/// It was the FIRST `await` in `main()`, so that one call took the whole
/// startup with it: the Android Auto browse source below it was never
/// registered (`getChildren` had no source → black car screen) and `runApp`
/// was never reached. Opening the app then reused that same cached, already
/// dead engine → white screen. Only a force-kill, which disposes the cached
/// engine, recovered it — exactly the workaround that was reported.
///
/// The user's own guess was that portrait-only + a landscape phone made the
/// app "go a bit crazy". Right file, right trigger, different mechanism: a
/// broken layout renders overflow stripes or a red error box, never white.
/// White means nothing was ever built.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('política de orientación', () {
test('un móvil se queda en vertical', () {
expect(orientacionesPara(411), const [DeviceOrientation.portraitUp]);
expect(orientacionesPara(599.9), const [DeviceOrientation.portraitUp]);
});
test('una tablet puede girar', () {
expect(orientacionesPara(600), DeviceOrientation.values);
expect(orientacionesPara(1280), DeviceOrientation.values);
});
});
group('nunca puede tumbar el arranque', () {
test('un fallo del canal de plataforma se traga, no se propaga', () async {
// This is the headless case: no PlatformPlugin, so the call fails.
// Before the fix this exception escaped out of main() and killed
// startup before runApp and before the Android Auto registration.
await expectLater(
aplicarPoliticaOrientacion(
aplicar:
(_) async =>
throw MissingPluginException(
'No implementation found for method '
'SystemChrome.setPreferredOrientations on channel '
'flutter/platform',
),
),
completes,
);
});
test('un canal que nunca responde tampoco puede colgar a quien llama, '
'porque main() ya no lo espera', () async {
// The structural half of the fix: main() calls this through
// `unawaited(...)`. Proven here by starting a call that never settles
// and showing the test still finishes -- if startup awaited it, this
// future is exactly what would hang forever on the headless engine.
var termino = false;
unawaited(
aplicarPoliticaOrientacion(
aplicar: (_) => Completer<void>().future,
).then((_) => termino = true),
);
await Future<void>.delayed(Duration.zero);
expect(termino, isFalse, reason: 'sigue pendiente, como debe');
// The point is that nothing above depends on it.
});
test('el camino feliz sigue aplicando la política de la pantalla', () {
// Guard against "fixed" by neutering: the swallow-everything wrapper
// must still actually apply something on a healthy engine.
late List<DeviceOrientation> aplicadas;
return aplicarPoliticaOrientacion(
aplicar: (o) async => aplicadas = o,
).then((_) {
expect(aplicadas, isNotEmpty);
expect(aplicadas, orientacionesPara(800 / 1));
});
});
});
}
@@ -0,0 +1,55 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// Every file under `assets/content/` must be loadable through `rootBundle`,
/// which is the only thing that proves it is DECLARED in pubspec.yaml and
/// therefore actually ships.
///
/// Found by reading the installed APK: it contained ZERO entries under
/// `assets/content/`, while `assets/icons/alarmas/*` was present. pubspec
/// declared `assets/content/` — and Flutter does NOT recurse: naming a
/// directory includes the files sitting directly in it, never its
/// subdirectories. All of this content lives in subdirectories
/// (`onboarding/`, `updates/<locale>/`), so the entire onboarding and
/// release-notes feature had never shipped in any build. On the device it
/// surfaced on every launch as:
///
/// Unable to load asset: "assets/content/onboarding/en.md"
///
/// with the file plainly present on disk.
///
/// Same family as the drawables the resource shrinker deleted: a reference by
/// NAME that nothing validates at compile time, so it fails only on a device.
/// A test that merely checked `File(...).existsSync()` would have stayed green
/// throughout — the files were never missing. Loading through `rootBundle` is
/// what makes it a real guard, because that is the path the app itself takes.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final directorio = Directory('assets/content');
final ficheros =
directorio
.listSync(recursive: true)
.whereType<File>()
.map((f) => f.path.replaceAll(r'\', '/'))
.toList()
..sort();
test('hay contenido que comprobar (si no, este test sería vacuo)', () {
expect(ficheros, isNotEmpty);
});
for (final ruta in ficheros) {
test('$ruta está declarado y se puede cargar', () async {
await expectLater(
rootBundle.loadString(ruta),
completes,
reason:
'existe en disco pero rootBundle no lo encuentra: falta declarar '
'su directorio en pubspec.yaml, y no viajará en el APK',
);
});
}
}

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