7b90f20e36
StringSetPref.add/remove machten Read-Modify-Write auf möglicherweise noch ungeladenem State: ein add() direkt nach Erstellung persistierte nur den neuen Eintrag und LÖSCHTE alle bereits gespeicherten (per neuem Test am alten Code bewiesen). Jetzt warten add/remove auf _loaded. BoolPref bekommt einen _userSet-Guard, damit ein spät landender persistierter Wert nie eine schon getätigte Nutzeraktion zurücküberschreibt (Reihenfolge ist plattform- abhängig). In der Praxis kaum treffbar (Provider laden beim App-Start), aber die Fassade wird beim M2-Storage-Umbau Pflicht-Schnittstelle für neue, ggf. früh laufende Aufrufer. 2 neue Regressionstests, alle 24 grün. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
146 lines
5.2 KiB
Dart
146 lines
5.2 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
// ── Generic SharedPreferences-backed bool notifier ──────────────────────────
|
|
|
|
class BoolPref extends StateNotifier<bool> {
|
|
final String _key;
|
|
// Guards against the load race: if set()/toggle() ran before _load's read
|
|
// lands, the stale persisted value must not overwrite the user's choice.
|
|
bool _userSet = false;
|
|
|
|
BoolPref(this._key, bool defaultValue) : super(defaultValue) {
|
|
_load(defaultValue);
|
|
}
|
|
|
|
Future<void> _load(bool def) async {
|
|
final p = await SharedPreferences.getInstance();
|
|
if (mounted && !_userSet) state = p.getBool(_key) ?? def;
|
|
}
|
|
|
|
Future<void> set(bool v) async {
|
|
_userSet = true;
|
|
state = v;
|
|
final p = await SharedPreferences.getInstance();
|
|
await p.setBool(_key, v);
|
|
}
|
|
|
|
void toggle() => set(!state);
|
|
}
|
|
|
|
// ── Generic SharedPreferences-backed String-Set notifier ───────────────────
|
|
|
|
class StringSetPref extends StateNotifier<Set<String>> {
|
|
final String _key;
|
|
late final Future<void> _loaded;
|
|
|
|
StringSetPref(this._key) : super({}) {
|
|
_loaded = _load();
|
|
}
|
|
|
|
Future<void> _load() async {
|
|
final p = await SharedPreferences.getInstance();
|
|
final list = p.getStringList(_key);
|
|
if (mounted && list != null) state = list.toSet();
|
|
}
|
|
|
|
Future<void> add(String v) async {
|
|
// Read-modify-write: ohne abgeschlossenes Laden würde hier der halbleere
|
|
// Start-State zurückgeschrieben und persistierte Einträge gingen verloren.
|
|
await _loaded;
|
|
if (!mounted || state.contains(v)) return;
|
|
state = {...state, v};
|
|
final p = await SharedPreferences.getInstance();
|
|
await p.setStringList(_key, state.toList());
|
|
}
|
|
|
|
Future<void> remove(String v) async {
|
|
await _loaded;
|
|
if (!mounted || !state.contains(v)) return;
|
|
state = {...state}..remove(v);
|
|
final p = await SharedPreferences.getInstance();
|
|
await p.setStringList(_key, state.toList());
|
|
}
|
|
}
|
|
|
|
// ── Notification preferences ─────────────────────────────────────────────────
|
|
|
|
final notifDesktopProvider =
|
|
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('notif_desktop', true));
|
|
|
|
final notifSoundProvider =
|
|
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('notif_sound', true));
|
|
|
|
final notifPreviewProvider =
|
|
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('notif_preview', true));
|
|
|
|
final notifMentionOnlyDmProvider =
|
|
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('notif_mention_dm', false));
|
|
|
|
// ── Privacy preferences ──────────────────────────────────────────────────────
|
|
|
|
final privacyPresenceProvider =
|
|
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_presence', true));
|
|
|
|
final privacyReadReceiptsProvider =
|
|
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_read_receipts', true));
|
|
|
|
final privacyTypingProvider =
|
|
StateNotifierProvider<BoolPref, bool>((ref) => BoolPref('privacy_typing', true));
|
|
|
|
// ── Theme persistence helpers ────────────────────────────────────────────────
|
|
|
|
Future<void> saveThemePrefs({
|
|
bool? isDark,
|
|
int? accentIdx,
|
|
double? radius,
|
|
double? motion,
|
|
}) async {
|
|
final p = await SharedPreferences.getInstance();
|
|
if (isDark != null) await p.setBool('theme_dark', isDark);
|
|
if (accentIdx != null) await p.setInt('theme_accent', accentIdx);
|
|
if (radius != null) await p.setDouble('theme_radius', radius);
|
|
if (motion != null) await p.setDouble('theme_motion', motion);
|
|
}
|
|
|
|
Future<({bool isDark, int accentIdx, double radius, double motion})>
|
|
loadThemePrefs() async {
|
|
final p = await SharedPreferences.getInstance();
|
|
return (
|
|
isDark: p.getBool('theme_dark') ?? true,
|
|
accentIdx: p.getInt('theme_accent') ?? 0,
|
|
radius: p.getDouble('theme_radius') ?? 12.0,
|
|
motion: p.getDouble('theme_motion') ?? 1.0,
|
|
);
|
|
}
|
|
|
|
// ── Server banner persistence ─────────────────────────────────────────────────
|
|
// Stored as a single JSON map under 'server_banners': homeserverUrl → mxcUri
|
|
|
|
const _kServerBannersKey = 'server_banners';
|
|
|
|
Future<Map<String, String>> loadAllServerBanners() async {
|
|
final p = await SharedPreferences.getInstance();
|
|
final raw = p.getString(_kServerBannersKey);
|
|
if (raw == null) return {};
|
|
try {
|
|
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
|
return decoded.map((k, v) => MapEntry(k, v as String));
|
|
} catch (_) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
Future<void> saveServerBanner(String homeserverUrl, String? mxcUri) async {
|
|
final p = await SharedPreferences.getInstance();
|
|
final current = await loadAllServerBanners();
|
|
if (mxcUri == null || mxcUri.isEmpty) {
|
|
current.remove(homeserverUrl);
|
|
} else {
|
|
current[homeserverUrl] = mxcUri;
|
|
}
|
|
await p.setString(_kServerBannersKey, jsonEncode(current));
|
|
}
|