mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-25 03:42:55 +10:00
updated ui added new features
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' as crypto;
|
||||
import 'package:pointycastle/export.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../models/channel.dart';
|
||||
import '../models/channel_message.dart';
|
||||
@@ -17,7 +19,9 @@ import '../services/ble_debug_log_service.dart';
|
||||
import '../services/message_retry_service.dart';
|
||||
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';
|
||||
@@ -48,6 +52,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
BluetoothCharacteristic? _txCharacteristic;
|
||||
String? _deviceDisplayName;
|
||||
String? _deviceId;
|
||||
BluetoothDevice? _lastDevice;
|
||||
String? _lastDeviceId;
|
||||
String? _lastDeviceDisplayName;
|
||||
bool _manualDisconnect = false;
|
||||
|
||||
final List<ScanResult> _scanResults = [];
|
||||
final List<Contact> _contacts = [];
|
||||
@@ -60,6 +68,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
StreamSubscription<BluetoothConnectionState>? _connectionSubscription;
|
||||
StreamSubscription<List<int>>? _notifySubscription;
|
||||
Timer? _selfInfoRetryTimer;
|
||||
Timer? _reconnectTimer;
|
||||
int _reconnectAttempts = 0;
|
||||
|
||||
final StreamController<Uint8List> _receivedFramesController =
|
||||
StreamController<Uint8List>.broadcast();
|
||||
@@ -93,6 +103,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
MessageRetryService? _retryService;
|
||||
PathHistoryService? _pathHistoryService;
|
||||
AppSettingsService? _appSettingsService;
|
||||
BackgroundService? _backgroundService;
|
||||
final NotificationService _notificationService = NotificationService();
|
||||
BleDebugLogService? _bleDebugLogService;
|
||||
final ChannelMessageStore _channelMessageStore = ChannelMessageStore();
|
||||
@@ -102,6 +113,9 @@ 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 = {};
|
||||
@@ -110,12 +124,23 @@ 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!;
|
||||
@@ -130,7 +155,15 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
return 'Unknown Device';
|
||||
}
|
||||
List<ScanResult> get scanResults => List.unmodifiable(_scanResults);
|
||||
List<Contact> get contacts => List.unmodifiable(_contacts);
|
||||
List<Contact> get contacts {
|
||||
final selfKey = _selfPublicKey;
|
||||
if (selfKey == null) {
|
||||
return List.unmodifiable(_contacts);
|
||||
}
|
||||
return List.unmodifiable(
|
||||
_contacts.where((contact) => !listEquals(contact.publicKey, selfKey)),
|
||||
);
|
||||
}
|
||||
List<Channel> get channels => List.unmodifiable(_channels);
|
||||
bool get isConnected => _state == MeshCoreConnectionState.connected;
|
||||
bool get isLoadingContacts => _isLoadingContacts;
|
||||
@@ -194,6 +227,12 @@ 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();
|
||||
}
|
||||
@@ -358,11 +397,13 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
required PathHistoryService pathHistoryService,
|
||||
AppSettingsService? appSettingsService,
|
||||
BleDebugLogService? bleDebugLogService,
|
||||
BackgroundService? backgroundService,
|
||||
}) {
|
||||
_retryService = retryService;
|
||||
_pathHistoryService = pathHistoryService;
|
||||
_appSettingsService = appSettingsService;
|
||||
_bleDebugLogService = bleDebugLogService;
|
||||
_backgroundService = backgroundService;
|
||||
|
||||
// Initialize notification service
|
||||
_notificationService.initialize();
|
||||
@@ -461,6 +502,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
latitude: contact.latitude,
|
||||
longitude: contact.longitude,
|
||||
lastSeen: contact.lastSeen,
|
||||
lastMessageAt: contact.lastMessageAt,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -515,6 +557,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
} else if (device.platformName.isNotEmpty) {
|
||||
_deviceDisplayName = device.platformName;
|
||||
}
|
||||
_lastDevice = device;
|
||||
_lastDeviceId = _deviceId;
|
||||
_lastDeviceDisplayName = _deviceDisplayName;
|
||||
_manualDisconnect = false;
|
||||
_cancelReconnectTimer();
|
||||
unawaited(_backgroundService?.start());
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
@@ -565,6 +613,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
throw Exception("MeshCore characteristics not found");
|
||||
}
|
||||
|
||||
// Give the device a moment to be ready for descriptor writes
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
await _txCharacteristic!.setNotifyValue(true);
|
||||
_notifySubscription = _txCharacteristic!.onValueReceived.listen(_handleFrame);
|
||||
|
||||
@@ -583,7 +634,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
await syncTime();
|
||||
} catch (e) {
|
||||
debugPrint("Connection error: $e");
|
||||
await disconnect();
|
||||
await disconnect(manual: false);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
@@ -619,9 +670,58 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
bool get _shouldAutoReconnect =>
|
||||
!_manualDisconnect && _lastDeviceId != null;
|
||||
|
||||
void _cancelReconnectTimer() {
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = null;
|
||||
_reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
int _nextReconnectDelayMs() {
|
||||
final attempt = _reconnectAttempts < 6 ? _reconnectAttempts : 6;
|
||||
_reconnectAttempts += 1;
|
||||
final delayMs = 1000 * (1 << attempt);
|
||||
return delayMs > 30000 ? 30000 : delayMs;
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
if (!_shouldAutoReconnect) return;
|
||||
if (_reconnectTimer?.isActive == true) return;
|
||||
|
||||
final delayMs = _nextReconnectDelayMs();
|
||||
_reconnectTimer = Timer(Duration(milliseconds: delayMs), () async {
|
||||
if (!_shouldAutoReconnect) return;
|
||||
if (_state == MeshCoreConnectionState.connecting ||
|
||||
_state == MeshCoreConnectionState.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
final device = _lastDevice ??
|
||||
(_lastDeviceId == null
|
||||
? null
|
||||
: BluetoothDevice.fromId(_lastDeviceId!));
|
||||
if (device == null) return;
|
||||
|
||||
try {
|
||||
await connect(device, displayName: _lastDeviceDisplayName);
|
||||
} catch (_) {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> disconnect({bool manual = true}) async {
|
||||
if (_state == MeshCoreConnectionState.disconnecting) return;
|
||||
|
||||
if (manual) {
|
||||
_manualDisconnect = true;
|
||||
_cancelReconnectTimer();
|
||||
unawaited(_backgroundService?.stop());
|
||||
} else {
|
||||
_manualDisconnect = false;
|
||||
}
|
||||
_setState(MeshCoreConnectionState.disconnecting);
|
||||
|
||||
await _notifySubscription?.cancel();
|
||||
@@ -633,7 +733,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_selfInfoRetryTimer = null;
|
||||
|
||||
try {
|
||||
await _device?.disconnect();
|
||||
// Skip queued BLE operations so disconnect doesn't get stuck behind them.
|
||||
await _device?.disconnect(queue: false);
|
||||
} catch (e) {
|
||||
debugPrint("Disconnect error: $e");
|
||||
}
|
||||
@@ -663,6 +764,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_didInitialQueueSync = false;
|
||||
|
||||
_setState(MeshCoreConnectionState.disconnected);
|
||||
if (!manual) {
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendFrame(Uint8List data) async {
|
||||
@@ -762,6 +866,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) {
|
||||
@@ -825,6 +933,142 @@ 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 {
|
||||
if (!isConnected) return;
|
||||
|
||||
@@ -839,6 +1083,10 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
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);
|
||||
@@ -886,6 +1134,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
latitude: existing.latitude,
|
||||
longitude: existing.longitude,
|
||||
lastSeen: existing.lastSeen,
|
||||
lastMessageAt: existing.lastMessageAt,
|
||||
);
|
||||
notifyListeners();
|
||||
unawaited(_persistContacts());
|
||||
@@ -1233,7 +1482,13 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
_contacts[existingIndex] = contact;
|
||||
final existing = _contacts[existingIndex];
|
||||
final mergedLastMessageAt = existing.lastMessageAt.isAfter(contact.lastMessageAt)
|
||||
? existing.lastMessageAt
|
||||
: contact.lastMessageAt;
|
||||
_contacts[existingIndex] = contact.copyWith(
|
||||
lastMessageAt: mergedLastMessageAt,
|
||||
);
|
||||
} else {
|
||||
_contacts.add(contact);
|
||||
}
|
||||
@@ -1275,6 +1530,83 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
return latest;
|
||||
}
|
||||
|
||||
bool _setContactLastMessageAt(int index, DateTime timestamp) {
|
||||
final contact = _contacts[index];
|
||||
if (contact.type != advTypeChat) return false;
|
||||
if (!timestamp.isAfter(contact.lastMessageAt)) return false;
|
||||
_contacts[index] = contact.copyWith(lastMessageAt: timestamp);
|
||||
return true;
|
||||
}
|
||||
|
||||
void _updateContactLastMessageAt(
|
||||
String contactKeyHex,
|
||||
DateTime timestamp, {
|
||||
bool notify = false,
|
||||
}) {
|
||||
final index = _contacts.indexWhere((c) => c.publicKeyHex == contactKeyHex);
|
||||
if (index < 0) return;
|
||||
if (!_setContactLastMessageAt(index, timestamp)) return;
|
||||
unawaited(_persistContacts());
|
||||
if (notify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _updateContactLastMessageAtByName(
|
||||
String senderName,
|
||||
DateTime timestamp, {
|
||||
Uint8List? pathBytes,
|
||||
bool notify = false,
|
||||
}) {
|
||||
final normalized = senderName.trim().toLowerCase();
|
||||
final hasName = normalized.isNotEmpty && normalized != 'unknown';
|
||||
var updated = false;
|
||||
var matchedByName = false;
|
||||
|
||||
if (hasName) {
|
||||
for (var i = 0; i < _contacts.length; i++) {
|
||||
final contact = _contacts[i];
|
||||
if (contact.type != advTypeChat) continue;
|
||||
if (contact.name.trim().toLowerCase() == normalized) {
|
||||
matchedByName = true;
|
||||
updated = _setContactLastMessageAt(i, timestamp) || updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedByName && pathBytes != null && pathBytes.isNotEmpty) {
|
||||
final matches = <int>[];
|
||||
for (var i = 0; i < _contacts.length; i++) {
|
||||
final contact = _contacts[i];
|
||||
if (contact.type != advTypeChat) continue;
|
||||
if (_pathMatchesContact(pathBytes, contact.publicKey)) {
|
||||
matches.add(i);
|
||||
}
|
||||
}
|
||||
if (matches.length == 1) {
|
||||
updated = _setContactLastMessageAt(matches.first, timestamp) || updated;
|
||||
}
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
unawaited(_persistContacts());
|
||||
if (notify) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _pathMatchesContact(Uint8List pathBytes, Uint8List publicKey) {
|
||||
if (pathBytes.isEmpty || publicKey.length < pathHashSize) return false;
|
||||
for (int i = 0; i + pathHashSize <= pathBytes.length; i += pathHashSize) {
|
||||
final prefix = pathBytes.sublist(i, i + pathHashSize);
|
||||
if (_matchesPrefix(publicKey, prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _handleIncomingMessage(Uint8List frame) {
|
||||
if (_selfPublicKey == null) return;
|
||||
|
||||
@@ -1290,6 +1622,12 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
pathBytes: contact.pathLength < 0 ? Uint8List(0) : contact.path,
|
||||
);
|
||||
}
|
||||
if (_tryHandleVoiceChunk(message)) {
|
||||
return;
|
||||
}
|
||||
if (contact != null) {
|
||||
_updateContactLastMessageAt(contact.publicKeyHex, message.timestamp);
|
||||
}
|
||||
if (!message.isOutgoing) {
|
||||
final existing = _conversations[message.senderKeyHex];
|
||||
final incomingTimestamp = message.timestamp.millisecondsSinceEpoch;
|
||||
@@ -1404,22 +1742,179 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
|
||||
String _prepareContactOutboundText(Contact contact, String text) {
|
||||
final trimmed = text.trim();
|
||||
final isStructuredPayload = trimmed.startsWith('g:') || trimmed.startsWith('m:');
|
||||
final isStructuredPayload =
|
||||
trimmed.startsWith('g:') || trimmed.startsWith('m:') || trimmed.startsWith('V1|');
|
||||
if (!isStructuredPayload && isContactSmazEnabled(contact.publicKeyHex)) {
|
||||
return Smaz.encodeIfSmaller(text);
|
||||
}
|
||||
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) {
|
||||
if (channel.index != channelIndex) continue;
|
||||
return channel.name.isEmpty ? 'Channel $channelIndex' : channel.name;
|
||||
}
|
||||
return 'Channel $channelIndex';
|
||||
}
|
||||
|
||||
void _maybeNotifyChannelMessage(
|
||||
ChannelMessage message, {
|
||||
String? channelName,
|
||||
}) {
|
||||
if (message.isOutgoing || _appSettingsService == null) return;
|
||||
final channelIndex = message.channelIndex;
|
||||
if (channelIndex == null) return;
|
||||
|
||||
final settings = _appSettingsService!.settings;
|
||||
if (!settings.notificationsEnabled || !settings.notifyOnNewChannelMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
final label = channelName ?? _channelDisplayName(channelIndex);
|
||||
_notificationService.showChannelMessageNotification(
|
||||
channelName: label,
|
||||
message: message.text,
|
||||
channelIndex: channelIndex,
|
||||
);
|
||||
}
|
||||
|
||||
void _handleIncomingChannelMessage(Uint8List frame) {
|
||||
final message = ChannelMessage.fromFrame(frame);
|
||||
if (message != null && message.channelIndex != null) {
|
||||
if (_shouldDropSelfChannelMessage(message.senderName, message.pathBytes)) {
|
||||
return;
|
||||
}
|
||||
_addChannelMessage(message.channelIndex!, message);
|
||||
_updateContactLastMessageAtByName(
|
||||
message.senderName,
|
||||
message.timestamp,
|
||||
pathBytes: message.pathBytes,
|
||||
);
|
||||
final isNew = _addChannelMessage(message.channelIndex!, message);
|
||||
_maybeMarkActiveChannelRead(message);
|
||||
notifyListeners();
|
||||
if (isNew) {
|
||||
_maybeNotifyChannelMessage(message);
|
||||
}
|
||||
_handleQueuedMessageReceived();
|
||||
} else if (_isSyncingQueuedMessages) {
|
||||
_handleQueuedMessageReceived();
|
||||
@@ -1470,9 +1965,18 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
channelIndex: channel.index,
|
||||
);
|
||||
|
||||
_addChannelMessage(channel.index, message);
|
||||
_updateContactLastMessageAtByName(
|
||||
parsed.senderName,
|
||||
message.timestamp,
|
||||
pathBytes: message.pathBytes,
|
||||
);
|
||||
final isNew = _addChannelMessage(channel.index, message);
|
||||
_maybeMarkActiveChannelRead(message);
|
||||
notifyListeners();
|
||||
if (isNew) {
|
||||
final label = channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name;
|
||||
_maybeNotifyChannelMessage(message, channelName: label);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1484,11 +1988,15 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
// [2-5] = expected_ack_hash (uint32)
|
||||
// [6-9] = estimated_timeout_ms (uint32)
|
||||
|
||||
if (frame.length >= 10 && _retryService != null) {
|
||||
if (frame.length >= 10) {
|
||||
final isFlood = frame[1] != 0;
|
||||
final ackHash = Uint8List.fromList(frame.sublist(2, 6));
|
||||
final timeoutMs = readUint32LE(frame, 6);
|
||||
|
||||
_retryService!.updateMessageFromSent(ackHash, timeoutMs);
|
||||
if (_retryService != null) {
|
||||
_retryService!.updateMessageFromSent(ackHash, timeoutMs);
|
||||
}
|
||||
_handleVoiceMessageSent(ackHash, timeoutMs, isFlood: isFlood);
|
||||
} else {
|
||||
// Fallback to old behavior
|
||||
for (var messages in _conversations.values) {
|
||||
@@ -1517,6 +2025,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
if (_retryService != null) {
|
||||
_retryService!.handleAckReceived(ackHash, tripTimeMs);
|
||||
}
|
||||
_handleVoiceSendConfirmed(ackHash);
|
||||
} else {
|
||||
// Fallback to old behavior
|
||||
for (var messages in _conversations.values) {
|
||||
@@ -1564,8 +2073,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
Future<void> setChannelOrder(List<int> order) async {
|
||||
_channelOrder = List<int>.from(order);
|
||||
_applyChannelOrder();
|
||||
await _channelOrderStore.saveChannelOrder(_channelOrder);
|
||||
notifyListeners();
|
||||
await _channelOrderStore.saveChannelOrder(_channelOrder);
|
||||
}
|
||||
|
||||
bool _shouldTrackUnreadForContactKey(String contactKeyHex) {
|
||||
@@ -1760,17 +2269,26 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
return contact.pathLength;
|
||||
}
|
||||
|
||||
void _addChannelMessage(int channelIndex, ChannelMessage message) {
|
||||
bool _addChannelMessage(int channelIndex, ChannelMessage message) {
|
||||
_channelMessages.putIfAbsent(channelIndex, () => []);
|
||||
final messages = _channelMessages[channelIndex]!;
|
||||
final existingIndex = _findChannelRepeatIndex(messages, message);
|
||||
var isNew = true;
|
||||
if (existingIndex >= 0) {
|
||||
isNew = false;
|
||||
final existing = messages[existingIndex];
|
||||
final mergedPathBytes = existing.pathBytes.isEmpty ? message.pathBytes : existing.pathBytes;
|
||||
final mergedPathBytes = _selectPreferredPathBytes(existing.pathBytes, message.pathBytes);
|
||||
final mergedPathVariants = _mergePathVariants(existing.pathVariants, message.pathVariants);
|
||||
final mergedPathLength = _mergePathLength(
|
||||
existing.pathLength,
|
||||
message.pathLength,
|
||||
mergedPathBytes.length,
|
||||
);
|
||||
messages[existingIndex] = existing.copyWith(
|
||||
repeatCount: existing.repeatCount + 1,
|
||||
pathLength: message.pathLength ?? existing.pathLength,
|
||||
pathLength: mergedPathLength,
|
||||
pathBytes: mergedPathBytes,
|
||||
pathVariants: mergedPathVariants,
|
||||
);
|
||||
} else {
|
||||
messages.add(message);
|
||||
@@ -1781,6 +2299,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
channelIndex,
|
||||
messages,
|
||||
);
|
||||
return isNew;
|
||||
}
|
||||
|
||||
int _findChannelRepeatIndex(List<ChannelMessage> messages, ChannelMessage incoming) {
|
||||
@@ -1838,6 +2357,56 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
return false;
|
||||
}
|
||||
|
||||
Uint8List _selectPreferredPathBytes(Uint8List existing, Uint8List incoming) {
|
||||
if (incoming.isEmpty) return existing;
|
||||
if (existing.isEmpty) return incoming;
|
||||
if (incoming.length > existing.length) return incoming;
|
||||
return existing;
|
||||
}
|
||||
|
||||
int? _mergePathLength(int? existing, int? incoming, int observedLength) {
|
||||
if (existing == null) {
|
||||
if (incoming == null) return observedLength > 0 ? observedLength : null;
|
||||
return incoming >= observedLength ? incoming : observedLength;
|
||||
}
|
||||
if (incoming == null) {
|
||||
return existing >= observedLength ? existing : observedLength;
|
||||
}
|
||||
final merged = existing >= incoming ? existing : incoming;
|
||||
return merged >= observedLength ? merged : observedLength;
|
||||
}
|
||||
|
||||
List<Uint8List> _mergePathVariants(
|
||||
List<Uint8List> existing,
|
||||
List<Uint8List> incoming,
|
||||
) {
|
||||
if (incoming.isEmpty) return existing;
|
||||
if (existing.isEmpty) return incoming;
|
||||
|
||||
final merged = <Uint8List>[...existing];
|
||||
for (final candidate in incoming) {
|
||||
var already = false;
|
||||
for (final current in merged) {
|
||||
if (_pathsEqual(current, candidate)) {
|
||||
already = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!already && candidate.isNotEmpty) {
|
||||
merged.add(candidate);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
bool _pathsEqual(Uint8List a, Uint8List b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void _handleDisconnection() {
|
||||
_notifySubscription?.cancel();
|
||||
_notifySubscription = null;
|
||||
@@ -1853,8 +2422,11 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_maxChannels = _defaultMaxChannels;
|
||||
_isSyncingQueuedMessages = false;
|
||||
_queuedMessageSyncInFlight = false;
|
||||
_voiceAssemblies.clear();
|
||||
_voiceSendSession = null;
|
||||
|
||||
_setState(MeshCoreConnectionState.disconnected);
|
||||
_scheduleReconnect();
|
||||
}
|
||||
|
||||
void _setState(MeshCoreConnectionState newState) {
|
||||
@@ -1869,6 +2441,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
||||
_scanSubscription?.cancel();
|
||||
_connectionSubscription?.cancel();
|
||||
_notifySubscription?.cancel();
|
||||
_reconnectTimer?.cancel();
|
||||
_receivedFramesController.close();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -1917,3 +2490,93 @@ class _ParsedText {
|
||||
required this.text,
|
||||
});
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -9,9 +10,12 @@ import 'services/path_history_service.dart';
|
||||
import 'services/app_settings_service.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'services/ble_debug_log_service.dart';
|
||||
import 'services/background_service.dart';
|
||||
import 'services/map_tile_cache_service.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
MediaKit.ensureInitialized();
|
||||
|
||||
// Initialize services
|
||||
final storage = StorageService();
|
||||
@@ -20,6 +24,8 @@ void main() async {
|
||||
final retryService = MessageRetryService(storage);
|
||||
final appSettingsService = AppSettingsService();
|
||||
final bleDebugLogService = BleDebugLogService();
|
||||
final backgroundService = BackgroundService();
|
||||
final mapTileCacheService = MapTileCacheService();
|
||||
|
||||
// Load settings
|
||||
await appSettingsService.loadSettings();
|
||||
@@ -27,6 +33,7 @@ void main() async {
|
||||
// Initialize notification service
|
||||
final notificationService = NotificationService();
|
||||
await notificationService.initialize();
|
||||
await backgroundService.initialize();
|
||||
|
||||
// Wire up connector with services
|
||||
connector.initialize(
|
||||
@@ -34,6 +41,7 @@ void main() async {
|
||||
pathHistoryService: pathHistoryService,
|
||||
appSettingsService: appSettingsService,
|
||||
bleDebugLogService: bleDebugLogService,
|
||||
backgroundService: backgroundService,
|
||||
);
|
||||
|
||||
await connector.loadContactCache();
|
||||
@@ -50,6 +58,7 @@ void main() async {
|
||||
storage: storage,
|
||||
appSettingsService: appSettingsService,
|
||||
bleDebugLogService: bleDebugLogService,
|
||||
mapTileCacheService: mapTileCacheService,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -60,6 +69,7 @@ class MeshCoreApp extends StatelessWidget {
|
||||
final StorageService storage;
|
||||
final AppSettingsService appSettingsService;
|
||||
final BleDebugLogService bleDebugLogService;
|
||||
final MapTileCacheService mapTileCacheService;
|
||||
|
||||
const MeshCoreApp({
|
||||
super.key,
|
||||
@@ -69,6 +79,7 @@ class MeshCoreApp extends StatelessWidget {
|
||||
required this.storage,
|
||||
required this.appSettingsService,
|
||||
required this.bleDebugLogService,
|
||||
required this.mapTileCacheService,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -81,6 +92,7 @@ class MeshCoreApp extends StatelessWidget {
|
||||
ChangeNotifierProvider.value(value: appSettingsService),
|
||||
ChangeNotifierProvider.value(value: bleDebugLogService),
|
||||
Provider.value(value: storage),
|
||||
Provider.value(value: mapTileCacheService),
|
||||
],
|
||||
child: Consumer<AppSettingsService>(
|
||||
builder: (context, settingsService, child) {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class AppSettings {
|
||||
static const Object _unset = Object();
|
||||
|
||||
final bool clearPathOnMaxRetry;
|
||||
final bool mapShowRepeaters;
|
||||
final bool mapShowChatNodes;
|
||||
@@ -7,8 +9,12 @@ class AppSettings {
|
||||
final bool mapKeyPrefixEnabled;
|
||||
final String mapKeyPrefix;
|
||||
final bool mapShowMarkers;
|
||||
final Map<String, double>? mapCacheBounds;
|
||||
final int mapCacheMinZoom;
|
||||
final int mapCacheMaxZoom;
|
||||
final bool notificationsEnabled;
|
||||
final bool notifyOnNewMessage;
|
||||
final bool notifyOnNewChannelMessage;
|
||||
final bool notifyOnNewAdvert;
|
||||
final bool autoRouteRotationEnabled;
|
||||
final String themeMode;
|
||||
@@ -23,8 +29,12 @@ class AppSettings {
|
||||
this.mapKeyPrefixEnabled = false,
|
||||
this.mapKeyPrefix = '',
|
||||
this.mapShowMarkers = true,
|
||||
this.mapCacheBounds,
|
||||
this.mapCacheMinZoom = 10,
|
||||
this.mapCacheMaxZoom = 15,
|
||||
this.notificationsEnabled = true,
|
||||
this.notifyOnNewMessage = true,
|
||||
this.notifyOnNewChannelMessage = true,
|
||||
this.notifyOnNewAdvert = true,
|
||||
this.autoRouteRotationEnabled = false,
|
||||
this.themeMode = 'system',
|
||||
@@ -41,8 +51,12 @@ class AppSettings {
|
||||
'map_key_prefix_enabled': mapKeyPrefixEnabled,
|
||||
'map_key_prefix': mapKeyPrefix,
|
||||
'map_show_markers': mapShowMarkers,
|
||||
'map_cache_bounds': mapCacheBounds,
|
||||
'map_cache_min_zoom': mapCacheMinZoom,
|
||||
'map_cache_max_zoom': mapCacheMaxZoom,
|
||||
'notifications_enabled': notificationsEnabled,
|
||||
'notify_on_new_message': notifyOnNewMessage,
|
||||
'notify_on_new_channel_message': notifyOnNewChannelMessage,
|
||||
'notify_on_new_advert': notifyOnNewAdvert,
|
||||
'auto_route_rotation_enabled': autoRouteRotationEnabled,
|
||||
'theme_mode': themeMode,
|
||||
@@ -60,8 +74,15 @@ class AppSettings {
|
||||
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
|
||||
mapKeyPrefix: json['map_key_prefix'] as String? ?? '',
|
||||
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
|
||||
mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map(
|
||||
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
|
||||
),
|
||||
mapCacheMinZoom: json['map_cache_min_zoom'] as int? ?? 10,
|
||||
mapCacheMaxZoom: json['map_cache_max_zoom'] as int? ?? 15,
|
||||
notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
|
||||
notifyOnNewMessage: json['notify_on_new_message'] as bool? ?? true,
|
||||
notifyOnNewChannelMessage:
|
||||
json['notify_on_new_channel_message'] as bool? ?? true,
|
||||
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
|
||||
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
|
||||
themeMode: json['theme_mode'] as String? ?? 'system',
|
||||
@@ -81,8 +102,12 @@ class AppSettings {
|
||||
bool? mapKeyPrefixEnabled,
|
||||
String? mapKeyPrefix,
|
||||
bool? mapShowMarkers,
|
||||
Object? mapCacheBounds = _unset,
|
||||
int? mapCacheMinZoom,
|
||||
int? mapCacheMaxZoom,
|
||||
bool? notificationsEnabled,
|
||||
bool? notifyOnNewMessage,
|
||||
bool? notifyOnNewChannelMessage,
|
||||
bool? notifyOnNewAdvert,
|
||||
bool? autoRouteRotationEnabled,
|
||||
String? themeMode,
|
||||
@@ -97,8 +122,14 @@ class AppSettings {
|
||||
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
|
||||
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
|
||||
mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers,
|
||||
mapCacheBounds:
|
||||
mapCacheBounds == _unset ? this.mapCacheBounds : mapCacheBounds as Map<String, double>?,
|
||||
mapCacheMinZoom: mapCacheMinZoom ?? this.mapCacheMinZoom,
|
||||
mapCacheMaxZoom: mapCacheMaxZoom ?? this.mapCacheMaxZoom,
|
||||
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
|
||||
notifyOnNewMessage: notifyOnNewMessage ?? this.notifyOnNewMessage,
|
||||
notifyOnNewChannelMessage:
|
||||
notifyOnNewChannelMessage ?? this.notifyOnNewChannelMessage,
|
||||
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
|
||||
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
|
||||
@@ -32,6 +32,7 @@ class ChannelMessage {
|
||||
final int repeatCount;
|
||||
final int? pathLength;
|
||||
final Uint8List pathBytes;
|
||||
final List<Uint8List> pathVariants;
|
||||
final int? channelIndex;
|
||||
|
||||
ChannelMessage({
|
||||
@@ -45,8 +46,13 @@ class ChannelMessage {
|
||||
this.repeatCount = 0,
|
||||
this.pathLength,
|
||||
Uint8List? pathBytes,
|
||||
List<Uint8List>? pathVariants,
|
||||
this.channelIndex,
|
||||
}) : pathBytes = pathBytes ?? Uint8List(0);
|
||||
}) : pathBytes = pathBytes ?? Uint8List(0),
|
||||
pathVariants = _mergePathVariants(
|
||||
pathBytes ?? Uint8List(0),
|
||||
pathVariants,
|
||||
);
|
||||
|
||||
String? get senderKeyHex => senderKey != null ? pubKeyToHex(senderKey!) : null;
|
||||
|
||||
@@ -56,6 +62,7 @@ class ChannelMessage {
|
||||
int? repeatCount,
|
||||
int? pathLength,
|
||||
Uint8List? pathBytes,
|
||||
List<Uint8List>? pathVariants,
|
||||
}) {
|
||||
return ChannelMessage(
|
||||
senderKey: senderKey,
|
||||
@@ -68,6 +75,7 @@ class ChannelMessage {
|
||||
repeatCount: repeatCount ?? this.repeatCount,
|
||||
pathLength: pathLength ?? this.pathLength,
|
||||
pathBytes: pathBytes ?? this.pathBytes,
|
||||
pathVariants: pathVariants ?? this.pathVariants,
|
||||
channelIndex: channelIndex,
|
||||
);
|
||||
}
|
||||
@@ -164,7 +172,39 @@ class ChannelMessage {
|
||||
status: ChannelMessageStatus.pending,
|
||||
pathLength: null,
|
||||
pathBytes: Uint8List(0),
|
||||
pathVariants: const [],
|
||||
channelIndex: channelIndex,
|
||||
);
|
||||
}
|
||||
|
||||
static List<Uint8List> _mergePathVariants(
|
||||
Uint8List pathBytes,
|
||||
List<Uint8List>? pathVariants,
|
||||
) {
|
||||
final merged = <Uint8List>[];
|
||||
|
||||
void addPath(Uint8List bytes) {
|
||||
if (bytes.isEmpty) return;
|
||||
for (final existing in merged) {
|
||||
if (_pathsEqual(existing, bytes)) return;
|
||||
}
|
||||
merged.add(bytes);
|
||||
}
|
||||
|
||||
if (pathVariants != null) {
|
||||
for (final variant in pathVariants) {
|
||||
addPath(variant);
|
||||
}
|
||||
}
|
||||
addPath(pathBytes);
|
||||
return merged;
|
||||
}
|
||||
|
||||
static bool _pathsEqual(Uint8List a, Uint8List b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+27
-1
@@ -10,6 +10,7 @@ class Contact {
|
||||
final double? latitude;
|
||||
final double? longitude;
|
||||
final DateTime lastSeen;
|
||||
final DateTime lastMessageAt;
|
||||
|
||||
Contact({
|
||||
required this.publicKey,
|
||||
@@ -20,7 +21,8 @@ class Contact {
|
||||
this.latitude,
|
||||
this.longitude,
|
||||
required this.lastSeen,
|
||||
});
|
||||
DateTime? lastMessageAt,
|
||||
}) : lastMessageAt = lastMessageAt ?? lastSeen;
|
||||
|
||||
String get publicKeyHex => pubKeyToHex(publicKey);
|
||||
|
||||
@@ -47,6 +49,30 @@ class Contact {
|
||||
|
||||
bool get hasLocation => latitude != null && longitude != null;
|
||||
|
||||
Contact copyWith({
|
||||
Uint8List? publicKey,
|
||||
String? name,
|
||||
int? type,
|
||||
int? pathLength,
|
||||
Uint8List? path,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
DateTime? lastSeen,
|
||||
DateTime? lastMessageAt,
|
||||
}) {
|
||||
return Contact(
|
||||
publicKey: publicKey ?? this.publicKey,
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
pathLength: pathLength ?? this.pathLength,
|
||||
path: path ?? this.path,
|
||||
latitude: latitude ?? this.latitude,
|
||||
longitude: longitude ?? this.longitude,
|
||||
lastSeen: lastSeen ?? this.lastSeen,
|
||||
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
|
||||
);
|
||||
}
|
||||
|
||||
String get pathIdList {
|
||||
if (path.isEmpty) return '';
|
||||
final parts = <String>[];
|
||||
|
||||
@@ -10,6 +10,10 @@ 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;
|
||||
@@ -30,6 +34,10 @@ 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,
|
||||
@@ -55,6 +63,10 @@ class Message {
|
||||
int? pathLength,
|
||||
Uint8List? pathBytes,
|
||||
bool? isCli,
|
||||
bool? isVoice,
|
||||
String? voicePath,
|
||||
int? voiceDurationMs,
|
||||
String? voiceCodec,
|
||||
}) {
|
||||
return Message(
|
||||
senderKey: senderKey,
|
||||
@@ -63,6 +75,10 @@ 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,
|
||||
@@ -101,6 +117,7 @@ class Message {
|
||||
isOutgoing: false,
|
||||
isCli: false,
|
||||
status: MessageStatus.delivered,
|
||||
isVoice: false,
|
||||
pathBytes: Uint8List(0),
|
||||
);
|
||||
}
|
||||
@@ -118,6 +135,7 @@ class Message {
|
||||
isOutgoing: true,
|
||||
isCli: false,
|
||||
status: MessageStatus.pending,
|
||||
isVoice: false,
|
||||
pathLength: pathLength,
|
||||
pathBytes: pathBytes,
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import 'map_cache_screen.dart';
|
||||
|
||||
class AppSettingsScreen extends StatelessWidget {
|
||||
const AppSettingsScreen({super.key});
|
||||
@@ -133,6 +134,31 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
: null,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.forum_outlined,
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
'Channel Message Notifications',
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'Show notification when receiving channel messages',
|
||||
style: TextStyle(
|
||||
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
|
||||
),
|
||||
),
|
||||
value: settingsService.settings.notifyOnNewChannelMessage,
|
||||
onChanged: settingsService.settings.notificationsEnabled
|
||||
? (value) {
|
||||
settingsService.setNotifyOnNewChannelMessage(value);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
const Divider(height: 1),
|
||||
SwitchListTile(
|
||||
secondary: Icon(
|
||||
Icons.cell_tower,
|
||||
@@ -267,6 +293,24 @@ class AppSettingsScreen extends StatelessWidget {
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showTimeFilterDialog(context, settingsService),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.download_outlined),
|
||||
title: const Text('Offline Map Cache'),
|
||||
subtitle: Text(
|
||||
settingsService.settings.mapCacheBounds == null
|
||||
? 'No area selected'
|
||||
: 'Area selected (zoom ${settingsService.settings.mapCacheMinZoom}'
|
||||
'-${settingsService.settings.mapCacheMaxZoom})',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const MapCacheScreen()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -168,6 +168,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
final isOutgoing = message.isOutgoing;
|
||||
final gifId = _parseGifId(message.text);
|
||||
final poi = _parsePoiMessage(message.text);
|
||||
final displayPath = message.pathBytes.isNotEmpty
|
||||
? message.pathBytes
|
||||
: (message.pathVariants.isNotEmpty ? message.pathVariants.first : Uint8List(0));
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||
@@ -223,10 +226,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
||||
message.text,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
if (message.pathBytes.isNotEmpty) ...[
|
||||
if (displayPath.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'via ${_formatPathPrefixes(message.pathBytes)}',
|
||||
'via ${_formatPathPrefixes(displayPath)}',
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../services/map_tile_cache_service.dart';
|
||||
import '../connector/meshcore_protocol.dart';
|
||||
import '../models/channel_message.dart';
|
||||
import '../models/contact.dart';
|
||||
@@ -23,8 +24,14 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final hops = _buildPathHops(message.pathBytes, connector.contacts);
|
||||
final hasHopDetails = message.pathBytes.isNotEmpty;
|
||||
final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants);
|
||||
final hops = _buildPathHops(primaryPath, connector.contacts);
|
||||
final hasHopDetails = primaryPath.isNotEmpty;
|
||||
final observedLabel = _formatObservedHops(
|
||||
primaryPath.length,
|
||||
message.pathLength,
|
||||
);
|
||||
final extraPaths = _otherPaths(primaryPath, message.pathVariants);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -35,13 +42,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
tooltip: 'View map',
|
||||
onPressed: hasHopDetails
|
||||
? () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
ChannelMessagePathMapScreen(message: message),
|
||||
),
|
||||
);
|
||||
_openPathMap(context);
|
||||
}
|
||||
: null,
|
||||
),
|
||||
@@ -50,8 +51,17 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildSummaryCard(context),
|
||||
_buildSummaryCard(context, observedLabel: observedLabel),
|
||||
const SizedBox(height: 16),
|
||||
if (extraPaths.isNotEmpty) ...[
|
||||
Text(
|
||||
'Other Observed Paths',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildPathVariants(context, extraPaths),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
Text(
|
||||
'Repeater Hops',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
@@ -71,7 +81,10 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard(BuildContext context) {
|
||||
Widget _buildSummaryCard(
|
||||
BuildContext context, {
|
||||
String? observedLabel,
|
||||
}) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -88,12 +101,37 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
if (message.repeatCount > 0)
|
||||
_buildDetailRow('Repeats', message.repeatCount.toString()),
|
||||
_buildDetailRow('Path', _formatPathLabel(message.pathLength)),
|
||||
if (observedLabel != null) _buildDetailRow('Observed', observedLabel),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPathVariants(
|
||||
BuildContext context,
|
||||
List<Uint8List> variants,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (int i = 0; i < variants.length; i++)
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
title: Text(
|
||||
'Observed path ${i + 1} • ${_formatHopCount(variants[i].length)}',
|
||||
),
|
||||
subtitle: Text(_formatPathPrefixes(variants[i])),
|
||||
trailing: const Icon(Icons.map_outlined, size: 20),
|
||||
onTap: () => _openPathMap(context, initialPath: variants[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildHopTiles(List<_PathHop> hops) {
|
||||
return [
|
||||
for (final hop in hops)
|
||||
@@ -138,6 +176,22 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
return '$pathLength hops';
|
||||
}
|
||||
|
||||
String? _formatObservedHops(int observedCount, int? pathLength) {
|
||||
if (observedCount <= 0 && (pathLength == null || pathLength <= 0)) {
|
||||
return null;
|
||||
}
|
||||
if (pathLength == null || pathLength < 0) {
|
||||
return observedCount > 0 ? '$observedCount hops' : null;
|
||||
}
|
||||
if (observedCount == 0) {
|
||||
return '0 of $pathLength hops';
|
||||
}
|
||||
if (observedCount == pathLength) {
|
||||
return '$observedCount hops';
|
||||
}
|
||||
return '$observedCount of $pathLength hops';
|
||||
}
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
@@ -153,21 +207,71 @@ class ChannelMessagePathScreen extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openPathMap(BuildContext context, {Uint8List? initialPath}) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChannelMessagePathMapScreen(
|
||||
message: message,
|
||||
initialPath: initialPath,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class ChannelMessagePathMapScreen extends StatelessWidget {
|
||||
class ChannelMessagePathMapScreen extends StatefulWidget {
|
||||
final ChannelMessage message;
|
||||
final Uint8List? initialPath;
|
||||
|
||||
const ChannelMessagePathMapScreen({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.initialPath,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChannelMessagePathMapScreen> createState() =>
|
||||
_ChannelMessagePathMapScreenState();
|
||||
}
|
||||
|
||||
class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScreen> {
|
||||
Uint8List? _selectedPath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedPath = widget.initialPath;
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.message != widget.message ||
|
||||
!_pathsEqual(oldWidget.initialPath ?? Uint8List(0),
|
||||
widget.initialPath ?? Uint8List(0))) {
|
||||
_selectedPath = widget.initialPath;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, _) {
|
||||
final hops = _buildPathHops(message.pathBytes, connector.contacts);
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final primaryPath =
|
||||
_selectPrimaryPath(widget.message.pathBytes, widget.message.pathVariants);
|
||||
final observedPaths =
|
||||
_buildObservedPaths(primaryPath, widget.message.pathVariants);
|
||||
final selectedPath = _resolveSelectedPath(
|
||||
_selectedPath,
|
||||
observedPaths,
|
||||
primaryPath,
|
||||
);
|
||||
final selectedIndex = _indexForPath(selectedPath, observedPaths);
|
||||
final hops = _buildPathHops(selectedPath, connector.contacts);
|
||||
final points = hops
|
||||
.where((hop) => hop.hasLocation)
|
||||
.map((hop) => hop.position!)
|
||||
@@ -186,6 +290,7 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
|
||||
points.isNotEmpty ? points.first : const LatLng(0, 0);
|
||||
final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
|
||||
final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null;
|
||||
final mapKey = ValueKey(_formatPathPrefixes(selectedPath));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -194,6 +299,7 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
|
||||
body: Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
key: mapKey,
|
||||
options: MapOptions(
|
||||
initialCenter: initialCenter,
|
||||
initialZoom: initialZoom,
|
||||
@@ -209,9 +315,10 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.meshcore.open',
|
||||
urlTemplate: kMapTileUrlTemplate,
|
||||
tileProvider: tileCache.tileProvider,
|
||||
userAgentPackageName:
|
||||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
|
||||
@@ -220,6 +327,17 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (observedPaths.length > 1)
|
||||
_buildPathSelector(
|
||||
context,
|
||||
observedPaths,
|
||||
selectedIndex,
|
||||
(index) {
|
||||
setState(() {
|
||||
_selectedPath = observedPaths[index].pathBytes;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (points.isEmpty)
|
||||
Center(
|
||||
child: Card(
|
||||
@@ -238,6 +356,65 @@ class ChannelMessagePathMapScreen extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPathSelector(
|
||||
BuildContext context,
|
||||
List<_ObservedPath> paths,
|
||||
int selectedIndex,
|
||||
ValueChanged<int> onSelected,
|
||||
) {
|
||||
final selectedPath = paths[selectedIndex];
|
||||
final label = selectedPath.isPrimary
|
||||
? 'Path ${selectedIndex + 1} (Primary)'
|
||||
: 'Path ${selectedIndex + 1}';
|
||||
return Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
child: SafeArea(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Observed Path',
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
isExpanded: true,
|
||||
value: selectedIndex,
|
||||
items: [
|
||||
for (int i = 0; i < paths.length; i++)
|
||||
DropdownMenuItem(
|
||||
value: i,
|
||||
child: Text(
|
||||
'${paths[i].isPrimary ? 'Path ${i + 1} (Primary)' : 'Path ${i + 1}'}'
|
||||
' • ${_formatHopCount(paths[i].pathBytes.length)}',
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
onSelected(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$label • ${_formatPathPrefixes(selectedPath.pathBytes)}',
|
||||
style: TextStyle(color: Colors.grey[700], fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Marker> _buildHopMarkers(List<_PathHop> hops) {
|
||||
return [
|
||||
for (final hop in hops)
|
||||
@@ -356,6 +533,16 @@ class _PathHop {
|
||||
}
|
||||
}
|
||||
|
||||
class _ObservedPath {
|
||||
final Uint8List pathBytes;
|
||||
final bool isPrimary;
|
||||
|
||||
const _ObservedPath({
|
||||
required this.pathBytes,
|
||||
required this.isPrimary,
|
||||
});
|
||||
}
|
||||
|
||||
List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
|
||||
final hops = <_PathHop>[];
|
||||
for (var i = 0; i < pathBytes.length; i++) {
|
||||
@@ -375,7 +562,10 @@ List<_PathHop> _buildPathHops(Uint8List pathBytes, List<Contact> contacts) {
|
||||
|
||||
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
|
||||
final matches = contacts
|
||||
.where((contact) => contact.publicKey.isNotEmpty && contact.publicKey[0] == prefix)
|
||||
.where((contact) =>
|
||||
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
|
||||
contact.publicKey.isNotEmpty &&
|
||||
contact.publicKey[0] == prefix)
|
||||
.toList();
|
||||
if (matches.isEmpty) return null;
|
||||
|
||||
@@ -410,6 +600,16 @@ String _formatPrefix(int prefix) {
|
||||
return prefix.toRadixString(16).padLeft(2, '0').toUpperCase();
|
||||
}
|
||||
|
||||
String _formatPathPrefixes(Uint8List pathBytes) {
|
||||
return pathBytes
|
||||
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
|
||||
.join(',');
|
||||
}
|
||||
|
||||
String _formatHopCount(int count) {
|
||||
return '$count ${count == 1 ? 'hop' : 'hops'}';
|
||||
}
|
||||
|
||||
String _resolveName(Contact? contact) {
|
||||
if (contact == null) return 'Unknown Repeater';
|
||||
final name = contact.name.trim();
|
||||
@@ -418,3 +618,81 @@ String _resolveName(Contact? contact) {
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
Uint8List _selectPrimaryPath(Uint8List pathBytes, List<Uint8List> variants) {
|
||||
Uint8List primary = pathBytes;
|
||||
for (final variant in variants) {
|
||||
if (variant.length > primary.length) {
|
||||
primary = variant;
|
||||
}
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
List<Uint8List> _otherPaths(Uint8List primary, List<Uint8List> variants) {
|
||||
final others = <Uint8List>[];
|
||||
for (final variant in variants) {
|
||||
if (variant.isEmpty) continue;
|
||||
if (!_pathsEqual(primary, variant)) {
|
||||
others.add(variant);
|
||||
}
|
||||
}
|
||||
return others;
|
||||
}
|
||||
|
||||
List<_ObservedPath> _buildObservedPaths(
|
||||
Uint8List primary,
|
||||
List<Uint8List> variants,
|
||||
) {
|
||||
final observed = <_ObservedPath>[];
|
||||
|
||||
void addPath(Uint8List pathBytes, bool isPrimary) {
|
||||
if (pathBytes.isEmpty) return;
|
||||
for (final existing in observed) {
|
||||
if (_pathsEqual(existing.pathBytes, pathBytes)) return;
|
||||
}
|
||||
observed.add(_ObservedPath(pathBytes: pathBytes, isPrimary: isPrimary));
|
||||
}
|
||||
|
||||
addPath(primary, true);
|
||||
for (final variant in variants) {
|
||||
addPath(variant, false);
|
||||
}
|
||||
|
||||
return observed;
|
||||
}
|
||||
|
||||
Uint8List _resolveSelectedPath(
|
||||
Uint8List? selected,
|
||||
List<_ObservedPath> observedPaths,
|
||||
Uint8List fallback,
|
||||
) {
|
||||
if (selected != null) {
|
||||
for (final path in observedPaths) {
|
||||
if (_pathsEqual(path.pathBytes, selected)) {
|
||||
return path.pathBytes;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (observedPaths.isNotEmpty) {
|
||||
return observedPaths.first.pathBytes;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
int _indexForPath(Uint8List selected, List<_ObservedPath> paths) {
|
||||
for (int i = 0; i < paths.length; i++) {
|
||||
if (_pathsEqual(paths[i].pathBytes, selected)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool _pathsEqual(Uint8List a, Uint8List b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (a[i] != b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
@@ -6,11 +7,21 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../models/channel.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import '../widgets/unread_badge.dart';
|
||||
import 'channel_chat_screen.dart';
|
||||
import 'contacts_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
class ChannelsScreen extends StatefulWidget {
|
||||
const ChannelsScreen({super.key});
|
||||
final bool hideBackButton;
|
||||
|
||||
const ChannelsScreen({
|
||||
super.key,
|
||||
this.hideBackButton = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChannelsScreen> createState() => _ChannelsScreenState();
|
||||
@@ -31,7 +42,21 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
appBar: AppBar(
|
||||
title: const Text('Channels'),
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: !widget.hideBackButton,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: 'Disconnect',
|
||||
onPressed: () => _disconnect(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => context.read<MeshCoreConnector>().getChannels(),
|
||||
@@ -69,20 +94,23 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
}
|
||||
|
||||
return ReorderableListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 88),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: channels.length,
|
||||
onReorder: (oldIndex, newIndex) async {
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final reordered = List<Channel>.from(channels);
|
||||
final item = reordered.removeAt(oldIndex);
|
||||
reordered.insert(newIndex, item);
|
||||
await connector.setChannelOrder(
|
||||
reordered.map((c) => c.index).toList(),
|
||||
unawaited(
|
||||
connector.setChannelOrder(
|
||||
reordered.map((c) => c.index).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final channel = channels[index];
|
||||
return _buildChannelTile(context, connector, channel);
|
||||
return _buildChannelTile(context, connector, channel, index);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -91,6 +119,13 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
onPressed: () => _showAddChannelDialog(context),
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: QuickSwitchBar(
|
||||
selectedIndex: 1,
|
||||
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -98,11 +133,17 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Channel channel,
|
||||
int index,
|
||||
) {
|
||||
final unreadCount = connector.getUnreadCountForChannel(channel);
|
||||
return Card(
|
||||
key: ValueKey('channel_${channel.index}'),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
minVerticalPadding: 0,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
visualDensity: const VisualDensity(vertical: -2),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: channel.isPublicChannel
|
||||
? Colors.green.withValues(alpha: 0.2)
|
||||
@@ -120,36 +161,26 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
channel.name.isEmpty ? 'Channel ${channel.index}' : channel.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
channel.name.startsWith('#')
|
||||
? 'Hashtag channel'
|
||||
: channel.isPublicChannel
|
||||
? 'Public channel'
|
||||
: 'Private channel',
|
||||
),
|
||||
subtitle: Text(
|
||||
channel.name.startsWith('#')
|
||||
? 'Hashtag channel'
|
||||
: channel.isPublicChannel
|
||||
? 'Public channel'
|
||||
: 'Private channel',
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (unreadCount > 0) ...[
|
||||
UnreadBadge(count: unreadCount),
|
||||
const SizedBox(width: 8),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
onPressed: () => _showEditChannelDialog(context, connector, channel),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (value) {
|
||||
if (value == 'delete') {
|
||||
_confirmDeleteChannel(context, connector, channel);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text('Delete'),
|
||||
),
|
||||
],
|
||||
ReorderableDelayedDragStartListener(
|
||||
index: index,
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -162,10 +193,91 @@ class _ChannelsScreenState extends State<ChannelsScreen> {
|
||||
),
|
||||
);
|
||||
},
|
||||
onLongPress: () => _showChannelActions(context, connector, channel),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showChannelActions(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
Channel channel,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit_outlined),
|
||||
title: const Text('Edit channel'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_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: () {
|
||||
Navigator.pop(context);
|
||||
_confirmDeleteChannel(context, connector, channel);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleQuickSwitch(int index, BuildContext context) {
|
||||
if (index == 1) return;
|
||||
switch (index) {
|
||||
case 0:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const ContactsScreen(hideBackButton: true),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const MapScreen(hideBackButton: true),
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
void _showAddChannelDialog(BuildContext context) {
|
||||
final connector = context.read<MeshCoreConnector>();
|
||||
final nameController = TextEditingController();
|
||||
|
||||
+333
-67
@@ -1,10 +1,13 @@
|
||||
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';
|
||||
@@ -12,12 +15,14 @@ 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;
|
||||
@@ -32,6 +37,16 @@ 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() {
|
||||
@@ -47,6 +62,11 @@ 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();
|
||||
}
|
||||
|
||||
@@ -56,35 +76,29 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
appBar: AppBar(
|
||||
title: Consumer2<PathHistoryService, MeshCoreConnector>(
|
||||
builder: (context, pathService, connector, _) {
|
||||
final paths = pathService.getRecentPaths(widget.contact.publicKeyHex);
|
||||
final contact = _resolveContact(connector);
|
||||
final showRecentPath = paths.isNotEmpty && contact.pathLength >= 0;
|
||||
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
|
||||
final unreadLabel = 'Unread: $unreadCount';
|
||||
final pathLabel = _forceFlood ? 'Flood (forced)' : _currentPathLabel(contact);
|
||||
final canShowPathDetails = !_forceFlood && contact.path.isNotEmpty;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(contact.name),
|
||||
if (showRecentPath)
|
||||
if (canShowPathDetails)
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onLongPress: () => _showFullPathDialog(context, paths.first.pathBytes),
|
||||
onLongPress: () => _showFullPathDialog(context, contact.path),
|
||||
child: Text(
|
||||
'${paths.first.displayText} • $unreadLabel',
|
||||
'$pathLabel • $unreadLabel',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
|
||||
),
|
||||
)
|
||||
else if (contact.pathLength >= 0)
|
||||
Text(
|
||||
'${contact.pathLength} ${contact.pathLength == 1 ? 'hop' : 'hops'} • $unreadLabel',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'No path • $unreadLabel',
|
||||
'$pathLabel • $unreadLabel',
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.normal),
|
||||
),
|
||||
@@ -207,10 +221,14 @@ 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),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
color: colorScheme.surface,
|
||||
border: Border(
|
||||
top: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
@@ -218,59 +236,93 @@ 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: () => _showGifPicker(context),
|
||||
onPressed: (_isRecordingVoice || isVoiceBusy || hasPendingVoice)
|
||||
? null
|
||||
: () => _showGifPicker(context),
|
||||
tooltip: 'Send GIF',
|
||||
),
|
||||
Expanded(
|
||||
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: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
fallbackTextColor:
|
||||
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
width: 160,
|
||||
height: 110,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => _textController.clear(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
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,
|
||||
inputFormatters: [
|
||||
Utf8LengthLimitingTextInputFormatter(maxBytes),
|
||||
],
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Type a message...',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
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),
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_isRecordingVoice || isVoiceBusy)
|
||||
? null
|
||||
: (_) => _sendMessage(connector),
|
||||
);
|
||||
},
|
||||
),
|
||||
textInputAction: TextInputAction.send,
|
||||
onSubmitted: (_) => _sendMessage(connector),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: () => _sendMessage(connector),
|
||||
),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -325,6 +377,209 @@ 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);
|
||||
|
||||
@@ -1024,14 +1279,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copy),
|
||||
title: const Text('Copy'),
|
||||
onTap: () {
|
||||
Navigator.pop(sheetContext);
|
||||
_copyMessageText(message.text);
|
||||
},
|
||||
),
|
||||
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.delete_outline),
|
||||
title: const Text('Delete'),
|
||||
@@ -1040,7 +1296,9 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
await _deleteMessage(message);
|
||||
},
|
||||
),
|
||||
if (message.isOutgoing && message.status == MessageStatus.failed)
|
||||
if (message.isOutgoing &&
|
||||
message.status == MessageStatus.failed &&
|
||||
!message.isVoice)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.refresh),
|
||||
title: const Text('Retry'),
|
||||
@@ -1154,7 +1412,15 @@ class _MessageBubble extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
if (poi != null)
|
||||
if (message.isVoice)
|
||||
VoiceMessageBubble(
|
||||
message: message,
|
||||
backgroundColor: bubbleColor,
|
||||
textColor: textColor,
|
||||
metaColor: metaColor,
|
||||
isOutgoing: isOutgoing,
|
||||
)
|
||||
else if (poi != null)
|
||||
_buildPoiMessage(context, poi, textColor, metaColor)
|
||||
else if (gifId != null)
|
||||
GifMessage(
|
||||
|
||||
+346
-256
@@ -6,11 +6,17 @@ import '../connector/meshcore_protocol.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../models/contact_group.dart';
|
||||
import '../storage/contact_group_store.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../utils/emoji_utils.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import '../widgets/repeater_login_dialog.dart';
|
||||
import '../widgets/unread_badge.dart';
|
||||
import '../utils/emoji_utils.dart';
|
||||
import 'channels_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
import 'repeater_hub_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
enum ContactSortOption {
|
||||
lastSeen,
|
||||
@@ -19,8 +25,22 @@ enum ContactSortOption {
|
||||
type,
|
||||
}
|
||||
|
||||
enum _ContactMenuAction {
|
||||
sortRecentMessages,
|
||||
sortName,
|
||||
sortType,
|
||||
toggleLastSeenFilter,
|
||||
toggleUnreadOnly,
|
||||
newGroup,
|
||||
}
|
||||
|
||||
class ContactsScreen extends StatefulWidget {
|
||||
const ContactsScreen({super.key});
|
||||
final bool hideBackButton;
|
||||
|
||||
const ContactsScreen({
|
||||
super.key,
|
||||
this.hideBackButton = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ContactsScreen> createState() => _ContactsScreenState();
|
||||
@@ -30,6 +50,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
ContactSortOption _sortOption = ContactSortOption.lastSeen;
|
||||
bool _forceLastSeenSort = true;
|
||||
bool _showUnreadOnly = false;
|
||||
final ContactGroupStore _groupStore = ContactGroupStore();
|
||||
List<ContactGroup> _groups = [];
|
||||
@@ -60,275 +81,309 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
|
||||
if (!connector.isConnected) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted) {
|
||||
Navigator.popUntil(context, (route) => route.isFirst);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Contacts'),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
PopupMenuButton<ContactSortOption>(
|
||||
icon: const Icon(Icons.sort),
|
||||
tooltip: 'Sort by',
|
||||
onSelected: (option) {
|
||||
setState(() {
|
||||
_sortOption = option;
|
||||
});
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: ContactSortOption.lastSeen,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
size: 20,
|
||||
color: _sortOption == ContactSortOption.lastSeen
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Last Seen',
|
||||
style: TextStyle(
|
||||
fontWeight: _sortOption == ContactSortOption.lastSeen
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
titleSpacing: 16,
|
||||
centerTitle: false,
|
||||
automaticallyImplyLeading: !widget.hideBackButton,
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text('Contacts'),
|
||||
Text(
|
||||
'${connector.contacts.length} contacts',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ContactSortOption.recentMessages,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble,
|
||||
size: 20,
|
||||
color: _sortOption == ContactSortOption.recentMessages
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Recent Messages',
|
||||
style: TextStyle(
|
||||
fontWeight: _sortOption == ContactSortOption.recentMessages
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ContactSortOption.name,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sort_by_alpha,
|
||||
size: 20,
|
||||
color: _sortOption == ContactSortOption.name
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Name',
|
||||
style: TextStyle(
|
||||
fontWeight: _sortOption == ContactSortOption.name
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: ContactSortOption.type,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.category,
|
||||
size: 20,
|
||||
color: _sortOption == ContactSortOption.type
|
||||
? Theme.of(context).primaryColor
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Type',
|
||||
style: TextStyle(
|
||||
fontWeight: _sortOption == ContactSortOption.type
|
||||
? FontWeight.bold
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.mark_chat_unread_outlined,
|
||||
color: _showUnreadOnly ? Theme.of(context).primaryColor : null,
|
||||
),
|
||||
tooltip: _showUnreadOnly ? 'Showing unread only' : 'Show unread only',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showUnreadOnly = !_showUnreadOnly;
|
||||
});
|
||||
},
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: connector.isLoadingContacts
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
onPressed: connector.isLoadingContacts ? null : () => connector.getContacts(),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.group_add),
|
||||
tooltip: 'New group',
|
||||
onPressed: () {
|
||||
final contacts = context.read<MeshCoreConnector>().contacts;
|
||||
_showGroupEditor(context, contacts);
|
||||
},
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: 'Disconnect',
|
||||
onPressed: () => _disconnect(context, connector),
|
||||
),
|
||||
Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
return IconButton(
|
||||
icon: connector.isLoadingContacts
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.refresh),
|
||||
onPressed: connector.isLoadingContacts
|
||||
? null
|
||||
: () => connector.getContacts(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<_ContactMenuAction>(
|
||||
tooltip: 'Contacts options',
|
||||
onSelected: (action) {
|
||||
switch (action) {
|
||||
case _ContactMenuAction.sortRecentMessages:
|
||||
setState(() {
|
||||
_sortOption = ContactSortOption.recentMessages;
|
||||
_forceLastSeenSort = false;
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.sortName:
|
||||
setState(() {
|
||||
_sortOption = ContactSortOption.name;
|
||||
_forceLastSeenSort = false;
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.sortType:
|
||||
setState(() {
|
||||
_sortOption = ContactSortOption.type;
|
||||
_forceLastSeenSort = false;
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.toggleLastSeenFilter:
|
||||
setState(() {
|
||||
_forceLastSeenSort = !_forceLastSeenSort;
|
||||
if (_forceLastSeenSort) {
|
||||
_sortOption = ContactSortOption.lastSeen;
|
||||
}
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.toggleUnreadOnly:
|
||||
setState(() {
|
||||
_showUnreadOnly = !_showUnreadOnly;
|
||||
});
|
||||
break;
|
||||
case _ContactMenuAction.newGroup:
|
||||
_showGroupEditor(context, connector.contacts);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) {
|
||||
final labelStyle = theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
return [
|
||||
PopupMenuItem<_ContactMenuAction>(
|
||||
enabled: false,
|
||||
child: Text('Sort by', style: labelStyle),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.sortRecentMessages,
|
||||
checked: _sortOption == ContactSortOption.recentMessages,
|
||||
child: const Text('Recent messages'),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.sortName,
|
||||
checked: _sortOption == ContactSortOption.name,
|
||||
child: const Text('Name'),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.sortType,
|
||||
checked: _sortOption == ContactSortOption.type,
|
||||
child: const Text('Type'),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem<_ContactMenuAction>(
|
||||
enabled: false,
|
||||
child: Text('Filters', style: labelStyle),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.toggleLastSeenFilter,
|
||||
checked: _forceLastSeenSort,
|
||||
child: const Text('Last seen'),
|
||||
),
|
||||
CheckedPopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.toggleUnreadOnly,
|
||||
checked: _showUnreadOnly,
|
||||
child: const Text('Unread only'),
|
||||
),
|
||||
PopupMenuItem<_ContactMenuAction>(
|
||||
value: _ContactMenuAction.newGroup,
|
||||
child: const Text('New group'),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<MeshCoreConnector>(
|
||||
builder: (context, connector, child) {
|
||||
final contacts = connector.contacts;
|
||||
|
||||
if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
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]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
|
||||
final filteredGroups =
|
||||
_showUnreadOnly ? const <ContactGroup>[] : _filterAndSortGroups(_groups, contacts);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search contacts...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value.toLowerCase();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_showUnreadOnly
|
||||
? 'No unread contacts'
|
||||
: 'No contacts or groups found',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () => connector.getContacts(),
|
||||
child: ListView.builder(
|
||||
itemCount: filteredGroups.length + filteredAndSorted.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < filteredGroups.length) {
|
||||
final group = filteredGroups[index];
|
||||
return _buildGroupTile(context, group, contacts);
|
||||
}
|
||||
final contact = filteredAndSorted[index - filteredGroups.length];
|
||||
final unreadCount = connector.getUnreadCountForContact(contact);
|
||||
return _ContactTile(
|
||||
contact: contact,
|
||||
unreadCount: unreadCount,
|
||||
onTap: () => _openChat(context, contact),
|
||||
onLongPress: () => _showContactOptions(context, connector, contact),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
body: _buildContactsBody(context, connector),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: QuickSwitchBar(
|
||||
selectedIndex: 0,
|
||||
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _disconnect(
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildContactsBody(BuildContext context, MeshCoreConnector connector) {
|
||||
final contacts = connector.contacts;
|
||||
|
||||
if (contacts.isEmpty && connector.isLoadingContacts && _groups.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
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]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final filteredAndSorted = _filterAndSortContacts(contacts, connector);
|
||||
final filteredGroups =
|
||||
_showUnreadOnly ? const <ContactGroup>[] : _filterAndSortGroups(_groups, contacts);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search contacts...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value.toLowerCase();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: filteredAndSorted.isEmpty && filteredGroups.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_showUnreadOnly
|
||||
? 'No unread contacts'
|
||||
: 'No contacts or groups found',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () => connector.getContacts(),
|
||||
child: ListView.builder(
|
||||
itemCount: filteredGroups.length + filteredAndSorted.length,
|
||||
itemBuilder: (context, index) {
|
||||
if (index < filteredGroups.length) {
|
||||
final group = filteredGroups[index];
|
||||
return _buildGroupTile(context, group, contacts);
|
||||
}
|
||||
final contact = filteredAndSorted[index - filteredGroups.length];
|
||||
final unreadCount = connector.getUnreadCountForContact(contact);
|
||||
return _ContactTile(
|
||||
contact: contact,
|
||||
lastSeen: _resolveLastSeen(contact),
|
||||
unreadCount: unreadCount,
|
||||
onTap: () => _openChat(context, contact),
|
||||
onLongPress: () => _showContactOptions(context, connector, contact),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<ContactGroup> _filterAndSortGroups(List<ContactGroup> groups, List<Contact> contacts) {
|
||||
final query = _searchQuery.trim().toLowerCase();
|
||||
final contactNames = <String, String>{};
|
||||
final contactsByKey = <String, Contact>{};
|
||||
for (final contact in contacts) {
|
||||
contactNames[contact.publicKeyHex] = contact.name.toLowerCase();
|
||||
contactsByKey[contact.publicKeyHex] = contact;
|
||||
}
|
||||
|
||||
final filtered = groups.where((group) {
|
||||
if (query.isEmpty) return true;
|
||||
if (group.name.toLowerCase().contains(query)) return true;
|
||||
for (final key in group.memberKeys) {
|
||||
final name = contactNames[key];
|
||||
if (name != null && name.contains(query)) return true;
|
||||
final contact = contactsByKey[key];
|
||||
if (contact != null && matchesContactQuery(contact, query)) return true;
|
||||
}
|
||||
return false;
|
||||
}).toList();
|
||||
@@ -340,7 +395,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
List<Contact> _filterAndSortContacts(List<Contact> contacts, MeshCoreConnector connector) {
|
||||
var filtered = contacts.where((contact) {
|
||||
if (_searchQuery.isEmpty) return true;
|
||||
return contact.name.toLowerCase().contains(_searchQuery);
|
||||
return matchesContactQuery(contact, _searchQuery);
|
||||
}).toList();
|
||||
|
||||
if (_showUnreadOnly) {
|
||||
@@ -349,9 +404,10 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
switch (_sortOption) {
|
||||
final sortOption = _forceLastSeenSort ? ContactSortOption.lastSeen : _sortOption;
|
||||
switch (sortOption) {
|
||||
case ContactSortOption.lastSeen:
|
||||
filtered.sort((a, b) => b.lastSeen.compareTo(a.lastSeen));
|
||||
filtered.sort((a, b) => _resolveLastSeen(b).compareTo(_resolveLastSeen(a)));
|
||||
break;
|
||||
case ContactSortOption.recentMessages:
|
||||
filtered.sort((a, b) {
|
||||
@@ -377,6 +433,13 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
DateTime _resolveLastSeen(Contact contact) {
|
||||
if (contact.type != advTypeChat) return contact.lastSeen;
|
||||
return contact.lastMessageAt.isAfter(contact.lastSeen)
|
||||
? contact.lastMessageAt
|
||||
: contact.lastSeen;
|
||||
}
|
||||
|
||||
Widget _buildGroupTile(BuildContext context, ContactGroup group, List<Contact> contacts) {
|
||||
final memberContacts = _resolveGroupContacts(group, contacts);
|
||||
final subtitle = _formatGroupMembers(memberContacts);
|
||||
@@ -432,6 +495,28 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleQuickSwitch(int index, BuildContext context) {
|
||||
if (index == 0) return;
|
||||
switch (index) {
|
||||
case 1:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const ChannelsScreen(hideBackButton: true),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const MapScreen(hideBackButton: true),
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void _showRepeaterLogin(BuildContext context, Contact repeater) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -542,7 +627,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
final filteredContacts = filterQuery.isEmpty
|
||||
? sortedContacts
|
||||
: sortedContacts
|
||||
.where((contact) => contact.name.toLowerCase().contains(filterQuery))
|
||||
.where((contact) => matchesContactQuery(contact, filterQuery))
|
||||
.toList();
|
||||
return AlertDialog(
|
||||
title: Text(isEditing ? 'Edit Group' : 'New Group'),
|
||||
@@ -728,12 +813,14 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
|
||||
class _ContactTile extends StatelessWidget {
|
||||
final Contact contact;
|
||||
final DateTime lastSeen;
|
||||
final int unreadCount;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
const _ContactTile({
|
||||
required this.contact,
|
||||
required this.lastSeen,
|
||||
required this.unreadCount,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
@@ -757,7 +844,7 @@ class _ContactTile extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
_formatLastSeen(contact.lastSeen),
|
||||
_formatLastSeen(lastSeen),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
),
|
||||
if (contact.hasLocation)
|
||||
@@ -814,10 +901,13 @@ class _ContactTile extends StatelessWidget {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(lastSeen);
|
||||
|
||||
if (diff.inMinutes < 1) return 'Just now';
|
||||
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
|
||||
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
||||
if (diff.inDays < 7) return '${diff.inDays}d ago';
|
||||
return '${lastSeen.month}/${lastSeen.day}';
|
||||
if (diff.isNegative || diff.inMinutes < 5) return 'Last seen now';
|
||||
if (diff.inMinutes < 60) return 'Last seen ${diff.inMinutes} mins ago';
|
||||
if (diff.inHours < 24) {
|
||||
final hours = diff.inHours;
|
||||
return hours == 1 ? 'Last seen 1 hour ago' : 'Last seen $hours hours ago';
|
||||
}
|
||||
final days = diff.inDays;
|
||||
return days == 1 ? 'Last seen 1 day ago' : 'Last seen $days days ago';
|
||||
}
|
||||
}
|
||||
|
||||
+191
-172
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import 'channels_screen.dart';
|
||||
import 'contacts_screen.dart';
|
||||
import 'map_screen.dart';
|
||||
@@ -17,6 +19,7 @@ class DeviceScreen extends StatefulWidget {
|
||||
|
||||
class _DeviceScreenState extends State<DeviceScreen> {
|
||||
bool _showBatteryVoltage = false;
|
||||
int _quickIndex = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -31,14 +34,26 @@ class _DeviceScreenState extends State<DeviceScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(connector.deviceDisplayName),
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 16,
|
||||
centerTitle: false,
|
||||
title: _buildAppBarTitle(connector, theme),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsScreen(),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: 'Disconnect',
|
||||
@@ -46,20 +61,15 @@ class _DeviceScreenState extends State<DeviceScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
children: [
|
||||
// Connection status card
|
||||
_buildStatusCard(connector, context),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Navigation grid
|
||||
Expanded(
|
||||
child: _buildNavigationGrid(context),
|
||||
),
|
||||
_buildConnectionCard(connector, context),
|
||||
const SizedBox(height: 16),
|
||||
_buildSectionLabel(theme, 'Quick switch'),
|
||||
const SizedBox(height: 12),
|
||||
_buildQuickSwitchBar(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -69,54 +79,114 @@ class _DeviceScreenState extends State<DeviceScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusCard(MeshCoreConnector connector, BuildContext context) {
|
||||
Widget _buildAppBarTitle(MeshCoreConnector connector, ThemeData theme) {
|
||||
final colorScheme = theme.colorScheme;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'MeshCore',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
connector.deviceDisplayName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionLabel(ThemeData theme, String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.6,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConnectionCard(
|
||||
MeshCoreConnector connector,
|
||||
BuildContext context,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: colorScheme.surfaceVariant,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.bluetooth_connected, color: Colors.green, size: 32),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
connector.deviceDisplayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
connector.deviceIdLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Text(
|
||||
'Connected',
|
||||
style: TextStyle(
|
||||
color: Colors.green,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
child: Icon(
|
||||
Icons.wifi_tethering_rounded,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
connector.deviceDisplayName,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
connector.deviceIdLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Chip(
|
||||
avatar: Icon(
|
||||
Icons.check_circle,
|
||||
size: 18,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
label: const Text('Connected'),
|
||||
backgroundColor: colorScheme.secondaryContainer,
|
||||
labelStyle: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
_buildBatteryIndicator(connector, context),
|
||||
],
|
||||
),
|
||||
@@ -126,7 +196,22 @@ class _DeviceScreenState extends State<DeviceScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBatteryIndicator(MeshCoreConnector connector, BuildContext context) {
|
||||
Widget _buildQuickSwitchBar(BuildContext context) {
|
||||
return QuickSwitchBar(
|
||||
selectedIndex: _quickIndex,
|
||||
onDestinationSelected: (index) {
|
||||
_openQuickDestination(index, context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildBatteryIndicator(
|
||||
MeshCoreConnector connector,
|
||||
BuildContext context,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final percent = connector.batteryPercent;
|
||||
final millivolts = connector.batteryMillivolts;
|
||||
final percentLabel = percent != null ? '$percent%' : '--%';
|
||||
@@ -136,31 +221,24 @@ class _DeviceScreenState extends State<DeviceScreen> {
|
||||
final displayLabel = _showBatteryVoltage ? voltageLabel : percentLabel;
|
||||
final icon = _batteryIcon(percent);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
return ActionChip(
|
||||
avatar: Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
),
|
||||
label: Text(displayLabel),
|
||||
labelStyle: theme.textTheme.labelMedium?.copyWith(
|
||||
color: colorScheme.onSecondaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
backgroundColor: colorScheme.secondaryContainer,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showBatteryVoltage = !_showBatteryVoltage;
|
||||
});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: Colors.grey[700]),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
displayLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[700],
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,89 +248,44 @@ class _DeviceScreenState extends State<DeviceScreen> {
|
||||
return Icons.battery_full;
|
||||
}
|
||||
|
||||
Widget _buildNavigationGrid(BuildContext context) {
|
||||
final items = [
|
||||
_NavItem(
|
||||
icon: Icons.people_outline,
|
||||
label: 'Contacts',
|
||||
color: Colors.blue,
|
||||
onTap: () => Navigator.push(
|
||||
void _openQuickDestination(int index, BuildContext context) {
|
||||
if (_quickIndex != index) {
|
||||
setState(() {
|
||||
_quickIndex = index;
|
||||
});
|
||||
}
|
||||
switch (index) {
|
||||
case 0:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ContactsScreen()),
|
||||
),
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.tag,
|
||||
label: 'Channels',
|
||||
color: Colors.green,
|
||||
onTap: () => Navigator.push(
|
||||
buildQuickSwitchRoute(
|
||||
const ContactsScreen(hideBackButton: true),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case 1:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ChannelsScreen()),
|
||||
),
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.map_outlined,
|
||||
label: 'Map',
|
||||
color: Colors.orange,
|
||||
onTap: () => Navigator.push(
|
||||
buildQuickSwitchRoute(
|
||||
const ChannelsScreen(hideBackButton: true),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const MapScreen()),
|
||||
),
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.settings_outlined,
|
||||
label: 'Settings',
|
||||
color: Colors.grey,
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 1.2,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return _buildNavCard(item);
|
||||
},
|
||||
);
|
||||
buildQuickSwitchRoute(
|
||||
const MapScreen(hideBackButton: true),
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildNavCard(_NavItem item) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: item.onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
item.icon,
|
||||
size: 48,
|
||||
color: item.color,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
item.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _disconnect(BuildContext context, MeshCoreConnector connector) async {
|
||||
Future<void> _disconnect(
|
||||
BuildContext context,
|
||||
MeshCoreConnector connector,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
@@ -276,17 +309,3 @@ class _DeviceScreenState extends State<DeviceScreen> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _NavItem {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
_NavItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/map_tile_cache_service.dart';
|
||||
|
||||
class MapCacheScreen extends StatefulWidget {
|
||||
const MapCacheScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MapCacheScreen> createState() => _MapCacheScreenState();
|
||||
}
|
||||
|
||||
class _MapCacheScreenState extends State<MapCacheScreen> {
|
||||
final MapController _mapController = MapController();
|
||||
|
||||
LatLngBounds? _selectedBounds;
|
||||
int _minZoom = MapTileCacheService.defaultMinZoom;
|
||||
int _maxZoom = MapTileCacheService.defaultMaxZoom;
|
||||
int _estimatedTiles = 0;
|
||||
bool _isDownloading = false;
|
||||
int _completedTiles = 0;
|
||||
int _failedTiles = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_loadSettings();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mapController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _loadSettings() {
|
||||
final settings = context.read<AppSettingsService>().settings;
|
||||
final bounds = MapTileCacheService.boundsFromJson(settings.mapCacheBounds);
|
||||
final minZoom = settings.mapCacheMinZoom.clamp(3, 18);
|
||||
final maxZoom = settings.mapCacheMaxZoom.clamp(3, 18);
|
||||
final safeMin = minZoom <= maxZoom ? minZoom : maxZoom;
|
||||
final safeMax = minZoom <= maxZoom ? maxZoom : minZoom;
|
||||
setState(() {
|
||||
_minZoom = safeMin;
|
||||
_maxZoom = safeMax;
|
||||
_selectedBounds = bounds;
|
||||
});
|
||||
_updateEstimate();
|
||||
if (bounds != null) {
|
||||
_mapController.fitCamera(
|
||||
CameraFit.bounds(
|
||||
bounds: bounds,
|
||||
padding: const EdgeInsets.all(48),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateEstimate() {
|
||||
if (_selectedBounds == null) {
|
||||
setState(() {
|
||||
_estimatedTiles = 0;
|
||||
});
|
||||
return;
|
||||
}
|
||||
final cacheService = context.read<MapTileCacheService>();
|
||||
final count =
|
||||
cacheService.estimateTileCount(_selectedBounds!, _minZoom, _maxZoom);
|
||||
setState(() {
|
||||
_estimatedTiles = count;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _setBoundsFromView() async {
|
||||
final bounds = _mapController.camera.visibleBounds;
|
||||
await _saveBounds(bounds);
|
||||
}
|
||||
|
||||
Future<void> _saveBounds(LatLngBounds bounds) async {
|
||||
setState(() {
|
||||
_selectedBounds = bounds;
|
||||
});
|
||||
final settings = context.read<AppSettingsService>();
|
||||
await settings.setMapCacheBounds(MapTileCacheService.boundsToJson(bounds));
|
||||
_updateEstimate();
|
||||
}
|
||||
|
||||
Future<void> _clearBounds() async {
|
||||
setState(() {
|
||||
_selectedBounds = null;
|
||||
_estimatedTiles = 0;
|
||||
});
|
||||
final settings = context.read<AppSettingsService>();
|
||||
await settings.setMapCacheBounds(null);
|
||||
}
|
||||
|
||||
Future<void> _saveZoomRange() async {
|
||||
final settings = context.read<AppSettingsService>();
|
||||
await settings.setMapCacheZoomRange(_minZoom, _maxZoom);
|
||||
_updateEstimate();
|
||||
}
|
||||
|
||||
Future<void> _startDownload() async {
|
||||
final bounds = _selectedBounds;
|
||||
if (bounds == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Select an area to cache first')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_estimatedTiles == 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('No tiles to download for this area')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Download tiles'),
|
||||
content: Text(
|
||||
'Download $_estimatedTiles tiles for offline use?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Download'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
setState(() {
|
||||
_isDownloading = true;
|
||||
_completedTiles = 0;
|
||||
_failedTiles = 0;
|
||||
});
|
||||
|
||||
final cacheService = context.read<MapTileCacheService>();
|
||||
final result = await cacheService.downloadRegion(
|
||||
bounds: bounds,
|
||||
minZoom: _minZoom,
|
||||
maxZoom: _maxZoom,
|
||||
onProgress: (progress) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_completedTiles = progress.completed;
|
||||
_failedTiles = progress.failed;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isDownloading = false;
|
||||
_completedTiles = result.downloaded + result.failed;
|
||||
_failedTiles = result.failed;
|
||||
});
|
||||
|
||||
final message = result.failed > 0
|
||||
? 'Cached ${result.downloaded} tiles (${result.failed} failed)'
|
||||
: 'Cached ${result.downloaded} tiles';
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _clearCache() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Clear offline cache'),
|
||||
content: const Text('Remove all cached map tiles?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) return;
|
||||
|
||||
final cacheService = context.read<MapTileCacheService>();
|
||||
await cacheService.clearCache();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Offline cache cleared')),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final selectedBounds = _selectedBounds;
|
||||
final progressValue = _estimatedTiles == 0
|
||||
? 0.0
|
||||
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Offline Map Cache'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
mapController: _mapController,
|
||||
options: const MapOptions(
|
||||
initialCenter: LatLng(0, 0),
|
||||
initialZoom: 2.0,
|
||||
minZoom: 2.0,
|
||||
maxZoom: 18.0,
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: kMapTileUrlTemplate,
|
||||
tileProvider: tileCache.tileProvider,
|
||||
userAgentPackageName:
|
||||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
if (selectedBounds != null)
|
||||
PolygonLayer(
|
||||
polygons: [
|
||||
Polygon(
|
||||
points: _boundsToPolygon(selectedBounds),
|
||||
borderStrokeWidth: 2,
|
||||
color: Colors.blue.withValues(alpha: 0.2),
|
||||
borderColor: Colors.blue,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
selectedBounds == null
|
||||
? 'No area selected'
|
||||
: _formatBounds(selectedBounds),
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Cache Area',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.crop_free),
|
||||
label: const Text('Use Current View'),
|
||||
onPressed: _isDownloading ? null : _setBoundsFromView,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
TextButton(
|
||||
onPressed:
|
||||
_isDownloading || selectedBounds == null ? null : _clearBounds,
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Zoom Range',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
RangeSlider(
|
||||
values:
|
||||
RangeValues(_minZoom.toDouble(), _maxZoom.toDouble()),
|
||||
min: 3,
|
||||
max: 18,
|
||||
divisions: 15,
|
||||
labels: RangeLabels('$_minZoom', '$_maxZoom'),
|
||||
onChanged: _isDownloading
|
||||
? null
|
||||
: (values) {
|
||||
setState(() {
|
||||
_minZoom = values.start.round();
|
||||
_maxZoom = values.end.round();
|
||||
});
|
||||
},
|
||||
onChangeEnd: _isDownloading
|
||||
? null
|
||||
: (_) {
|
||||
_saveZoomRange();
|
||||
},
|
||||
),
|
||||
Text('Estimated tiles: $_estimatedTiles'),
|
||||
if (_isDownloading) ...[
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(value: progressValue),
|
||||
const SizedBox(height: 4),
|
||||
Text('Downloaded $_completedTiles / $_estimatedTiles'),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Download Tiles'),
|
||||
onPressed: _isDownloading || selectedBounds == null
|
||||
? null
|
||||
: _startDownload,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton(
|
||||
onPressed: _isDownloading ? null : _clearCache,
|
||||
child: const Text('Clear Cache'),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_failedTiles > 0 && !_isDownloading)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'Failed downloads: $_failedTiles',
|
||||
style: TextStyle(color: Colors.orange[700]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<LatLng> _boundsToPolygon(LatLngBounds bounds) {
|
||||
return [
|
||||
bounds.northWest,
|
||||
bounds.northEast,
|
||||
bounds.southEast,
|
||||
bounds.southWest,
|
||||
];
|
||||
}
|
||||
|
||||
String _formatBounds(LatLngBounds bounds) {
|
||||
return 'N ${bounds.north.toStringAsFixed(4)}, '
|
||||
'S ${bounds.south.toStringAsFixed(4)}, '
|
||||
'E ${bounds.east.toStringAsFixed(4)}, '
|
||||
'W ${bounds.west.toStringAsFixed(4)}';
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,27 @@ import '../models/channel.dart';
|
||||
import '../models/contact.dart';
|
||||
import '../services/app_settings_service.dart';
|
||||
import '../services/map_marker_service.dart';
|
||||
import '../services/map_tile_cache_service.dart';
|
||||
import '../utils/contact_search.dart';
|
||||
import '../utils/route_transitions.dart';
|
||||
import '../widgets/quick_switch_bar.dart';
|
||||
import 'channels_screen.dart';
|
||||
import 'chat_screen.dart';
|
||||
import 'contacts_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
class MapScreen extends StatefulWidget {
|
||||
final LatLng? highlightPosition;
|
||||
final String? highlightLabel;
|
||||
final double highlightZoom;
|
||||
final bool hideBackButton;
|
||||
|
||||
const MapScreen({
|
||||
super.key,
|
||||
this.highlightPosition,
|
||||
this.highlightLabel,
|
||||
this.highlightZoom = 15.0,
|
||||
this.hideBackButton = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -60,6 +69,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer2<MeshCoreConnector, AppSettingsService>(
|
||||
builder: (context, connector, settingsService, child) {
|
||||
final tileCache = context.read<MapTileCacheService>();
|
||||
final settings = settingsService.settings;
|
||||
final contacts = connector.contacts;
|
||||
final highlightPosition = widget.highlightPosition;
|
||||
@@ -124,6 +134,22 @@ class _MapScreenState extends State<MapScreen> {
|
||||
appBar: AppBar(
|
||||
title: const Text('Node Map'),
|
||||
centerTitle: true,
|
||||
automaticallyImplyLeading: !widget.hideBackButton,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: 'Settings',
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsScreen()),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.bluetooth_disabled),
|
||||
tooltip: 'Disconnect',
|
||||
onPressed: () => _disconnect(context, connector),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: !hasMapContent
|
||||
? _buildEmptyState()
|
||||
@@ -173,8 +199,10 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.meshcore.open',
|
||||
urlTemplate: kMapTileUrlTemplate,
|
||||
tileProvider: tileCache.tileProvider,
|
||||
userAgentPackageName:
|
||||
MapTileCacheService.userAgentPackageName,
|
||||
maxZoom: 19,
|
||||
),
|
||||
MarkerLayer(
|
||||
@@ -199,6 +227,13 @@ class _MapScreenState extends State<MapScreen> {
|
||||
_buildLegend(contactsWithLocation.length, sharedMarkers.length),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: QuickSwitchBar(
|
||||
selectedIndex: 2,
|
||||
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showFilterDialog(context, settingsService),
|
||||
child: const Icon(Icons.filter_list),
|
||||
@@ -556,6 +591,55 @@ class _MapScreenState extends State<MapScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _handleQuickSwitch(int index, BuildContext context) {
|
||||
if (index == 2) return;
|
||||
switch (index) {
|
||||
case 0:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const ContactsScreen(hideBackButton: true),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case 1:
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
buildQuickSwitchRoute(
|
||||
const ChannelsScreen(hideBackButton: true),
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect(
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
void _showMarkerInfo(_SharedMarker marker) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -792,8 +876,7 @@ class _MapScreenState extends State<MapScreen> {
|
||||
),
|
||||
...allContacts
|
||||
.where((contact) =>
|
||||
query.isEmpty ||
|
||||
contact.name.toLowerCase().contains(query))
|
||||
query.isEmpty || matchesContactQuery(contact, query))
|
||||
.map((contact) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.person),
|
||||
|
||||
@@ -4,7 +4,7 @@ import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../widgets/device_tile.dart';
|
||||
import 'device_screen.dart';
|
||||
import 'contacts_screen.dart';
|
||||
|
||||
/// Screen for scanning and connecting to MeshCore devices
|
||||
class ScannerScreen extends StatelessWidget {
|
||||
@@ -161,7 +161,7 @@ class ScannerScreen extends StatelessWidget {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const DeviceScreen(),
|
||||
builder: (context) => const ContactsScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,21 @@ class AppSettingsService extends ChangeNotifier {
|
||||
await updateSettings(_settings.copyWith(mapShowMarkers: value));
|
||||
}
|
||||
|
||||
Future<void> setMapCacheBounds(Map<String, double>? value) async {
|
||||
await updateSettings(_settings.copyWith(mapCacheBounds: value));
|
||||
}
|
||||
|
||||
Future<void> setMapCacheZoomRange(int minZoom, int maxZoom) async {
|
||||
final safeMin = minZoom <= maxZoom ? minZoom : maxZoom;
|
||||
final safeMax = minZoom <= maxZoom ? maxZoom : minZoom;
|
||||
await updateSettings(
|
||||
_settings.copyWith(
|
||||
mapCacheMinZoom: safeMin,
|
||||
mapCacheMaxZoom: safeMax,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setNotificationsEnabled(bool value) async {
|
||||
await updateSettings(_settings.copyWith(notificationsEnabled: value));
|
||||
}
|
||||
@@ -81,6 +96,10 @@ class AppSettingsService extends ChangeNotifier {
|
||||
await updateSettings(_settings.copyWith(notifyOnNewMessage: value));
|
||||
}
|
||||
|
||||
Future<void> setNotifyOnNewChannelMessage(bool value) async {
|
||||
await updateSettings(_settings.copyWith(notifyOnNewChannelMessage: value));
|
||||
}
|
||||
|
||||
Future<void> setNotifyOnNewAdvert(bool value) async {
|
||||
await updateSettings(_settings.copyWith(notifyOnNewAdvert: value));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'dart:isolate';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
|
||||
class BackgroundService {
|
||||
bool _initialized = false;
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (!Platform.isAndroid || _initialized) return;
|
||||
FlutterForegroundTask.init(
|
||||
androidNotificationOptions: AndroidNotificationOptions(
|
||||
channelId: 'meshcore_background',
|
||||
channelName: 'MeshCore Background',
|
||||
channelDescription: 'Keeps MeshCore running in the background.',
|
||||
channelImportance: NotificationChannelImportance.LOW,
|
||||
priority: NotificationPriority.LOW,
|
||||
iconData: const NotificationIconData(
|
||||
resType: ResourceType.mipmap,
|
||||
resPrefix: ResourcePrefix.ic,
|
||||
name: 'launcher',
|
||||
),
|
||||
),
|
||||
iosNotificationOptions: const IOSNotificationOptions(
|
||||
showNotification: false,
|
||||
playSound: false,
|
||||
),
|
||||
foregroundTaskOptions: const ForegroundTaskOptions(
|
||||
interval: 5000,
|
||||
autoRunOnBoot: false,
|
||||
allowWakeLock: true,
|
||||
allowWifiLock: false,
|
||||
),
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<void> start() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
if (!_initialized) {
|
||||
await initialize();
|
||||
}
|
||||
final running = await FlutterForegroundTask.isRunningService;
|
||||
if (running) return;
|
||||
await FlutterForegroundTask.startService(
|
||||
notificationTitle: 'MeshCore running',
|
||||
notificationText: 'Keeping BLE connected',
|
||||
callback: startCallback,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
final running = await FlutterForegroundTask.isRunningService;
|
||||
if (!running) return;
|
||||
await FlutterForegroundTask.stopService();
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void startCallback() {
|
||||
FlutterForegroundTask.setTaskHandler(_MeshCoreTaskHandler());
|
||||
}
|
||||
|
||||
class _MeshCoreTaskHandler extends TaskHandler {
|
||||
@override
|
||||
void onStart(DateTime timestamp, SendPort? sendPort) {}
|
||||
|
||||
@override
|
||||
void onRepeatEvent(DateTime timestamp, SendPort? sendPort) {}
|
||||
|
||||
@override
|
||||
void onDestroy(DateTime timestamp, SendPort? sendPort) {}
|
||||
|
||||
@override
|
||||
void onNotificationButtonPressed(String id) {}
|
||||
|
||||
@override
|
||||
void onNotificationPressed() {
|
||||
FlutterForegroundTask.launchApp('/');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
const int _codec2Mode1300 = 4;
|
||||
|
||||
class Codec2Ffi {
|
||||
Codec2Ffi._(this._lib)
|
||||
: _codec2Create = _lib
|
||||
.lookupFunction<_codec2_create_c, _codec2_create_d>('codec2_create'),
|
||||
_codec2Destroy = _lib
|
||||
.lookupFunction<_codec2_destroy_c, _codec2_destroy_d>('codec2_destroy'),
|
||||
_codec2Encode = _lib
|
||||
.lookupFunction<_codec2_encode_c, _codec2_encode_d>('codec2_encode'),
|
||||
_codec2Decode = _lib
|
||||
.lookupFunction<_codec2_decode_c, _codec2_decode_d>('codec2_decode'),
|
||||
_codec2SamplesPerFrame = _lib.lookupFunction<_codec2_samples_per_frame_c,
|
||||
_codec2_samples_per_frame_d>('codec2_samples_per_frame'),
|
||||
_codec2BytesPerFrame = _lib.lookupFunction<_codec2_bytes_per_frame_c,
|
||||
_codec2_bytes_per_frame_d>('codec2_bytes_per_frame');
|
||||
|
||||
static final Codec2Ffi instance = Codec2Ffi._(_openLibrary());
|
||||
|
||||
final DynamicLibrary _lib;
|
||||
final _codec2_create_d _codec2Create;
|
||||
final _codec2_destroy_d _codec2Destroy;
|
||||
final _codec2_encode_d _codec2Encode;
|
||||
final _codec2_decode_d _codec2Decode;
|
||||
final _codec2_samples_per_frame_d _codec2SamplesPerFrame;
|
||||
final _codec2_bytes_per_frame_d _codec2BytesPerFrame;
|
||||
|
||||
Codec2Session createSession() {
|
||||
final handle = _codec2Create(_codec2Mode1300);
|
||||
if (handle == nullptr) {
|
||||
throw StateError('codec2_create returned null');
|
||||
}
|
||||
return Codec2Session._(
|
||||
handle: handle,
|
||||
destroy: _codec2Destroy,
|
||||
encode: _codec2Encode,
|
||||
decode: _codec2Decode,
|
||||
samplesPerFrame: _codec2SamplesPerFrame,
|
||||
bytesPerFrame: _codec2BytesPerFrame,
|
||||
);
|
||||
}
|
||||
|
||||
static DynamicLibrary _openLibrary() {
|
||||
if (Platform.isAndroid) {
|
||||
return DynamicLibrary.open('libcodec2.so');
|
||||
}
|
||||
if (Platform.isIOS || Platform.isMacOS) {
|
||||
return DynamicLibrary.process();
|
||||
}
|
||||
throw UnsupportedError('Codec2 is only supported on Android and iOS.');
|
||||
}
|
||||
}
|
||||
|
||||
class Codec2Session {
|
||||
Codec2Session._({
|
||||
required this.handle,
|
||||
required this.destroy,
|
||||
required this.encode,
|
||||
required this.decode,
|
||||
required this.samplesPerFrame,
|
||||
required this.bytesPerFrame,
|
||||
});
|
||||
|
||||
final Pointer<Void> handle;
|
||||
final _codec2_destroy_d destroy;
|
||||
final _codec2_encode_d encode;
|
||||
final _codec2_decode_d decode;
|
||||
final _codec2_samples_per_frame_d samplesPerFrame;
|
||||
final _codec2_bytes_per_frame_d bytesPerFrame;
|
||||
|
||||
int get samplesPerFrameValue => samplesPerFrame(handle);
|
||||
int get bytesPerFrameValue => bytesPerFrame(handle);
|
||||
|
||||
Uint8List encodePcmFrame(Int16List pcmFrame) {
|
||||
final bytesOut = calloc<Uint8>(bytesPerFrameValue);
|
||||
final pcmIn = calloc<Int16>(samplesPerFrameValue);
|
||||
try {
|
||||
final sampleCount = samplesPerFrameValue;
|
||||
final pcmBuffer = pcmIn.asTypedList(sampleCount);
|
||||
final copyLen = pcmFrame.length < sampleCount ? pcmFrame.length : sampleCount;
|
||||
pcmBuffer.setRange(0, copyLen, pcmFrame);
|
||||
if (copyLen < sampleCount) {
|
||||
for (var i = copyLen; i < sampleCount; i++) {
|
||||
pcmBuffer[i] = 0;
|
||||
}
|
||||
}
|
||||
encode(handle, bytesOut, pcmIn);
|
||||
return Uint8List.fromList(bytesOut.asTypedList(bytesPerFrameValue));
|
||||
} finally {
|
||||
calloc.free(bytesOut);
|
||||
calloc.free(pcmIn);
|
||||
}
|
||||
}
|
||||
|
||||
Int16List decodeCodecFrame(Uint8List codecFrame) {
|
||||
final pcmOut = calloc<Int16>(samplesPerFrameValue);
|
||||
final bytesIn = calloc<Uint8>(bytesPerFrameValue);
|
||||
try {
|
||||
final codecBuffer = bytesIn.asTypedList(bytesPerFrameValue);
|
||||
codecBuffer.setRange(0, bytesPerFrameValue, codecFrame);
|
||||
decode(handle, pcmOut, bytesIn);
|
||||
return Int16List.fromList(pcmOut.asTypedList(samplesPerFrameValue));
|
||||
} finally {
|
||||
calloc.free(bytesIn);
|
||||
calloc.free(pcmOut);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
destroy(handle);
|
||||
}
|
||||
}
|
||||
|
||||
typedef _codec2_create_c = Pointer<Void> Function(Int32 mode);
|
||||
typedef _codec2_create_d = Pointer<Void> Function(int mode);
|
||||
|
||||
typedef _codec2_destroy_c = Void Function(Pointer<Void> codec2State);
|
||||
typedef _codec2_destroy_d = void Function(Pointer<Void> codec2State);
|
||||
|
||||
typedef _codec2_encode_c = Void Function(
|
||||
Pointer<Void> codec2State,
|
||||
Pointer<Uint8> bytes,
|
||||
Pointer<Int16> speechIn,
|
||||
);
|
||||
typedef _codec2_encode_d = void Function(
|
||||
Pointer<Void> codec2State,
|
||||
Pointer<Uint8> bytes,
|
||||
Pointer<Int16> speechIn,
|
||||
);
|
||||
|
||||
typedef _codec2_decode_c = Void Function(
|
||||
Pointer<Void> codec2State,
|
||||
Pointer<Int16> speechOut,
|
||||
Pointer<Uint8> bytes,
|
||||
);
|
||||
typedef _codec2_decode_d = void Function(
|
||||
Pointer<Void> codec2State,
|
||||
Pointer<Int16> speechOut,
|
||||
Pointer<Uint8> bytes,
|
||||
);
|
||||
|
||||
typedef _codec2_samples_per_frame_c = Int32 Function(Pointer<Void> codec2State);
|
||||
typedef _codec2_samples_per_frame_d = int Function(Pointer<Void> codec2State);
|
||||
|
||||
typedef _codec2_bytes_per_frame_c = Int32 Function(Pointer<Void> codec2State);
|
||||
typedef _codec2_bytes_per_frame_d = int Function(Pointer<Void> codec2State);
|
||||
@@ -0,0 +1,241 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
|
||||
const String kMapTileUrlTemplate =
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
|
||||
class MapTileCacheProgress {
|
||||
final int completed;
|
||||
final int total;
|
||||
final int failed;
|
||||
|
||||
const MapTileCacheProgress({
|
||||
required this.completed,
|
||||
required this.total,
|
||||
required this.failed,
|
||||
});
|
||||
}
|
||||
|
||||
class MapTileCacheResult {
|
||||
final int total;
|
||||
final int downloaded;
|
||||
final int failed;
|
||||
|
||||
const MapTileCacheResult({
|
||||
required this.total,
|
||||
required this.downloaded,
|
||||
required this.failed,
|
||||
});
|
||||
}
|
||||
|
||||
class MapTileCacheService {
|
||||
static const String cacheKey = 'map_tile_cache';
|
||||
static const String userAgentPackageName = 'com.meshcore.open';
|
||||
static const int defaultMinZoom = 10;
|
||||
static const int defaultMaxZoom = 15;
|
||||
|
||||
final BaseCacheManager cacheManager;
|
||||
late final TileProvider tileProvider;
|
||||
|
||||
MapTileCacheService({BaseCacheManager? cacheManager})
|
||||
: cacheManager = cacheManager ??
|
||||
CacheManager(
|
||||
Config(
|
||||
cacheKey,
|
||||
stalePeriod: const Duration(days: 365),
|
||||
maxNrOfCacheObjects: 200000,
|
||||
),
|
||||
) {
|
||||
tileProvider = CachedNetworkTileProvider(cacheManager: this.cacheManager);
|
||||
}
|
||||
|
||||
Map<String, String> get defaultHeaders => {
|
||||
'User-Agent': 'flutter_map ($userAgentPackageName)',
|
||||
};
|
||||
|
||||
Future<void> clearCache() async {
|
||||
await cacheManager.emptyCache();
|
||||
}
|
||||
|
||||
int estimateTileCount(LatLngBounds bounds, int minZoom, int maxZoom) {
|
||||
final safeMin = math.min(minZoom, maxZoom);
|
||||
final safeMax = math.max(minZoom, maxZoom);
|
||||
int total = 0;
|
||||
|
||||
for (int zoom = safeMin; zoom <= safeMax; zoom++) {
|
||||
final tileBounds = _tileBoundsForBounds(bounds, zoom);
|
||||
final xCount = tileBounds.maxX - tileBounds.minX + 1;
|
||||
final yCount = tileBounds.maxY - tileBounds.minY + 1;
|
||||
total += xCount * yCount;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
Future<MapTileCacheResult> downloadRegion({
|
||||
required LatLngBounds bounds,
|
||||
required int minZoom,
|
||||
required int maxZoom,
|
||||
int concurrentDownloads = 8,
|
||||
Map<String, String>? headers,
|
||||
void Function(MapTileCacheProgress progress)? onProgress,
|
||||
}) async {
|
||||
final safeMin = math.min(minZoom, maxZoom);
|
||||
final safeMax = math.max(minZoom, maxZoom);
|
||||
final total = estimateTileCount(bounds, safeMin, safeMax);
|
||||
final authHeaders = headers ?? defaultHeaders;
|
||||
final safeConcurrency = math.max(1, concurrentDownloads);
|
||||
int completed = 0;
|
||||
int failed = 0;
|
||||
|
||||
final pending = <Future<void>>[];
|
||||
Future<void> queueDownload(String url) async {
|
||||
final future = cacheManager
|
||||
.downloadFile(url, key: url, authHeaders: authHeaders)
|
||||
.then((_) {
|
||||
completed += 1;
|
||||
}).catchError((_) {
|
||||
completed += 1;
|
||||
failed += 1;
|
||||
}).whenComplete(() {
|
||||
onProgress?.call(MapTileCacheProgress(
|
||||
completed: completed,
|
||||
total: total,
|
||||
failed: failed,
|
||||
));
|
||||
});
|
||||
|
||||
pending.add(future);
|
||||
if (pending.length >= safeConcurrency) {
|
||||
await Future.wait(pending);
|
||||
pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
for (int zoom = safeMin; zoom <= safeMax; zoom++) {
|
||||
final tileBounds = _tileBoundsForBounds(bounds, zoom);
|
||||
for (int x = tileBounds.minX; x <= tileBounds.maxX; x++) {
|
||||
for (int y = tileBounds.minY; y <= tileBounds.maxY; y++) {
|
||||
final url = _buildTileUrl(x, y, zoom);
|
||||
await queueDownload(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pending.isNotEmpty) {
|
||||
await Future.wait(pending);
|
||||
}
|
||||
|
||||
return MapTileCacheResult(
|
||||
total: total,
|
||||
downloaded: completed - failed,
|
||||
failed: failed,
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, double> boundsToJson(LatLngBounds bounds) {
|
||||
return {
|
||||
'north': bounds.north,
|
||||
'south': bounds.south,
|
||||
'east': bounds.east,
|
||||
'west': bounds.west,
|
||||
};
|
||||
}
|
||||
|
||||
static LatLngBounds? boundsFromJson(Map<String, dynamic>? json) {
|
||||
if (json == null) return null;
|
||||
final north = (json['north'] as num?)?.toDouble();
|
||||
final south = (json['south'] as num?)?.toDouble();
|
||||
final east = (json['east'] as num?)?.toDouble();
|
||||
final west = (json['west'] as num?)?.toDouble();
|
||||
if (north == null || south == null || east == null || west == null) {
|
||||
return null;
|
||||
}
|
||||
return LatLngBounds.unsafe(
|
||||
north: north,
|
||||
south: south,
|
||||
east: east,
|
||||
west: west,
|
||||
);
|
||||
}
|
||||
|
||||
_TileBounds _tileBoundsForBounds(LatLngBounds bounds, int zoom) {
|
||||
final north = _clampLatitude(bounds.north);
|
||||
final south = _clampLatitude(bounds.south);
|
||||
final maxIndex = (1 << zoom) - 1;
|
||||
|
||||
final minX = _lonToTileX(bounds.west, zoom, maxIndex);
|
||||
final maxX = _lonToTileX(bounds.east, zoom, maxIndex);
|
||||
final minY = _latToTileY(north, zoom, maxIndex);
|
||||
final maxY = _latToTileY(south, zoom, maxIndex);
|
||||
|
||||
return _TileBounds(
|
||||
minX: math.min(minX, maxX),
|
||||
maxX: math.max(minX, maxX),
|
||||
minY: math.min(minY, maxY),
|
||||
maxY: math.max(minY, maxY),
|
||||
);
|
||||
}
|
||||
|
||||
int _lonToTileX(double lon, int zoom, int maxIndex) {
|
||||
final n = 1 << zoom;
|
||||
final value = ((lon + 180.0) / 360.0 * n).floor();
|
||||
return value.clamp(0, maxIndex) as int;
|
||||
}
|
||||
|
||||
int _latToTileY(double lat, int zoom, int maxIndex) {
|
||||
final n = 1 << zoom;
|
||||
final rad = lat * math.pi / 180.0;
|
||||
final value = ((1 -
|
||||
math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) /
|
||||
2 *
|
||||
n)
|
||||
.floor();
|
||||
return value.clamp(0, maxIndex) as int;
|
||||
}
|
||||
|
||||
double _clampLatitude(double lat) {
|
||||
const maxLat = 85.05112878;
|
||||
return lat.clamp(-maxLat, maxLat) as double;
|
||||
}
|
||||
|
||||
String _buildTileUrl(int x, int y, int zoom) {
|
||||
return kMapTileUrlTemplate
|
||||
.replaceAll('{z}', zoom.toString())
|
||||
.replaceAll('{x}', x.toString())
|
||||
.replaceAll('{y}', y.toString());
|
||||
}
|
||||
}
|
||||
|
||||
class CachedNetworkTileProvider extends TileProvider {
|
||||
final BaseCacheManager cacheManager;
|
||||
|
||||
CachedNetworkTileProvider({required this.cacheManager, super.headers});
|
||||
|
||||
@override
|
||||
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) {
|
||||
final url = getTileUrl(coordinates, options);
|
||||
return CachedNetworkImageProvider(
|
||||
url,
|
||||
cacheManager: cacheManager,
|
||||
headers: headers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TileBounds {
|
||||
final int minX;
|
||||
final int maxX;
|
||||
final int minY;
|
||||
final int maxY;
|
||||
|
||||
const _TileBounds({
|
||||
required this.minX,
|
||||
required this.maxX,
|
||||
required this.minY,
|
||||
required this.maxY,
|
||||
});
|
||||
}
|
||||
@@ -103,6 +103,7 @@ class MessageRetryService extends ChangeNotifier {
|
||||
latitude: contact.latitude,
|
||||
longitude: contact.longitude,
|
||||
lastSeen: contact.lastSeen,
|
||||
lastMessageAt: contact.lastMessageAt,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,55 @@ class NotificationService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showChannelMessageNotification({
|
||||
required String channelName,
|
||||
required String message,
|
||||
int? channelIndex,
|
||||
}) async {
|
||||
if (!_isInitialized) {
|
||||
await initialize();
|
||||
}
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'channel_messages',
|
||||
'Channel Messages',
|
||||
channelDescription: 'New channel message notifications',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
final preview = _truncateMessage(message, 30);
|
||||
final body = preview.isEmpty
|
||||
? 'Received new message'
|
||||
: 'Received new message: $preview';
|
||||
|
||||
await _notifications.show(
|
||||
channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
|
||||
channelName,
|
||||
body,
|
||||
notificationDetails,
|
||||
payload: 'channel:$channelIndex',
|
||||
);
|
||||
}
|
||||
|
||||
String _truncateMessage(String message, int maxLength) {
|
||||
final trimmed = message.trim();
|
||||
if (trimmed.length <= maxLength) return trimmed;
|
||||
return '${trimmed.substring(0, maxLength)}...';
|
||||
}
|
||||
|
||||
void _onNotificationTapped(NotificationResponse response) {
|
||||
final payload = response.payload;
|
||||
if (payload != null) {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -65,6 +65,7 @@ class ChannelMessageStore {
|
||||
'repeatCount': msg.repeatCount,
|
||||
'pathLength': msg.pathLength,
|
||||
'pathBytes': base64Encode(msg.pathBytes),
|
||||
'pathVariants': msg.pathVariants.map(base64Encode).toList(),
|
||||
'repeats': msg.repeats.map(_repeatToJson).toList(),
|
||||
};
|
||||
}
|
||||
@@ -87,6 +88,9 @@ class ChannelMessageStore {
|
||||
pathBytes: json['pathBytes'] != null
|
||||
? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
|
||||
: Uint8List(0),
|
||||
pathVariants: (json['pathVariants'] as List<dynamic>?)
|
||||
?.map((entry) => Uint8List.fromList(base64Decode(entry as String)))
|
||||
.toList(),
|
||||
repeats: (json['repeats'] as List<dynamic>?)
|
||||
?.map((entry) => _repeatFromJson(entry as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
|
||||
@@ -37,10 +37,13 @@ class ContactStore {
|
||||
'latitude': contact.latitude,
|
||||
'longitude': contact.longitude,
|
||||
'lastSeen': contact.lastSeen.millisecondsSinceEpoch,
|
||||
'lastMessageAt': contact.lastMessageAt.millisecondsSinceEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
Contact _fromJson(Map<String, dynamic> json) {
|
||||
final lastSeenMs = json['lastSeen'] as int? ?? 0;
|
||||
final lastMessageMs = json['lastMessageAt'] as int?;
|
||||
return Contact(
|
||||
publicKey: Uint8List.fromList(base64Decode(json['publicKey'] as String)),
|
||||
name: json['name'] as String? ?? 'Unknown',
|
||||
@@ -51,7 +54,8 @@ class ContactStore {
|
||||
: Uint8List(0),
|
||||
latitude: (json['latitude'] as num?)?.toDouble(),
|
||||
longitude: (json['longitude'] as num?)?.toDouble(),
|
||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(json['lastSeen'] as int? ?? 0),
|
||||
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
|
||||
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(lastMessageMs ?? lastSeenMs),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ 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,
|
||||
@@ -65,6 +69,10 @@ 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,26 @@
|
||||
import '../models/contact.dart';
|
||||
|
||||
bool matchesContactQuery(Contact contact, String query) {
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
if (normalizedQuery.isEmpty) return true;
|
||||
|
||||
if (contact.name.toLowerCase().contains(normalizedQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final hexPrefix = _extractHexPrefix(normalizedQuery);
|
||||
if (hexPrefix == null) return false;
|
||||
|
||||
return contact.publicKeyHex.toLowerCase().startsWith(hexPrefix);
|
||||
}
|
||||
|
||||
String? _extractHexPrefix(String query) {
|
||||
var cleaned = query;
|
||||
if (cleaned.startsWith('0x')) {
|
||||
cleaned = cleaned.substring(2);
|
||||
}
|
||||
cleaned = cleaned.replaceAll(' ', '');
|
||||
if (cleaned.length < 2) return null;
|
||||
if (!RegExp(r'^[0-9a-f]+$').hasMatch(cleaned)) return null;
|
||||
return cleaned;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
Route<T> buildQuickSwitchRoute<T>(Widget page) {
|
||||
return PageRouteBuilder<T>(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 220),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 200),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
final curved = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
return FadeTransition(
|
||||
opacity: curved,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.02, 0),
|
||||
end: Offset.zero,
|
||||
).animate(curved),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class QuickSwitchBar extends StatelessWidget {
|
||||
final int selectedIndex;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
|
||||
const QuickSwitchBar({
|
||||
super.key,
|
||||
required this.selectedIndex,
|
||||
required this.onDestinationSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final labelStyle = theme.textTheme.labelMedium ?? const TextStyle();
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: NavigationBarTheme(
|
||||
data: NavigationBarThemeData(
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
indicatorColor: colorScheme.primaryContainer,
|
||||
labelTextStyle: MaterialStateProperty.resolveWith((states) {
|
||||
final isSelected = states.contains(MaterialState.selected);
|
||||
return labelStyle.copyWith(
|
||||
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
);
|
||||
}),
|
||||
iconTheme: MaterialStateProperty.resolveWith((states) {
|
||||
final isSelected = states.contains(MaterialState.selected);
|
||||
return IconThemeData(
|
||||
color: isSelected
|
||||
? colorScheme.onPrimaryContainer
|
||||
: colorScheme.onSurfaceVariant,
|
||||
);
|
||||
}),
|
||||
),
|
||||
child: NavigationBar(
|
||||
height: 60,
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.people_outline),
|
||||
label: 'Contacts',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.tag),
|
||||
label: 'Channels',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.map_outlined),
|
||||
label: 'Map',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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