Tres defectos preexistentes alrededor de la reanudacion en Android Auto. Ninguno es una regresion: el consumidor (la raiz `recent`) se añadio en septiembre y es lo que dejo el hueco a la vista. La ultima emisora solo la escribia el telefono La clave `ultima_emisora_v1` tenia como unico escritor a `EstadoRadio._persistirUltimaEmisora`, y `EstadoRadio` solo existe si hay arbol de widgets. El motor que arranca Android Auto es headless de verdad, asi que una sesion que ocurriera solo en el coche jamas actualizaba la clave y al reconectar se ofrecia la emisora de la ultima vez que se uso el movil. El handler recibe ahora sus puertos de lectura y escritura, con la misma forma que los del ecualizador y el contexto de salto, y escribe desde `_cambiarFuente`: el cuello de botella por el que pasan todas las rutas -- telefono, toque en el coche, voz, saltos, avance de cola y la propia reanudacion. Se ELIMINA el escritor del telefono en vez de sumar un segundo. Dos escritores independientes de la misma clave acaban divergiendo siempre; es exactamente lo que ya costo varias rondas con el flag del ecualizador. Las pistas locales quedan excluidas: un `content://` guardado como ultima emisora seria una fila de reanudacion que no resuelve a nada. play() sin fuente levantaba un servicio en primer plano vacio just_audio publica `playing:true` antes de comprobar si hay fuente, asi que un `play()` en frio no tocaba la plataforma pero si emitia ese estado sobre `processingState: idle`. audio_service entraba en estado de reproduccion mientras el estado nativo seguia en NONE: notificacion con boton de pausa, cero audio, sin titulo ni caratula, y un Future que no se completaba nunca. El coche enruta su tecla de play directamente ahi. Ahora `play()` sin fuente abierta restaura la ultima emisora por la ruta normal, y si no hay nada que restaurar no toca el reproductor ni publica nada. En frio no habia metadatos que enseñar El unico `mediaItem.add` util vivia dentro de `_cambiarFuente`, asi que en un motor recien arrancado el lado nativo nunca recibia metadatos. Se siembra el `mediaItem` de la emisora persistida sin cargar ni reproducir nada, con guarda antes y despues de la lectura de disco para no pisar una emisora ya sonando. `getMediaItem` resolvia solo contra el universo completo -- vacio en el motor del coche -- mientras `porUuid` si caia en las destacadas. El coche podia navegar una emisora destacada y luego no resolver su ficha. Ambos usan ahora la misma ruta. Suite completa: 1529 pasan, 2 omitidos. flutter analyze mantiene los 5 avisos preexistentes.
434 lines
20 KiB
Dart
434 lines
20 KiB
Dart
import 'dart:async';
|
|
import 'dart:developer' as developer;
|
|
import 'dart:ui' as ui;
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'app.dart';
|
|
import 'estado/estado_entitlement.dart';
|
|
import 'servicios/arranque_audio.dart';
|
|
import 'servicios/contexto_reproduccion.dart';
|
|
import 'servicios/emisoras_destacadas.dart';
|
|
import 'servicios/musica_local_auto.dart';
|
|
import 'servicios/navegacion_auto.dart';
|
|
import 'servicios/servicio_audio.dart';
|
|
import 'servicios/servicio_audio_session.dart';
|
|
import 'servicios/servicio_compras.dart';
|
|
import 'servicios/servicio_consentimiento.dart';
|
|
import 'servicios/servicio_ecualizador.dart';
|
|
import 'servicios/servicio_presets_personalizados.dart';
|
|
import 'tema/pluriwave_tokens.dart';
|
|
|
|
const _anchoMinimoLandscape = 600.0;
|
|
|
|
/// Branded monochrome status-bar icon, replacing the default full-color
|
|
/// launcher silhouette fallback.
|
|
const androidNotificationIconResource = 'drawable/ic_stat_pluriwave';
|
|
|
|
/// S5-R8: media notification accent uses the brand color, not the M3
|
|
/// default purple. Top-level const so tests can assert it.
|
|
const configuracionAudioService = AudioServiceConfig(
|
|
androidNotificationChannelId: 'es.freetimelab.pluriwave.audio',
|
|
androidNotificationChannelName: 'PluriWave Radio',
|
|
// 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();
|
|
|
|
// 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.estadoCarpeta() != noConfigurada`.
|
|
// Moving ONLY the station source above the awaits meant the car could get
|
|
// a root response in the window before this line ran, find a null source,
|
|
// and be told there is no local music — and Android Auto caches the browse
|
|
// root, so it stayed missing for the whole session. Before the reorder
|
|
// both registrations sat together after the await, so the window did not
|
|
// exist.
|
|
//
|
|
// `FuenteMusicaLocalAutoImpl` needs no prefs to be CONSTRUCTED: it
|
|
// resolves them lazily per call (`_resolverPrefs`, falling back to
|
|
// `getInstance()`), the same convention `ServicioAlarmas` uses. So there
|
|
// was never a reason for it to wait on that await.
|
|
registrarFuenteMusicaLocal(FuenteMusicaLocalAutoImpl());
|
|
|
|
// Cosmetic, and deliberately NOT awaited: a display preference must never
|
|
// gate `runApp`. `OrientacionResponsiveApp.didChangeDependencies` applies
|
|
// it again as soon as a real view exists, which is the only moment it can
|
|
// actually take effect anyway.
|
|
unawaited(aplicarPoliticaOrientacion());
|
|
|
|
// iap-freemium-unlock: neither SDK init call blocks `runApp` — a purchase
|
|
// stream subscription and an ad-SDK warm-up are both safe to finish late
|
|
// (mirrors `aplicarPoliticaOrientacion`'s "cosmetic, never gates startup"
|
|
// rule immediately above).
|
|
//
|
|
// FIX 4 (code review): the Mobile Ads SDK is only initialized AFTER the
|
|
// GDPR/UMP consent flow resolves that ads may actually be requested
|
|
// (`ConsentInformation.canRequestAds()`) — serving personalized ads to
|
|
// EEA/UK users with no CMP violates Google's EU User Consent Policy.
|
|
// Premium users never even reach the consent form (`resolverConsentimientoAnuncios`
|
|
// short-circuits for them — they get zero ads regardless). This whole
|
|
// chain is deliberately `unawaited`: consent/ads are exactly as
|
|
// "cosmetic, never gates startup" as `aplicarPoliticaOrientacion` above,
|
|
// and any failure inside it degrades to "no ads", never a crash or a
|
|
// blocked UI.
|
|
unawaited(
|
|
esPremiumPersistido()
|
|
.then(
|
|
(premium) => resolverConsentimientoAnuncios(
|
|
esPremium: premium,
|
|
consentimiento: ServicioConsentimientoUmp(),
|
|
),
|
|
)
|
|
.then((puedeSolicitarAnuncios) async {
|
|
if (puedeSolicitarAnuncios) {
|
|
await MobileAds.instance.initialize();
|
|
}
|
|
}),
|
|
);
|
|
final compras = ServicioComprasPlayBilling();
|
|
|
|
// S3-R4: single SharedPreferences instance resolved once at startup and
|
|
// injected into every state/service below.
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
// 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);
|
|
|
|
// eq-estado-unico items A/B: the handler's own link to the equalizer's
|
|
// persisted on/off flag. `ServicioEcualizador` needs nothing but the
|
|
// `prefs` instance resolved just above — no widget tree, no Provider — so
|
|
// it is available on EVERY engine, including the headless one Android Auto
|
|
// starts. Before this, the persisted value only reached the handler
|
|
// through `EstadoEcualizador.cargarPersistido()`, which that engine never
|
|
// runs: the handler played with the equalizer forced on while disk and the
|
|
// phone UI both said off, and a toggle made in the car was lost on
|
|
// restart. Passed as two narrow function ports, mirroring the
|
|
// read-function convention used for the preset folder right above.
|
|
final ecualizador = ServicioEcualizador(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 —
|
|
// the plugin caches state internally, so a double-configure call is
|
|
// unsafe and must never happen, even on the degraded/timeout path below.
|
|
final handlerFuturo = AudioService.init(
|
|
builder: () => PluriWaveAudioHandler(),
|
|
config: configuracionAudioService,
|
|
);
|
|
|
|
// S3-R1: audio focus — phone calls / transient losses pause or duck the
|
|
// radio; headphones unplugged pauses it. Shared by both the on-time and
|
|
// degraded/late-completion paths below.
|
|
void conectarHandler(PluriWaveAudioHandler handler) {
|
|
registrarHandler(
|
|
handler,
|
|
leerEqActivoPersistido: ecualizador.leerActivo,
|
|
guardarEqActivoPersistido: ecualizador.guardarActivo,
|
|
// The PRESET's half of the same seam. Without it the handler enabled
|
|
// the equalizer with its hardcoded FLAT preset on any engine where the
|
|
// phone UI never ran — i.e. every headless Android Auto bind. There is
|
|
// no write port: `EstadoEcualizador` still owns saving presets (a car
|
|
// preset choice goes through it), so the handler only ever reads.
|
|
leerPresetPersistido: ecualizador.leerPresetPrincipal,
|
|
// Skip context («in which list am I»). Bound here, on the audio
|
|
// bootstrap path of EVERY engine, precisely because the headless
|
|
// Android Auto engine builds no widget tree and therefore no
|
|
// `EstadoRadio`: a context only the phone UI could write would be a
|
|
// context the car could never have.
|
|
leerContextoSalto: contextoSaltoPersistido,
|
|
guardarContextoSalto: guardarContextoSalto,
|
|
// Last played station (`ultima_emisora_v1`). Bound here for the SAME
|
|
// reason as the skip context: `EstadoRadio` — which used to be its only
|
|
// writer — belongs to the widget tree, and the Android Auto engine
|
|
// builds none, so a session that happened only in the car never updated
|
|
// the key and the head unit was offered whatever the PHONE last played.
|
|
// The write port is now the key's single writer; the read port feeds the
|
|
// cold-start metadata seed and the bare-`play()` resume.
|
|
leerUltimaEmisora: ultimaEmisoraPersistida,
|
|
guardarUltimaEmisora: guardarUltimaEmisoraPersistida,
|
|
);
|
|
// The handler is the only thing this app ever tears down
|
|
// (`onTaskRemoved`), so the asyncError subscription's `cancel` travels
|
|
// with it and can never leak — same "register from main.dart" convention
|
|
// as `registrarHandler` itself.
|
|
registrarLimpiezaArranque(subErroresAudio.cancel);
|
|
final sesionAudio = ServicioAudioSession(objetivo: handler);
|
|
unawaited(sesionAudio.configurar());
|
|
}
|
|
|
|
Widget construirApp() => OrientacionResponsiveApp(
|
|
child: PluriWaveApp(prefs: prefs, fuenteAuto: fuenteAuto, compras: compras),
|
|
);
|
|
|
|
final resultado = await esperarArranqueAudio(handlerFuturo);
|
|
switch (resultado) {
|
|
case ArranqueAudioListo<PluriWaveAudioHandler>(:final handler):
|
|
// Handshake finished in time — exactly today's startup path.
|
|
conectarHandler(handler);
|
|
runApp(construirApp());
|
|
case ArranqueAudioPendiente<PluriWaveAudioHandler>(
|
|
handlerFuturo: final futuroPendiente,
|
|
):
|
|
// Handshake still hung after the timeout: run the app anyway with a
|
|
// minimal loading bootstrap that keeps awaiting the SAME
|
|
// `futuroPendiente` — identical to the outer `handlerFuturo`, per
|
|
// esperarArranqueAudio's contract (never a second AudioService.init
|
|
// call) — and wires the handler + swaps to the real app once it
|
|
// eventually resolves.
|
|
runApp(
|
|
ArranqueAudioApp<PluriWaveAudioHandler>(
|
|
handlerFuturo: futuroPendiente,
|
|
alListo: conectarHandler,
|
|
construirApp: (_) => construirApp(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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;
|
|
|
|
/// 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,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Whether the Android Auto browse tree must be invalidated right now
|
|
/// (fix/android-auto-musica-local, item 4 — CORRECTED trigger).
|
|
///
|
|
/// The trigger used to be `View.maybeOf(context) != null` inside
|
|
/// `didChangeDependencies`, latched once, on the premise that «a View means
|
|
/// there is an Activity». That premise is FALSE: `runApp` unconditionally
|
|
/// wraps the tree in a `View` built from
|
|
/// `platformDispatcher.implicitView` and throws a `StateError` when there is
|
|
/// none (`flutter/lib/src/widgets/binding.dart`, `wrapWithDefaultView`). So
|
|
/// on the headless `audio_service` engine — which demonstrably reaches
|
|
/// `runApp`, see [aplicarPoliticaOrientacion] — the View is ALREADY there at
|
|
/// the first `didChangeDependencies`. The one-shot latch was spent at the
|
|
/// exact moment it could accomplish nothing (`_childrenSubjects` still
|
|
/// empty, so `notificarHijosCambiaron` is a silent no-op) and could never
|
|
/// fire again, because `didChangeDependencies` does not re-run when an
|
|
/// Activity later attaches to that same cached engine.
|
|
///
|
|
/// Two conditions replace it, both required:
|
|
///
|
|
/// * [estado] is [AppLifecycleState.resumed] — the only state that genuinely
|
|
/// means «an Activity is attached and in the foreground». It reaches Dart
|
|
/// exclusively through `SystemChannels.lifecycle` (or
|
|
/// `PlatformDispatcher.initialLifecycleState`, which buffers the same
|
|
/// messages), and on Android only `LifecycleChannel.appIsResumed()` sends
|
|
/// it, driven by the Activity's own `onResume`.
|
|
/// `AudioServicePlugin.getFlutterEngine` builds its engine from the
|
|
/// APPLICATION context and runs the Dart entrypoint immediately, with no
|
|
/// Activity and no `FlutterActivityAndFragmentDelegate`, so nothing sends
|
|
/// it on the headless engine.
|
|
/// * [hayCocheSuscrito] — a head unit has actually subscribed to at least
|
|
/// one browse id (`hayCocheSuscritoAlArbol`). This is what makes the latch
|
|
/// worth spending, and it is also the belt to `resumed`'s braces: even if
|
|
/// a lifecycle event did somehow arrive during a headless cold start,
|
|
/// nothing has subscribed yet, so the latch survives for the moment an
|
|
/// Activity really does attach.
|
|
///
|
|
/// [yaInvalidado] keeps it one-shot: an app foregrounded twenty times must
|
|
/// not send twenty `notifyChildrenChanged` storms to the car.
|
|
///
|
|
/// Pure, so the whole policy is testable without an engine.
|
|
@visibleForTesting
|
|
bool debeInvalidarArbolAutoAlReanudar({
|
|
required AppLifecycleState estado,
|
|
required bool hayCocheSuscrito,
|
|
required bool yaInvalidado,
|
|
}) =>
|
|
!yaInvalidado && hayCocheSuscrito && estado == AppLifecycleState.resumed;
|
|
|
|
/// Root wrapper that keeps the orientation policy applied and owns the
|
|
/// Android Auto browse-tree recovery hook.
|
|
///
|
|
/// Public only so a test can mount it and drive real lifecycle events
|
|
/// through [debeInvalidarArbolAutoAlReanudar]'s call site — the previous
|
|
/// trigger shipped broken precisely because nothing could reach it.
|
|
@visibleForTesting
|
|
class OrientacionResponsiveApp extends StatefulWidget {
|
|
const OrientacionResponsiveApp({super.key, required this.child});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
State<OrientacionResponsiveApp> createState() =>
|
|
_OrientacionResponsiveAppState();
|
|
}
|
|
|
|
class _OrientacionResponsiveAppState extends State<OrientacionResponsiveApp>
|
|
with WidgetsBindingObserver {
|
|
ui.Display? _display;
|
|
|
|
/// fix/android-auto-musica-local, item 4: la invalidación del árbol del
|
|
/// coche se dispara UNA sola vez. Ver
|
|
/// [debeInvalidarArbolAutoAlReanudar].
|
|
bool _arbolAutoInvalidado = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_display = View.maybeOf(context)?.display;
|
|
unawaited(aplicarPoliticaOrientacion(display: _display));
|
|
}
|
|
|
|
/// `resumed` es lo único que significa de verdad «ya hay Activity
|
|
/// adjunta», y con ella el handler nativo de `pluriwave/file_actions` que
|
|
/// `MainActivity.configureFlutterEngine` instala. Si el coche había
|
|
/// navegado la raíz ANTES (arranque headless), la cacheó sin poder
|
|
/// resolver la música local; Android Auto no vuelve a preguntar por su
|
|
/// cuenta, así que se lo decimos aquí. Ver
|
|
/// [debeInvalidarArbolAutoAlReanudar] para las tres condiciones.
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
super.didChangeAppLifecycleState(state);
|
|
if (!debeInvalidarArbolAutoAlReanudar(
|
|
estado: state,
|
|
hayCocheSuscrito: hayCocheSuscritoAlArbol(),
|
|
yaInvalidado: _arbolAutoInvalidado,
|
|
)) {
|
|
return;
|
|
}
|
|
_arbolAutoInvalidado = true;
|
|
invalidarArbolAuto();
|
|
}
|
|
|
|
@override
|
|
void didChangeMetrics() {
|
|
unawaited(aplicarPoliticaOrientacion(display: _display));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => widget.child;
|
|
}
|