mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-25 03:42:55 +10:00
update to current dev a50c0d0b2d
This commit is contained in:
@@ -193,6 +193,7 @@ Devices are discovered by scanning for BLE advertisements with known MeshCore de
|
|||||||
- `WisCore-`
|
- `WisCore-`
|
||||||
- `HT-`
|
- `HT-`
|
||||||
- `LowMesh_MC_`
|
- `LowMesh_MC_`
|
||||||
|
- `NRF52`
|
||||||
|
|
||||||
New device prefixes can be added in `lib/connector/meshcore_uuids.dart`.
|
New device prefixes can be added in `lib/connector/meshcore_uuids.dart`.
|
||||||
|
|
||||||
|
|||||||
@@ -302,6 +302,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
final Map<String, int> _contactUnreadCount = {};
|
final Map<String, int> _contactUnreadCount = {};
|
||||||
final Map<String, RepeaterBatterySnapshot> _repeaterBatterySnapshots = {};
|
final Map<String, RepeaterBatterySnapshot> _repeaterBatterySnapshots = {};
|
||||||
bool _unreadStateLoaded = false;
|
bool _unreadStateLoaded = false;
|
||||||
|
int _cachedContactsUnreadTotal = 0;
|
||||||
|
int _cachedChannelsUnreadTotal = 0;
|
||||||
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
|
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
|
||||||
String? _activeContactKey;
|
String? _activeContactKey;
|
||||||
int? _activeChannelIndex;
|
int? _activeChannelIndex;
|
||||||
@@ -606,16 +608,42 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
int getTotalUnreadCount() {
|
int getTotalUnreadCount() {
|
||||||
if (!_unreadStateLoaded) return 0;
|
if (!_unreadStateLoaded) return 0;
|
||||||
var total = 0;
|
return getTotalContactsUnreadCount() + getTotalChannelsUnreadCount();
|
||||||
// Count unread contact messages
|
}
|
||||||
for (final contact in _contacts) {
|
|
||||||
total += getUnreadCountForContact(contact);
|
int getTotalContactsUnreadCount() {
|
||||||
}
|
if (!_unreadStateLoaded) return 0;
|
||||||
// Count unread channel messages
|
return _cachedContactsUnreadTotal;
|
||||||
for (final channelIndex in _channelMessages.keys) {
|
}
|
||||||
total += getUnreadCountForChannelIndex(channelIndex);
|
|
||||||
}
|
int getTotalChannelsUnreadCount() {
|
||||||
return total;
|
if (!_unreadStateLoaded) return 0;
|
||||||
|
return _cachedChannelsUnreadTotal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recalculates both cached unread totals from scratch.
|
||||||
|
/// Called when unread state is first loaded.
|
||||||
|
void _recalculateCachedUnreadTotals() {
|
||||||
|
_recalculateCachedContactsUnreadTotal();
|
||||||
|
_recalculateCachedChannelsUnreadTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recalculateCachedContactsUnreadTotal() {
|
||||||
|
int total = 0;
|
||||||
|
_contactUnreadCount.forEach((contactKeyHex, count) {
|
||||||
|
if (_shouldTrackUnreadForContactKey(contactKeyHex)) {
|
||||||
|
total += count;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_cachedContactsUnreadTotal = total;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recalculateCachedChannelsUnreadTotal() {
|
||||||
|
final allChannels = _channels.isNotEmpty ? _channels : _cachedChannels;
|
||||||
|
_cachedChannelsUnreadTotal = allChannels.fold(
|
||||||
|
0,
|
||||||
|
(total, ch) => total + ch.unreadCount,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool isChannelSmazEnabled(int channelIndex) {
|
bool isChannelSmazEnabled(int channelIndex) {
|
||||||
@@ -649,11 +677,13 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
..clear()
|
..clear()
|
||||||
..addAll(await _unreadStore.loadContactUnreadCount());
|
..addAll(await _unreadStore.loadContactUnreadCount());
|
||||||
_unreadStateLoaded = true;
|
_unreadStateLoaded = true;
|
||||||
|
_recalculateCachedUnreadTotals();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadCachedChannels() async {
|
Future<void> loadCachedChannels() async {
|
||||||
_cachedChannels = await _channelStore.loadChannels();
|
_cachedChannels = await _channelStore.loadChannels();
|
||||||
|
_recalculateCachedChannelsUnreadTotal();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setActiveContact(String? contactKeyHex) {
|
void setActiveContact(String? contactKeyHex) {
|
||||||
@@ -680,6 +710,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
final previousCount = _contactUnreadCount[contactKeyHex] ?? 0;
|
final previousCount = _contactUnreadCount[contactKeyHex] ?? 0;
|
||||||
if (previousCount > 0) {
|
if (previousCount > 0) {
|
||||||
_contactUnreadCount[contactKeyHex] = 0;
|
_contactUnreadCount[contactKeyHex] = 0;
|
||||||
|
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - previousCount)
|
||||||
|
.clamp(0, _cachedContactsUnreadTotal);
|
||||||
_appDebugLogService?.info(
|
_appDebugLogService?.info(
|
||||||
'Contact $contactKeyHex marked as read (was $previousCount unread)',
|
'Contact $contactKeyHex marked as read (was $previousCount unread)',
|
||||||
tag: 'Unread',
|
tag: 'Unread',
|
||||||
@@ -721,6 +753,8 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
if (channel != null && channel.unreadCount > 0) {
|
if (channel != null && channel.unreadCount > 0) {
|
||||||
final previousCount = channel.unreadCount;
|
final previousCount = channel.unreadCount;
|
||||||
channel.unreadCount = 0;
|
channel.unreadCount = 0;
|
||||||
|
_cachedChannelsUnreadTotal = (_cachedChannelsUnreadTotal - previousCount)
|
||||||
|
.clamp(0, _cachedChannelsUnreadTotal);
|
||||||
_appDebugLogService?.info(
|
_appDebugLogService?.info(
|
||||||
'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)',
|
'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)',
|
||||||
tag: 'Unread',
|
tag: 'Unread',
|
||||||
@@ -3156,6 +3190,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
unawaited(_persistContacts());
|
unawaited(_persistContacts());
|
||||||
_conversations.remove(contact.publicKeyHex);
|
_conversations.remove(contact.publicKeyHex);
|
||||||
_loadedConversationKeys.remove(contact.publicKeyHex);
|
_loadedConversationKeys.remove(contact.publicKeyHex);
|
||||||
|
final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0;
|
||||||
|
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount)
|
||||||
|
.clamp(0, _cachedContactsUnreadTotal);
|
||||||
_contactUnreadCount.remove(contact.publicKeyHex);
|
_contactUnreadCount.remove(contact.publicKeyHex);
|
||||||
_unreadStore.saveContactUnreadCount(
|
_unreadStore.saveContactUnreadCount(
|
||||||
Map<String, int>.from(_contactUnreadCount),
|
Map<String, int>.from(_contactUnreadCount),
|
||||||
@@ -3549,6 +3586,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
// Cache channels for offline use
|
// Cache channels for offline use
|
||||||
_cachedChannels = List<Channel>.from(_channels);
|
_cachedChannels = List<Channel>.from(_channels);
|
||||||
unawaited(_channelStore.saveChannels(_channels));
|
unawaited(_channelStore.saveChannels(_channels));
|
||||||
|
_recalculateCachedChannelsUnreadTotal();
|
||||||
|
|
||||||
// Apply ordering and notify UI
|
// Apply ordering and notify UI
|
||||||
_applyChannelOrder();
|
_applyChannelOrder();
|
||||||
@@ -4101,6 +4139,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
_handleDiscovery(contact, frame, noNotify: true, addActive: true);
|
_handleDiscovery(contact, frame, noNotify: true, addActive: true);
|
||||||
|
|
||||||
if (contact.type == advTypeRepeater) {
|
if (contact.type == advTypeRepeater) {
|
||||||
|
final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0;
|
||||||
|
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount)
|
||||||
|
.clamp(0, _cachedContactsUnreadTotal);
|
||||||
_contactUnreadCount.remove(contact.publicKeyHex);
|
_contactUnreadCount.remove(contact.publicKeyHex);
|
||||||
_unreadStore.saveContactUnreadCount(
|
_unreadStore.saveContactUnreadCount(
|
||||||
Map<String, int>.from(_contactUnreadCount),
|
Map<String, int>.from(_contactUnreadCount),
|
||||||
@@ -4191,6 +4232,9 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (contact.type == advTypeRepeater) {
|
if (contact.type == advTypeRepeater) {
|
||||||
|
final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0;
|
||||||
|
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount)
|
||||||
|
.clamp(0, _cachedContactsUnreadTotal);
|
||||||
_contactUnreadCount.remove(contact.publicKeyHex);
|
_contactUnreadCount.remove(contact.publicKeyHex);
|
||||||
_unreadStore.saveContactUnreadCount(
|
_unreadStore.saveContactUnreadCount(
|
||||||
Map<String, int>.from(_contactUnreadCount),
|
Map<String, int>.from(_contactUnreadCount),
|
||||||
@@ -4464,17 +4508,13 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
badgeCount: getTotalUnreadCount(),
|
badgeCount: getTotalUnreadCount(),
|
||||||
);
|
);
|
||||||
} else if (c?.type == advTypeRoom) {
|
} else if (c?.type == advTypeRoom) {
|
||||||
// Room server messages include a 4-char prefix; strip it for notifications
|
|
||||||
final bodyText = msg.text.length > 4
|
|
||||||
? msg.text.substring(4)
|
|
||||||
: msg.text;
|
|
||||||
final resolvedText =
|
final resolvedText =
|
||||||
(translationResult != null &&
|
(translationResult != null &&
|
||||||
translationResult.status ==
|
translationResult.status ==
|
||||||
MessageTranslationStatus.completed &&
|
MessageTranslationStatus.completed &&
|
||||||
translationResult.translatedText.trim().isNotEmpty)
|
translationResult.translatedText.trim().isNotEmpty)
|
||||||
? translationResult.translatedText.trim()
|
? translationResult.translatedText.trim()
|
||||||
: bodyText.trim();
|
: msg.text.trim();
|
||||||
await _notificationService.showMessageNotification(
|
await _notificationService.showMessageNotification(
|
||||||
contactName: c?.name ?? 'Unknown Room',
|
contactName: c?.name ?? 'Unknown Room',
|
||||||
message: resolvedText,
|
message: resolvedText,
|
||||||
@@ -4522,16 +4562,24 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
timestampRaw * 1000,
|
timestampRaw * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (txtType == 2) {
|
final flags = txtType;
|
||||||
reader.skipBytes(4); // Skip extra 4 bytes for signed/plain variants
|
final shiftedType = flags >> 2;
|
||||||
|
final rawType = flags;
|
||||||
|
final isSigned = shiftedType == txtTypeSigned || rawType == txtTypeSigned;
|
||||||
|
final Uint8List? roomAuthorPrefix;
|
||||||
|
if (isSigned) {
|
||||||
|
// Room-server pushed posts use signed/plain contact messages where this
|
||||||
|
// 4-byte "signature" field is actually the original author's pubkey
|
||||||
|
// prefix. Keep it as metadata; the text starts after these bytes.
|
||||||
|
roomAuthorPrefix = reader.readBytes(4);
|
||||||
|
} else {
|
||||||
|
roomAuthorPrefix = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final msgText = reader.readCString();
|
final msgText = reader.readCString();
|
||||||
|
|
||||||
final flags = txtType;
|
final isPlain =
|
||||||
final shiftedType = flags >> 2;
|
shiftedType == txtTypePlain || rawType == txtTypePlain || isSigned;
|
||||||
final rawType = flags;
|
|
||||||
final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain;
|
|
||||||
final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
|
final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
|
||||||
if (!isPlain && !isCli) {
|
if (!isPlain && !isCli) {
|
||||||
appLogger.warn(
|
appLogger.warn(
|
||||||
@@ -4568,9 +4616,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
status: MessageStatus.delivered,
|
status: MessageStatus.delivered,
|
||||||
pathLength: pathLength == 0xFF ? 0 : pathLength,
|
pathLength: pathLength == 0xFF ? 0 : pathLength,
|
||||||
pathBytes: Uint8List(0),
|
pathBytes: Uint8List(0),
|
||||||
fourByteRoomContactKey: msgText.length >= 4
|
fourByteRoomContactKey: roomAuthorPrefix,
|
||||||
? Uint8List.fromList(msgText.substring(0, 4).codeUnits)
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.warn('Error parsing contact direct message: $e');
|
appLogger.warn('Error parsing contact direct message: $e');
|
||||||
@@ -5247,6 +5293,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
final channel = _findChannelByIndex(channelIndex);
|
final channel = _findChannelByIndex(channelIndex);
|
||||||
if (channel != null) {
|
if (channel != null) {
|
||||||
channel.unreadCount++;
|
channel.unreadCount++;
|
||||||
|
_cachedChannelsUnreadTotal++;
|
||||||
_appDebugLogService?.info(
|
_appDebugLogService?.info(
|
||||||
'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} unread count incremented to ${channel.unreadCount}',
|
'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} unread count incremented to ${channel.unreadCount}',
|
||||||
tag: 'Unread',
|
tag: 'Unread',
|
||||||
@@ -5291,6 +5338,7 @@ class MeshCoreConnector extends ChangeNotifier {
|
|||||||
|
|
||||||
final currentCount = _contactUnreadCount[contactKey] ?? 0;
|
final currentCount = _contactUnreadCount[contactKey] ?? 0;
|
||||||
_contactUnreadCount[contactKey] = currentCount + 1;
|
_contactUnreadCount[contactKey] = currentCount + 1;
|
||||||
|
_cachedContactsUnreadTotal++;
|
||||||
_appDebugLogService?.info(
|
_appDebugLogService?.info(
|
||||||
'Contact $contactKey unread count incremented to ${currentCount + 1}',
|
'Contact $contactKey unread count incremented to ${currentCount + 1}',
|
||||||
tag: 'Unread',
|
tag: 'Unread',
|
||||||
|
|||||||
@@ -11,5 +11,6 @@ class MeshCoreUuids {
|
|||||||
"Lilygo",
|
"Lilygo",
|
||||||
"HT-",
|
"HT-",
|
||||||
"LowMesh_MC_",
|
"LowMesh_MC_",
|
||||||
|
"NRF52",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import 'dart:typed_data';
|
|||||||
import 'package:crypto/crypto.dart' as crypto;
|
import 'package:crypto/crypto.dart' as crypto;
|
||||||
|
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
|
import 'community.dart';
|
||||||
|
|
||||||
|
enum ChannelType { public, private, hashtag, communityPublic, communityHashtag }
|
||||||
|
|
||||||
class Channel {
|
class Channel {
|
||||||
final int index;
|
final int index;
|
||||||
@@ -111,5 +114,36 @@ class Channel {
|
|||||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool isCommunityChannel(ChannelType channelType) {
|
||||||
|
switch (channelType) {
|
||||||
|
case ChannelType.communityPublic:
|
||||||
|
case ChannelType.communityHashtag:
|
||||||
|
return true;
|
||||||
|
case ChannelType.public:
|
||||||
|
case ChannelType.private:
|
||||||
|
case ChannelType.hashtag:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static ChannelType getChannelType(
|
||||||
|
Channel channel,
|
||||||
|
CommunityPskIndex communityIndex,
|
||||||
|
) {
|
||||||
|
Community? community = communityIndex.getCommunityForChannel(channel);
|
||||||
|
if (community != null) {
|
||||||
|
if (Community.isCommunityPublicChannel(channel, community)) {
|
||||||
|
return ChannelType.communityPublic;
|
||||||
|
}
|
||||||
|
return ChannelType.communityHashtag;
|
||||||
|
}
|
||||||
|
if (channel.isPublicChannel) {
|
||||||
|
return ChannelType.public;
|
||||||
|
} else if (channel.name.startsWith('#')) {
|
||||||
|
return ChannelType.hashtag;
|
||||||
|
}
|
||||||
|
return ChannelType.private;
|
||||||
|
}
|
||||||
|
|
||||||
static const String publicChannelPsk = '8b3387e9c5cdea6ac9e5edbaa115cd72';
|
static const String publicChannelPsk = '8b3387e9c5cdea6ac9e5edbaa115cd72';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:crypto/crypto.dart' as crypto;
|
import 'package:crypto/crypto.dart' as crypto;
|
||||||
|
|
||||||
|
import 'channel.dart';
|
||||||
|
|
||||||
/// Represents a community with a shared secret for deriving channel PSKs.
|
/// Represents a community with a shared secret for deriving channel PSKs.
|
||||||
///
|
///
|
||||||
/// A Community is a namespace with a shared secret K (32 random bytes),
|
/// A Community is a namespace with a shared secret K (32 random bytes),
|
||||||
@@ -162,6 +164,12 @@ class Community {
|
|||||||
return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim();
|
return hashtag.replaceFirst(RegExp(r'^#'), '').toLowerCase().trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if this is the community's public channel
|
||||||
|
static bool isCommunityPublicChannel(Channel channel, Community community) {
|
||||||
|
final publicPsk = community.deriveCommunityPublicPsk();
|
||||||
|
return channel.pskHex == Channel.formatPskHex(publicPsk);
|
||||||
|
}
|
||||||
|
|
||||||
/// Add a hashtag channel to this community's list
|
/// Add a hashtag channel to this community's list
|
||||||
Community addHashtagChannel(String hashtag) {
|
Community addHashtagChannel(String hashtag) {
|
||||||
final normalized = _normalizeCommunityHashtag(hashtag);
|
final normalized = _normalizeCommunityHashtag(hashtag);
|
||||||
@@ -237,3 +245,28 @@ class Community {
|
|||||||
@override
|
@override
|
||||||
int get hashCode => id.hashCode;
|
int get hashCode => id.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class CommunityPskIndex {
|
||||||
|
// Cache of PSK hex -> Community for quick lookup
|
||||||
|
final Map<String, Community> _pskToCommunity = {};
|
||||||
|
|
||||||
|
void initialize(List<Community> communities) {
|
||||||
|
_pskToCommunity.clear();
|
||||||
|
for (final community in communities) {
|
||||||
|
// Map the community public channel PSK
|
||||||
|
final publicPsk = community.deriveCommunityPublicPsk();
|
||||||
|
_pskToCommunity[Channel.formatPskHex(publicPsk)] = community;
|
||||||
|
|
||||||
|
// Map all known hashtag channel PSKs
|
||||||
|
for (final hashtag in community.hashtagChannels) {
|
||||||
|
final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag);
|
||||||
|
_pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the community this channel belongs to, or null if not a community channel
|
||||||
|
Community? getCommunityForChannel(Channel channel) {
|
||||||
|
return _pskToCommunity[channel.pskHex];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -181,6 +181,276 @@ class RadioSettings {
|
|||||||
txPowerDbm: 14,
|
txPowerDbm: 14,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
'Russia Artyom (VVO)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 864.281,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_6,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Biysk (BSK)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 869.000,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_5,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Chelyabinsk (CEK)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.731,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_6,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Cherepovets (CEE)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.570,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||||
|
codingRate: LoRaCodingRate.cr4_8,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Irkutsk (IKT)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.731,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||||
|
codingRate: LoRaCodingRate.cr4_7,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Ivanovo (IWA)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.731,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_8,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Izhevsk (IJK)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.732,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_8,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Kaluga (KLF)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.731,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||||
|
codingRate: LoRaCodingRate.cr4_7,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Kazan (KZN)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.731,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_6,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Khabarovsk (KHV)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 864.281,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_6,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Kirov (KVX)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.731,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_8,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Lipetsk (LPK)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.950,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf9,
|
||||||
|
codingRate: LoRaCodingRate.cr4_7,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Moscow (MOW)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.731,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||||
|
codingRate: LoRaCodingRate.cr4_7,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Nizhny Novgorod (GOJ)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.731,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_6,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Novosibirsk (OVB)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 869.000,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf9,
|
||||||
|
codingRate: LoRaCodingRate.cr4_8,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Rostov-on-Don (ROV)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.731,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf9,
|
||||||
|
codingRate: LoRaCodingRate.cr4_7,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Ryazan (RZN)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.880,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf9,
|
||||||
|
codingRate: LoRaCodingRate.cr4_5,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Samara (KUF)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 864.281,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_7,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Saratov (GSV)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 864.281,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_7,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia St. Petersburg (LED)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.856,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||||
|
codingRate: LoRaCodingRate.cr4_7,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Tambov (TBW)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.950,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf10,
|
||||||
|
codingRate: LoRaCodingRate.cr4_5,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Tula (TYA)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.731,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_7,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Tver (KLD)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 869.169,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_8,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Ufa (UFA)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.732,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_8,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Volgograd (VOG)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 869.525,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||||
|
codingRate: LoRaCodingRate.cr4_7,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Voronezh (VOZ)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 868.731,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf8,
|
||||||
|
codingRate: LoRaCodingRate.cr4_6,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Russia Yekaterinburg (SVX)',
|
||||||
|
RadioSettings(
|
||||||
|
frequencyMHz: 869.046,
|
||||||
|
bandwidth: LoRaBandwidth.bw62_5,
|
||||||
|
spreadingFactor: LoRaSpreadingFactor.sf7,
|
||||||
|
codingRate: LoRaCodingRate.cr4_7,
|
||||||
|
txPowerDbm: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
'Switzerland',
|
'Switzerland',
|
||||||
RadioSettings(
|
RadioSettings(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../connector/meshcore_connector.dart';
|
import '../connector/meshcore_connector.dart';
|
||||||
|
import '../models/community.dart';
|
||||||
|
import '../storage/community_store.dart';
|
||||||
import '../utils/platform_info.dart';
|
import '../utils/platform_info.dart';
|
||||||
import '../helpers/chat_scroll_controller.dart';
|
import '../helpers/chat_scroll_controller.dart';
|
||||||
import '../connector/meshcore_protocol.dart';
|
import '../connector/meshcore_protocol.dart';
|
||||||
@@ -57,8 +59,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
final ChatScrollController _scrollController = ChatScrollController();
|
final ChatScrollController _scrollController = ChatScrollController();
|
||||||
final FocusNode _textFieldFocusNode = FocusNode();
|
final FocusNode _textFieldFocusNode = FocusNode();
|
||||||
ChannelMessage? _replyingToMessage;
|
ChannelMessage? _replyingToMessage;
|
||||||
|
final CommunityStore _communityStore = CommunityStore();
|
||||||
|
final CommunityPskIndex _communityIndex = CommunityPskIndex();
|
||||||
final Map<String, GlobalKey> _messageKeys = {};
|
final Map<String, GlobalKey> _messageKeys = {};
|
||||||
bool _isLoadingOlder = false;
|
bool _isLoadingOlder = false;
|
||||||
|
bool _communitiesLoaded = false;
|
||||||
|
|
||||||
MeshCoreConnector? _connector;
|
MeshCoreConnector? _connector;
|
||||||
DateTime? _lastChannelSendAt;
|
DateTime? _lastChannelSendAt;
|
||||||
@@ -82,6 +87,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
final idx = widget.channel.index;
|
final idx = widget.channel.index;
|
||||||
final unread = widget.initialUnreadCount;
|
final unread = widget.initialUnreadCount;
|
||||||
final messages = connector.getChannelMessages(widget.channel);
|
final messages = connector.getChannelMessages(widget.channel);
|
||||||
|
_loadCommunities();
|
||||||
ChannelMessage? anchor;
|
ChannelMessage? anchor;
|
||||||
if (unread > 0) {
|
if (unread > 0) {
|
||||||
anchor = _findOldestUnreadChannelAnchor(messages, unread);
|
anchor = _findOldestUnreadChannelAnchor(messages, unread);
|
||||||
@@ -108,6 +114,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Reload communities when returning from another screen
|
||||||
|
Future<void> _loadCommunities() async {
|
||||||
|
final connector = context.read<MeshCoreConnector>();
|
||||||
|
_communityStore.setPublicKeyHex = connector.selfPublicKeyHex;
|
||||||
|
final communities = await _communityStore.loadCommunities();
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_communityIndex.initialize(communities);
|
||||||
|
_communitiesLoaded = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ChannelMessage? _findOldestUnreadChannelAnchor(
|
ChannelMessage? _findOldestUnreadChannelAnchor(
|
||||||
List<ChannelMessage> messages,
|
List<ChannelMessage> messages,
|
||||||
int unreadCount,
|
int unreadCount,
|
||||||
@@ -194,16 +213,63 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _channelIcon(Channel channel) {
|
||||||
|
// Determine icon based on channel type
|
||||||
|
final ChannelType channelType = Channel.getChannelType(
|
||||||
|
channel,
|
||||||
|
_communityIndex,
|
||||||
|
);
|
||||||
|
final bool isCommunityChannel = Channel.isCommunityChannel(channelType);
|
||||||
|
IconData icon;
|
||||||
|
switch (channelType) {
|
||||||
|
case ChannelType.communityPublic:
|
||||||
|
icon = Icons.groups;
|
||||||
|
case ChannelType.communityHashtag:
|
||||||
|
icon = Icons.tag;
|
||||||
|
case ChannelType.public:
|
||||||
|
icon = Icons.public;
|
||||||
|
case ChannelType.hashtag:
|
||||||
|
icon = Icons.tag;
|
||||||
|
case ChannelType.private:
|
||||||
|
icon = Icons.lock;
|
||||||
|
}
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||||
|
child: _communitiesLoaded
|
||||||
|
? Icon(icon, size: 20)
|
||||||
|
: SizedBox.square(dimension: 20),
|
||||||
|
),
|
||||||
|
if (isCommunityChannel)
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.purple,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).cardColor,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.people, size: 8, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
_channelIcon(widget.channel),
|
||||||
widget.channel.isPublicChannel ? Icons.public : Icons.tag,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -1321,11 +1387,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showMessageActions(ChannelMessage message) {
|
void _showMessageActions(ChannelMessage message) {
|
||||||
final settings = context.read<AppSettingsService>().settings;
|
|
||||||
final translationService = context.read<TranslationService>();
|
final translationService = context.read<TranslationService>();
|
||||||
final canTranslateMessage =
|
final canTranslateMessage =
|
||||||
settings.translationEnabled &&
|
|
||||||
!settings.autoTranslateIncomingMessages &&
|
|
||||||
translationService.canTranslateIncoming(
|
translationService.canTranslateIncoming(
|
||||||
text: message.text,
|
text: message.text,
|
||||||
isCli: false,
|
isCli: false,
|
||||||
|
|||||||
@@ -44,11 +44,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
with DisconnectNavigationMixin {
|
with DisconnectNavigationMixin {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final CommunityStore _communityStore = CommunityStore();
|
final CommunityStore _communityStore = CommunityStore();
|
||||||
Timer? _searchDebounce;
|
final CommunityPskIndex _communityIndex = CommunityPskIndex();
|
||||||
List<Community> _communities = [];
|
List<Community> _communities = [];
|
||||||
|
Timer? _searchDebounce;
|
||||||
// Cache of PSK hex -> Community for quick lookup
|
|
||||||
final Map<String, Community> _pskToCommunity = {};
|
|
||||||
|
|
||||||
ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
|
ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
|
||||||
|
|
||||||
@@ -71,37 +69,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_communities = communities;
|
_communities = communities;
|
||||||
_buildPskCommunityMap();
|
_communityIndex.initialize(communities);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _buildPskCommunityMap() {
|
|
||||||
_pskToCommunity.clear();
|
|
||||||
for (final community in _communities) {
|
|
||||||
// Map the community public channel PSK
|
|
||||||
final publicPsk = community.deriveCommunityPublicPsk();
|
|
||||||
_pskToCommunity[Channel.formatPskHex(publicPsk)] = community;
|
|
||||||
|
|
||||||
// Map all known hashtag channel PSKs
|
|
||||||
for (final hashtag in community.hashtagChannels) {
|
|
||||||
final hashtagPsk = community.deriveCommunityHashtagPsk(hashtag);
|
|
||||||
_pskToCommunity[Channel.formatPskHex(hashtagPsk)] = community;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the community this channel belongs to, or null if not a community channel
|
|
||||||
Community? _getCommunityForChannel(Channel channel) {
|
|
||||||
return _pskToCommunity[channel.pskHex];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if this is the community's public channel
|
|
||||||
bool _isCommunityPublicChannel(Channel channel, Community community) {
|
|
||||||
final publicPsk = community.deriveCommunityPublicPsk();
|
|
||||||
return channel.pskHex == Channel.formatPskHex(publicPsk);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchDebounce?.cancel();
|
_searchDebounce?.cancel();
|
||||||
@@ -360,6 +332,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
selectedIndex: 1,
|
selectedIndex: 1,
|
||||||
onDestinationSelected: (index) =>
|
onDestinationSelected: (index) =>
|
||||||
_handleQuickSwitch(index, context),
|
_handleQuickSwitch(index, context),
|
||||||
|
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
|
||||||
|
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -375,37 +349,37 @@ class _ChannelsScreenState extends State<ChannelsScreen>
|
|||||||
int? dragIndex,
|
int? dragIndex,
|
||||||
}) {
|
}) {
|
||||||
final unreadCount = connector.getUnreadCountForChannel(channel);
|
final unreadCount = connector.getUnreadCountForChannel(channel);
|
||||||
final community = _getCommunityForChannel(channel);
|
|
||||||
final isCommunityChannel = community != null;
|
|
||||||
final isCommunityPublic =
|
|
||||||
isCommunityChannel && _isCommunityPublicChannel(channel, community);
|
|
||||||
|
|
||||||
// Determine icon and colors based on channel type
|
// Determine icon and colors based on channel type
|
||||||
IconData icon;
|
IconData icon;
|
||||||
Color iconColor;
|
Color iconColor;
|
||||||
Color bgColor;
|
Color bgColor;
|
||||||
|
final ChannelType channelType = Channel.getChannelType(
|
||||||
if (isCommunityChannel) {
|
channel,
|
||||||
// Community channel styling
|
_communityIndex,
|
||||||
iconColor = Colors.purple;
|
);
|
||||||
bgColor = Colors.purple.withValues(alpha: 0.2);
|
final bool isCommunityChannel = Channel.isCommunityChannel(channelType);
|
||||||
if (isCommunityPublic) {
|
switch (channelType) {
|
||||||
|
case ChannelType.communityPublic:
|
||||||
icon = Icons.groups;
|
icon = Icons.groups;
|
||||||
} else {
|
iconColor = Colors.purple;
|
||||||
|
bgColor = Colors.purple.withValues(alpha: 0.2);
|
||||||
|
case ChannelType.communityHashtag:
|
||||||
icon = Icons.tag;
|
icon = Icons.tag;
|
||||||
}
|
iconColor = Colors.purple;
|
||||||
} else if (channel.isPublicChannel) {
|
bgColor = Colors.purple.withValues(alpha: 0.2);
|
||||||
icon = Icons.public;
|
case ChannelType.public:
|
||||||
iconColor = Colors.green;
|
icon = Icons.public;
|
||||||
bgColor = Colors.green.withValues(alpha: 0.2);
|
iconColor = Colors.green;
|
||||||
} else if (channel.name.startsWith('#')) {
|
bgColor = Colors.green.withValues(alpha: 0.2);
|
||||||
icon = Icons.tag;
|
case ChannelType.hashtag:
|
||||||
iconColor = Colors.blue;
|
icon = Icons.tag;
|
||||||
bgColor = Colors.blue.withValues(alpha: 0.2);
|
iconColor = Colors.blue;
|
||||||
} else {
|
bgColor = Colors.blue.withValues(alpha: 0.2);
|
||||||
icon = Icons.lock;
|
case ChannelType.private:
|
||||||
iconColor = Colors.blue;
|
icon = Icons.lock;
|
||||||
bgColor = Colors.blue.withValues(alpha: 0.2);
|
iconColor = Colors.blue;
|
||||||
|
bgColor = Colors.blue.withValues(alpha: 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
|
|||||||
@@ -485,6 +485,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
final message = reversedMessages[messageIndex];
|
final message = reversedMessages[messageIndex];
|
||||||
String fourByteHex = '';
|
String fourByteHex = '';
|
||||||
if (contact.type == advTypeRoom) {
|
if (contact.type == advTypeRoom) {
|
||||||
|
// Room-server messages carry the original author's 4-byte prefix
|
||||||
|
// separately from message.text; use it only for resolving the name.
|
||||||
contact = _resolveContactFrom4Bytes(
|
contact = _resolveContactFrom4Bytes(
|
||||||
connector,
|
connector,
|
||||||
message.fourByteRoomContactKey.isEmpty
|
message.fourByteRoomContactKey.isEmpty
|
||||||
@@ -509,7 +511,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
? "${contact.name} [$fourByteHex]"
|
? "${contact.name} [$fourByteHex]"
|
||||||
: contact.name,
|
: contact.name,
|
||||||
sourceId: widget.contact.publicKeyHex,
|
sourceId: widget.contact.publicKeyHex,
|
||||||
isRoomServer: resolvedContact.type == advTypeRoom,
|
|
||||||
textScale: textScale,
|
textScale: textScale,
|
||||||
onTap: () => _openMessagePath(message, contact),
|
onTap: () => _openMessagePath(message, contact),
|
||||||
onLongPress: () => _showMessageActions(message, contact),
|
onLongPress: () => _showMessageActions(message, contact),
|
||||||
@@ -1577,11 +1578,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showMessageActions(Message message, Contact contact) {
|
void _showMessageActions(Message message, Contact contact) {
|
||||||
final settings = context.read<AppSettingsService>().settings;
|
|
||||||
final translationService = context.read<TranslationService>();
|
final translationService = context.read<TranslationService>();
|
||||||
final canTranslateMessage =
|
final canTranslateMessage =
|
||||||
settings.translationEnabled &&
|
|
||||||
!settings.autoTranslateIncomingMessages &&
|
|
||||||
translationService.canTranslateIncoming(
|
translationService.canTranslateIncoming(
|
||||||
text: message.text,
|
text: message.text,
|
||||||
isCli: message.isCli,
|
isCli: message.isCli,
|
||||||
@@ -1748,7 +1746,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
|||||||
class _MessageBubble extends StatelessWidget {
|
class _MessageBubble extends StatelessWidget {
|
||||||
final Message message;
|
final Message message;
|
||||||
final String senderName;
|
final String senderName;
|
||||||
final bool isRoomServer;
|
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onLongPress;
|
final VoidCallback? onLongPress;
|
||||||
final void Function(Message message, String emoji)? onRetryReaction;
|
final void Function(Message message, String emoji)? onRetryReaction;
|
||||||
@@ -1759,7 +1756,6 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
required this.message,
|
required this.message,
|
||||||
required this.senderName,
|
required this.senderName,
|
||||||
required this.sourceId,
|
required this.sourceId,
|
||||||
required this.isRoomServer,
|
|
||||||
required this.textScale,
|
required this.textScale,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.onLongPress,
|
this.onLongPress,
|
||||||
@@ -1785,10 +1781,9 @@ class _MessageBubble extends StatelessWidget {
|
|||||||
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
|
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
|
||||||
final metaColor = textColor.withValues(alpha: 0.7);
|
final metaColor = textColor.withValues(alpha: 0.7);
|
||||||
const bodyFontSize = 14.0;
|
const bodyFontSize = 14.0;
|
||||||
String messageText = message.text;
|
// Do not strip room-server author bytes here: the parser stores them in
|
||||||
if (isRoomServer && !isOutgoing) {
|
// fourByteRoomContactKey, so message.text is safe to render as-is.
|
||||||
messageText = message.text.substring(4.clamp(0, message.text.length));
|
final messageText = message.text;
|
||||||
}
|
|
||||||
final translatedDisplayText =
|
final translatedDisplayText =
|
||||||
message.translatedText != null &&
|
message.translatedText != null &&
|
||||||
message.translatedText!.trim().isNotEmpty
|
message.translatedText!.trim().isNotEmpty
|
||||||
|
|||||||
@@ -430,6 +430,8 @@ class _ContactsScreenState extends State<ContactsScreen>
|
|||||||
selectedIndex: 0,
|
selectedIndex: 0,
|
||||||
onDestinationSelected: (index) =>
|
onDestinationSelected: (index) =>
|
||||||
_handleQuickSwitch(index, context),
|
_handleQuickSwitch(index, context),
|
||||||
|
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
|
||||||
|
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -539,6 +539,12 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
|
|||||||
child: QuickSwitchBar(
|
child: QuickSwitchBar(
|
||||||
selectedIndex: 2,
|
selectedIndex: 2,
|
||||||
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
|
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
|
||||||
|
contactsUnreadCount: context
|
||||||
|
.watch<MeshCoreConnector>()
|
||||||
|
.getTotalContactsUnreadCount(),
|
||||||
|
channelsUnreadCount: context
|
||||||
|
.watch<MeshCoreConnector>()
|
||||||
|
.getTotalChannelsUnreadCount(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -676,6 +676,8 @@ class _MapScreenState extends State<MapScreen> {
|
|||||||
selectedIndex: 2,
|
selectedIndex: 2,
|
||||||
onDestinationSelected: (index) =>
|
onDestinationSelected: (index) =>
|
||||||
_handleQuickSwitch(index, context),
|
_handleQuickSwitch(index, context),
|
||||||
|
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
|
||||||
|
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
|
|||||||
@@ -388,7 +388,9 @@ class TranslationService extends ChangeNotifier {
|
|||||||
if (targetLanguageCode == null || !_isPlainTextEligible(text)) {
|
if (targetLanguageCode == null || !_isPlainTextEligible(text)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final detectedLanguageCode = await detectLanguage(text);
|
final detectedLanguageCode = await detectLanguage(
|
||||||
|
_stripReplyInfoForDetection(text),
|
||||||
|
);
|
||||||
if (detectedLanguageCode != null &&
|
if (detectedLanguageCode != null &&
|
||||||
detectedLanguageCode == targetLanguageCode) {
|
detectedLanguageCode == targetLanguageCode) {
|
||||||
return const TranslationResult(
|
return const TranslationResult(
|
||||||
@@ -429,7 +431,9 @@ class TranslationService extends ChangeNotifier {
|
|||||||
if (targetLanguageCode == null || !_isPlainTextEligible(text)) {
|
if (targetLanguageCode == null || !_isPlainTextEligible(text)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final detectedLanguageCode = await detectLanguage(text);
|
final detectedLanguageCode = await detectLanguage(
|
||||||
|
_stripReplyInfoForDetection(text),
|
||||||
|
);
|
||||||
if (detectedLanguageCode != null &&
|
if (detectedLanguageCode != null &&
|
||||||
detectedLanguageCode == targetLanguageCode) {
|
detectedLanguageCode == targetLanguageCode) {
|
||||||
return const TranslationResult(
|
return const TranslationResult(
|
||||||
@@ -470,6 +474,14 @@ class TranslationService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _stripReplyInfoForDetection(String text) {
|
||||||
|
final match = RegExp(
|
||||||
|
r'@\[([^\]]+)\]\s+(.+)$',
|
||||||
|
dotAll: true,
|
||||||
|
).firstMatch(text);
|
||||||
|
return match?.group(2) ?? text;
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> _translateText({
|
Future<String?> _translateText({
|
||||||
required String text,
|
required String text,
|
||||||
required String targetLanguageCode,
|
required String targetLanguageCode,
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import '../l10n/l10n.dart';
|
|||||||
class QuickSwitchBar extends StatelessWidget {
|
class QuickSwitchBar extends StatelessWidget {
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
final ValueChanged<int> onDestinationSelected;
|
final ValueChanged<int> onDestinationSelected;
|
||||||
|
final int contactsUnreadCount;
|
||||||
|
final int channelsUnreadCount;
|
||||||
|
|
||||||
const QuickSwitchBar({
|
const QuickSwitchBar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onDestinationSelected,
|
required this.onDestinationSelected,
|
||||||
|
this.contactsUnreadCount = 0,
|
||||||
|
this.channelsUnreadCount = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -62,15 +66,30 @@ class QuickSwitchBar extends StatelessWidget {
|
|||||||
onDestinationSelected: onDestinationSelected,
|
onDestinationSelected: onDestinationSelected,
|
||||||
destinations: [
|
destinations: [
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: const Icon(Icons.people_outline),
|
icon: _buildIconWithBadge(
|
||||||
|
const Icon(Icons.people_outline),
|
||||||
|
contactsUnreadCount,
|
||||||
|
),
|
||||||
|
selectedIcon: _buildIconWithBadge(
|
||||||
|
const Icon(Icons.people),
|
||||||
|
contactsUnreadCount,
|
||||||
|
),
|
||||||
label: context.l10n.nav_contacts,
|
label: context.l10n.nav_contacts,
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: const Icon(Icons.tag),
|
icon: _buildIconWithBadge(
|
||||||
|
const Icon(Icons.tag),
|
||||||
|
channelsUnreadCount,
|
||||||
|
),
|
||||||
|
selectedIcon: _buildIconWithBadge(
|
||||||
|
const Icon(Icons.tag),
|
||||||
|
channelsUnreadCount,
|
||||||
|
),
|
||||||
label: context.l10n.nav_channels,
|
label: context.l10n.nav_channels,
|
||||||
),
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: const Icon(Icons.map_outlined),
|
icon: const Icon(Icons.map_outlined),
|
||||||
|
selectedIcon: const Icon(Icons.map),
|
||||||
label: context.l10n.nav_map,
|
label: context.l10n.nav_map,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -81,4 +100,27 @@ class QuickSwitchBar extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildIconWithBadge(Icon icon, int count) {
|
||||||
|
if (count <= 0) return icon;
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
icon,
|
||||||
|
Positioned(
|
||||||
|
right: -2,
|
||||||
|
top: -2,
|
||||||
|
child: Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.redAccent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user