import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; /// Web/Chrome [AppDatabase] backed by [SharedPreferences] (IndexedDB under the hood). /// /// SQLCipher / dart:ffi cannot compile for Flutter Web. Persistence is /// **not encrypted** โ€” acceptable for the demo/preview surface /// (`docs/web-upgrade.md` N4-W1). Drift WASM is intentionally not used. /// /// [AppDatabase.memory] stays ephemeral (unit tests / fallbacks). class AppDatabase { AppDatabase({ this.encrypted = false, SharedPreferences? prefs, bool persist = false, }) : _prefs = prefs, _persist = persist && prefs != null; /// Always false on web โ€” there is no SQLCipher path. final bool encrypted; final SharedPreferences? _prefs; final bool _persist; final Map _kv = {}; List _toneSamples = []; final Map _snoozes = {}; static const _kTone = 'ykavu_web_db_tone_samples'; static const _kSnoozes = 'ykavu_web_db_snoozes'; static const _kvPrefix = 'ykavu_web_db_kv_'; factory AppDatabase.memory() => AppDatabase(encrypted: false, persist: false); /// Opens a durable preview DB (survives hard refresh in the same browser profile). static Future open() async { final prefs = await SharedPreferences.getInstance(); final db = AppDatabase(encrypted: false, prefs: prefs, persist: true); await db._hydrate(); return db; } Future _hydrate() async { final prefs = _prefs; if (!_persist || prefs == null) return; try { final toneRaw = prefs.getString(_kTone); if (toneRaw != null && toneRaw.isNotEmpty) { final decoded = jsonDecode(toneRaw); if (decoded is List) { _toneSamples = decoded.map((e) => e.toString()).toList(); } } } catch (_) { _toneSamples = []; await prefs.remove(_kTone); } try { final snoozeRaw = prefs.getString(_kSnoozes); if (snoozeRaw != null && snoozeRaw.isNotEmpty) { final decoded = jsonDecode(snoozeRaw); if (decoded is Map) { _snoozes ..clear() ..addEntries( decoded.entries.map((e) { final id = int.tryParse(e.key.toString()); final ms = e.value is int ? e.value as int : int.tryParse(e.value.toString()); if (id == null || ms == null) return null; return MapEntry(id, DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true)); }).whereType>(), ); } } } catch (_) { _snoozes.clear(); await prefs.remove(_kSnoozes); } for (final key in prefs.getKeys()) { if (!key.startsWith(_kvPrefix)) continue; final logical = key.substring(_kvPrefix.length); final value = prefs.getString(key); if (value != null) _kv[logical] = value; } } Future _persistTone() async { if (!_persist || _prefs == null) return; await _prefs.setString(_kTone, jsonEncode(_toneSamples)); } Future _persistSnoozes() async { if (!_persist || _prefs == null) return; final map = { for (final e in _snoozes.entries) e.key.toString(): e.value.toUtc().millisecondsSinceEpoch, }; await _prefs.setString(_kSnoozes, jsonEncode(map)); } Future _persistKv(String key, String value) async { if (!_persist || _prefs == null) return; await _prefs.setString('$_kvPrefix$key', value); } Future> loadToneSamples() async => List.from(_toneSamples); Future replaceToneSamples(List samples) async { _toneSamples = List.from(samples); await _persistTone(); } Future getKv(String key) async => _kv[key]; Future setKv(String key, String value) async { _kv[key] = value; await _persistKv(key, value); } Future getBoolKv(String key, {bool defaultValue = false}) async { final v = await getKv(key); if (v == null) return defaultValue; return v == '1' || v.toLowerCase() == 'true'; } Future setBoolKv(String key, bool value) async { await setKv(key, value ? '1' : '0'); } /// roadmap.md ยง2.7-F โ€” on-device snooze; web preview persists via SharedPreferences. Future setSnoozedUntil(int conversationId, DateTime until) async { _snoozes[conversationId] = until.toUtc(); await _persistSnoozes(); } Future getSnoozedUntil(int conversationId) async => _snoozes[conversationId]; Future clearSnooze(int conversationId) async { _snoozes.remove(conversationId); await _persistSnoozes(); } Future> loadAllSnoozes() async => Map.from(_snoozes); Future close() async {} }