Matthias Nott
9 days ago 525030c3caccef27a1da44605d28e259dc1cc17c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
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<String, List<Message>> _pendingSaves = {};
  static const _backupChannel =
      MethodChannel('com.mnsoft.pailot/backup');
  /// Initialize the base directory for message storage.
  /// On iOS, the directory is excluded from iCloud / iTunes backup so that
  /// large base64 image attachments do not bloat the user's cloud storage.
  /// Messages can be re-fetched from the server if needed.
  static Future<Directory> _getBaseDir() async {
    if (_baseDir != null) return _baseDir!;
    final appDir = await getApplicationDocumentsDirectory();
    _baseDir = Directory('${appDir.path}/messages');
    final created = !await _baseDir!.exists();
    if (created) {
      await _baseDir!.create(recursive: true);
    }
    // Exclude from iCloud / iTunes backup (best-effort, iOS only).
    if (Platform.isIOS) {
      try {
        await _backupChannel.invokeMethod<void>(
          'excludeFromBackup',
          _baseDir!.path,
        );
      } catch (_) {
        // Non-fatal: if the channel call fails, backup exclusion is skipped.
      }
    }
    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<Message> messages) {
    _pendingSaves[sessionId] = messages;
    _debounceTimer?.cancel();
    _debounceTimer = Timer(const Duration(seconds: 1), _flushAll);
  }
  /// Immediately flush all pending saves.
  static Future<void> flush() async {
    _debounceTimer?.cancel();
    await _flushAll();
  }
  static Future<void> _flushAll() async {
    final entries = Map<String, List<Message>>.from(_pendingSaves);
    _pendingSaves.clear();
    for (final entry in entries.entries) {
      await _writeSession(entry.key, entry.value);
    }
  }
  static Future<void> _writeSession(
      String sessionId, List<Message> 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<List<Message>> 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<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>;
      final allMessages = jsonList
          .map((j) => _messageFromJson(j as Map<String, dynamic>))
          .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<List<Message>> 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<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>;
      return jsonList
          .map((j) => _messageFromJson(j as Map<String, dynamic>))
          .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<String, dynamic> 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<void> 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<void> clearAll() async {
    try {
      final dir = await _getBaseDir();
      if (await dir.exists()) {
        await dir.delete(recursive: true);
        await dir.create(recursive: true);
      }
    } catch (_) {}
  }
}