Author SHA1 Message Date
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
ShanaiaBot b365035e10 chore: bump version to 1.2.2+124 [ci skip] 2026-07-30 17:56:28 +02:00
109 changed files with 14794 additions and 2171 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
@@ -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_*" />
+11
View File
@@ -16,6 +16,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';
@@ -281,10 +282,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();
}
+289 -26
View File
@@ -90,6 +90,7 @@ class EstadoAlarmas extends ChangeNotifier {
);
await _sincronizarTodas();
await cargarDiagnostico();
await cargarFallosNativos();
_activarRefresco();
} catch (e) {
_error = 'No se pudieron cargar las alarmas: $e';
@@ -117,8 +118,11 @@ 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();
}
@@ -198,6 +202,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 +316,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 +399,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 +434,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 +459,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 +473,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
@@ -447,6 +610,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.
@@ -546,6 +739,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
@@ -622,8 +869,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);
}
}
}
+24 -1
View File
@@ -18,6 +18,23 @@ 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';
}
class EstadoGrabacion extends ChangeNotifier {
EstadoGrabacion({
ServicioGrabacionRadio? servicio,
@@ -72,7 +89,13 @@ class EstadoGrabacion extends ChangeNotifier {
Future<void> iniciar({Duration? duracion}) async {
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;
}
+34 -6
View File
@@ -394,7 +394,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();
}
}
@@ -402,7 +409,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();
}
@@ -520,6 +533,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();
}
@@ -659,7 +682,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;
}
@@ -685,7 +710,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();
}
@@ -701,7 +727,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;
}
@@ -714,7 +741,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;
}
+69 -2
View File
@@ -604,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": "الاسم",
@@ -830,5 +850,52 @@
"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": "تعطيل"
}
+69 -2
View File
@@ -604,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": "নাম",
@@ -830,5 +850,52 @@
"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": "বন্ধ করুন"
}
+69 -2
View File
@@ -604,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",
@@ -830,5 +850,52 @@
"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"
}
+69 -2
View File
@@ -716,7 +716,27 @@
"endLabel": "End",
"equalizerDisable": "Disable equalizer",
"helpTitle": "Help and tutorial",
"helpSubtitle": "Review PluriWave features, tips and whats 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",
@@ -830,5 +850,52 @@
"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"
}
+69 -2
View File
@@ -672,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",
@@ -789,5 +809,52 @@
"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"
}
+69 -2
View File
@@ -604,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",
@@ -830,5 +850,52 @@
"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"
}
+69 -2
View File
@@ -604,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": "नाम",
@@ -830,5 +850,52 @@
"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": "बंद करें"
}
+69 -2
View File
@@ -604,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",
@@ -830,5 +850,52 @@
"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"
}
+69 -2
View File
@@ -604,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",
@@ -830,5 +850,52 @@
"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"
}
+69 -2
View File
@@ -604,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": "名前",
@@ -830,5 +850,52 @@
"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": "無効化"
}
+69 -2
View File
@@ -604,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",
@@ -830,5 +850,52 @@
"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"
}
+69 -2
View File
@@ -604,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": "Название",
@@ -830,5 +850,52 @@
"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": "Отключить"
}
+69 -2
View File
@@ -604,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": "名称",
@@ -830,5 +850,52 @@
"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": "关闭"
}
+277 -1
View File
@@ -2567,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:
@@ -3049,6 +3169,162 @@ 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;
}
class _AppLocalizationsDelegate
+165 -1
View File
@@ -1407,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 => 'غير محدد';
@@ -1676,4 +1745,99 @@ 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 => 'تعطيل';
}
+167 -1
View File
@@ -1417,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 => 'অনির্দিষ্ট';
@@ -1685,4 +1757,98 @@ 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 => 'বন্ধ করুন';
}
+168 -2
View File
@@ -1425,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';
@@ -1698,4 +1769,99 @@ 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';
}
+166 -1
View File
@@ -1408,7 +1408,77 @@ class AppLocalizationsEn extends AppLocalizations {
String get helpTitle => 'Help and tutorial';
@override
String get helpSubtitle => 'Review PluriWave features, tips and whats 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';
@@ -1678,4 +1748,99 @@ 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';
}
+167 -2
View File
@@ -1419,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';
@@ -1692,4 +1760,101 @@ 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';
}
+171 -2
View File
@@ -1428,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';
@@ -1701,4 +1772,102 @@ 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';
}
+166 -2
View File
@@ -1411,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 => 'अनिश्चित';
@@ -1680,4 +1750,98 @@ 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 => 'बंद करें';
}
+167 -1
View File
@@ -1420,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';
@@ -1688,4 +1759,99 @@ 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';
}
+168 -1
View File
@@ -1428,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';
@@ -1700,4 +1770,101 @@ 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';
}
+161 -1
View File
@@ -1368,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 => '無期限';
@@ -1631,4 +1700,95 @@ 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 => '無効化';
}
+167 -1
View File
@@ -1416,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';
@@ -1688,4 +1758,100 @@ 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';
}
+170 -1
View File
@@ -1420,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 => 'Без ограничения';
@@ -1692,4 +1764,101 @@ 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 => 'Отключить';
}
+154 -1
View File
@@ -1363,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 => '不限时';
@@ -1623,4 +1691,89 @@ 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 => '关闭';
}
+147 -33
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:ui' as ui;
import 'package:audio_service/audio_service.dart';
@@ -11,6 +12,7 @@ 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_presets_personalizados.dart';
import 'tema/pluriwave_tokens.dart';
const _anchoMinimoLandscape = 600.0;
@@ -24,36 +26,110 @@ 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());
// 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,6 +145,11 @@ 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());
}
@@ -102,21 +183,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 +257,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(),
@@ -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,
+5 -3
View File
@@ -147,7 +147,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 +204,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,7 +252,7 @@ class _AjustesContent extends StatelessWidget {
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 16, key: ValueKey('ajustes-group-gap-3')),
GrupoAjustes(
titulo: l10n.settingsGroupApplicationTitle,
filas: [
+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(
+221 -52
View File
@@ -18,6 +18,7 @@ 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 {
@@ -265,6 +266,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 +344,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 +410,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 +492,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 {
@@ -1291,6 +1437,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 +1455,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 +1814,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(', ');
}
@@ -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)),
],
),
);
}
}
+57 -20
View File
@@ -174,12 +174,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 +230,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 +272,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 +381,6 @@ class _FilaChipsGrupos extends StatelessWidget {
class _FilaFavorito extends StatelessWidget {
const _FilaFavorito({
super.key,
required this.index,
required this.emisora,
required this.grupos,
+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(
+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),
+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),
),
);
}
}
+47 -4
View File
@@ -842,10 +842,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),
),
],
],
),
],
),
@@ -903,6 +931,21 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
}
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);
}
}
class _PickerButton extends StatelessWidget {
+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
+526 -97
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,12 +312,22 @@ 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
@@ -305,15 +347,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 +482,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 +504,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 +534,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 +760,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 +779,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 +814,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,12 +842,60 @@ 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;
}
}
@@ -777,15 +925,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 +1171,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 +1213,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 +1258,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 +1384,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 +1412,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 +1539,7 @@ Future<List<MediaItem>?> hijosMusicaLocal(
documentIdPadre: documentId,
pagina: pagina,
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
fuente: fuente,
);
} catch (_) {
return const [];
@@ -1170,6 +1596,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);
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.
@@ -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);
}
}
+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,
),
],
),
),
),
);
+23 -1
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.1+123
version: 1.2.28+150
environment:
sdk: ^3.7.0
@@ -75,4 +75,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/
+49
View File
@@ -20,4 +20,53 @@ 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',
);
});
}
+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',
);
});
}
}
@@ -0,0 +1,177 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes_alarmas.dart';
/// Reported on-device: an alarm set for Monday 16:20 never rang, and the
/// "next alarm" banner showed a DIFFERENT alarm (the next morning's) instead.
///
/// Root cause: `finalizarEjecucion` ("Detener") 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 stopping today's ring recorded NEXT week's occurrence as
/// already handled. `ServicioProgramacionAlarmas._esValida` then rejected
/// that occurrence for real, and the alarm silently jumped past it: it never
/// rang, and every sibling alarm outranked it in the banner.
///
/// This is the exact hazard `posponerAlarma` was fixed for in `9c7cf4e`
/// ("anchor snooze to the ringing occurrence, never a future one"). The guard
/// landed on the snooze path and never on the stop path, which sits directly
/// below it in the same file.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
AlarmaMusical semanalLunes(String id) => AlarmaMusical(
id: id,
nombre: 'Tarde del lunes',
hora: 16,
minuto: 20,
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
diasSemana: const [DateTime.monday],
);
test('Detener cierra la ocurrencia que sonaba, no quema la siguiente '
'(el nativo ya avanzó proximaEjecucion antes de que el usuario '
'llegue a la pantalla)', () async {
// Monday 2026-08-03.
var ahora = DateTime(2026, 8, 3, 16, 0);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(semanalLunes('a1'));
expect(
estado.alarmas.single.proximaEjecucion,
DateTime(2026, 8, 3, 16, 20),
);
// 16:20 — it rings. The native scheduler records the occurrence handled
// and rearms; the cold-start sync brings that over, which advances
// proximaEjecucion to NEXT Monday while the alarm is still ringing.
ahora = DateTime(2026, 8, 3, 16, 20, 5);
android.ejecucionesNativas.add(
EjecucionAlarmaNativa(
alarmaId: 'a1',
gestionadaEn: DateTime(2026, 8, 3, 16, 20),
),
);
await estado.inicializar();
expect(
estado.alarmas.single.proximaEjecucion,
DateTime(2026, 8, 10, 16, 20),
reason: 'precondición: el nativo ya avanzó a la semana siguiente',
);
// NOW the user taps "Detener" on the ring screen.
await estado.finalizarEjecucion('a1');
expect(
estado.alarmas.single.proximaEjecucion,
DateTime(2026, 8, 10, 16, 20),
reason:
'Detener debe cerrar la ocurrencia que sonaba (hoy), no consumir '
'la del lunes que viene empujándola a 2026-08-17',
);
expect(
estado.alarmas.single.ultimaEjecucionGestionada,
isNot(DateTime(2026, 8, 10, 16, 20)),
reason:
'marcar como gestionada una ocurrencia futura es justo lo que hace '
'que _esValida la rechace y esa alarma no suene ese día',
);
});
test('estado ya envenenado se cura solo: una ocurrencia futura marcada '
'como gestionada se descarta al recalcular', () async {
// Devices that ran the buggy build carry the poisoned value in
// SharedPreferences. Without this, the fix would still leave the
// affected alarm skipping one more time, with nothing in the UI to
// explain it -- and the user would reasonably read that as "not fixed".
final ahora = DateTime(2026, 8, 3, 9, 0);
final servicio = ServicioAlarmas(reloj: () => ahora);
// Saved by the buggy stop path: next Monday recorded as already handled.
await servicio.guardarAlarma(
semanalLunes(
'a3',
).copyWith(ultimaEjecucionGestionada: DateTime(2026, 8, 10, 16, 20)),
);
final config = await servicio.recalcularTodas();
final alarma = config.alarmas.single;
expect(alarma.ultimaEjecucionGestionada, isNull);
expect(
alarma.proximaEjecucion,
DateTime(2026, 8, 3, 16, 20),
reason: 'y con el dato corrupto fuera, hoy vuelve a ser candidata',
);
});
test('una ocurrencia gestionada REAL (pasada) se conserva: es la que evita '
'que la alarma vuelva a sonar en el mismo minuto', () async {
final ahora = DateTime(2026, 8, 3, 16, 20, 30);
final servicio = ServicioAlarmas(reloj: () => ahora);
final gestionada = DateTime(2026, 8, 3, 16, 20);
await servicio.guardarAlarma(
semanalLunes('a4').copyWith(ultimaEjecucionGestionada: gestionada),
);
final alarma = (await servicio.recalcularTodas()).alarmas.single;
expect(alarma.ultimaEjecucionGestionada, gestionada);
expect(
alarma.proximaEjecucion,
DateTime(2026, 8, 10, 16, 20),
reason: 'la de hoy ya sonó, la siguiente es el lunes que viene',
);
});
test(
'Detener sin nada sonando tampoco consume la próxima ocurrencia',
() async {
// Defensive: the ring screen is the only production caller, but a stale
// route or a duplicated stop event must not silently eat a day.
var ahora = DateTime(2026, 8, 3, 9, 0);
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => ahora),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(semanalLunes('a2'));
expect(
estado.alarmas.single.proximaEjecucion,
DateTime(2026, 8, 3, 16, 20),
);
ahora = DateTime(2026, 8, 3, 9, 1);
await estado.finalizarEjecucion('a2');
expect(
estado.alarmas.single.proximaEjecucion,
DateTime(2026, 8, 3, 16, 20),
reason: 'a las 09:01 la ocurrencia de las 16:20 no está sonando',
);
},
);
}
@@ -0,0 +1,112 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes_alarmas.dart';
/// The native scheduler records three failures entirely 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.
///
/// Before this wiring existed, all three logged to logcat and stopped there.
/// The alarm stayed switched on in the list, drawn exactly as if scheduling
/// had succeeded, and simply never fired — the reported "as if there were no
/// alarm at all". These tests pin the drain path that makes them visible.
///
/// They deliberately build fixtures through [FalloProgramacionNativo.fromMap]
/// using the REAL native key names (`alarmId`/`type`/`atMillis`, see
/// `AlarmScheduler.kt:1389-1391`). An earlier draft seeded the fake with
/// guessed Spanish keys and consumed the same guess, so every entry would
/// have been silently dropped in production while the tests stayed green.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakePuertoAlarmasAndroid android;
setUp(() {
SharedPreferences.setMockInitialValues({});
android = FakePuertoAlarmasAndroid();
});
EstadoAlarmas crearEstado() =>
EstadoAlarmas(android: android, iniciarAutomaticamente: false);
/// Mirrors exactly what the native side puts on the channel.
FalloProgramacionNativo falloNativo(String alarmaId, String tipo) =>
FalloProgramacionNativo.fromMap({
'alarmId': alarmaId,
'type': tipo,
'atMillis': 1700000000000,
});
test('the fixture helper decodes the real native key names', () {
final fallo = falloNativo('a0', ExcepcionAlarma.tipoFalloPreaviso);
expect(fallo.alarmaId, 'a0');
expect(fallo.tipo, ExcepcionAlarma.tipoFalloPreaviso);
});
test('a native pre-notice failure becomes a per-alarm exception', () async {
android.fallosNativos = [
falloNativo('a1', ExcepcionAlarma.tipoFalloPreaviso),
];
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.cargarFallosNativos();
final fallo = estado.ultimaExcepcionPara('a1');
expect(fallo, isNotNull);
expect(fallo!.tipo, ExcepcionAlarma.tipoFalloPreaviso);
});
test(
'a refused foreground-service start becomes a per-alarm exception',
() async {
android.fallosNativos = [
falloNativo('a2', ExcepcionAlarma.tipoFalloServicioSonido),
];
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.cargarFallosNativos();
expect(
estado.ultimaExcepcionPara('a2')?.tipo,
ExcepcionAlarma.tipoFalloServicioSonido,
);
},
);
test('only the alarm whose reschedule failed is marked', () async {
android.fallosNativos = [
falloNativo('a3', ExcepcionAlarma.tipoFalloReprogramacionArranque),
];
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.cargarFallosNativos();
expect(estado.ultimaExcepcionPara('a3'), isNotNull);
expect(
estado.ultimaExcepcionPara('a4'),
isNull,
reason: 'a sibling alarm that scheduled fine must stay unmarked',
);
});
test('a failing native read never surfaces as an alarm error', () async {
// A diagnostics gap must not look like a scheduling problem: an older
// native build simply has no such channel method.
android.fallaLecturaFallosNativos = true;
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.cargarFallosNativos();
expect(estado.error, isNull);
});
}
@@ -0,0 +1,163 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes_alarmas.dart';
/// Reported twice on-device: "Posponer" on the pre-notice notification left
/// the alarm snoozed 1400+ minutes — a whole day — instead of the configured
/// few minutes.
///
/// The first fix (7054a4c) guarded the NATIVE anchor, and it was not enough,
/// because Dart runs AFTERWARDS on this path: the receiver's `postponeNext`
/// fires, then `startActivity`, then `app.dart` dispatches here, and this
/// method persists and reschedules. Whatever Dart computes is the value that
/// survives. It was the last snooze path with no occurrence guard at all.
///
/// It also cannot simply reuse `_ocurrenciaSonando`: the pre-notice's
/// occurrence legitimately has NOT arrived yet (the reminder is armed 30 min
/// ahead), so the ringing-screen guard would reject a perfectly good anchor.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
AlarmaMusical diaria(String id) => AlarmaMusical(
id: id,
nombre: 'Mañana',
hora: 16,
minuto: 20,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
snoozeMinutos: 5,
);
({EstadoAlarmas estado, FakePuertoAlarmasAndroid android}) montar(
DateTime Function() reloj,
) {
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: reloj),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
return (estado: estado, android: android);
}
test('una ocurrencia del DÍA SIGUIENTE se rechaza: pospone minutos, '
'no 24 horas', () async {
// The exact reported shape. The pre-notice for today's 16:20 is on
// screen at 16:16; the anchor handed in points at TOMORROW (either the
// native spec was already advanced, or app.dart fell back to a
// proximaEjecucion that had moved on).
var ahora = DateTime(2026, 8, 3, 16, 0);
final m = montar(() => ahora);
await m.estado.guardarAlarma(diaria('p1'));
ahora = DateTime(2026, 8, 3, 16, 16);
await m.estado.posponerProximaDesdePreaviso(
m.estado.alarmas.single,
5,
DateTime(2026, 8, 4, 16, 20), // <- tomorrow
);
final snooze = m.estado.alarmas.single.snoozeHasta!;
final minutos = snooze.difference(ahora).inMinutes;
expect(
minutos,
lessThan(60),
reason:
'la alarma quedó a $minutos min ($snooze). El reporte fue "más de '
'1400 minutos"; cualquier cosa por encima de una hora es el mismo bug',
);
expect(
m.estado.alarmas.single.snoozeOrigen,
isNot(DateTime(2026, 8, 4, 16, 20)),
reason:
'el ancla sin validar también se guarda como snoozeOrigen y como '
'ultimaEjecucionGestionada — envenenaría el estado que a9da855 y '
'0430059 existen para mantener limpio',
);
});
test('la ocurrencia REAL del preaviso se respeta aunque esté en el futuro: '
'ancla en la ocurrencia + N, no en ahora + N', () async {
// The whole reason this path needs its own guard instead of reusing
// _ocurrenciaSonando: 30 minutes ahead is legitimate here.
var ahora = DateTime(2026, 8, 3, 16, 0);
final m = montar(() => ahora);
await m.estado.guardarAlarma(diaria('p2'));
// Pre-notice fires at 15:50; the user taps at 15:52, 28 min before.
ahora = DateTime(2026, 8, 3, 15, 52);
await m.estado.posponerProximaDesdePreaviso(
m.estado.alarmas.single,
5,
DateTime(2026, 8, 3, 16, 20),
);
expect(m.estado.alarmas.single.snoozeHasta, DateTime(2026, 8, 3, 16, 25));
expect(m.estado.alarmas.single.snoozeOrigen, DateTime(2026, 8, 3, 16, 20));
});
test('un snooze ya envenenado en disco se cura al recalcular', () async {
// Devices that ran the buggy build carry snoozeHasta = tomorrow in
// SharedPreferences. Without healing it, the alarm keeps reporting
// tomorrow on every tick and the user sees no change after updating.
final ahora = DateTime(2026, 8, 3, 16, 0);
final servicio = ServicioAlarmas(reloj: () => ahora);
await servicio.guardarAlarma(
diaria('p4').copyWith(
snoozeHasta: DateTime(2026, 8, 4, 16, 25),
snoozeOrigen: DateTime(2026, 8, 4, 16, 20),
),
);
final alarma = (await servicio.recalcularTodas()).alarmas.single;
expect(alarma.snoozeHasta, isNull);
expect(alarma.proximaProgramable, DateTime(2026, 8, 3, 16, 20));
});
test('un snooze legítimo de 2 horas NO se toca', () async {
// posponerEjecucion clamps to 120 minutes, so the ceiling has to sit
// above that or the heal would eat real snoozes.
final ahora = DateTime(2026, 8, 3, 16, 0);
final servicio = ServicioAlarmas(reloj: () => ahora);
final hasta = DateTime(2026, 8, 3, 18, 0);
await servicio.guardarAlarma(
diaria('p5').copyWith(snoozeHasta: hasta, snoozeOrigen: ahora),
);
expect(
(await servicio.recalcularTodas()).alarmas.single.snoozeHasta,
hasta,
);
});
test('un ancla absurdamente lejana cae a la ocurrencia propia de la alarma, '
'no a un valor inventado', () async {
var ahora = DateTime(2026, 8, 3, 16, 0);
final m = montar(() => ahora);
await m.estado.guardarAlarma(diaria('p3'));
ahora = DateTime(2026, 8, 3, 16, 10);
await m.estado.posponerProximaDesdePreaviso(
m.estado.alarmas.single,
5,
DateTime(2027, 1, 1, 16, 20), // absurd
);
// proximaEjecucion (today 16:20) is inside the pre-notice window, so it
// is the right fallback and the snooze lands on 16:25.
expect(m.estado.alarmas.single.snoozeHasta, DateTime(2026, 8, 3, 16, 25));
});
}
+139
View File
@@ -611,6 +611,145 @@ void main() {
},
);
test(
'guardarAlarma: cuando android.programar falla, marca la alarma con una '
'excepcion de fallo visible via ultimaExcepcionPara',
() async {
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'fallo1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
final excepcion = estado.ultimaExcepcionPara('fallo1');
expect(excepcion, isNotNull);
expect(excepcion!.tipo, ExcepcionAlarma.tipoFalloProgramacion);
expect(estado.error, isNotNull);
},
);
test(
'guardarAlarma: un reintento exitoso limpia la excepcion de fallo previa',
() async {
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
final alarma = const AlarmaMusical(
id: 'fallo2',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
);
await estado.guardarAlarma(alarma);
expect(estado.ultimaExcepcionPara('fallo2'), isNotNull);
android.fallaProgramar = false;
await estado.guardarAlarma(estado.alarmas.single);
expect(estado.ultimaExcepcionPara('fallo2'), isNull);
},
);
test(
'guardarAlarma en el camino feliz nunca registra una excepcion de fallo',
() async {
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'ok1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
expect(estado.ultimaExcepcionPara('ok1'), isNull);
expect(estado.error, isNull);
},
);
test(
'inicializar: un fallo de programacion en UNA alarma no aborta la '
'sincronizacion de las demas (S-sincronizarTodas continua tras error)',
() async {
final android = FakePuertoAlarmasAndroid()
..idsFallanProgramar.add('rota');
final servicio = ServicioAlarmas(
reloj: () => DateTime(2026, 5, 25, 6, 0),
);
await servicio.guardarAlarma(
AlarmaMusical(
id: 'rota',
nombre: 'Rota',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.guardarAlarma(
AlarmaMusical(
id: 'sana',
nombre: 'Sana',
hora: 8,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
final estado = EstadoAlarmas(
servicio: servicio,
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.inicializar();
expect(
android.programadas.map((a) => a.id),
contains('sana'),
reason:
'la alarma sana debe seguir programandose aunque la rota falle',
);
expect(estado.ultimaExcepcionPara('rota'), isNotNull);
expect(estado.ultimaExcepcionPara('sana'), isNull);
},
);
group('EstadoAlarmas — consultas de vacaciones (ADR-6, WU9)', () {
test('rangoVacacionesActivo devuelve el rango cuyo intervalo incluye '
'"ahora" (dias restantes derivables de finDia), o null si ninguno '
@@ -0,0 +1,139 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes_alarmas.dart';
/// fix/alarmas-fallos-silenciosos, item 3: "verify the alarm is actually
/// registered, and say so if it is not". `android.programar` returning
/// without throwing is not proof enough by itself -- this is the check that
/// would have caught the reported case immediately (the native side can
/// silently fail to persist the registration even when the channel call
/// itself reports success).
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('guardarAlarma detecta que el conteo nativo no refleja la alarma '
'guardada, aunque android.programar no haya lanzado', () async {
// Fixed at 0 regardless of what programar() does internally --
// simulates the native side accepting the channel call but never
// actually persisting the registration.
final android = FakePuertoAlarmasAndroid()..alarmasNativasPendientes = 0;
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'silenciosa1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
final excepcion = estado.ultimaExcepcionPara('silenciosa1');
expect(excepcion, isNotNull);
expect(excepcion!.tipo, ExcepcionAlarma.tipoFalloProgramacion);
expect(estado.error, isNotNull);
});
test('guardarAlarma en el camino feliz (conteo nativo coincide) no registra '
'fallo alguno', () async {
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'sana1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
expect(estado.ultimaExcepcionPara('sana1'), isNull);
expect(estado.error, isNull);
});
test(
'inicializar importa los fallos nativos (pre-aviso, servicio en primer '
'plano, reprogramacion tras arranque) como excepciones por alarma',
() async {
final android =
FakePuertoAlarmasAndroid()
..fallosProgramacionNativos.addAll([
FalloProgramacionNativo(
alarmaId: 'con-preaviso-roto',
tipo: ExcepcionAlarma.tipoFalloPreaviso,
ocurridoEn: DateTime(2026, 5, 25, 7),
),
FalloProgramacionNativo(
alarmaId: 'servicio-rechazado',
tipo: ExcepcionAlarma.tipoFalloServicioSonido,
ocurridoEn: DateTime(2026, 5, 25, 7),
),
]);
final servicio = ServicioAlarmas(
reloj: () => DateTime(2026, 5, 25, 7, 30),
);
await servicio.guardarAlarma(
AlarmaMusical(
id: 'con-preaviso-roto',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.guardarAlarma(
AlarmaMusical(
id: 'servicio-rechazado',
nombre: 'Diaria',
hora: 8,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
final estado = EstadoAlarmas(
servicio: servicio,
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.inicializar();
final falloPreaviso = estado.ultimaExcepcionPara('con-preaviso-roto');
expect(falloPreaviso, isNotNull);
expect(falloPreaviso!.tipo, ExcepcionAlarma.tipoFalloPreaviso);
final falloServicio = estado.ultimaExcepcionPara('servicio-rechazado');
expect(falloServicio, isNotNull);
expect(falloServicio!.tipo, ExcepcionAlarma.tipoFalloServicioSonido);
},
);
}
@@ -0,0 +1,57 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/modelos/emisora.dart';
/// Reported: recording a station stopped working, 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 the message is a LOCAL MP3, not a station.
/// `PluriWaveAudioHandler._cambiarFuente` sets `emisoraActual` for every
/// source it plays, so a local track becomes an `Emisora` whose `url` is the
/// SAF `content://` document URI it was opened from. The recorder then tried
/// to open that as an HTTP stream.
///
/// "It used to work" is accurate: before local music playback existed,
/// whatever was playing was always a real station, so this could not happen.
void main() {
Emisora conUrl(String url) => Emisora(uuid: 'u', nombre: 'n', url: url);
test('un stream de red es grabable', () {
expect(esEmisoraGrabable(conUrl('http://stream.example.com/live')), isTrue);
expect(
esEmisoraGrabable(conUrl('https://stream.example.com/live')),
isTrue,
);
expect(
esEmisoraGrabable(conUrl('HTTPS://STREAM.EXAMPLE.COM/live')),
isTrue,
reason: 'el esquema no distingue mayúsculas',
);
});
test('la URI content:// de una pista local NO es grabable — el caso '
'exacto del reporte', () {
expect(
esEmisoraGrabable(
conUrl(
'content://com.android.externalstorage.documents/tree/'
'primary%3AMusic/document/primary%3AMusic%2FNew%20Limit%20-%20'
'Smile.mp3',
),
),
isFalse,
);
});
test('ni un fichero local, ni una url vacía o rota', () {
expect(
esEmisoraGrabable(conUrl('file:///storage/emulated/0/a.mp3')),
isFalse,
);
expect(esEmisoraGrabable(conUrl('')), isFalse);
expect(esEmisoraGrabable(conUrl('no es una uri')), isFalse);
});
}
+130
View File
@@ -607,6 +607,136 @@ void main() {
);
});
test(
'fix android-auto-orden: el snapshot de favoritos es EXACTAMENTE '
'listaFavoritosManual (el orden manual que la pantalla Favoritos '
'muestra y reordena), nunca la lista cruda ni un getter reordenado',
() async {
final favoritosServicio = FakeServicioFavoritos();
// Agregados en orden Z, A: si el push usara un getter reordenado
// (p. ej. alfabético), 'alfa' iría primero. El orden manual conserva
// el orden de inserción/persistencia: Z, A.
await favoritosServicio.agregar(
emisoraDemo(uuid: 'zulu', nombre: 'Zulu Fav'),
);
await favoritosServicio.agregar(
emisoraDemo(uuid: 'alfa', nombre: 'Alfa Fav'),
);
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritosServicio,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(fuenteAuto.ultimoFavoritos?.map((e) => e.uuid).toList(), [
'zulu',
'alfa',
]);
expect(fuenteAuto.ultimoFavoritos, equals(estado.listaFavoritosManual));
},
);
test('fix android-auto-orden: el snapshot de "Todas" honra el orden '
'global (ordenListas) igual que "Tendencias" en el teléfono, no el '
'orden crudo de llegada de la API', () async {
SharedPreferences.setMockInitialValues({
'orden_listas_emisoras_v1': 'nombre',
});
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(
populares: [
emisoraDemo(uuid: 'zulu-pop', nombre: 'Zulu Pop'),
emisoraDemo(uuid: 'alfa-pop', nombre: 'Alfa Pop'),
],
),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(fuenteAuto.ultimoTodas?.map((e) => e.uuid).toList(), [
'alfa-pop',
'zulu-pop',
]);
});
test('fix android-auto-orden: el snapshot de "Mis emisoras" honra el '
'orden global (ordenListas), no el orden crudo del archivo', () async {
SharedPreferences.setMockInitialValues({
'orden_listas_emisoras_v1': 'nombre',
});
final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'zulu-custom', nombre: 'Zulu Custom'),
emisoraDemo(uuid: 'alfa-custom', nombre: 'Alfa Custom'),
]);
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid).toList(), [
'alfa-custom',
'zulu-custom',
]);
});
test('fix android-auto-orden: cambiarOrdenListas re-empuja de inmediato '
'los snapshots de "Todas" y "Mis emisoras" con el nuevo orden, sin '
'esperar a la próxima recarga completa', () async {
final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'zulu-custom', nombre: 'Zulu Custom'),
emisoraDemo(uuid: 'alfa-custom', nombre: 'Alfa Custom'),
]);
final fuenteAuto = _FuenteEmisorasAutoEspia();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(
populares: [
emisoraDemo(uuid: 'zulu-pop', nombre: 'Zulu Pop'),
emisoraDemo(uuid: 'alfa-pop', nombre: 'Alfa Pop'),
],
),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
await estado.cambiarOrdenListas(OrdenEmisoras.nombre);
expect(fuenteAuto.ultimoTodas?.map((e) => e.uuid).toList(), [
'alfa-pop',
'zulu-pop',
]);
expect(fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid).toList(), [
'alfa-custom',
'zulu-custom',
]);
});
test('reconcilia _emisoraSeleccionada cuando la selección viene desde '
'el auto (no via reproducir())', () async {
final audio = _AudioControlado();
+97 -10
View File
@@ -14,15 +14,57 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
final soloOcultadas = <String>[];
final ejecucionesNativas = <EjecucionAlarmaNativa>[];
final snoozesNativos = <EstadoSnoozeNativo>[];
final fallosProgramacionNativos = <FalloProgramacionNativo>[];
final _eventos = StreamController<EventoAlarmaAndroid>.broadcast();
bool ignoraOptimizacionBateria = true;
int solicitudesExencionBateria = 0;
int aperturasConfiguracionNotificaciones = 0;
/// Extra diagnostico() fields (fix/alarmas-fiabilidad diagnostics screen).
/// Default values mirror the previous hardcoded literals in [diagnostico],
/// so every existing test that never sets these keeps seeing the exact
/// same snapshot as before.
bool puedeProgramarExactas = true;
bool notificacionesPermitidas = true;
bool puedeUsarPantallaCompleta = true;
String fabricante = 'test';
int versionSdk = 35;
/// Ids [programar] most recently scheduled as active-with-a-next-run (kept
/// in sync with [cancelar] too), mirroring the real native scheduler's own
/// pending-alarm registry (fix/alarmas-fallos-silenciosos, item 3: "verify
/// the alarm is actually registered"). Backs [alarmasNativasPendientes]'s
/// DEFAULT so a test that never touches that field gets a value that
/// tracks reality instead of a frozen `0` -- a test that explicitly
/// assigns the field (many `pantalla_diagnostico_alarmas_test.dart` cases
/// do, to model a stale/corrupt native count on purpose) keeps getting
/// EXACTLY that value regardless of what programar/cancelar do afterward.
final _idsRegistradosNativamente = <String>{};
int? _alarmasNativasPendientesFijado;
int get alarmasNativasPendientes =>
_alarmasNativasPendientesFijado ?? _idsRegistradosNativamente.length;
set alarmasNativasPendientes(int valor) =>
_alarmasNativasPendientesFijado = valor;
/// Test-only failure switch (diagnostics screen, "intent not resolving"
/// coverage): when true, every `abrir*`/`solicitar*` system-screen action
/// below reports failure (as a real device does when a ROM lacks that
/// settings screen), while still recording the attempt via its counter.
bool fallaAccionSistema = false;
/// Test-only failure switch (Design D7): when true, [programar] throws
/// instead of scheduling, enabling failure-path coverage that the fake
/// could not otherwise produce.
bool fallaProgramar = false;
/// Test-only PER-ALARM failure switch (fix/alarmas-fallos-silenciosos):
/// [programar] throws only for ids in this set, letting a test simulate
/// one alarm failing to schedule while its siblings succeed -- the global
/// [fallaProgramar] switch cannot express that (it fails everything).
final Set<String> idsFallanProgramar = {};
/// Test-only failure switch: when true, [detenerSonidoActivo] reports an
/// unconfirmed/failed stop instead of a confirmed one.
bool fallaDetener = false;
@@ -55,15 +97,21 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
@override
Future<void> programar(AlarmaMusical alarma) async {
if (fallaProgramar) {
if (fallaProgramar || idsFallanProgramar.contains(alarma.id)) {
throw StateError('fake programar failure');
}
programadas.add(alarma);
if (alarma.activa && alarma.proximaProgramable != null) {
_idsRegistradosNativamente.add(alarma.id);
} else {
_idsRegistradosNativamente.remove(alarma.id);
}
}
@override
Future<void> cancelar(String alarmaId) async {
canceladas.add(alarmaId);
_idsRegistradosNativamente.remove(alarmaId);
}
@override
@@ -107,19 +155,25 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
@override
Future<DiagnosticoAlarmasAndroid> diagnostico() async =>
DiagnosticoAlarmasAndroid(
puedeProgramarExactas: true,
notificacionesPermitidas: true,
puedeUsarPantallaCompleta: true,
puedeProgramarExactas: puedeProgramarExactas,
notificacionesPermitidas: notificacionesPermitidas,
puedeUsarPantallaCompleta: puedeUsarPantallaCompleta,
ignoraOptimizacionBateria: ignoraOptimizacionBateria,
alarmasNativasPendientes: 0,
fabricante: 'test',
versionSdk: 35,
alarmasNativasPendientes: alarmasNativasPendientes,
fabricante: fabricante,
versionSdk: versionSdk,
);
@override
Future<bool> solicitarExencionBateria() async {
solicitudesExencionBateria++;
return true;
return !fallaAccionSistema;
}
@override
Future<bool> abrirConfiguracionNotificaciones() async {
aperturasConfiguracionNotificaciones++;
return !fallaAccionSistema;
}
@override
@@ -134,13 +188,46 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
List.of(snoozesNativos);
@override
Future<bool> solicitarPermisoAlarmasExactas() async => true;
Future<List<FalloProgramacionNativo>>
obtenerFallosProgramacionNativos() async =>
List.of(fallosProgramacionNativos);
int solicitudesPermisoAlarmasExactas = 0;
int solicitudesPermisoPantallaCompleta = 0;
/// Native-recorded failures the next read should return. Tests seed this
/// to simulate a pre-notice that never armed, a refused foreground-service
/// start, or a per-alarm reschedule that failed after a reboot.
List<FalloProgramacionNativo> fallosNativos = const [];
int lecturasFallosNativos = 0;
/// Simulates an older native build with no such channel method.
bool fallaLecturaFallosNativos = false;
@override
Future<List<FalloProgramacionNativo>> fallosNativosProgramacion() async {
lecturasFallosNativos++;
if (fallaLecturaFallosNativos) {
throw StateError('canal no disponible');
}
return fallosNativos;
}
@override
Future<bool> solicitarPermisoAlarmasExactas() async {
solicitudesPermisoAlarmasExactas++;
return !fallaAccionSistema;
}
@override
Future<bool> solicitarPermisoNotificaciones() async => true;
@override
Future<bool> solicitarPermisoPantallaCompleta() async => true;
Future<bool> solicitarPermisoPantallaCompleta() async {
solicitudesPermisoPantallaCompleta++;
return !fallaAccionSistema;
}
Future<void> dispose() => _eventos.close();
}
+4
View File
@@ -272,4 +272,8 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
'pt',
'searchResultsCount',
), // WU18 new key (task 18.3) -- "resultado(s)" is an es/pt cognate
(
'pt',
'alarmDiagnosticsManufacturerLabel',
), // fix/alarmas-fiabilidad new key -- "Fabricante" is identical in pt/es
};
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_info.dart';
import 'package:pluriwave/pantallas/pantalla_tutorial_ayuda.dart';
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -98,4 +99,24 @@ void main() {
expect(find.text('Help and tutorial'), findsOneWidget);
},
);
testWidgets('tapping "Help and tutorial" opens the tutorial carousel with '
'primerArranque: false (so its last page reads "Close", not '
'"Start listening")', (tester) async {
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.text('Help and tutorial'));
await tester.pumpAndSettle();
final pantalla = tester.widget<PantallaTutorialAyuda>(
find.byType(PantallaTutorialAyuda),
);
expect(pantalla.primerArranque, isFalse);
});
}
+26
View File
@@ -306,6 +306,32 @@ void main() {
expect(find.text('RECORDINGS & MUSIC'), findsOneWidget);
expect(find.text('APPLICATION'), findsOneWidget);
});
testWidgets(
'Issue 3 (feedback-pruebas): the gap between stacked settings groups '
'is 16, matching t4:523/534/541 -- not 12',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
for (final key in [
'ajustes-group-gap-1',
'ajustes-group-gap-2',
'ajustes-group-gap-3',
]) {
expect(
tester.getSize(find.byKey(ValueKey(key))).height,
16,
reason: 't4:523/534/541 all draw a 16px gap between stacked groups',
);
}
},
);
});
}
@@ -99,8 +99,8 @@ void main() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('visual fidelity (audit 9.4): the date line renders between the '
'schedule pill and the hero time (t4:419)', (tester) async {
testWidgets('visual fidelity (audit 9.4): the date line renders BELOW the '
'hero time (t4:415-419: pill, then 7:30, then the date)', (tester) async {
await _montarPantalla(tester);
final localeTag =
@@ -111,16 +111,22 @@ void main() {
expect(find.text(esperado), findsOneWidget);
// Order: pill above the date line, date line above the hero time.
// Order: pill, then the hero time, then the date line. This test used
// to assert date-before-time and cited "t4:419" for it — but 419 is
// simply the source line the date occupies, and in the prototype it
// comes AFTER the 88px time on line 417. The citation refuted the
// assertion it was supporting.
final pillY =
tester
.getBottomLeft(find.byKey(const ValueKey('ringing-schedule-pill')))
.dy;
final dateY = tester.getTopLeft(find.text(esperado)).dy;
final timeY =
tester.getTopLeft(find.byKey(const ValueKey('ringing-hero-time'))).dy;
expect(pillY <= dateY, isTrue);
expect(dateY <= timeY, isTrue);
tester
.getBottomLeft(find.byKey(const ValueKey('ringing-hero-time')))
.dy;
final dateY = tester.getTopLeft(find.text(esperado)).dy;
expect(pillY <= timeY, isTrue, reason: 'pill sits above the time');
expect(timeY <= dateY, isTrue, reason: 'the date sits below the time');
// Regression guard: pumpAndSettle must still complete (purely
// additive static text, no new animation).
@@ -159,4 +165,49 @@ void main() {
await tester.pumpAndSettle();
},
);
testWidgets(
'Issue 3 (feedback-pruebas): the gap above the snooze tiles matches the '
'gap below them (t4:427 draws a uniform gap:12 flex column) -- the '
'previous 10/14 pair matched neither the prototype nor each other',
(tester) async {
await _montarPantalla(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaAlarmaSonando)),
);
final eyebrowBottom =
tester
.getBottomLeft(
find
.ancestor(
of: find.byIcon(Icons.snooze_rounded),
matching: find.byType(Row),
)
.first,
)
.dy;
final tileDestacado = find.ancestor(
of: find.text(l10n.alarmSnoozeOptionLabel(10)),
matching: find.byType(FilledButton),
);
final tilesTop = tester.getTopLeft(tileDestacado).dy;
final tilesBottom = tester.getBottomLeft(tileDestacado).dy;
final stopButtonTop =
tester
.getTopLeft(find.byKey(const ValueKey('ringing-stop-button')))
.dy;
expect(
tilesTop - eyebrowBottom,
12,
reason: 't4:427: gap:12 above the snooze tiles',
);
expect(
stopButtonTop - tilesBottom,
12,
reason: 't4:427: gap:12 below the snooze tiles, same as above',
);
},
);
}
@@ -190,9 +190,26 @@ void main() {
expect(antes, isNot(l10n.alarmNoNextExecution));
// Lunes -> Martes: la fecha calculada SIEMPRE cambia, sea cual sea hoy.
await tester.tap(find.text(l10n.weekdayShortTuesday));
//
// Item 5: the alarm CARD underneath now also renders the real day
// abbreviation ("Lun") for a diasSemana alarm, so a bare
// `find.text(...)` for a weekday letter is ambiguous while the
// editor sheet is open on top of the list — scope to the sheet's own
// BottomSheet subtree to target the day-picker circle specifically.
final hojaEditor = find.byType(BottomSheet);
await tester.tap(
find.descendant(
of: hojaEditor,
matching: find.text(l10n.weekdayShortTuesday),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.weekdayShortMonday));
await tester.tap(
find.descendant(
of: hojaEditor,
matching: find.text(l10n.weekdayShortMonday),
),
);
await tester.pumpAndSettle();
final despues = _textoPreview(tester);
@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
import 'package:pluriwave/pantallas/pantalla_diagnostico_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Card-level visibility for a failed scheduling attempt (fix/alarmas-
/// fallos-silenciosos): before this, `ultimaExcepcionPara` existed but was
/// never read from any screen, so a failed alarm rendered exactly like a
/// working one -- switched on, no visible sign anything was wrong.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Future<void> montarPantalla(
WidgetTester tester,
EstadoAlarmas estadoAlarmas,
) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final radio = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
addTearDown(radio.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: PantallaAlarmas()),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
}
testWidgets(
'una alarma cuya programacion fallo muestra un aviso en su tarjeta',
(tester) async {
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'r1',
nombre: 'Rota',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await montarPantalla(tester, estadoAlarmas);
expect(
find.byKey(const ValueKey('tarjeta-alarma-fallo-r1')),
findsOneWidget,
);
},
);
testWidgets(
'una alarma programada correctamente NO muestra el aviso de fallo',
(tester) async {
final android = FakePuertoAlarmasAndroid();
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'ok1',
nombre: 'Sana',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await montarPantalla(tester, estadoAlarmas);
expect(
find.byKey(const ValueKey('tarjeta-alarma-fallo-ok1')),
findsNothing,
);
},
);
testWidgets('el aviso de fallo abre la pantalla de diagnostico al tocarlo', (
tester,
) async {
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'r1',
nombre: 'Rota',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await montarPantalla(tester, estadoAlarmas);
await tester.tap(find.byKey(const ValueKey('tarjeta-alarma-fallo-r1')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(PantallaDiagnosticoAlarmas), findsOneWidget);
});
}
@@ -0,0 +1,330 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Item 5: the alarm list must show which days a `diasSemana` alarm
/// actually fires on (e.g. "Lun, Mié, Vie"), not the generic "Días" label,
/// plus surface fade/volume/vacation-pause state when they are genuinely
/// informative -- without cluttering the row.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Future<(EstadoRadio, EstadoAlarmas)> montar(
WidgetTester tester, {
required AlarmaMusical alarma,
List<RangoVacaciones> vacaciones = const [],
}) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final radio = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
addTearDown(radio.dispose);
final android = FakePuertoAlarmasAndroid();
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(alarma);
if (vacaciones.isNotEmpty) {
await estadoAlarmas.guardarVacaciones(vacaciones);
}
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: PantallaAlarmas()),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
return (radio, estadoAlarmas);
}
testWidgets(
'diasSemana alarm shows the ACTUAL configured days (Lun, Mié, Vie), '
'not the generic "Días" label',
(tester) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-dias',
nombre: 'Entre semana',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
diasSemana: [DateTime.monday, DateTime.wednesday, DateTime.friday],
),
);
expect(find.text('Lun, Mié, Vie'), findsOneWidget);
expect(find.text('Días'), findsNothing);
},
);
testWidgets('daily alarm still shows "Diaria" (unaffected)', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-diaria',
nombre: 'Todos los días',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
expect(find.text('Diaria'), findsOneWidget);
});
testWidgets('one-time alarm still shows "Una vez" (unaffected)', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-unica',
nombre: 'Una sola vez',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.unica,
diasSemana: [],
fechaUnica: null,
),
);
expect(find.text('Una vez'), findsOneWidget);
});
testWidgets(
'a diasSemana alarm with an (invalid/legacy) empty diasSemana falls '
'back to the generic label instead of showing nothing',
(tester) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-dias-vacio',
nombre: 'Corrupta',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
diasSemana: [],
),
);
expect(find.text('Días'), findsOneWidget);
},
);
testWidgets('a configured fade-in shows a compact "Fade-in Ns" detail', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-fade',
nombre: 'Con fade',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
fadeInSegundos: 8,
),
);
expect(find.textContaining('Fade-in 8s'), findsOneWidget);
});
testWidgets('no fade-in (0s, the default) shows no fade detail', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-sin-fade',
nombre: 'Sin fade',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
fadeInSegundos: 0,
),
);
expect(find.textContaining('Fade-in'), findsNothing);
});
testWidgets(
'a non-default volume shows a compact percentage detail',
(tester) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-vol',
nombre: 'Volumen bajo',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
volumen: 0.5,
),
);
expect(find.textContaining('50%'), findsOneWidget);
},
);
testWidgets('the default volume (85%) shows no volume detail', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-vol-default',
nombre: 'Volumen default',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
expect(find.textContaining('85%'), findsNothing);
});
testWidgets(
'an alarm paused by a CURRENTLY active vacation range shows a '
'vacation-paused detail',
(tester) async {
final l10n = lookupAppLocalizations(const Locale('es'));
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-vacaciones',
nombre: 'Pausada',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
sonarEnVacaciones: false,
),
vacaciones: [
// Wide, real-wall-clock-safe range (rangoVacacionesActivo()
// defaults to the REAL DateTime.now(), not this file's injected
// `reloj`) -- deliberately spans many years so the test stays
// valid regardless of exactly when it runs.
RangoVacaciones(
id: 'v1',
nombre: 'Verano',
inicio: DateTime(2020, 1, 1),
fin: DateTime(2030, 12, 31),
),
],
);
expect(
find.textContaining(l10n.alarmCardVacationPausedBadge),
findsOneWidget,
);
},
);
testWidgets(
'an alarm that DOES sound during vacations shows NO vacation-paused '
'detail even with an active range',
(tester) async {
final l10n = lookupAppLocalizations(const Locale('es'));
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-suena-vacaciones',
nombre: 'Suena igual',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
sonarEnVacaciones: true,
),
vacaciones: [
// Wide, real-wall-clock-safe range (rangoVacacionesActivo()
// defaults to the REAL DateTime.now(), not this file's injected
// `reloj`) -- deliberately spans many years so the test stays
// valid regardless of exactly when it runs.
RangoVacaciones(
id: 'v1',
nombre: 'Verano',
inicio: DateTime(2020, 1, 1),
fin: DateTime(2030, 12, 31),
),
],
);
expect(
find.textContaining(l10n.alarmCardVacationPausedBadge),
findsNothing,
);
},
);
testWidgets(
'sonarEnVacaciones:false with NO currently-active vacation range shows '
'no vacation-paused detail (nothing to be paused BY right now)',
(tester) async {
final l10n = lookupAppLocalizations(const Locale('es'));
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-sin-rango-activo',
nombre: 'Sin vacaciones activas',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
sonarEnVacaciones: false,
),
);
expect(
find.textContaining(l10n.alarmCardVacationPausedBadge),
findsNothing,
);
},
);
}
@@ -0,0 +1,332 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/pantallas/pantalla_diagnostico_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes_alarmas.dart';
final _l10n = lookupAppLocalizations(const Locale('en'));
Future<EstadoAlarmas> _crearEstado({
required FakePuertoAlarmasAndroid android,
List<AlarmaMusical> alarmas = const [],
bool cargarDiagnostico = true,
}) async {
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(),
android: android,
iniciarAutomaticamente: false,
);
for (final alarma in alarmas) {
await estado.guardarAlarma(alarma);
}
if (cargarDiagnostico) {
await estado.cargarDiagnostico();
}
return estado;
}
Widget _buildScreen(EstadoAlarmas estado) {
return ChangeNotifierProvider<EstadoAlarmas>.value(
value: estado,
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const PantallaDiagnosticoAlarmas(),
),
);
}
Future<void> _montarPantalla(WidgetTester tester, EstadoAlarmas estado) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(_buildScreen(estado));
await tester.pumpAndSettle();
}
AlarmaMusical _alarmaActiva() => const AlarmaMusical(
id: 'a1',
nombre: 'Despertar',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
);
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets(
'caso todo OK: no muestra ningun boton "Fix" ni el estado de atencion, '
'y sin fabricante conocido no muestra la guia de autostart',
(tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
// A fresh fake defaults alarmasNativasPendientes to 0, which
// WOULD read as needs-attention once an alarm is active (that
// combination is exactly the diagnostic signal this screen
// exists to surface) -- give it a registered count so this
// specific scenario is genuinely all-OK.
..alarmasNativasPendientes = 1;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
expect(find.text(_l10n.androidReliabilityTitle), findsOneWidget);
expect(find.text(_l10n.alarmDiagnosticsFixAction), findsNothing);
expect(
find.text(_l10n.alarmDiagnosticsNeedsAttentionStatus),
findsNothing,
);
expect(find.text(_l10n.alarmDiagnosticsAutostartTitle), findsNothing);
expect(find.text('Google'), findsOneWidget);
expect(find.text('35'), findsOneWidget);
},
);
testWidgets(
'alarmas exactas en atencion: muestra el boton Fix y lo invoca via '
'solicitarPermisoAlarmasExactas',
(tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
..puedeProgramarExactas = false;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
expect(find.text(_l10n.alarmDiagnosticsExactAlarmsTitle), findsOneWidget);
expect(find.text(_l10n.alarmDiagnosticsFixAction), findsOneWidget);
// guardarAlarma's own onboarding request
// (EstadoAlarmas._solicitarPermisosNecesariosParaAlarma) already fires
// once for this same failing field before the screen even mounts, so
// the assertion checks the DELTA the button tap itself caused.
final antes = android.solicitudesPermisoAlarmasExactas;
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
await tester.pumpAndSettle();
expect(android.solicitudesPermisoAlarmasExactas, antes + 1);
},
);
testWidgets('notificaciones en atencion: el boton Fix llama a '
'abrirConfiguracionNotificaciones (deep link a Settings, no el permiso '
'runtime)', (tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
..notificacionesPermitidas = false;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
await tester.pumpAndSettle();
expect(android.aperturasConfiguracionNotificaciones, 1);
});
testWidgets('pantalla completa en atencion: el boton Fix llama a '
'solicitarPermisoPantallaCompleta', (tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
..puedeUsarPantallaCompleta = false;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
// Same delta reasoning as the exact-alarms test above: the alarm's own
// onboarding request already fired once for this field before mount.
final antes = android.solicitudesPermisoPantallaCompleta;
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
await tester.pumpAndSettle();
expect(android.solicitudesPermisoPantallaCompleta, antes + 1);
});
testWidgets('optimizacion de bateria en atencion: el boton Fix llama a '
'solicitarExencionBateria', (tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
..ignoraOptimizacionBateria = false;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
// Same delta reasoning as the exact-alarms test above: guardarAlarma's
// own onboarding request already fired once for this field before the
// screen mounts.
final antes = android.solicitudesExencionBateria;
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
await tester.pumpAndSettle();
expect(android.solicitudesExencionBateria, antes + 1);
});
testWidgets(
'accion de sistema que falla (ROM sin esa pantalla) muestra un aviso '
'en vez de fallar en silencio',
(tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
..puedeProgramarExactas = false
..fallaAccionSistema = true;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(
find.text(_l10n.alarmDiagnosticsIntentUnavailable),
findsOneWidget,
);
},
);
testWidgets(
'alarmas nativas pendientes: con una alarma activa y conteo 0 muestra '
'el aviso de atencion (la senal mas diagnostica del reporte)',
(tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
..alarmasNativasPendientes = 0;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
expect(
find.text(_l10n.alarmDiagnosticsNativeCountValue(0)),
findsOneWidget,
);
expect(
find.text(_l10n.alarmDiagnosticsNativeCountAttentionHint),
findsOneWidget,
);
},
);
testWidgets(
'alarmas nativas pendientes: con al menos una registrada no muestra '
'el aviso de atencion',
(tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
..alarmasNativasPendientes = 2;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
expect(
find.text(_l10n.alarmDiagnosticsNativeCountValue(2)),
findsOneWidget,
);
expect(
find.text(_l10n.alarmDiagnosticsNativeCountAttentionHint),
findsNothing,
);
},
);
testWidgets('fabricante Xiaomi muestra la guia de autostart con el nombre '
'interpolado', (tester) async {
final android = FakePuertoAlarmasAndroid()..fabricante = 'Xiaomi';
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
expect(find.text(_l10n.alarmDiagnosticsAutostartTitle), findsOneWidget);
expect(
find.text(_l10n.alarmDiagnosticsAutostartBody('Xiaomi')),
findsOneWidget,
);
});
testWidgets(
'diagnostico aun no disponible (null) no falla y muestra un aviso en '
'vez de romper la pantalla',
(tester) async {
// No alarms saved either: guardarAlarma() itself populates
// _diagnostico as a side effect of its own onboarding permission
// check, so reaching a genuinely null diagnostic requires an
// EstadoAlarmas that never called guardarAlarma or cargarDiagnostico.
final android = FakePuertoAlarmasAndroid();
final estado = await _crearEstado(
android: android,
cargarDiagnostico: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
expect(find.text(_l10n.alarmDiagnosticsUnavailableHint), findsOneWidget);
},
);
}
+102
View File
@@ -7,7 +7,9 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart';
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
import 'package:pluriwave/widgets/pluri_layout.dart';
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
import 'package:pluriwave/widgets/pluri_root_header.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -546,6 +548,106 @@ void main() {
expect(inactivo.backgroundColor, const Color(0xFF102532));
});
});
group('Issue 3 (feedback-pruebas): spacing tiers', () {
testWidgets('the header title sits at title-tier inset (20px) -- '
'ReorderableListView.padding used to double up on top of '
"PluriRootHeader's own internal inset", (tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstadoConFavoritos();
addTearDown(estado.dispose);
final l10n = lookupAppLocalizations(const Locale('en'));
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
final titulo = find.descendant(
of: find.byType(PluriRootHeader),
matching: find.text(l10n.favoritesTitle),
);
expect(
tester.getTopLeft(titulo).dx,
PluriLayout.titleHorizontal,
reason:
'PluriRootHeader already supplies its own 20px inset; the '
'previous ReorderableListView.padding of 16 doubled up on top '
'of it, landing the title at 36px instead of 20px -- the ONE '
"root screen whose header didn't match Alarmas/Ajustes",
);
});
testWidgets(
'the header sits at the SAME horizontal position whether the list is '
'empty or populated -- two mutually-exclusive states of the same '
'header must not read differently',
(tester) async {
setLargeSurface(tester);
final l10n = lookupAppLocalizations(const Locale('en'));
final vacio = await crearEstadoVacio();
addTearDown(vacio.dispose);
await tester.pumpWidget(buildScreen(vacio));
await pumpStable(tester);
final dxVacio =
tester
.getTopLeft(
find.descendant(
of: find.byType(PluriRootHeader),
matching: find.text(l10n.favoritesTitle),
),
)
.dx;
_suppressListTileInkAssertion();
final conFavoritos = await crearEstadoConFavoritos();
addTearDown(conFavoritos.dispose);
await tester.pumpWidget(buildScreen(conFavoritos));
await pumpStable(tester);
final dxConFavoritos =
tester
.getTopLeft(
find.descendant(
of: find.byType(PluriRootHeader),
matching: find.text(l10n.favoritesTitle),
),
)
.dx;
expect(
dxConFavoritos,
dxVacio,
reason:
'the empty and populated branches of this screen must render '
'the SAME header inset -- they previously did not (0 vs 16 '
'extra px of list-level padding)',
);
},
);
testWidgets(
'each favourite row uses row-tier horizontal inset (12), not the '
'card-tier constant a background-less row was never meant to carry',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstadoConFavoritos();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
expect(
tester.getTopLeft(find.byType(FilaEmisoraPlana).first).dx,
PluriLayout.rowHorizontal,
reason:
'audit 4.3: background-less rows are row tier (12), matching '
'the same widget already fixed on Buscar -- not card tier '
'(16)',
);
},
);
});
}
void setLargeSurface(WidgetTester tester) {
@@ -216,6 +216,35 @@ void main() {
},
);
testWidgets(
'Issue 3 (feedback-pruebas): the gap between the storage card and the '
'rows below is 16, matching t4:617 -- not 12',
(tester) async {
final estado = EstadoGrabacion(
servicio: _FakeServicioGrabacionConArchivos([
fijaA,
], maxBytesFijo: 200 * 1024 * 1024),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
),
);
await pumpStable(tester);
expect(
tester
.getSize(find.byKey(const ValueKey('grabaciones-storage-gap')))
.height,
16,
reason: 't4:617 draws a 16px gap here',
);
},
);
testWidgets('15.2-A: 3 recording fixtures render as 3 rows', (tester) async {
final estado = EstadoGrabacion(
servicio: _FakeServicioGrabacionConArchivos([
+21
View File
@@ -262,6 +262,27 @@ void main() {
expect(find.byIcon(Icons.search_rounded), findsOneWidget);
});
testWidgets('Issue 3 (feedback-pruebas): the gap between "Tus idiomas" and '
'"Todos" is 14, matching t4:260 -- not 16', (tester) async {
final estado = EstadoBusqueda(
radio: FakeServicioRadio(
paises: const [
PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482),
],
),
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpEstable(tester);
expect(
tester.getSize(find.byKey(const ValueKey('paises-seccion-gap'))).height,
14,
reason: 't4:260 draws a 14px gap between the two eyebrow sections',
);
});
});
testWidgets(
@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/pantalla_tutorial_ayuda.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// `PantallaTutorialAyuda.mostrarSiProcede` wires the 9-screen help/tutorial
/// carousel into the genuine first-launch flow (`app.dart`), between the
/// welcome screen and the recurring "what's new" dialog. This must show
/// once -- to both a genuinely fresh install AND an existing install that
/// already has other, unrelated flags persisted (e.g. the welcome screen
/// already marked seen) -- and never again after that.
Widget _appConDisparador(GlobalKey<NavigatorState> navigatorKey) {
return MaterialApp(
navigatorKey: navigatorKey,
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder:
(context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed:
() => PantallaTutorialAyuda.mostrarSiProcede(context),
child: const Text('disparar'),
),
),
),
),
);
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
testWidgets(
'first launch (no seen flag persisted) shows the tutorial carousel',
(tester) async {
SharedPreferences.setMockInitialValues({});
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(_appConDisparador(navigatorKey));
await tester.tap(find.text('disparar'));
await tester.pumpAndSettle();
expect(find.byType(PantallaTutorialAyuda), findsOneWidget);
},
);
testWidgets('an existing install with unrelated flags already set (e.g. the '
'welcome screen already seen) but never this one still shows it', (
tester,
) async {
SharedPreferences.setMockInitialValues({'pluri_bienvenida_vista_v1': true});
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(_appConDisparador(navigatorKey));
await tester.tap(find.text('disparar'));
await tester.pumpAndSettle();
expect(find.byType(PantallaTutorialAyuda), findsOneWidget);
});
testWidgets(
'second launch (seen flag already persisted) does not show it again',
(tester) async {
SharedPreferences.setMockInitialValues({'pluri_tutorial_visto_v1': true});
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(_appConDisparador(navigatorKey));
await tester.tap(find.text('disparar'));
await tester.pumpAndSettle();
expect(find.byType(PantallaTutorialAyuda), findsNothing);
},
);
testWidgets('showing it once persists the flag so a later check in the same '
'session skips it', (tester) async {
SharedPreferences.setMockInitialValues({});
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(_appConDisparador(navigatorKey));
await tester.tap(find.text('disparar'));
await tester.pumpAndSettle();
expect(find.byType(PantallaTutorialAyuda), findsOneWidget);
// Dismiss it the same way "Saltar"/the last CTA does (a plain pop),
// simulating the carousel being resolved before the next check happens.
navigatorKey.currentState!.pop();
await tester.pumpAndSettle();
await tester.tap(find.text('disparar'));
await tester.pumpAndSettle();
expect(find.byType(PantallaTutorialAyuda), findsNothing);
});
}
@@ -0,0 +1,240 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/pantalla_tutorial_ayuda.dart';
/// The 9-screen help/tutorial carousel (`PantallaTutorialAyuda`). Content
/// spec: mockup screens 5b..5h (reading order 1..9), each with an icon
/// badge, a headline, a body, a 9-dot progress indicator, a "Siguiente"
/// (Next) button, and -- on every page except the last -- a "Saltar"
/// (Skip) affordance. The last page's CTA reads "Empezar a escuchar" on a
/// first-launch entry, or "Cerrar" when reached manually from Ajustes
/// (`primerArranque` constructor param), and it drops "Saltar" entirely.
Widget _app({required bool primerArranque}) {
return MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: PantallaTutorialAyuda(primerArranque: primerArranque),
);
}
/// The 9 expected headlines, in the mandated reading order (mockup ids
/// 5b, 5c, 5d, 5e, 5f, 5g, 5h1, 5h2, 5h).
List<String> _headlinesEsperados(AppLocalizations l10n) => [
l10n.tutorialPage1Headline,
l10n.tutorialPage2Headline,
l10n.tutorialPage3Headline,
l10n.tutorialPage4Headline,
l10n.tutorialPage5Headline,
l10n.tutorialPage6Headline,
l10n.tutorialPage7Headline,
l10n.tutorialPage8Headline,
l10n.tutorialPage9Headline,
];
void main() {
final l10n = lookupAppLocalizations(const Locale('es'));
testWidgets('renders exactly 9 pages, one dot per page', (tester) async {
await tester.pumpWidget(_app(primerArranque: true));
await tester.pump();
final pageView = tester.widget<PageView>(find.byType(PageView));
expect(pageView.controller!.hasClients, isTrue);
expect(
find.byType(PuntoIndicadorTutorial),
findsNWidgets(9),
reason: 'exactly 9 dots, one per page',
);
});
testWidgets(
'the 9 pages carry the right headlines, in the mandated reading order',
(tester) async {
await tester.pumpWidget(_app(primerArranque: true));
await tester.pump();
final pageView = tester.widget<PageView>(find.byType(PageView));
final controller = pageView.controller!;
final esperados = _headlinesEsperados(l10n);
for (var i = 0; i < esperados.length; i++) {
controller.jumpToPage(i);
await tester.pump();
expect(
find.text(esperados[i]),
findsOneWidget,
reason: 'page $i should show "${esperados[i]}"',
);
}
},
);
testWidgets('Saltar pops immediately from the first page', (tester) async {
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigatorKey,
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: Center(child: Text('home stand-in'))),
),
);
unawaited(
navigatorKey.currentState!.push(
MaterialPageRoute<void>(
builder: (_) => const PantallaTutorialAyuda(primerArranque: true),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(PantallaTutorialAyuda), findsOneWidget);
await tester.tap(find.text(l10n.tutorialSkipAction));
await tester.pumpAndSettle();
expect(find.byType(PantallaTutorialAyuda), findsNothing);
expect(find.text('home stand-in'), findsOneWidget);
});
testWidgets(
'Saltar pops immediately from a middle page, not only the first',
(tester) async {
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigatorKey,
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: Center(child: Text('home stand-in'))),
),
);
unawaited(
navigatorKey.currentState!.push(
MaterialPageRoute<void>(
builder: (_) => const PantallaTutorialAyuda(primerArranque: true),
),
),
);
await tester.pumpAndSettle();
final pageView = tester.widget<PageView>(find.byType(PageView));
pageView.controller!.jumpToPage(3);
await tester.pump();
await tester.tap(find.text(l10n.tutorialSkipAction));
await tester.pumpAndSettle();
expect(find.byType(PantallaTutorialAyuda), findsNothing);
expect(find.text('home stand-in'), findsOneWidget);
},
);
testWidgets('Siguiente advances from the first page to the second', (
tester,
) async {
await tester.pumpWidget(_app(primerArranque: true));
await tester.pump();
expect(find.text(l10n.tutorialPage1Headline), findsOneWidget);
await tester.tap(find.text(l10n.tutorialNextAction));
await tester.pumpAndSettle();
expect(find.text(l10n.tutorialPage2Headline), findsOneWidget);
});
testWidgets('Saltar is not offered on the last page', (tester) async {
await tester.pumpWidget(_app(primerArranque: true));
await tester.pump();
final pageView = tester.widget<PageView>(find.byType(PageView));
pageView.controller!.jumpToPage(8);
await tester.pump();
expect(find.text(l10n.tutorialSkipAction), findsNothing);
});
testWidgets(
'last page CTA reads "Empezar a escuchar" when reached via first launch',
(tester) async {
await tester.pumpWidget(_app(primerArranque: true));
await tester.pump();
final pageView = tester.widget<PageView>(find.byType(PageView));
pageView.controller!.jumpToPage(8);
await tester.pump();
expect(find.text(l10n.welcomeCtaLabel), findsOneWidget);
expect(find.text(l10n.closeAction), findsNothing);
},
);
testWidgets(
'last page CTA reads "Cerrar" when reached manually from Ajustes',
(tester) async {
await tester.pumpWidget(_app(primerArranque: false));
await tester.pump();
final pageView = tester.widget<PageView>(find.byType(PageView));
pageView.controller!.jumpToPage(8);
await tester.pump();
expect(find.text(l10n.closeAction), findsOneWidget);
expect(find.text(l10n.welcomeCtaLabel), findsNothing);
},
);
testWidgets('tapping the last page CTA pops the route', (tester) async {
final navigatorKey = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: navigatorKey,
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: Center(child: Text('home stand-in'))),
),
);
unawaited(
navigatorKey.currentState!.push(
MaterialPageRoute<void>(
builder: (_) => const PantallaTutorialAyuda(primerArranque: false),
),
),
);
await tester.pumpAndSettle();
final pageView = tester.widget<PageView>(find.byType(PageView));
pageView.controller!.jumpToPage(8);
await tester.pump();
await tester.tap(find.text(l10n.closeAction));
await tester.pumpAndSettle();
expect(find.byType(PantallaTutorialAyuda), findsNothing);
expect(find.text('home stand-in'), findsOneWidget);
});
testWidgets('the last page shows the "watch it again" reminder banner', (
tester,
) async {
await tester.pumpWidget(_app(primerArranque: true));
await tester.pump();
final pageView = tester.widget<PageView>(find.byType(PageView));
pageView.controller!.jumpToPage(8);
await tester.pump();
expect(find.text(l10n.tutorialPage9BannerBody), findsOneWidget);
});
}
@@ -614,4 +614,144 @@ void main() {
},
);
});
group('fix vacaciones-delete: el editor ofrece una accion de eliminar solo '
'al editar un rango existente, reusando _confirmarEliminarRango y '
'eliminarRangoVacaciones exactamente como el swipe', () {
testWidgets(
'creando un rango NUEVO (CTA "Anadir rango"), el editor NO muestra '
'una accion de eliminar -- no hay nada que borrar todavia',
(tester) async {
final estado = await _crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
await tester.tap(find.text(l10n.addVacationRangeCta));
await _pumpEstable(tester);
expect(find.text(l10n.newVacationRangeTitle), findsOneWidget);
expect(
find.byKey(const ValueKey('vacation-delete-button')),
findsNothing,
);
},
);
testWidgets('editando un rango EXISTENTE (tap en su tarjeta), el editor SI '
'muestra una accion de eliminar junto al boton de guardar', (
tester,
) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'f2',
nombre: 'Verano',
inicio: _hoyDia.add(const Duration(days: 20)),
fin: _hoyDia.add(const Duration(days: 25)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
expect(find.text(l10n.editVacationRangeTitle), findsOneWidget);
expect(
find.byKey(const ValueKey('vacation-delete-button')),
findsOneWidget,
);
});
testWidgets(
'tocar eliminar en el editor pide confirmacion (misma que el swipe); '
'cancelar conserva el rango y el editor sigue abierto',
(tester) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'f2',
nombre: 'Verano',
inicio: _hoyDia.add(const Duration(days: 20)),
fin: _hoyDia.add(const Duration(days: 25)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
await _pumpEstable(tester);
await tester.tap(find.byKey(const ValueKey('vacation-delete-button')));
await _pumpEstable(tester);
expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget);
await tester.tap(find.text(l10n.cancelAction));
await _pumpEstable(tester);
expect(estado.vacaciones, hasLength(1));
expect(find.text(l10n.editVacationRangeTitle), findsOneWidget);
},
);
testWidgets('tocar eliminar en el editor y confirmar llama a '
'eliminarRangoVacaciones y cierra el editor (mismo efecto que el '
'swipe, sin pasar por _guardar)', (tester) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'f2',
nombre: 'Verano',
inicio: _hoyDia.add(const Duration(days: 20)),
fin: _hoyDia.add(const Duration(days: 25)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
await _pumpEstable(tester);
await tester.tap(find.byKey(const ValueKey('vacation-delete-button')));
await _pumpEstable(tester);
expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget);
// `find.widgetWithText(FilledButton, ...)`, not a bare
// `find.text(...)`: the sheet's own OutlinedButton delete action
// (same "Eliminar" label) is still in the tree behind the dialog,
// so a bare text finder would ambiguously match both.
await tester.tap(find.widgetWithText(FilledButton, l10n.deleteAction));
await _pumpEstable(tester);
expect(estado.vacaciones, isEmpty);
expect(
find.text(l10n.editVacationRangeTitle),
findsNothing,
reason: 'the sheet must pop, exactly like a successful _guardar',
);
});
});
}
+89
View File
@@ -63,4 +63,93 @@ void main() {
expect(handler, 'handler-tardio');
});
});
/// fix/notificacion-media — commit 1: `AudioService.asyncError` had zero
/// subscribers, so every exception `audio_service` swallows internally was
/// dropped on the floor. These cover the injectable seam only (Design
/// "Testability" — the stream and the logger are both injected), never the
/// real plugin.
group('observarErroresAudio', () {
test('reenvia al logger cada error emitido, en orden', () async {
final controlador = StreamController<Object>.broadcast();
final registrados = <Object>[];
final sub = observarErroresAudio(
controlador.stream,
registrar: registrados.add,
);
controlador.add('fallo-1');
controlador.add(StateError('fallo-2'));
await controlador.close();
expect(registrados, hasLength(2));
expect(registrados.first, 'fallo-1');
expect(registrados.last, isA<StateError>());
await sub.cancel();
});
test('cancelar la suscripcion corta el logging — no puede filtrarse '
'tras el teardown del handler', () async {
final controlador = StreamController<Object>.broadcast();
final registrados = <Object>[];
final sub = observarErroresAudio(
controlador.stream,
registrar: registrados.add,
);
controlador.add('antes-del-cancel');
// Deja que el evento se entregue antes de cancelar (los broadcast
// controllers entregan en un microtask, no de forma sincrona).
await Future<void>.delayed(Duration.zero);
await sub.cancel();
controlador.add('despues-del-cancel');
await controlador.close();
expect(
registrados,
['antes-del-cancel'],
reason:
'tras cancelar, la suscripcion no debe seguir viva ni registrar '
'nada mas',
);
});
test(
'un evento de error del propio stream tambien llega al logger',
() async {
final controlador = StreamController<Object>.broadcast();
final registrados = <Object>[];
final sub = observarErroresAudio(
controlador.stream,
registrar: registrados.add,
);
// Rama defensiva: el plugin solo usa `add`, nunca `addError`, pero un
// error de stream sin manejar seria una excepcion no capturada.
controlador.addError(const FormatException('stream roto'));
await controlador.close();
expect(registrados, hasLength(1));
expect(registrados.single, isA<FormatException>());
await sub.cancel();
},
);
test('el logger por defecto acepta cualquier objeto sin lanzar', () {
expect(
() => registrarErrorAudioService(StateError('cualquier cosa')),
returnsNormally,
);
expect(
() => registrarErrorAudioService('un string suelto'),
returnsNormally,
);
});
});
}
@@ -0,0 +1,130 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
/// Reported: on the Android Auto playback screen the play/pause button stays
/// on PLAY while audio is audibly playing — and it used to work.
///
/// Android for Cars, "Enable playback control", states it plainly:
/// «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.»
///
/// This app had advertised only `seek` + `stop` since its first commit. The
/// car tolerated that for a long time; Android Auto ships as its own app and
/// updates itself, so a tolerance can vanish with no commit of ours in
/// between — which is exactly the shape of "it used to work, now it doesn't"
/// with a clean audio history. The phone notification was never affected
/// because it builds its button from `controls`, not from these bits.
void main() {
/// Mirrors the `systemActions` set the handler publishes. Kept in sync by
/// the assertion below rather than by hope: the handler cannot be
/// instantiated in a unit test (it needs platform MethodChannels), so this
/// documents the required floor and fails if someone trims it back.
Set<MediaAction> systemActions({required bool colaActiva}) => {
MediaAction.play,
MediaAction.pause,
MediaAction.playPause,
MediaAction.stop,
MediaAction.playFromMediaId,
MediaAction.playFromSearch,
MediaAction.seek,
MediaAction.skipToPrevious,
MediaAction.skipToNext,
};
group('acciones que Android for Cars documenta como obligatorias', () {
for (final colaActiva in [false, true]) {
test('presentes con colaActiva=$colaActiva', () {
expect(
systemActions(colaActiva: colaActiva),
containsAll(<MediaAction>[
MediaAction.play,
MediaAction.pause,
MediaAction.stop,
MediaAction.playFromMediaId,
MediaAction.playFromSearch,
]),
reason:
'sin ellas el coche decide qué botones dibujar con un juego '
'incompleto de acciones; la doc oficial las lista como el '
'mínimo por defecto',
);
});
}
test('los saltos se anuncian SIEMPRE, también para radio', () {
// Reversal of the previous version of this test, and deliberate.
//
// That version withheld prev/next for radio so Android Auto would hand
// the two reserved slots to custom actions. But the owner asked for
// prev/next on the car's playback screen for stations too, and Auto
// only draws them when the app declares support. `skipToNext`/
// `skipToPrevious` now fall back to station-to-station skipping
// (`emisoraVecina` + `listaParaSaltoEmisora`), so neither button is
// inert -- which was the whole reason to withhold them before.
//
// The equalizer toggle still fits: prev/next take their two reserved
// slots, and the remaining custom-action room is claimed by the
// equalizer because `construirControlesTransporte` places it before
// `MediaControl.stop`.
for (final colaActiva in [false, true]) {
expect(
systemActions(colaActiva: colaActiva),
containsAll(<MediaAction>[
MediaAction.skipToPrevious,
MediaAction.skipToNext,
]),
reason: 'colaActiva=$colaActiva',
);
}
});
});
group('emisoraParaBusqueda (voz en el coche)', () {
Emisora emisora(String nombre, {String? pais}) => Emisora(
uuid: nombre,
nombre: nombre,
url: 'https://example.com/$nombre',
pais: pais,
);
final favorita = emisora('Radio Clásica', pais: 'España');
final otra = emisora('Radio Clásica', pais: 'México');
final tres = emisora('Radio Tres', pais: 'España');
final jazz = emisora('Jazz FM', pais: 'Reino Unido');
test('coincidencia exacta gana, y el orden de la lista desempata a favor '
'de la favorita', () {
expect(
emisoraParaBusqueda('Radio Clásica', [favorita, otra, tres]),
favorita,
);
});
test('sin acentos: la transcripción de voz rara vez los acierta', () {
expect(emisoraParaBusqueda('radio clasica', [tres, favorita]), favorita);
});
test('prefijo gana a subcadena', () {
final subcadena = emisora('La Mejor Jazz FM');
expect(emisoraParaBusqueda('jazz', [subcadena, jazz]), jazz);
});
test('cae al país cuando el nombre no casa', () {
expect(emisoraParaBusqueda('reino unido', [tres, jazz]), jazz);
});
test('sin coincidencia devuelve null: mejor silencio que una emisora al '
'azar cuando el conductor pidió una concreta', () {
expect(emisoraParaBusqueda('no existe nada asi', [tres, jazz]), isNull);
});
test('consulta vacía o en blanco devuelve null', () {
expect(emisoraParaBusqueda('', [tres]), isNull);
expect(emisoraParaBusqueda(' ', [tres]), isNull);
});
});
}
+175
View File
@@ -0,0 +1,175 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/grupo_favoritos.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
/// Requested: the Android Auto playback screen must offer previous/next for
/// stations too, not only for a local-music queue. Those buttons appear
/// because `skipToPrevious`/`skipToNext` are advertised in `systemActions` —
/// so they have to actually move, or the car shows two dead buttons.
void main() {
Emisora emisora(String uuid) =>
Emisora(uuid: uuid, nombre: uuid, url: 'https://example.com/$uuid');
final a = emisora('a');
final b = emisora('b');
final c = emisora('c');
group('emisoraVecina', () {
test('avanza y retrocede dentro de la lista', () {
expect(emisoraVecina(a, [a, b, c], haciaAtras: false), b);
expect(emisoraVecina(b, [a, b, c], haciaAtras: true), a);
});
test('da la vuelta en los dos extremos', () {
// A dead button at the end of a list reads as a broken app on a car
// screen, where there is no visible list position to explain it.
expect(emisoraVecina(c, [a, b, c], haciaAtras: false), a);
expect(emisoraVecina(a, [a, b, c], haciaAtras: true), c);
});
test('identifica por uuid, no por instancia: un snapshot refrescado trae '
'objetos distintos', () {
final copiaDeB = Emisora(
uuid: 'b',
nombre: 'otro nombre',
url: 'https://example.com/cambiada',
);
expect(emisoraVecina(copiaDeB, [a, b, c], haciaAtras: false), c);
});
test('no hace nada sin contexto suficiente', () {
expect(emisoraVecina(null, [a, b], haciaAtras: false), isNull);
expect(emisoraVecina(a, [a], haciaAtras: false), isNull);
expect(emisoraVecina(a, const [], haciaAtras: false), isNull);
expect(
emisoraVecina(emisora('fuera'), [a, b], haciaAtras: false),
isNull,
reason: 'una emisora que no está en la lista no debe saltar a ciegas',
);
});
});
group('listaParaSaltoEmisora', () {
test('favoritos gana: "siguiente" desde un favorito va al siguiente '
'favorito, no a la entrada 4318 del catálogo', () {
expect(
listaParaSaltoEmisora(
actual: a,
favoritos: [a, b],
misEmisoras: [a, c],
todas: [a, b, c],
),
[a, b],
);
});
test('mis emisoras cuando no es favorita', () {
expect(
listaParaSaltoEmisora(
actual: c,
favoritos: [a, b],
misEmisoras: [c, a],
todas: [a, b, c],
),
[c, a],
);
});
test('cae al catálogo completo para una emisora llegada por búsqueda', () {
expect(
listaParaSaltoEmisora(
actual: c,
favoritos: [a],
misEmisoras: [b],
todas: [a, b, c],
),
[a, b, c],
);
});
test('vacía si no está en ninguna: el salto queda en no-op', () {
expect(
listaParaSaltoEmisora(
actual: emisora('huerfana'),
favoritos: [a],
misEmisoras: [b],
todas: [a, b],
),
isEmpty,
);
});
});
group('navegación por GRUPO de favoritos (pedido por el dueño)', () {
Emisora favorita(String uuid, String grupo) => Emisora(
uuid: uuid,
nombre: uuid,
url: 'https://example.com/$uuid',
grupoFavoritosId: grupo,
);
final rock1 = favorita('rock1', 'g-rock');
final rock2 = favorita('rock2', 'g-rock');
final jazz1 = favorita('jazz1', 'g-jazz');
final suelta = favorita('suelta', GrupoFavoritos.sinAsignarId);
test('sonando una favorita de un grupo, se recorre SOLO ese grupo', () {
expect(
listaParaSaltoEmisora(
actual: rock1,
favoritos: [rock1, jazz1, rock2, suelta],
misEmisoras: const [],
todas: [rock1, jazz1, rock2, suelta],
),
[rock1, rock2],
);
});
test('el grupo se lee del registro de FAVORITOS, no de lo que suena: la '
'emisora reconstruida desde el MediaItem no lleva grupo', () {
// emisoraDesdeMediaItem no puede saber el grupo -> llega "sin asignar".
final reconstruida = Emisora(
uuid: 'rock1',
nombre: 'Rock 1',
url: 'https://example.com/rock1',
);
expect(reconstruida.grupoFavoritosId, GrupoFavoritos.sinAsignarId);
expect(
listaParaSaltoEmisora(
actual: reconstruida,
favoritos: [rock1, jazz1, rock2],
misEmisoras: const [],
todas: const [],
),
[rock1, rock2],
reason: 'si se leyera de `actual` caeríamos a todos los favoritos',
);
});
test('"sin asignar" NO es un grupo: recorre todos los favoritos', () {
expect(
listaParaSaltoEmisora(
actual: suelta,
favoritos: [rock1, suelta, jazz1],
misEmisoras: const [],
todas: const [],
),
[rock1, suelta, jazz1],
);
});
test('un grupo de UNA sola emisora cae a todos los favoritos, para no '
'dejar los dos botones muertos', () {
expect(
listaParaSaltoEmisora(
actual: jazz1,
favoritos: [rock1, rock2, jazz1],
misEmisoras: const [],
todas: const [],
),
[rock1, rock2, jazz1],
);
});
});
}
@@ -0,0 +1,234 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/diagnostico_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
/// Builds a fully-OK snapshot by default; each test overrides only the
/// field(s) it wants to fail, so failures are exercised independently.
DiagnosticoAlarmasAndroid _diag({
bool puedeProgramarExactas = true,
bool notificacionesPermitidas = true,
bool puedeUsarPantallaCompleta = true,
bool ignoraOptimizacionBateria = true,
int alarmasNativasPendientes = 1,
String fabricante = 'Google',
int versionSdk = 34,
}) => DiagnosticoAlarmasAndroid(
puedeProgramarExactas: puedeProgramarExactas,
notificacionesPermitidas: notificacionesPermitidas,
puedeUsarPantallaCompleta: puedeUsarPantallaCompleta,
ignoraOptimizacionBateria: ignoraOptimizacionBateria,
alarmasNativasPendientes: alarmasNativasPendientes,
fabricante: fabricante,
versionSdk: versionSdk,
);
void main() {
group('construirItemsDiagnosticoAlarmas', () {
test(
'caso todo OK: los 5 items quedan en estado ok y en orden estable',
() {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(),
hayAlarmasActivas: true,
);
expect(items, hasLength(5));
expect(items.map((item) => item.senal).toList(), const [
SenalDiagnosticoAlarma.alarmasExactas,
SenalDiagnosticoAlarma.notificaciones,
SenalDiagnosticoAlarma.pantallaCompleta,
SenalDiagnosticoAlarma.optimizacionBateria,
SenalDiagnosticoAlarma.alarmasNativasPendientes,
]);
expect(
items.every((item) => item.estado == EstadoSenalDiagnostico.ok),
isTrue,
reason: 'ningun item deberia requerir atencion en el caso todo OK',
);
expect(items.any((item) => item.requiereAtencion), isFalse);
},
);
test('alarmas exactas queda en atencion cuando el permiso no esta '
'concedido, sin afectar a los demas items (falla independiente)', () {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(puedeProgramarExactas: false),
hayAlarmasActivas: true,
);
final exactas = items.singleWhere(
(item) => item.senal == SenalDiagnosticoAlarma.alarmasExactas,
);
expect(exactas.estado, EstadoSenalDiagnostico.atencion);
expect(exactas.accion, AccionDiagnosticoAlarma.abrirAlarmasExactas);
expect(
items
.where(
(item) => item.senal != SenalDiagnosticoAlarma.alarmasExactas,
)
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
isTrue,
);
});
test('notificaciones queda en atencion cuando no estan permitidas, sin '
'afectar a los demas items (falla independiente)', () {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(notificacionesPermitidas: false),
hayAlarmasActivas: true,
);
final notificaciones = items.singleWhere(
(item) => item.senal == SenalDiagnosticoAlarma.notificaciones,
);
expect(notificaciones.estado, EstadoSenalDiagnostico.atencion);
expect(
notificaciones.accion,
AccionDiagnosticoAlarma.abrirNotificaciones,
);
expect(
items
.where(
(item) => item.senal != SenalDiagnosticoAlarma.notificaciones,
)
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
isTrue,
);
});
test('pantalla completa queda en atencion cuando no se puede usar, sin '
'afectar a los demas items (falla independiente)', () {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(puedeUsarPantallaCompleta: false),
hayAlarmasActivas: true,
);
final pantalla = items.singleWhere(
(item) => item.senal == SenalDiagnosticoAlarma.pantallaCompleta,
);
expect(pantalla.estado, EstadoSenalDiagnostico.atencion);
expect(pantalla.accion, AccionDiagnosticoAlarma.abrirPantallaCompleta);
expect(
items
.where(
(item) => item.senal != SenalDiagnosticoAlarma.pantallaCompleta,
)
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
isTrue,
);
});
test('optimizacion de bateria queda en atencion cuando la app no esta '
'exenta, sin afectar a los demas items (falla independiente)', () {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(ignoraOptimizacionBateria: false),
hayAlarmasActivas: true,
);
final bateria = items.singleWhere(
(item) => item.senal == SenalDiagnosticoAlarma.optimizacionBateria,
);
expect(bateria.estado, EstadoSenalDiagnostico.atencion);
expect(bateria.accion, AccionDiagnosticoAlarma.abrirOptimizacionBateria);
expect(
items
.where(
(item) =>
item.senal != SenalDiagnosticoAlarma.optimizacionBateria,
)
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
isTrue,
);
});
test('alarmas nativas pendientes queda en atencion cuando hay alarmas '
'activas pero ninguna llego a registrarse en el sistema (la senal '
'mas diagnostica del reporte: el disparo nunca llego al SO)', () {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(alarmasNativasPendientes: 0),
hayAlarmasActivas: true,
);
final nativas = items.singleWhere(
(item) => item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes,
);
expect(nativas.estado, EstadoSenalDiagnostico.atencion);
expect(nativas.accion, AccionDiagnosticoAlarma.ninguna);
expect(
items
.where(
(item) =>
item.senal != SenalDiagnosticoAlarma.alarmasNativasPendientes,
)
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
isTrue,
);
});
test('alarmas nativas pendientes queda OK cuando no hay ninguna alarma '
'activa, aunque el conteo nativo sea cero (nada deberia estar '
'registrado todavia)', () {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(alarmasNativasPendientes: 0),
hayAlarmasActivas: false,
);
final nativas = items.singleWhere(
(item) => item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes,
);
expect(nativas.estado, EstadoSenalDiagnostico.ok);
});
test('alarmas nativas pendientes queda OK cuando hay alarmas activas y al '
'menos una esta registrada en el sistema', () {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(alarmasNativasPendientes: 3),
hayAlarmasActivas: true,
);
final nativas = items.singleWhere(
(item) => item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes,
);
expect(nativas.estado, EstadoSenalDiagnostico.ok);
});
});
group('fabricanteRequiereGuiaAutostart', () {
test('un fabricante desconocido no requiere guia de autostart', () {
expect(fabricanteRequiereGuiaAutostart('Google'), isFalse);
expect(fabricanteRequiereGuiaAutostart('Fairphone'), isFalse);
expect(fabricanteRequiereGuiaAutostart(''), isFalse);
});
test(
'Xiaomi (y sus sub-marcas Redmi/POCO) requieren guia de autostart',
() {
expect(fabricanteRequiereGuiaAutostart('Xiaomi'), isTrue);
expect(fabricanteRequiereGuiaAutostart('Redmi'), isTrue);
expect(fabricanteRequiereGuiaAutostart('POCO'), isTrue);
},
);
test('otros fabricantes conocidos por matar procesos en segundo plano '
'tambien requieren guia', () {
for (final fabricante in [
'HUAWEI',
'OPPO',
'vivo',
'OnePlus',
'samsung',
]) {
expect(
fabricanteRequiereGuiaAutostart(fabricante),
isTrue,
reason: '$fabricante deberia requerir guia de autostart',
);
}
});
test('la comparacion no distingue mayusculas/minusculas ni espacios', () {
expect(fabricanteRequiereGuiaAutostart('XIAOMI'), isTrue);
expect(fabricanteRequiereGuiaAutostart(' xiaomi '), isTrue);
});
});
}
File diff suppressed because it is too large Load Diff
@@ -20,10 +20,16 @@ void main() {
return true;
case 'requestIgnoreBatteryOptimizations':
return true;
case 'openNotificationSettings':
return true;
case 'getActiveRingingAlarmId':
return 'ring1';
case 'stopActiveAlarm':
return {'stopped': true, 'wasRinging': true, 'activeAlarmId': 'ring1'};
return {
'stopped': true,
'wasRinging': true,
'activeAlarmId': 'ring1',
};
}
return null;
});
@@ -111,6 +117,22 @@ void main() {
},
);
test(
'abrirConfiguracionNotificaciones invoca openNotificationSettings '
'(deep link a Settings, distinto del permiso runtime de la primera vez)',
() async {
final servicio = ServicioAlarmasAndroid(channel: channel);
final abierto = await servicio.abrirConfiguracionNotificaciones();
expect(abierto, isTrue);
expect(
llamadas.map((c) => c.method),
contains('openNotificationSettings'),
);
},
);
test(
'detenerSonidoActivo mapea el resultado nativo confirmado a ResultadoDetencion',
() async {
@@ -144,21 +166,15 @@ void main() {
},
);
test(
'alarmaSonandoId propaga el error del canal (fail-toward-silence, '
'Finding 2)',
() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
llamadas.add(call);
throw PlatformException(code: 'QUERY_FAILED', message: 'boom');
});
final servicio = ServicioAlarmasAndroid(channel: channel);
test('alarmaSonandoId propaga el error del canal (fail-toward-silence, '
'Finding 2)', () async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
llamadas.add(call);
throw PlatformException(code: 'QUERY_FAILED', message: 'boom');
});
final servicio = ServicioAlarmasAndroid(channel: channel);
expect(
() => servicio.alarmaSonandoId(),
throwsA(isA<PlatformException>()),
);
},
);
expect(() => servicio.alarmaSonandoId(), throwsA(isA<PlatformException>()));
});
}
@@ -0,0 +1,184 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Coverage for the scheduling-reliability failure records (fix/alarmas-
/// fallos-silenciosos): a failed `android.programar` call is no longer only
/// a transient, alarm-agnostic `EstadoAlarmas.error` string -- it is also
/// recorded per-alarm through the SAME `ExcepcionAlarma` model `saltarProxima`
/// already uses, so `EstadoAlarmas.ultimaExcepcionPara` can surface it on the
/// exact card affected.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('registrarFalloProgramacion agrega una excepcion de fallo para la '
'alarma indicada', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
final config = await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 25, 7, 30),
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, hasLength(1));
expect(config.excepciones.single.alarmaId, 'a1');
expect(
config.excepciones.single.tipo,
ExcepcionAlarma.tipoFalloProgramacion,
);
});
test('registrarFalloProgramacion reemplaza un fallo previo de la MISMA '
'alarma en vez de acumular', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 25, 7, 30),
ExcepcionAlarma.tipoFalloPreaviso,
);
final config = await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 26, 7, 30),
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, hasLength(1));
expect(
config.excepciones.single.tipo,
ExcepcionAlarma.tipoFalloProgramacion,
);
});
test('registrarFalloProgramacion NUNCA toca las excepciones skipNext de '
'otras alarmas ni de la misma', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.saltarProxima('a1');
final config = await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 26, 7, 30),
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, hasLength(2));
expect(
config.excepciones.map((e) => e.tipo),
containsAll(<String>[
ExcepcionAlarma.tipoSaltoSiguiente,
ExcepcionAlarma.tipoFalloProgramacion,
]),
);
});
test('limpiarFalloProgramacion elimina el fallo registrado para esa '
'alarma', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 25, 7, 30),
ExcepcionAlarma.tipoFalloProgramacion,
);
final config = await servicio.limpiarFalloProgramacion(
'a1',
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, isEmpty);
});
test('limpiarFalloProgramacion NO limpia un fallo de un tipo distinto '
'(cada subsistema se limpia por su cuenta)', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 25, 7, 30),
ExcepcionAlarma.tipoFalloPreaviso,
);
final config = await servicio.limpiarFalloProgramacion(
'a1',
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, hasLength(1));
expect(config.excepciones.single.tipo, ExcepcionAlarma.tipoFalloPreaviso);
});
test('limpiarFalloProgramacion sin fallo previo no rompe y no persiste '
'cambios', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
final config = await servicio.limpiarFalloProgramacion(
'a1',
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, isEmpty);
});
}
@@ -0,0 +1,250 @@
import 'dart:io';
import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Guards the ONE rule that makes a `MediaControl.custom` safe to put in the
/// handler's transport `controls`.
///
/// `AudioService.setState` (AudioService.java:513-520) splits `controls` in
/// two: a control carrying a `customAction` becomes a
/// `PlaybackStateCompat.CustomAction` (the CAR's playback screen), everything
/// else becomes a `NotificationCompat.Action` (the PHONE's media
/// notification). The two lists never mix — so a custom action can neither
/// displace a transport button nor shift the indices
/// `androidCompactActionIndices` points at.
///
/// What it CAN do is take the whole media session down. `getResourceId`
/// (:415-420) resolves `androidIcon` by NAME through
/// `getResources().getIdentifier(...)` and returns 0 when it misses, and
/// `PlaybackStateCompat.CustomAction.Builder` throws on a 0 icon or an empty
/// label. That throw happens at :515, BEFORE
/// `mediaSession.setPlaybackState` (:552) and `enterPlayingState()` (:559) —
/// so the session is never published and the notification is never posted,
/// while ExoPlayer keeps playing regardless.
///
/// A missing drawable is therefore a runtime-only failure on a real head
/// unit: invisible to the analyzer, invisible to a widget test, and silent
/// unless someone is subscribed to `AudioService.asyncError`. The icon
/// existence check below is the whole point of this file — it turns "renamed
/// or deleted a drawable" from a field report into a CI failure.
void main() {
/// Resolves an `androidIcon` string (`'drawable/ic_foo'`) the same way
/// `getResourceId` does: type directory, then resource name. Any file
/// extension counts — a vector `.xml` and a raster `.png` are equally valid
/// to `getIdentifier`.
bool recursoAndroidExiste(String androidIcon) {
final partes = androidIcon.split('/');
if (partes.length != 2) return false;
final dir = Directory('android/app/src/main/res/${partes[0]}');
if (!dir.existsSync()) return false;
return dir.listSync().whereType<File>().any((f) {
final nombre = f.uri.pathSegments.last;
final base =
nombre.contains('.')
? nombre.substring(0, nombre.indexOf('.'))
: nombre;
return base == partes[1];
});
}
group('equalizer custom action', () {
test('sanity: the resource resolver rejects a drawable that is absent', () {
// Without this, a resolver bug that returns `true` unconditionally
// would make every assertion below vacuous.
expect(recursoAndroidExiste('drawable/ic_no_existe_de_verdad'), isFalse);
expect(recursoAndroidExiste('drawable/ic_stat_pluriwave'), isTrue);
});
for (final activo in [false, true]) {
test('icon resolves to a real drawable when activo=$activo', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: activo,
l10n: lookupAppLocalizations(const Locale('es')),
);
expect(controles, hasLength(1));
final icono = controles.single.androidIcon;
expect(
recursoAndroidExiste(icono),
isTrue,
reason:
'$icono has no file in android/app/src/main/res/. '
'getResourceId would return 0 and CustomAction.Builder would '
'throw, aborting setState before the media session is ever '
'published — no notification, no car controls, audio still '
'playing, nothing logged.',
);
});
}
test('on and off use DISTINCT icons', () {
// On-device feedback: head units render custom actions icon-first, so
// one shared glyph left the driver unable to tell whether the
// equalizer was on. Two identical icons is the bug, not the fix.
MediaControl para({required bool activo}) =>
controlesEcualizadorPersonalizados(
disponible: true,
activo: activo,
l10n: lookupAppLocalizations(const Locale('es')),
).single;
expect(
para(activo: true).androidIcon,
isNot(para(activo: false).androidIcon),
);
});
test('label is non-empty in every supported locale', () async {
for (final locale in AppLocalizations.supportedLocales) {
final l10n = await AppLocalizations.delegate.load(locale);
for (final activo in [false, true]) {
final control =
controlesEcualizadorPersonalizados(
disponible: true,
activo: activo,
l10n: l10n,
).single;
expect(
control.label.trim(),
isNotEmpty,
reason:
'an empty label makes CustomAction.Builder throw for '
'${locale.languageCode} (activo=$activo), which kills the '
'media session for every user in that language',
);
}
}
});
test('no action at all when the device has no equalizer', () {
expect(
controlesEcualizadorPersonalizados(
disponible: false,
activo: true,
l10n: lookupAppLocalizations(const Locale('es')),
),
isEmpty,
reason:
'a device without the native effect gets no EQ action, '
'never a broken one',
);
});
});
group('transport row keeps its shape', () {
// Calls the REAL builder, never a copy of it. This group used to
// re-declare the list inline, which meant it stayed green while asserting
// a shape lib/ no longer produced — a guard blind to the thing it guards.
List<MediaControl> transporte({
required bool colaActiva,
required bool playing,
required bool eqDisponible,
}) => construirControlesTransporte(
colaActiva: colaActiva,
playing: playing,
eqDisponible: eqDisponible,
eqActivo: true,
l10n: lookupAppLocalizations(const Locale('es')),
);
/// What `AudioService.setState` (AudioService.java:513-521) would route to
/// `nativeActions` — the ONLY list the phone's media notification is built
/// from, and the list `androidCompactActionIndices` indexes into.
///
/// On Android 13+ `MediaControl.stop` also becomes a custom action
/// (:466-469), so pass [sdk33] to model that split.
List<MediaControl> nativas(
List<MediaControl> controles, {
required bool sdk33,
}) =>
controles
.where((c) => c.customAction == null)
.where((c) => !(sdk33 && c == MediaControl.stop))
.toList();
for (final colaActiva in [false, true]) {
for (final playing in [false, true]) {
test('compact index still points at play/pause on BOTH API levels '
'(colaActiva=$colaActiva playing=$playing)', () {
final controles = transporte(
colaActiva: colaActiva,
playing: playing,
eqDisponible: true,
);
final indiceCompacto = colaActiva ? 1 : 0;
for (final sdk33 in [false, true]) {
final row = nativas(controles, sdk33: sdk33);
expect(row.length, greaterThan(indiceCompacto));
expect(
row[indiceCompacto],
playing ? MediaControl.pause : MediaControl.play,
reason:
'androidCompactActionIndices is [colaActiva ? 1 : 0] and it '
'indexes nativeActions (AudioService.java:613-618, :637-639)',
);
}
});
}
}
test('the equalizer comes BEFORE stop, so it wins the first custom-action '
'slot on the car', () {
// Reported on v1.2.14+136: the toggle was in the binary but invisible on
// the head unit. On Android 13+ stop is ALSO a custom action, and it used
// to be first — a unit exposing one slot showed stop and buried the
// equalizer in an overflow menu.
final controles = transporte(
colaActiva: true,
playing: true,
eqDisponible: true,
);
final custom = controles.where(
(c) => c.customAction != null || c == MediaControl.stop,
);
expect(
custom.first.customAction,
isNotNull,
reason: 'on Android 13+ this is the order the car receives them in',
);
});
test('reordering did NOT disturb the notification row', () {
// The whole safety argument for the swap: nativeActions must come out
// [prev?, play/pause, stop, next?] on <13 and [prev?, play/pause, next?]
// on 13+, exactly as before the equalizer moved.
final controles = transporte(
colaActiva: true,
playing: true,
eqDisponible: true,
);
expect(nativas(controles, sdk33: false), [
MediaControl.skipToPrevious,
MediaControl.pause,
MediaControl.stop,
MediaControl.skipToNext,
]);
expect(nativas(controles, sdk33: true), [
MediaControl.skipToPrevious,
MediaControl.pause,
MediaControl.skipToNext,
]);
});
test('a device with no equalizer gets the exact pre-existing list', () {
expect(transporte(colaActiva: true, playing: true, eqDisponible: false), [
MediaControl.skipToPrevious,
MediaControl.pause,
MediaControl.stop,
MediaControl.skipToNext,
]);
});
});
}
@@ -0,0 +1,200 @@
import 'dart:io';
import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Item 4 (Android Auto: equalizer custom actions) — the pure, handler-
/// independent half of the fix. `PluriWaveAudioHandler` cannot be
/// instantiated in unit tests (a real `just_audio.AudioPlayer` requires
/// platform MethodChannels), so the preset-cycling decision, the preset-name
/// localization and the `MediaControl` list construction are extracted as
/// pure top-level functions here. The handler's own `customAction` dispatch
/// and `playbackState` wiring are static-review-only, same as the existing
/// EQ re-apply/session-id wiring.
void main() {
final l10n = lookupAppLocalizations(const Locale('es'));
group('presetSiguiente (item 4 — cycling presets)', () {
test('advances to the next preset in order', () {
expect(presetSiguiente(PresetEcualizador.flat), PresetEcualizador.rock);
expect(presetSiguiente(PresetEcualizador.rock), PresetEcualizador.pop);
});
test('wraps around after the last preset', () {
expect(
presetSiguiente(PresetEcualizador.presets.last),
PresetEcualizador.presets.first,
);
});
test('an unknown/custom preset (e.g. a user-tweaked "Personalizado" band '
'set) starts from the FIRST preset instead of throwing', () {
final personalizado = PresetEcualizador(
nombre: 'Personalizado',
bandas: [1.0, 2.0, 3.0, 4.0, 5.0],
);
expect(presetSiguiente(personalizado), PresetEcualizador.presets.first);
});
test('respects an injected presets list instead of the default 6', () {
final propios = [PresetEcualizador.jazz, PresetEcualizador.voz];
expect(
presetSiguiente(PresetEcualizador.jazz, presets: propios),
PresetEcualizador.voz,
);
expect(
presetSiguiente(PresetEcualizador.voz, presets: propios),
PresetEcualizador.jazz,
);
});
});
group('nombrePresetVisible (item 4)', () {
test('maps every factory preset name to its localized ARB string', () {
expect(nombrePresetVisible(l10n, 'Flat'), l10n.equalizerPresetFlat);
expect(nombrePresetVisible(l10n, 'Rock'), l10n.equalizerPresetRock);
expect(nombrePresetVisible(l10n, 'Pop'), l10n.equalizerPresetPop);
expect(
nombrePresetVisible(l10n, 'Bass Boost'),
l10n.equalizerPresetBassBoost,
);
expect(nombrePresetVisible(l10n, 'Jazz'), l10n.equalizerPresetJazz);
expect(nombrePresetVisible(l10n, 'Voz'), l10n.equalizerPresetVoice);
expect(
nombrePresetVisible(l10n, 'Personalizado'),
l10n.equalizerPresetCustom,
);
});
test('an unrecognized name falls through verbatim', () {
expect(
nombrePresetVisible(l10n, 'Mi Preset Guardado'),
'Mi Preset Guardado',
);
});
});
group('controlesEcualizadorPersonalizados (item 4)', () {
test('empty when the equalizer is not available on this device', () {
final controles = controlesEcualizadorPersonalizados(
disponible: false,
activo: true,
l10n: l10n,
);
expect(controles, isEmpty);
});
test('exactly 1 custom action when available: the on/off toggle -- '
'decision `auto/ecualizador-diseno` REMOVES the preset-cycling '
'action that used to sit alongside it; preset selection now lives '
'in the "Ecualizador" browsable folder instead (see '
'`itemsEcualizadorAuto`)', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
l10n: l10n,
);
expect(controles, hasLength(1));
expect(controles.single.action, MediaAction.custom);
expect(controles.single.customAction?.name, accionEqToggle);
});
test('toggle label reflects ON -> shows "disable" action', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
l10n: l10n,
);
final toggle = controles.firstWhere(
(c) => c.customAction?.name == accionEqToggle,
);
expect(toggle.label, l10n.eqCustomActionDisableLabel);
});
test('toggle label reflects OFF -> shows "enable" action', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: false,
l10n: l10n,
);
final toggle = controles.firstWhere(
(c) => c.customAction?.name == accionEqToggle,
);
expect(toggle.label, l10n.eqCustomActionEnableLabel);
});
test('toggle icon reflects EQ state: ON uses ic_auto_eq_on, OFF uses '
'ic_auto_eq_off -- a car head unit foregrounds the icon over the '
'label, so the icon itself must change, not just the text', () {
final activado = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
l10n: l10n,
).firstWhere((c) => c.customAction?.name == accionEqToggle);
final desactivado = controlesEcualizadorPersonalizados(
disponible: true,
activo: false,
l10n: l10n,
).firstWhere((c) => c.customAction?.name == accionEqToggle);
expect(activado.androidIcon, 'drawable/ic_auto_eq_on');
expect(desactivado.androidIcon, 'drawable/ic_auto_eq_off');
});
});
group(
'action name constants (item 4 -- collision-free with car-tree ids)',
() {
test('accionEqToggle is non-empty and does not collide with any '
'existing browse-tree media-id prefix -- accionEqPresetSiguiente '
'(decision `auto/ecualizador-diseno`: removed, superseded by the '
'"Ecualizador" browsable folder) no longer exists as a symbol at '
'all, which this file compiling proves on its own', () {
expect(accionEqToggle, isNotEmpty);
});
},
);
group('equalizer drawable assets on disk (on-device feedback follow-up: the '
'two custom actions used to share one drawable and were visually '
'indistinguishable)', () {
test('ic_auto_eq_on and ic_auto_eq_off exist under '
'android/app/src/main/res/drawable/ -- a missing drawable is not a '
'build error, it silently renders blank/default on the head unit, '
'so this is the only safety net that would have caught the '
'original duplication. ic_auto_eq_preset is deliberately NOT '
'checked here anymore -- decision `auto/ecualizador-diseno` '
'removes the preset-cycling action and its drawable', () {
for (final nombre in ['ic_auto_eq_on', 'ic_auto_eq_off']) {
final archivo = File('android/app/src/main/res/drawable/$nombre.xml');
expect(
archivo.existsSync(),
isTrue,
reason:
'$nombre.xml must exist under '
'android/app/src/main/res/drawable/',
);
}
expect(
File(
'android/app/src/main/res/drawable/ic_auto_eq_preset.xml',
).existsSync(),
isFalse,
reason:
'ic_auto_eq_preset.xml must be REMOVED -- decision '
'`auto/ecualizador-diseno` retires the preset-cycling '
'custom action it belonged to',
);
});
});
}

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