fix(settings): retain pending preferences during asynchronous loading

This commit is contained in:
zarzet committed 2026-10-06 22:00:48 +07:00
1 parent 66c5f5a86a
commit 6741aada4a
4 files changed
+349 -28

No files matched your search

+19 -7
View File
@@ -92,6 +92,9 @@ Future<void> resetRestoredInstallationSettings(SharedPreferences prefs) async {
}
class SettingsNotifier extends Notifier<AppSettings> {
SettingsNotifier({Future<SharedPreferences> Function()? preferences})
: _loadPreferences = preferences ?? SharedPreferences.getInstance;
static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$');
static const Set<String> _searchTabValues = {
'all',
@@ -119,9 +122,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
2000,
};
Future<SharedPreferences> get _prefs => SharedPreferences.getInstance();
final Future<SharedPreferences> Function() _loadPreferences;
Future<SharedPreferences> get _prefs => _loadPreferences();
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
bool _isSavingSettings = false;
Future<void>? _saveSettingsFuture;
bool _saveQueued = false;
String? _pendingSettingsJson;
Future<void>? _loadSettingsFuture;
@@ -302,15 +306,22 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
}
Future<void> _saveSettings() async {
Future<void> _saveSettings() {
_pendingSettingsJson = jsonEncode(state.toJson());
if (_isSavingSettings) {
final activeSave = _saveSettingsFuture;
if (activeSave != null) {
_saveQueued = true;
return;
return activeSave;
}
_isSavingSettings = true;
final saved = Completer<void>();
_saveSettingsFuture = saved.future;
unawaited(_drainSettingsSave(saved));
return saved.future;
}
Future<void> _drainSettingsSave(Completer<void> saved) async {
try {
final prefs = await _prefs;
do {
@@ -323,7 +334,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
} catch (e) {
_log.e('Failed to save settings: $e');
} finally {
_isSavingSettings = false;
_saveSettingsFuture = null;
saved.complete();
}
}
+46 -21
View File
@@ -27,71 +27,96 @@ final themeProvider = NotifierProvider<ThemeNotifier, ThemeSettings>(() {
});
class ThemeNotifier extends Notifier<ThemeSettings> {
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
ThemeNotifier({Future<SharedPreferences>? preferences})
: _prefs = preferences ?? SharedPreferences.getInstance();
final Future<SharedPreferences> _prefs;
late Future<void> _loadFuture;
Future<void> _saveChain = Future<void>.value();
final _loadingEdits = <ThemeSettings Function(ThemeSettings)>[];
bool _loaded = false;
@override
ThemeSettings build() {
_loadFromStorage();
_loadFuture = _loadFromStorage();
return ref.read(initialThemeSettingsProvider);
}
Future<void> _loadFromStorage() async {
try {
final prefs = await _prefs;
state = loadBootstrapThemeSettings(prefs);
var loaded = loadBootstrapThemeSettings(prefs);
for (final edit in _loadingEdits) {
loaded = edit(loaded);
}
if (ref.mounted) state = loaded;
} catch (e) {
debugPrint('Error loading theme settings: $e');
} finally {
_loaded = true;
_loadingEdits.clear();
}
}
Future<void> _saveToStorage() async {
void _editState(ThemeSettings Function(ThemeSettings) edit) {
if (!_loaded) _loadingEdits.add(edit);
state = edit(state);
}
Future<void> _saveToStorage() => _saveChain = _saveChain.then((_) async {
try {
await _loadFuture;
final prefs = await _prefs;
await prefs.setString(kThemeModeKey, state.themeMode.name);
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
await prefs.setInt(kSeedColorKey, state.seedColorValue);
await prefs.setBool(kUseAmoledKey, state.useAmoled);
await prefs.setString(kThemeStyleKey, state.style.name);
await prefs.setString(kMornyeAccentKey, state.mornyeAccent.name);
await prefs.setBool(kUseSystemFontKey, state.useSystemFont);
await prefs.setDouble(kMornyeGlassClarityKey, state.mornyeGlassClarity);
if (!ref.mounted) return;
final snapshot = state;
await prefs.setString(kThemeModeKey, snapshot.themeMode.name);
await prefs.setBool(kUseDynamicColorKey, snapshot.useDynamicColor);
await prefs.setInt(kSeedColorKey, snapshot.seedColorValue);
await prefs.setBool(kUseAmoledKey, snapshot.useAmoled);
await prefs.setString(kThemeStyleKey, snapshot.style.name);
await prefs.setString(kMornyeAccentKey, snapshot.mornyeAccent.name);
await prefs.setBool(kUseSystemFontKey, snapshot.useSystemFont);
await prefs.setDouble(
kMornyeGlassClarityKey,
snapshot.mornyeGlassClarity,
);
} catch (e) {
debugPrint('Error saving theme settings: $e');
}
}
});
Future<void> setThemeMode(ThemeMode mode) async {
state = state.copyWith(themeMode: mode);
_editState((current) => current.copyWith(themeMode: mode));
await _saveToStorage();
}
Future<void> setStyle(AppThemeStyle style) async {
state = state.copyWith(style: style);
_editState((current) => current.copyWith(style: style));
await _saveToStorage();
}
Future<void> setUseDynamicColor(bool value) async {
state = state.copyWith(useDynamicColor: value);
_editState((current) => current.copyWith(useDynamicColor: value));
await _saveToStorage();
}
Future<void> setSeedColor(Color color) async {
state = state.copyWith(seedColorValue: color.toARGB32());
_editState((current) => current.copyWith(seedColorValue: color.toARGB32()));
await _saveToStorage();
}
Future<void> setUseAmoled(bool value) async {
state = state.copyWith(useAmoled: value);
_editState((current) => current.copyWith(useAmoled: value));
await _saveToStorage();
}
Future<void> setMornyeAccent(MornyeAccent accent) async {
state = state.copyWith(mornyeAccent: accent);
_editState((current) => current.copyWith(mornyeAccent: accent));
await _saveToStorage();
}
Future<void> setUseSystemFont(bool value) async {
state = state.copyWith(useSystemFont: value);
_editState((current) => current.copyWith(useSystemFont: value));
await _saveToStorage();
}
@@ -99,7 +124,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
double value, {
bool persist = true,
}) async {
state = state.copyWith(mornyeGlassClarity: value);
_editState((current) => current.copyWith(mornyeGlassClarity: value));
if (persist) await _saveToStorage();
}
}
+153
View File
@@ -0,0 +1,153 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/logger.dart';
class _Preferences implements SharedPreferences {
final writes = <Map<String, dynamic>>[];
final _started = <int, Completer<void>>{};
final _released = <int, Completer<bool>>{};
Future<void> whenStarted(int index) =>
(_started[index] ??= Completer<void>()).future;
Completer<bool> release(int index) => _released[index] ??= Completer<bool>();
@override
Future<bool> setString(String key, String value) {
expect(key, 'app_settings');
final index = writes.length;
writes.add(jsonDecode(value) as Map<String, dynamic>);
(_started[index] ??= Completer<void>()).complete();
return release(index).future;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Settings extends SettingsNotifier {
_Settings(_Preferences preferences)
: super(preferences: () async => preferences);
_Settings.withLoader(Future<SharedPreferences> Function() load)
: super(preferences: load);
@override
AppSettings build() => const AppSettings();
}
void main() {
test('queued save callers await the complete coalescing drain', () async {
final preferences = _Preferences();
final container = ProviderContainer(
overrides: [settingsProvider.overrideWith(() => _Settings(preferences))],
);
addTearDown(container.dispose);
final notifier = container.read(settingsProvider.notifier);
final first = notifier.useAppFolderStorage('/output/first');
await preferences.whenStarted(0);
final second = notifier.setNetworkDownloadFolder('remote:first', 'First');
final third = notifier.setNetworkDownloadFolder('remote:latest', 'Latest');
var firstCompleted = false;
var secondCompleted = false;
var thirdCompleted = false;
unawaited(first.then((_) => firstCompleted = true));
unawaited(second.then((_) => secondCompleted = true));
unawaited(third.then((_) => thirdCompleted = true));
await Future<void>.delayed(Duration.zero);
expect(
[firstCompleted, secondCompleted, thirdCompleted],
[false, false, false],
);
preferences.release(0).complete(true);
await preferences.whenStarted(1);
expect(preferences.writes.length, 2);
expect(preferences.writes.last['networkDownloadFolder'], 'remote:latest');
expect(preferences.writes.last['networkDownloadLabel'], 'Latest');
expect(preferences.writes.last['downloadDirectory'], '/output/first');
expect(
[firstCompleted, secondCompleted, thirdCompleted],
[false, false, false],
);
preferences.release(1).complete(true);
await Future.wait([first, second, third]);
expect(
[firstCompleted, secondCompleted, thirdCompleted],
[true, true, true],
);
expect(preferences.writes.length, 2);
});
test(
'failed active save keeps logged-error API and releases its owner',
() async {
final preferences = _Preferences();
final container = ProviderContainer(
overrides: [
settingsProvider.overrideWith(() => _Settings(preferences)),
],
);
addTearDown(container.dispose);
final notifier = container.read(settingsProvider.notifier);
final first = notifier.useAppFolderStorage('/output/first');
await preferences.whenStarted(0);
final queued = notifier.setNetworkDownloadFolder(
'remote:queued',
'Queued',
);
preferences.release(0).completeError(StateError('settings_save_failed'));
await Future.wait([first, queued]);
expect(
LogBuffer().entries.any(
(entry) =>
entry.tag == 'SettingsProvider' &&
entry.message.contains('settings_save_failed'),
),
isTrue,
);
final retry = notifier.useAppFolderStorage('/output/retry');
await preferences.whenStarted(1);
expect(preferences.writes.last['downloadDirectory'], '/output/retry');
expect(preferences.writes.last['networkDownloadFolder'], 'remote:queued');
preferences.release(1).complete(true);
await retry;
},
);
test(
'synchronous preference failure does not retain a finished drain',
() async {
final preferences = _Preferences();
var loads = 0;
final container = ProviderContainer(
overrides: [
settingsProvider.overrideWith(
() => _Settings.withLoader(() {
if (loads++ == 0) throw StateError('settings_preferences_failed');
return Future.value(preferences);
}),
),
],
);
addTearDown(container.dispose);
final notifier = container.read(settingsProvider.notifier);
await notifier.useAppFolderStorage('/output/first');
expect(preferences.writes, isEmpty);
final retry = notifier.useAppFolderStorage('/output/retry');
await preferences.whenStarted(0);
expect(loads, 2);
expect(preferences.writes.single['downloadDirectory'], '/output/retry');
preferences.release(0).complete(true);
await retry;
},
);
}
+131
View File
@@ -0,0 +1,131 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/models/theme_settings.dart';
import 'package:spotiflac_android/providers/theme_provider.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'early theme edits preserve untouched saved settings and await saving',
() async {
SharedPreferences.setMockInitialValues({
kThemeModeKey: 'dark',
kThemeStyleKey: 'mornye',
kSeedColorKey: 0xff123456,
kUseDynamicColorKey: false,
kUseAmoledKey: true,
kUseSystemFontKey: true,
kMornyeAccentKey: 'blue',
kMornyeGlassClarityKey: 0.2,
});
final prefs = await SharedPreferences.getInstance();
final releaseLoad = Completer<SharedPreferences>();
final container = ProviderContainer(
overrides: [
themeProvider.overrideWith(
() => ThemeNotifier(preferences: releaseLoad.future),
),
],
);
addTearDown(container.dispose);
final notifier = container.read(themeProvider.notifier);
// An explicit edit equal to the bootstrap default still owns this field.
final savingMode = notifier.setThemeMode(ThemeMode.system);
final savingAccent = notifier.setMornyeAccent(MornyeAccent.red);
await notifier.setMornyeGlassClarity(0.8, persist: false);
var saved = false;
unawaited(savingAccent.then((_) => saved = true));
await Future<void>.delayed(Duration.zero);
expect(saved, isFalse);
expect(container.read(themeProvider).mornyeGlassClarity, 0.8);
expect(prefs.getString(kThemeModeKey), 'dark');
releaseLoad.complete(prefs);
await Future.wait([savingMode, savingAccent]);
final state = container.read(themeProvider);
expect(state.themeMode, ThemeMode.system);
expect(state.mornyeAccent, MornyeAccent.red);
expect(state.mornyeGlassClarity, 0.8);
expect(state.style, AppThemeStyle.mornye);
expect(state.seedColorValue, 0xff123456);
expect(state.useDynamicColor, isFalse);
expect(state.useAmoled, isTrue);
expect(state.useSystemFont, isTrue);
expect(loadBootstrapThemeSettings(prefs), state);
},
);
test('early preview remains immediate without writing preferences', () async {
SharedPreferences.setMockInitialValues({
kMornyeGlassClarityKey: 0.2,
kMornyeAccentKey: 'blue',
});
final prefs = await SharedPreferences.getInstance();
final releaseLoad = Completer<SharedPreferences>();
final loaded = Completer<void>();
final container = ProviderContainer(
overrides: [
themeProvider.overrideWith(
() => ThemeNotifier(preferences: releaseLoad.future),
),
],
);
addTearDown(container.dispose);
container.listen(themeProvider, (_, next) {
if (next.mornyeAccent == MornyeAccent.blue) loaded.complete();
});
await container
.read(themeProvider.notifier)
.setMornyeGlassClarity(0.8, persist: false);
expect(container.read(themeProvider).mornyeGlassClarity, 0.8);
releaseLoad.complete(prefs);
await loaded.future;
expect(container.read(themeProvider).mornyeGlassClarity, 0.8);
expect(prefs.getDouble(kMornyeGlassClarityKey), 0.2);
});
test(
'failed preference loading keeps early edits without leaking errors',
() async {
final releaseLoad = Completer<SharedPreferences>();
final container = ProviderContainer(
overrides: [
themeProvider.overrideWith(
() => ThemeNotifier(preferences: releaseLoad.future),
),
],
);
addTearDown(container.dispose);
final saving = container.read(themeProvider.notifier).setUseAmoled(true);
releaseLoad.completeError(StateError('theme_load_failed'));
await saving;
expect(container.read(themeProvider).useAmoled, isTrue);
},
);
test(
'disposed theme does not read state or save after delayed loading',
() async {
SharedPreferences.setMockInitialValues({kUseAmoledKey: false});
final prefs = await SharedPreferences.getInstance();
final releaseLoad = Completer<SharedPreferences>();
final container = ProviderContainer(
overrides: [
themeProvider.overrideWith(
() => ThemeNotifier(preferences: releaseLoad.future),
),
],
);
final saving = container.read(themeProvider.notifier).setUseAmoled(true);
container.dispose();
releaseLoad.complete(prefs);
await saving;
expect(prefs.getBool(kUseAmoledKey), isFalse);
},
);
}