diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 646400d..a4d9039 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -16,8 +16,6 @@
-
-
This app uses Bluetooth to communicate with MeshCore devices.
NSBluetoothPeripheralUsageDescription
This app uses Bluetooth to communicate with MeshCore devices.
- NSMicrophoneUsageDescription
- This app needs microphone access to record voice messages.
diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart
index ba88ee6..f65834f 100644
--- a/lib/connector/meshcore_connector.dart
+++ b/lib/connector/meshcore_connector.dart
@@ -21,7 +21,6 @@ import '../services/path_history_service.dart';
import '../services/app_settings_service.dart';
import '../services/background_service.dart';
import '../services/notification_service.dart';
-import '../services/voice_message_service.dart';
import '../storage/channel_message_store.dart';
import '../storage/channel_order_store.dart';
import '../storage/channel_settings_store.dart';
@@ -46,6 +45,9 @@ enum MeshCoreConnectionState {
}
class MeshCoreConnector extends ChangeNotifier {
+ // Message windowing to limit memory usage
+ static const int _messageWindowSize = 200;
+
MeshCoreConnectionState _state = MeshCoreConnectionState.disconnected;
BluetoothDevice? _device;
BluetoothCharacteristic? _rxCharacteristic;
@@ -113,9 +115,6 @@ class MeshCoreConnector extends ChangeNotifier {
final ContactSettingsStore _contactSettingsStore = ContactSettingsStore();
final ContactStore _contactStore = ContactStore();
final UnreadStore _unreadStore = UnreadStore();
- final VoiceMessageService _voiceMessageService = VoiceMessageService.instance;
- final Map _voiceAssemblies = {};
- _VoiceSendSession? _voiceSendSession;
final Map _channelSmazEnabled = {};
final Map _contactSmazEnabled = {};
final Set _knownContactKeys = {};
@@ -124,23 +123,13 @@ class MeshCoreConnector extends ChangeNotifier {
String? _activeContactKey;
int? _activeChannelIndex;
List _channelOrder = [];
- int _lastVoiceTimestampSeconds = 0;
// Getters
MeshCoreConnectionState get state => _state;
BluetoothDevice? get device => _device;
String? get deviceId => _deviceId;
String get deviceIdLabel => _deviceId ?? 'Unknown';
- bool get isVoiceSending => _voiceSendSession != null;
- void cancelVoiceSend() {
- final session = _voiceSendSession;
- if (session == null) return;
- session.cancel();
- _voiceSendSession = null;
- _updateVoiceMessageStatus(session.messageId, MessageStatus.failed);
- notifyListeners();
- }
String get deviceDisplayName {
if (_selfName != null && _selfName!.isNotEmpty) {
return _selfName!;
@@ -227,12 +216,6 @@ class MeshCoreConnector extends ChangeNotifier {
if (messages == null) return;
final removed = messages.remove(message);
if (!removed) return;
- if (message.isVoice && message.voicePath != null) {
- final file = File(message.voicePath!);
- if (await file.exists()) {
- await file.delete();
- }
- }
await _messageStore.saveMessages(contactKeyHex, messages);
notifyListeners();
}
@@ -241,13 +224,43 @@ class MeshCoreConnector extends ChangeNotifier {
if (_loadedConversationKeys.contains(contactKeyHex)) return;
_loadedConversationKeys.add(contactKeyHex);
- final messages = await _messageStore.loadMessages(contactKeyHex);
- if (messages.isNotEmpty) {
- _conversations[contactKeyHex] = messages;
+ final allMessages = await _messageStore.loadMessages(contactKeyHex);
+ if (allMessages.isNotEmpty) {
+ // Keep only the most recent N messages in memory to bound memory usage
+ final windowedMessages = allMessages.length > _messageWindowSize
+ ? allMessages.sublist(allMessages.length - _messageWindowSize)
+ : allMessages;
+
+ _conversations[contactKeyHex] = windowedMessages;
notifyListeners();
}
}
+ /// Load older messages for a contact (pagination)
+ Future> loadOlderMessages(
+ String contactKeyHex, {
+ int count = 50,
+ }) async {
+ final allMessages = await _messageStore.loadMessages(contactKeyHex);
+ final currentMessages = _conversations[contactKeyHex] ?? [];
+
+ if (allMessages.length <= currentMessages.length) {
+ return []; // No more messages to load
+ }
+
+ final currentOffset = allMessages.length - currentMessages.length;
+ final fetchCount = count.clamp(0, currentOffset);
+ final startIndex = currentOffset - fetchCount;
+
+ final olderMessages = allMessages.sublist(startIndex, currentOffset);
+
+ // Prepend to current conversation
+ _conversations[contactKeyHex] = [...olderMessages, ...currentMessages];
+ notifyListeners();
+
+ return olderMessages;
+ }
+
List getChannelMessages(Channel channel) {
return _channelMessages[channel.index] ?? [];
}
@@ -376,13 +389,43 @@ class MeshCoreConnector extends ChangeNotifier {
/// Load persisted channel messages for a specific channel
Future _loadChannelMessages(int channelIndex) async {
- final messages = await _channelMessageStore.loadChannelMessages(channelIndex);
- if (messages.isNotEmpty) {
- _channelMessages[channelIndex] = messages;
+ final allMessages = await _channelMessageStore.loadChannelMessages(channelIndex);
+ if (allMessages.isNotEmpty) {
+ // Keep only the most recent N messages in memory to bound memory usage
+ final windowedMessages = allMessages.length > _messageWindowSize
+ ? allMessages.sublist(allMessages.length - _messageWindowSize)
+ : allMessages;
+
+ _channelMessages[channelIndex] = windowedMessages;
notifyListeners();
}
}
+ /// Load older channel messages (pagination)
+ Future> loadOlderChannelMessages(
+ int channelIndex, {
+ int count = 50,
+ }) async {
+ final allMessages = await _channelMessageStore.loadChannelMessages(channelIndex);
+ final currentMessages = _channelMessages[channelIndex] ?? [];
+
+ if (allMessages.length <= currentMessages.length) {
+ return []; // No more messages to load
+ }
+
+ final currentOffset = allMessages.length - currentMessages.length;
+ final fetchCount = count.clamp(0, currentOffset);
+ final startIndex = currentOffset - fetchCount;
+
+ final olderMessages = allMessages.sublist(startIndex, currentOffset);
+
+ // Prepend to current conversation
+ _channelMessages[channelIndex] = [...olderMessages, ...currentMessages];
+ notifyListeners();
+
+ return olderMessages;
+ }
+
/// Load all persisted channel messages on startup
Future loadAllChannelMessages({int? maxChannels}) async {
final channelCount = maxChannels ?? _maxChannels;
@@ -876,16 +919,10 @@ class MeshCoreConnector extends ChangeNotifier {
int? customPathLen,
}) async {
if (!isConnected || text.isEmpty) return;
- if (_voiceSendSession != null) {
- debugPrint('Voice send in progress, skipping text send.');
- return;
- }
// If custom path is provided, temporarily update the contact's path
if (customPath != null && customPathLen != null && customPathLen >= 0) {
await setContactPath(contact, customPath, customPathLen);
- // Small delay to ensure the path update is processed
- await Future.delayed(const Duration(milliseconds: 50));
}
PathSelection? autoSelection;
@@ -901,7 +938,6 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List.fromList(autoSelection.pathBytes),
autoSelection.pathBytes.length,
);
- await Future.delayed(const Duration(milliseconds: 50));
}
}
}
@@ -943,143 +979,11 @@ class MeshCoreConnector extends ChangeNotifier {
}
}
- Future sendVoiceMessage({
- required Contact contact,
- required Uint8List codec2Bytes,
- required String voicePath,
- required int durationMs,
- int? timestampSeconds,
- }) async {
- if (!isConnected || codec2Bytes.isEmpty) return;
- if (_voiceSendSession != null) return;
-
- final voiceTimestampSeconds = timestampSeconds ?? _nextVoiceTimestampSeconds();
- final chunks = _voiceMessageService.buildVoiceChunks(codec2Bytes);
- if (chunks.isEmpty) return;
-
- final messageId = const Uuid().v4();
- final message = Message(
- senderKey: contact.publicKey,
- text: 'Voice message',
- timestamp: DateTime.fromMillisecondsSinceEpoch(voiceTimestampSeconds * 1000),
- isOutgoing: true,
- isCli: false,
- status: MessageStatus.pending,
- messageId: messageId,
- forceFlood: false,
- isVoice: true,
- voicePath: voicePath,
- voiceDurationMs: durationMs,
- voiceCodec: VoiceMessageService.codecName,
- );
-
- _addMessage(contact.publicKeyHex, message);
- notifyListeners();
-
- final session = _VoiceSendSession(
- contact: contact,
- messageId: messageId,
- chunks: chunks,
- timestampSeconds: voiceTimestampSeconds,
- );
- _voiceSendSession = session;
- notifyListeners();
-
- unawaited(_sendVoiceChunks(session));
- }
-
- int reserveVoiceTimestampSeconds() {
- return _nextVoiceTimestampSeconds();
- }
-
- int _nextVoiceTimestampSeconds() {
- final nowSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
- if (nowSeconds <= _lastVoiceTimestampSeconds) {
- _lastVoiceTimestampSeconds += 1;
- } else {
- _lastVoiceTimestampSeconds = nowSeconds;
- }
- return _lastVoiceTimestampSeconds;
- }
-
- Future _sendVoiceChunks(_VoiceSendSession session) async {
- for (var i = 0; i < session.chunks.length; i++) {
- if (session.isCancelled) return;
- final ok = await _sendVoiceChunk(session, i);
- if (!ok) {
- if (session.isCancelled) return;
- _updateVoiceMessageStatus(session.messageId, MessageStatus.failed);
- _voiceSendSession = null;
- notifyListeners();
- return;
- }
- }
- if (session.isCancelled) return;
- _updateVoiceMessageStatus(session.messageId, MessageStatus.delivered);
- _voiceSendSession = null;
- notifyListeners();
- }
-
- Future _sendVoiceChunk(_VoiceSendSession session, int index) async {
- if (session.isCancelled) return false;
- session.beginChunk(index);
- await sendFrame(
- buildSendTextMsgFrame(
- session.contact.publicKey,
- session.chunks[index],
- forceFlood: false,
- attempt: 0,
- timestampSeconds: session.timestampSeconds,
- ),
- );
-
- try {
- await session.sentCompleter!.future.timeout(const Duration(seconds: 10));
- } catch (_) {
- return false;
- }
-
- final timeoutMs = session.expectedTimeoutMs;
- final confirmTimeout = timeoutMs != null && timeoutMs > 0
- ? Duration(milliseconds: timeoutMs)
- : const Duration(seconds: 30);
-
- try {
- await session.confirmCompleter!.future.timeout(confirmTimeout);
- } catch (_) {
- return false;
- }
- return true;
- }
-
- void _updateVoiceMessageStatus(String messageId, MessageStatus status) {
- for (final entry in _conversations.entries) {
- final messages = entry.value;
- final index = messages.indexWhere((m) => m.messageId == messageId);
- if (index == -1) continue;
- messages[index] = messages[index].copyWith(status: status);
- _messageStore.saveMessages(entry.key, messages);
- break;
- }
- }
-
- void _handleVoiceMessageSent(Uint8List ackHash, int timeoutMs, {required bool isFlood}) {
- final session = _voiceSendSession;
- if (session == null) return;
- session.handleSent(ackHash, timeoutMs);
- if (isFlood) {
- // Flooded sends may not emit send-confirmed; unblock voice chunking.
- session.handleConfirmed(ackHash);
- }
- }
-
- void _handleVoiceSendConfirmed(Uint8List ackHash) {
- final session = _voiceSendSession;
- if (session == null) return;
- session.handleConfirmed(ackHash);
- }
-
- Future setContactPath(Contact contact, Uint8List customPath, int pathLen) async {
+ Future setContactPath(
+ Contact contact,
+ Uint8List customPath,
+ int pathLen,
+ ) async {
if (!isConnected) return;
await sendFrame(buildUpdateContactPathFrame(
@@ -1091,12 +995,8 @@ class MeshCoreConnector extends ChangeNotifier {
));
}
- Future sendChannelMessage(Channel channel, String text) async {
+ Future sendChannelMessage(Channel channel, String text) async{
if (!isConnected || text.isEmpty) return;
- if (_voiceSendSession != null) {
- debugPrint('Voice send in progress, skipping channel send.');
- return;
- }
final message = ChannelMessage.outgoing(text, _selfName ?? 'Me', channel.index);
_addChannelMessage(channel.index, message);
@@ -1120,9 +1020,9 @@ class MeshCoreConnector extends ChangeNotifier {
_conversations.remove(contact.publicKeyHex);
_loadedConversationKeys.remove(contact.publicKeyHex);
_contactLastReadMs.remove(contact.publicKeyHex);
- unawaited(_unreadStore.saveContactLastRead(
+ _unreadStore.saveContactLastRead(
Map.from(_contactLastReadMs),
- ));
+ );
_messageStore.clearMessages(contact.publicKeyHex);
notifyListeners();
}
@@ -1250,9 +1150,9 @@ class MeshCoreConnector extends ChangeNotifier {
// Delete by setting empty name and zero PSK
await sendFrame(buildSetChannelFrame(index, '', Uint8List(16)));
_channelLastReadMs.remove(index);
- unawaited(_unreadStore.saveChannelLastRead(
+ _unreadStore.saveChannelLastRead(
Map.from(_channelLastReadMs),
- ));
+ );
// Refresh channels after deleting
await getChannels();
}
@@ -1505,9 +1405,9 @@ class MeshCoreConnector extends ChangeNotifier {
if (contact != null) {
if (contact.type == advTypeRepeater) {
_contactLastReadMs.remove(contact.publicKeyHex);
- unawaited(_unreadStore.saveContactLastRead(
+ _unreadStore.saveContactLastRead(
Map.from(_contactLastReadMs),
- ));
+ );
}
// Check if this is a new contact
final isNewContact = !_knownContactKeys.contains(contact.publicKeyHex);
@@ -1656,9 +1556,6 @@ class MeshCoreConnector extends ChangeNotifier {
pathBytes: contact.pathLength < 0 ? Uint8List(0) : contact.path,
);
}
- if (_tryHandleVoiceChunk(message)) {
- return;
- }
if (contact != null) {
_updateContactLastMessageAt(contact.publicKeyHex, message.timestamp);
}
@@ -1784,124 +1681,9 @@ class MeshCoreConnector extends ChangeNotifier {
return text;
}
- bool _tryHandleVoiceChunk(Message message) {
- if (message.isOutgoing || message.isCli) return false;
- final chunk = _voiceMessageService.tryParseChunk(message.text);
- if (chunk == null) return false;
- _updateContactLastMessageAt(
- message.senderKeyHex,
- message.timestamp,
- notify: true,
- );
- final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
- final key = _voiceAssemblyKey(message.senderKeyHex, timestampSeconds);
- final assembly = _voiceAssemblies.putIfAbsent(
- key,
- () => _VoiceAssembly(
- senderKey: message.senderKey,
- senderKeyHex: message.senderKeyHex,
- timestampSeconds: timestampSeconds,
- totalChunks: chunk.count,
- ),
- );
- if (assembly.totalChunks != chunk.count) {
- _voiceAssemblies.remove(key);
- return true;
- }
- assembly.addChunk(chunk);
- if (assembly.isComplete) {
- _voiceAssemblies.remove(key);
- unawaited(_finalizeVoiceAssembly(assembly, message));
- }
- _cleanupVoiceAssemblies();
- if (_isSyncingQueuedMessages) {
- _handleQueuedMessageReceived();
- }
- return true;
- }
- String _voiceAssemblyKey(String senderKeyHex, int timestampSeconds) {
- return '$senderKeyHex:$timestampSeconds';
- }
- Future _finalizeVoiceAssembly(_VoiceAssembly assembly, Message chunkMessage) async {
- final codec2Bytes = assembly.assemble();
- if (codec2Bytes.isEmpty) return;
- final existing = _conversations[assembly.senderKeyHex];
- if (existing != null) {
- final alreadyAdded = existing.any((message) {
- if (!message.isVoice) return false;
- final tsSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
- return tsSeconds == assembly.timestampSeconds;
- });
- if (alreadyAdded) return;
- }
- String? filePath;
- int durationMs = 0;
- try {
- final pcmBytes = _voiceMessageService.decodeCodec2ToPcm(codec2Bytes);
- durationMs = _voiceMessageService.durationMsForCodec2Bytes(codec2Bytes);
- final fileName = _voiceMessageService.buildVoiceFileName(
- senderKeyHex: assembly.senderKeyHex,
- timestampSeconds: assembly.timestampSeconds,
- );
- filePath = await _voiceMessageService.writeWavFile(
- pcmBytes: pcmBytes,
- fileName: fileName,
- );
- } catch (e) {
- debugPrint('Voice decode failed: $e');
- return;
- }
- final message = Message(
- senderKey: assembly.senderKey,
- text: 'Voice message',
- timestamp: DateTime.fromMillisecondsSinceEpoch(assembly.timestampSeconds * 1000),
- isOutgoing: false,
- isCli: false,
- status: MessageStatus.delivered,
- isVoice: true,
- voicePath: filePath,
- voiceDurationMs: durationMs,
- voiceCodec: VoiceMessageService.codecName,
- pathLength: chunkMessage.pathLength,
- pathBytes: chunkMessage.pathBytes,
- );
-
- _addMessage(assembly.senderKeyHex, message);
- _maybeMarkActiveContactRead(message);
- notifyListeners();
-
- if (_appSettingsService != null) {
- final settings = _appSettingsService!.settings;
- if (settings.notificationsEnabled && settings.notifyOnNewMessage) {
- final contact = _contacts.cast().firstWhere(
- (c) => c != null && c.publicKeyHex == assembly.senderKeyHex,
- orElse: () => null,
- );
- _notificationService.showMessageNotification(
- contactName: contact?.name ?? 'Unknown',
- message: 'Voice message',
- contactId: assembly.senderKeyHex,
- );
- }
- }
- }
-
- void _cleanupVoiceAssemblies() {
- if (_voiceAssemblies.isEmpty) return;
- final cutoff = DateTime.now().subtract(const Duration(minutes: 3));
- final expiredKeys = [];
- for (final entry in _voiceAssemblies.entries) {
- if (entry.value.startedAt.isBefore(cutoff)) {
- expiredKeys.add(entry.key);
- }
- }
- for (final key in expiredKeys) {
- _voiceAssemblies.remove(key);
- }
- }
String _channelDisplayName(int channelIndex) {
for (final channel in _channels) {
@@ -2030,7 +1812,6 @@ class MeshCoreConnector extends ChangeNotifier {
if (_retryService != null) {
_retryService!.updateMessageFromSent(ackHash, timeoutMs);
}
- _handleVoiceMessageSent(ackHash, timeoutMs, isFlood: isFlood);
} else {
// Fallback to old behavior
for (var messages in _conversations.values) {
@@ -2059,7 +1840,6 @@ class MeshCoreConnector extends ChangeNotifier {
if (_retryService != null) {
_retryService!.handleAckReceived(ackHash, tripTimeMs);
}
- _handleVoiceSendConfirmed(ackHash);
} else {
// Fallback to old behavior
for (var messages in _conversations.values) {
@@ -2138,9 +1918,9 @@ class MeshCoreConnector extends ChangeNotifier {
final existing = _contactLastReadMs[contactKeyHex] ?? 0;
if (timestampMs <= existing) return;
_contactLastReadMs[contactKeyHex] = timestampMs;
- unawaited(_unreadStore.saveContactLastRead(
+ _unreadStore.saveContactLastRead(
Map.from(_contactLastReadMs),
- ));
+ );
if (notify) {
notifyListeners();
}
@@ -2150,9 +1930,9 @@ class MeshCoreConnector extends ChangeNotifier {
final existing = _channelLastReadMs[channelIndex] ?? 0;
if (timestampMs <= existing) return;
_channelLastReadMs[channelIndex] = timestampMs;
- unawaited(_unreadStore.saveChannelLastRead(
+ _unreadStore.saveChannelLastRead(
Map.from(_channelLastReadMs),
- ));
+ );
if (notify) {
notifyListeners();
}
@@ -2306,16 +2086,48 @@ class MeshCoreConnector extends ChangeNotifier {
bool _addChannelMessage(int channelIndex, ChannelMessage message) {
_channelMessages.putIfAbsent(channelIndex, () => []);
final messages = _channelMessages[channelIndex]!;
- final existingIndex = _findChannelRepeatIndex(messages, message);
+
+ // Parse reply info from message text
+ final replyInfo = ChannelMessage.parseReplyMention(message.text);
+ ChannelMessage processedMessage = message;
+
+ if (replyInfo != null) {
+ // Find original message by sender name (most recent match)
+ final originalMessage = _findMessageBySender(messages, replyInfo.mentionedNode);
+
+ if (originalMessage != null) {
+ // Create new message with reply metadata
+ processedMessage = ChannelMessage(
+ senderKey: message.senderKey,
+ senderName: message.senderName,
+ text: replyInfo.actualMessage,
+ timestamp: message.timestamp,
+ isOutgoing: message.isOutgoing,
+ status: message.status,
+ repeats: message.repeats,
+ repeatCount: message.repeatCount,
+ pathLength: message.pathLength,
+ pathBytes: message.pathBytes,
+ pathVariants: message.pathVariants,
+ channelIndex: message.channelIndex,
+ messageId: message.messageId,
+ replyToMessageId: originalMessage.messageId,
+ replyToSenderName: originalMessage.senderName,
+ replyToText: originalMessage.text,
+ );
+ }
+ }
+
+ final existingIndex = _findChannelRepeatIndex(messages, processedMessage);
var isNew = true;
if (existingIndex >= 0) {
isNew = false;
final existing = messages[existingIndex];
- final mergedPathBytes = _selectPreferredPathBytes(existing.pathBytes, message.pathBytes);
- final mergedPathVariants = _mergePathVariants(existing.pathVariants, message.pathVariants);
+ final mergedPathBytes = _selectPreferredPathBytes(existing.pathBytes, processedMessage.pathBytes);
+ final mergedPathVariants = _mergePathVariants(existing.pathVariants, processedMessage.pathVariants);
final mergedPathLength = _mergePathLength(
existing.pathLength,
- message.pathLength,
+ processedMessage.pathLength,
mergedPathBytes.length,
);
messages[existingIndex] = existing.copyWith(
@@ -2325,7 +2137,7 @@ class MeshCoreConnector extends ChangeNotifier {
pathVariants: mergedPathVariants,
);
} else {
- messages.add(message);
+ messages.add(processedMessage);
}
// Save to persistent storage
@@ -2336,6 +2148,16 @@ class MeshCoreConnector extends ChangeNotifier {
return isNew;
}
+ ChannelMessage? _findMessageBySender(List messages, String mentionedNode) {
+ // Search backwards for most recent message from this sender
+ for (int i = messages.length - 1; i >= 0; i--) {
+ if (messages[i].senderName == mentionedNode && !messages[i].isOutgoing) {
+ return messages[i];
+ }
+ }
+ return null;
+ }
+
int _findChannelRepeatIndex(List messages, ChannelMessage incoming) {
for (int i = messages.length - 1; i >= 0; i--) {
final existing = messages[i];
@@ -2456,8 +2278,6 @@ class MeshCoreConnector extends ChangeNotifier {
_maxChannels = _defaultMaxChannels;
_isSyncingQueuedMessages = false;
_queuedMessageSyncInFlight = false;
- _voiceAssemblies.clear();
- _voiceSendSession = null;
_setState(MeshCoreConnectionState.disconnected);
_scheduleReconnect();
@@ -2477,6 +2297,10 @@ class MeshCoreConnector extends ChangeNotifier {
_notifySubscription?.cancel();
_reconnectTimer?.cancel();
_receivedFramesController.close();
+
+ // Flush pending unread writes before disposal
+ _unreadStore.flush();
+
super.dispose();
}
}
@@ -2525,92 +2349,4 @@ class _ParsedText {
});
}
-class _VoiceAssembly {
- _VoiceAssembly({
- required this.senderKey,
- required this.senderKeyHex,
- required this.timestampSeconds,
- required this.totalChunks,
- });
- final Uint8List senderKey;
- final String senderKeyHex;
- final int timestampSeconds;
- final int totalChunks;
- final DateTime startedAt = DateTime.now();
- final Map _chunks = {};
-
- bool get isComplete => _chunks.length == totalChunks;
-
- void addChunk(VoiceChunk chunk) {
- _chunks.putIfAbsent(chunk.index, () => chunk.bytes);
- }
-
- Uint8List assemble() {
- if (!isComplete) return Uint8List(0);
- final builder = BytesBuilder(copy: false);
- for (var i = 0; i < totalChunks; i++) {
- final part = _chunks[i];
- if (part == null) return Uint8List(0);
- builder.add(part);
- }
- return builder.takeBytes();
- }
-}
-
-class _VoiceSendSession {
- _VoiceSendSession({
- required this.contact,
- required this.messageId,
- required this.chunks,
- required this.timestampSeconds,
- });
-
- final Contact contact;
- final String messageId;
- final List chunks;
- final int timestampSeconds;
-
- int currentChunkIndex = -1;
- Uint8List? expectedAckHash;
- int? expectedTimeoutMs;
- Completer? sentCompleter;
- Completer? confirmCompleter;
- bool _cancelled = false;
-
- bool get isCancelled => _cancelled;
-
- void beginChunk(int index) {
- currentChunkIndex = index;
- expectedAckHash = null;
- expectedTimeoutMs = null;
- sentCompleter = Completer();
- confirmCompleter = Completer();
- }
-
- void handleSent(Uint8List ackHash, int timeoutMs) {
- if (sentCompleter == null || sentCompleter!.isCompleted) return;
- expectedAckHash = Uint8List.fromList(ackHash);
- expectedTimeoutMs = timeoutMs > 0 ? timeoutMs : null;
- sentCompleter!.complete();
- }
-
- void handleConfirmed(Uint8List ackHash) {
- if (confirmCompleter == null || confirmCompleter!.isCompleted) return;
- final expected = expectedAckHash;
- if (expected == null) return;
- if (!listEquals(expected, ackHash)) return;
- confirmCompleter!.complete();
- }
-
- void cancel() {
- if (_cancelled) return;
- _cancelled = true;
- if (sentCompleter != null && !sentCompleter!.isCompleted) {
- sentCompleter!.completeError(StateError('cancelled'));
- }
- if (confirmCompleter != null && !confirmCompleter!.isCompleted) {
- confirmCompleter!.completeError(StateError('cancelled'));
- }
- }
-}
diff --git a/lib/main.dart b/lib/main.dart
index f1c7c6d..541b1f6 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
-import 'package:media_kit_fork/media_kit_fork.dart';
import 'connector/meshcore_connector.dart';
import 'screens/scanner_screen.dart';
@@ -12,10 +11,13 @@ import 'services/notification_service.dart';
import 'services/ble_debug_log_service.dart';
import 'services/background_service.dart';
import 'services/map_tile_cache_service.dart';
+import 'storage/prefs_manager.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
- MediaKit.ensureInitialized();
+
+ // Initialize SharedPreferences cache
+ await PrefsManager.initialize();
// Initialize services
final storage = StorageService();
diff --git a/lib/models/channel_message.dart b/lib/models/channel_message.dart
index 14d4bf0..5a56d33 100644
--- a/lib/models/channel_message.dart
+++ b/lib/models/channel_message.dart
@@ -34,6 +34,10 @@ class ChannelMessage {
final Uint8List pathBytes;
final List pathVariants;
final int? channelIndex;
+ final String messageId;
+ final String? replyToMessageId;
+ final String? replyToSenderName;
+ final String? replyToText;
ChannelMessage({
this.senderKey,
@@ -48,7 +52,12 @@ class ChannelMessage {
Uint8List? pathBytes,
List? pathVariants,
this.channelIndex,
- }) : pathBytes = pathBytes ?? Uint8List(0),
+ String? messageId,
+ this.replyToMessageId,
+ this.replyToSenderName,
+ this.replyToText,
+ }) : messageId = messageId ?? '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}',
+ pathBytes = pathBytes ?? Uint8List(0),
pathVariants = _mergePathVariants(
pathBytes ?? Uint8List(0),
pathVariants,
@@ -63,6 +72,9 @@ class ChannelMessage {
int? pathLength,
Uint8List? pathBytes,
List? pathVariants,
+ String? replyToMessageId,
+ String? replyToSenderName,
+ String? replyToText,
}) {
return ChannelMessage(
senderKey: senderKey,
@@ -77,6 +89,10 @@ class ChannelMessage {
pathBytes: pathBytes ?? this.pathBytes,
pathVariants: pathVariants ?? this.pathVariants,
channelIndex: channelIndex,
+ messageId: messageId,
+ replyToMessageId: replyToMessageId ?? this.replyToMessageId,
+ replyToSenderName: replyToSenderName ?? this.replyToSenderName,
+ replyToText: replyToText ?? this.replyToText,
);
}
@@ -207,4 +223,24 @@ class ChannelMessage {
}
return true;
}
+
+ static ReplyInfo? parseReplyMention(String text) {
+ final regex = RegExp(r'^@\[([^\]]+)\]\s+(.+)$', dotAll: true);
+ final match = regex.firstMatch(text);
+ if (match == null) return null;
+ return ReplyInfo(
+ mentionedNode: match.group(1)!,
+ actualMessage: match.group(2)!,
+ );
+ }
+}
+
+class ReplyInfo {
+ final String mentionedNode;
+ final String actualMessage;
+
+ ReplyInfo({
+ required this.mentionedNode,
+ required this.actualMessage,
+ });
}
diff --git a/lib/models/message.dart b/lib/models/message.dart
index 3ed7354..4c347d4 100644
--- a/lib/models/message.dart
+++ b/lib/models/message.dart
@@ -10,10 +10,6 @@ class Message {
final bool isOutgoing;
final bool isCli;
final MessageStatus status;
- final bool isVoice;
- final String? voicePath;
- final int? voiceDurationMs;
- final String? voiceCodec;
// NEW: Retry logic fields
final String? messageId;
@@ -34,10 +30,6 @@ class Message {
required this.isOutgoing,
this.isCli = false,
this.status = MessageStatus.pending,
- this.isVoice = false,
- this.voicePath,
- this.voiceDurationMs,
- this.voiceCodec,
this.messageId,
this.retryCount = 0,
this.estimatedTimeoutMs,
@@ -63,10 +55,6 @@ class Message {
int? pathLength,
Uint8List? pathBytes,
bool? isCli,
- bool? isVoice,
- String? voicePath,
- int? voiceDurationMs,
- String? voiceCodec,
}) {
return Message(
senderKey: senderKey,
@@ -75,10 +63,6 @@ class Message {
isOutgoing: isOutgoing,
isCli: isCli ?? this.isCli,
status: status ?? this.status,
- isVoice: isVoice ?? this.isVoice,
- voicePath: voicePath ?? this.voicePath,
- voiceDurationMs: voiceDurationMs ?? this.voiceDurationMs,
- voiceCodec: voiceCodec ?? this.voiceCodec,
messageId: messageId,
retryCount: retryCount ?? this.retryCount,
estimatedTimeoutMs: estimatedTimeoutMs ?? this.estimatedTimeoutMs,
@@ -117,7 +101,6 @@ class Message {
isOutgoing: false,
isCli: false,
status: MessageStatus.delivered,
- isVoice: false,
pathBytes: Uint8List(0),
);
}
@@ -135,7 +118,6 @@ class Message {
isOutgoing: true,
isCli: false,
status: MessageStatus.pending,
- isVoice: false,
pathLength: pathLength,
pathBytes: pathBytes,
);
diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart
index 6fb8358..6bf4485 100644
--- a/lib/screens/channel_chat_screen.dart
+++ b/lib/screens/channel_chat_screen.dart
@@ -32,6 +32,8 @@ class ChannelChatScreen extends StatefulWidget {
class _ChannelChatScreenState extends State {
final TextEditingController _textController = TextEditingController();
final ScrollController _scrollController = ScrollController();
+ ChannelMessage? _replyingToMessage;
+ final Map _messageKeys = {};
@override
void initState() {
@@ -60,6 +62,41 @@ class _ChannelChatScreenState extends State {
}
}
+ void _setReplyingTo(ChannelMessage message) {
+ setState(() {
+ _replyingToMessage = message;
+ });
+ }
+
+ void _cancelReply() {
+ setState(() {
+ _replyingToMessage = null;
+ });
+ }
+
+ Future _scrollToMessage(String messageId) async {
+ final key = _messageKeys[messageId];
+ if (key == null) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ const SnackBar(
+ content: Text('Original message not found'),
+ duration: Duration(seconds: 2),
+ ),
+ );
+ return;
+ }
+
+ final targetContext = key.currentContext;
+ if (targetContext == null) return;
+
+ await Scrollable.ensureVisible(
+ targetContext,
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeInOut,
+ alignment: 0.3,
+ );
+ }
+
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -149,12 +186,16 @@ class _ChannelChatScreenState extends State {
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(8),
- cacheExtent: 0,
- addAutomaticKeepAlives: false,
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
- return _buildMessageBubble(message);
+ if (!_messageKeys.containsKey(message.messageId)) {
+ _messageKeys[message.messageId] = GlobalKey();
+ }
+ return Container(
+ key: _messageKeys[message.messageId]!,
+ child: _buildMessageBubble(message),
+ );
},
);
},
@@ -214,6 +255,10 @@ class _ChannelChatScreenState extends State {
),
const SizedBox(height: 4),
],
+ if (message.replyToMessageId != null) ...[
+ _buildReplyPreview(message),
+ const SizedBox(height: 8),
+ ],
if (poi != null)
_buildPoiMessage(context, poi, isOutgoing)
else if (gifId != null)
@@ -282,6 +327,79 @@ class _ChannelChatScreenState extends State {
);
}
+ Widget _buildReplyPreview(ChannelMessage message) {
+ final connector = context.read();
+ final isOwnNode = message.replyToSenderName == connector.selfName;
+ final replyText = message.replyToText ?? '';
+
+ final gifId = _parseGifId(replyText);
+ final poi = _parsePoiMessage(replyText);
+
+ Widget contentPreview;
+ if (gifId != null) {
+ contentPreview = Row(
+ children: [
+ Icon(Icons.gif_box, size: 14, color: Colors.grey[600]),
+ const SizedBox(width: 4),
+ Text('GIF', style: TextStyle(fontSize: 12, color: Colors.grey[700])),
+ ],
+ );
+ } else if (poi != null) {
+ contentPreview = Row(
+ children: [
+ Icon(Icons.location_on_outlined, size: 14, color: Colors.grey[600]),
+ const SizedBox(width: 4),
+ Text('Location', style: TextStyle(fontSize: 12, color: Colors.grey[700])),
+ ],
+ );
+ } else {
+ contentPreview = Text(
+ replyText,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontSize: 12,
+ color: Colors.grey[700],
+ fontStyle: FontStyle.italic,
+ ),
+ );
+ }
+
+ return GestureDetector(
+ onTap: () => _scrollToMessage(message.replyToMessageId!),
+ child: Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: Colors.black.withValues(alpha: 0.05),
+ borderRadius: BorderRadius.circular(8),
+ border: Border(
+ left: BorderSide(
+ color: Theme.of(context).colorScheme.primary,
+ width: 3,
+ ),
+ ),
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Reply to ${message.replyToSenderName}',
+ style: TextStyle(
+ fontSize: 11,
+ fontWeight: FontWeight.bold,
+ color: isOwnNode
+ ? Theme.of(context).colorScheme.primary
+ : Theme.of(context).colorScheme.onSurface,
+ ),
+ ),
+ const SizedBox(height: 2),
+ contentPreview,
+ ],
+ ),
+ ),
+ );
+ }
+
String? _parseGifId(String text) {
final trimmed = text.trim();
final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
@@ -412,22 +530,84 @@ class _ChannelChatScreenState extends State {
return colors[hash.abs() % colors.length];
}
- Widget _buildMessageComposer() {
- final connector = context.watch();
- final maxBytes = maxChannelMessageBytes(connector.selfName);
+ Widget _buildReplyBanner() {
+ final message = _replyingToMessage!;
return Container(
- padding: const EdgeInsets.all(8),
+ width: double.infinity,
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
- color: Theme.of(context).colorScheme.surface,
- boxShadow: [
- BoxShadow(
- color: Colors.black.withValues(alpha: 0.1),
- blurRadius: 4,
- offset: const Offset(0, -2),
+ color: Theme.of(context).colorScheme.secondaryContainer,
+ border: Border(
+ bottom: BorderSide(
+ color: Theme.of(context).dividerColor,
+ width: 1,
+ ),
+ ),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ Icons.reply,
+ size: 18,
+ color: Theme.of(context).colorScheme.onSecondaryContainer,
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ 'Replying to ${message.senderName}',
+ style: TextStyle(
+ fontSize: 12,
+ fontWeight: FontWeight.bold,
+ color: Theme.of(context).colorScheme.onSecondaryContainer,
+ ),
+ ),
+ Text(
+ message.text,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: TextStyle(
+ fontSize: 11,
+ color: Theme.of(context).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
+ ),
+ ),
+ ],
+ ),
+ ),
+ IconButton(
+ icon: const Icon(Icons.close, size: 18),
+ onPressed: _cancelReply,
+ color: Theme.of(context).colorScheme.onSecondaryContainer,
+ padding: EdgeInsets.zero,
+ constraints: const BoxConstraints(),
),
],
),
- child: Row(
+ );
+ }
+
+ Widget _buildMessageComposer() {
+ final connector = context.watch();
+ final maxBytes = maxChannelMessageBytes(connector.selfName);
+ return Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ if (_replyingToMessage != null) _buildReplyBanner(),
+ Container(
+ padding: const EdgeInsets.all(8),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surface,
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: 0.1),
+ blurRadius: 4,
+ offset: const Offset(0, -2),
+ ),
+ ],
+ ),
+ child: Row(
children: [
IconButton(
icon: const Icon(Icons.gif_box),
@@ -491,7 +671,9 @@ class _ChannelChatScreenState extends State {
color: Theme.of(context).colorScheme.primary,
),
],
- ),
+ ),
+ ),
+ ],
);
}
@@ -500,16 +682,23 @@ class _ChannelChatScreenState extends State {
if (text.isEmpty) return;
final connector = context.read();
+
+ String messageText = text;
+ if (_replyingToMessage != null) {
+ messageText = '@[${_replyingToMessage!.senderName}] $text';
+ }
+
final maxBytes = maxChannelMessageBytes(connector.selfName);
- if (utf8.encode(text).length > maxBytes) {
+ if (utf8.encode(messageText).length > maxBytes) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Message too long (max $maxBytes bytes).')),
);
return;
}
- connector.sendChannelMessage(widget.channel, text);
+ connector.sendChannelMessage(widget.channel, messageText);
_textController.clear();
+ _cancelReply();
}
String _formatTime(DateTime time) {
@@ -539,6 +728,14 @@ class _ChannelChatScreenState extends State {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
+ ListTile(
+ leading: const Icon(Icons.reply),
+ title: const Text('Reply'),
+ onTap: () {
+ Navigator.pop(sheetContext);
+ _setReplyingTo(message);
+ },
+ ),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Copy'),
diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart
index 45fea9a..56b1400 100644
--- a/lib/screens/channels_screen.dart
+++ b/lib/screens/channels_screen.dart
@@ -7,7 +7,10 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../models/channel.dart';
+import '../utils/dialog_utils.dart';
+import '../utils/disconnect_navigation_mixin.dart';
import '../utils/route_transitions.dart';
+import '../widgets/empty_state.dart';
import '../widgets/quick_switch_bar.dart';
import '../widgets/unread_badge.dart';
import 'channel_chat_screen.dart';
@@ -27,7 +30,8 @@ class ChannelsScreen extends StatefulWidget {
State createState() => _ChannelsScreenState();
}
-class _ChannelsScreenState extends State {
+class _ChannelsScreenState extends State
+ with DisconnectNavigationMixin {
@override
void initState() {
super.initState();
@@ -39,6 +43,12 @@ class _ChannelsScreenState extends State {
@override
Widget build(BuildContext context) {
final connector = context.watch();
+
+ // Auto-navigate back to scanner if disconnected
+ if (!checkConnectionAndNavigate(connector)) {
+ return const SizedBox.shrink();
+ }
+
final allowBack = !connector.isConnected;
return PopScope(
@@ -77,23 +87,13 @@ class _ChannelsScreenState extends State {
final channels = connector.channels;
if (channels.isEmpty) {
- return Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(Icons.tag, size: 64, color: Colors.grey[400]),
- const SizedBox(height: 16),
- Text(
- 'No channels configured',
- style: TextStyle(fontSize: 16, color: Colors.grey[600]),
- ),
- const SizedBox(height: 24),
- FilledButton.icon(
- onPressed: () => _addPublicChannel(context, connector),
- icon: const Icon(Icons.public),
- label: const Text('Add Public Channel'),
- ),
- ],
+ return EmptyState(
+ icon: Icons.tag,
+ title: 'No channels configured',
+ action: FilledButton.icon(
+ onPressed: () => _addPublicChannel(context, connector),
+ icon: const Icon(Icons.public),
+ label: const Text('Add Public Channel'),
),
);
}
@@ -190,14 +190,17 @@ class _ChannelsScreenState extends State {
),
],
),
- onTap: () {
+ onTap: () async {
connector.markChannelRead(channel.index);
- Navigator.push(
- context,
- MaterialPageRoute(
- builder: (context) => ChannelChatScreen(channel: channel),
- ),
- );
+ await Future.delayed(const Duration(milliseconds: 50));
+ if (context.mounted) {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (context) => ChannelChatScreen(channel: channel),
+ ),
+ );
+ }
},
onLongPress: () => _showChannelActions(context, connector, channel),
),
@@ -218,17 +221,23 @@ class _ChannelsScreenState extends State {
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('Edit channel'),
- onTap: () {
+ onTap: () async {
Navigator.pop(context);
- _showEditChannelDialog(context, connector, channel);
+ await Future.delayed(const Duration(milliseconds: 100));
+ if (context.mounted) {
+ _showEditChannelDialog(context, connector, channel);
+ }
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.red),
title: const Text('Delete channel', style: TextStyle(color: Colors.red)),
- onTap: () {
+ onTap: () async {
Navigator.pop(context);
- _confirmDeleteChannel(context, connector, channel);
+ await Future.delayed(const Duration(milliseconds: 100));
+ if (context.mounted) {
+ _confirmDeleteChannel(context, connector, channel);
+ }
},
),
],
@@ -261,27 +270,7 @@ class _ChannelsScreenState extends State {
Future _disconnect(BuildContext context) async {
final connector = context.read();
- final confirmed = await showDialog(
- context: context,
- builder: (context) => AlertDialog(
- title: const Text('Disconnect'),
- content: const Text('Are you sure you want to disconnect from this device?'),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(context, false),
- child: const Text('Cancel'),
- ),
- TextButton(
- onPressed: () => Navigator.pop(context, true),
- child: const Text('Disconnect'),
- ),
- ],
- ),
- );
-
- if (confirmed == true) {
- await connector.disconnect();
- }
+ await showDisconnectDialog(context, connector);
}
void _showAddChannelDialog(BuildContext context) {
@@ -402,9 +391,11 @@ class _ChannelsScreenState extends State {
Navigator.pop(context);
connector.setChannel(selectedIndex, name, psk);
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Channel "$name" added')),
- );
+ if (context.mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(content: Text('Channel "$name" added')),
+ );
+ }
},
child: const Text('Add'),
),
diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart
index 1cf33cc..5e3f647 100644
--- a/lib/screens/chat_screen.dart
+++ b/lib/screens/chat_screen.dart
@@ -1,13 +1,10 @@
import 'dart:async';
import 'dart:convert';
-import 'dart:io';
-import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:latlong2/latlong.dart';
-import 'package:record/record.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
@@ -15,14 +12,12 @@ import '../helpers/utf8_length_limiter.dart';
import '../models/channel_message.dart';
import '../models/contact.dart';
import '../models/message.dart';
-import '../services/voice_message_service.dart';
import '../services/path_history_service.dart';
import 'channel_message_path_screen.dart';
import 'map_screen.dart';
import '../utils/emoji_utils.dart';
import '../widgets/gif_message.dart';
import '../widgets/gif_picker.dart';
-import '../widgets/voice_message.dart';
class ChatScreen extends StatefulWidget {
final Contact contact;
@@ -37,16 +32,6 @@ class _ChatScreenState extends State {
final _textController = TextEditingController();
final _scrollController = ScrollController();
bool _forceFlood = false;
- final AudioRecorder _voiceRecorder = AudioRecorder();
- StreamSubscription? _voiceStreamSubscription;
- BytesBuilder _voiceBuffer = BytesBuilder(copy: false);
- Timer? _voiceRecordTimer;
- bool _isRecordingVoice = false;
- Message? _pendingVoiceMessage;
- Uint8List? _pendingVoiceCodec2Bytes;
- int? _pendingVoiceTimestampSeconds;
- int? _pendingVoiceDurationMs;
- String? _pendingVoicePath;
@override
void initState() {
@@ -62,11 +47,6 @@ class _ChatScreenState extends State {
context.read().setActiveContact(null);
_textController.dispose();
_scrollController.dispose();
- _voiceRecordTimer?.cancel();
- _voiceStreamSubscription?.cancel();
- unawaited(_voiceRecorder.stop());
- _voiceRecorder.dispose();
- unawaited(_clearPendingVoicePreview(deleteFile: true, notify: false));
super.dispose();
}
@@ -204,8 +184,6 @@ class _ChatScreenState extends State {
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
- cacheExtent: 0,
- addAutomaticKeepAlives: false,
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[index];
@@ -221,9 +199,6 @@ class _ChatScreenState extends State {
Widget _buildInputBar(MeshCoreConnector connector) {
final maxBytes = maxContactMessageBytes();
- final isVoiceBusy = connector.isVoiceSending;
- final voiceSupported = Platform.isAndroid || Platform.isIOS;
- final hasPendingVoice = _pendingVoiceMessage != null;
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(8),
@@ -236,93 +211,59 @@ class _ChatScreenState extends State {
child: SafeArea(
child: Row(
children: [
- if (voiceSupported)
- IconButton(
- icon: Icon(_isRecordingVoice ? Icons.stop_circle : Icons.mic),
- onPressed: (isVoiceBusy || hasPendingVoice) ? null : () => _toggleVoiceRecording(connector),
- tooltip: _isRecordingVoice ? 'Stop recording' : 'Record voice',
- ),
IconButton(
icon: const Icon(Icons.gif_box),
- onPressed: (_isRecordingVoice || isVoiceBusy || hasPendingVoice)
- ? null
- : () => _showGifPicker(context),
+ onPressed: () => _showGifPicker(context),
tooltip: 'Send GIF',
),
Expanded(
- child: hasPendingVoice
- ? _buildVoicePreview(colorScheme)
- : ValueListenableBuilder(
- valueListenable: _textController,
- builder: (context, value, child) {
- final gifId = _parseGifId(value.text);
- if (gifId != null) {
- return Row(
- children: [
- Expanded(
- child: GifMessage(
- url: 'https://media.giphy.com/media/$gifId/giphy.gif',
- backgroundColor: colorScheme.surfaceContainerHighest,
- fallbackTextColor:
- colorScheme.onSurface.withValues(alpha: 0.6),
- width: 160,
- height: 110,
- ),
- ),
- const SizedBox(width: 8),
- IconButton(
- icon: const Icon(Icons.close),
- onPressed: () => _textController.clear(),
- ),
- ],
- );
- }
-
- return TextField(
- controller: _textController,
- enabled: !_isRecordingVoice && !isVoiceBusy,
- inputFormatters: [
- Utf8LengthLimitingTextInputFormatter(maxBytes),
- ],
- decoration: const InputDecoration(
- hintText: 'Type a message...',
- border: OutlineInputBorder(),
- contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+ child: ValueListenableBuilder(
+ valueListenable: _textController,
+ builder: (context, value, child) {
+ final gifId = _parseGifId(value.text);
+ if (gifId != null) {
+ return Row(
+ children: [
+ Expanded(
+ child: GifMessage(
+ url: 'https://media.giphy.com/media/$gifId/giphy.gif',
+ backgroundColor: colorScheme.surfaceContainerHighest,
+ fallbackTextColor:
+ colorScheme.onSurface.withValues(alpha: 0.6),
+ width: 160,
+ height: 110,
),
- textInputAction: TextInputAction.send,
- onSubmitted: (_isRecordingVoice || isVoiceBusy)
- ? null
- : (_) => _sendMessage(connector),
- );
- },
+ ),
+ const SizedBox(width: 8),
+ IconButton(
+ icon: const Icon(Icons.close),
+ onPressed: () => _textController.clear(),
+ ),
+ ],
+ );
+ }
+
+ return TextField(
+ controller: _textController,
+ inputFormatters: [
+ Utf8LengthLimitingTextInputFormatter(maxBytes),
+ ],
+ decoration: const InputDecoration(
+ hintText: 'Type a message...',
+ border: OutlineInputBorder(),
+ contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
+ textInputAction: TextInputAction.send,
+ onSubmitted: (_) => _sendMessage(connector),
+ );
+ },
+ ),
),
const SizedBox(width: 8),
- if (isVoiceBusy)
- IconButton.filled(
- icon: const Icon(Icons.stop_circle),
- onPressed: () => _cancelVoiceSend(connector),
- tooltip: 'Cancel voice send',
- )
- else if (hasPendingVoice) ...[
- IconButton(
- icon: const Icon(Icons.close),
- onPressed: () => _clearPendingVoicePreview(deleteFile: true),
- tooltip: 'Discard voice message',
- ),
- IconButton.filled(
- icon: const Icon(Icons.send),
- onPressed: () => _sendPendingVoice(connector),
- tooltip: 'Send voice message',
- ),
- ]
- else
- IconButton.filled(
- icon: const Icon(Icons.send),
- onPressed: (_isRecordingVoice || isVoiceBusy)
- ? null
- : () => _sendMessage(connector),
- ),
+ IconButton.filled(
+ icon: const Icon(Icons.send),
+ onPressed: () => _sendMessage(connector),
+ ),
],
),
),
@@ -377,208 +318,6 @@ class _ChatScreenState extends State {
});
}
- void _cancelVoiceSend(MeshCoreConnector connector) {
- connector.cancelVoiceSend();
- if (!mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Voice send canceled')),
- );
- }
-
- Future _toggleVoiceRecording(MeshCoreConnector connector) async {
- if (_isRecordingVoice) {
- await _stopVoiceRecording(connector);
- } else {
- await _startVoiceRecording();
- }
- }
-
- Future _startVoiceRecording() async {
- if (_isRecordingVoice) return;
- final hasPermission = await _voiceRecorder.hasPermission();
- if (!hasPermission) {
- if (!mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Microphone permission denied')),
- );
- return;
- }
-
- _voiceBuffer = BytesBuilder(copy: false);
- try {
- final stream = await _voiceRecorder.startStream(
- const RecordConfig(
- encoder: AudioEncoder.pcm16bits,
- sampleRate: VoiceMessageService.sampleRate,
- numChannels: VoiceMessageService.channels,
- ),
- );
- _voiceStreamSubscription = stream.listen((data) {
- _voiceBuffer.add(data);
- });
- } catch (e) {
- if (!mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Failed to start recording: $e')),
- );
- return;
- }
- _voiceRecordTimer?.cancel();
- _voiceRecordTimer = Timer(
- const Duration(seconds: VoiceMessageService.maxRecordSeconds),
- () => _stopVoiceRecording(context.read()),
- );
- setState(() {
- _isRecordingVoice = true;
- });
- }
-
- Future _stopVoiceRecording(MeshCoreConnector connector) async {
- if (!_isRecordingVoice) return;
- _voiceRecordTimer?.cancel();
- await _voiceRecorder.stop();
- await _voiceStreamSubscription?.cancel();
- _voiceStreamSubscription = null;
- final pcmBytes = _voiceBuffer.takeBytes();
- setState(() {
- _isRecordingVoice = false;
- });
- if (pcmBytes.isEmpty) return;
- await _prepareVoicePreview(connector, pcmBytes);
- }
-
- Future _prepareVoicePreview(MeshCoreConnector connector, Uint8List pcmBytes) async {
- final voiceService = VoiceMessageService.instance;
- try {
- final codec2Bytes = voiceService.encodePcmToCodec2(pcmBytes);
- if (codec2Bytes.isEmpty) return;
- final timestampSeconds = connector.reserveVoiceTimestampSeconds();
- final durationMs = voiceService.durationMsForCodec2Bytes(codec2Bytes);
- final decodedPcm = voiceService.decodeCodec2ToPcm(codec2Bytes);
- final fileName = voiceService.buildVoiceFileName(
- senderKeyHex: widget.contact.publicKeyHex,
- timestampSeconds: timestampSeconds,
- outgoing: true,
- );
- final voicePath = await voiceService.writeWavFile(
- pcmBytes: decodedPcm,
- fileName: fileName,
- );
-
- final previewMessage = Message(
- senderKey: widget.contact.publicKey,
- text: 'Voice message',
- timestamp: DateTime.fromMillisecondsSinceEpoch(timestampSeconds * 1000),
- isOutgoing: true,
- isCli: false,
- status: MessageStatus.pending,
- isVoice: true,
- voicePath: voicePath,
- voiceDurationMs: durationMs,
- voiceCodec: VoiceMessageService.codecName,
- );
-
- if (!mounted) return;
- setState(() {
- _pendingVoiceMessage = previewMessage;
- _pendingVoiceCodec2Bytes = codec2Bytes;
- _pendingVoiceTimestampSeconds = timestampSeconds;
- _pendingVoiceDurationMs = durationMs;
- _pendingVoicePath = voicePath;
- });
- } catch (e) {
- if (!mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- SnackBar(content: Text('Voice message failed: $e')),
- );
- }
- }
-
- Widget _buildVoicePreview(ColorScheme colorScheme) {
- final message = _pendingVoiceMessage;
- if (message == null) {
- return const SizedBox.shrink();
- }
-
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- decoration: BoxDecoration(
- color: colorScheme.surfaceContainerHighest,
- borderRadius: BorderRadius.circular(12),
- ),
- child: VoiceMessageBubble(
- message: message,
- backgroundColor: colorScheme.surfaceContainerHighest,
- textColor: colorScheme.onSurface,
- metaColor: colorScheme.onSurface.withValues(alpha: 0.7),
- isOutgoing: true,
- ),
- );
- }
-
- Future _sendPendingVoice(MeshCoreConnector connector) async {
- final codec2Bytes = _pendingVoiceCodec2Bytes;
- final voicePath = _pendingVoicePath;
- final durationMs = _pendingVoiceDurationMs;
- final timestampSeconds = _pendingVoiceTimestampSeconds;
-
- if (codec2Bytes == null ||
- codec2Bytes.isEmpty ||
- voicePath == null ||
- voicePath.isEmpty ||
- durationMs == null ||
- timestampSeconds == null) {
- return;
- }
- if (!connector.isConnected) {
- if (!mounted) return;
- ScaffoldMessenger.of(context).showSnackBar(
- const SnackBar(content: Text('Not connected to a MeshCore device')),
- );
- return;
- }
- if (connector.isVoiceSending) {
- return;
- }
-
- await connector.sendVoiceMessage(
- contact: widget.contact,
- codec2Bytes: codec2Bytes,
- voicePath: voicePath,
- durationMs: durationMs,
- timestampSeconds: timestampSeconds,
- );
- unawaited(_clearPendingVoicePreview(deleteFile: false));
- }
-
- Future _clearPendingVoicePreview({required bool deleteFile, bool notify = true}) async {
- final path = _pendingVoicePath;
- if (notify && mounted) {
- setState(() {
- _pendingVoiceMessage = null;
- _pendingVoiceCodec2Bytes = null;
- _pendingVoiceTimestampSeconds = null;
- _pendingVoiceDurationMs = null;
- _pendingVoicePath = null;
- });
- } else {
- _pendingVoiceMessage = null;
- _pendingVoiceCodec2Bytes = null;
- _pendingVoiceTimestampSeconds = null;
- _pendingVoiceDurationMs = null;
- _pendingVoicePath = null;
- }
- if (deleteFile && path != null && path.isNotEmpty) {
- try {
- final file = File(path);
- if (await file.exists()) {
- await file.delete();
- }
- } catch (_) {
- return;
- }
- }
- }
void _showPathHistory(BuildContext context) {
final connector = Provider.of(context, listen: false);
@@ -1279,15 +1018,14 @@ class _ChatScreenState extends State {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
- if (!message.isVoice)
- ListTile(
- leading: const Icon(Icons.copy),
- title: const Text('Copy'),
- onTap: () {
- Navigator.pop(sheetContext);
- _copyMessageText(message.text);
- },
- ),
+ ListTile(
+ leading: const Icon(Icons.copy),
+ title: const Text('Copy'),
+ onTap: () {
+ Navigator.pop(sheetContext);
+ _copyMessageText(message.text);
+ },
+ ),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Delete'),
@@ -1297,8 +1035,7 @@ class _ChatScreenState extends State {
},
),
if (message.isOutgoing &&
- message.status == MessageStatus.failed &&
- !message.isVoice)
+ message.status == MessageStatus.failed)
ListTile(
leading: const Icon(Icons.refresh),
title: const Text('Retry'),
@@ -1412,15 +1149,7 @@ class _MessageBubble extends StatelessWidget {
),
const SizedBox(height: 4),
],
- if (message.isVoice)
- VoiceMessageBubble(
- message: message,
- backgroundColor: bubbleColor,
- textColor: textColor,
- metaColor: metaColor,
- isOutgoing: isOutgoing,
- )
- else if (poi != null)
+ if (poi != null)
_buildPoiMessage(context, poi, textColor, metaColor)
else if (gifId != null)
GifMessage(
diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart
index 8bceace..31382a5 100644
--- a/lib/screens/contacts_screen.dart
+++ b/lib/screens/contacts_screen.dart
@@ -1,3 +1,5 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -7,8 +9,11 @@ import '../models/contact.dart';
import '../models/contact_group.dart';
import '../storage/contact_group_store.dart';
import '../utils/contact_search.dart';
+import '../utils/dialog_utils.dart';
+import '../utils/disconnect_navigation_mixin.dart';
import '../utils/emoji_utils.dart';
import '../utils/route_transitions.dart';
+import '../widgets/empty_state.dart';
import '../widgets/quick_switch_bar.dart';
import '../widgets/repeater_login_dialog.dart';
import '../widgets/unread_badge.dart';
@@ -46,7 +51,8 @@ class ContactsScreen extends StatefulWidget {
State createState() => _ContactsScreenState();
}
-class _ContactsScreenState extends State {
+class _ContactsScreenState extends State
+ with DisconnectNavigationMixin {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
ContactSortOption _sortOption = ContactSortOption.lastSeen;
@@ -54,6 +60,7 @@ class _ContactsScreenState extends State {
bool _showUnreadOnly = false;
final ContactGroupStore _groupStore = ContactGroupStore();
List _groups = [];
+ Timer? _searchDebounce;
@override
void initState() {
@@ -63,6 +70,7 @@ class _ContactsScreenState extends State {
@override
void dispose() {
+ _searchDebounce?.cancel();
_searchController.dispose();
super.dispose();
}
@@ -82,16 +90,13 @@ class _ContactsScreenState extends State {
@override
Widget build(BuildContext context) {
final connector = context.watch();
- final allowBack = !connector.isConnected;
- if (!connector.isConnected) {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (context.mounted) {
- Navigator.popUntil(context, (route) => route.isFirst);
- }
- });
+ // Auto-navigate back to scanner if disconnected
+ if (!checkConnectionAndNavigate(connector)) {
+ return const SizedBox.shrink();
}
+ final allowBack = !connector.isConnected;
final theme = Theme.of(context);
return PopScope(
@@ -245,27 +250,7 @@ class _ContactsScreenState extends State {
BuildContext context,
MeshCoreConnector connector,
) async {
- final confirmed = await showDialog(
- context: context,
- builder: (context) => AlertDialog(
- title: const Text('Disconnect'),
- content: const Text('Are you sure you want to disconnect from this device?'),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(context, false),
- child: const Text('Cancel'),
- ),
- TextButton(
- onPressed: () => Navigator.pop(context, true),
- child: const Text('Disconnect'),
- ),
- ],
- ),
- );
-
- if (confirmed == true) {
- await connector.disconnect();
- }
+ await showDisconnectDialog(context, connector);
}
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
@@ -276,23 +261,10 @@ class _ContactsScreenState extends State {
}
if (contacts.isEmpty && _groups.isEmpty) {
- return Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(Icons.people_outline, size: 64, color: Colors.grey[400]),
- const SizedBox(height: 16),
- Text(
- 'No contacts yet',
- style: TextStyle(fontSize: 16, color: Colors.grey[600]),
- ),
- const SizedBox(height: 8),
- Text(
- 'Contacts will appear when devices advertise',
- style: TextStyle(fontSize: 14, color: Colors.grey[500]),
- ),
- ],
- ),
+ return const EmptyState(
+ icon: Icons.people_outline,
+ title: 'No contacts yet',
+ subtitle: 'Contacts will appear when devices advertise',
);
}
@@ -326,8 +298,12 @@ class _ContactsScreenState extends State {
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
onChanged: (value) {
- setState(() {
- _searchQuery = value.toLowerCase();
+ _searchDebounce?.cancel();
+ _searchDebounce = Timer(const Duration(milliseconds: 300), () {
+ if (!mounted) return;
+ setState(() {
+ _searchQuery = value.toLowerCase();
+ });
});
},
),
diff --git a/lib/screens/device_screen.dart b/lib/screens/device_screen.dart
index d99aa57..ad81a47 100644
--- a/lib/screens/device_screen.dart
+++ b/lib/screens/device_screen.dart
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
+import '../utils/dialog_utils.dart';
+import '../utils/disconnect_navigation_mixin.dart';
import '../utils/route_transitions.dart';
import '../widgets/quick_switch_bar.dart';
import 'channels_screen.dart';
@@ -17,7 +19,8 @@ class DeviceScreen extends StatefulWidget {
State createState() => _DeviceScreenState();
}
-class _DeviceScreenState extends State {
+class _DeviceScreenState extends State
+ with DisconnectNavigationMixin {
bool _showBatteryVoltage = false;
int _quickIndex = 0;
@@ -25,13 +28,9 @@ class _DeviceScreenState extends State {
Widget build(BuildContext context) {
return Consumer(
builder: (context, connector, child) {
- // If disconnected, pop back to scanner
- if (!connector.isConnected) {
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (context.mounted) {
- Navigator.popUntil(context, (route) => route.isFirst);
- }
- });
+ // Auto-navigate back to scanner if disconnected
+ if (!checkConnectionAndNavigate(connector)) {
+ return const SizedBox.shrink();
}
final theme = Theme.of(context);
@@ -286,26 +285,6 @@ class _DeviceScreenState extends State {
BuildContext context,
MeshCoreConnector connector,
) async {
- final confirmed = await showDialog(
- context: context,
- builder: (context) => AlertDialog(
- title: const Text('Disconnect'),
- content: const Text('Are you sure you want to disconnect from this device?'),
- actions: [
- TextButton(
- onPressed: () => Navigator.pop(context, false),
- child: const Text('Cancel'),
- ),
- TextButton(
- onPressed: () => Navigator.pop(context, true),
- child: const Text('Disconnect'),
- ),
- ],
- ),
- );
-
- if (confirmed == true) {
- await connector.disconnect();
- }
+ await showDisconnectDialog(context, connector);
}
}
diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart
index e79f973..36c8651 100644
--- a/lib/services/app_settings_service.dart
+++ b/lib/services/app_settings_service.dart
@@ -1,7 +1,7 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
-import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_settings.dart';
+import '../storage/prefs_manager.dart';
class AppSettingsService extends ChangeNotifier {
static const String _settingsKey = 'app_settings';
@@ -17,7 +17,7 @@ class AppSettingsService extends ChangeNotifier {
}
Future loadSettings() async {
- final prefs = await SharedPreferences.getInstance();
+ final prefs = PrefsManager.instance;
final jsonStr = prefs.getString(_settingsKey);
if (jsonStr != null) {
@@ -36,7 +36,7 @@ class AppSettingsService extends ChangeNotifier {
_settings = newSettings;
notifyListeners();
- final prefs = await SharedPreferences.getInstance();
+ final prefs = PrefsManager.instance;
final jsonStr = jsonEncode(_settings.toJson());
await prefs.setString(_settingsKey, jsonStr);
}
diff --git a/lib/services/map_marker_service.dart b/lib/services/map_marker_service.dart
index 9ae109e..66b5047 100644
--- a/lib/services/map_marker_service.dart
+++ b/lib/services/map_marker_service.dart
@@ -1,16 +1,16 @@
-import 'package:shared_preferences/shared_preferences.dart';
+import '../storage/prefs_manager.dart';
class MapMarkerService {
static const String _removedKey = 'map_removed_marker_ids';
Future> loadRemovedIds() async {
- final prefs = await SharedPreferences.getInstance();
+ final prefs = PrefsManager.instance;
final items = prefs.getStringList(_removedKey) ?? const [];
return items.toSet();
}
Future saveRemovedIds(Set ids) async {
- final prefs = await SharedPreferences.getInstance();
+ final prefs = PrefsManager.instance;
await prefs.setStringList(_removedKey, ids.toList());
}
}
diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart
index 9c8d804..81caef0 100644
--- a/lib/services/path_history_service.dart
+++ b/lib/services/path_history_service.dart
@@ -10,6 +10,10 @@ class PathHistoryService extends ChangeNotifier {
final Map _autoRotationIndex = {};
final Map _floodStats = {};
+ // LRU cache eviction tracking
+ static const int _maxCachedContacts = 50;
+ final List _cacheAccessOrder = [];
+
static const int _maxHistoryEntries = 100;
static const int _autoRotationTopCount = 3;
@@ -91,6 +95,8 @@ class PathHistoryService extends ChangeNotifier {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
+ _trackAccess(contactPubKeyHex);
+
final selections = ranked
.map((path) => PathSelection(
pathBytes: path.pathBytes,
@@ -208,6 +214,8 @@ class PathHistoryService extends ChangeNotifier {
);
_cache[contactPubKeyHex] = updatedHistory;
+ _trackAccess(contactPubKeyHex);
+ _evictIfNeeded();
_storage.savePathHistory(contactPubKeyHex, updatedHistory);
notifyListeners();
@@ -216,12 +224,15 @@ class PathHistoryService extends ChangeNotifier {
List getRecentPaths(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
if (history != null) {
+ _trackAccess(contactPubKeyHex);
return history.recentPaths;
}
_loadHistoryFromStorage(contactPubKeyHex).then((loaded) {
if (loaded != null) {
_cache[contactPubKeyHex] = loaded;
+ _trackAccess(contactPubKeyHex);
+ _evictIfNeeded();
notifyListeners();
}
});
@@ -236,16 +247,23 @@ class PathHistoryService extends ChangeNotifier {
PathRecord? getFastestPath(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
+ if (history != null) {
+ _trackAccess(contactPubKeyHex);
+ }
return history?.fastest;
}
PathRecord? getMostRecentPath(String contactPubKeyHex) {
final history = _cache[contactPubKeyHex];
+ if (history != null) {
+ _trackAccess(contactPubKeyHex);
+ }
return history?.mostRecent;
}
Future clearPathHistory(String contactPubKeyHex) async {
_cache.remove(contactPubKeyHex);
+ _cacheAccessOrder.remove(contactPubKeyHex);
_autoRotationIndex.remove(contactPubKeyHex);
_floodStats.remove(contactPubKeyHex);
await _storage.clearPathHistory(contactPubKeyHex);
@@ -314,6 +332,20 @@ class PathHistoryService extends ChangeNotifier {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
stats.lastUsed = DateTime.now();
}
+
+ void _trackAccess(String contactPubKeyHex) {
+ _cacheAccessOrder.remove(contactPubKeyHex);
+ _cacheAccessOrder.add(contactPubKeyHex);
+ }
+
+ void _evictIfNeeded() {
+ while (_cache.length > _maxCachedContacts && _cacheAccessOrder.isNotEmpty) {
+ final oldest = _cacheAccessOrder.removeAt(0);
+ _cache.remove(oldest);
+ _autoRotationIndex.remove(oldest);
+ _floodStats.remove(oldest);
+ }
+ }
}
class _FloodStats {
diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart
index 052f0c8..39d6e6b 100644
--- a/lib/services/storage_service.dart
+++ b/lib/services/storage_service.dart
@@ -1,6 +1,6 @@
import 'dart:convert';
-import 'package:shared_preferences/shared_preferences.dart';
import '../models/path_history.dart';
+import '../storage/prefs_manager.dart';
class StorageService {
static const String _pathHistoryPrefix = 'path_history_';
@@ -9,14 +9,14 @@ class StorageService {
Future savePathHistory(
String contactPubKeyHex, ContactPathHistory history) async {
- final prefs = await SharedPreferences.getInstance();
+ final prefs = PrefsManager.instance;
final key = '$_pathHistoryPrefix$contactPubKeyHex';
final jsonStr = jsonEncode(history.toJson());
await prefs.setString(key, jsonStr);
}
Future loadPathHistory(String contactPubKeyHex) async {
- final prefs = await SharedPreferences.getInstance();
+ final prefs = PrefsManager.instance;
final key = '$_pathHistoryPrefix$contactPubKeyHex';
final jsonStr = prefs.getString(key);
@@ -31,13 +31,13 @@ class StorageService {
}
Future clearPathHistory(String contactPubKeyHex) async {
- final prefs = await SharedPreferences.getInstance();
+ final prefs = PrefsManager.instance;
final key = '$_pathHistoryPrefix$contactPubKeyHex';
await prefs.remove(key);
}
Future clearAllPathHistories() async {
- final prefs = await SharedPreferences.getInstance();
+ final prefs = PrefsManager.instance;
final keys = prefs.getKeys();
final pathHistoryKeys =
keys.where((key) => key.startsWith(_pathHistoryPrefix));
@@ -48,7 +48,7 @@ class StorageService {
}
Future