mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-25 03:42:55 +10:00
remove voice code make optimizations. Fix channels race conditions. add reply function
This commit is contained in:
@@ -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<String, _VoiceAssembly> _voiceAssemblies = {};
|
||||
_VoiceSendSession? _voiceSendSession;
|
||||
final Map<int, bool> _channelSmazEnabled = {};
|
||||
final Map<String, bool> _contactSmazEnabled = {};
|
||||
final Set<String> _knownContactKeys = {};
|
||||
@@ -124,23 +123,13 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
String? _activeContactKey;
|
||||
int? _activeChannelIndex;
|
||||
List<int> _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<List<Message>> 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<ChannelMessage> getChannelMessages(Channel channel) {
|
||||
return _channelMessages[channel.index] ?? [];
|
||||
}
|
||||
@@ -376,13 +389,43 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
/// Load persisted channel messages for a specific channel
|
||||
Future<void> _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<List<ChannelMessage>> 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<void> 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<void> 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<void> _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<bool> _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<void> setContactPath(Contact contact, Uint8List customPath, int pathLen) async {
|
||||
Future<void> setContactPath(
|
||||
Contact contact,
|
||||
Uint8List customPath,
|
||||
int pathLen,
|
||||
) async {
|
||||
if (!isConnected) return;
|
||||
|
||||
await sendFrame(buildUpdateContactPathFrame(
|
||||
@@ -1091,12 +995,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
));
|
||||
}
|
||||
|
||||
Future<void> sendChannelMessage(Channel channel, String text) async {
|
||||
Future<void> 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<String, int>.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<int, int>.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<String, int>.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<void> _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<Contact?>().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 = <String>[];
|
||||
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<String, int>.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<int, int>.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<ChannelMessage> 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<ChannelMessage> 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<int, Uint8List> _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<String> chunks;
|
||||
final int timestampSeconds;
|
||||
|
||||
int currentChunkIndex = -1;
|
||||
Uint8List? expectedAckHash;
|
||||
int? expectedTimeoutMs;
|
||||
Completer<void>? sentCompleter;
|
||||
Completer<void>? confirmCompleter;
|
||||
bool _cancelled = false;
|
||||
|
||||
bool get isCancelled => _cancelled;
|
||||
|
||||
void beginChunk(int index) {
|
||||
currentChunkIndex = index;
|
||||
expectedAckHash = null;
|
||||
expectedTimeoutMs = null;
|
||||
sentCompleter = Completer<void>();
|
||||
confirmCompleter = Completer<void>();
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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();
|
||||
|
||||
@@ -34,6 +34,10 @@ class ChannelMessage {
|
||||
final Uint8List pathBytes;
|
||||
final List<Uint8List> 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<Uint8List>? 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<Uint8List>? 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -32,6 +32,8 @@ class ChannelChatScreen extends StatefulWidget {
|
||||
class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
ChannelMessage? _replyingToMessage;
|
||||
final Map<String, GlobalKey> _messageKeys = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -60,6 +62,41 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _setReplyingTo(ChannelMessage message) {
|
||||
setState(() {
|
||||
_replyingToMessage = message;
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelReply() {
|
||||
setState(() {
|
||||
_replyingToMessage = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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<ChannelChatScreen> {
|
||||
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<ChannelChatScreen> {
|
||||
),
|
||||
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<ChannelChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReplyPreview(ChannelMessage message) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
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<ChannelChatScreen> {
|
||||
return colors[hash.abs() % colors.length];
|
||||
}
|
||||
|
||||
Widget _buildMessageComposer() {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
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<MeshCoreConnector>();
|
||||
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<ChannelChatScreen> {
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -500,16 +682,23 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
if (text.isEmpty) return;
|
||||
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
|
||||
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<ChannelChatScreen> {
|
||||
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'),
|
||||
|
||||
@@ -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<ChannelsScreen> createState() => _ChannelsScreenState();
|
||||
}
|
||||
|
||||
class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
class _ChannelsScreenState extends State<ChannelsScreen>
|
||||
with DisconnectNavigationMixin {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -39,6 +43,12 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
|
||||
// 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<ChannelsScreen> {
|
||||
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<ChannelsScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
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<ChannelsScreen> {
|
||||
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<ChannelsScreen> {
|
||||
|
||||
Future<void> _disconnect(BuildContext context) async {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<ChannelsScreen> {
|
||||
|
||||
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'),
|
||||
),
|
||||
|
||||
+54
-325
@@ -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<ChatScreen> {
|
||||
final _textController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
bool _forceFlood = false;
|
||||
final AudioRecorder _voiceRecorder = AudioRecorder();
|
||||
StreamSubscription<Uint8List>? _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<ChatScreen> {
|
||||
context.read<MeshCoreConnector>().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<ChatScreen> {
|
||||
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<ChatScreen> {
|
||||
|
||||
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<ChatScreen> {
|
||||
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<TextEditingValue>(
|
||||
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<TextEditingValue>(
|
||||
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<ChatScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelVoiceSend(MeshCoreConnector connector) {
|
||||
connector.cancelVoiceSend();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Voice send canceled')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _toggleVoiceRecording(MeshCoreConnector connector) async {
|
||||
if (_isRecordingVoice) {
|
||||
await _stopVoiceRecording(connector);
|
||||
} else {
|
||||
await _startVoiceRecording();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<MeshCoreConnector>()),
|
||||
);
|
||||
setState(() {
|
||||
_isRecordingVoice = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _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<void> _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<MeshCoreConnector>(context, listen: false);
|
||||
@@ -1279,15 +1018,14 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
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<ChatScreen> {
|
||||
},
|
||||
),
|
||||
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(
|
||||
|
||||
@@ -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<ContactsScreen> createState() => _ContactsScreenState();
|
||||
}
|
||||
|
||||
class _ContactsScreenState extends State<ContactsScreen> {
|
||||
class _ContactsScreenState extends State<ContactsScreen>
|
||||
with DisconnectNavigationMixin {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
ContactSortOption _sortOption = ContactSortOption.lastSeen;
|
||||
@@ -54,6 +60,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
bool _showUnreadOnly = false;
|
||||
final ContactGroupStore _groupStore = ContactGroupStore();
|
||||
List<ContactGroup> _groups = [];
|
||||
Timer? _searchDebounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -63,6 +70,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -82,16 +90,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
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<ContactsScreen> {
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<ContactsScreen> {
|
||||
}
|
||||
|
||||
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<ContactsScreen> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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<DeviceScreen> createState() => _DeviceScreenState();
|
||||
}
|
||||
|
||||
class _DeviceScreenState extends State<DeviceScreen> {
|
||||
class _DeviceScreenState extends State<DeviceScreen>
|
||||
with DisconnectNavigationMixin {
|
||||
bool _showBatteryVoltage = false;
|
||||
int _quickIndex = 0;
|
||||
|
||||
@@ -25,13 +28,9 @@ class _DeviceScreenState extends State<DeviceScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
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<DeviceScreen> {
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> 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);
|
||||
}
|
||||
|
||||
@@ -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<Set<String>> loadRemovedIds() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final items = prefs.getStringList(_removedKey) ?? const [];
|
||||
return items.toSet();
|
||||
}
|
||||
|
||||
Future<void> saveRemovedIds(Set<String> ids) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
await prefs.setStringList(_removedKey, ids.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ class PathHistoryService extends ChangeNotifier {
|
||||
final Map<String, int> _autoRotationIndex = {};
|
||||
final Map<String, _FloodStats> _floodStats = {};
|
||||
|
||||
// LRU cache eviction tracking
|
||||
static const int _maxCachedContacts = 50;
|
||||
final List<String> _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<PathRecord> 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<void> 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 {
|
||||
|
||||
@@ -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<void> 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<ContactPathHistory?> 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<void> clearPathHistory(String contactPubKeyHex) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_pathHistoryPrefix$contactPubKeyHex';
|
||||
await prefs.remove(key);
|
||||
}
|
||||
|
||||
Future<void> 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<Map<String, String>> loadPendingMessages() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = prefs.getString(_pendingMessagesKey);
|
||||
|
||||
if (jsonStr == null) return {};
|
||||
@@ -62,20 +62,20 @@ class StorageService {
|
||||
}
|
||||
|
||||
Future<void> savePendingMessages(Map<String, String> pending) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = jsonEncode(pending);
|
||||
await prefs.setString(_pendingMessagesKey, jsonStr);
|
||||
}
|
||||
|
||||
Future<void> clearPendingMessages() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
await prefs.remove(_pendingMessagesKey);
|
||||
}
|
||||
|
||||
/// Save a repeater password by public key hex
|
||||
Future<void> saveRepeaterPassword(
|
||||
String repeaterPubKeyHex, String password) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final passwords = await loadRepeaterPasswords();
|
||||
passwords[repeaterPubKeyHex] = password;
|
||||
final jsonStr = jsonEncode(passwords);
|
||||
@@ -84,7 +84,7 @@ class StorageService {
|
||||
|
||||
/// Load all saved repeater passwords (map of pubKeyHex -> password)
|
||||
Future<Map<String, String>> loadRepeaterPasswords() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = prefs.getString(_repeaterPasswordsKey);
|
||||
|
||||
if (jsonStr == null) return {};
|
||||
@@ -105,7 +105,7 @@ class StorageService {
|
||||
|
||||
/// Remove a saved repeater password
|
||||
Future<void> removeRepeaterPassword(String repeaterPubKeyHex) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final passwords = await loadRepeaterPasswords();
|
||||
passwords.remove(repeaterPubKeyHex);
|
||||
final jsonStr = jsonEncode(passwords);
|
||||
@@ -114,7 +114,7 @@ class StorageService {
|
||||
|
||||
/// Clear all saved repeater passwords
|
||||
Future<void> clearAllRepeaterPasswords() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
await prefs.remove(_repeaterPasswordsKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'codec2_ffi.dart';
|
||||
|
||||
class VoiceMessageService {
|
||||
static const int sampleRate = 8000;
|
||||
static const int channels = 1;
|
||||
static const int bitsPerSample = 16;
|
||||
static const int maxRecordSeconds = 5;
|
||||
static const int chunkRawBytes = 90;
|
||||
static const String codecName = 'codec2_1300';
|
||||
static const String chunkPrefix = 'V1|';
|
||||
|
||||
static final VoiceMessageService instance = VoiceMessageService._();
|
||||
|
||||
VoiceMessageService._();
|
||||
|
||||
Future<Directory> ensureVoiceDir() async {
|
||||
final docs = await getApplicationDocumentsDirectory();
|
||||
final dir = Directory(path.join(docs.path, 'voice'));
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
String buildVoiceFileName({
|
||||
required String senderKeyHex,
|
||||
required int timestampSeconds,
|
||||
bool outgoing = false,
|
||||
}) {
|
||||
final suffix = outgoing ? 'out' : 'in';
|
||||
return 'voice_${senderKeyHex}_${timestampSeconds}_$suffix.wav';
|
||||
}
|
||||
|
||||
List<String> buildVoiceChunks(Uint8List codec2Bytes) {
|
||||
if (codec2Bytes.isEmpty) return [];
|
||||
final chunks = <Uint8List>[];
|
||||
for (var offset = 0; offset < codec2Bytes.length; offset += chunkRawBytes) {
|
||||
final end = (offset + chunkRawBytes).clamp(0, codec2Bytes.length).toInt();
|
||||
chunks.add(Uint8List.fromList(codec2Bytes.sublist(offset, end)));
|
||||
}
|
||||
final count = chunks.length;
|
||||
return List<String>.generate(count, (index) {
|
||||
final encoded = _base64UrlEncodeNoPad(chunks[index]);
|
||||
return '$chunkPrefix$index/$count|$encoded';
|
||||
});
|
||||
}
|
||||
|
||||
VoiceChunk? tryParseChunk(String text) {
|
||||
final trimmed = text.trim();
|
||||
if (!trimmed.startsWith(chunkPrefix)) return null;
|
||||
final match = RegExp(r'^V1\|(\d+)/(\d+)\|([A-Za-z0-9_-]+)$').firstMatch(trimmed);
|
||||
if (match == null) return null;
|
||||
final idx = int.tryParse(match.group(1) ?? '');
|
||||
final count = int.tryParse(match.group(2) ?? '');
|
||||
final payload = match.group(3);
|
||||
if (idx == null || count == null || payload == null) return null;
|
||||
if (idx < 0 || count <= 0 || idx >= count) return null;
|
||||
try {
|
||||
final bytes = _base64UrlDecode(payload);
|
||||
return VoiceChunk(index: idx, count: count, bytes: bytes);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List encodePcmToCodec2(Uint8List pcmBytes) {
|
||||
final session = Codec2Ffi.instance.createSession();
|
||||
try {
|
||||
final samplesPerFrame = session.samplesPerFrameValue;
|
||||
final pcmSamples = _toInt16(pcmBytes);
|
||||
final frameCount = (pcmSamples.length + samplesPerFrame - 1) ~/ samplesPerFrame;
|
||||
final builder = BytesBuilder(copy: false);
|
||||
|
||||
for (var frameIndex = 0; frameIndex < frameCount; frameIndex++) {
|
||||
final start = frameIndex * samplesPerFrame;
|
||||
final end = (start + samplesPerFrame).clamp(0, pcmSamples.length).toInt();
|
||||
final frame = Int16List(samplesPerFrame);
|
||||
final copyLen = end - start;
|
||||
if (copyLen > 0) {
|
||||
frame.setRange(0, copyLen, pcmSamples.sublist(start, end));
|
||||
}
|
||||
final encoded = session.encodePcmFrame(frame);
|
||||
builder.add(encoded);
|
||||
}
|
||||
|
||||
return builder.takeBytes();
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List decodeCodec2ToPcm(Uint8List codec2Bytes) {
|
||||
final session = Codec2Ffi.instance.createSession();
|
||||
try {
|
||||
final bytesPerFrame = session.bytesPerFrameValue;
|
||||
if (bytesPerFrame <= 0) return Uint8List(0);
|
||||
final frameCount = codec2Bytes.length ~/ bytesPerFrame;
|
||||
final builder = BytesBuilder(copy: false);
|
||||
|
||||
for (var frameIndex = 0; frameIndex < frameCount; frameIndex++) {
|
||||
final start = frameIndex * bytesPerFrame;
|
||||
final frameBytes = codec2Bytes.sublist(start, start + bytesPerFrame);
|
||||
final decoded = session.decodeCodecFrame(frameBytes);
|
||||
builder.add(Uint8List.view(
|
||||
decoded.buffer,
|
||||
decoded.offsetInBytes,
|
||||
decoded.lengthInBytes,
|
||||
));
|
||||
}
|
||||
|
||||
return builder.takeBytes();
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
int durationMsForCodec2Bytes(Uint8List codec2Bytes) {
|
||||
final session = Codec2Ffi.instance.createSession();
|
||||
try {
|
||||
final bytesPerFrame = session.bytesPerFrameValue;
|
||||
final samplesPerFrame = session.samplesPerFrameValue;
|
||||
if (bytesPerFrame <= 0 || samplesPerFrame <= 0) return 0;
|
||||
final frameCount = codec2Bytes.length ~/ bytesPerFrame;
|
||||
final frameDurationMs = (samplesPerFrame * 1000 / sampleRate).round();
|
||||
return frameCount * frameDurationMs;
|
||||
} finally {
|
||||
session.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> writeWavFile({
|
||||
required Uint8List pcmBytes,
|
||||
required String fileName,
|
||||
}) async {
|
||||
final dir = await ensureVoiceDir();
|
||||
final filePath = path.join(dir.path, fileName);
|
||||
final wavHeader = _buildWavHeader(
|
||||
pcmDataSize: pcmBytes.length,
|
||||
sampleRate: sampleRate,
|
||||
channels: channels,
|
||||
bitsPerSample: bitsPerSample,
|
||||
);
|
||||
final file = File(filePath);
|
||||
final builder = BytesBuilder(copy: false);
|
||||
builder.add(wavHeader);
|
||||
builder.add(pcmBytes);
|
||||
await file.writeAsBytes(builder.takeBytes(), flush: true);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
Uint8List _buildWavHeader({
|
||||
required int pcmDataSize,
|
||||
required int sampleRate,
|
||||
required int channels,
|
||||
required int bitsPerSample,
|
||||
}) {
|
||||
final byteRate = sampleRate * channels * (bitsPerSample ~/ 8);
|
||||
final blockAlign = channels * (bitsPerSample ~/ 8);
|
||||
final buffer = BytesBuilder(copy: false);
|
||||
buffer.add(ascii.encode('RIFF'));
|
||||
buffer.add(_le32(36 + pcmDataSize));
|
||||
buffer.add(ascii.encode('WAVE'));
|
||||
buffer.add(ascii.encode('fmt '));
|
||||
buffer.add(_le32(16));
|
||||
buffer.add(_le16(1));
|
||||
buffer.add(_le16(channels));
|
||||
buffer.add(_le32(sampleRate));
|
||||
buffer.add(_le32(byteRate));
|
||||
buffer.add(_le16(blockAlign));
|
||||
buffer.add(_le16(bitsPerSample));
|
||||
buffer.add(ascii.encode('data'));
|
||||
buffer.add(_le32(pcmDataSize));
|
||||
return buffer.takeBytes();
|
||||
}
|
||||
|
||||
Uint8List _le16(int value) {
|
||||
final data = ByteData(2)..setUint16(0, value, Endian.little);
|
||||
return data.buffer.asUint8List();
|
||||
}
|
||||
|
||||
Uint8List _le32(int value) {
|
||||
final data = ByteData(4)..setUint32(0, value, Endian.little);
|
||||
return data.buffer.asUint8List();
|
||||
}
|
||||
|
||||
Int16List _toInt16(Uint8List bytes) {
|
||||
final evenLength = bytes.lengthInBytes - (bytes.lengthInBytes % 2);
|
||||
if (evenLength <= 0) return Int16List(0);
|
||||
return Int16List.view(bytes.buffer, bytes.offsetInBytes, evenLength ~/ 2);
|
||||
}
|
||||
|
||||
String _base64UrlEncodeNoPad(Uint8List bytes) {
|
||||
return base64Url.encode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
Uint8List _base64UrlDecode(String encoded) {
|
||||
final paddedLength = (encoded.length + 3) ~/ 4 * 4;
|
||||
final padded = encoded.padRight(paddedLength, '=');
|
||||
return base64Url.decode(padded);
|
||||
}
|
||||
}
|
||||
|
||||
class VoiceChunk {
|
||||
final int index;
|
||||
final int count;
|
||||
final Uint8List bytes;
|
||||
|
||||
VoiceChunk({
|
||||
required this.index,
|
||||
required this.count,
|
||||
required this.bytes,
|
||||
});
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/channel_message.dart';
|
||||
import '../helpers/smaz.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class ChannelMessageStore {
|
||||
static const String _keyPrefix = 'channel_messages_';
|
||||
|
||||
/// Save messages for a specific channel
|
||||
Future<void> saveChannelMessages(int channelIndex, List<ChannelMessage> messages) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_keyPrefix$channelIndex';
|
||||
|
||||
// Convert messages to JSON
|
||||
@@ -21,7 +21,7 @@ class ChannelMessageStore {
|
||||
|
||||
/// Load messages for a specific channel
|
||||
Future<List<ChannelMessage>> loadChannelMessages(int channelIndex) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_keyPrefix$channelIndex';
|
||||
|
||||
final jsonString = prefs.getString(key);
|
||||
@@ -38,14 +38,14 @@ class ChannelMessageStore {
|
||||
|
||||
/// Clear messages for a specific channel
|
||||
Future<void> clearChannelMessages(int channelIndex) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_keyPrefix$channelIndex';
|
||||
await prefs.remove(key);
|
||||
}
|
||||
|
||||
/// Clear all channel messages
|
||||
Future<void> clearAllChannelMessages() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final keys = prefs.getKeys().where((k) => k.startsWith(_keyPrefix));
|
||||
for (var key in keys) {
|
||||
await prefs.remove(key);
|
||||
@@ -67,6 +67,10 @@ class ChannelMessageStore {
|
||||
'pathBytes': base64Encode(msg.pathBytes),
|
||||
'pathVariants': msg.pathVariants.map(base64Encode).toList(),
|
||||
'repeats': msg.repeats.map(_repeatToJson).toList(),
|
||||
'messageId': msg.messageId,
|
||||
'replyToMessageId': msg.replyToMessageId,
|
||||
'replyToSenderName': msg.replyToSenderName,
|
||||
'replyToText': msg.replyToText,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,6 +100,10 @@ class ChannelMessageStore {
|
||||
.toList() ??
|
||||
const [],
|
||||
channelIndex: json['channelIndex'] as int?,
|
||||
messageId: json['messageId'] as String?,
|
||||
replyToMessageId: json['replyToMessageId'] as String?,
|
||||
replyToSenderName: json['replyToSenderName'] as String?,
|
||||
replyToText: json['replyToText'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class ChannelOrderStore {
|
||||
static const String _key = 'channel_order';
|
||||
|
||||
Future<void> saveChannelOrder(List<int> order) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
await prefs.setString(_key, jsonEncode(order));
|
||||
}
|
||||
|
||||
Future<List<int>> loadChannelOrder() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final raw = prefs.getString(_key);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
try {
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class ChannelSettingsStore {
|
||||
static const String _smazKeyPrefix = 'channel_smaz_';
|
||||
|
||||
Future<bool> loadSmazEnabled(int channelIndex) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_smazKeyPrefix$channelIndex';
|
||||
return prefs.getBool(key) ?? false;
|
||||
}
|
||||
|
||||
Future<void> saveSmazEnabled(int channelIndex, bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_smazKeyPrefix$channelIndex';
|
||||
await prefs.setBool(key, enabled);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/contact_group.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class ContactGroupStore {
|
||||
static const String _key = 'contact_groups';
|
||||
|
||||
Future<List<ContactGroup>> loadGroups() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final raw = prefs.getString(_key);
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
|
||||
@@ -25,7 +25,7 @@ class ContactGroupStore {
|
||||
}
|
||||
|
||||
Future<void> saveGroups(List<ContactGroup> groups) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final encoded = jsonEncode(groups.map((group) => group.toJson()).toList());
|
||||
await prefs.setString(_key, encoded);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class ContactSettingsStore {
|
||||
static const String _smazKeyPrefix = 'contact_smaz_';
|
||||
|
||||
Future<bool> loadSmazEnabled(String contactKeyHex) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_smazKeyPrefix$contactKeyHex';
|
||||
return prefs.getBool(key) ?? false;
|
||||
}
|
||||
|
||||
Future<void> saveSmazEnabled(String contactKeyHex, bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_smazKeyPrefix$contactKeyHex';
|
||||
await prefs.setBool(key, enabled);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/contact.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class ContactStore {
|
||||
static const String _key = 'contacts';
|
||||
|
||||
Future<List<Contact>> loadContacts() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = prefs.getString(_key);
|
||||
if (jsonStr == null) return [];
|
||||
|
||||
@@ -22,7 +21,7 @@ class ContactStore {
|
||||
}
|
||||
|
||||
Future<void> saveContacts(List<Contact> contacts) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonList = contacts.map(_toJson).toList();
|
||||
await prefs.setString(_key, jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/message.dart';
|
||||
import '../helpers/smaz.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
class MessageStore {
|
||||
static const String _keyPrefix = 'messages_';
|
||||
|
||||
Future<void> saveMessages(String contactKeyHex, List<Message> messages) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_keyPrefix$contactKeyHex';
|
||||
final jsonList = messages.map(_messageToJson).toList();
|
||||
await prefs.setString(key, jsonEncode(jsonList));
|
||||
}
|
||||
|
||||
Future<List<Message>> loadMessages(String contactKeyHex) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_keyPrefix$contactKeyHex';
|
||||
final jsonString = prefs.getString(key);
|
||||
if (jsonString == null) return [];
|
||||
@@ -29,7 +29,7 @@ class MessageStore {
|
||||
}
|
||||
|
||||
Future<void> clearMessages(String contactKeyHex) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final key = '$_keyPrefix$contactKeyHex';
|
||||
await prefs.remove(key);
|
||||
}
|
||||
@@ -41,10 +41,6 @@ class MessageStore {
|
||||
'timestamp': msg.timestamp.millisecondsSinceEpoch,
|
||||
'isOutgoing': msg.isOutgoing,
|
||||
'isCli': msg.isCli,
|
||||
'isVoice': msg.isVoice,
|
||||
'voicePath': msg.voicePath,
|
||||
'voiceDurationMs': msg.voiceDurationMs,
|
||||
'voiceCodec': msg.voiceCodec,
|
||||
'status': msg.status.index,
|
||||
'messageId': msg.messageId,
|
||||
'retryCount': msg.retryCount,
|
||||
@@ -69,10 +65,6 @@ class MessageStore {
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(json['timestamp'] as int),
|
||||
isOutgoing: json['isOutgoing'] as bool,
|
||||
isCli: isCli,
|
||||
isVoice: json['isVoice'] as bool? ?? false,
|
||||
voicePath: json['voicePath'] as String?,
|
||||
voiceDurationMs: json['voiceDurationMs'] as int?,
|
||||
voiceCodec: json['voiceCodec'] as String?,
|
||||
status: MessageStatus.values[json['status'] as int],
|
||||
messageId: json['messageId'] as String?,
|
||||
retryCount: json['retryCount'] as int? ?? 0,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Singleton wrapper for SharedPreferences to avoid redundant getInstance() calls.
|
||||
///
|
||||
/// BEFORE: Every storage operation called SharedPreferences.getInstance()
|
||||
/// AFTER: Single getInstance() on app startup, reused throughout lifecycle
|
||||
///
|
||||
/// This eliminates 30+ redundant platform channel calls across the app.
|
||||
class PrefsManager {
|
||||
PrefsManager._();
|
||||
|
||||
static SharedPreferences? _instance;
|
||||
|
||||
/// Initialize the cached instance. Call this once during app startup in main().
|
||||
static Future<void> initialize() async {
|
||||
_instance ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
/// Get the cached SharedPreferences instance.
|
||||
/// Throws StateError if initialize() hasn't been called.
|
||||
static SharedPreferences get instance {
|
||||
if (_instance == null) {
|
||||
throw StateError(
|
||||
'PrefsManager not initialized. Call PrefsManager.initialize() in main() before use.');
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
/// For testing: reset the instance
|
||||
static void reset() {
|
||||
_instance = null;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,30 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'prefs_manager.dart';
|
||||
|
||||
/// Storage for unread message tracking with debounced writes to reduce I/O.
|
||||
class UnreadStore {
|
||||
static const String _contactLastReadKey = 'contact_last_read';
|
||||
static const String _channelLastReadKey = 'channel_last_read';
|
||||
|
||||
// Debounce timers to batch rapid writes
|
||||
Timer? _contactSaveTimer;
|
||||
Timer? _channelSaveTimer;
|
||||
static const Duration _saveDebounceDuration = Duration(milliseconds: 500);
|
||||
|
||||
// Pending write data
|
||||
Map<String, int>? _pendingContactLastRead;
|
||||
Map<int, int>? _pendingChannelLastRead;
|
||||
|
||||
/// Dispose timers when no longer needed
|
||||
void dispose() {
|
||||
_contactSaveTimer?.cancel();
|
||||
_channelSaveTimer?.cancel();
|
||||
}
|
||||
|
||||
Future<Map<String, int>> loadContactLastRead() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = prefs.getString(_contactLastReadKey);
|
||||
if (jsonStr == null) return {};
|
||||
|
||||
@@ -19,14 +36,24 @@ class UnreadStore {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveContactLastRead(Map<String, int> lastReadMs) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final jsonStr = jsonEncode(lastReadMs);
|
||||
await prefs.setString(_contactLastReadKey, jsonStr);
|
||||
/// Save contact last read timestamps with debouncing.
|
||||
/// Writes are delayed by 500ms and batched to reduce I/O operations.
|
||||
void saveContactLastRead(Map<String, int> lastReadMs) {
|
||||
_pendingContactLastRead = lastReadMs;
|
||||
|
||||
// Cancel existing timer
|
||||
_contactSaveTimer?.cancel();
|
||||
|
||||
// Schedule new write
|
||||
_contactSaveTimer = Timer(_saveDebounceDuration, () async {
|
||||
if (_pendingContactLastRead != null) {
|
||||
await _flushContactLastRead();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<int, int>> loadChannelLastRead() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = prefs.getString(_channelLastReadKey);
|
||||
if (jsonStr == null) return {};
|
||||
|
||||
@@ -38,10 +65,48 @@ class UnreadStore {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveChannelLastRead(Map<int, int> lastReadMs) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final asString = lastReadMs.map((key, value) => MapEntry(key.toString(), value));
|
||||
/// Save channel last read timestamps with debouncing.
|
||||
/// Writes are delayed by 500ms and batched to reduce I/O operations.
|
||||
void saveChannelLastRead(Map<int, int> lastReadMs) {
|
||||
_pendingChannelLastRead = lastReadMs;
|
||||
|
||||
_channelSaveTimer?.cancel();
|
||||
|
||||
_channelSaveTimer = Timer(_saveDebounceDuration, () async {
|
||||
if (_pendingChannelLastRead != null) {
|
||||
await _flushChannelLastRead();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _flushContactLastRead() async {
|
||||
if (_pendingContactLastRead == null) return;
|
||||
|
||||
final prefs = PrefsManager.instance;
|
||||
final jsonStr = jsonEncode(_pendingContactLastRead);
|
||||
await prefs.setString(_contactLastReadKey, jsonStr);
|
||||
_pendingContactLastRead = null;
|
||||
}
|
||||
|
||||
Future<void> _flushChannelLastRead() async {
|
||||
if (_pendingChannelLastRead == null) return;
|
||||
|
||||
final prefs = PrefsManager.instance;
|
||||
final asString =
|
||||
_pendingChannelLastRead!.map((key, value) => MapEntry(key.toString(), value));
|
||||
final jsonStr = jsonEncode(asString);
|
||||
await prefs.setString(_channelLastReadKey, jsonStr);
|
||||
_pendingChannelLastRead = null;
|
||||
}
|
||||
|
||||
/// Immediately flush pending writes (call before app termination or disposal)
|
||||
Future<void> flush() async {
|
||||
_contactSaveTimer?.cancel();
|
||||
_channelSaveTimer?.cancel();
|
||||
|
||||
await Future.wait([
|
||||
_flushContactLastRead(),
|
||||
_flushChannelLastRead(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
|
||||
/// Shows a confirmation dialog before disconnecting from the device.
|
||||
/// Returns true if user confirmed and disconnect completed, false otherwise.
|
||||
Future<bool> showDisconnectDialog(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
|
||||
/// Mixin that automatically navigates back to scanner when disconnected.
|
||||
/// Use in State classes for screens that require active connection.
|
||||
mixin DisconnectNavigationMixin<T extends StatefulWidget> on State<T> {
|
||||
/// Call this in your Widget build method to enable auto-navigation.
|
||||
/// Returns true if still connected, false if navigation was triggered.
|
||||
bool checkConnectionAndNavigate(MeshCoreConnector connector) {
|
||||
if (!connector.isConnected) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
Navigator.popUntil(context, (route) => route.isFirst);
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A centered empty state display with icon, title, and optional subtitle/action.
|
||||
class EmptyState extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? action;
|
||||
|
||||
const EmptyState({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.action,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
action!,
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit_fork/media_kit_fork.dart';
|
||||
|
||||
import '../models/message.dart';
|
||||
|
||||
class VoiceMessageBubble extends StatefulWidget {
|
||||
final Message message;
|
||||
final Color backgroundColor;
|
||||
final Color textColor;
|
||||
final Color metaColor;
|
||||
final bool isOutgoing;
|
||||
|
||||
const VoiceMessageBubble({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.backgroundColor,
|
||||
required this.textColor,
|
||||
required this.metaColor,
|
||||
required this.isOutgoing,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VoiceMessageBubble> createState() => _VoiceMessageBubbleState();
|
||||
}
|
||||
|
||||
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
|
||||
late final Player _player;
|
||||
StreamSubscription<Duration>? _durationSubscription;
|
||||
StreamSubscription<bool>? _completeSubscription;
|
||||
Duration _duration = Duration.zero;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_player = Player();
|
||||
final voicePath = widget.message.voicePath;
|
||||
if (voicePath != null && voicePath.isNotEmpty) {
|
||||
_player.open(Media(Uri.file(voicePath).toString()), play: false);
|
||||
}
|
||||
_durationSubscription = _player.stream.duration.listen((value) {
|
||||
if (!mounted) return;
|
||||
if (value > Duration.zero && value != _duration) {
|
||||
setState(() {
|
||||
_duration = value;
|
||||
});
|
||||
}
|
||||
});
|
||||
_completeSubscription = _player.stream.completed.listen((completed) {
|
||||
if (!completed) return;
|
||||
_player.seek(Duration.zero);
|
||||
_player.pause();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_durationSubscription?.cancel();
|
||||
_completeSubscription?.cancel();
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasAudio = widget.message.voicePath != null && widget.message.voicePath!.isNotEmpty;
|
||||
final fallbackDuration = Duration(milliseconds: widget.message.voiceDurationMs ?? 0);
|
||||
final displayDuration = _duration > Duration.zero ? _duration : fallbackDuration;
|
||||
|
||||
return StreamBuilder<bool>(
|
||||
stream: _player.stream.playing,
|
||||
initialData: false,
|
||||
builder: (context, playingSnapshot) {
|
||||
final isPlaying = playingSnapshot.data ?? false;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(isPlaying ? Icons.pause : Icons.play_arrow),
|
||||
color: widget.textColor,
|
||||
onPressed: hasAudio
|
||||
? () {
|
||||
if (isPlaying) {
|
||||
_player.pause();
|
||||
} else {
|
||||
_player.play();
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<Duration>(
|
||||
stream: _player.stream.position,
|
||||
initialData: Duration.zero,
|
||||
builder: (context, positionSnapshot) {
|
||||
final position = positionSnapshot.data ?? Duration.zero;
|
||||
final progress = displayDuration.inMilliseconds > 0
|
||||
? position.inMilliseconds / displayDuration.inMilliseconds
|
||||
: 0.0;
|
||||
return LinearProgressIndicator(
|
||||
value: progress.clamp(0.0, 1.0),
|
||||
backgroundColor: widget.metaColor.withValues(alpha: 0.2),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(widget.textColor),
|
||||
minHeight: 4,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatDuration(displayDuration),
|
||||
style: TextStyle(
|
||||
color: widget.metaColor,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final totalSeconds = duration.inSeconds;
|
||||
final minutes = totalSeconds ~/ 60;
|
||||
final seconds = totalSeconds % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user