import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:path_provider/path_provider.dart'; import '../models/message.dart'; /// Per-session JSON file persistence with debounced saves. class MessageStore { MessageStore._(); static Directory? _baseDir; static Timer? _debounceTimer; static final Map> _pendingSaves = {}; /// Initialize the base directory for message storage. static Future _getBaseDir() async { if (_baseDir != null) return _baseDir!; final appDir = await getApplicationDocumentsDirectory(); _baseDir = Directory('${appDir.path}/messages'); if (!await _baseDir!.exists()) { await _baseDir!.create(recursive: true); } return _baseDir!; } static String _fileForSession(String sessionId) { // Sanitize session ID for filename final safe = sessionId.replaceAll(RegExp(r'[^\w\-]'), '_'); return 'session_$safe.json'; } /// Save messages for a session with 1-second debounce. static void save(String sessionId, List messages) { _pendingSaves[sessionId] = messages; _debounceTimer?.cancel(); _debounceTimer = Timer(const Duration(seconds: 1), _flushAll); } /// Immediately flush all pending saves. static Future flush() async { _debounceTimer?.cancel(); await _flushAll(); } static Future _flushAll() async { final entries = Map>.from(_pendingSaves); _pendingSaves.clear(); for (final entry in entries.entries) { await _writeSession(entry.key, entry.value); } } static Future _writeSession( String sessionId, List messages) async { try { final dir = await _getBaseDir(); final file = File('${dir.path}/${_fileForSession(sessionId)}'); // Strip heavy fields for persistence final lightMessages = messages.map((m) => m.toJsonLight()).toList(); await file.writeAsString(jsonEncode(lightMessages)); } catch (e) { // Silently fail - message persistence is best-effort } } /// Load messages for a session. /// [limit] controls how many recent messages to return (default: 50). /// [offset] is the number of messages to skip from the end (for pagination). static Future> load( String sessionId, { int limit = 50, int offset = 0, }) async { try { final dir = await _getBaseDir(); final file = File('${dir.path}/${_fileForSession(sessionId)}'); if (!await file.exists()) return []; final jsonStr = await file.readAsString(); final List jsonList = jsonDecode(jsonStr) as List; final allMessages = jsonList .map((j) => _messageFromJson(j as Map)) .where((m) => !m.isEmptyVoice && !m.isEmptyText) .toList(); // Paginate from the end (newest messages first in storage) if (offset >= allMessages.length) return []; final end = allMessages.length - offset; final start = (end - limit).clamp(0, end); return allMessages.sublist(start, end); } catch (e) { return []; } } /// Load all messages for a session (no pagination). static Future> loadAll(String sessionId) async { try { final dir = await _getBaseDir(); final file = File('${dir.path}/${_fileForSession(sessionId)}'); if (!await file.exists()) return []; final jsonStr = await file.readAsString(); final List jsonList = jsonDecode(jsonStr) as List; return jsonList .map((j) => _messageFromJson(j as Map)) .where((m) => !m.isEmptyVoice && !m.isEmptyText) .toList(); } catch (e) { return []; } } /// Deserialize a message from JSON, applying migration rules: /// - Voice messages without audioUri are downgraded to text (transcript only). /// This handles messages saved before a restart, where the temp audio file /// is no longer available. The transcript (content) is preserved. static Message _messageFromJson(Map json) { final raw = Message.fromJson(json); if (raw.type == MessageType.voice && (raw.audioUri == null || raw.audioUri!.isEmpty)) { // Downgrade to text so the bubble shows the transcript instead of a // broken play button. return Message( id: raw.id, role: raw.role, type: MessageType.text, content: raw.content, timestamp: raw.timestamp, status: raw.status, duration: raw.duration, ); } return raw; } /// Delete stored messages for a session. static Future delete(String sessionId) async { try { final dir = await _getBaseDir(); final file = File('${dir.path}/${_fileForSession(sessionId)}'); if (await file.exists()) { await file.delete(); } } catch (_) {} } /// Clear all stored messages. static Future clearAll() async { try { final dir = await _getBaseDir(); if (await dir.exists()) { await dir.delete(recursive: true); await dir.create(recursive: true); } } catch (_) {} } }