update to current dev a50c0d0b2d

This commit is contained in:
ericz
2026-05-20 23:20:16 +02:00
parent 9ada4ea601
commit 3fe5cdf55d
14 changed files with 583 additions and 100 deletions
+72 -24
View File
@@ -302,6 +302,8 @@ class MeshCoreConnector extends ChangeNotifier {
final Map<String, int> _contactUnreadCount = {};
final Map<String, RepeaterBatterySnapshot> _repeaterBatterySnapshots = {};
bool _unreadStateLoaded = false;
int _cachedContactsUnreadTotal = 0;
int _cachedChannelsUnreadTotal = 0;
final Map<String, _RepeaterAckContext> _pendingRepeaterAcks = {};
String? _activeContactKey;
int? _activeChannelIndex;
@@ -606,16 +608,42 @@ class MeshCoreConnector extends ChangeNotifier {
int getTotalUnreadCount() {
if (!_unreadStateLoaded) return 0;
var total = 0;
// Count unread contact messages
for (final contact in _contacts) {
total += getUnreadCountForContact(contact);
}
// Count unread channel messages
for (final channelIndex in _channelMessages.keys) {
total += getUnreadCountForChannelIndex(channelIndex);
}
return total;
return getTotalContactsUnreadCount() + getTotalChannelsUnreadCount();
}
int getTotalContactsUnreadCount() {
if (!_unreadStateLoaded) return 0;
return _cachedContactsUnreadTotal;
}
int getTotalChannelsUnreadCount() {
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) {
@@ -649,11 +677,13 @@ class MeshCoreConnector extends ChangeNotifier {
..clear()
..addAll(await _unreadStore.loadContactUnreadCount());
_unreadStateLoaded = true;
_recalculateCachedUnreadTotals();
notifyListeners();
}
Future<void> loadCachedChannels() async {
_cachedChannels = await _channelStore.loadChannels();
_recalculateCachedChannelsUnreadTotal();
}
void setActiveContact(String? contactKeyHex) {
@@ -680,6 +710,8 @@ class MeshCoreConnector extends ChangeNotifier {
final previousCount = _contactUnreadCount[contactKeyHex] ?? 0;
if (previousCount > 0) {
_contactUnreadCount[contactKeyHex] = 0;
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - previousCount)
.clamp(0, _cachedContactsUnreadTotal);
_appDebugLogService?.info(
'Contact $contactKeyHex marked as read (was $previousCount unread)',
tag: 'Unread',
@@ -721,6 +753,8 @@ class MeshCoreConnector extends ChangeNotifier {
if (channel != null && channel.unreadCount > 0) {
final previousCount = channel.unreadCount;
channel.unreadCount = 0;
_cachedChannelsUnreadTotal = (_cachedChannelsUnreadTotal - previousCount)
.clamp(0, _cachedChannelsUnreadTotal);
_appDebugLogService?.info(
'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} marked as read (was $previousCount unread)',
tag: 'Unread',
@@ -3156,6 +3190,9 @@ class MeshCoreConnector extends ChangeNotifier {
unawaited(_persistContacts());
_conversations.remove(contact.publicKeyHex);
_loadedConversationKeys.remove(contact.publicKeyHex);
final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0;
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount)
.clamp(0, _cachedContactsUnreadTotal);
_contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
@@ -3549,6 +3586,7 @@ class MeshCoreConnector extends ChangeNotifier {
// Cache channels for offline use
_cachedChannels = List<Channel>.from(_channels);
unawaited(_channelStore.saveChannels(_channels));
_recalculateCachedChannelsUnreadTotal();
// Apply ordering and notify UI
_applyChannelOrder();
@@ -4101,6 +4139,9 @@ class MeshCoreConnector extends ChangeNotifier {
_handleDiscovery(contact, frame, noNotify: true, addActive: true);
if (contact.type == advTypeRepeater) {
final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0;
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount)
.clamp(0, _cachedContactsUnreadTotal);
_contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
@@ -4191,6 +4232,9 @@ class MeshCoreConnector extends ChangeNotifier {
}
if (contact.type == advTypeRepeater) {
final removedCount = _contactUnreadCount[contact.publicKeyHex] ?? 0;
_cachedContactsUnreadTotal = (_cachedContactsUnreadTotal - removedCount)
.clamp(0, _cachedContactsUnreadTotal);
_contactUnreadCount.remove(contact.publicKeyHex);
_unreadStore.saveContactUnreadCount(
Map<String, int>.from(_contactUnreadCount),
@@ -4464,17 +4508,13 @@ class MeshCoreConnector extends ChangeNotifier {
badgeCount: getTotalUnreadCount(),
);
} 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 =
(translationResult != null &&
translationResult.status ==
MessageTranslationStatus.completed &&
translationResult.translatedText.trim().isNotEmpty)
? translationResult.translatedText.trim()
: bodyText.trim();
: msg.text.trim();
await _notificationService.showMessageNotification(
contactName: c?.name ?? 'Unknown Room',
message: resolvedText,
@@ -4522,16 +4562,24 @@ class MeshCoreConnector extends ChangeNotifier {
timestampRaw * 1000,
);
if (txtType == 2) {
reader.skipBytes(4); // Skip extra 4 bytes for signed/plain variants
final flags = txtType;
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 flags = txtType;
final shiftedType = flags >> 2;
final rawType = flags;
final isPlain = shiftedType == txtTypePlain || rawType == txtTypePlain;
final isPlain =
shiftedType == txtTypePlain || rawType == txtTypePlain || isSigned;
final isCli = shiftedType == txtTypeCliData || rawType == txtTypeCliData;
if (!isPlain && !isCli) {
appLogger.warn(
@@ -4568,9 +4616,7 @@ class MeshCoreConnector extends ChangeNotifier {
status: MessageStatus.delivered,
pathLength: pathLength == 0xFF ? 0 : pathLength,
pathBytes: Uint8List(0),
fourByteRoomContactKey: msgText.length >= 4
? Uint8List.fromList(msgText.substring(0, 4).codeUnits)
: null,
fourByteRoomContactKey: roomAuthorPrefix,
);
} catch (e) {
appLogger.warn('Error parsing contact direct message: $e');
@@ -5247,6 +5293,7 @@ class MeshCoreConnector extends ChangeNotifier {
final channel = _findChannelByIndex(channelIndex);
if (channel != null) {
channel.unreadCount++;
_cachedChannelsUnreadTotal++;
_appDebugLogService?.info(
'Channel ${channel.name.isNotEmpty ? channel.name : channelIndex} unread count incremented to ${channel.unreadCount}',
tag: 'Unread',
@@ -5291,6 +5338,7 @@ class MeshCoreConnector extends ChangeNotifier {
final currentCount = _contactUnreadCount[contactKey] ?? 0;
_contactUnreadCount[contactKey] = currentCount + 1;
_cachedContactsUnreadTotal++;
_appDebugLogService?.info(
'Contact $contactKey unread count incremented to ${currentCount + 1}',
tag: 'Unread',
+1
View File
@@ -11,5 +11,6 @@ class MeshCoreUuids {
"Lilygo",
"HT-",
"LowMesh_MC_",
"NRF52",
];
}
+34
View File
@@ -4,6 +4,9 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto;
import '../connector/meshcore_protocol.dart';
import 'community.dart';
enum ChannelType { public, private, hashtag, communityPublic, communityHashtag }
class Channel {
final int index;
@@ -111,5 +114,36 @@ class Channel {
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';
}
+33
View File
@@ -4,6 +4,8 @@ import 'dart:typed_data';
import 'package:crypto/crypto.dart' as crypto;
import 'channel.dart';
/// Represents a community with a shared secret for deriving channel PSKs.
///
/// 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();
}
/// 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
Community addHashtagChannel(String hashtag) {
final normalized = _normalizeCommunityHashtag(hashtag);
@@ -237,3 +245,28 @@ class Community {
@override
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];
}
}
+270
View File
@@ -181,6 +181,276 @@ class RadioSettings {
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',
RadioSettings(
+70 -7
View File
@@ -9,6 +9,8 @@ import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../models/community.dart';
import '../storage/community_store.dart';
import '../utils/platform_info.dart';
import '../helpers/chat_scroll_controller.dart';
import '../connector/meshcore_protocol.dart';
@@ -57,8 +59,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final ChatScrollController _scrollController = ChatScrollController();
final FocusNode _textFieldFocusNode = FocusNode();
ChannelMessage? _replyingToMessage;
final CommunityStore _communityStore = CommunityStore();
final CommunityPskIndex _communityIndex = CommunityPskIndex();
final Map<String, GlobalKey> _messageKeys = {};
bool _isLoadingOlder = false;
bool _communitiesLoaded = false;
MeshCoreConnector? _connector;
DateTime? _lastChannelSendAt;
@@ -82,6 +87,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final idx = widget.channel.index;
final unread = widget.initialUnreadCount;
final messages = connector.getChannelMessages(widget.channel);
_loadCommunities();
ChannelMessage? anchor;
if (unread > 0) {
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(
List<ChannelMessage> messages,
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
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Row(
children: [
Icon(
widget.channel.isPublicChannel ? Icons.public : Icons.tag,
size: 20,
),
_channelIcon(widget.channel),
const SizedBox(width: 8),
Expanded(
child: Column(
@@ -1321,11 +1387,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
}
void _showMessageActions(ChannelMessage message) {
final settings = context.read<AppSettingsService>().settings;
final translationService = context.read<TranslationService>();
final canTranslateMessage =
settings.translationEnabled &&
!settings.autoTranslateIncomingMessages &&
translationService.canTranslateIncoming(
text: message.text,
isCli: false,
+29 -55
View File
@@ -44,11 +44,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
with DisconnectNavigationMixin {
final TextEditingController _searchController = TextEditingController();
final CommunityStore _communityStore = CommunityStore();
Timer? _searchDebounce;
final CommunityPskIndex _communityIndex = CommunityPskIndex();
List<Community> _communities = [];
// Cache of PSK hex -> Community for quick lookup
final Map<String, Community> _pskToCommunity = {};
Timer? _searchDebounce;
ChannelMessageStore get _channelMessageStore => ChannelMessageStore();
@@ -71,37 +69,11 @@ class _ChannelsScreenState extends State<ChannelsScreen>
if (mounted) {
setState(() {
_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
void dispose() {
_searchDebounce?.cancel();
@@ -360,6 +332,8 @@ class _ChannelsScreenState extends State<ChannelsScreen>
selectedIndex: 1,
onDestinationSelected: (index) =>
_handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
),
),
),
@@ -375,37 +349,37 @@ class _ChannelsScreenState extends State<ChannelsScreen>
int? dragIndex,
}) {
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
IconData icon;
Color iconColor;
Color bgColor;
if (isCommunityChannel) {
// Community channel styling
iconColor = Colors.purple;
bgColor = Colors.purple.withValues(alpha: 0.2);
if (isCommunityPublic) {
final ChannelType channelType = Channel.getChannelType(
channel,
_communityIndex,
);
final bool isCommunityChannel = Channel.isCommunityChannel(channelType);
switch (channelType) {
case ChannelType.communityPublic:
icon = Icons.groups;
} else {
iconColor = Colors.purple;
bgColor = Colors.purple.withValues(alpha: 0.2);
case ChannelType.communityHashtag:
icon = Icons.tag;
}
} else if (channel.isPublicChannel) {
icon = Icons.public;
iconColor = Colors.green;
bgColor = Colors.green.withValues(alpha: 0.2);
} else if (channel.name.startsWith('#')) {
icon = Icons.tag;
iconColor = Colors.blue;
bgColor = Colors.blue.withValues(alpha: 0.2);
} else {
icon = Icons.lock;
iconColor = Colors.blue;
bgColor = Colors.blue.withValues(alpha: 0.2);
iconColor = Colors.purple;
bgColor = Colors.purple.withValues(alpha: 0.2);
case ChannelType.public:
icon = Icons.public;
iconColor = Colors.green;
bgColor = Colors.green.withValues(alpha: 0.2);
case ChannelType.hashtag:
icon = Icons.tag;
iconColor = Colors.blue;
bgColor = Colors.blue.withValues(alpha: 0.2);
case ChannelType.private:
icon = Icons.lock;
iconColor = Colors.blue;
bgColor = Colors.blue.withValues(alpha: 0.2);
}
return Card(
+5 -10
View File
@@ -485,6 +485,8 @@ class _ChatScreenState extends State<ChatScreen> {
final message = reversedMessages[messageIndex];
String fourByteHex = '';
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(
connector,
message.fourByteRoomContactKey.isEmpty
@@ -509,7 +511,6 @@ class _ChatScreenState extends State<ChatScreen> {
? "${contact.name} [$fourByteHex]"
: contact.name,
sourceId: widget.contact.publicKeyHex,
isRoomServer: resolvedContact.type == advTypeRoom,
textScale: textScale,
onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact),
@@ -1577,11 +1578,8 @@ class _ChatScreenState extends State<ChatScreen> {
}
void _showMessageActions(Message message, Contact contact) {
final settings = context.read<AppSettingsService>().settings;
final translationService = context.read<TranslationService>();
final canTranslateMessage =
settings.translationEnabled &&
!settings.autoTranslateIncomingMessages &&
translationService.canTranslateIncoming(
text: message.text,
isCli: message.isCli,
@@ -1748,7 +1746,6 @@ class _ChatScreenState extends State<ChatScreen> {
class _MessageBubble extends StatelessWidget {
final Message message;
final String senderName;
final bool isRoomServer;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final void Function(Message message, String emoji)? onRetryReaction;
@@ -1759,7 +1756,6 @@ class _MessageBubble extends StatelessWidget {
required this.message,
required this.senderName,
required this.sourceId,
required this.isRoomServer,
required this.textScale,
this.onTap,
this.onLongPress,
@@ -1785,10 +1781,9 @@ class _MessageBubble extends StatelessWidget {
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
final metaColor = textColor.withValues(alpha: 0.7);
const bodyFontSize = 14.0;
String messageText = message.text;
if (isRoomServer && !isOutgoing) {
messageText = message.text.substring(4.clamp(0, message.text.length));
}
// Do not strip room-server author bytes here: the parser stores them in
// fourByteRoomContactKey, so message.text is safe to render as-is.
final messageText = message.text;
final translatedDisplayText =
message.translatedText != null &&
message.translatedText!.trim().isNotEmpty
+2
View File
@@ -430,6 +430,8 @@ class _ContactsScreenState extends State<ContactsScreen>
selectedIndex: 0,
onDestinationSelected: (index) =>
_handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
),
),
),
@@ -539,6 +539,12 @@ class _LineOfSightMapScreenState extends State<LineOfSightMapScreen> {
child: QuickSwitchBar(
selectedIndex: 2,
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
contactsUnreadCount: context
.watch<MeshCoreConnector>()
.getTotalContactsUnreadCount(),
channelsUnreadCount: context
.watch<MeshCoreConnector>()
.getTotalChannelsUnreadCount(),
),
),
);
+2
View File
@@ -676,6 +676,8 @@ class _MapScreenState extends State<MapScreen> {
selectedIndex: 2,
onDestinationSelected: (index) =>
_handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
),
),
floatingActionButton: FloatingActionButton(
+14 -2
View File
@@ -388,7 +388,9 @@ class TranslationService extends ChangeNotifier {
if (targetLanguageCode == null || !_isPlainTextEligible(text)) {
return null;
}
final detectedLanguageCode = await detectLanguage(text);
final detectedLanguageCode = await detectLanguage(
_stripReplyInfoForDetection(text),
);
if (detectedLanguageCode != null &&
detectedLanguageCode == targetLanguageCode) {
return const TranslationResult(
@@ -429,7 +431,9 @@ class TranslationService extends ChangeNotifier {
if (targetLanguageCode == null || !_isPlainTextEligible(text)) {
return null;
}
final detectedLanguageCode = await detectLanguage(text);
final detectedLanguageCode = await detectLanguage(
_stripReplyInfoForDetection(text),
);
if (detectedLanguageCode != null &&
detectedLanguageCode == targetLanguageCode) {
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({
required String text,
required String targetLanguageCode,
+44 -2
View File
@@ -6,11 +6,15 @@ import '../l10n/l10n.dart';
class QuickSwitchBar extends StatelessWidget {
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
final int contactsUnreadCount;
final int channelsUnreadCount;
const QuickSwitchBar({
super.key,
required this.selectedIndex,
required this.onDestinationSelected,
this.contactsUnreadCount = 0,
this.channelsUnreadCount = 0,
});
@override
@@ -62,15 +66,30 @@ class QuickSwitchBar extends StatelessWidget {
onDestinationSelected: onDestinationSelected,
destinations: [
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,
),
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,
),
NavigationDestination(
icon: const Icon(Icons.map_outlined),
selectedIcon: const Icon(Icons.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,
),
),
),
],
);
}
}