remove voice code make optimizations. Fix channels race conditions. add reply function

This commit is contained in:
zach
2025-12-30 19:27:25 -07:00
parent 6ff950d426
commit baf92ef672
582 changed files with 814 additions and 179108 deletions
+136 -400
View File
@@ -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
View File
@@ -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();
+37 -1
View File
@@ -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,
});
}
-18
View File
@@ -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,
);
+214 -17
View File
@@ -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'),
+44 -53
View File
@@ -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
View File
@@ -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(
+24 -48
View File
@@ -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();
});
});
},
),
+8 -29
View File
@@ -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);
}
}
+3 -3
View File
@@ -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);
}
+3 -3
View File
@@ -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());
}
}
+32
View File
@@ -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 {
+12 -12
View File
@@ -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);
}
}
-220
View File
@@ -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,
});
}
+13 -5
View File
@@ -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?,
);
}
+3 -3
View File
@@ -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 {
+3 -3
View File
@@ -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);
}
+3 -3
View File
@@ -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);
}
+3 -3
View File
@@ -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);
}
+3 -4
View File
@@ -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));
}
+4 -12
View File
@@ -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,
+33
View File
@@ -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;
}
}
+75 -10
View File
@@ -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(),
]);
}
}
+34
View File
@@ -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;
}
}
+46
View File
@@ -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!,
],
],
),
);
}
}
-134
View File
@@ -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')}';
}
}