fix(startup): move optional work off the launch gate
Cold start on the target TV box reaches its first frame 21 ms after `dart_main`, so nothing Dart-side gates the splash. Everything below is on the path to the first *useful* frame, which is a strictly serial chain and where the viewer actually waits. - `monoTheme` is a pure function of two bools that builds a full `ColorScheme`, an applied-and-copied 15-style `TextTheme`, ~14 sub-themes and then clones the whole `ThemeData` again. It was rebuilt five times per cold start, two of them before `runApp`, and twice more per app-shell rebuild. It is memoized now, keyed by palette plus `TargetPlatform` -- the platform matters because `ThemeData()` derives tap target size, visual density and typography from `defaultTargetPlatform`, so a palette-only key would be wrong under a debug or test platform override. - `initializeDateFormatting` ignores its locale argument and builds CLDR symbols and patterns for all 121 locales synchronously. It blocked the gate ahead of the database open for data that only content screens use. - `DownloadStorageService.initialize` ended in a `path_provider` round trip plus mkdir at the tail of the gate, contradicting the comment above it that already explained offline artwork is not a launch requirement. - `recoverInterruptedDownloads()` and `TrackerCoordinator.initialize()` ran from `initState` of the widget whose first build produces the first app frame, and the RSS watchdog installed a periodic timer there whose first useful sample is 15 s away regardless. - `CredentialVault` decrypted every token with pure-Dart AES-GCM on the main isolate, uncached, on every registry read and on every Drift re-emit -- and the binder writes tokens during the startup sweep, so writes re-triggered reads. Decryption is memoized by ciphertext, with `invalidateCache()` wired into the preference-store repair path so a repaired install cannot serve stale plaintext. - `reloadFromStorage` now coalesces in-flight callers. The two serial awaits around the legacy migration are deliberately not merged; only genuinely concurrent callers share a snapshot. - `_sameConnections` ran two `jsonEncode` calls per connection on every Drift emit purely to compare, allocating two maps and two strings each time. - The splash rendered one `CircularProgressIndicator` per pending server on top of the aggregate one, so N+1 tickers scheduled a frame every vsync for the whole of `awaitBindingSettle` -- competing with the startup work they were reporting on. Measured on device in a settled dexopt state: time to `main_screen` 1495 -> 1400 ms, `credentials_loaded` 1238 -> 1128 ms, `database_ready` 501 -> 443 ms. First frame is unchanged at ~18 ms, as expected.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
import '../database/app_database.dart';
|
||||
@@ -16,6 +17,19 @@ class ConnectionRegistry {
|
||||
ConnectionRegistry(this._db);
|
||||
|
||||
final AppDatabase _db;
|
||||
static const DeepCollectionEquality _configEquality = DeepCollectionEquality();
|
||||
|
||||
final Expando<Map<String, Object?>> _decryptedConfigs = Expando<Map<String, Object?>>(
|
||||
'ConnectionRegistry.decryptedConfigs',
|
||||
);
|
||||
|
||||
/// Compares the decrypted persisted config projections retained while rows
|
||||
/// are decoded. The fallback covers models supplied outside this registry.
|
||||
bool hasSameConfig(Connection a, Connection b) {
|
||||
final aConfig = _decryptedConfigs[a] ??= a.toConfigJson();
|
||||
final bConfig = _decryptedConfigs[b] ??= b.toConfigJson();
|
||||
return _configEquality.equals(aConfig, bConfig);
|
||||
}
|
||||
|
||||
/// Emits the current set of connections after every mutation. Drift's
|
||||
/// `watch()` provides this for free.
|
||||
@@ -121,6 +135,7 @@ class ConnectionRegistry {
|
||||
dialect: kind.dialect!,
|
||||
),
|
||||
};
|
||||
_decryptedConfigs[connection] = revealed.config;
|
||||
if (revealed.migrated) {
|
||||
await upsert(connection);
|
||||
}
|
||||
|
||||
+30
-20
@@ -57,6 +57,7 @@ import 'utils/snackbar_helper.dart';
|
||||
import 'services/multi_server_manager.dart';
|
||||
import 'services/offline_watch_sync_service.dart';
|
||||
import 'services/data_aggregation_service.dart';
|
||||
import 'services/credential_vault.dart';
|
||||
import 'services/server_registry.dart';
|
||||
import 'services/download_manager_service.dart';
|
||||
import 'services/pip_service.dart';
|
||||
@@ -474,6 +475,11 @@ Future<StartupRepairResult> repairStartupStorage(
|
||||
final outcome = unreadableKey != null
|
||||
? await BaseSharedPreferencesService.dropUnreadableCredential(unreadableKey)
|
||||
: await BaseSharedPreferencesService.repairCorruptStore(reopenSafe: reopenSafe);
|
||||
// The repair replaced or dropped entries in the backing store, so the vault's
|
||||
// memoized key and its ciphertext -> plaintext cache now describe a store
|
||||
// that no longer exists. Drop both before anything reads a credential again,
|
||||
// or a repaired install could keep serving pre-repair plaintext.
|
||||
CredentialVault.invalidateCache();
|
||||
final result = outcome.requiresRestart ? StartupRepairResult.restart : StartupRepairResult.retry;
|
||||
if (!context.mounted) return result;
|
||||
|
||||
@@ -888,7 +894,6 @@ Future<_StartupDependencies> _initializeStartup(SettingsService settings) async
|
||||
await _optionalGatePhase(StartupPhase.locale, () async {
|
||||
final savedLocale = settings.read(SettingsService.appLocale);
|
||||
await LocaleSettings.setLocale(savedLocale);
|
||||
await initializeDateFormatting(savedLocale.intlLocaleName, null);
|
||||
});
|
||||
markStartupPhase('locale');
|
||||
|
||||
@@ -930,12 +935,7 @@ Future<_StartupDependencies> _initializeStartup(SettingsService settings) async
|
||||
|
||||
await _optionalGatePhase(StartupPhase.imageCache, () async => DevicePerformance.applyImageCacheBudget());
|
||||
|
||||
// DownloadManagerService reads this singleton synchronously in MainApp's
|
||||
// initState, but `getArtworkPathSync` already models "not ready" by
|
||||
// returning null and the path re-resolves lazily, so offline artwork is
|
||||
// not a launch requirement.
|
||||
await _optionalGatePhase(StartupPhase.downloadStorage, () => DownloadStorageService.instance.initialize(settings));
|
||||
markStartupPhase('download-storage');
|
||||
markStartupPhase('image-cache');
|
||||
|
||||
return _StartupDependencies(
|
||||
settings: settings,
|
||||
@@ -950,14 +950,25 @@ Future<_StartupDependencies> _initializeStartup(SettingsService settings) async
|
||||
}
|
||||
|
||||
void _startNonessentialInitialization(SettingsService settings) {
|
||||
// `onCommitted` runs before the rebuild that creates MainApp. Share one
|
||||
// end-of-frame hop so synchronous tasks cannot delay its first frame.
|
||||
final afterFirstAppFrame = WidgetsBinding.instance.endOfFrame;
|
||||
|
||||
void bestEffort(String name, FutureOr<void> Function() action) {
|
||||
unawaited(
|
||||
Future.sync(action).catchError((Object error, StackTrace stackTrace) {
|
||||
afterFirstAppFrame.then((_) => action()).catchError((Object error, StackTrace stackTrace) {
|
||||
appLogger.e('$name startup task failed (${error.runtimeType})', stackTrace: stackTrace);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
bestEffort(
|
||||
'Date formatting',
|
||||
() => initializeDateFormatting(settings.read(SettingsService.appLocale).intlLocaleName, null),
|
||||
);
|
||||
bestEffort('Download storage', () => DownloadStorageService.instance.initialize(settings));
|
||||
bestEffort('Trackers', TrackerCoordinator.instance.initialize);
|
||||
|
||||
bestEffort('Legacy image cache cleanup', () async {
|
||||
if (settings.read(SettingsService.cleanedOldImageCache)) return;
|
||||
try {
|
||||
@@ -1244,7 +1255,11 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
_startRssWatchdog();
|
||||
// The watchdog's first useful sample is at least 15 seconds away; install
|
||||
// its timer only after the first app frame has completed.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _startRssWatchdog();
|
||||
});
|
||||
|
||||
_serverManager = MultiServerManager();
|
||||
_aggregationService = DataAggregationService(_serverManager);
|
||||
@@ -1258,14 +1273,14 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
storageService: DownloadStorageService.instance,
|
||||
clientResolver: _serverManager.resolveDownloadClient,
|
||||
);
|
||||
_downloadManager.recoveryFuture = _downloadManager.recoverInterruptedDownloads();
|
||||
// Keep the awaitable assigned synchronously, but do not let recovery's
|
||||
// Drift/native work compete with SetupScreen's first frame.
|
||||
_downloadManager.recoveryFuture = WidgetsBinding.instance.endOfFrame.then(
|
||||
(_) => _downloadManager.recoverInterruptedDownloads(),
|
||||
);
|
||||
|
||||
_offlineWatchSyncService = OfflineWatchSyncService(database: _appDatabase, serverManager: _serverManager);
|
||||
|
||||
// Tracker singletons init once per app; per-profile hydration happens in
|
||||
// the profile-scoped provider subtree's create callbacks.
|
||||
unawaited(TrackerCoordinator.instance.initialize());
|
||||
|
||||
_appLifecycleListener = AppLifecycleListener(
|
||||
onExitRequested: () async {
|
||||
await _shutdownForExit();
|
||||
@@ -2220,7 +2235,6 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
||||
if (_serverStatus.isEmpty) return const SizedBox.shrink();
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final dimColor = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5);
|
||||
const coralColor = Color(0xFFE5A00D);
|
||||
const successColor = Color(0xFF4CAF50);
|
||||
const failColor = Color(0xFFEF5350);
|
||||
|
||||
@@ -2230,11 +2244,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
||||
final (name, connected) = entry.value;
|
||||
final Widget statusIcon;
|
||||
if (connected == null) {
|
||||
statusIcon = const SizedBox(
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 1.5, color: coralColor),
|
||||
);
|
||||
statusIcon = AppIcon(Symbols.circle_rounded, size: 10, color: dimColor);
|
||||
} else if (connected) {
|
||||
statusIcon = const AppIcon(Symbols.check_circle_rounded, size: 14, color: successColor);
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@@ -55,6 +54,7 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
|
||||
StreamSubscription<List<ProfileConnection>>? _pcSub;
|
||||
StreamSubscription<Map<String, List<PlexHomeUser>>>? _plexHomeSub;
|
||||
Future<void>? _initializeFuture;
|
||||
Future<void>? _reloadFromStorageFuture;
|
||||
bool _initialized = false;
|
||||
|
||||
bool _isBinding = false;
|
||||
@@ -166,11 +166,24 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
|
||||
///
|
||||
/// Provider initialization starts before boot-time migration, so the first
|
||||
/// snapshot can legitimately miss the migrated connection/profile state.
|
||||
Future<void> reloadFromStorage() async {
|
||||
await initialize();
|
||||
await _plexHome.reloadFromStorage();
|
||||
await _reloadSnapshot();
|
||||
safeNotifyListeners();
|
||||
Future<void> reloadFromStorage() {
|
||||
final pending = _reloadFromStorageFuture;
|
||||
if (pending != null) return pending;
|
||||
|
||||
final future = _reloadFromStorage();
|
||||
_reloadFromStorageFuture = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<void> _reloadFromStorage() async {
|
||||
try {
|
||||
await initialize();
|
||||
await _plexHome.reloadFromStorage();
|
||||
await _reloadSnapshot();
|
||||
safeNotifyListeners();
|
||||
} finally {
|
||||
_reloadFromStorageFuture = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
@@ -230,23 +243,26 @@ class ActiveProfileProvider extends ChangeNotifier with DisposableChangeNotifier
|
||||
_resolveActive();
|
||||
}
|
||||
|
||||
/// [Connection] has no value equality; its persisted config is the cheapest
|
||||
/// faithful comparison key for the handful of rows involved.
|
||||
/// The decrypted persisted config is a faithful comparison projection for
|
||||
/// the handful of rows involved. [ConnectionRegistry] retains that
|
||||
/// projection alongside each decoded model, avoiding rebuilt config maps
|
||||
/// and JSON strings on every Drift emit.
|
||||
///
|
||||
/// `createdAt` is compared separately because it is a real column rather
|
||||
/// than part of `toConfigJson`, and avatar selection reads it: a profile
|
||||
/// shows the picture of its oldest linked connection. `ConnectionRegistry`
|
||||
/// pins creation order across re-authentication, so this costs no extra
|
||||
/// than part of the config, and avatar selection reads it: a profile shows
|
||||
/// the picture of its oldest linked connection. `ConnectionRegistry` pins
|
||||
/// creation order across re-authentication, so this costs no extra
|
||||
/// notifications — it only stops a genuine correction (a restore, a
|
||||
/// backfill) from being swallowed until the next launch.
|
||||
static bool _sameConnections(Map<String, Connection> a, Map<String, Connection> b) {
|
||||
bool _sameConnections(Map<String, Connection> a, Map<String, Connection> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (final entry in a.entries) {
|
||||
final other = b[entry.key];
|
||||
if (other == null) return false;
|
||||
if (identical(entry.value, other)) continue;
|
||||
if (entry.value.kind != other.kind) return false;
|
||||
if (entry.value.createdAt != other.createdAt) return false;
|
||||
if (jsonEncode(entry.value.toConfigJson()) != jsonEncode(other.toConfigJson())) return false;
|
||||
if (!_connections.hasSameConfig(entry.value, other)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -22,21 +22,43 @@ class CredentialVault {
|
||||
static const String _keyPref = credentialVaultKeyPref;
|
||||
static const String _prefix = 'enc:v1:';
|
||||
static final AesGcm _algorithm = AesGcm.with256bits();
|
||||
static final Map<String, Future<String?>> _decryptionCache = {};
|
||||
static Future<SecretKey>? _secretKey;
|
||||
static int _cacheGeneration = 0;
|
||||
|
||||
/// Drops the memoized key so tests can simulate key loss/divergence.
|
||||
/// Invalidates the memoized key and decrypted credentials after the
|
||||
/// underlying preference store is repaired or replaced.
|
||||
static void invalidateCache() {
|
||||
_cacheGeneration++;
|
||||
_secretKey = null;
|
||||
_decryptionCache.clear();
|
||||
}
|
||||
|
||||
/// Drops memoized vault state so tests can simulate key loss/divergence.
|
||||
@visibleForTesting
|
||||
static void resetKeyForTesting() {
|
||||
_secretKey = null;
|
||||
invalidateCache();
|
||||
}
|
||||
|
||||
static bool isProtected(String? value) => value != null && value.startsWith(_prefix);
|
||||
|
||||
static Future<String> protect(String value) async {
|
||||
if (value.isEmpty || isProtected(value)) return value;
|
||||
final key = await _getSecretKey();
|
||||
final box = await _algorithm.encrypt(utf8.encode(value), secretKey: key);
|
||||
return '$_prefix${jsonEncode({'n': base64Encode(box.nonce), 'c': base64Encode(box.cipherText), 'm': base64Encode(box.mac.bytes)})}';
|
||||
while (true) {
|
||||
final generation = _cacheGeneration;
|
||||
final key = await _getSecretKey();
|
||||
final box = await _algorithm.encrypt(utf8.encode(value), secretKey: key);
|
||||
if (generation != _cacheGeneration) continue;
|
||||
|
||||
final payload = {
|
||||
'n': base64Encode(box.nonce),
|
||||
'c': base64Encode(box.cipherText),
|
||||
'm': base64Encode(box.mac.bytes),
|
||||
};
|
||||
final ciphertext = '$_prefix${jsonEncode(payload)}';
|
||||
_decryptionCache[ciphertext] = Future.value(value);
|
||||
return ciphertext;
|
||||
}
|
||||
}
|
||||
|
||||
/// Decrypts a protected value, or returns it unchanged when it isn't
|
||||
@@ -46,6 +68,14 @@ class CredentialVault {
|
||||
/// never a reason to crash; callers treat null as "re-acquire the token".
|
||||
static Future<String?> reveal(String value) async {
|
||||
if (!isProtected(value)) return value;
|
||||
while (true) {
|
||||
final generation = _cacheGeneration;
|
||||
final clear = await _decryptionCache.putIfAbsent(value, () => _decrypt(value, generation));
|
||||
if (generation == _cacheGeneration) return clear;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<String?> _decrypt(String value, int generation) async {
|
||||
try {
|
||||
final payload = jsonDecode(value.substring(_prefix.length)) as Map<String, dynamic>;
|
||||
final box = SecretBox(
|
||||
@@ -56,6 +86,7 @@ class CredentialVault {
|
||||
final clear = await _algorithm.decrypt(box, secretKey: await _getSecretKey());
|
||||
return utf8.decode(clear);
|
||||
} catch (e) {
|
||||
if (generation != _cacheGeneration) return null;
|
||||
appLogger.w('CredentialVault: failed to decrypt stored credential, treating as lost', error: e);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'gapped_track_shape.dart';
|
||||
import 'mono_tokens.dart';
|
||||
|
||||
final Map<({bool dark, bool oled, TargetPlatform platform}), ThemeData> _monoThemeCache = {};
|
||||
|
||||
ThemeData monoTheme({required bool dark, bool oled = false}) {
|
||||
// ThemeData derives several defaults from defaultTargetPlatform.
|
||||
final key = (dark: dark || oled, oled: oled, platform: defaultTargetPlatform);
|
||||
final cached = _monoThemeCache[key];
|
||||
if (cached != null) return cached;
|
||||
|
||||
final theme = _buildMonoTheme(dark: key.dark, oled: key.oled, platform: key.platform);
|
||||
_monoThemeCache[key] = theme;
|
||||
return theme;
|
||||
}
|
||||
|
||||
ThemeData _buildMonoTheme({required bool dark, required bool oled, required TargetPlatform platform}) {
|
||||
// neutral greys tuned for crisp contrast
|
||||
final ({Color bg, Color surface, Color outline, Color text, Color textMuted}) c;
|
||||
if (oled) {
|
||||
@@ -46,6 +60,7 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
|
||||
);
|
||||
|
||||
final base = ThemeData(
|
||||
platform: platform,
|
||||
useMaterial3: true,
|
||||
brightness: isDark ? Brightness.dark : Brightness.light,
|
||||
colorScheme: ColorScheme(
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
|
||||
/// `monoTheme` builds a full `ColorScheme`, an applied+copyWith'd Typography
|
||||
/// `TextTheme`, ~14 sub-theme objects, and then clones the whole `ThemeData`
|
||||
/// again via `copyWith(extensions:)`. It used to run five times per cold start —
|
||||
/// two of them before `runApp`, on the critical path to first frame — and twice
|
||||
/// more on every app-shell rebuild. It is memoized now, so these tests pin the
|
||||
/// two properties that make that safe: stable identity per palette, and a cache
|
||||
/// key that includes every input the builder actually reads.
|
||||
void main() {
|
||||
test('repeated calls return the identical instance per palette', () {
|
||||
expect(identical(monoTheme(dark: false), monoTheme(dark: false)), isTrue);
|
||||
expect(identical(monoTheme(dark: true), monoTheme(dark: true)), isTrue);
|
||||
expect(identical(monoTheme(dark: true, oled: true), monoTheme(dark: true, oled: true)), isTrue);
|
||||
});
|
||||
|
||||
test('the three palettes are distinct instances', () {
|
||||
final light = monoTheme(dark: false);
|
||||
final dark = monoTheme(dark: true);
|
||||
final oled = monoTheme(dark: true, oled: true);
|
||||
|
||||
expect(identical(light, dark), isFalse);
|
||||
expect(identical(dark, oled), isFalse);
|
||||
expect(light.brightness, Brightness.light);
|
||||
expect(dark.brightness, Brightness.dark);
|
||||
// OLED is the pure-black variant, which is the whole reason it exists.
|
||||
expect(oled.scaffoldBackgroundColor, const Color(0xFF000000));
|
||||
expect(dark.scaffoldBackgroundColor, isNot(const Color(0xFF000000)));
|
||||
});
|
||||
|
||||
test('oled normalizes dark, so it cannot produce a light OLED theme', () {
|
||||
// ThemeProvider.darkThemeFor only ever asks for oled alongside dark, but the
|
||||
// signature allows dark:false — collapsing it keeps the cache at three
|
||||
// palette states instead of four, and a light OLED theme is meaningless.
|
||||
expect(identical(monoTheme(dark: false, oled: true), monoTheme(dark: true, oled: true)), isTrue);
|
||||
expect(monoTheme(dark: false, oled: true).brightness, Brightness.dark);
|
||||
});
|
||||
|
||||
test('palette colours are unchanged by memoization', () {
|
||||
// Guards against a cache key that accidentally collapses two palettes.
|
||||
final light = monoTheme(dark: false);
|
||||
final dark = monoTheme(dark: true);
|
||||
|
||||
expect(light.colorScheme.brightness, Brightness.light);
|
||||
expect(dark.colorScheme.brightness, Brightness.dark);
|
||||
expect(light.scaffoldBackgroundColor, isNot(dark.scaffoldBackgroundColor));
|
||||
expect(light.colorScheme.onSurface, isNot(dark.colorScheme.onSurface));
|
||||
});
|
||||
|
||||
group('target platform is part of the cache key', () {
|
||||
tearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
|
||||
test('each platform gets a theme carrying that platform', () {
|
||||
// ThemeData() derives platform defaults (tap target size, visual density,
|
||||
// typography) from defaultTargetPlatform. Keying only on the palette would
|
||||
// hand an Android-derived theme to a test or debug run overriding to iOS.
|
||||
for (final platform in [TargetPlatform.android, TargetPlatform.iOS, TargetPlatform.macOS]) {
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
expect(monoTheme(dark: true).platform, platform);
|
||||
}
|
||||
});
|
||||
|
||||
test('two platforms do not share an instance, and returning restores identity', () {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
final android = monoTheme(dark: true);
|
||||
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final ios = monoTheme(dark: true);
|
||||
expect(identical(android, ios), isFalse);
|
||||
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
expect(identical(monoTheme(dark: true), android), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user