Files
pyramid/lib/core/settings_prefs.dart
T
Bernd SteckmeisterandClaude Opus 5.5 c5f10901e6 feat: UI-Umbau - weiches Dunkel, Schriftgroessen-Regler, Sprechblasen umschaltbar, Einstellungen als Vollbild
Bernds Entscheidungen 2026-10-07:
- theme.dart: ThemeVariant (Dunkel = neues weiches Dunkelgrau als Standard,
  Schwarz = bisheriges Fast-Schwarz, Hell) mit PyramidPalette; Nebentexte
  kontrastreicher. Alte Einstellung theme_dark wird uebernommen
  (dunkel -> weiches Dunkel). Fest verdrahtete Dunkelfarben im Profil-Popup
  und GIF-Picker folgen jetzt dem Farbmodus.
- appearance.dart: Schriftgroesse = Pyramid-Regler x Systemeinstellung
  (Handy-Standard 115 %, PC 100 %), MessageStyle Automatisch/Sprechblasen/
  Liste, gemeinsame isMobileLayout-Regel.
- message_bubbles.dart: Sprechblasen-Darstellung (eigene rechts in
  Akzentfarbe, Gruppen mit Name + Avatar, Bilder ohne Blase in voller Groesse);
  Listen-Darstellung unveraendert, Weiche in MessageGroup.
- Formatierte Nachrichten (u. a. Antworten) nutzten RichText ohne
  Schriftskalierung -> erschienen kleiner; jetzt Text.rich.
- Einstellungen auf dem Handy als ganze Seite, Schalter unter dem Text.
- UI-Durchlauf um Liste/Hell/Gruppe/Einstellungen erweitert; Tests fuer
  Theme-Varianten, Uebernahme alter Werte, Schriftfaktor, Darstellung.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 17:16:07 +02:00

230 lines
8.2 KiB
Dart

import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.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));
// ── Voice preferences ────────────────────────────────────────────────────────
// Geschrieben von der Settings-Voice-Sektion, gelesen von den Call-Modulen
// (call_signaling, voice_channel). Die historischen voice_*-Keys bleiben
// unverändert, damit bestehende Nutzer-Einstellungen erhalten bleiben.
class VoicePrefs {
VoicePrefs._();
static const _kMic = 'voice_mic_device';
static const _kSpeaker = 'voice_speaker_device';
static const _kCamera = 'voice_camera_device';
static const _kOutputVolume = 'voice_output_volume';
static Future<double> loadOutputVolume() async {
final p = await SharedPreferences.getInstance();
return p.getDouble(_kOutputVolume) ?? 1.0;
}
static Future<void> saveOutputVolume(double v) async {
final p = await SharedPreferences.getInstance();
await p.setDouble(_kOutputVolume, v);
}
/// Gewählte Geräte-IDs; null = kein Pref gesetzt (System-Standard nutzen).
static Future<({String? mic, String? speaker, String? camera})>
loadDevices() async {
final p = await SharedPreferences.getInstance();
return (
mic: p.getString(_kMic),
speaker: p.getString(_kSpeaker),
camera: p.getString(_kCamera),
);
}
static Future<void> saveMic(String? deviceId) => _saveDevice(_kMic, deviceId);
static Future<void> saveSpeaker(String? deviceId) =>
_saveDevice(_kSpeaker, deviceId);
static Future<void> saveCamera(String? deviceId) =>
_saveDevice(_kCamera, deviceId);
static Future<void> _saveDevice(String key, String? deviceId) async {
final p = await SharedPreferences.getInstance();
if (deviceId == null || deviceId.isEmpty) {
await p.remove(key);
} else {
await p.setString(key, deviceId);
}
}
}
// ── Theme persistence helpers ────────────────────────────────────────────────
Future<void> saveThemePrefs({
ThemeVariant? variant,
int? accentIdx,
double? radius,
double? motion,
double? textScale,
bool resetTextScale = false,
MessageStyle? messageStyle,
}) async {
final p = await SharedPreferences.getInstance();
if (variant != null) {
await p.setString('theme_variant', variant.name);
// Altes Feld mitpflegen (falls jemand auf eine ältere Version zurückgeht).
await p.setBool('theme_dark', variant.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);
if (resetTextScale) {
await p.remove('ui_text_scale');
} else if (textScale != null) {
await p.setDouble('ui_text_scale', textScale);
}
if (messageStyle != null) {
await p.setString('chat_message_style', messageStyle.name);
}
}
Future<
({
ThemeVariant variant,
int accentIdx,
double radius,
double motion,
double? textScale,
MessageStyle messageStyle,
})> loadThemePrefs() async {
final p = await SharedPreferences.getInstance();
// Vor 2026-10-07 gab es nur hell/dunkel ('theme_dark'). Wer dunkel hatte,
// bekommt jetzt das neue „Weiche Dunkel“ (Bernds Entscheidung).
final storedVariant = p.getString('theme_variant');
final variant = storedVariant != null
? ThemeVariant.fromName(storedVariant)
: (p.getBool('theme_dark') ?? true)
? ThemeVariant.soft
: ThemeVariant.light;
return (
variant: variant,
accentIdx: p.getInt('theme_accent') ?? 0,
radius: p.getDouble('theme_radius') ?? 12.0,
motion: p.getDouble('theme_motion') ?? 1.0,
textScale: p.getDouble('ui_text_scale'),
messageStyle: MessageStyle.fromName(p.getString('chat_message_style')),
);
}
// ── 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));
}