format dart files

formats all dart files using `dart format .` from the root project dir

this makes the code style repeatable by new contributors and makes PR review easier
This commit is contained in:
446564
2026-02-04 08:32:35 -08:00
parent 488a286701
commit b34d684e67
66 changed files with 2882 additions and 1848 deletions
+25 -14
View File
@@ -1286,9 +1286,12 @@ class MeshCoreConnector extends ChangeNotifier {
if (reactionInfo != null) { if (reactionInfo != null) {
// Check if we've already processed this reaction // Check if we've already processed this reaction
_processedChannelReactions.putIfAbsent(channel.index, () => {}); _processedChannelReactions.putIfAbsent(channel.index, () => {});
final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}'; final reactionIdentifier =
'${reactionInfo.targetHash}_${reactionInfo.emoji}';
if (_processedChannelReactions[channel.index]!.contains(reactionIdentifier)) { if (_processedChannelReactions[channel.index]!.contains(
reactionIdentifier,
)) {
// Already processed, don't process again // Already processed, don't process again
return; return;
} }
@@ -1504,7 +1507,9 @@ class MeshCoreConnector extends ChangeNotifier {
// Skip fetching if already loaded and not forced // Skip fetching if already loaded and not forced
if (_hasLoadedChannels && !force) { if (_hasLoadedChannels && !force) {
debugPrint('[ChannelSync] Channels already loaded, skipping fetch (use force=true to reload)'); debugPrint(
'[ChannelSync] Channels already loaded, skipping fetch (use force=true to reload)',
);
return; return;
} }
@@ -2696,10 +2701,12 @@ class MeshCoreConnector extends ChangeNotifier {
if (reactionInfo != null) { if (reactionInfo != null) {
// Check if we've already processed this exact reaction // Check if we've already processed this exact reaction
_processedContactReactions.putIfAbsent(pubKeyHex, () => {}); _processedContactReactions.putIfAbsent(pubKeyHex, () => {});
final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}'; final reactionIdentifier =
'${reactionInfo.targetHash}_${reactionInfo.emoji}';
final isDuplicate = final isDuplicate = _processedContactReactions[pubKeyHex]!.contains(
_processedContactReactions[pubKeyHex]!.contains(reactionIdentifier); reactionIdentifier,
);
if (!isDuplicate) { if (!isDuplicate) {
// New reaction - process it // New reaction - process it
@@ -2734,20 +2741,22 @@ class MeshCoreConnector extends ChangeNotifier {
for (int i = messages.length - 1; i >= 0; i--) { for (int i = messages.length - 1; i >= 0; i--) {
final msg = messages[i]; final msg = messages[i];
// For 1:1 chats: contact reacts to my outgoing messages only // For 1:1 chats: contact reacts to my outgoing messages only
// For room servers: any message can be reacted to (multi-user) // For room servers: any message can be reacted to (multi-user)
if (!isRoomServer && !msg.isOutgoing) continue; if (!isRoomServer && !msg.isOutgoing) continue;
final timestampSecs = msg.timestamp.millisecondsSinceEpoch ~/ 1000; final timestampSecs = msg.timestamp.millisecondsSinceEpoch ~/ 1000;
// For room servers, include sender name (resolve from fourByteRoomContactKey) // For room servers, include sender name (resolve from fourByteRoomContactKey)
// For 1:1 chats, sender is implicit (null) // For 1:1 chats, sender is implicit (null)
String? senderName; String? senderName;
if (isRoomServer && !msg.isOutgoing) { if (isRoomServer && !msg.isOutgoing) {
// Resolve sender from the message's fourByteRoomContactKey // Resolve sender from the message's fourByteRoomContactKey
final senderContact = _contacts.cast<Contact?>().firstWhere( final senderContact = _contacts.cast<Contact?>().firstWhere(
(c) => c != null && _matchesPrefix(c.publicKey, msg.fourByteRoomContactKey), (c) =>
c != null &&
_matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
orElse: () => null, orElse: () => null,
); );
senderName = senderContact?.name; senderName = senderContact?.name;
@@ -2755,7 +2764,7 @@ class MeshCoreConnector extends ChangeNotifier {
senderName = selfName; senderName = selfName;
} }
// For 1:1, senderName stays null // For 1:1, senderName stays null
final msgHash = ReactionHelper.computeReactionHash( final msgHash = ReactionHelper.computeReactionHash(
timestampSecs, timestampSecs,
senderName, senderName,
@@ -2919,10 +2928,12 @@ class MeshCoreConnector extends ChangeNotifier {
if (reactionInfo != null) { if (reactionInfo != null) {
// Check if we've already processed this exact reaction // Check if we've already processed this exact reaction
_processedChannelReactions.putIfAbsent(channelIndex, () => {}); _processedChannelReactions.putIfAbsent(channelIndex, () => {});
final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}'; final reactionIdentifier =
'${reactionInfo.targetHash}_${reactionInfo.emoji}';
final isDuplicate = final isDuplicate = _processedChannelReactions[channelIndex]!.contains(
_processedChannelReactions[channelIndex]!.contains(reactionIdentifier); reactionIdentifier,
);
if (!isDuplicate) { if (!isDuplicate) {
// New reaction - process it // New reaction - process it
+8 -5
View File
@@ -113,7 +113,9 @@ class BufferWriter {
final hexByte = hex.substring(i * 2, i * 2 + 2); final hexByte = hex.substring(i * 2, i * 2 + 2);
final byte = int.tryParse(hexByte, radix: 16); final byte = int.tryParse(hexByte, radix: 16);
if (byte == null) { if (byte == null) {
throw FormatException('Invalid hex characters at position $i: $hexByte'); throw FormatException(
'Invalid hex characters at position $i: $hexByte',
);
} }
result.add(byte); result.add(byte);
} }
@@ -219,8 +221,10 @@ const int maxFrameSize = 172;
const int appProtocolVersion = 3; const int appProtocolVersion = 3;
// Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE). // Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE).
const int maxTextPayloadBytes = 160; const int maxTextPayloadBytes = 160;
const int _sendTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 6 + 1 + 2; // +2 safety margin const int _sendTextMsgOverheadBytes =
const int _sendChannelTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin 1 + 1 + 1 + 4 + 6 + 1 + 2; // +2 safety margin
const int _sendChannelTextMsgOverheadBytes =
1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin
int maxContactMessageBytes() { int maxContactMessageBytes() {
final byFrame = maxFrameSize - _sendTextMsgOverheadBytes; final byFrame = maxFrameSize - _sendTextMsgOverheadBytes;
@@ -735,8 +739,7 @@ Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) {
//Build a trace request frame //Build a trace request frame
//[cmd][tag x4][auth x4][flag][payload] //[cmd][tag x4][auth x4][flag][payload]
Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload}) Uint8List buildTraceReq(int tag, int auth, int flag, {Uint8List? payload}) {
{
final writer = BufferWriter(); final writer = BufferWriter();
writer.writeByte(cmdSendTracePath); writer.writeByte(cmdSendTracePath);
writer.writeUInt32LE(tag); writer.writeUInt32LE(tag);
+11 -9
View File
@@ -26,9 +26,11 @@ class CayenneLpp {
static const int lppUnixTime = 133; // 4 bytes, unsigned static const int lppUnixTime = 133; // 4 bytes, unsigned
static const int lppGyrometer = 134; // 2 bytes per axis, 0.01 °/s static const int lppGyrometer = 134; // 2 bytes per axis, 0.01 °/s
static const int lppColour = 135; // 1 byte per RGB Color static const int lppColour = 135; // 1 byte per RGB Color
static const int lppGps = 136; // 3 byte lon/lat 0.0001 °, 3 bytes alt 0.01 meter static const int lppGps =
136; // 3 byte lon/lat 0.0001 °, 3 bytes alt 0.01 meter
static const int lppSwitch = 142; // 1 byte, 0/1 static const int lppSwitch = 142; // 1 byte, 0/1
static const int lppPolyline = 240; // 1 byte size, 1 byte delta factor, 3 byte lon/lat 0.0001° * factor, n (size-8) bytes deltas static const int lppPolyline =
240; // 1 byte size, 1 byte delta factor, 3 byte lon/lat 0.0001° * factor, n (size-8) bytes deltas
final BufferWriter _writer = BufferWriter(); final BufferWriter _writer = BufferWriter();
@@ -201,10 +203,10 @@ class CayenneLpp {
break; break;
} }
final channelData = channels.putIfAbsent(channel, () => { final channelData = channels.putIfAbsent(
'channel': channel, channel,
'values': <String, dynamic>{}, () => {'channel': channel, 'values': <String, dynamic>{}},
}); );
switch (type) { switch (type) {
case lppGenericSensor: case lppGenericSensor:
@@ -254,8 +256,8 @@ class CayenneLpp {
} }
} }
final List<Map<String, dynamic>> channelsOut = channels.values.toList(); final List<Map<String, dynamic>> channelsOut = channels.values.toList();
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel'])); channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
return channelsOut; return channelsOut;
} }
} }
+1 -4
View File
@@ -26,10 +26,7 @@ class LinkHandler {
), ),
child: SelectableText( child: SelectableText(
url, url,
style: const TextStyle( style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
fontSize: 12,
fontFamily: 'monospace',
),
), ),
), ),
], ],
+7 -9
View File
@@ -4,10 +4,7 @@ class ReactionInfo {
final String targetHash; final String targetHash;
final String emoji; final String emoji;
ReactionInfo({ ReactionInfo({required this.targetHash, required this.emoji});
required this.targetHash,
required this.emoji,
});
} }
class ReactionHelper { class ReactionHelper {
@@ -42,7 +39,11 @@ class ReactionHelper {
/// Compute a 4-char hex hash for a message reaction. /// Compute a 4-char hex hash for a message reaction.
/// Hash input: timestampSeconds + [senderName] + first 5 chars of text /// Hash input: timestampSeconds + [senderName] + first 5 chars of text
/// For 1:1 chats, senderName can be null (sender is implicit). /// For 1:1 chats, senderName can be null (sender is implicit).
static String computeReactionHash(int timestampSeconds, String? senderName, String text) { static String computeReactionHash(
int timestampSeconds,
String? senderName,
String text,
) {
final first5 = text.length >= 5 ? text.substring(0, 5) : text; final first5 = text.length >= 5 ? text.substring(0, 5) : text;
final input = senderName != null final input = senderName != null
? '$timestampSeconds$senderName$first5' ? '$timestampSeconds$senderName$first5'
@@ -62,9 +63,6 @@ class ReactionHelper {
final emoji = indexToEmoji(match.group(2)!); final emoji = indexToEmoji(match.group(2)!);
if (emoji == null) return null; if (emoji == null) return null;
return ReactionInfo( return ReactionInfo(targetHash: match.group(1)!, emoji: emoji);
targetHash: match.group(1)!,
emoji: emoji,
);
} }
} }
+15 -6
View File
@@ -262,8 +262,9 @@ class Smaz {
".com", ".com",
]; ];
static final List<Uint8List> _rcbBytes = static final List<Uint8List> _rcbBytes = _rcb
_rcb.map((s) => Uint8List.fromList(ascii.encode(s))).toList(growable: false); .map((s) => Uint8List.fromList(ascii.encode(s)))
.toList(growable: false);
static final int _maxEntryLen = _rcbBytes.fold(0, (maxLen, entry) { static final int _maxEntryLen = _rcbBytes.fold(0, (maxLen, entry) {
return entry.length > maxLen ? entry.length : maxLen; return entry.length > maxLen ? entry.length : maxLen;
}); });
@@ -358,24 +359,32 @@ class Smaz {
final code = input[index]; final code = input[index];
if (code == _verbatimSingle) { if (code == _verbatimSingle) {
if (index + 1 >= input.length) { if (index + 1 >= input.length) {
throw const FormatException('Invalid SMAZ stream: truncated verbatim byte.'); throw const FormatException(
'Invalid SMAZ stream: truncated verbatim byte.',
);
} }
out.addByte(input[index + 1]); out.addByte(input[index + 1]);
index += 2; index += 2;
} else if (code == _verbatimRun) { } else if (code == _verbatimRun) {
if (index + 1 >= input.length) { if (index + 1 >= input.length) {
throw const FormatException('Invalid SMAZ stream: truncated verbatim length.'); throw const FormatException(
'Invalid SMAZ stream: truncated verbatim length.',
);
} }
final len = input[index + 1] + 1; final len = input[index + 1] + 1;
final end = index + 2 + len; final end = index + 2 + len;
if (end > input.length) { if (end > input.length) {
throw const FormatException('Invalid SMAZ stream: truncated verbatim run.'); throw const FormatException(
'Invalid SMAZ stream: truncated verbatim run.',
);
} }
out.add(input.sublist(index + 2, end)); out.add(input.sublist(index + 2, end));
index = end; index = end;
} else { } else {
if (code >= _rcbBytes.length) { if (code >= _rcbBytes.length) {
throw const FormatException('Invalid SMAZ stream: code out of range.'); throw const FormatException(
'Invalid SMAZ stream: code out of range.',
);
} }
out.add(_rcbBytes[code]); out.add(_rcbBytes[code]);
index += 1; index += 1;
+4 -1
View File
@@ -8,7 +8,10 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
const Utf8LengthLimitingTextInputFormatter(this.maxBytes); const Utf8LengthLimitingTextInputFormatter(this.maxBytes);
@override @override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (maxBytes <= 0) return oldValue; if (maxBytes <= 0) return oldValue;
final bytes = utf8.encode(newValue.text); final bytes = utf8.encode(newValue.text);
if (bytes.length <= maxBytes) return newValue; if (bytes.length <= maxBytes) return newValue;
+18 -12
View File
@@ -65,16 +65,18 @@ void main() async {
await connector.loadAllChannelMessages(); await connector.loadAllChannelMessages();
await connector.loadUnreadState(); await connector.loadUnreadState();
runApp(MeshCoreApp( runApp(
connector: connector, MeshCoreApp(
retryService: retryService, connector: connector,
pathHistoryService: pathHistoryService, retryService: retryService,
storage: storage, pathHistoryService: pathHistoryService,
appSettingsService: appSettingsService, storage: storage,
bleDebugLogService: bleDebugLogService, appSettingsService: appSettingsService,
appDebugLogService: appDebugLogService, bleDebugLogService: bleDebugLogService,
mapTileCacheService: mapTileCacheService, appDebugLogService: appDebugLogService,
)); mapTileCacheService: mapTileCacheService,
),
);
} }
class MeshCoreApp extends StatelessWidget { class MeshCoreApp extends StatelessWidget {
@@ -124,7 +126,9 @@ class MeshCoreApp extends StatelessWidget {
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
locale: _localeFromSetting(settingsService.settings.languageOverride), locale: _localeFromSetting(
settingsService.settings.languageOverride,
),
theme: ThemeData( theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true, useMaterial3: true,
@@ -142,7 +146,9 @@ class MeshCoreApp extends StatelessWidget {
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
), ),
), ),
themeMode: _themeModeFromSetting(settingsService.settings.themeMode), themeMode: _themeModeFromSetting(
settingsService.settings.themeMode,
),
home: const ScannerScreen(), home: const ScannerScreen(),
); );
}, },
+18 -11
View File
@@ -76,13 +76,14 @@ class AppSettings {
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true, mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true, mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true, mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
mapTimeFilterHours: (json['map_time_filter_hours'] as num?)?.toDouble() ?? 0, mapTimeFilterHours:
(json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false, mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
mapKeyPrefix: json['map_key_prefix'] as String? ?? '', mapKeyPrefix: json['map_key_prefix'] as String? ?? '',
mapShowMarkers: json['map_show_markers'] as bool? ?? true, mapShowMarkers: json['map_show_markers'] as bool? ?? true,
mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map( mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), (value as num).toDouble()), (key, value) => MapEntry(key.toString(), (value as num).toDouble()),
), ),
mapCacheMinZoom: json['map_cache_min_zoom'] as int? ?? 10, mapCacheMinZoom: json['map_cache_min_zoom'] as int? ?? 10,
mapCacheMaxZoom: json['map_cache_max_zoom'] as int? ?? 15, mapCacheMaxZoom: json['map_cache_max_zoom'] as int? ?? 15,
notificationsEnabled: json['notifications_enabled'] as bool? ?? true, notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
@@ -90,11 +91,13 @@ class AppSettings {
notifyOnNewChannelMessage: notifyOnNewChannelMessage:
json['notify_on_new_channel_message'] as bool? ?? true, json['notify_on_new_channel_message'] as bool? ?? true,
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true, notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false, autoRouteRotationEnabled:
json['auto_route_rotation_enabled'] as bool? ?? false,
themeMode: json['theme_mode'] as String? ?? 'system', themeMode: json['theme_mode'] as String? ?? 'system',
languageOverride: json['language_override'] as String?, languageOverride: json['language_override'] as String?,
appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false, appDebugLogEnabled: json['app_debug_log_enabled'] as bool? ?? false,
batteryChemistryByDeviceId: (json['battery_chemistry_by_device_id'] as Map?)?.map( batteryChemistryByDeviceId:
(json['battery_chemistry_by_device_id'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), value.toString()), (key, value) => MapEntry(key.toString(), value.toString()),
) ?? ) ??
{}, {},
@@ -132,8 +135,9 @@ class AppSettings {
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled, mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix, mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers, mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers,
mapCacheBounds: mapCacheBounds: mapCacheBounds == _unset
mapCacheBounds == _unset ? this.mapCacheBounds : mapCacheBounds as Map<String, double>?, ? this.mapCacheBounds
: mapCacheBounds as Map<String, double>?,
mapCacheMinZoom: mapCacheMinZoom ?? this.mapCacheMinZoom, mapCacheMinZoom: mapCacheMinZoom ?? this.mapCacheMinZoom,
mapCacheMaxZoom: mapCacheMaxZoom ?? this.mapCacheMaxZoom, mapCacheMaxZoom: mapCacheMaxZoom ?? this.mapCacheMaxZoom,
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled, notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
@@ -141,12 +145,15 @@ class AppSettings {
notifyOnNewChannelMessage: notifyOnNewChannelMessage:
notifyOnNewChannelMessage ?? this.notifyOnNewChannelMessage, notifyOnNewChannelMessage ?? this.notifyOnNewChannelMessage,
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert, notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled, autoRouteRotationEnabled:
autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
themeMode: themeMode ?? this.themeMode, themeMode: themeMode ?? this.themeMode,
languageOverride: languageOverride: languageOverride == _unset
languageOverride == _unset ? this.languageOverride : languageOverride as String?, ? this.languageOverride
: languageOverride as String?,
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled, appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId, batteryChemistryByDeviceId:
batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
); );
} }
} }
+2 -10
View File
@@ -10,11 +10,7 @@ class Channel {
final String name; final String name;
final Uint8List psk; // 16 bytes final Uint8List psk; // 16 bytes
Channel({ Channel({required this.index, required this.name, required this.psk});
required this.index,
required this.name,
required this.psk,
});
String get pskHex => _bytesToHex(psk); String get pskHex => _bytesToHex(psk);
@@ -39,11 +35,7 @@ class Channel {
} }
static Channel empty(int index) { static Channel empty(int index) {
return Channel( return Channel(index: index, name: '', psk: Uint8List(16));
index: index,
name: '',
psk: Uint8List(16),
);
} }
static Channel fromHex(int index, String name, String pskHex) { static Channel fromHex(int index, String name, String pskHex) {
+23 -16
View File
@@ -59,15 +59,18 @@ class ChannelMessage {
this.replyToSenderName, this.replyToSenderName,
this.replyToText, this.replyToText,
Map<String, int>? reactions, Map<String, int>? reactions,
}) : messageId = messageId ?? '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}', }) : messageId =
reactions = reactions ?? {}, messageId ??
pathBytes = pathBytes ?? Uint8List(0), '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}',
pathVariants = _mergePathVariants( reactions = reactions ?? {},
pathBytes ?? Uint8List(0), pathBytes = pathBytes ?? Uint8List(0),
pathVariants, pathVariants = _mergePathVariants(
); pathBytes ?? Uint8List(0),
pathVariants,
);
String? get senderKeyHex => senderKey != null ? pubKeyToHex(senderKey!) : null; String? get senderKeyHex =>
senderKey != null ? pubKeyToHex(senderKey!) : null;
ChannelMessage copyWith({ ChannelMessage copyWith({
ChannelMessageStatus? status, ChannelMessageStatus? status,
@@ -125,8 +128,10 @@ class ChannelMessage {
final hasPathBytesFlag = (data[2] & 0x01) != 0; final hasPathBytesFlag = (data[2] & 0x01) != 0;
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5; final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
final hasValidTxtType = final hasValidTxtType =
cursor < data.length && (data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData); cursor < data.length &&
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) && canFitPath) { (data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) &&
canFitPath) {
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen)); pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
cursor += pathLen; cursor += pathLen;
} }
@@ -162,7 +167,8 @@ class ChannelMessage {
final potentialSender = text.substring(0, colonIndex); final potentialSender = text.substring(0, colonIndex);
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) { if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
senderName = potentialSender; senderName = potentialSender;
final offset = (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ') final offset =
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
? colonIndex + 2 ? colonIndex + 2
: colonIndex + 1; : colonIndex + 1;
actualText = text.substring(offset); actualText = text.substring(offset);
@@ -184,7 +190,11 @@ class ChannelMessage {
); );
} }
static ChannelMessage outgoing(String text, String senderName, int channelIndex) { static ChannelMessage outgoing(
String text,
String senderName,
int channelIndex,
) {
return ChannelMessage( return ChannelMessage(
senderKey: null, senderKey: null,
senderName: senderName, senderName: senderName,
@@ -249,8 +259,5 @@ class ReplyInfo {
final String mentionedNode; final String mentionedNode;
final String actualMessage; final String actualMessage;
ReplyInfo({ ReplyInfo({required this.mentionedNode, required this.actualMessage});
required this.mentionedNode,
required this.actualMessage,
});
} }
+4 -8
View File
@@ -34,10 +34,7 @@ class Community {
}) : hashtagChannels = hashtagChannels ?? []; }) : hashtagChannels = hashtagChannels ?? [];
/// Generate a new community with a random 32-byte secret /// Generate a new community with a random 32-byte secret
factory Community.create({ factory Community.create({required String id, required String name}) {
required String id,
required String name,
}) {
final random = Random.secure(); final random = Random.secure();
final secret = Uint8List(32); final secret = Uint8List(32);
for (int i = 0; i < 32; i++) { for (int i = 0; i < 32; i++) {
@@ -84,7 +81,8 @@ class Community {
name: json['name'] as String, name: json['name'] as String,
secret: base64Decode(json['secret'] as String), secret: base64Decode(json['secret'] as String),
createdAt: DateTime.fromMillisecondsSinceEpoch(json['created_at'] as int), createdAt: DateTime.fromMillisecondsSinceEpoch(json['created_at'] as int),
hashtagChannels: (json['hashtag_channels'] as List<dynamic>?) hashtagChannels:
(json['hashtag_channels'] as List<dynamic>?)
?.map((e) => e as String) ?.map((e) => e as String)
.toList() ?? .toList() ??
[], [],
@@ -234,9 +232,7 @@ class Community {
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
other is Community && other is Community && runtimeType == other.runtimeType && id == other.id;
runtimeType == other.runtimeType &&
id == other.id;
@override @override
int get hashCode => id.hashCode; int get hashCode => id.hashCode;
+21 -12
View File
@@ -7,7 +7,8 @@ class Contact {
final int type; final int type;
final int pathLength; // -1 = flood, 0+ = direct hops (from device) final int pathLength; // -1 = flood, 0+ = direct hops (from device)
final Uint8List path; // Path bytes from device final Uint8List path; // Path bytes from device
final int? pathOverride; // User's path override: -1 = force flood, null = auto final int?
pathOverride; // User's path override: -1 = force flood, null = auto
final Uint8List? pathOverrideBytes; // User's path override bytes final Uint8List? pathOverrideBytes; // User's path override bytes
final double? latitude; final double? latitude;
final double? longitude; final double? longitude;
@@ -78,8 +79,12 @@ class Contact {
type: type ?? this.type, type: type ?? this.type,
pathLength: pathLength ?? this.pathLength, pathLength: pathLength ?? this.pathLength,
path: path ?? this.path, path: path ?? this.path,
pathOverride: clearPathOverride ? null : (pathOverride ?? this.pathOverride), pathOverride: clearPathOverride
pathOverrideBytes: clearPathOverride ? null : (pathOverrideBytes ?? this.pathOverrideBytes), ? null
: (pathOverride ?? this.pathOverride),
pathOverrideBytes: clearPathOverride
? null
: (pathOverrideBytes ?? this.pathOverrideBytes),
latitude: latitude ?? this.latitude, latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
lastSeen: lastSeen ?? this.lastSeen, lastSeen: lastSeen ?? this.lastSeen,
@@ -93,10 +98,14 @@ class Contact {
final parts = <String>[]; final parts = <String>[];
final groupSize = pathHashSize; final groupSize = pathHashSize;
for (int i = 0; i < pathBytes.length; i += groupSize) { for (int i = 0; i < pathBytes.length; i += groupSize) {
final end = (i + groupSize) <= pathBytes.length ? (i + groupSize) : pathBytes.length; final end = (i + groupSize) <= pathBytes.length
? (i + groupSize)
: pathBytes.length;
final chunk = pathBytes.sublist(i, end); final chunk = pathBytes.sublist(i, end);
parts.add( parts.add(
chunk.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(), chunk
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(),
); );
} }
return parts.join(','); return parts.join(',');
@@ -110,32 +119,32 @@ class Contact {
final pathBytes = _pathBytesForDisplay; final pathBytes = _pathBytesForDisplay;
Uint8List? traceBytes; Uint8List? traceBytes;
if(pathLength <= 0) { if (pathLength <= 0) {
traceBytes = Uint8List(1); traceBytes = Uint8List(1);
traceBytes[0] = publicKey[0]; traceBytes[0] = publicKey[0];
return traceBytes; return traceBytes;
} }
if(type == advTypeRepeater || type == advTypeRoom) { if (type == advTypeRepeater || type == advTypeRoom) {
final len = (pathBytes.length + pathBytes.length + 1); final len = (pathBytes.length + pathBytes.length + 1);
traceBytes = Uint8List(len); traceBytes = Uint8List(len);
traceBytes[pathBytes.length] = publicKey[0]; traceBytes[pathBytes.length] = publicKey[0];
for (int i = 0; i < pathBytes.length; i++) { for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i]; traceBytes[i] = pathBytes[i];
if (i < pathBytes.length) { if (i < pathBytes.length) {
traceBytes[len-1-i] = pathBytes[i]; traceBytes[len - 1 - i] = pathBytes[i];
} }
} }
} else { } else {
if(pathBytes.length < 2) { if (pathBytes.length < 2) {
return pathBytes[0] == 0 ? null : pathBytes; return pathBytes[0] == 0 ? null : pathBytes;
} }
final len = (pathBytes.length + pathBytes.length-1); final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len); traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) { for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i]; traceBytes[i] = pathBytes[i];
if (i < pathBytes.length-1) { if (i < pathBytes.length - 1) {
traceBytes[len-1-i] = pathBytes[i]; traceBytes[len - 1 - i] = pathBytes[i];
} }
} }
} }
+5 -15
View File
@@ -2,15 +2,9 @@ class ContactGroup {
final String name; final String name;
final List<String> memberKeys; final List<String> memberKeys;
const ContactGroup({ const ContactGroup({required this.name, required this.memberKeys});
required this.name,
required this.memberKeys,
});
ContactGroup copyWith({ ContactGroup copyWith({String? name, List<String>? memberKeys}) {
String? name,
List<String>? memberKeys,
}) {
return ContactGroup( return ContactGroup(
name: name ?? this.name, name: name ?? this.name,
memberKeys: memberKeys ?? List<String>.from(this.memberKeys), memberKeys: memberKeys ?? List<String>.from(this.memberKeys),
@@ -18,16 +12,12 @@ class ContactGroup {
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {'name': name, 'members': memberKeys};
'name': name,
'members': memberKeys,
};
} }
factory ContactGroup.fromJson(Map<String, dynamic> json) { factory ContactGroup.fromJson(Map<String, dynamic> json) {
final members = (json['members'] as List?) final members =
?.map((value) => value.toString()) (json['members'] as List?)?.map((value) => value.toString()).toList() ??
.toList() ??
<String>[]; <String>[];
return ContactGroup( return ContactGroup(
name: json['name'] as String? ?? '', name: json['name'] as String? ?? '',
+5 -4
View File
@@ -43,9 +43,9 @@ class Message {
Uint8List? pathBytes, Uint8List? pathBytes,
Uint8List? fourByteRoomContactKey, Uint8List? fourByteRoomContactKey,
Map<String, int>? reactions, Map<String, int>? reactions,
}) : pathBytes = pathBytes ?? Uint8List(0), }) : pathBytes = pathBytes ?? Uint8List(0),
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0), fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
reactions = reactions ?? {}; reactions = reactions ?? {};
String get senderKeyHex => pubKeyToHex(senderKey); String get senderKeyHex => pubKeyToHex(senderKey);
@@ -80,7 +80,8 @@ class Message {
pathLength: pathLength ?? this.pathLength, pathLength: pathLength ?? this.pathLength,
pathBytes: pathBytes ?? this.pathBytes, pathBytes: pathBytes ?? this.pathBytes,
reactions: reactions ?? this.reactions, reactions: reactions ?? this.reactions,
fourByteRoomContactKey: fourByteRoomContactKey ?? this.fourByteRoomContactKey, fourByteRoomContactKey:
fourByteRoomContactKey ?? this.fourByteRoomContactKey,
); );
} }
+8 -6
View File
@@ -38,7 +38,8 @@ class PathRecord {
tripTimeMs: json['trip_time_ms'] as int, tripTimeMs: json['trip_time_ms'] as int,
timestamp: DateTime.parse(json['timestamp'] as String), timestamp: DateTime.parse(json['timestamp'] as String),
wasFloodDiscovery: json['was_flood'] as bool, wasFloodDiscovery: json['was_flood'] as bool,
pathBytes: (json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [], pathBytes:
(json['path_bytes'] as List?)?.map((b) => b as int).toList() ?? [],
successCount: json['success_count'] as int? ?? 0, successCount: json['success_count'] as int? ?? 0,
failureCount: json['failure_count'] as int? ?? 0, failureCount: json['failure_count'] as int? ?? 0,
); );
@@ -65,14 +66,15 @@ class ContactPathHistory {
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {'recent_paths': recentPaths.map((p) => p.toJson()).toList()};
'recent_paths': recentPaths.map((p) => p.toJson()).toList(),
};
} }
factory ContactPathHistory.fromJson( factory ContactPathHistory.fromJson(
String contactPubKeyHex, Map<String, dynamic> json) { String contactPubKeyHex,
final pathsList = (json['recent_paths'] as List?) Map<String, dynamic> json,
) {
final pathsList =
(json['recent_paths'] as List?)
?.map((p) => PathRecord.fromJson(p as Map<String, dynamic>)) ?.map((p) => PathRecord.fromJson(p as Map<String, dynamic>))
.toList() ?? .toList() ??
[]; [];
+30 -30
View File
@@ -61,44 +61,44 @@ class RadioSettings {
// Preset configurations // Preset configurations
static RadioSettings get preset915MHz => RadioSettings( static RadioSettings get preset915MHz => RadioSettings(
frequencyMHz: 915.0, frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw125, bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7, spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5, codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20, txPowerDbm: 20,
); );
static RadioSettings get preset868MHz => RadioSettings( static RadioSettings get preset868MHz => RadioSettings(
frequencyMHz: 868.0, frequencyMHz: 868.0,
bandwidth: LoRaBandwidth.bw125, bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7, spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5, codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 14, txPowerDbm: 14,
); );
static RadioSettings get preset433MHz => RadioSettings( static RadioSettings get preset433MHz => RadioSettings(
frequencyMHz: 433.0, frequencyMHz: 433.0,
bandwidth: LoRaBandwidth.bw125, bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7, spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5, codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20, txPowerDbm: 20,
); );
static RadioSettings get presetLongRange => RadioSettings( static RadioSettings get presetLongRange => RadioSettings(
frequencyMHz: 915.0, frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw125, bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf12, spreadingFactor: LoRaSpreadingFactor.sf12,
codingRate: LoRaCodingRate.cr4_8, codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20, txPowerDbm: 20,
); );
static RadioSettings get presetFastSpeed => RadioSettings( static RadioSettings get presetFastSpeed => RadioSettings(
frequencyMHz: 915.0, frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw500, bandwidth: LoRaBandwidth.bw500,
spreadingFactor: LoRaSpreadingFactor.sf7, spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5, codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20, txPowerDbm: 20,
); );
int get frequencyHz => (frequencyMHz * 1000).round(); int get frequencyHz => (frequencyMHz * 1000).round();
int get bandwidthHz => bandwidth.hz; int get bandwidthHz => bandwidth.hz;
+30 -8
View File
@@ -26,8 +26,10 @@ class AppDebugLogScreen extends StatelessWidget {
onPressed: hasEntries onPressed: hasEntries
? () async { ? () async {
final text = entries final text = entries
.map((entry) => .map(
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}') (entry) =>
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}',
)
.join('\n'); .join('\n');
await Clipboard.setData(ClipboardData(text: text)); await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return; if (!context.mounted) return;
@@ -61,11 +63,17 @@ class AppDebugLogScreen extends StatelessWidget {
leading: _buildLevelIcon(entry.level), leading: _buildLevelIcon(entry.level),
title: Text( title: Text(
'[${entry.tag}] ${entry.message}', '[${entry.tag}] ${entry.message}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
),
), ),
subtitle: Text( subtitle: Text(
entry.formattedTime, entry.formattedTime,
style: TextStyle(fontSize: 10, color: Colors.grey[600]), style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
),
), ),
); );
}, },
@@ -74,16 +82,26 @@ class AppDebugLogScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.bug_report_outlined, size: 64, color: Colors.grey[400]), Icon(
Icons.bug_report_outlined,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
context.l10n.debugLog_noEntries, context.l10n.debugLog_noEntries,
style: TextStyle(fontSize: 16, color: Colors.grey[600]), style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
context.l10n.debugLog_enableInSettings, context.l10n.debugLog_enableInSettings,
style: TextStyle(fontSize: 12, color: Colors.grey[500]), style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
), ),
], ],
), ),
@@ -99,7 +117,11 @@ class AppDebugLogScreen extends StatelessWidget {
case AppDebugLogLevel.info: case AppDebugLogLevel.info:
return const Icon(Icons.info_outline, size: 18, color: Colors.blue); return const Icon(Icons.info_outline, size: 18, color: Colors.blue);
case AppDebugLogLevel.warning: case AppDebugLogLevel.warning:
return const Icon(Icons.warning_amber_outlined, size: 18, color: Colors.orange); return const Icon(
Icons.warning_amber_outlined,
size: 18,
color: Colors.orange,
);
case AppDebugLogLevel.error: case AppDebugLogLevel.error:
return const Icon(Icons.error_outline, size: 18, color: Colors.red); return const Icon(Icons.error_outline, size: 18, color: Colors.red);
} }
+117 -55
View File
@@ -43,7 +43,10 @@ class AppSettingsScreen extends StatelessWidget {
); );
} }
Widget _buildAppearanceCard(BuildContext context, AppSettingsService settingsService) { Widget _buildAppearanceCard(
BuildContext context,
AppSettingsService settingsService,
) {
return Card( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -58,7 +61,9 @@ class AppSettingsScreen extends StatelessWidget {
ListTile( ListTile(
leading: const Icon(Icons.brightness_6_outlined), leading: const Icon(Icons.brightness_6_outlined),
title: Text(context.l10n.appSettings_theme), title: Text(context.l10n.appSettings_theme),
subtitle: Text(_themeModeLabel(context, settingsService.settings.themeMode)), subtitle: Text(
_themeModeLabel(context, settingsService.settings.themeMode),
),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeModeDialog(context, settingsService), onTap: () => _showThemeModeDialog(context, settingsService),
), ),
@@ -66,7 +71,12 @@ class AppSettingsScreen extends StatelessWidget {
ListTile( ListTile(
leading: const Icon(Icons.language_outlined), leading: const Icon(Icons.language_outlined),
title: Text(context.l10n.appSettings_language), title: Text(context.l10n.appSettings_language),
subtitle: Text(_languageLabel(context, settingsService.settings.languageOverride)), subtitle: Text(
_languageLabel(
context,
settingsService.settings.languageOverride,
),
),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(context, settingsService), onTap: () => _showLanguageDialog(context, settingsService),
), ),
@@ -75,7 +85,10 @@ class AppSettingsScreen extends StatelessWidget {
); );
} }
Widget _buildNotificationsCard(BuildContext context, AppSettingsService settingsService) { Widget _buildNotificationsCard(
BuildContext context,
AppSettingsService settingsService,
) {
return Card( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -90,17 +103,22 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.notifications_outlined), secondary: const Icon(Icons.notifications_outlined),
title: Text(context.l10n.appSettings_enableNotifications), title: Text(context.l10n.appSettings_enableNotifications),
subtitle: Text(context.l10n.appSettings_enableNotificationsSubtitle), subtitle: Text(
context.l10n.appSettings_enableNotificationsSubtitle,
),
value: settingsService.settings.notificationsEnabled, value: settingsService.settings.notificationsEnabled,
onChanged: (value) async { onChanged: (value) async {
if (value) { if (value) {
// Request permission when enabling // Request permission when enabling
final granted = await NotificationService().requestPermissions(); final granted = await NotificationService()
.requestPermissions();
if (!granted) { if (!granted) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(context.l10n.appSettings_notificationPermissionDenied), content: Text(
context.l10n.appSettings_notificationPermissionDenied,
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -113,9 +131,11 @@ class AppSettingsScreen extends StatelessWidget {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(value content: Text(
? context.l10n.appSettings_notificationsEnabled value
: context.l10n.appSettings_notificationsDisabled), ? context.l10n.appSettings_notificationsEnabled
: context.l10n.appSettings_notificationsDisabled,
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -126,18 +146,24 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile( SwitchListTile(
secondary: Icon( secondary: Icon(
Icons.message_outlined, Icons.message_outlined,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
title: Text( title: Text(
context.l10n.appSettings_messageNotifications, context.l10n.appSettings_messageNotifications,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
), ),
subtitle: Text( subtitle: Text(
context.l10n.appSettings_messageNotificationsSubtitle, context.l10n.appSettings_messageNotificationsSubtitle,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
), ),
value: settingsService.settings.notifyOnNewMessage, value: settingsService.settings.notifyOnNewMessage,
@@ -151,18 +177,24 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile( SwitchListTile(
secondary: Icon( secondary: Icon(
Icons.forum_outlined, Icons.forum_outlined,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
title: Text( title: Text(
context.l10n.appSettings_channelMessageNotifications, context.l10n.appSettings_channelMessageNotifications,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
), ),
subtitle: Text( subtitle: Text(
context.l10n.appSettings_channelMessageNotificationsSubtitle, context.l10n.appSettings_channelMessageNotificationsSubtitle,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
), ),
value: settingsService.settings.notifyOnNewChannelMessage, value: settingsService.settings.notifyOnNewChannelMessage,
@@ -176,18 +208,24 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile( SwitchListTile(
secondary: Icon( secondary: Icon(
Icons.cell_tower, Icons.cell_tower,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
title: Text( title: Text(
context.l10n.appSettings_advertisementNotifications, context.l10n.appSettings_advertisementNotifications,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
), ),
subtitle: Text( subtitle: Text(
context.l10n.appSettings_advertisementNotificationsSubtitle, context.l10n.appSettings_advertisementNotificationsSubtitle,
style: TextStyle( style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey, color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
), ),
), ),
value: settingsService.settings.notifyOnNewAdvert, value: settingsService.settings.notifyOnNewAdvert,
@@ -202,7 +240,10 @@ class AppSettingsScreen extends StatelessWidget {
); );
} }
Widget _buildMessagingCard(BuildContext context, AppSettingsService settingsService) { Widget _buildMessagingCard(
BuildContext context,
AppSettingsService settingsService,
) {
return Card( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -217,15 +258,19 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile( SwitchListTile(
secondary: const Icon(Icons.refresh_outlined), secondary: const Icon(Icons.refresh_outlined),
title: Text(context.l10n.appSettings_clearPathOnMaxRetry), title: Text(context.l10n.appSettings_clearPathOnMaxRetry),
subtitle: Text(context.l10n.appSettings_clearPathOnMaxRetrySubtitle), subtitle: Text(
context.l10n.appSettings_clearPathOnMaxRetrySubtitle,
),
value: settingsService.settings.clearPathOnMaxRetry, value: settingsService.settings.clearPathOnMaxRetry,
onChanged: (value) { onChanged: (value) {
settingsService.setClearPathOnMaxRetry(value); settingsService.setClearPathOnMaxRetry(value);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(value content: Text(
? context.l10n.appSettings_pathsWillBeCleared value
: context.l10n.appSettings_pathsWillNotBeCleared), ? context.l10n.appSettings_pathsWillBeCleared
: context.l10n.appSettings_pathsWillNotBeCleared,
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -241,9 +286,11 @@ class AppSettingsScreen extends StatelessWidget {
settingsService.setAutoRouteRotationEnabled(value); settingsService.setAutoRouteRotationEnabled(value);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(value content: Text(
? context.l10n.appSettings_autoRouteRotationEnabled value
: context.l10n.appSettings_autoRouteRotationDisabled), ? context.l10n.appSettings_autoRouteRotationEnabled
: context.l10n.appSettings_autoRouteRotationDisabled,
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -254,7 +301,10 @@ class AppSettingsScreen extends StatelessWidget {
); );
} }
Widget _buildMapSettingsCard(BuildContext context, AppSettingsService settingsService) { Widget _buildMapSettingsCard(
BuildContext context,
AppSettingsService settingsService,
) {
return Card( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -302,7 +352,9 @@ class AppSettingsScreen extends StatelessWidget {
subtitle: Text( subtitle: Text(
settingsService.settings.mapTimeFilterHours == 0 settingsService.settings.mapTimeFilterHours == 0
? context.l10n.appSettings_timeFilterShowAll ? context.l10n.appSettings_timeFilterShowAll
: context.l10n.appSettings_timeFilterShowLast(settingsService.settings.mapTimeFilterHours.toInt()), : context.l10n.appSettings_timeFilterShowLast(
settingsService.settings.mapTimeFilterHours.toInt(),
),
), ),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _showTimeFilterDialog(context, settingsService), onTap: () => _showTimeFilterDialog(context, settingsService),
@@ -339,8 +391,9 @@ class AppSettingsScreen extends StatelessWidget {
) { ) {
final deviceId = connector.deviceId; final deviceId = connector.deviceId;
final isConnected = connector.isConnected && deviceId != null; final isConnected = connector.isConnected && deviceId != null;
final selection = final selection = isConnected
isConnected ? settingsService.batteryChemistryForDevice(deviceId) : 'nmc'; ? settingsService.batteryChemistryForDevice(deviceId)
: 'nmc';
return Card( return Card(
child: Column( child: Column(
@@ -358,7 +411,9 @@ class AppSettingsScreen extends StatelessWidget {
title: Text(context.l10n.appSettings_batteryChemistry), title: Text(context.l10n.appSettings_batteryChemistry),
subtitle: Text( subtitle: Text(
isConnected isConnected
? context.l10n.appSettings_batteryChemistryPerDevice(connector.deviceDisplayName) ? context.l10n.appSettings_batteryChemistryPerDevice(
connector.deviceDisplayName,
)
: context.l10n.appSettings_batteryChemistryConnectFirst, : context.l10n.appSettings_batteryChemistryConnectFirst,
), ),
trailing: DropdownButton<String>( trailing: DropdownButton<String>(
@@ -366,7 +421,10 @@ class AppSettingsScreen extends StatelessWidget {
onChanged: isConnected onChanged: isConnected
? (value) { ? (value) {
if (value != null) { if (value != null) {
settingsService.setBatteryChemistryForDevice(deviceId, value); settingsService.setBatteryChemistryForDevice(
deviceId,
value,
);
} }
} }
: null, : null,
@@ -391,7 +449,10 @@ class AppSettingsScreen extends StatelessWidget {
); );
} }
void _showThemeModeDialog(BuildContext context, AppSettingsService settingsService) { void _showThemeModeDialog(
BuildContext context,
AppSettingsService settingsService,
) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -480,7 +541,10 @@ class AppSettingsScreen extends StatelessWidget {
} }
} }
void _showLanguageDialog(BuildContext context, AppSettingsService settingsService) { void _showLanguageDialog(
BuildContext context,
AppSettingsService settingsService,
) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -573,7 +637,10 @@ class AppSettingsScreen extends StatelessWidget {
); );
} }
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) { void _showTimeFilterDialog(
BuildContext context,
AppSettingsService settingsService,
) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
@@ -593,33 +660,23 @@ class AppSettingsScreen extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
ListTile( ListTile(
title: Text(context.l10n.appSettings_allTime), title: Text(context.l10n.appSettings_allTime),
leading: Radio<double>( leading: Radio<double>(value: 0),
value: 0,
),
), ),
ListTile( ListTile(
title: Text(context.l10n.appSettings_lastHour), title: Text(context.l10n.appSettings_lastHour),
leading: Radio<double>( leading: Radio<double>(value: 1),
value: 1,
),
), ),
ListTile( ListTile(
title: Text(context.l10n.appSettings_last6Hours), title: Text(context.l10n.appSettings_last6Hours),
leading: Radio<double>( leading: Radio<double>(value: 6),
value: 6,
),
), ),
ListTile( ListTile(
title: Text(context.l10n.appSettings_last24Hours), title: Text(context.l10n.appSettings_last24Hours),
leading: Radio<double>( leading: Radio<double>(value: 24),
value: 24,
),
), ),
ListTile( ListTile(
title: Text(context.l10n.appSettings_lastWeek), title: Text(context.l10n.appSettings_lastWeek),
leading: Radio<double>( leading: Radio<double>(value: 168),
value: 168,
),
), ),
], ],
), ),
@@ -634,7 +691,10 @@ class AppSettingsScreen extends StatelessWidget {
); );
} }
Widget _buildDebugCard(BuildContext context, AppSettingsService settingsService) { Widget _buildDebugCard(
BuildContext context,
AppSettingsService settingsService,
) {
return Card( return Card(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -656,9 +716,11 @@ class AppSettingsScreen extends StatelessWidget {
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(value content: Text(
? context.l10n.appSettings_appDebugLoggingEnabled value
: context.l10n.appSettings_appDebugLoggingDisabled), ? context.l10n.appSettings_appDebugLoggingEnabled
: context.l10n.appSettings_appDebugLoggingDisabled,
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
+45 -17
View File
@@ -24,7 +24,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
final entries = logService.entries.reversed.toList(); final entries = logService.entries.reversed.toList();
final rawEntries = logService.rawLogRxEntries.reversed.toList(); final rawEntries = logService.rawLogRxEntries.reversed.toList();
final showingFrames = _view == _BleLogView.frames; final showingFrames = _view == _BleLogView.frames;
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty; final hasEntries = showingFrames
? entries.isNotEmpty
: rawEntries.isNotEmpty;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(context.l10n.debugLog_bleTitle), title: Text(context.l10n.debugLog_bleTitle),
@@ -36,15 +38,23 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
? () async { ? () async {
final text = showingFrames final text = showingFrames
? entries ? entries
.map((entry) => '${entry.description}\n${entry.hexPreview}\n') .map(
.join('\n') (entry) =>
'${entry.description}\n${entry.hexPreview}\n',
)
.join('\n')
: rawEntries : rawEntries
.map((entry) => 'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n') .map(
.join('\n'); (entry) =>
'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n',
)
.join('\n');
await Clipboard.setData(ClipboardData(text: text)); await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.debugLog_bleCopied)), SnackBar(
content: Text(context.l10n.debugLog_bleCopied),
),
); );
} }
: null, : null,
@@ -68,8 +78,14 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: SegmentedButton<_BleLogView>( child: SegmentedButton<_BleLogView>(
segments: [ segments: [
ButtonSegment(value: _BleLogView.frames, label: Text(context.l10n.debugLog_frames)), ButtonSegment(
ButtonSegment(value: _BleLogView.rawLogRx, label: Text(context.l10n.debugLog_rawLogRx)), value: _BleLogView.frames,
label: Text(context.l10n.debugLog_frames),
),
ButtonSegment(
value: _BleLogView.rawLogRx,
label: Text(context.l10n.debugLog_rawLogRx),
),
], ],
selected: {_view}, selected: {_view},
onSelectionChanged: (selection) { onSelectionChanged: (selection) {
@@ -81,7 +97,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
Expanded( Expanded(
child: hasEntries child: hasEntries
? ListView.separated( ? ListView.separated(
itemCount: showingFrames ? entries.length : rawEntries.length, itemCount: showingFrames
? entries.length
: rawEntries.length,
separatorBuilder: (_, __) => const Divider(height: 1), separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (showingFrames) { if (showingFrames) {
@@ -94,7 +112,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
subtitle: Text('${entry.hexPreview}\n$time'), subtitle: Text('${entry.hexPreview}\n$time'),
isThreeLine: true, isThreeLine: true,
leading: Icon( leading: Icon(
entry.outgoing ? Icons.upload : Icons.download, entry.outgoing
? Icons.upload
: Icons.download,
size: 18, size: 18,
), ),
); );
@@ -131,9 +151,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
context: context, context: context,
builder: (context) => AlertDialog( builder: (context) => AlertDialog(
title: Text(info.title), title: Text(info.title),
content: SingleChildScrollView( content: SingleChildScrollView(child: SelectableText(info.rawHex)),
child: SelectableText(info.rawHex),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
@@ -195,11 +213,18 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
} }
final payload = raw.sublist(index); final payload = raw.sublist(index);
final title = 'RX ${_payloadTypeLabel(payloadType)}${_routeLabel(routeType)} • v$payloadVer'; final title =
'RX ${_payloadTypeLabel(payloadType)}${_routeLabel(routeType)} • v$payloadVer';
final summary = _decodePayloadSummary(payloadType, payload); final summary = _decodePayloadSummary(payloadType, payload);
final pathSummary = pathLen > 0 ? 'Path=${_bytesToHex(pathBytes)}' : 'Path=none'; final pathSummary = pathLen > 0
? 'Path=${_bytesToHex(pathBytes)}'
: 'Path=none';
final detail = '$summary$pathSummary • len=${raw.length}'; final detail = '$summary$pathSummary • len=${raw.length}';
return _RawPacketInfo(title: title, summary: detail, rawHex: _bytesToHex(raw)); return _RawPacketInfo(
title: title,
summary: detail,
rawHex: _bytesToHex(raw),
);
} }
String _decodePayloadSummary(int payloadType, Uint8List payload) { String _decodePayloadSummary(int payloadType, Uint8List payload) {
@@ -245,7 +270,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
return 'ADVERT (short)'; return 'ADVERT (short)';
} }
var offset = 0; var offset = 0;
final pubKey = _bytesToHex(payload.sublist(offset, offset + 32), spaced: false); final pubKey = _bytesToHex(
payload.sublist(offset, offset + 32),
spaced: false,
);
offset += 32; offset += 32;
final timestamp = readUint32LE(payload, offset); final timestamp = readUint32LE(payload, offset);
offset += 4; offset += 4;
+272 -232
View File
@@ -27,10 +27,7 @@ import 'map_screen.dart';
class ChannelChatScreen extends StatefulWidget { class ChannelChatScreen extends StatefulWidget {
final Channel channel; final Channel channel;
const ChannelChatScreen({ const ChannelChatScreen({super.key, required this.channel});
super.key,
required this.channel,
});
@override @override
State<ChannelChatScreen> createState() => _ChannelChatScreenState(); State<ChannelChatScreen> createState() => _ChannelChatScreenState();
@@ -135,15 +132,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [ children: [
Text( Text(
widget.channel.name.isEmpty widget.channel.name.isEmpty
? context.l10n.channels_channelIndex(widget.channel.index) ? context.l10n.channels_channelIndex(
widget.channel.index,
)
: widget.channel.name, : widget.channel.name,
style: const TextStyle(fontSize: 16), style: const TextStyle(fontSize: 16),
), ),
Consumer<MeshCoreConnector>( Consumer<MeshCoreConnector>(
builder: (context, connector, _) { builder: (context, connector, _) {
final unreadCount = final unreadCount = connector
connector.getUnreadCountForChannelIndex(widget.channel.index); .getUnreadCountForChannelIndex(widget.channel.index);
final privacy = widget.channel.isPublicChannel ? context.l10n.channels_public : context.l10n.channels_private; final privacy = widget.channel.isPublicChannel
? context.l10n.channels_public
: context.l10n.channels_private;
return Text( return Text(
'$privacy${context.l10n.chat_unread(unreadCount)}', '$privacy${context.l10n.chat_unread(unreadCount)}',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -202,7 +203,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
// Reverse messages so newest appear at bottom with reverse: true // Reverse messages so newest appear at bottom with reverse: true
final reversedMessages = messages.reversed.toList(); final reversedMessages = messages.reversed.toList();
final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0); final itemCount =
reversedMessages.length + (_isLoadingOlder ? 1 : 0);
// Auto-scroll to bottom if user is already at bottom // Auto-scroll to bottom if user is already at bottom
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -225,7 +227,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
child: SizedBox( child: SizedBox(
width: 20, width: 20,
height: 20, height: 20,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(
strokeWidth: 2,
),
), ),
), ),
); );
@@ -241,9 +245,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
}, },
), ),
JumpToBottomButton( JumpToBottomButton(scrollController: _scrollController),
scrollController: _scrollController,
),
], ],
); );
}, },
@@ -262,15 +264,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final poi = _parsePoiMessage(message.text); final poi = _parsePoiMessage(message.text);
final displayPath = message.pathBytes.isNotEmpty final displayPath = message.pathBytes.isNotEmpty
? message.pathBytes ? message.pathBytes
: (message.pathVariants.isNotEmpty ? message.pathVariants.first : Uint8List(0)); : (message.pathVariants.isNotEmpty
? message.pathVariants.first
: Uint8List(0));
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Column( child: Column(
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start, crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (!isOutgoing) ...[ if (!isOutgoing) ...[
@@ -282,128 +290,160 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
onTap: () => _showMessagePathInfo(message), onTap: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message), onLongPress: () => _showMessageActions(message),
child: Container( child: Container(
padding: gifId != null padding: gifId != null
? const EdgeInsets.all(4) ? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), : const EdgeInsets.symmetric(
constraints: BoxConstraints( horizontal: 12,
maxWidth: MediaQuery.of(context).size.width * 0.65, vertical: 8,
),
decoration: BoxDecoration(
color: isOutgoing
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
Padding(
padding: gifId != null
? const EdgeInsets.only(left: 8, top: 4, bottom: 4)
: EdgeInsets.zero,
child: Text(
message.senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
), ),
), constraints: BoxConstraints(
), maxWidth: MediaQuery.of(context).size.width * 0.65,
if (gifId == null) const SizedBox(height: 4), ),
], decoration: BoxDecoration(
if (message.replyToMessageId != null) ...[ color: isOutgoing
_buildReplyPreview(message), ? Theme.of(context).colorScheme.primaryContainer
const SizedBox(height: 8), : Theme.of(
], context,
if (poi != null) ).colorScheme.surfaceContainerHighest,
_buildPoiMessage(context, poi, isOutgoing) borderRadius: BorderRadius.circular(12),
else if (gifId != null) ),
ClipRRect( child: Column(
borderRadius: BorderRadius.circular(8), crossAxisAlignment: CrossAxisAlignment.start,
child: GifMessage( children: [
url: 'https://media.giphy.com/media/$gifId/giphy.gif', if (!isOutgoing) ...[
backgroundColor: Colors.transparent, Padding(
fallbackTextColor: isOutgoing padding: gifId != null
? Theme.of(context).colorScheme.onPrimaryContainer.withValues(alpha: 0.7) ? const EdgeInsets.only(
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), left: 8,
), top: 4,
) bottom: 4,
else )
Linkify( : EdgeInsets.zero,
text: message.text, child: Text(
style: const TextStyle(fontSize: 14), message.senderName,
linkStyle: const TextStyle( style: TextStyle(
fontSize: 14, fontSize: 12,
color: Colors.green, fontWeight: FontWeight.bold,
decoration: TextDecoration.underline, color: Theme.of(context).colorScheme.primary,
), ),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) => LinkHandler.handleLinkTap(context, link.url),
),
if (displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
'via ${_formatPathPrefixes(displayPath)}',
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
),
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
: EdgeInsets.zero,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
), ),
), ),
if (message.repeatCount > 0) ...[ if (gifId == null) const SizedBox(height: 4),
const SizedBox(width: 6), ],
Icon(Icons.repeat, size: 12, color: Colors.grey[600]), if (message.replyToMessageId != null) ...[
const SizedBox(width: 2), _buildReplyPreview(message),
Text( const SizedBox(height: 8),
'${message.repeatCount}', ],
style: TextStyle(fontSize: 11, color: Colors.grey[600]), if (poi != null)
_buildPoiMessage(context, poi, isOutgoing)
else if (gifId != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: isOutgoing
? Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: 0.7)
: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.6),
), ),
], )
if (isOutgoing) ...[ else
const SizedBox(width: 4), Linkify(
Icon( text: message.text,
message.status == ChannelMessageStatus.sent style: const TextStyle(fontSize: 14),
? Icons.check linkStyle: const TextStyle(
: message.status == ChannelMessageStatus.pending fontSize: 14,
color: Colors.green,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) =>
LinkHandler.handleLinkTap(context, link.url),
),
if (displayPath.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
'via ${_formatPathPrefixes(displayPath)}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
),
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.only(
left: 8,
right: 8,
bottom: 4,
)
: EdgeInsets.zero,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
if (message.repeatCount > 0) ...[
const SizedBox(width: 6),
Icon(
Icons.repeat,
size: 12,
color: Colors.grey[600],
),
const SizedBox(width: 2),
Text(
'${message.repeatCount}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
],
if (isOutgoing) ...[
const SizedBox(width: 4),
Icon(
message.status == ChannelMessageStatus.sent
? Icons.check
: message.status ==
ChannelMessageStatus.pending
? Icons.schedule ? Icons.schedule
: Icons.error_outline, : Icons.error_outline,
size: 14, size: 14,
color: message.status == ChannelMessageStatus.failed color:
? Colors.red message.status ==
: Colors.grey[600], ChannelMessageStatus.failed
), ? Colors.red
], : Colors.grey[600],
], ),
), ],
],
),
),
],
), ),
], ),
), ),
), ),
),
),
], ],
), ),
if (message.reactions.isNotEmpty) ...[ if (message.reactions.isNotEmpty) ...[
@@ -444,7 +484,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [ children: [
Icon(Icons.location_on_outlined, size: 14, color: previewTextColor), Icon(Icons.location_on_outlined, size: 14, color: previewTextColor),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(context.l10n.chat_location, style: TextStyle(fontSize: 12, color: previewTextColor)), Text(
context.l10n.chat_location,
style: TextStyle(fontSize: 12, color: previewTextColor),
),
], ],
); );
} else { } else {
@@ -468,10 +511,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5), color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border( border: Border(
left: BorderSide( left: BorderSide(color: colorScheme.primary, width: 3),
color: colorScheme.primary,
width: 3,
),
), ),
), ),
child: Column( child: Column(
@@ -509,17 +549,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all( border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.3), color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.3),
width: 1, width: 1,
), ),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(emoji, style: const TextStyle(fontSize: 16)),
emoji,
style: const TextStyle(fontSize: 16),
),
if (count > 1) ...[ if (count > 1) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@@ -546,7 +585,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_PoiInfo? _parsePoiMessage(String text) { _PoiInfo? _parsePoiMessage(String text) {
final trimmed = text.trim(); final trimmed = text.trim();
final match = RegExp(r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|').firstMatch(trimmed); final match = RegExp(
r'm:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|',
).firstMatch(trimmed);
if (match == null) return null; if (match == null) return null;
final lat = double.tryParse(match.group(1) ?? ''); final lat = double.tryParse(match.group(1) ?? '');
final lon = double.tryParse(match.group(2) ?? ''); final lon = double.tryParse(match.group(2) ?? '');
@@ -557,10 +598,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) { Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textColor = final textColor = isOutgoing
isOutgoing ? colorScheme.onPrimaryContainer : colorScheme.onSurface; ? colorScheme.onPrimaryContainer
: colorScheme.onSurface;
final metaColor = textColor.withValues(alpha: 0.7); final metaColor = textColor.withValues(alpha: 0.7);
final channelColor = widget.channel.isPublicChannel ? Colors.orange : Colors.blue; final channelColor = widget.channel.isPublicChannel
? Colors.orange
: Colors.blue;
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -588,18 +632,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [ children: [
Text( Text(
context.l10n.chat_poiShared, context.l10n.chat_poiShared,
style: TextStyle( style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
color: textColor,
fontWeight: FontWeight.w600,
),
), ),
if (poi.label.isNotEmpty) if (poi.label.isNotEmpty)
Text( Text(
poi.label, poi.label,
style: TextStyle( style: TextStyle(color: metaColor, fontSize: 12),
color: metaColor,
fontSize: 12,
),
), ),
], ],
), ),
@@ -676,10 +714,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(context).colorScheme.secondaryContainer,
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
color: Theme.of(context).dividerColor,
width: 1,
),
), ),
), ),
child: Row( child: Row(
@@ -708,7 +743,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: Theme.of(context).colorScheme.onSecondaryContainer.withValues(alpha: 0.7), color: Theme.of(
context,
).colorScheme.onSecondaryContainer.withValues(alpha: 0.7),
), ),
), ),
], ],
@@ -746,73 +783,76 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
], ],
), ),
child: Row( child: Row(
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.gif_box), icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context), onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif, tooltip: context.l10n.chat_sendGif,
), ),
Expanded( Expanded(
child: ValueListenableBuilder<TextEditingValue>( child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController, valueListenable: _textController,
builder: (context, value, child) { builder: (context, value, child) {
final gifId = _parseGifId(value.text); final gifId = _parseGifId(value.text);
if (gifId != null) { if (gifId != null) {
return Row( return Row(
children: [ children: [
Expanded( Expanded(
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: GifMessage( child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif', url:
backgroundColor: 'https://media.giphy.com/media/$gifId/giphy.gif',
Theme.of(context).colorScheme.surfaceContainerHighest, backgroundColor: Theme.of(
fallbackTextColor: context,
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), ).colorScheme.surfaceContainerHighest,
maxSize: 160, fallbackTextColor: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
maxSize: 160,
),
),
), ),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => _textController.clear(),
),
],
);
}
return TextField(
controller: _textController,
focusNode: _textFieldFocusNode,
inputFormatters: [
Utf8LengthLimitingTextInputFormatter(maxBytes),
],
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
), ),
), ),
const SizedBox(width: 8), maxLines: null,
IconButton( textInputAction: TextInputAction.send,
icon: const Icon(Icons.close), onSubmitted: (_) => _sendMessage(),
onPressed: () => _textController.clear(), );
), },
], ),
); ),
} const SizedBox(width: 8),
IconButton(
return TextField( icon: const Icon(Icons.send),
controller: _textController, onPressed: _sendMessage,
focusNode: _textFieldFocusNode, color: Theme.of(context).colorScheme.primary,
inputFormatters: [ ),
Utf8LengthLimitingTextInputFormatter(maxBytes), ],
],
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
);
},
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.send),
onPressed: _sendMessage,
color: Theme.of(context).colorScheme.primary,
),
],
), ),
), ),
], ],
@@ -932,24 +972,28 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final emojiIndex = ReactionHelper.emojiToIndex(emoji); final emojiIndex = ReactionHelper.emojiToIndex(emoji);
if (emojiIndex == null) return; // Unknown emoji, skip if (emojiIndex == null) return; // Unknown emoji, skip
final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000; final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000;
final hash = ReactionHelper.computeReactionHash(timestampSecs, message.senderName, message.text); final hash = ReactionHelper.computeReactionHash(
timestampSecs,
message.senderName,
message.text,
);
final reactionText = 'r:$hash:$emojiIndex'; final reactionText = 'r:$hash:$emojiIndex';
connector.sendChannelMessage(widget.channel, reactionText); connector.sendChannelMessage(widget.channel, reactionText);
} }
void _copyMessageText(String text) { void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text)); Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(context.l10n.chat_messageCopied)), context,
); ).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
} }
Future<void> _deleteMessage(ChannelMessage message) async { Future<void> _deleteMessage(ChannelMessage message) async {
await context.read<MeshCoreConnector>().deleteChannelMessage(message); await context.read<MeshCoreConnector>().deleteChannelMessage(message);
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(context.l10n.chat_messageDeleted)), context,
); ).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
} }
String _formatPathPrefixes(Uint8List pathBytes) { String _formatPathPrefixes(Uint8List pathBytes) {
@@ -964,9 +1008,5 @@ class _PoiInfo {
final double lon; final double lon;
final String label; final String label;
const _PoiInfo({ const _PoiInfo({required this.lat, required this.lon, required this.label});
required this.lat,
required this.lon,
required this.label,
});
} }
+69 -57
View File
@@ -17,17 +17,17 @@ import '../models/contact.dart';
class ChannelMessagePathScreen extends StatelessWidget { class ChannelMessagePathScreen extends StatelessWidget {
final ChannelMessage message; final ChannelMessage message;
const ChannelMessagePathScreen({ const ChannelMessagePathScreen({super.key, required this.message});
super.key,
required this.message,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>( return Consumer<MeshCoreConnector>(
builder: (context, connector, _) { builder: (context, connector, _) {
final l10n = context.l10n; final l10n = context.l10n;
final primaryPath = _selectPrimaryPath(message.pathBytes, message.pathVariants); final primaryPath = _selectPrimaryPath(
message.pathBytes,
message.pathVariants,
);
final hops = _buildPathHops(primaryPath, connector.contacts, l10n); final hops = _buildPathHops(primaryPath, connector.contacts, l10n);
final hasHopDetails = primaryPath.isNotEmpty; final hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops( final observedLabel = _formatObservedHops(
@@ -88,10 +88,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
); );
} }
Widget _buildSummaryCard( Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) {
BuildContext context, {
String? observedLabel,
}) {
final l10n = context.l10n; final l10n = context.l10n;
return Card( return Card(
child: Padding( child: Padding(
@@ -105,21 +102,28 @@ class ChannelMessagePathScreen extends StatelessWidget {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildDetailRow(l10n.channelPath_senderLabel, message.senderName), _buildDetailRow(l10n.channelPath_senderLabel, message.senderName),
_buildDetailRow(l10n.channelPath_timeLabel, _formatTime(message.timestamp, l10n)), _buildDetailRow(
l10n.channelPath_timeLabel,
_formatTime(message.timestamp, l10n),
),
if (message.repeatCount > 0) if (message.repeatCount > 0)
_buildDetailRow(l10n.channelPath_repeatsLabel, message.repeatCount.toString()), _buildDetailRow(
_buildDetailRow(l10n.channelPath_pathLabelTitle, _formatPathLabel(message.pathLength, l10n)), l10n.channelPath_repeatsLabel,
if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel), message.repeatCount.toString(),
),
_buildDetailRow(
l10n.channelPath_pathLabelTitle,
_formatPathLabel(message.pathLength, l10n),
),
if (observedLabel != null)
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
], ],
), ),
), ),
); );
} }
Widget _buildPathVariants( Widget _buildPathVariants(BuildContext context, List<Uint8List> variants) {
BuildContext context,
List<Uint8List> variants,
) {
final l10n = context.l10n; final l10n = context.l10n;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -163,7 +167,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
subtitle: Text( subtitle: Text(
hop.hasLocation hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, ' ? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}' '${hop.position!.longitude.toStringAsFixed(5)}'
: l10n.channelPath_noLocationData, : l10n.channelPath_noLocationData,
), ),
), ),
@@ -239,7 +243,6 @@ class ChannelMessagePathScreen extends StatelessWidget {
), ),
); );
} }
} }
class ChannelMessagePathMapScreen extends StatefulWidget { class ChannelMessagePathMapScreen extends StatefulWidget {
@@ -257,7 +260,8 @@ class ChannelMessagePathMapScreen extends StatefulWidget {
_ChannelMessagePathMapScreenState(); _ChannelMessagePathMapScreenState();
} }
class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScreen> { class _ChannelMessagePathMapScreenState
extends State<ChannelMessagePathMapScreen> {
Uint8List? _selectedPath; Uint8List? _selectedPath;
@override @override
@@ -270,8 +274,10 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) { void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.message != widget.message || if (oldWidget.message != widget.message ||
!_pathsEqual(oldWidget.initialPath ?? Uint8List(0), !_pathsEqual(
widget.initialPath ?? Uint8List(0))) { oldWidget.initialPath ?? Uint8List(0),
widget.initialPath ?? Uint8List(0),
)) {
_selectedPath = widget.initialPath; _selectedPath = widget.initialPath;
} }
} }
@@ -281,17 +287,25 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
return Consumer<MeshCoreConnector>( return Consumer<MeshCoreConnector>(
builder: (context, connector, _) { builder: (context, connector, _) {
final tileCache = context.read<MapTileCacheService>(); final tileCache = context.read<MapTileCacheService>();
final primaryPath = final primaryPath = _selectPrimaryPath(
_selectPrimaryPath(widget.message.pathBytes, widget.message.pathVariants); widget.message.pathBytes,
final observedPaths = widget.message.pathVariants,
_buildObservedPaths(primaryPath, widget.message.pathVariants); );
final observedPaths = _buildObservedPaths(
primaryPath,
widget.message.pathVariants,
);
final selectedPath = _resolveSelectedPath( final selectedPath = _resolveSelectedPath(
_selectedPath, _selectedPath,
observedPaths, observedPaths,
primaryPath, primaryPath,
); );
final selectedIndex = _indexForPath(selectedPath, observedPaths); final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops(selectedPath, connector.contacts, context.l10n); final hops = _buildPathHops(
selectedPath,
connector.contacts,
context.l10n,
);
final points = hops final points = hops
.where((hop) => hop.hasLocation) .where((hop) => hop.hasLocation)
.map((hop) => hop.position!) .map((hop) => hop.position!)
@@ -306,16 +320,17 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
] ]
: <Polyline>[]; : <Polyline>[];
final initialCenter = final initialCenter = points.isNotEmpty
points.isNotEmpty ? points.first : const LatLng(0, 0); ? points.first
: const LatLng(0, 0);
final initialZoom = points.isNotEmpty ? 13.0 : 2.0; final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null; final bounds = points.length > 1
? LatLngBounds.fromPoints(points)
: null;
final mapKey = ValueKey(_formatPathPrefixes(selectedPath)); final mapKey = ValueKey(_formatPathPrefixes(selectedPath));
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)),
title: Text(context.l10n.channelPath_mapTitle),
),
body: SafeArea( body: SafeArea(
top: false, top: false,
child: Stack( child: Stack(
@@ -343,30 +358,28 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
MapTileCacheService.userAgentPackageName, MapTileCacheService.userAgentPackageName,
maxZoom: 19, maxZoom: 19,
), ),
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines), if (polylines.isNotEmpty)
MarkerLayer( PolylineLayer(polylines: polylines),
markers: _buildHopMarkers(hops), MarkerLayer(markers: _buildHopMarkers(hops)),
),
], ],
), ),
if (observedPaths.length > 1) if (observedPaths.length > 1)
_buildPathSelector( _buildPathSelector(context, observedPaths, selectedIndex, (
context, index,
observedPaths, ) {
selectedIndex, setState(() {
(index) { _selectedPath = observedPaths[index].pathBytes;
setState(() { });
_selectedPath = observedPaths[index].pathBytes; }),
});
},
),
if (points.isEmpty) if (points.isEmpty)
Center( Center(
child: Card( child: Card(
color: Colors.white.withValues(alpha: 0.9), color: Colors.white.withValues(alpha: 0.9),
child: Padding( child: Padding(
padding: EdgeInsets.all(12), padding: EdgeInsets.all(12),
child: Text(context.l10n.channelPath_noRepeaterLocations), child: Text(
context.l10n.channelPath_noRepeaterLocations,
),
), ),
), ),
), ),
@@ -525,7 +538,7 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
subtitle: Text( subtitle: Text(
hop.hasLocation hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, ' ? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}' '${hop.position!.longitude.toStringAsFixed(5)}'
: l10n.channelPath_noLocationData, : l10n.channelPath_noLocationData,
), ),
); );
@@ -567,10 +580,7 @@ class _ObservedPath {
final Uint8List pathBytes; final Uint8List pathBytes;
final bool isPrimary; final bool isPrimary;
const _ObservedPath({ const _ObservedPath({required this.pathBytes, required this.isPrimary});
required this.pathBytes,
required this.isPrimary,
});
} }
List<_PathHop> _buildPathHops( List<_PathHop> _buildPathHops(
@@ -597,10 +607,12 @@ List<_PathHop> _buildPathHops(
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) { Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
final matches = contacts final matches = contacts
.where((contact) => .where(
(contact.type == advTypeRepeater || contact.type == advTypeRoom) && (contact) =>
contact.publicKey.isNotEmpty && (contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
contact.publicKey[0] == prefix) contact.publicKey.isNotEmpty &&
contact.publicKey[0] == prefix,
)
.toList(); .toList();
if (matches.isEmpty) return null; if (matches.isEmpty) return null;
+11 -5
View File
@@ -154,7 +154,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
), ),
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute(builder: (context) => const SettingsScreen()), MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
), ),
), ),
], ],
@@ -951,7 +953,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
dialogContext.l10n.community_communityHashtag, dialogContext.l10n.community_communityHashtag,
), ),
subtitle: Text( subtitle: Text(
dialogContext.l10n.community_communityHashtagDesc, dialogContext
.l10n
.community_communityHashtagDesc,
), ),
dense: true, dense: true,
), ),
@@ -1047,7 +1051,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
hashtag = hashtag.substring(1); hashtag = hashtag.substring(1);
} }
final String channelName; final String channelName;
final Uint8List psk; final Uint8List psk;
if (isRegularHashtag) { if (isRegularHashtag) {
channelName = '#$hashtag'; channelName = '#$hashtag';
@@ -1069,8 +1073,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
); );
return; return;
} }
channelName = '${selectedCommunity!.name} #$hashtag'; channelName =
psk = selectedCommunity!.deriveCommunityHashtagPsk(hashtag); '${selectedCommunity!.name} #$hashtag';
psk = selectedCommunity!
.deriveCommunityHashtagPsk(hashtag);
// Track in community's hashtag list // Track in community's hashtag list
await _communityStore.addHashtagChannel( await _communityStore.addHashtagChannel(
selectedCommunity!.id, selectedCommunity!.id,
+340 -223
View File
@@ -52,7 +52,9 @@ class _ChatScreenState extends State<ChatScreen> {
_scrollController.onScrollNearTop = _loadOlderMessages; _scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) { SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
context.read<MeshCoreConnector>().setActiveContact(widget.contact.publicKeyHex); context.read<MeshCoreConnector>().setActiveContact(
widget.contact.publicKeyHex,
);
}); });
} }
@@ -91,12 +93,15 @@ class _ChatScreenState extends State<ChatScreen> {
title: Consumer2<PathHistoryService, MeshCoreConnector>( title: Consumer2<PathHistoryService, MeshCoreConnector>(
builder: (context, pathService, connector, _) { builder: (context, pathService, connector, _) {
final contact = _resolveContact(connector); final contact = _resolveContact(connector);
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex); final unreadCount = connector.getUnreadCountForContactKey(
widget.contact.publicKeyHex,
);
final unreadLabel = context.l10n.chat_unread(unreadCount); final unreadLabel = context.l10n.chat_unread(unreadCount);
final pathLabel = _currentPathLabel(contact); final pathLabel = _currentPathLabel(contact);
// Show path details if we have path data (from device or override) // Show path details if we have path data (from device or override)
final hasPathData = contact.path.isNotEmpty || contact.pathOverrideBytes != null; final hasPathData =
contact.path.isNotEmpty || contact.pathOverrideBytes != null;
final effectivePath = contact.pathOverrideBytes ?? contact.path; final effectivePath = contact.pathOverrideBytes ?? contact.path;
return Column( return Column(
@@ -106,7 +111,9 @@ class _ChatScreenState extends State<ChatScreen> {
Text(contact.name), Text(contact.name),
GestureDetector( GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: hasPathData ? () => _showFullPathDialog(context, effectivePath) : null, onTap: hasPathData
? () => _showFullPathDialog(context, effectivePath)
: null,
child: Text( child: Text(
'$pathLabel$unreadLabel', '$pathLabel$unreadLabel',
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -144,12 +151,20 @@ class _ChatScreenState extends State<ChatScreen> {
value: 'auto', value: 'auto',
child: Row( child: Row(
children: [ children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null), Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
context.l10n.chat_autoUseSavedPath, context.l10n.chat_autoUseSavedPath,
style: TextStyle( style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
), ),
), ),
], ],
@@ -159,12 +174,20 @@ class _ChatScreenState extends State<ChatScreen> {
value: 'flood', value: 'flood',
child: Row( child: Row(
children: [ children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null), Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
context.l10n.chat_forceFloodMode, context.l10n.chat_forceFloodMode,
style: TextStyle( style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
), ),
), ),
], ],
@@ -196,9 +219,7 @@ class _ChatScreenState extends State<ChatScreen> {
messages.isEmpty messages.isEmpty
? _buildEmptyState() ? _buildEmptyState()
: _buildMessageList(messages, connector), : _buildMessageList(messages, connector),
JumpToBottomButton( JumpToBottomButton(scrollController: _scrollController),
scrollController: _scrollController,
),
], ],
), ),
), ),
@@ -231,7 +252,10 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
Widget _buildMessageList(List<Message> messages, MeshCoreConnector connector) { Widget _buildMessageList(
List<Message> messages,
MeshCoreConnector connector,
) {
// Reverse messages so newest appear at bottom with reverse: true // Reverse messages so newest appear at bottom with reverse: true
final reversedMessages = messages.reversed.toList(); final reversedMessages = messages.reversed.toList();
final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0); final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0);
@@ -267,14 +291,21 @@ class _ChatScreenState extends State<ChatScreen> {
if (widget.contact.type == advTypeRoom) { if (widget.contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes( contact = _resolveContactFrom4Bytes(
connector, connector,
message.fourByteRoomContactKey.isEmpty ? Uint8List.fromList([0, 0, 0, 0]) : message.fourByteRoomContactKey, message.fourByteRoomContactKey.isEmpty
? Uint8List.fromList([0, 0, 0, 0])
: message.fourByteRoomContactKey,
); );
fourByteHex = message.fourByteRoomContactKey.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); fourByteHex = message.fourByteRoomContactKey
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join()
.toUpperCase();
} }
return _MessageBubble( return _MessageBubble(
message: message, message: message,
senderName: widget.contact.type == advTypeRoom ? "${contact.name} [$fourByteHex]" : contact.name, senderName: widget.contact.type == advTypeRoom
? "${contact.name} [$fourByteHex]"
: contact.name,
isRoomServer: widget.contact.type == advTypeRoom, isRoomServer: widget.contact.type == advTypeRoom,
onTap: () => _openMessagePath(message, contact), onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact), onLongPress: () => _showMessageActions(message, contact),
@@ -290,9 +321,7 @@ class _ChatScreenState extends State<ChatScreen> {
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.surface, color: colorScheme.surface,
border: Border( border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
top: BorderSide(color: Theme.of(context).dividerColor),
),
), ),
child: SafeArea( child: SafeArea(
child: Row( child: Row(
@@ -314,10 +343,12 @@ class _ChatScreenState extends State<ChatScreen> {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: GifMessage( child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif', url:
backgroundColor: colorScheme.surfaceContainerHighest, 'https://media.giphy.com/media/$gifId/giphy.gif',
fallbackTextColor: backgroundColor:
colorScheme.onSurface.withValues(alpha: 0.6), colorScheme.surfaceContainerHighest,
fallbackTextColor: colorScheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160, maxSize: 160,
), ),
), ),
@@ -341,7 +372,10 @@ class _ChatScreenState extends State<ChatScreen> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage, hintText: context.l10n.chat_typeMessage,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
), ),
textInputAction: TextInputAction.send, textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(connector), onSubmitted: (_) => _sendMessage(connector),
@@ -390,14 +424,10 @@ class _ChatScreenState extends State<ChatScreen> {
return; return;
} }
connector.sendMessage( connector.sendMessage(widget.contact, text);
widget.contact,
text,
);
_textController.clear(); _textController.clear();
} }
void _showPathHistory(BuildContext context) { void _showPathHistory(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
@@ -422,13 +452,19 @@ class _ChatScreenState extends State<ChatScreen> {
if (paths.isNotEmpty) ...[ if (paths.isNotEmpty) ...[
Text( Text(
context.l10n.chat_recentAckPaths, context.l10n.chat_recentAckPaths,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
), ),
if (paths.length >= 100) ...[ if (paths.length >= 100) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.amber[100], color: Colors.amber[100],
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -447,7 +483,9 @@ class _ChatScreenState extends State<ChatScreen> {
dense: true, dense: true,
leading: CircleAvatar( leading: CircleAvatar(
radius: 16, radius: 16,
backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green, backgroundColor: path.wasFloodDiscovery
? Colors.blue
: Colors.green,
child: Text( child: Text(
'${path.hopCount}', '${path.hopCount}',
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
@@ -475,23 +513,36 @@ class _ChatScreenState extends State<ChatScreen> {
}, },
), ),
path.wasFloodDiscovery path.wasFloodDiscovery
? const Icon(Icons.waves, size: 16, color: Colors.grey) ? const Icon(
: const Icon(Icons.route, size: 16, color: Colors.grey), Icons.waves,
size: 16,
color: Colors.grey,
)
: const Icon(
Icons.route,
size: 16,
color: Colors.grey,
),
], ],
), ),
onLongPress: () => _showFullPathDialog(context, path.pathBytes), onLongPress: () =>
_showFullPathDialog(context, path.pathBytes),
onTap: () async { onTap: () async {
if (path.pathBytes.isEmpty) { if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(context.l10n.chat_pathDetailsNotAvailable), content: Text(
context.l10n.chat_pathDetailsNotAvailable,
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
return; return;
} }
final pathBytes = Uint8List.fromList(path.pathBytes); final pathBytes = Uint8List.fromList(
path.pathBytes,
);
final pathLength = path.pathBytes.length; final pathLength = path.pathBytes.length;
// Set the path override to persist user's choice // Set the path override to persist user's choice
@@ -521,7 +572,10 @@ class _ChatScreenState extends State<ChatScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
context.l10n.chat_pathActions, context.l10n.chat_pathActions,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
ListTile( ListTile(
@@ -531,8 +585,14 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.purple, backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16), child: Icon(Icons.edit_road, size: 16),
), ),
title: Text(context.l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)), title: Text(
subtitle: Text(context.l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)), context.l10n.chat_setCustomPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_setCustomPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
_showCustomPathDialog(context); _showCustomPathDialog(context);
@@ -545,8 +605,14 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16), child: Icon(Icons.clear_all, size: 16),
), ),
title: Text(context.l10n.chat_clearPath, style: const TextStyle(fontSize: 14)), title: Text(
subtitle: Text(context.l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)), context.l10n.chat_clearPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_clearPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async { onTap: () async {
await connector.clearContactPath(widget.contact); await connector.clearContactPath(widget.contact);
if (!context.mounted) return; if (!context.mounted) return;
@@ -566,10 +632,19 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.blue, backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16), child: Icon(Icons.waves, size: 16),
), ),
title: Text(context.l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)), title: Text(
subtitle: Text(context.l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)), context.l10n.chat_forceFloodMode,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_floodModeSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async { onTap: () async {
await connector.setPathOverride(widget.contact, pathLen: -1); await connector.setPathOverride(
widget.contact,
pathLen: -1,
);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -598,7 +673,8 @@ class _ChatScreenState extends State<ChatScreen> {
String _formatRelativeTime(DateTime time) { String _formatRelativeTime(DateTime time) {
final diff = DateTime.now().difference(time); final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return context.l10n.time_justNow; if (diff.inSeconds < 60) return context.l10n.time_justNow;
if (diff.inMinutes < 60) return context.l10n.time_minutesAgo(diff.inMinutes); if (diff.inMinutes < 60)
return context.l10n.time_minutesAgo(diff.inMinutes);
if (diff.inHours < 24) return context.l10n.time_hoursAgo(diff.inHours); if (diff.inHours < 24) return context.l10n.time_hoursAgo(diff.inHours);
return context.l10n.time_daysAgo(diff.inDays); return context.l10n.time_daysAgo(diff.inDays);
} }
@@ -640,7 +716,10 @@ class _ChatScreenState extends State<ChatScreen> {
); );
} }
Contact _resolveContactFrom4Bytes(MeshCoreConnector connector, Uint8List key4Bytes) { Contact _resolveContactFrom4Bytes(
MeshCoreConnector connector,
Uint8List key4Bytes,
) {
return connector.contacts.firstWhere( return connector.contacts.firstWhere(
(c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)), (c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
orElse: () => widget.contact, orElse: () => widget.contact,
@@ -674,12 +753,12 @@ class _ChatScreenState extends State<ChatScreen> {
final status = !connector.isConnected final status = !connector.isConnected
? context.l10n.chat_pathSavedLocally ? context.l10n.chat_pathSavedLocally
: (verified ? context.l10n.chat_pathDeviceConfirmed : context.l10n.chat_pathDeviceNotConfirmed); : (verified
? context.l10n.chat_pathDeviceConfirmed
: context.l10n.chat_pathDeviceNotConfirmed);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(context.l10n.chat_pathSetHops(hopCount, status)),
context.l10n.chat_pathSetHops(hopCount, status),
),
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
), ),
); );
@@ -694,7 +773,9 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context) => Consumer<MeshCoreConnector>( builder: (context) => Consumer<MeshCoreConnector>(
builder: (context, connector, _) { builder: (context, connector, _) {
final contact = _resolveContact(connector); final contact = _resolveContact(connector);
final smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex); final smazEnabled = connector.isContactSmazEnabled(
contact.publicKeyHex,
);
return AlertDialog( return AlertDialog(
title: Text(contact.name), title: Text(contact.name),
@@ -710,7 +791,10 @@ class _ChatScreenState extends State<ChatScreen> {
context.l10n.chat_location, context.l10n.chat_location,
'${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}', '${contact.latitude?.toStringAsFixed(4)}, ${contact.longitude?.toStringAsFixed(4)}',
), ),
_buildInfoRow(context.l10n.chat_publicKey, '${contact.publicKeyHex.substring(0, 16)}...'), _buildInfoRow(
context.l10n.chat_publicKey,
'${contact.publicKeyHex.substring(0, 16)}...',
),
const Divider(), const Divider(),
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
@@ -718,7 +802,10 @@ class _ChatScreenState extends State<ChatScreen> {
subtitle: Text(context.l10n.chat_compressOutgoingMessages), subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled, value: smazEnabled,
onChanged: (value) { onChanged: (value) {
connector.setContactSmazEnabled(contact.publicKeyHex, value); connector.setContactSmazEnabled(
contact.publicKeyHex,
value,
);
}, },
), ),
], ],
@@ -765,7 +852,9 @@ class _ChatScreenState extends State<ChatScreen> {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final currentContact = _resolveContact(connector); final currentContact = _resolveContact(connector);
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) { if (currentContact.pathLength > 0 &&
currentContact.path.isEmpty &&
connector.isConnected) {
connector.getContacts(); connector.getContacts();
} }
@@ -786,19 +875,31 @@ class _ChatScreenState extends State<ChatScreen> {
onRefresh: connector.isConnected ? connector.getContacts : null, onRefresh: connector.isConnected ? connector.getContacts : null,
); );
appLogger.info('PathSelectionDialog returned: ${result?.length ?? 0} bytes, mounted: $mounted', tag: 'ChatScreen'); appLogger.info(
'PathSelectionDialog returned: ${result?.length ?? 0} bytes, mounted: $mounted',
tag: 'ChatScreen',
);
if (result == null) { if (result == null) {
appLogger.info('PathSelectionDialog was cancelled or returned null', tag: 'ChatScreen'); appLogger.info(
'PathSelectionDialog was cancelled or returned null',
tag: 'ChatScreen',
);
return; return;
} }
if (!mounted) { if (!mounted) {
appLogger.warn('Widget not mounted after dialog, cannot set path', tag: 'ChatScreen'); appLogger.warn(
'Widget not mounted after dialog, cannot set path',
tag: 'ChatScreen',
);
return; return;
} }
appLogger.info('Calling setPathOverride for ${widget.contact.name}', tag: 'ChatScreen'); appLogger.info(
'Calling setPathOverride for ${widget.contact.name}',
tag: 'ChatScreen',
);
await connector.setPathOverride( await connector.setPathOverride(
widget.contact, widget.contact,
pathLen: result.length, pathLen: result.length,
@@ -810,7 +911,6 @@ class _ChatScreenState extends State<ChatScreen> {
await _notifyPathSet(connector, widget.contact, result, result.length); await _notifyPathSet(connector, widget.contact, result, result.length);
} }
void _openMessagePath(Message message, Contact contact) { void _openMessagePath(Message message, Contact contact) {
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();
final fourByteHex = message.fourByteRoomContactKey final fourByteHex = message.fourByteRoomContactKey
@@ -877,8 +977,7 @@ class _ChatScreenState extends State<ChatScreen> {
await _deleteMessage(message); await _deleteMessage(message);
}, },
), ),
if (message.isOutgoing && if (message.isOutgoing && message.status == MessageStatus.failed)
message.status == MessageStatus.failed)
ListTile( ListTile(
leading: const Icon(Icons.refresh), leading: const Icon(Icons.refresh),
title: Text(context.l10n.common_retry), title: Text(context.l10n.common_retry),
@@ -909,29 +1008,26 @@ class _ChatScreenState extends State<ChatScreen> {
void _copyMessageText(String text) { void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text)); Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(context.l10n.chat_messageCopied)), context,
); ).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
} }
Future<void> _deleteMessage(Message message) async { Future<void> _deleteMessage(Message message) async {
await context.read<MeshCoreConnector>().deleteMessage(message); await context.read<MeshCoreConnector>().deleteMessage(message);
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(context.l10n.chat_messageDeleted)), context,
); ).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
} }
void _retryMessage(Message message) { void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry using the contact's current path override setting // Retry using the contact's current path override setting
connector.sendMessage( connector.sendMessage(widget.contact, message.text);
widget.contact, ScaffoldMessenger.of(
message.text, context,
); ).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_retryingMessage)),
);
} }
void _showEmojiPicker(Message message, Contact senderContact) { void _showEmojiPicker(Message message, Contact senderContact) {
@@ -951,11 +1047,17 @@ class _ChatScreenState extends State<ChatScreen> {
final emojiIndex = ReactionHelper.emojiToIndex(emoji); final emojiIndex = ReactionHelper.emojiToIndex(emoji);
if (emojiIndex == null) return; // Unknown emoji, skip if (emojiIndex == null) return; // Unknown emoji, skip
final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000; final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000;
// For room servers, include sender name (like channels) since multiple users // For room servers, include sender name (like channels) since multiple users
// For 1:1 chats, sender is implicit (null) // For 1:1 chats, sender is implicit (null)
final senderName = widget.contact.type == advTypeRoom ? senderContact.name : null; final senderName = widget.contact.type == advTypeRoom
final hash = ReactionHelper.computeReactionHash(timestampSecs, senderName, message.text); ? senderContact.name
: null;
final hash = ReactionHelper.computeReactionHash(
timestampSecs,
senderName,
message.text,
);
final reactionText = 'r:$hash:$emojiIndex'; final reactionText = 'r:$hash:$emojiIndex';
connector.sendMessage(widget.contact, reactionText); connector.sendMessage(widget.contact, reactionText);
} }
@@ -985,7 +1087,9 @@ class _MessageBubble extends StatelessWidget {
final isFailed = message.status == MessageStatus.failed; final isFailed = message.status == MessageStatus.failed;
final bubbleColor = isFailed final bubbleColor = isFailed
? colorScheme.errorContainer ? colorScheme.errorContainer
: (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest); : (isOutgoing
? colorScheme.primary
: colorScheme.surfaceContainerHighest);
final textColor = isFailed final textColor = isFailed
? colorScheme.onErrorContainer ? colorScheme.onErrorContainer
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface); : (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
@@ -997,13 +1101,17 @@ class _MessageBubble extends StatelessWidget {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Column( child: Column(
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start, crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [ children: [
GestureDetector( GestureDetector(
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
child: Row( child: Row(
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (!isOutgoing) ...[ if (!isOutgoing) ...[
@@ -1012,133 +1120,154 @@ class _MessageBubble extends StatelessWidget {
], ],
Flexible( Flexible(
child: Container( child: Container(
padding: gifId != null padding: gifId != null
? const EdgeInsets.all(4) ? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), : const EdgeInsets.symmetric(
constraints: BoxConstraints( horizontal: 12,
maxWidth: MediaQuery.of(context).size.width * 0.65, vertical: 8,
),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
Padding(
padding: gifId != null
? const EdgeInsets.only(left: 8, top: 4, bottom: 4)
: EdgeInsets.zero,
child: Text(
senderName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: colorScheme.primary,
), ),
), constraints: BoxConstraints(
), maxWidth: MediaQuery.of(context).size.width * 0.65,
if (gifId == null) const SizedBox(height: 4), ),
], decoration: BoxDecoration(
if (poi != null) color: bubbleColor,
_buildPoiMessage(context, poi, textColor, metaColor) borderRadius: BorderRadius.circular(16),
else if (gifId != null) ),
ClipRRect( child: Column(
borderRadius: BorderRadius.circular(12), crossAxisAlignment: CrossAxisAlignment.start,
child: GifMessage( children: [
url: 'https://media.giphy.com/media/$gifId/giphy.gif', if (!isOutgoing) ...[
backgroundColor: Colors.transparent, Padding(
fallbackTextColor: textColor.withValues(alpha: 0.7), padding: gifId != null
), ? const EdgeInsets.only(
) left: 8,
else top: 4,
Linkify( bottom: 4,
text: messageText, )
style: TextStyle( : EdgeInsets.zero,
color: textColor, child: Text(
), senderName,
linkStyle: const TextStyle(
color: Colors.green,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) => LinkHandler.handleLinkTap(context, link.url),
),
if (isOutgoing && message.retryCount > 0) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
context.l10n.chat_retryCount(message.retryCount, 4),
style: TextStyle(
fontSize: 10,
color: metaColor,
fontWeight: FontWeight.w500,
),
),
),
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.only(left: 8, right: 8, bottom: 4)
: EdgeInsets.zero,
child: Wrap(
spacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 10,
color: metaColor,
),
),
if (isOutgoing) ...[
const SizedBox(width: 4),
_buildStatusIcon(metaColor),
],
if (message.tripTimeMs != null &&
message.status == MessageStatus.delivered) ...[
const SizedBox(width: 4),
Icon(
Icons.speed,
size: 10,
color: isOutgoing ? metaColor : Colors.green[700],
),
Text(
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
style: TextStyle( style: TextStyle(
fontSize: 9, fontSize: 12,
color: isOutgoing ? metaColor : Colors.green[700], fontWeight: FontWeight.bold,
color: colorScheme.primary,
), ),
), ),
], ),
if (gifId == null) const SizedBox(height: 4),
], ],
), if (poi != null)
_buildPoiMessage(context, poi, textColor, metaColor)
else if (gifId != null)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Colors.transparent,
fallbackTextColor: textColor.withValues(
alpha: 0.7,
),
),
)
else
Linkify(
text: messageText,
style: TextStyle(color: textColor),
linkStyle: const TextStyle(
color: Colors.green,
decoration: TextDecoration.underline,
),
options: const LinkifyOptions(
humanize: false,
defaultToHttps: false,
),
linkifiers: const [UrlLinkifier()],
onOpen: (link) =>
LinkHandler.handleLinkTap(context, link.url),
),
if (isOutgoing && message.retryCount > 0) ...[
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Text(
context.l10n.chat_retryCount(
message.retryCount,
4,
),
style: TextStyle(
fontSize: 10,
color: metaColor,
fontWeight: FontWeight.w500,
),
),
),
],
const SizedBox(height: 4),
Padding(
padding: gifId != null
? const EdgeInsets.only(
left: 8,
right: 8,
bottom: 4,
)
: EdgeInsets.zero,
child: Wrap(
spacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 10,
color: metaColor,
),
),
if (isOutgoing) ...[
const SizedBox(width: 4),
_buildStatusIcon(metaColor),
],
if (message.tripTimeMs != null &&
message.status ==
MessageStatus.delivered) ...[
const SizedBox(width: 4),
Icon(
Icons.speed,
size: 10,
color: isOutgoing
? metaColor
: Colors.green[700],
),
Text(
'${(message.tripTimeMs! / 1000).toStringAsFixed(1)}s',
style: TextStyle(
fontSize: 9,
color: isOutgoing
? metaColor
: Colors.green[700],
),
),
],
],
),
),
],
), ),
], ),
), ),
), ],
),
),
if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
child: _buildReactionsDisplay(context, message, colorScheme),
), ),
], ],
), ],
),
if (message.reactions.isNotEmpty) ...[
const SizedBox(height: 4),
Padding(
padding: EdgeInsets.only(left: isOutgoing ? 0 : 48),
child: _buildReactionsDisplay(context, message, colorScheme),
),
],
],
), ),
); );
} }
@@ -1151,8 +1280,9 @@ class _MessageBubble extends StatelessWidget {
_PoiInfo? _parsePoiMessage(String text) { _PoiInfo? _parsePoiMessage(String text) {
final trimmed = text.trim(); final trimmed = text.trim();
final match = RegExp(r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$') final match = RegExp(
.firstMatch(trimmed); r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$',
).firstMatch(trimmed);
if (match == null) return null; if (match == null) return null;
final lat = double.tryParse(match.group(1) ?? ''); final lat = double.tryParse(match.group(1) ?? '');
final lon = double.tryParse(match.group(2) ?? ''); final lon = double.tryParse(match.group(2) ?? '');
@@ -1193,18 +1323,12 @@ class _MessageBubble extends StatelessWidget {
children: [ children: [
Text( Text(
context.l10n.chat_poiShared, context.l10n.chat_poiShared,
style: TextStyle( style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
color: textColor,
fontWeight: FontWeight.w600,
),
), ),
if (poi.label.isNotEmpty) if (poi.label.isNotEmpty)
Text( Text(
poi.label, poi.label,
style: TextStyle( style: TextStyle(color: metaColor, fontSize: 12),
color: metaColor,
fontSize: 12,
),
), ),
], ],
), ),
@@ -1213,7 +1337,11 @@ class _MessageBubble extends StatelessWidget {
); );
} }
Widget _buildReactionsDisplay(BuildContext context, Message message, ColorScheme colorScheme) { Widget _buildReactionsDisplay(
BuildContext context,
Message message,
ColorScheme colorScheme,
) {
return Wrap( return Wrap(
spacing: 6, spacing: 6,
runSpacing: 6, runSpacing: 6,
@@ -1234,10 +1362,7 @@ class _MessageBubble extends StatelessWidget {
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(emoji, style: const TextStyle(fontSize: 16)),
emoji,
style: const TextStyle(fontSize: 16),
),
if (count > 1) ...[ if (count > 1) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@@ -1321,11 +1446,7 @@ class _MessageBubble extends StatelessWidget {
break; break;
} }
return Icon( return Icon(icon, size: 12, color: color);
icon,
size: 12,
color: color,
);
} }
String _formatTime(DateTime time) { String _formatTime(DateTime time) {
@@ -1340,9 +1461,5 @@ class _PoiInfo {
final double lon; final double lon;
final String label; final String label;
const _PoiInfo({ const _PoiInfo({required this.lat, required this.lon, required this.label});
required this.lat,
required this.lon,
required this.label,
});
} }
+153 -116
View File
@@ -29,16 +29,9 @@ import 'map_screen.dart';
import 'repeater_hub_screen.dart'; import 'repeater_hub_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
enum RoomLoginDestination { enum RoomLoginDestination { chat, management }
chat,
management,
}
enum ContactOperationType { enum ContactOperationType { import, export, zeroHopShare }
import,
export,
zeroHopShare,
}
class ContactsScreen extends StatefulWidget { class ContactsScreen extends StatefulWidget {
final bool hideBackButton; final bool hideBackButton;
@@ -105,7 +98,9 @@ class _ContactsScreenState extends State<ContactsScreen>
if (advertPacket.length < 98) { if (advertPacket.length < 98) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)), SnackBar(
content: Text(context.l10n.contacts_invalidAdvertFormat),
),
); );
} }
_pendingOperations.remove(ContactOperationType.export); _pendingOperations.remove(ContactOperationType.export);
@@ -115,23 +110,25 @@ class _ContactsScreenState extends State<ContactsScreen>
Clipboard.setData(ClipboardData(text: "meshcore://$hexString")); Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
} }
if(code == respCodeOk) { if (code == respCodeOk) {
// Show a snackbar indicating success // Show a snackbar indicating success
if(!mounted) return; if (!mounted) return;
if(_pendingOperations.contains(ContactOperationType.import)){ if (_pendingOperations.contains(ContactOperationType.import)) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_contactImported)), SnackBar(content: Text(context.l10n.contacts_contactImported)),
); );
} }
if(_pendingOperations.contains(ContactOperationType.zeroHopShare)) { if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_zeroHopContactAdvertSent)), SnackBar(
content: Text(context.l10n.contacts_zeroHopContactAdvertSent),
),
); );
} }
if(_pendingOperations.contains(ContactOperationType.export)) { if (_pendingOperations.contains(ContactOperationType.export)) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)), SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)),
); );
@@ -140,30 +137,33 @@ class _ContactsScreenState extends State<ContactsScreen>
_pendingOperations.clear(); _pendingOperations.clear();
} }
if(code == respCodeErr) { if (code == respCodeErr) {
// Show a snackbar indicating failure // Show a snackbar indicating failure
if(!mounted) return; if (!mounted) return;
if(_pendingOperations.contains(ContactOperationType.import)){ if (_pendingOperations.contains(ContactOperationType.import)) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_contactImportFailed)), SnackBar(content: Text(context.l10n.contacts_contactImportFailed)),
); );
} }
if(_pendingOperations.contains(ContactOperationType.zeroHopShare)) { if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_zeroHopContactAdvertFailed)), SnackBar(
content: Text(context.l10n.contacts_zeroHopContactAdvertFailed),
),
); );
} }
if(_pendingOperations.contains(ContactOperationType.export)) { if (_pendingOperations.contains(ContactOperationType.export)) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopyFailed)), SnackBar(
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
),
); );
} }
_pendingOperations.clear(); _pendingOperations.clear();
} }
}); });
} }
@@ -185,7 +185,7 @@ class _ContactsScreenState extends State<ContactsScreen>
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final clipboardData = await Clipboard.getData('text/plain'); final clipboardData = await Clipboard.getData('text/plain');
if (clipboardData == null || clipboardData.text == null) { if (clipboardData == null || clipboardData.text == null) {
if(mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_clipboardEmpty)), SnackBar(content: Text(context.l10n.contacts_clipboardEmpty)),
); );
@@ -194,7 +194,7 @@ class _ContactsScreenState extends State<ContactsScreen>
} }
final text = clipboardData.text!.trim(); final text = clipboardData.text!.trim();
if (!text.startsWith('meshcore://')) { if (!text.startsWith('meshcore://')) {
if(mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)), SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
); );
@@ -207,7 +207,7 @@ class _ContactsScreenState extends State<ContactsScreen>
_pendingOperations.add(ContactOperationType.import); _pendingOperations.add(ContactOperationType.import);
await connector.sendFrame(importContactFrame); await connector.sendFrame(importContactFrame);
} catch (e) { } catch (e) {
if(mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)), SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
); );
@@ -234,59 +234,64 @@ class _ContactsScreenState extends State<ContactsScreen>
centerTitle: true, centerTitle: true,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [ actions: [
PopupMenuButton(itemBuilder: (context) => [ PopupMenuButton(
PopupMenuItem( itemBuilder: (context) => [
child: Row( PopupMenuItem(
children: [ child: Row(
const Icon(Icons.connect_without_contact), children: [
const SizedBox(width: 8), const Icon(Icons.connect_without_contact),
Text(context.l10n.contacts_zeroHopAdvert), const SizedBox(width: 8),
], Text(context.l10n.contacts_zeroHopAdvert),
), ],
onTap: () => { ),
onTap: () => {
connector.sendSelfAdvert(flood: false), connector.sendSelfAdvert(flood: false),
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
).showSnackBar(SnackBar(content: Text(context.l10n.settings_advertisementSent))), content: Text(context.l10n.settings_advertisementSent),
}, ),
), ),
PopupMenuItem( },
child: Row(
children: [
const Icon(Icons.cell_tower),
const SizedBox(width: 8),
Text(context.l10n.contacts_floodAdvert),
],
), ),
onTap: () => { PopupMenuItem(
child: Row(
children: [
const Icon(Icons.cell_tower),
const SizedBox(width: 8),
Text(context.l10n.contacts_floodAdvert),
],
),
onTap: () => {
connector.sendSelfAdvert(flood: true), connector.sendSelfAdvert(flood: true),
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context, SnackBar(
).showSnackBar(SnackBar(content: Text(context.l10n.settings_advertisementSent))), content: Text(context.l10n.settings_advertisementSent),
}, ),
), ),
PopupMenuItem( },
child: Row(
children: [
const Icon(Icons.copy),
const SizedBox(width: 8),
Text(context.l10n.contacts_copyAdvertToClipboard),
],
), ),
onTap: () => _contactExport(Uint8List.fromList([])), PopupMenuItem(
), child: Row(
PopupMenuItem( children: [
child: Row( const Icon(Icons.copy),
children: [ const SizedBox(width: 8),
const Icon(Icons.paste), Text(context.l10n.contacts_copyAdvertToClipboard),
const SizedBox(width: 8), ],
Text(context.l10n.contacts_addContactFromClipboard), ),
], onTap: () => _contactExport(Uint8List.fromList([])),
), ),
onTap: () => _contactImport(), PopupMenuItem(
), child: Row(
], children: [
icon: const Icon(Icons.connect_without_contact), const Icon(Icons.paste),
const SizedBox(width: 8),
Text(context.l10n.contacts_addContactFromClipboard),
],
),
onTap: () => _contactImport(),
),
],
icon: const Icon(Icons.connect_without_contact),
), ),
PopupMenuButton( PopupMenuButton(
itemBuilder: (context) => [ itemBuilder: (context) => [
@@ -310,7 +315,9 @@ class _ContactsScreenState extends State<ContactsScreen>
), ),
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute(builder: (context) => const SettingsScreen()), MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
), ),
), ),
], ],
@@ -704,7 +711,8 @@ class _ContactsScreenState extends State<ContactsScreen>
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => destination == RoomLoginDestination.management builder: (context) =>
destination == RoomLoginDestination.management
? RepeaterHubScreen(repeater: room, password: password) ? RepeaterHubScreen(repeater: room, password: password)
: ChatScreen(contact: room), : ChatScreen(contact: room),
), ),
@@ -970,15 +978,22 @@ class _ContactsScreenState extends State<ContactsScreen>
if (isRepeater) ...[ if (isRepeater) ...[
ListTile( ListTile(
leading: const Icon(Icons.radar, color: Colors.green), leading: const Icon(Icons.radar, color: Colors.green),
title: contact.pathLength > 0 ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping), title: contact.pathLength > 0
? Text(context.l10n.contacts_pathTrace)
: Text(context.l10n.contacts_ping),
onTap: () { onTap: () {
showDialog(context: context, builder: (context) { showDialog(
return PathTraceDialog( context: context,
title: contact.pathLength > 0 ? context.l10n.contacts_repeaterPathTrace : context.l10n.contacts_repeaterPing, builder: (context) {
path: contact.traceRouteBytes ?? Uint8List(0), return PathTraceDialog(
); title: contact.pathLength > 0
}); ? context.l10n.contacts_repeaterPathTrace
} : context.l10n.contacts_repeaterPing,
path: contact.traceRouteBytes ?? Uint8List(0),
);
},
);
},
), ),
ListTile( ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange), leading: const Icon(Icons.cell_tower, color: Colors.orange),
@@ -987,19 +1002,26 @@ class _ContactsScreenState extends State<ContactsScreen>
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_showRepeaterLogin(context, contact); _showRepeaterLogin(context, contact);
}, },
) ),
]else if (isRoom) ...[ ] else if (isRoom) ...[
ListTile( ListTile(
leading: const Icon(Icons.radar, color: Colors.green), leading: const Icon(Icons.radar, color: Colors.green),
title: contact.pathLength > 0 ? Text(context.l10n.contacts_pathTrace) : Text(context.l10n.contacts_ping), title: contact.pathLength > 0
? Text(context.l10n.contacts_pathTrace)
: Text(context.l10n.contacts_ping),
onTap: () { onTap: () {
showDialog(context: context, builder: (context) { showDialog(
return PathTraceDialog( context: context,
title: contact.pathLength > 0 ? context.l10n.contacts_roomPathTrace : context.l10n.contacts_roomPing, builder: (context) {
path: contact.traceRouteBytes ?? Uint8List(0), return PathTraceDialog(
); title: contact.pathLength > 0
}); ? context.l10n.contacts_roomPathTrace
} : context.l10n.contacts_roomPing,
path: contact.traceRouteBytes ?? Uint8List(0),
);
},
);
},
), ),
ListTile( ListTile(
leading: const Icon(Icons.room, color: Colors.blue), leading: const Icon(Icons.room, color: Colors.blue),
@@ -1010,27 +1032,39 @@ class _ContactsScreenState extends State<ContactsScreen>
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.room_preferences, color: Colors.orange), leading: const Icon(
Icons.room_preferences,
color: Colors.orange,
),
title: Text(context.l10n.room_management), title: Text(context.l10n.room_management),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_showRoomLogin(context, contact, RoomLoginDestination.management); _showRoomLogin(
context,
contact,
RoomLoginDestination.management,
);
}, },
), ),
] else ...[ ] else ...[
if(contact.pathLength > 0) if (contact.pathLength > 0)
ListTile( ListTile(
leading: const Icon(Icons.radar, color: Colors.green), leading: const Icon(Icons.radar, color: Colors.green),
title: Text(context.l10n.contacts_chatTraceRoute), title: Text(context.l10n.contacts_chatTraceRoute),
onTap: () { onTap: () {
showDialog(context: context, builder: (context) { showDialog(
return PathTraceDialog( context: context,
title: context.l10n.contacts_pathTraceTo(contact.name), builder: (context) {
path: contact.traceRouteBytes ?? Uint8List(0), return PathTraceDialog(
title: context.l10n.contacts_pathTraceTo(
contact.name,
),
path: contact.traceRouteBytes ?? Uint8List(0),
);
},
); );
}); },
} ),
),
ListTile( ListTile(
leading: const Icon(Icons.chat), leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat), title: Text(context.l10n.contacts_openChat),
@@ -1051,7 +1085,7 @@ class _ContactsScreenState extends State<ContactsScreen>
ListTile( ListTile(
leading: const Icon(Icons.connect_without_contact), leading: const Icon(Icons.connect_without_contact),
title: Text(context.l10n.contacts_ShareContactZeroHop), title: Text(context.l10n.contacts_ShareContactZeroHop),
onTap: () { onTap: () {
Navigator.pop(sheetContext); Navigator.pop(sheetContext);
_contactZeroHop(contact.publicKey); _contactZeroHop(contact.publicKey);
}, },
@@ -1127,10 +1161,13 @@ class _ContactTile extends StatelessWidget {
child: _buildContactAvatar(contact), child: _buildContactAvatar(contact),
), ),
title: Text(contact.name), title: Text(contact.name),
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ subtitle: Column(
Text(contact.pathLabel), crossAxisAlignment: CrossAxisAlignment.start,
Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12)) children: [
],), Text(contact.pathLabel),
Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12)),
],
),
// Clamp text scaling in trailing section to prevent overflow while // Clamp text scaling in trailing section to prevent overflow while
// maintaining accessibility. Primary content (title/subtitle) scales normally. // maintaining accessibility. Primary content (title/subtitle) scales normally.
trailing: MediaQuery( trailing: MediaQuery(
@@ -1154,8 +1191,8 @@ class _ContactTile extends StatelessWidget {
Row( Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (contact.hasLocation) if (contact.hasLocation)
Icon(Icons.location_on, size: 14, color: Colors.grey[400]), Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
], ],
), ),
], ],
+5 -18
View File
@@ -127,9 +127,7 @@ class _DeviceScreenState extends State<DeviceScreen>
return Card( return Card(
elevation: 0, elevation: 0,
color: colorScheme.surfaceContainerHighest, color: colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
borderRadius: BorderRadius.circular(24),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
@@ -207,7 +205,6 @@ class _DeviceScreenState extends State<DeviceScreen>
); );
} }
Widget _buildBatteryIndicator( Widget _buildBatteryIndicator(
MeshCoreConnector connector, MeshCoreConnector connector,
BuildContext context, BuildContext context,
@@ -224,11 +221,7 @@ class _DeviceScreenState extends State<DeviceScreen>
final icon = _batteryIcon(percent); final icon = _batteryIcon(percent);
return ActionChip( return ActionChip(
avatar: Icon( avatar: Icon(icon, size: 16, color: colorScheme.onSecondaryContainer),
icon,
size: 16,
color: colorScheme.onSecondaryContainer,
),
label: Text(displayLabel), label: Text(displayLabel),
labelStyle: theme.textTheme.labelMedium?.copyWith( labelStyle: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer, color: colorScheme.onSecondaryContainer,
@@ -260,25 +253,19 @@ class _DeviceScreenState extends State<DeviceScreen>
case 0: case 0:
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
buildQuickSwitchRoute( buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
const ContactsScreen(hideBackButton: true),
),
); );
break; break;
case 1: case 1:
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
buildQuickSwitchRoute( buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
const ChannelsScreen(hideBackButton: true),
),
); );
break; break;
case 2: case 2:
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
buildQuickSwitchRoute( buildQuickSwitchRoute(const MapScreen(hideBackButton: true)),
const MapScreen(hideBackButton: true),
),
); );
break; break;
} }
+31 -23
View File
@@ -56,10 +56,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
_updateEstimate(); _updateEstimate();
if (bounds != null) { if (bounds != null) {
_mapController.fitCamera( _mapController.fitCamera(
CameraFit.bounds( CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)),
bounds: bounds,
padding: const EdgeInsets.all(48),
),
); );
} }
} }
@@ -72,8 +69,11 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
return; return;
} }
final cacheService = context.read<MapTileCacheService>(); final cacheService = context.read<MapTileCacheService>();
final count = final count = cacheService.estimateTileCount(
cacheService.estimateTileCount(_selectedBounds!, _minZoom, _maxZoom); _selectedBounds!,
_minZoom,
_maxZoom,
);
setState(() { setState(() {
_estimatedTiles = count; _estimatedTiles = count;
}); });
@@ -181,9 +181,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
result.failed, result.failed,
) )
: context.l10n.mapCache_cachedTiles(result.downloaded); : context.l10n.mapCache_cachedTiles(result.downloaded);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(message)), context,
); ).showSnackBar(SnackBar(content: Text(message)));
} }
Future<void> _clearCache() async { Future<void> _clearCache() async {
@@ -224,10 +224,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble(); : (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: Text(l10n.mapCache_title), centerTitle: true),
title: Text(l10n.mapCache_title),
centerTitle: true,
),
body: Column( body: Column(
children: [ children: [
Expanded( Expanded(
@@ -290,7 +287,10 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
children: [ children: [
Text( Text(
l10n.mapCache_cacheArea, l10n.mapCache_cacheArea,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( Row(
@@ -304,8 +304,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
TextButton( TextButton(
onPressed: onPressed: _isDownloading || selectedBounds == null
_isDownloading || selectedBounds == null ? null : _clearBounds, ? null
: _clearBounds,
child: Text(l10n.common_clear), child: Text(l10n.common_clear),
), ),
], ],
@@ -313,11 +314,16 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
l10n.mapCache_zoomRange, l10n.mapCache_zoomRange,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
), ),
RangeSlider( RangeSlider(
values: values: RangeValues(
RangeValues(_minZoom.toDouble(), _maxZoom.toDouble()), _minZoom.toDouble(),
_maxZoom.toDouble(),
),
min: 3, min: 3,
max: 18, max: 18,
divisions: 15, divisions: 15,
@@ -341,10 +347,12 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
const SizedBox(height: 8), const SizedBox(height: 8),
LinearProgressIndicator(value: progressValue), LinearProgressIndicator(value: progressValue),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(l10n.mapCache_downloadedTiles( Text(
_completedTiles, l10n.mapCache_downloadedTiles(
_estimatedTiles, _completedTiles,
)), _estimatedTiles,
),
),
], ],
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
+78 -80
View File
@@ -269,7 +269,9 @@ class _MapScreenState extends State<MapScreen> {
), ),
onTap: () => Navigator.push( onTap: () => Navigator.push(
context, context,
MaterialPageRoute(builder: (context) => const SettingsScreen()), MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
), ),
), ),
], ],
@@ -278,85 +280,82 @@ class _MapScreenState extends State<MapScreen> {
], ],
), ),
body: Stack( body: Stack(
children: [ children: [
FlutterMap( FlutterMap(
mapController: _mapController, mapController: _mapController,
options: MapOptions( options: MapOptions(
initialCenter: center, initialCenter: center,
initialZoom: initialZoom, initialZoom: initialZoom,
minZoom: 2.0, minZoom: 2.0,
maxZoom: 18.0, maxZoom: 18.0,
interactionOptions: InteractionOptions( interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate flags: ~InteractiveFlag.rotate,
), ),
onTap: (_, latLng) { onTap: (_, latLng) {
if (_isSelectingPoi) { if (_isSelectingPoi) {
setState(() { setState(() {
_isSelectingPoi = false; _isSelectingPoi = false;
}); });
_shareMarker( _shareMarker(
context: context, context: context,
connector: connector, connector: connector,
position: latLng, position: latLng,
defaultLabel: context.l10n.map_pointOfInterest, defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi', flags: 'poi',
); );
} }
}, },
onLongPress: (_, latLng) { onLongPress: (_, latLng) {
if (_isSelectingPoi) { if (_isSelectingPoi) {
setState(() { setState(() {
_isSelectingPoi = false; _isSelectingPoi = false;
}); });
_shareMarker( _shareMarker(
context: context, context: context,
connector: connector, connector: connector,
position: latLng, position: latLng,
defaultLabel: context.l10n.map_pointOfInterest, defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi', flags: 'poi',
); );
return; return;
} }
_showShareMarkerAtPositionSheet( _showShareMarkerAtPositionSheet(
context: context, context: context,
connector: connector, connector: connector,
position: latLng, position: latLng,
); );
}, },
),
children: [
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
MarkerLayer(
markers: [
if (highlightPosition != null)
Marker(
point: highlightPosition,
width: 40,
height: 40,
child: Icon(
Icons.location_on_outlined,
color: Colors.red[600],
size: 34,
),
),
..._buildMarkers(contactsWithLocation, settings),
...sharedMarkers.map(_buildSharedMarker),
],
),
],
),
_buildLegend(
contactsWithLocation.length,
sharedMarkers.length,
),
],
), ),
children: [
TileLayer(
urlTemplate: kMapTileUrlTemplate,
tileProvider: tileCache.tileProvider,
userAgentPackageName:
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
MarkerLayer(
markers: [
if (highlightPosition != null)
Marker(
point: highlightPosition,
width: 40,
height: 40,
child: Icon(
Icons.location_on_outlined,
color: Colors.red[600],
size: 34,
),
),
..._buildMarkers(contactsWithLocation, settings),
...sharedMarkers.map(_buildSharedMarker),
],
),
],
),
_buildLegend(contactsWithLocation.length, sharedMarkers.length),
],
),
bottomNavigationBar: SafeArea( bottomNavigationBar: SafeArea(
top: false, top: false,
child: QuickSwitchBar( child: QuickSwitchBar(
@@ -376,7 +375,6 @@ class _MapScreenState extends State<MapScreen> {
); );
} }
List<Marker> _buildMarkers(List<Contact> contacts, settings) { List<Marker> _buildMarkers(List<Contact> contacts, settings) {
final markers = <Marker>[]; final markers = <Marker>[];
+61 -25
View File
@@ -119,14 +119,24 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
// Show debug info if requested // Show debug info if requested
if (showDebug && mounted) { if (showDebug && mounted) {
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command); final frame = buildSendCliCommandFrame(
DebugFrameViewer.showFrameDebug(context, frame, context.l10n.repeater_cliCommandFrameTitle); widget.repeater.publicKey,
command,
);
DebugFrameViewer.showFrameDebug(
context,
frame,
context.l10n.repeater_cliCommandFrameTitle,
);
} }
// Send CLI command to repeater with retry // Send CLI command to repeater with retry
try { try {
if (_commandService != null) { if (_commandService != null) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(
context,
listen: false,
);
final repeater = _resolveRepeater(connector); final repeater = _resolveRepeater(connector);
final response = await _commandService!.sendCommand( final response = await _commandService!.sendCommand(
repeater, repeater,
@@ -230,7 +240,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Text(l10n.repeater_cliTitle), Text(l10n.repeater_cliTitle),
Text( Text(
repeater.name, repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
), ),
], ],
), ),
@@ -251,12 +264,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
value: 'auto', value: 'auto',
child: Row( child: Row(
children: [ children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null), Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
l10n.repeater_autoUseSavedPath, l10n.repeater_autoUseSavedPath,
style: TextStyle( style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
), ),
), ),
], ],
@@ -266,12 +287,20 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
value: 'flood', value: 'flood',
child: Row( child: Row(
children: [ children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null), Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
l10n.repeater_forceFloodMode, l10n.repeater_forceFloodMode,
style: TextStyle( style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
), ),
), ),
], ],
@@ -282,7 +311,8 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
IconButton( IconButton(
icon: const Icon(Icons.timeline), icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement, tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater), onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
), ),
IconButton( IconButton(
icon: const Icon(Icons.bug_report), icon: const Icon(Icons.bug_report),
@@ -473,7 +503,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: l10n.repeater_enterCommandHint, hintText: l10n.repeater_enterCommandHint,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
prefixText: '> ', prefixText: '> ',
), ),
style: const TextStyle(fontFamily: 'monospace'), style: const TextStyle(fontFamily: 'monospace'),
@@ -718,10 +751,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
]; ];
final gpsCommands = [ final gpsCommands = [
_CommandHelpEntry( _CommandHelpEntry(command: 'gps', description: l10n.repeater_cliHelpGps),
command: 'gps',
description: l10n.repeater_cliHelpGps,
),
_CommandHelpEntry( _CommandHelpEntry(
command: 'gps {on|off}', command: 'gps {on|off}',
description: l10n.repeater_cliHelpGpsOnOff, description: l10n.repeater_cliHelpGpsOnOff,
@@ -758,13 +788,25 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
style: const TextStyle(fontSize: 13), style: const TextStyle(fontSize: 13),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_general, generalCommands), _buildHelpSection(
context,
l10n.repeater_general,
generalCommands,
),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_settingsCategory, settingsCommands), _buildHelpSection(
context,
l10n.repeater_settingsCategory,
settingsCommands,
),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_bridge, bridgeCommands), _buildHelpSection(context, l10n.repeater_bridge, bridgeCommands),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_logging, loggingCommands), _buildHelpSection(
context,
l10n.repeater_logging,
loggingCommands,
),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildHelpSection( _buildHelpSection(
context, context,
@@ -813,10 +855,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
), ),
if (note != null) ...[ if (note != null) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(note, style: const TextStyle(fontSize: 12)),
note,
style: const TextStyle(fontSize: 12),
),
], ],
const SizedBox(height: 8), const SizedBox(height: 8),
...commands.map((entry) => _buildHelpCommandCard(context, entry)), ...commands.map((entry) => _buildHelpCommandCard(context, entry)),
@@ -871,8 +910,5 @@ class _CommandHelpEntry {
final String command; final String command;
final String description; final String description;
const _CommandHelpEntry({ const _CommandHelpEntry({required this.command, required this.description});
required this.command,
required this.description,
});
} }
+67 -18
View File
@@ -28,7 +28,8 @@ class RepeaterStatusScreen extends StatefulWidget {
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> { class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
static const int _statusPayloadOffset = 8; static const int _statusPayloadOffset = 8;
static const int _statusStatsSize = 52; static const int _statusStatsSize = 52;
static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize; static const int _statusResponseBytes =
_statusPayloadOffset + _statusStatsSize;
bool _isLoading = false; bool _isLoading = false;
StreamSubscription<Uint8List>? _frameSubscription; StreamSubscription<Uint8List>? _frameSubscription;
@@ -293,7 +294,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())), content: Text(
context.l10n.repeater_errorLoadingStatus(e.toString()),
),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
); );
@@ -327,7 +330,10 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
Text(l10n.repeater_statusTitle), Text(l10n.repeater_statusTitle),
Text( Text(
repeater.name, repeater.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.normal), style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
),
), ),
], ],
), ),
@@ -348,12 +354,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
value: 'auto', value: 'auto',
child: Row( child: Row(
children: [ children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null), Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
l10n.repeater_autoUseSavedPath, l10n.repeater_autoUseSavedPath,
style: TextStyle( style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
), ),
), ),
], ],
@@ -363,12 +377,20 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
value: 'flood', value: 'flood',
child: Row( child: Row(
children: [ children: [
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null), Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
l10n.repeater_forceFloodMode, l10n.repeater_forceFloodMode,
style: TextStyle( style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal, fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
), ),
), ),
], ],
@@ -379,7 +401,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
IconButton( IconButton(
icon: const Icon(Icons.timeline), icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement, tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater), onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
), ),
IconButton( IconButton(
icon: _isLoading icon: _isLoading
@@ -423,11 +446,17 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [ children: [
Row( Row(
children: [ children: [
Icon(Icons.info_outline, color: Theme.of(context).textTheme.headlineSmall?.color), Icon(
Icons.info_outline,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
l10n.repeater_systemInformation, l10n.repeater_systemInformation,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
), ),
], ],
), ),
@@ -453,18 +482,30 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [ children: [
Row( Row(
children: [ children: [
Icon(Icons.radio, color: Theme.of(context).textTheme.headlineSmall?.color), Icon(
Icons.radio,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
l10n.repeater_radioStatistics, l10n.repeater_radioStatistics,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
), ),
], ],
), ),
const Divider(), const Divider(),
_buildInfoRow(l10n.repeater_lastRssi, _formatValue(_lastRssi, suffix: ' dB')), _buildInfoRow(
l10n.repeater_lastRssi,
_formatValue(_lastRssi, suffix: ' dB'),
),
_buildInfoRow(l10n.repeater_lastSnr, _formatSnr(_lastSnr)), _buildInfoRow(l10n.repeater_lastSnr, _formatSnr(_lastSnr)),
_buildInfoRow(l10n.repeater_noiseFloor, _formatValue(_noiseFloor, suffix: ' dB')), _buildInfoRow(
l10n.repeater_noiseFloor,
_formatValue(_noiseFloor, suffix: ' dB'),
),
_buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)), _buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)),
_buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)), _buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
], ],
@@ -483,11 +524,17 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [ children: [
Row( Row(
children: [ children: [
Icon(Icons.analytics, color: Theme.of(context).textTheme.headlineSmall?.color), Icon(
Icons.analytics,
color: Theme.of(context).textTheme.headlineSmall?.color,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
l10n.repeater_packetStatistics, l10n.repeater_packetStatistics,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
), ),
], ],
), ),
@@ -561,7 +608,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
if (_statusRequestedAt == null) return ''; if (_statusRequestedAt == null) return '';
final dt = _statusRequestedAt!; final dt = _statusRequestedAt!;
final date = '${dt.day}/${dt.month}/${dt.year}'; final date = '${dt.day}/${dt.month}/${dt.year}';
final time = '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; final time =
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
return '$date $time'; return '$date $time';
} }
@@ -598,7 +646,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
final direct = _formatValue(_dupDirect); final direct = _formatValue(_dupDirect);
return l10n.repeater_duplicatesFloodDirect(flood, direct); return l10n.repeater_duplicatesFloodDirect(flood, direct);
} }
if (_packetsRecv == null || _floodRx == null || _directRx == null) return ''; if (_packetsRecv == null || _floodRx == null || _directRx == null)
return '';
final dupTotal = _packetsRecv! - _floodRx! - _directRx!; final dupTotal = _packetsRecv! - _floodRx! - _directRx!;
if (dupTotal < 0) return ''; if (dupTotal < 0) return '';
return l10n.repeater_duplicatesTotal(dupTotal); return l10n.repeater_duplicatesTotal(dupTotal);
+18 -23
View File
@@ -23,22 +23,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
void initState() { void initState() {
super.initState(); super.initState();
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_connectionListener = () { _connectionListener = () {
if (connector.state == MeshCoreConnectionState.disconnected) { if (connector.state == MeshCoreConnectionState.disconnected) {
_changedNavigation = false; _changedNavigation = false;
} else if (connector.state == MeshCoreConnectionState.connected && !_changedNavigation) { } else if (connector.state == MeshCoreConnectionState.connected &&
!_changedNavigation) {
_changedNavigation = true; _changedNavigation = true;
if (mounted) { if (mounted) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(builder: (context) => const ContactsScreen()),
builder: (context) => const ContactsScreen(),
),
); );
} }
} }
}; };
connector.addListener(_connectionListener); connector.addListener(_connectionListener);
} }
@@ -67,9 +66,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
_buildStatusBar(context, connector), _buildStatusBar(context, connector),
// Device list // Device list
Expanded( Expanded(child: _buildDeviceList(context, connector)),
child: _buildDeviceList(context, connector),
),
], ],
); );
}, },
@@ -77,8 +74,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
), ),
floatingActionButton: Consumer<MeshCoreConnector>( floatingActionButton: Consumer<MeshCoreConnector>(
builder: (context, connector, child) { builder: (context, connector, child) {
final isScanning = connector.state == MeshCoreConnectionState.scanning; final isScanning =
connector.state == MeshCoreConnectionState.scanning;
return FloatingActionButton.extended( return FloatingActionButton.extended(
onPressed: () { onPressed: () {
if (isScanning) { if (isScanning) {
@@ -87,7 +85,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
connector.startScan(); connector.startScan();
} }
}, },
icon: isScanning icon: isScanning
? const SizedBox( ? const SizedBox(
width: 20, width: 20,
height: 20, height: 20,
@@ -97,7 +95,11 @@ class _ScannerScreenState extends State<ScannerScreen> {
), ),
) )
: const Icon(Icons.bluetooth_searching), : const Icon(Icons.bluetooth_searching),
label: Text(isScanning ? context.l10n.scanner_stop : context.l10n.scanner_scan), label: Text(
isScanning
? context.l10n.scanner_stop
: context.l10n.scanner_scan,
),
); );
}, },
), ),
@@ -108,7 +110,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
String statusText; String statusText;
Color statusColor; Color statusColor;
final l10n = context.l10n; final l10n = context.l10n;
switch (connector.state) { switch (connector.state) {
case MeshCoreConnectionState.scanning: case MeshCoreConnectionState.scanning:
statusText = l10n.scanner_scanning; statusText = l10n.scanner_scanning;
@@ -155,20 +157,13 @@ final l10n = context.l10n;
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(Icons.bluetooth, size: 64, color: Colors.grey[400]),
Icons.bluetooth,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
connector.state == MeshCoreConnectionState.scanning connector.state == MeshCoreConnectionState.scanning
? context.l10n.scanner_searchingDevices ? context.l10n.scanner_searchingDevices
: context.l10n.scanner_tapToScan, : context.l10n.scanner_tapToScan,
style: TextStyle( style: TextStyle(fontSize: 16, color: Colors.grey[600]),
fontSize: 16,
color: Colors.grey[600],
),
), ),
], ],
), ),
+3 -4
View File
@@ -442,7 +442,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool isGPSEnabled = customVars["gps"] == "1"; bool isGPSEnabled = customVars["gps"] == "1";
// Read current interval or default to 900 (15 minutes) // Read current interval or default to 900 (15 minutes)
final currentInterval = int.tryParse(customVars["gps_interval"] ?? "") ?? 900; final currentInterval =
int.tryParse(customVars["gps_interval"] ?? "") ?? 900;
intervalController.text = currentInterval.toString(); intervalController.text = currentInterval.toString();
showDialog( showDialog(
@@ -782,9 +783,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
final maxTxPower = widget.connector.maxTxPower ?? 22; final maxTxPower = widget.connector.maxTxPower ?? 22;
if (txPower == null || txPower < 0 || txPower > maxTxPower) { if (txPower == null || txPower < 0 || txPower > maxTxPower) {
ScaffoldMessenger.of( ScaffoldMessenger.of(context).showSnackBar(
context,
).showSnackBar(
SnackBar( SnackBar(
content: Text('${l10n.settings_txPowerInvalid} (0-$maxTxPower dBm)'), content: Text('${l10n.settings_txPowerInvalid} (0-$maxTxPower dBm)'),
), ),
+6 -6
View File
@@ -1,10 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
enum AppDebugLogLevel { enum AppDebugLogLevel { info, warning, error }
info,
warning,
error,
}
class AppDebugLogEntry { class AppDebugLogEntry {
final DateTime timestamp; final DateTime timestamp;
@@ -51,7 +47,11 @@ class AppDebugLogService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) { void log(
String message, {
String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info,
}) {
if (!_enabled) return; if (!_enabled) return;
_entries.add( _entries.add(
+11 -7
View File
@@ -82,10 +82,7 @@ class AppSettingsService extends ChangeNotifier {
final safeMin = minZoom <= maxZoom ? minZoom : maxZoom; final safeMin = minZoom <= maxZoom ? minZoom : maxZoom;
final safeMax = minZoom <= maxZoom ? maxZoom : minZoom; final safeMax = minZoom <= maxZoom ? maxZoom : minZoom;
await updateSettings( await updateSettings(
_settings.copyWith( _settings.copyWith(mapCacheMinZoom: safeMin, mapCacheMaxZoom: safeMax),
mapCacheMinZoom: safeMin,
mapCacheMaxZoom: safeMax,
),
); );
} }
@@ -123,9 +120,16 @@ class AppSettingsService extends ChangeNotifier {
appLogger.setEnabled(value); appLogger.setEnabled(value);
} }
Future<void> setBatteryChemistryForDevice(String deviceId, String chemistry) async { Future<void> setBatteryChemistryForDevice(
final updated = Map<String, String>.from(_settings.batteryChemistryByDeviceId); String deviceId,
String chemistry,
) async {
final updated = Map<String, String>.from(
_settings.batteryChemistryByDeviceId,
);
updated[deviceId] = chemistry; updated[deviceId] = chemistry;
await updateSettings(_settings.copyWith(batteryChemistryByDeviceId: updated)); await updateSettings(
_settings.copyWith(batteryChemistryByDeviceId: updated),
);
} }
} }
+1 -1
View File
@@ -197,7 +197,7 @@ class BleDebugLogService extends ChangeNotifier {
return 'RESP_CODE_CHANNEL_INFO'; return 'RESP_CODE_CHANNEL_INFO';
case respCodeRadioSettings: case respCodeRadioSettings:
return 'RESP_CODE_RADIO_SETTINGS'; return 'RESP_CODE_RADIO_SETTINGS';
case pushCodeTraceData: case pushCodeTraceData:
return 'PUSH_CODE_TRACE_DATA'; return 'PUSH_CODE_TRACE_DATA';
default: default:
return null; return null;
+29 -26
View File
@@ -42,20 +42,21 @@ class MapTileCacheService {
late final TileProvider tileProvider; late final TileProvider tileProvider;
MapTileCacheService({BaseCacheManager? cacheManager}) MapTileCacheService({BaseCacheManager? cacheManager})
: cacheManager = cacheManager ?? : cacheManager =
CacheManager( cacheManager ??
Config( CacheManager(
cacheKey, Config(
stalePeriod: const Duration(days: 365), cacheKey,
maxNrOfCacheObjects: 200000, stalePeriod: const Duration(days: 365),
), maxNrOfCacheObjects: 200000,
) { ),
) {
tileProvider = CachedNetworkTileProvider(cacheManager: this.cacheManager); tileProvider = CachedNetworkTileProvider(cacheManager: this.cacheManager);
} }
Map<String, String> get defaultHeaders => { Map<String, String> get defaultHeaders => {
'User-Agent': 'flutter_map ($userAgentPackageName)', 'User-Agent': 'flutter_map ($userAgentPackageName)',
}; };
Future<void> clearCache() async { Future<void> clearCache() async {
await cacheManager.emptyCache(); await cacheManager.emptyCache();
@@ -96,17 +97,21 @@ class MapTileCacheService {
final future = cacheManager final future = cacheManager
.downloadFile(url, key: url, authHeaders: authHeaders) .downloadFile(url, key: url, authHeaders: authHeaders)
.then((_) { .then((_) {
completed += 1; completed += 1;
}).catchError((_) { })
completed += 1; .catchError((_) {
failed += 1; completed += 1;
}).whenComplete(() { failed += 1;
onProgress?.call(MapTileCacheProgress( })
completed: completed, .whenComplete(() {
total: total, onProgress?.call(
failed: failed, MapTileCacheProgress(
)); completed: completed,
}); total: total,
failed: failed,
),
);
});
pending.add(future); pending.add(future);
if (pending.length >= safeConcurrency) { if (pending.length >= safeConcurrency) {
@@ -189,11 +194,9 @@ class MapTileCacheService {
int _latToTileY(double lat, int zoom, int maxIndex) { int _latToTileY(double lat, int zoom, int maxIndex) {
final n = 1 << zoom; final n = 1 << zoom;
final rad = lat * math.pi / 180.0; final rad = lat * math.pi / 180.0;
final value = ((1 - final value =
math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) / ((1 - math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) / 2 * n)
2 * .floor();
n)
.floor();
return value.clamp(0, maxIndex); return value.clamp(0, maxIndex);
} }
+140 -57
View File
@@ -25,10 +25,7 @@ class _AckHashMapping {
final String messageId; final String messageId;
final DateTime timestamp; final DateTime timestamp;
_AckHashMapping({ _AckHashMapping({required this.messageId, required this.timestamp});
required this.messageId,
required this.timestamp,
});
} }
class MessageRetryService extends ChangeNotifier { class MessageRetryService extends ChangeNotifier {
@@ -39,11 +36,16 @@ class MessageRetryService extends ChangeNotifier {
final Map<String, Message> _pendingMessages = {}; final Map<String, Message> _pendingMessages = {};
final Map<String, Contact> _pendingContacts = {}; final Map<String, Contact> _pendingContacts = {};
final Map<String, PathSelection> _pendingPathSelections = {}; final Map<String, PathSelection> _pendingPathSelections = {};
final Map<String, _AckHashMapping> _ackHashToMessageId = {}; // ackHashHex messageId + timestamp for O(1) lookup final Map<String, _AckHashMapping> _ackHashToMessageId =
final Map<String, List<Uint8List>> _expectedAckHashes = {}; // Track all expected ACKs for retries (for history) {}; // ackHashHex messageId + timestamp for O(1) lookup
final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes final Map<String, List<Uint8List>> _expectedAckHashes =
final Map<String, List<String>> _pendingMessageQueuePerContact = {}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed) {}; // Track all expected ACKs for retries (for history)
final Map<String, String> _expectedHashToMessageId = {}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash) final List<_AckHistoryEntry> _ackHistory =
[]; // Rolling buffer of recent ACK hashes
final Map<String, List<String>> _pendingMessageQueuePerContact =
{}; // contactPubKeyHex FIFO queue of messageIds (DEPRECATED - will be removed)
final Map<String, String> _expectedHashToMessageId =
{}; // expectedAckHashHex messageId (for matching RESP_CODE_SENT by hash)
Function(Contact, String, int, int)? _sendMessageCallback; Function(Contact, String, int, int)? _sendMessageCallback;
Function(String, Message)? _addMessageCallback; Function(String, Message)? _addMessageCallback;
@@ -130,7 +132,8 @@ class MessageRetryService extends ChangeNotifier {
final messagePathBytes = final messagePathBytes =
pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection); pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection);
final messagePathLength = final messagePathLength =
pathLength ?? _resolveMessagePathLength(contact, useFlood, pathSelection); pathLength ??
_resolveMessagePathLength(contact, useFlood, pathSelection);
final message = Message( final message = Message(
senderKey: contact.publicKey, senderKey: contact.publicKey,
text: text, text: text,
@@ -167,15 +170,25 @@ class MessageRetryService extends ChangeNotifier {
if (_setContactPathCallback != null && _clearContactPathCallback != null) { if (_setContactPathCallback != null && _clearContactPathCallback != null) {
if (message.pathLength != null && message.pathLength! < 0) { if (message.pathLength != null && message.pathLength! < 0) {
// Flood mode - clear the path // Flood mode - clear the path
debugPrint('Setting flood mode for retry attempt ${message.retryCount}'); debugPrint(
'Setting flood mode for retry attempt ${message.retryCount}',
);
_clearContactPathCallback!(contact); _clearContactPathCallback!(contact);
} else if (message.pathLength != null && message.pathLength! >= 0) { } else if (message.pathLength != null && message.pathLength! >= 0) {
// Specific path (including direct neighbor with pathLength=0) // Specific path (including direct neighbor with pathLength=0)
final pathStr = message.pathBytes.isEmpty final pathStr = message.pathBytes.isEmpty
? 'direct' ? 'direct'
: message.pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(','); : message.pathBytes
debugPrint('Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}'); .map((b) => b.toRadixString(16).padLeft(2, '0'))
await _setContactPathCallback!(contact, message.pathBytes, message.pathLength!); .join(',');
debugPrint(
'Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}',
);
await _setContactPathCallback!(
contact,
message.pathBytes,
message.pathLength!,
);
} }
} }
@@ -186,22 +199,30 @@ class MessageRetryService extends ChangeNotifier {
// IMPORTANT: Use the transformed text (with SMAZ encoding if enabled) to match device's hash // IMPORTANT: Use the transformed text (with SMAZ encoding if enabled) to match device's hash
final selfPubKey = _getSelfPublicKeyCallback?.call(); final selfPubKey = _getSelfPublicKeyCallback?.call();
if (selfPubKey != null) { if (selfPubKey != null) {
final outboundText = _prepareContactOutboundTextCallback?.call(contact, message.text) ?? message.text; final outboundText =
_prepareContactOutboundTextCallback?.call(contact, message.text) ??
message.text;
final expectedHash = MessageRetryService.computeExpectedAckHash( final expectedHash = MessageRetryService.computeExpectedAckHash(
timestampSeconds, timestampSeconds,
attempt, attempt,
outboundText, outboundText,
selfPubKey, selfPubKey,
); );
final expectedHashHex = expectedHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); final expectedHashHex = expectedHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
_expectedHashToMessageId[expectedHashHex] = messageId; _expectedHashToMessageId[expectedHashHex] = messageId;
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text; final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
_debugLogService?.info( _debugLogService?.info(
'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)', 'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)',
tag: 'AckHash', tag: 'AckHash',
); );
debugPrint('Computed expected ACK hash $expectedHashHex for message $messageId'); debugPrint(
'Computed expected ACK hash $expectedHashHex for message $messageId',
);
} }
// DEPRECATED: Old queue-based matching (kept for fallback) // DEPRECATED: Old queue-based matching (kept for fallback)
@@ -209,17 +230,14 @@ class MessageRetryService extends ChangeNotifier {
_pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId); _pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId);
if (_sendMessageCallback != null) { if (_sendMessageCallback != null) {
_sendMessageCallback!( _sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
contact,
message.text,
attempt,
timestampSeconds,
);
} }
} }
void updateMessageFromSent(Uint8List ackHash, int timeoutMs) { void updateMessageFromSent(Uint8List ackHash, int timeoutMs) {
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
// NEW: Try hash-based matching first (fixes LoRa message drops causing mismatches) // NEW: Try hash-based matching first (fixes LoRa message drops causing mismatches)
String? messageId = _expectedHashToMessageId.remove(ackHashHex); String? messageId = _expectedHashToMessageId.remove(ackHashHex);
@@ -230,16 +248,21 @@ class MessageRetryService extends ChangeNotifier {
final message = _pendingMessages[messageId]; final message = _pendingMessages[messageId];
if (contact != null && message != null) { if (contact != null && message != null) {
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text; final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
_debugLogService?.info( _debugLogService?.info(
'RESP_CODE_SENT received: ACK hash $ackHashHex ✓ matched "$shortText" to ${contact.name}', 'RESP_CODE_SENT received: ACK hash $ackHashHex ✓ matched "$shortText" to ${contact.name}',
tag: 'AckHash', tag: 'AckHash',
); );
debugPrint('Hash-based match: ACK hash $ackHashHex → message $messageId'); debugPrint(
'Hash-based match: ACK hash $ackHashHex → message $messageId',
);
// Remove from old queue since we matched // Remove from old queue since we matched
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId); _pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) { if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex); _pendingMessageQueuePerContact.remove(contact.publicKeyHex);
} }
} else { } else {
@@ -259,7 +282,9 @@ class MessageRetryService extends ChangeNotifier {
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue', 'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
tag: 'AckHash', tag: 'AckHash',
); );
debugPrint('Hash-based match failed for $ackHashHex, falling back to queue-based matching'); debugPrint(
'Hash-based match failed for $ackHashHex, falling back to queue-based matching',
);
for (var entry in _pendingMessageQueuePerContact.entries) { for (var entry in _pendingMessageQueuePerContact.entries) {
final contactKey = entry.key; final contactKey = entry.key;
@@ -271,7 +296,9 @@ class MessageRetryService extends ChangeNotifier {
if (_pendingMessages.containsKey(candidateMessageId)) { if (_pendingMessages.containsKey(candidateMessageId)) {
messageId = candidateMessageId; messageId = candidateMessageId;
contact = _pendingContacts[candidateMessageId]; contact = _pendingContacts[candidateMessageId];
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey'); debugPrint(
'Queue-based match (fallback): $ackHashHex → message $messageId for $contactKey',
);
break; break;
} else { } else {
debugPrint('Dequeued stale message $candidateMessageId - skipping'); debugPrint('Dequeued stale message $candidateMessageId - skipping');
@@ -280,7 +307,9 @@ class MessageRetryService extends ChangeNotifier {
if (_pendingMessages.containsKey(nextMessageId)) { if (_pendingMessages.containsKey(nextMessageId)) {
messageId = nextMessageId; messageId = nextMessageId;
contact = _pendingContacts[nextMessageId]; contact = _pendingContacts[nextMessageId];
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId'); debugPrint(
'Queue-based match (fallback): $ackHashHex → message $messageId',
);
break; break;
} }
} }
@@ -306,16 +335,22 @@ class MessageRetryService extends ChangeNotifier {
final selection = _pendingPathSelections[messageId]; final selection = _pendingPathSelections[messageId];
if (message == null) { if (message == null) {
debugPrint('Message $messageId no longer pending for ACK hash: $ackHashHex'); debugPrint(
'Message $messageId no longer pending for ACK hash: $ackHashHex',
);
_ackHashToMessageId.remove(ackHashHex); _ackHashToMessageId.remove(ackHashHex);
return; return;
} }
// Add this ACK hash to the list of expected ACKs for this message (for history) // Add this ACK hash to the list of expected ACKs for this message (for history)
_expectedAckHashes[messageId] ??= []; _expectedAckHashes[messageId] ??= [];
if (!_expectedAckHashes[messageId]!.any((hash) => listEquals(hash, ackHash))) { if (!_expectedAckHashes[messageId]!.any(
(hash) => listEquals(hash, ackHash),
)) {
_expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash)); _expectedAckHashes[messageId]!.add(Uint8List.fromList(ackHash));
debugPrint('Added ACK hash $ackHashHex to message $messageId (total: ${_expectedAckHashes[messageId]!.length})'); debugPrint(
'Added ACK hash $ackHashHex to message $messageId (total: ${_expectedAckHashes[messageId]!.length})',
);
} }
// Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid // Use device-provided timeout, or calculate from radio settings if timeout is 0 or invalid
@@ -330,8 +365,13 @@ class MessageRetryService extends ChangeNotifier {
} else { } else {
pathLengthValue = contact.pathLength; pathLengthValue = contact.pathLength;
} }
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length); actualTimeout = _calculateTimeoutCallback!(
debugPrint('Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue'); pathLengthValue,
message.text.length,
);
debugPrint(
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
);
} }
final updatedMessage = message.copyWith( final updatedMessage = message.copyWith(
@@ -364,16 +404,22 @@ class MessageRetryService extends ChangeNotifier {
final selection = _pendingPathSelections[messageId]; final selection = _pendingPathSelections[messageId];
if (message == null || contact == null) { if (message == null || contact == null) {
debugPrint('Timeout fired but message $messageId no longer pending (likely already delivered)'); debugPrint(
'Timeout fired but message $messageId no longer pending (likely already delivered)',
);
return; return;
} }
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text; final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
_debugLogService?.warn( _debugLogService?.warn(
'Timeout: No ACK received for "$shortText" to ${contact.name} (attempt ${message.retryCount}) → retrying', 'Timeout: No ACK received for "$shortText" to ${contact.name} (attempt ${message.retryCount}) → retrying',
tag: 'AckHash', tag: 'AckHash',
); );
debugPrint('Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})'); debugPrint(
'Timeout for message $messageId (retry ${message.retryCount}/${maxRetries - 1})',
);
if (message.retryCount < maxRetries - 1) { if (message.retryCount < maxRetries - 1) {
final backoffMs = 1000 * (1 << message.retryCount); final backoffMs = 1000 * (1 << message.retryCount);
@@ -402,7 +448,9 @@ class MessageRetryService extends ChangeNotifier {
if (_pendingMessages.containsKey(messageId)) { if (_pendingMessages.containsKey(messageId)) {
_attemptSend(messageId); _attemptSend(messageId);
} else { } else {
debugPrint('Retry cancelled: message $messageId was delivered while waiting'); debugPrint(
'Retry cancelled: message $messageId was delivered while waiting',
);
} }
}); });
} else { } else {
@@ -420,7 +468,8 @@ class MessageRetryService extends ChangeNotifier {
// Clean up the queue entry for this contact // Clean up the queue entry for this contact
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId); _pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) { if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex); _pendingMessageQueuePerContact.remove(contact.publicKeyHex);
} }
@@ -430,7 +479,13 @@ class MessageRetryService extends ChangeNotifier {
_clearContactPathCallback!(contact); _clearContactPathCallback!(contact);
} }
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, false, null); _recordPathResultFromMessage(
contact.publicKeyHex,
message,
selection,
false,
null,
);
if (_updateMessageCallback != null) { if (_updateMessageCallback != null) {
_updateMessageCallback!(failedMessage); _updateMessageCallback!(failedMessage);
@@ -443,18 +498,22 @@ class MessageRetryService extends ChangeNotifier {
void _moveAckHashesToHistory(String messageId) { void _moveAckHashesToHistory(String messageId) {
final ackHashes = _expectedAckHashes.remove(messageId); final ackHashes = _expectedAckHashes.remove(messageId);
if (ackHashes != null && ackHashes.isNotEmpty) { if (ackHashes != null && ackHashes.isNotEmpty) {
_ackHistory.add(_AckHistoryEntry( _ackHistory.add(
messageId: messageId, _AckHistoryEntry(
ackHashes: ackHashes, messageId: messageId,
timestamp: DateTime.now(), ackHashes: ackHashes,
)); timestamp: DateTime.now(),
),
);
// Trim history to max size (rolling buffer) // Trim history to max size (rolling buffer)
while (_ackHistory.length > maxAckHistorySize) { while (_ackHistory.length > maxAckHistorySize) {
_ackHistory.removeAt(0); _ackHistory.removeAt(0);
} }
debugPrint('Moved ${ackHashes.length} ACK hashes to history for message $messageId (history size: ${_ackHistory.length})'); debugPrint(
'Moved ${ackHashes.length} ACK hashes to history for message $messageId (history size: ${_ackHistory.length})',
);
} }
} }
@@ -462,7 +521,9 @@ class MessageRetryService extends ChangeNotifier {
for (final entry in _ackHistory) { for (final entry in _ackHistory) {
for (final expectedHash in entry.ackHashes) { for (final expectedHash in entry.ackHashes) {
if (listEquals(expectedHash, ackHash)) { if (listEquals(expectedHash, ackHash)) {
debugPrint('Found ACK match in history: messageId=${entry.messageId}, age=${DateTime.now().difference(entry.timestamp).inSeconds}s'); debugPrint(
'Found ACK match in history: messageId=${entry.messageId}, age=${DateTime.now().difference(entry.timestamp).inSeconds}s',
);
return true; return true;
} }
} }
@@ -472,7 +533,9 @@ class MessageRetryService extends ChangeNotifier {
void handleAckReceived(Uint8List ackHash, int tripTimeMs) { void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
String? matchedMessageId; String? matchedMessageId;
final ackHashHex = ackHash.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); final ackHashHex = ackHash
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms'); debugPrint('ACK received: $ackHashHex, trip time: ${tripTimeMs}ms');
@@ -502,7 +565,9 @@ class MessageRetryService extends ChangeNotifier {
tag: 'AckHash', tag: 'AckHash',
); );
// Fallback: Check against ALL expected ACK hashes (from all retry attempts) // Fallback: Check against ALL expected ACK hashes (from all retry attempts)
debugPrint('ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)'); debugPrint(
'ACK not in mapping, checking _expectedAckHashes (${_expectedAckHashes.length} messages)',
);
for (var entry in _expectedAckHashes.entries) { for (var entry in _expectedAckHashes.entries) {
final messageId = entry.key; final messageId = entry.key;
final expectedHashes = entry.value; final expectedHashes = entry.value;
@@ -510,7 +575,9 @@ class MessageRetryService extends ChangeNotifier {
for (final expectedHash in expectedHashes) { for (final expectedHash in expectedHashes) {
if (listEquals(expectedHash, ackHash)) { if (listEquals(expectedHash, ackHash)) {
matchedMessageId = messageId; matchedMessageId = messageId;
debugPrint('Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})'); debugPrint(
'Matched ACK to message via fallback: $matchedMessageId (attempt ${expectedHashes.indexOf(expectedHash)})',
);
break; break;
} }
} }
@@ -524,7 +591,9 @@ class MessageRetryService extends ChangeNotifier {
final contact = _pendingContacts[matchedMessageId]; final contact = _pendingContacts[matchedMessageId];
final selection = _pendingPathSelections[matchedMessageId]; final selection = _pendingPathSelections[matchedMessageId];
final shortText = message.text.length > 20 ? '${message.text.substring(0, 20)}...' : message.text; final shortText = message.text.length > 20
? '${message.text.substring(0, 20)}...'
: message.text;
_debugLogService?.info( _debugLogService?.info(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms', 'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms',
tag: 'AckHash', tag: 'AckHash',
@@ -549,8 +618,11 @@ class MessageRetryService extends ChangeNotifier {
// Clean up the queue entry for this contact (remove any remaining references to this message) // Clean up the queue entry for this contact (remove any remaining references to this message)
if (contact != null) { if (contact != null) {
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(matchedMessageId); _pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) { matchedMessageId,
);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex); _pendingMessageQueuePerContact.remove(contact.publicKeyHex);
} }
} }
@@ -560,7 +632,13 @@ class MessageRetryService extends ChangeNotifier {
} }
if (contact != null) { if (contact != null) {
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, true, tripTimeMs); _recordPathResultFromMessage(
contact.publicKeyHex,
message,
selection,
true,
tripTimeMs,
);
} }
notifyListeners(); notifyListeners();
@@ -663,7 +741,12 @@ class MessageRetryService extends ChangeNotifier {
if (_recordPathResultCallback == null) return; if (_recordPathResultCallback == null) return;
final recordSelection = selection ?? _selectionFromMessage(message); final recordSelection = selection ?? _selectionFromMessage(message);
if (recordSelection == null) return; if (recordSelection == null) return;
_recordPathResultCallback!(contactKey, recordSelection, success, tripTimeMs); _recordPathResultCallback!(
contactKey,
recordSelection,
success,
tripTimeMs,
);
} }
PathSelection? _selectionFromMessage(Message message) { PathSelection? _selectionFromMessage(Message message) {
+14 -9
View File
@@ -6,13 +6,16 @@ class NotificationService {
factory NotificationService() => _instance; factory NotificationService() => _instance;
NotificationService._internal(); NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
bool _isInitialized = false; bool _isInitialized = false;
Future<void> initialize() async { Future<void> initialize() async {
if (_isInitialized) return; if (_isInitialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher'); const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
const iosSettings = DarwinInitializationSettings( const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true, requestAlertPermission: true,
requestBadgePermission: true, requestBadgePermission: true,
@@ -47,16 +50,20 @@ class NotificationService {
} }
// Request Android 13+ notification permission // Request Android 13+ notification permission
final androidPlugin = _notifications.resolvePlatformSpecificImplementation< final androidPlugin = _notifications
AndroidFlutterLocalNotificationsPlugin>(); .resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) { if (androidPlugin != null) {
final granted = await androidPlugin.requestNotificationsPermission(); final granted = await androidPlugin.requestNotificationsPermission();
return granted ?? false; return granted ?? false;
} }
// iOS permissions are requested during initialization // iOS permissions are requested during initialization
final iosPlugin = _notifications.resolvePlatformSpecificImplementation< final iosPlugin = _notifications
IOSFlutterLocalNotificationsPlugin>(); .resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>();
if (iosPlugin != null) { if (iosPlugin != null) {
final granted = await iosPlugin.requestPermissions( final granted = await iosPlugin.requestPermissions(
alert: true, alert: true,
@@ -204,9 +211,7 @@ class NotificationService {
); );
final preview = message.trim(); final preview = message.trim();
final body = preview.isEmpty final body = preview.isEmpty ? 'Received new message' : preview;
? 'Received new message'
: preview;
await _notifications.show( await _notifications.show(
channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch, channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,
+30 -16
View File
@@ -61,7 +61,10 @@ class PathHistoryService extends ChangeNotifier {
int? tripTimeMs, int? tripTimeMs,
}) { }) {
if (selection.useFlood) { if (selection.useFlood) {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats()); final stats = _floodStats.putIfAbsent(
contactPubKeyHex,
() => _FloodStats(),
);
if (success) { if (success) {
stats.successCount += 1; stats.successCount += 1;
if (tripTimeMs != null) stats.lastTripTimeMs = tripTimeMs; if (tripTimeMs != null) stats.lastTripTimeMs = tripTimeMs;
@@ -88,23 +91,28 @@ class PathHistoryService extends ChangeNotifier {
} }
PathSelection getNextAutoPathSelection(String contactPubKeyHex) { PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
final ranked = _getRankedPaths(contactPubKeyHex) final ranked = _getRankedPaths(
.take(_autoRotationTopCount) contactPubKeyHex,
.toList(); ).take(_autoRotationTopCount).toList();
if (ranked.isEmpty) { if (ranked.isEmpty) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true); return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
} }
_trackAccess(contactPubKeyHex); _trackAccess(contactPubKeyHex);
final selections = ranked final selections =
.map((path) => PathSelection( ranked
pathBytes: path.pathBytes, .map(
hopCount: path.hopCount, (path) => PathSelection(
useFlood: false, pathBytes: path.pathBytes,
)) hopCount: path.hopCount,
.toList() useFlood: false,
..add(const PathSelection(pathBytes: [], hopCount: -1, useFlood: true)); ),
)
.toList()
..add(
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
);
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0; final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
final selection = selections[currentIndex % selections.length]; final selection = selections[currentIndex % selections.length];
@@ -241,7 +249,8 @@ class PathHistoryService extends ChangeNotifier {
} }
Future<ContactPathHistory?> _loadHistoryFromStorage( Future<ContactPathHistory?> _loadHistoryFromStorage(
String contactPubKeyHex) async { String contactPubKeyHex,
) async {
return await _storage.loadPathHistory(contactPubKeyHex); return await _storage.loadPathHistory(contactPubKeyHex);
} }
@@ -308,8 +317,10 @@ class PathHistoryService extends ChangeNotifier {
..removeWhere((p) => p.pathBytes.isEmpty); ..removeWhere((p) => p.pathBytes.isEmpty);
ranked.sort((a, b) { ranked.sort((a, b) {
final aRate = (a.successCount + 1) / (a.successCount + a.failureCount + 2); final aRate =
final bRate = (b.successCount + 1) / (b.successCount + b.failureCount + 2); (a.successCount + 1) / (a.successCount + a.failureCount + 2);
final bRate =
(b.successCount + 1) / (b.successCount + b.failureCount + 2);
if (aRate != bRate) return bRate.compareTo(aRate); if (aRate != bRate) return bRate.compareTo(aRate);
if (a.successCount != b.successCount) { if (a.successCount != b.successCount) {
return b.successCount.compareTo(a.successCount); return b.successCount.compareTo(a.successCount);
@@ -329,7 +340,10 @@ class PathHistoryService extends ChangeNotifier {
} }
void _updateFloodStats(String contactPubKeyHex) { void _updateFloodStats(String contactPubKeyHex) {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats()); final stats = _floodStats.putIfAbsent(
contactPubKeyHex,
() => _FloodStats(),
);
stats.lastUsed = DateTime.now(); stats.lastUsed = DateTime.now();
} }
+9 -3
View File
@@ -26,7 +26,9 @@ class RepeaterCommandService {
int retries = maxRetries, int retries = maxRetries,
}) async { }) async {
final repeaterKey = repeater.publicKeyHex; final repeaterKey = repeater.publicKeyHex;
final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey)); final hasPending = _pendingCommands.keys.any(
(id) => id.startsWith(repeaterKey),
);
if (hasPending) { if (hasPending) {
throw Exception('Another command is still awaiting a response.'); throw Exception('Another command is still awaiting a response.');
} }
@@ -84,7 +86,9 @@ class RepeaterCommandService {
attempt: attempt, attempt: attempt,
timestampSeconds: timestampSeconds, timestampSeconds: timestampSeconds,
); );
final responseBytes = frame.length > maxFrameSize ? frame.length : maxFrameSize; final responseBytes = frame.length > maxFrameSize
? frame.length
: maxFrameSize;
final timeoutMs = _connector.calculateTimeout( final timeoutMs = _connector.calculateTimeout(
pathLength: pathLengthValue, pathLength: pathLengthValue,
messageBytes: responseBytes, messageBytes: responseBytes,
@@ -97,7 +101,9 @@ class RepeaterCommandService {
() { () {
final completer = _pendingCommands[commandId]; final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) { if (completer != null && !completer.isCompleted) {
completer.completeError('Command timeout after $timeoutSeconds seconds'); completer.completeError(
'Command timeout after $timeoutSeconds seconds',
);
_cleanup(commandId); _cleanup(commandId);
} }
}, },
+9 -4
View File
@@ -8,7 +8,9 @@ class StorageService {
static const String _repeaterPasswordsKey = 'repeater_passwords'; static const String _repeaterPasswordsKey = 'repeater_passwords';
Future<void> savePathHistory( Future<void> savePathHistory(
String contactPubKeyHex, ContactPathHistory history) async { String contactPubKeyHex,
ContactPathHistory history,
) async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_pathHistoryPrefix$contactPubKeyHex'; final key = '$_pathHistoryPrefix$contactPubKeyHex';
final jsonStr = jsonEncode(history.toJson()); final jsonStr = jsonEncode(history.toJson());
@@ -39,8 +41,9 @@ class StorageService {
Future<void> clearAllPathHistories() async { Future<void> clearAllPathHistories() async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final keys = prefs.getKeys(); final keys = prefs.getKeys();
final pathHistoryKeys = final pathHistoryKeys = keys.where(
keys.where((key) => key.startsWith(_pathHistoryPrefix)); (key) => key.startsWith(_pathHistoryPrefix),
);
for (final key in pathHistoryKeys) { for (final key in pathHistoryKeys) {
await prefs.remove(key); await prefs.remove(key);
@@ -74,7 +77,9 @@ class StorageService {
/// Save a repeater password by public key hex /// Save a repeater password by public key hex
Future<void> saveRepeaterPassword( Future<void> saveRepeaterPassword(
String repeaterPubKeyHex, String password) async { String repeaterPubKeyHex,
String password,
) async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final passwords = await loadRepeaterPasswords(); final passwords = await loadRepeaterPasswords();
passwords[repeaterPubKeyHex] = password; passwords[repeaterPubKeyHex] = password;
+14 -6
View File
@@ -8,7 +8,10 @@ class ChannelMessageStore {
static const String _keyPrefix = 'channel_messages_'; static const String _keyPrefix = 'channel_messages_';
/// Save messages for a specific channel /// Save messages for a specific channel
Future<void> saveChannelMessages(int channelIndex, List<ChannelMessage> messages) async { Future<void> saveChannelMessages(
int channelIndex,
List<ChannelMessage> messages,
) async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$channelIndex'; final key = '$_keyPrefix$channelIndex';
@@ -96,7 +99,8 @@ class ChannelMessageStore {
pathVariants: (json['pathVariants'] as List<dynamic>?) pathVariants: (json['pathVariants'] as List<dynamic>?)
?.map((entry) => Uint8List.fromList(base64Decode(entry as String))) ?.map((entry) => Uint8List.fromList(base64Decode(entry as String)))
.toList(), .toList(),
repeats: (json['repeats'] as List<dynamic>?) repeats:
(json['repeats'] as List<dynamic>?)
?.map((entry) => _repeatFromJson(entry as Map<String, dynamic>)) ?.map((entry) => _repeatFromJson(entry as Map<String, dynamic>))
.toList() ?? .toList() ??
const [], const [],
@@ -105,15 +109,19 @@ class ChannelMessageStore {
replyToMessageId: json['replyToMessageId'] as String?, replyToMessageId: json['replyToMessageId'] as String?,
replyToSenderName: json['replyToSenderName'] as String?, replyToSenderName: json['replyToSenderName'] as String?,
replyToText: json['replyToText'] as String?, replyToText: json['replyToText'] as String?,
reactions: (json['reactions'] as Map<String, dynamic>?)?.map( reactions:
(key, value) => MapEntry(key, value as int), (json['reactions'] as Map<String, dynamic>?)?.map(
) ?? {}, (key, value) => MapEntry(key, value as int),
) ??
{},
); );
} }
Map<String, dynamic> _repeatToJson(Repeat repeat) { Map<String, dynamic> _repeatToJson(Repeat repeat) {
return { return {
'repeaterKey': repeat.repeaterKey != null ? base64Encode(repeat.repeaterKey!) : null, 'repeaterKey': repeat.repeaterKey != null
? base64Encode(repeat.repeaterKey!)
: null,
'repeaterName': repeat.repeaterName, 'repeaterName': repeat.repeaterName,
'tripTimeMs': repeat.tripTimeMs, 'tripTimeMs': repeat.tripTimeMs,
'path': repeat.path?.map((bytes) => base64Encode(bytes)).toList() ?? [], 'path': repeat.path?.map((bytes) => base64Encode(bytes)).toList() ?? [],
+4 -1
View File
@@ -16,7 +16,10 @@ class ChannelOrderStore {
try { try {
final decoded = jsonDecode(raw); final decoded = jsonDecode(raw);
if (decoded is List) { if (decoded is List) {
return decoded.map((value) => value is int ? value : int.tryParse('$value')).whereType<int>().toList(); return decoded
.map((value) => value is int ? value : int.tryParse('$value'))
.whereType<int>()
.toList();
} }
} catch (_) { } catch (_) {
// fall through to legacy parse // fall through to legacy parse
+4 -10
View File
@@ -40,7 +40,7 @@ class CommunityStore {
/// Add a new community /// Add a new community
Future<void> addCommunity(Community community) async { Future<void> addCommunity(Community community) async {
final communities = await loadCommunities(); final communities = await loadCommunities();
// Check if community with same ID already exists // Check if community with same ID already exists
final existingIndex = communities.indexWhere((c) => c.id == community.id); final existingIndex = communities.indexWhere((c) => c.id == community.id);
if (existingIndex >= 0) { if (existingIndex >= 0) {
@@ -49,7 +49,7 @@ class CommunityStore {
} else { } else {
communities.add(community); communities.add(community);
} }
await saveCommunities(communities); await saveCommunities(communities);
} }
@@ -92,10 +92,7 @@ class CommunityStore {
} }
/// Add a hashtag channel to a community /// Add a hashtag channel to a community
Future<void> addHashtagChannel( Future<void> addHashtagChannel(String communityId, String hashtag) async {
String communityId,
String hashtag,
) async {
final community = await getCommunity(communityId); final community = await getCommunity(communityId);
if (community != null) { if (community != null) {
final updated = community.addHashtagChannel(hashtag); final updated = community.addHashtagChannel(hashtag);
@@ -104,10 +101,7 @@ class CommunityStore {
} }
/// Remove a hashtag channel from a community /// Remove a hashtag channel from a community
Future<void> removeHashtagChannel( Future<void> removeHashtagChannel(String communityId, String hashtag) async {
String communityId,
String hashtag,
) async {
final community = await getCommunity(communityId); final community = await getCommunity(communityId);
if (community != null) { if (community != null) {
final updated = community.removeHashtagChannel(hashtag); final updated = community.removeHashtagChannel(hashtag);
+9 -3
View File
@@ -14,7 +14,9 @@ class ContactStore {
try { try {
final jsonList = jsonDecode(jsonStr) as List<dynamic>; final jsonList = jsonDecode(jsonStr) as List<dynamic>;
return jsonList.map((entry) => _fromJson(entry as Map<String, dynamic>)).toList(); return jsonList
.map((entry) => _fromJson(entry as Map<String, dynamic>))
.toList();
} catch (_) { } catch (_) {
return []; return [];
} }
@@ -57,12 +59,16 @@ class ContactStore {
: Uint8List(0), : Uint8List(0),
pathOverride: json['pathOverride'] as int?, pathOverride: json['pathOverride'] as int?,
pathOverrideBytes: json['pathOverrideBytes'] != null pathOverrideBytes: json['pathOverrideBytes'] != null
? Uint8List.fromList(base64Decode(json['pathOverrideBytes'] as String)) ? Uint8List.fromList(
base64Decode(json['pathOverrideBytes'] as String),
)
: null, : null,
latitude: (json['latitude'] as num?)?.toDouble(), latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(), longitude: (json['longitude'] as num?)?.toDouble(),
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs), lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(lastMessageMs ?? lastSeenMs), lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
lastMessageMs ?? lastSeenMs,
),
); );
} }
} }
+21 -8
View File
@@ -7,7 +7,10 @@ import 'prefs_manager.dart';
class MessageStore { class MessageStore {
static const String _keyPrefix = 'messages_'; static const String _keyPrefix = 'messages_';
Future<void> saveMessages(String contactKeyHex, List<Message> messages) async { Future<void> saveMessages(
String contactKeyHex,
List<Message> messages,
) async {
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final key = '$_keyPrefix$contactKeyHex'; final key = '$_keyPrefix$contactKeyHex';
final jsonList = messages.map(_messageToJson).toList(); final jsonList = messages.map(_messageToJson).toList();
@@ -45,12 +48,16 @@ class MessageStore {
'messageId': msg.messageId, 'messageId': msg.messageId,
'retryCount': msg.retryCount, 'retryCount': msg.retryCount,
'estimatedTimeoutMs': msg.estimatedTimeoutMs, 'estimatedTimeoutMs': msg.estimatedTimeoutMs,
'expectedAckHash': msg.expectedAckHash != null ? base64Encode(msg.expectedAckHash!) : null, 'expectedAckHash': msg.expectedAckHash != null
? base64Encode(msg.expectedAckHash!)
: null,
'sentAt': msg.sentAt?.millisecondsSinceEpoch, 'sentAt': msg.sentAt?.millisecondsSinceEpoch,
'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch, 'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch,
'tripTimeMs': msg.tripTimeMs, 'tripTimeMs': msg.tripTimeMs,
'pathLength': msg.pathLength, 'pathLength': msg.pathLength,
'pathBytes': msg.pathBytes.isNotEmpty ? base64Encode(msg.pathBytes) : null, 'pathBytes': msg.pathBytes.isNotEmpty
? base64Encode(msg.pathBytes)
: null,
'reactions': msg.reactions, 'reactions': msg.reactions,
'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey), 'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey),
}; };
@@ -59,7 +66,9 @@ class MessageStore {
Message _messageFromJson(Map<String, dynamic> json) { Message _messageFromJson(Map<String, dynamic> json) {
final rawText = json['text'] as String; final rawText = json['text'] as String;
final isCli = json['isCli'] as bool? ?? false; final isCli = json['isCli'] as bool? ?? false;
final decodedText = isCli ? rawText : (Smaz.tryDecodePrefixed(rawText) ?? rawText); final decodedText = isCli
? rawText
: (Smaz.tryDecodePrefixed(rawText) ?? rawText);
return Message( return Message(
senderKey: Uint8List.fromList(base64Decode(json['senderKey'] as String)), senderKey: Uint8List.fromList(base64Decode(json['senderKey'] as String)),
text: decodedText, text: decodedText,
@@ -84,11 +93,15 @@ class MessageStore {
pathBytes: json['pathBytes'] != null pathBytes: json['pathBytes'] != null
? Uint8List.fromList(base64Decode(json['pathBytes'] as String)) ? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
: Uint8List(0), : Uint8List(0),
reactions: (json['reactions'] as Map<String, dynamic>?)?.map( reactions:
(key, value) => MapEntry(key, value as int), (json['reactions'] as Map<String, dynamic>?)?.map(
) ?? {}, (key, value) => MapEntry(key, value as int),
) ??
{},
fourByteRoomContactKey: json['fourByteRoomContactKey'] != null fourByteRoomContactKey: json['fourByteRoomContactKey'] != null
? Uint8List.fromList(base64Decode(json['fourByteRoomContactKey'] as String)) ? Uint8List.fromList(
base64Decode(json['fourByteRoomContactKey'] as String),
)
: null, : null,
); );
} }
+2 -1
View File
@@ -21,7 +21,8 @@ class PrefsManager {
static SharedPreferences get instance { static SharedPreferences get instance {
if (_instance == null) { if (_instance == null) {
throw StateError( throw StateError(
'PrefsManager not initialized. Call PrefsManager.initialize() in main() before use.'); 'PrefsManager not initialized. Call PrefsManager.initialize() in main() before use.',
);
} }
return _instance!; return _instance!;
} }
+4 -6
View File
@@ -92,8 +92,9 @@ class UnreadStore {
if (_pendingChannelLastRead == null) return; if (_pendingChannelLastRead == null) return;
final prefs = PrefsManager.instance; final prefs = PrefsManager.instance;
final asString = final asString = _pendingChannelLastRead!.map(
_pendingChannelLastRead!.map((key, value) => MapEntry(key.toString(), value)); (key, value) => MapEntry(key.toString(), value),
);
final jsonStr = jsonEncode(asString); final jsonStr = jsonEncode(asString);
await prefs.setString(_channelLastReadKey, jsonStr); await prefs.setString(_channelLastReadKey, jsonStr);
_pendingChannelLastRead = null; _pendingChannelLastRead = null;
@@ -104,9 +105,6 @@ class UnreadStore {
_contactSaveTimer?.cancel(); _contactSaveTimer?.cancel();
_channelSaveTimer?.cancel(); _channelSaveTimer?.cancel();
await Future.wait([ await Future.wait([_flushContactLastRead(), _flushChannelLastRead()]);
_flushContactLastRead(),
_flushChannelLastRead(),
]);
} }
} }
+5 -1
View File
@@ -44,7 +44,11 @@ class AppLogger {
} }
/// Log a message with custom level /// Log a message with custom level
void log(String message, {String tag = 'App', AppDebugLogLevel level = AppDebugLogLevel.info}) { void log(
String message, {
String tag = 'App',
AppDebugLogLevel level = AppDebugLogLevel.info,
}) {
if (_enabled && _service != null) { if (_enabled && _service != null) {
_service!.log(message, tag: tag, level: level); _service!.log(message, tag: tag, level: level);
} }
+1 -4
View File
@@ -29,10 +29,7 @@ BatteryUi batteryUiForPercent(int? percent) {
class BatteryIndicator extends StatefulWidget { class BatteryIndicator extends StatefulWidget {
final MeshCoreConnector connector; final MeshCoreConnector connector;
const BatteryIndicator({ const BatteryIndicator({super.key, required this.connector});
super.key,
required this.connector,
});
@override @override
State<BatteryIndicator> createState() => _BatteryIndicatorState(); State<BatteryIndicator> createState() => _BatteryIndicatorState();
+20 -6
View File
@@ -5,7 +5,11 @@ import '../connector/meshcore_protocol.dart';
/// Debug widget to show the hex dump of a frame /// Debug widget to show the hex dump of a frame
class DebugFrameViewer { class DebugFrameViewer {
static void showFrameDebug(BuildContext context, Uint8List frame, String title) { static void showFrameDebug(
BuildContext context,
Uint8List frame,
String title,
) {
final hexString = frame final hexString = frame
.map((b) => b.toRadixString(16).padLeft(2, '0')) .map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(' '); .join(' ');
@@ -14,16 +18,26 @@ class DebugFrameViewer {
details.writeln(context.l10n.debugFrame_length(frame.length)); details.writeln(context.l10n.debugFrame_length(frame.length));
details.writeln(''); details.writeln('');
details.writeln( details.writeln(
context.l10n.debugFrame_command(frame[0].toRadixString(16).padLeft(2, '0')), context.l10n.debugFrame_command(
frame[0].toRadixString(16).padLeft(2, '0'),
),
); );
if (frame[0] == cmdSendTxtMsg && frame.length > 37) { if (frame[0] == cmdSendTxtMsg && frame.length > 37) {
details.writeln(''); details.writeln('');
details.writeln(context.l10n.debugFrame_textMessageHeader); details.writeln(context.l10n.debugFrame_textMessageHeader);
details.writeln(context.l10n.debugFrame_destinationPubKey(pubKeyToHex(frame.sublist(1, 33))));
details.writeln(context.l10n.debugFrame_timestamp(readUint32LE(frame, 33)));
details.writeln( details.writeln(
context.l10n.debugFrame_flags(frame[37].toRadixString(16).padLeft(2, '0')), context.l10n.debugFrame_destinationPubKey(
pubKeyToHex(frame.sublist(1, 33)),
),
);
details.writeln(
context.l10n.debugFrame_timestamp(readUint32LE(frame, 33)),
);
details.writeln(
context.l10n.debugFrame_flags(
frame[37].toRadixString(16).padLeft(2, '0'),
),
); );
final txtType = (frame[37] >> 2) & 0x03; final txtType = (frame[37] >> 2) & 0x03;
final typeLabel = txtType == txtTypeCliData final typeLabel = txtType == txtTypeCliData
@@ -34,7 +48,7 @@ class DebugFrameViewer {
final textBytes = frame.sublist(38); final textBytes = frame.sublist(38);
final nullIdx = textBytes.indexOf(0); final nullIdx = textBytes.indexOf(0);
final text = String.fromCharCodes( final text = String.fromCharCodes(
nullIdx >= 0 ? textBytes.sublist(0, nullIdx) : textBytes nullIdx >= 0 ? textBytes.sublist(0, nullIdx) : textBytes,
); );
details.writeln(context.l10n.debugFrame_text(text)); details.writeln(context.l10n.debugFrame_text(text));
} }
+4 -12
View File
@@ -7,18 +7,14 @@ class DeviceTile extends StatelessWidget {
final ScanResult scanResult; final ScanResult scanResult;
final VoidCallback onTap; final VoidCallback onTap;
const DeviceTile({ const DeviceTile({super.key, required this.scanResult, required this.onTap});
super.key,
required this.scanResult,
required this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final device = scanResult.device; final device = scanResult.device;
final rssi = scanResult.rssi; final rssi = scanResult.rssi;
final name = device.platformName.isNotEmpty final name = device.platformName.isNotEmpty
? device.platformName ? device.platformName
: scanResult.advertisementData.advName; : scanResult.advertisementData.advName;
return ListTile( return ListTile(
@@ -58,12 +54,8 @@ class DeviceTile extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(icon, color: color), Icon(icon, color: color),
Text( Text('$rssi dBm', style: TextStyle(fontSize: 10, color: color)),
'$rssi dBm',
style: TextStyle(fontSize: 10, color: color),
),
], ],
); );
} }
} }
+196 -26
View File
@@ -5,32 +5,196 @@ import '../l10n/l10n.dart';
class EmojiPicker extends StatelessWidget { class EmojiPicker extends StatelessWidget {
final Function(String) onEmojiSelected; final Function(String) onEmojiSelected;
const EmojiPicker({ const EmojiPicker({super.key, required this.onEmojiSelected});
super.key,
required this.onEmojiSelected,
});
static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥']; static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
static const List<String> smileys = [ static const List<String> smileys = [
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', '😀',
'😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩', '🥳', '😏', '😃',
'😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡', '😄',
'🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶', '😁',
]; '😅',
'😂',
'🤣',
'😊',
'😇',
'🙂',
'🙃',
'😉',
'😌',
'😍',
'🥰',
'😘',
'😗',
'😙',
'😚',
'😋',
'😛',
'😝',
'😜',
'🤪',
'🤨',
'🧐',
'🤓',
'😎',
'🥸',
'🤩',
'🥳',
'😏',
'😒',
'😞',
'😔',
'😟',
'😕',
'🙁',
'😣',
'😖',
'😫',
'😩',
'🥺',
'😢',
'😭',
'😤',
'😠',
'😡',
'🤬',
'🤯',
'😳',
'🥵',
'🥶',
'😱',
'😨',
'😰',
'😥',
'😓',
'🤗',
'🤔',
'🤭',
'🤫',
'🤥',
'😶',
];
static const List<String> gestures = [ static const List<String> gestures = [
'👍', '👎', '👊', '', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘', '👌', '🤌', '🤏', '👈', '👉', '👆', '👍',
'👇', '☝️', '👋', '🤚', '🖐️', '', '🖖', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳', '💪', '👎',
]; '👊',
'',
'🤛',
'🤜',
'🤞',
'✌️',
'🤟',
'🤘',
'👌',
'🤌',
'🤏',
'👈',
'👉',
'👆',
'👇',
'☝️',
'👋',
'🤚',
'🖐️',
'',
'🖖',
'👏',
'🙌',
'👐',
'🤲',
'🤝',
'🙏',
'✍️',
'💅',
'🤳',
'💪',
];
static const List<String> hearts = [ static const List<String> hearts = [
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❤️‍🔥', '❤️‍🩹', '💕', '💞', '💓', '💗', '❤️',
'💖', '💘', '💝', '💟', '💌', '💢', '💥', '💫', '💦', '💨', '🕳️', '💬', '👁️‍🗨️', '🗨️', '🗯️', '💭', '🧡',
]; '💛',
'💚',
'💙',
'💜',
'🖤',
'🤍',
'🤎',
'💔',
'❤️‍🔥',
'❤️‍🩹',
'💕',
'💞',
'💓',
'💗',
'💖',
'💘',
'💝',
'💟',
'💌',
'💢',
'💥',
'💫',
'💦',
'💨',
'🕳️',
'💬',
'👁️‍🗨️',
'🗨️',
'🗯️',
'💭',
];
static const List<String> objects = [ static const List<String> objects = [
'🎉', '🎊', '🎈', '🎁', '🎀', '🪅', '🪆', '🏆', '🥇', '🥈', '🥉', '', '', '🥎', '🏀', '🏐', '🎉',
'🏈', '🏉', '🎾', '🥏', '🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅', '', '🔥', '🎊',
'', '🌟', '', '', '💡', '🔦', '🏮', '🪔', '📱', '💻', '', '📷', '📺', '📻', '🎵', '🎶', '🚀', '🎈',
]; '🎁',
'🎀',
'🪅',
'🪆',
'🏆',
'🥇',
'🥈',
'🥉',
'',
'',
'🥎',
'🏀',
'🏐',
'🏈',
'🏉',
'🎾',
'🥏',
'🎳',
'🏏',
'🏑',
'🏒',
'🥍',
'🏓',
'🏸',
'🥊',
'🥋',
'🥅',
'',
'🔥',
'',
'🌟',
'',
'',
'💡',
'🔦',
'🏮',
'🪔',
'📱',
'💻',
'',
'📷',
'📺',
'📻',
'🎵',
'🎶',
'🚀',
];
Map<String, List<String>> _emojiCategories(AppLocalizations l10n) { Map<String, List<String>> _emojiCategories(AppLocalizations l10n) {
return { return {
@@ -60,7 +224,10 @@ class EmojiPicker extends StatelessWidget {
children: [ children: [
Text( Text(
l10n.chat_addReaction, l10n.chat_addReaction,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
), ),
IconButton( IconButton(
icon: const Icon(Icons.close), icon: const Icon(Icons.close),
@@ -83,7 +250,9 @@ class EmojiPicker extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer, color: Theme.of(
context,
).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: Text(
@@ -114,11 +283,12 @@ class EmojiPicker extends StatelessWidget {
.map( .map(
(emojis) => GridView.builder( (emojis) => GridView.builder(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate:
crossAxisCount: 8, const SliverGridDelegateWithFixedCrossAxisCount(
mainAxisSpacing: 8, crossAxisCount: 8,
crossAxisSpacing: 8, mainAxisSpacing: 8,
), crossAxisSpacing: 8,
),
itemCount: emojis.length, itemCount: emojis.length,
itemBuilder: (context, index) => InkWell( itemBuilder: (context, index) => InkWell(
onTap: () { onTap: () {
+2 -8
View File
@@ -23,10 +23,7 @@ class EmptyState extends StatelessWidget {
children: [ children: [
Icon(icon, size: 64, color: Colors.grey[400]), Icon(icon, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(title, style: TextStyle(fontSize: 16, color: Colors.grey[600])),
title,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
if (subtitle != null) ...[ if (subtitle != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
@@ -35,10 +32,7 @@ class EmptyState extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
if (action != null) ...[ if (action != null) ...[const SizedBox(height: 24), action!],
const SizedBox(height: 24),
action!,
],
], ],
), ),
); );
+27 -33
View File
@@ -7,10 +7,7 @@ import '../l10n/l10n.dart';
class GifPicker extends StatefulWidget { class GifPicker extends StatefulWidget {
final Function(String gifId) onGifSelected; final Function(String gifId) onGifSelected;
const GifPicker({ const GifPicker({super.key, required this.onGifSelected});
super.key,
required this.onGifSelected,
});
@override @override
State<GifPicker> createState() => _GifPickerState(); State<GifPicker> createState() => _GifPickerState();
@@ -45,11 +42,13 @@ class _GifPickerState extends State<GifPicker> {
}); });
try { try {
final response = await http.get( final response = await http
Uri.parse( .get(
'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=25&rating=g', Uri.parse(
), 'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=25&rating=g',
).timeout(const Duration(seconds: 10)); ),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = json.decode(response.body); final data = json.decode(response.body);
@@ -85,11 +84,13 @@ class _GifPickerState extends State<GifPicker> {
}); });
try { try {
final response = await http.get( final response = await http
Uri.parse( .get(
'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(query)}&limit=25&rating=g', Uri.parse(
), 'https://api.giphy.com/v1/gifs/search?api_key=$_giphyApiKey&q=${Uri.encodeComponent(query)}&limit=25&rating=g',
).timeout(const Duration(seconds: 10)); ),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = json.decode(response.body); final data = json.decode(response.body);
@@ -127,7 +128,10 @@ class _GifPickerState extends State<GifPicker> {
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
context.l10n.gifPicker_title, context.l10n.gifPicker_title,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
), ),
const Spacer(), const Spacer(),
IconButton( IconButton(
@@ -170,18 +174,13 @@ class _GifPickerState extends State<GifPicker> {
const SizedBox(height: 16), const SizedBox(height: 16),
// GIF grid // GIF grid
Expanded( Expanded(child: _buildContent()),
child: _buildContent(),
),
// Powered by Giphy attribution // Powered by Giphy attribution
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
context.l10n.gifPicker_poweredBy, context.l10n.gifPicker_poweredBy,
style: TextStyle( style: TextStyle(fontSize: 11, color: Colors.grey[600]),
fontSize: 11,
color: Colors.grey[600],
),
), ),
], ],
), ),
@@ -190,9 +189,7 @@ class _GifPickerState extends State<GifPicker> {
Widget _buildContent() { Widget _buildContent() {
if (_isLoading) { if (_isLoading) {
return const Center( return const Center(child: CircularProgressIndicator());
child: CircularProgressIndicator(),
);
} }
if (_error != null) { if (_error != null) {
@@ -244,7 +241,8 @@ class _GifPickerState extends State<GifPicker> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final gif = _gifs[index]; final gif = _gifs[index];
final gifId = gif['id'] as String; final gifId = gif['id'] as String;
final previewUrl = gif['images']?['fixed_height_small']?['url'] as String?; final previewUrl =
gif['images']?['fixed_height_small']?['url'] as String?;
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
@@ -265,20 +263,16 @@ class _GifPickerState extends State<GifPicker> {
child: CircularProgressIndicator( child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / ? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes! loadingProgress.expectedTotalBytes!
: null, : null,
), ),
); );
}, },
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
return const Center( return const Center(child: Icon(Icons.error_outline));
child: Icon(Icons.error_outline),
);
}, },
) )
: const Center( : const Center(child: Icon(Icons.gif_box)),
child: Icon(Icons.gif_box),
),
), ),
), ),
); );
+1 -4
View File
@@ -4,10 +4,7 @@ import '../helpers/chat_scroll_controller.dart';
class JumpToBottomButton extends StatelessWidget { class JumpToBottomButton extends StatelessWidget {
final ChatScrollController scrollController; final ChatScrollController scrollController;
const JumpToBottomButton({ const JumpToBottomButton({super.key, required this.scrollController});
super.key,
required this.scrollController,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
+6 -16
View File
@@ -1,18 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
enum ContactSortOption { enum ContactSortOption { lastSeen, recentMessages, name }
lastSeen,
recentMessages,
name,
}
enum ContactTypeFilter { enum ContactTypeFilter { all, users, repeaters, rooms }
all,
users,
repeaters,
rooms,
}
class SortFilterMenuOption { class SortFilterMenuOption {
final int value; final int value;
@@ -30,10 +21,7 @@ class SortFilterMenuSection {
final String title; final String title;
final List<SortFilterMenuOption> options; final List<SortFilterMenuOption> options;
const SortFilterMenuSection({ const SortFilterMenuSection({required this.title, required this.options});
required this.title,
required this.options,
});
} }
class SortFilterMenu extends StatelessWidget { class SortFilterMenu extends StatelessWidget {
@@ -62,7 +50,9 @@ class SortFilterMenu extends StatelessWidget {
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
); );
final visibleSections = sections.where((section) => section.options.isNotEmpty).toList(); final visibleSections = sections
.where((section) => section.options.isNotEmpty)
.toList();
final entries = <PopupMenuEntry<int>>[]; final entries = <PopupMenuEntry<int>>[];
for (int i = 0; i < visibleSections.length; i++) { for (int i = 0; i < visibleSections.length; i++) {
final section = visibleSections[i]; final section = visibleSections[i];
+67 -27
View File
@@ -10,15 +10,10 @@ import '../services/path_history_service.dart';
import 'path_selection_dialog.dart'; import 'path_selection_dialog.dart';
class PathManagementDialog { class PathManagementDialog {
static Future<void> show( static Future<void> show(BuildContext context, {required Contact contact}) {
BuildContext context, {
required Contact contact,
}) {
return showDialog<void>( return showDialog<void>(
context: context, context: context,
builder: (context) => _PathManagementDialog( builder: (context) => _PathManagementDialog(contact: contact),
contact: contact,
),
); );
} }
} }
@@ -26,9 +21,7 @@ class PathManagementDialog {
class _PathManagementDialog extends StatelessWidget { class _PathManagementDialog extends StatelessWidget {
final Contact contact; final Contact contact;
const _PathManagementDialog({ const _PathManagementDialog({required this.contact});
required this.contact,
});
Contact _resolveContact(MeshCoreConnector connector) { Contact _resolveContact(MeshCoreConnector connector) {
return connector.contacts.firstWhere( return connector.contacts.firstWhere(
@@ -83,7 +76,9 @@ class _PathManagementDialog extends StatelessWidget {
Contact currentContact, Contact currentContact,
) async { ) async {
final l10n = context.l10n; final l10n = context.l10n;
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) { if (currentContact.pathLength > 0 &&
currentContact.path.isEmpty &&
connector.isConnected) {
connector.getContacts(); connector.getContacts();
} }
@@ -140,13 +135,19 @@ class _PathManagementDialog extends StatelessWidget {
if (paths.isNotEmpty) ...[ if (paths.isNotEmpty) ...[
Text( Text(
l10n.chat_recentAckPaths, l10n.chat_recentAckPaths,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
), ),
if (paths.length >= 100) ...[ if (paths.length >= 100) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.amberAccent, color: Colors.amberAccent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -165,7 +166,9 @@ class _PathManagementDialog extends StatelessWidget {
dense: true, dense: true,
leading: CircleAvatar( leading: CircleAvatar(
radius: 16, radius: 16,
backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green, backgroundColor: path.wasFloodDiscovery
? Colors.blue
: Colors.green,
child: Text( child: Text(
'${path.hopCount}', '${path.hopCount}',
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
@@ -193,16 +196,27 @@ class _PathManagementDialog extends StatelessWidget {
}, },
), ),
path.wasFloodDiscovery path.wasFloodDiscovery
? const Icon(Icons.waves, size: 16, color: Colors.grey) ? const Icon(
: const Icon(Icons.route, size: 16, color: Colors.grey), Icons.waves,
size: 16,
color: Colors.grey,
)
: const Icon(
Icons.route,
size: 16,
color: Colors.grey,
),
], ],
), ),
onLongPress: () => _showFullPathDialog(context, path.pathBytes), onLongPress: () =>
_showFullPathDialog(context, path.pathBytes),
onTap: () async { onTap: () async {
if (path.pathBytes.isEmpty) { if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(l10n.chat_pathDetailsNotAvailable), content: Text(
l10n.chat_pathDetailsNotAvailable,
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -222,7 +236,9 @@ class _PathManagementDialog extends StatelessWidget {
Navigator.pop(context); Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(l10n.path_usingHopsPath(path.hopCount)), content: Text(
l10n.path_usingHopsPath(path.hopCount),
),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
@@ -238,7 +254,10 @@ class _PathManagementDialog extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
l10n.chat_pathActions, l10n.chat_pathActions,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12), style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
ListTile( ListTile(
@@ -248,8 +267,14 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.purple, backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16), child: Icon(Icons.edit_road, size: 16),
), ),
title: Text(l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)), title: Text(
subtitle: Text(l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)), l10n.chat_setCustomPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
l10n.chat_setCustomPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async { onTap: () async {
await _setCustomPath(context, connector, currentContact); await _setCustomPath(context, connector, currentContact);
}, },
@@ -261,8 +286,14 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.orange, backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16), child: Icon(Icons.clear_all, size: 16),
), ),
title: Text(l10n.chat_clearPath, style: const TextStyle(fontSize: 14)), title: Text(
subtitle: Text(l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)), l10n.chat_clearPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
l10n.chat_clearPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async { onTap: () async {
await connector.clearContactPath(currentContact); await connector.clearContactPath(currentContact);
if (!context.mounted) return; if (!context.mounted) return;
@@ -282,10 +313,19 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.blue, backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16), child: Icon(Icons.waves, size: 16),
), ),
title: Text(l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)), title: Text(
subtitle: Text(l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)), l10n.chat_forceFloodMode,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
l10n.chat_floodModeSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async { onTap: () async {
await connector.setPathOverride(currentContact, pathLen: -1); await connector.setPathOverride(
currentContact,
pathLen: -1,
);
if (!context.mounted) return; if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
+48 -16
View File
@@ -70,12 +70,15 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
} }
void _updateTextFromContacts() { void _updateTextFromContacts() {
final pathParts = _selectedContacts.map((contact) { final pathParts = _selectedContacts
if (contact.publicKeyHex.length >= 2) { .map((contact) {
return contact.publicKeyHex.substring(0, 2); if (contact.publicKeyHex.length >= 2) {
} return contact.publicKeyHex.substring(0, 2);
return ''; }
}).where((s) => s.isNotEmpty).toList(); return '';
})
.where((s) => s.isNotEmpty)
.toList();
_controller.text = pathParts.join(','); _controller.text = pathParts.join(',');
} }
@@ -107,7 +110,11 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
} }
// Parse comma-separated hex prefixes // Parse comma-separated hex prefixes
final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); final pathIds = path
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
final pathBytesList = <int>[]; final pathBytesList = <int>[];
final invalidPrefixes = <String>[]; final invalidPrefixes = <String>[];
@@ -132,7 +139,9 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
if (invalidPrefixes.isNotEmpty) { if (invalidPrefixes.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(l10n.path_invalidHexPrefixes(invalidPrefixes.join(", "))), content: Text(
l10n.path_invalidHexPrefixes(invalidPrefixes.join(", ")),
),
duration: const Duration(seconds: 3), duration: const Duration(seconds: 3),
backgroundColor: Colors.red, backgroundColor: Colors.red,
), ),
@@ -180,7 +189,10 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
children: [ children: [
Text( Text(
l10n.path_currentPathLabel, l10n.path_currentPathLabel,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
), ),
const Spacer(), const Spacer(),
if (widget.onRefresh != null) if (widget.onRefresh != null)
@@ -225,7 +237,10 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
children: [ children: [
Text( Text(
l10n.path_selectFromContacts, l10n.path_selectFromContacts,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
), ),
const Spacer(), const Spacer(),
if (_selectedContacts.isNotEmpty) if (_selectedContacts.isNotEmpty)
@@ -242,7 +257,11 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
children: [ children: [
const Icon(Icons.info_outline, size: 48, color: Colors.grey), const Icon(
Icons.info_outline,
size: 48,
color: Colors.grey,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
l10n.path_noRepeatersFound, l10n.path_noRepeatersFound,
@@ -252,7 +271,10 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
l10n.path_customPathsRequire, l10n.path_customPathsRequire,
style: const TextStyle(fontSize: 12, color: Colors.grey), style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
], ],
@@ -275,20 +297,30 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
radius: 16, radius: 16,
backgroundColor: isSelected backgroundColor: isSelected
? Colors.green ? Colors.green
: (contact.type == 2 ? Colors.blue : Colors.purple), : (contact.type == 2
? Colors.blue
: Colors.purple),
child: Icon( child: Icon(
contact.type == 2 ? Icons.router : Icons.meeting_room, contact.type == 2
? Icons.router
: Icons.meeting_room,
size: 16, size: 16,
color: Colors.white, color: Colors.white,
), ),
), ),
title: Text(contact.name, style: const TextStyle(fontSize: 14)), title: Text(
contact.name,
style: const TextStyle(fontSize: 14),
),
subtitle: Text( subtitle: Text(
'${contact.typeLabel}${contact.publicKeyHex.substring(0, 2)}', '${contact.typeLabel}${contact.publicKeyHex.substring(0, 2)}',
style: const TextStyle(fontSize: 10), style: const TextStyle(fontSize: 10),
), ),
trailing: isSelected trailing: isSelected
? const Icon(Icons.check_circle, color: Colors.green) ? const Icon(
Icons.check_circle,
color: Colors.green,
)
: const Icon(Icons.add_circle_outline), : const Icon(Icons.add_circle_outline),
onTap: () => _toggleContact(contact), onTap: () => _toggleContact(contact),
); );
+64 -50
View File
@@ -8,13 +8,9 @@ import '../connector/meshcore_protocol.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../widgets/snr_indicator.dart'; import '../widgets/snr_indicator.dart';
import '../l10n/l10n.dart'; import '../l10n/l10n.dart';
class PathTraceDialog extends StatefulWidget {
const PathTraceDialog({ class PathTraceDialog extends StatefulWidget {
super.key, const PathTraceDialog({super.key, required this.title, required this.path});
required this.title,
required this.path,
});
final String title; final String title;
final Uint8List path; final Uint8List path;
@@ -31,7 +27,7 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
bool _failed2Loaded = false; bool _failed2Loaded = false;
bool _hasData = false; bool _hasData = false;
Uint8List _pathData = Uint8List(0); Uint8List _pathData = Uint8List(0);
Uint8List _snrData = Uint8List(0) ; Uint8List _snrData = Uint8List(0);
Map<int, Contact> _pathContacts = {}; Map<int, Contact> _pathContacts = {};
@override @override
@@ -49,13 +45,13 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
} }
Future<void> _doPathTrace() async { Future<void> _doPathTrace() async {
if(mounted) { if (mounted) {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_failed2Loaded = false; _failed2Loaded = false;
}); });
} }
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildTraceReq( final frame = buildTraceReq(
DateTime.now().millisecondsSinceEpoch ~/ 1000, DateTime.now().millisecondsSinceEpoch ~/ 1000,
@@ -92,18 +88,19 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
} }
// Check if it's a binary response // Check if it's a binary response
if (code == pushCodeTraceData && listEquals(frame.sublist(4, 8), tagData)) { if (code == pushCodeTraceData &&
listEquals(frame.sublist(4, 8), tagData)) {
_timeoutTimer?.cancel(); _timeoutTimer?.cancel();
if (!mounted) return; if (!mounted) return;
frameBuffer.skipBytes(3); //reserved + path length + flag frameBuffer.skipBytes(3); //reserved + path length + flag
if(listEquals(frameBuffer.readBytes(4), tagData)){ if (listEquals(frameBuffer.readBytes(4), tagData)) {
_handleTraceResponse(frame); _handleTraceResponse(frame);
} }
} }
}); });
} }
Future<void> _handleTraceResponse(Uint8List frame)async { Future<void> _handleTraceResponse(Uint8List frame) async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final buffer = BufferReader(frame); final buffer = BufferReader(frame);
@@ -116,9 +113,7 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
Map<int, Contact> pathContacts = {}; Map<int, Contact> pathContacts = {};
connector.contacts.where((c) => c.type != advTypeChat).forEach(( connector.contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
repeater,
) {
for (var neighbourData in pathData) { for (var neighbourData in pathData) {
if (listEquals( if (listEquals(
repeater.publicKey.sublist(0, 1), repeater.publicKey.sublist(0, 1),
@@ -143,21 +138,26 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
if (index == 0) { if (index == 0) {
return context.l10n.pathTrace_you; return context.l10n.pathTrace_you;
} else { } else {
return _pathContacts[_pathData[_pathData.length - 1]]?.name ?? "0x${_pathData[_pathData.length - 1].toRadixString(16).toUpperCase()}"; return _pathContacts[_pathData[_pathData.length - 1]]?.name ??
"0x${_pathData[_pathData.length - 1].toRadixString(16).toUpperCase()}";
} }
} else { } else {
return _pathContacts[_pathData[index-1]]?.name ?? "0x${_pathData[index-1].toRadixString(16).toUpperCase()}"; return _pathContacts[_pathData[index - 1]]?.name ??
"0x${_pathData[index - 1].toRadixString(16).toUpperCase()}";
} }
} }
String formatDirectionSubText(int index) { String formatDirectionSubText(int index) {
if (index == 0 || index == _snrData.length - 1) { if (index == 0 || index == _snrData.length - 1) {
if (index == 0) { if (index == 0) {
return _pathContacts[_pathData[0]]?.name ?? "0x${_pathData[0].toRadixString(16).toUpperCase()}"; return _pathContacts[_pathData[0]]?.name ??
"0x${_pathData[0].toRadixString(16).toUpperCase()}";
} else { } else {
return context.l10n.pathTrace_you; return context.l10n.pathTrace_you;
} }
} else { } else {
return _pathContacts[_pathData[index]]?.name ?? "0x${_pathData[index].toRadixString(16).toUpperCase()}"; return _pathContacts[_pathData[index]]?.name ??
"0x${_pathData[index].toRadixString(16).toUpperCase()}";
} }
} }
@@ -165,47 +165,61 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = context.l10n; final l10n = context.l10n;
return AlertDialog( return AlertDialog(
title: Column( children: [ title: Column(
FittedBox(fit: BoxFit.scaleDown, child: Text(widget.title, style: const TextStyle(fontSize: 24))), children: [
if(_failed2Loaded) FittedBox(
Text(l10n.pathTrace_failed, style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.error),), fit: BoxFit.scaleDown,
child: Text(widget.title, style: const TextStyle(fontSize: 24)),
),
if (_failed2Loaded)
Text(
l10n.pathTrace_failed,
style: TextStyle(
fontSize: 14,
color: Theme.of(context).colorScheme.error,
),
),
], ],
), ),
content: SafeArea( content: SafeArea(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: _doPathTrace, onRefresh: _doPathTrace,
child: !_hasData child: !_hasData
? Center( ? Center(child: Text(l10n.pathTrace_notAvailable))
child: Text(l10n.pathTrace_notAvailable), : ListView.builder(
) itemCount: _snrData.length,
: ListView.builder( itemBuilder: (context, index) {
itemCount: _snrData.length, return Column(
itemBuilder: (context, index) { children: [
return Column( ListTile(
children: [ leading: index >= _snrData.length / 2
ListTile( ? Icon(Icons.call_received)
leading: index >= _snrData.length / 2 ? Icon(Icons.call_received) : Icon(Icons.call_made), : Icon(Icons.call_made),
title: Text( title: Text(
formatDirectionText(index), style: const TextStyle(fontSize: 14), formatDirectionText(index),
), style: const TextStyle(fontSize: 14),
subtitle: Text(
formatDirectionSubText(index),
style: const TextStyle(fontSize: 14),
),
trailing: SNRIcon(snr: _snrData[index].toSigned(8) / 4.0),
onTap: () {
// Handle item tap
},
), ),
if (index < _snrData.length - 1) const Divider(height: 0.0), subtitle: Text(
], formatDirectionSubText(index),
); style: const TextStyle(fontSize: 14),
}, ),
), trailing: SNRIcon(
snr: _snrData[index].toSigned(8) / 4.0,
),
onTap: () {
// Handle item tap
},
),
if (index < _snrData.length - 1)
const Divider(height: 0.0),
],
);
},
),
), ),
), ),
actions: [ actions: [
IconButton( IconButton(
icon: _isLoading icon: _isLoading
? const SizedBox( ? const SizedBox(
width: 20, width: 20,
+2 -8
View File
@@ -121,10 +121,7 @@ class QrCodeDisplay extends StatelessWidget {
size: size, size: size,
backgroundColor: bgColor, backgroundColor: bgColor,
errorCorrectionLevel: errorCorrectionLevel, errorCorrectionLevel: errorCorrectionLevel,
eyeStyle: QrEyeStyle( eyeStyle: QrEyeStyle(eyeShape: QrEyeShape.square, color: fgColor),
eyeShape: QrEyeShape.square,
color: fgColor,
),
dataModuleStyle: QrDataModuleStyle( dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square, dataModuleShape: QrDataModuleShape.square,
color: fgColor, color: fgColor,
@@ -143,10 +140,7 @@ class QrCodeDisplay extends StatelessWidget {
backgroundColor: bgColor, backgroundColor: bgColor,
// Use higher error correction when embedding image // Use higher error correction when embedding image
errorCorrectionLevel: QrErrorCorrectLevel.H, errorCorrectionLevel: QrErrorCorrectLevel.H,
eyeStyle: QrEyeStyle( eyeStyle: QrEyeStyle(eyeShape: QrEyeShape.square, color: fgColor),
eyeShape: QrEyeShape.square,
color: fgColor,
),
dataModuleStyle: QrDataModuleStyle( dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square, dataModuleShape: QrDataModuleShape.square,
color: fgColor, color: fgColor,
+6 -10
View File
@@ -215,10 +215,7 @@ class _QrScannerWidgetState extends State<QrScannerWidget>
), ),
child: Text( child: Text(
widget.instructions!, widget.instructions!,
style: const TextStyle( style: const TextStyle(color: Colors.white, fontSize: 14),
color: Colors.white,
fontSize: 14,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
@@ -274,7 +271,8 @@ class _QrScannerWidgetState extends State<QrScannerWidget>
switch (error.errorCode) { switch (error.errorCode) {
case MobileScannerErrorCode.permissionDenied: case MobileScannerErrorCode.permissionDenied:
message = 'Camera permission denied.\nPlease enable camera access in settings.'; message =
'Camera permission denied.\nPlease enable camera access in settings.';
icon = Icons.no_photography; icon = Icons.no_photography;
break; break;
case MobileScannerErrorCode.unsupported: case MobileScannerErrorCode.unsupported:
@@ -282,7 +280,8 @@ class _QrScannerWidgetState extends State<QrScannerWidget>
icon = Icons.videocam_off; icon = Icons.videocam_off;
break; break;
default: default:
message = 'Failed to start camera.\n${error.errorDetails?.message ?? ''}'; message =
'Failed to start camera.\n${error.errorDetails?.message ?? ''}';
icon = Icons.error_outline; icon = Icons.error_outline;
} }
@@ -297,10 +296,7 @@ class _QrScannerWidgetState extends State<QrScannerWidget>
Text( Text(
message, message,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(color: Colors.grey[600], fontSize: 16),
color: Colors.grey[600],
fontSize: 16,
),
), ),
], ],
), ),
+178 -148
View File
@@ -44,8 +44,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
} }
Future<void> _loadSavedPassword() async { Future<void> _loadSavedPassword() async {
final savedPassword = final savedPassword = await _storage.getRepeaterPassword(
await _storage.getRepeaterPassword(widget.repeater.publicKeyHex); widget.repeater.publicKeyHex,
);
if (savedPassword != null) { if (savedPassword != null) {
setState(() { setState(() {
_passwordController.text = savedPassword; _passwordController.text = savedPassword;
@@ -102,12 +103,10 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
); );
final timeoutSeconds = (timeoutMs / 1000).ceil(); final timeoutSeconds = (timeoutMs / 1000).ceil();
final timeout = Duration(milliseconds: timeoutMs); final timeout = Duration(milliseconds: timeoutMs);
final selectionLabel = final selectionLabel = selection.useFlood
selection.useFlood ? 'flood' : '${selection.hopCount} hops'; ? 'flood'
appLogger.info( : '${selection.hopCount} hops';
'Login routing: $selectionLabel', appLogger.info('Login routing: $selectionLabel', tag: 'RepeaterLogin');
tag: 'RepeaterLogin',
);
bool? loginResult; bool? loginResult;
for (int attempt = 0; attempt < _maxAttempts; attempt++) { for (int attempt = 0; attempt < _maxAttempts; attempt++) {
if (!mounted) return; if (!mounted) return;
@@ -119,9 +118,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
'Sending login attempt ${attempt + 1}/$_maxAttempts', 'Sending login attempt ${attempt + 1}/$_maxAttempts',
tag: 'RepeaterLogin', tag: 'RepeaterLogin',
); );
await _connector.sendFrame( await _connector.sendFrame(loginFrame);
loginFrame,
);
loginResult = await _awaitLoginResponse(timeout); loginResult = await _awaitLoginResponse(timeout);
if (loginResult == true) { if (loginResult == true) {
@@ -171,7 +168,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
// Save password if requested // Save password if requested
if (_savePassword) { if (_savePassword) {
await _storage.saveRepeaterPassword( await _storage.saveRepeaterPassword(
widget.repeater.publicKeyHex, password); widget.repeater.publicKeyHex,
password,
);
} else { } else {
// Remove saved password if user unchecked the box // Remove saved password if user unchecked the box
await _storage.removeRepeaterPassword(widget.repeater.publicKeyHex); await _storage.removeRepeaterPassword(widget.repeater.publicKeyHex);
@@ -269,152 +268,183 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
l10n.login_repeaterDescription, l10n.login_repeaterDescription,
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
if (_loginError != null) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error, size: 18, color: Theme.of(context).colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(
_loginError!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 13,
),
),
),
],
),
const SizedBox(height: 12),
],
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: l10n.login_password,
hintText: l10n.login_enterPassword,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
onChanged: (_) {
if (_loginError != null && mounted) {
setState(() {
_loginError = null;
});
}
},
onSubmitted: (_) => _handleLogin(),
autofocus: !(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS) &&
_passwordController.text.isEmpty,
),
const SizedBox(height: 12),
CheckboxListTile(
value: _savePassword,
onChanged: (value) {
setState(() {
_savePassword = value ?? false;
});
},
title: Text(
l10n.login_savePassword,
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
subtitle: Text( const SizedBox(height: 16),
l10n.login_savePasswordSubtitle, if (_loginError != null) ...[
style: const TextStyle(fontSize: 12), Row(
), crossAxisAlignment: CrossAxisAlignment.start,
controlAffinity: ListTileControlAffinity.leading, children: [
contentPadding: EdgeInsets.zero, Icon(
), Icons.error,
const Divider(), size: 18,
Row( color: Theme.of(context).colorScheme.error,
children: [
Text(
l10n.login_routing,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
const Spacer(),
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.login_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
l10n.login_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
), ),
PopupMenuItem( const SizedBox(width: 8),
value: 'flood', Expanded(
child: Row( child: Text(
children: [ _loginError!,
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null), style: TextStyle(
const SizedBox(width: 8), color: Theme.of(context).colorScheme.error,
Text( fontSize: 13,
l10n.login_forceFloodMode, ),
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
), ),
), ),
], ],
), ),
const SizedBox(height: 12),
], ],
), TextField(
const SizedBox(height: 4), controller: _passwordController,
Text( obscureText: _obscurePassword,
repeater.pathLabel, decoration: InputDecoration(
style: const TextStyle(fontSize: 11, color: Colors.grey), labelText: l10n.login_password,
), hintText: l10n.login_enterPassword,
const SizedBox(height: 8), border: const OutlineInputBorder(),
Align( prefixIcon: const Icon(Icons.lock),
alignment: Alignment.centerLeft, suffixIcon: IconButton(
child: TextButton.icon( icon: Icon(
onPressed: () => PathManagementDialog.show(context, contact: repeater), _obscurePassword
icon: const Icon(Icons.timeline, size: 18), ? Icons.visibility
label: Text(l10n.login_managePaths), : Icons.visibility_off,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
onChanged: (_) {
if (_loginError != null && mounted) {
setState(() {
_loginError = null;
});
}
},
onSubmitted: (_) => _handleLogin(),
autofocus:
!(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS) &&
_passwordController.text.isEmpty,
), ),
), const SizedBox(height: 12),
], CheckboxListTile(
value: _savePassword,
onChanged: (value) {
setState(() {
_savePassword = value ?? false;
});
},
title: Text(
l10n.login_savePassword,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
l10n.login_savePasswordSubtitle,
style: const TextStyle(fontSize: 12),
),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const Divider(),
Row(
children: [
Text(
l10n.login_routing,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.login_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(
repeater,
pathLen: -1,
);
} else {
await connector.setPathOverride(
repeater,
pathLen: null,
);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.login_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.login_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
],
),
const SizedBox(height: 4),
Text(
repeater.pathLabel,
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
icon: const Icon(Icons.timeline, size: 18),
label: Text(l10n.login_managePaths),
),
),
],
),
), ),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
+159 -152
View File
@@ -15,11 +15,7 @@ class RoomLoginDialog extends StatefulWidget {
final Contact room; final Contact room;
final Function(String password) onLogin; final Function(String password) onLogin;
const RoomLoginDialog({ const RoomLoginDialog({super.key, required this.room, required this.onLogin});
super.key,
required this.room,
required this.onLogin,
});
@override @override
State<RoomLoginDialog> createState() => _RoomLoginDialogState(); State<RoomLoginDialog> createState() => _RoomLoginDialogState();
@@ -43,8 +39,9 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
} }
Future<void> _loadSavedPassword() async { Future<void> _loadSavedPassword() async {
final savedPassword = final savedPassword = await _storage.getRepeaterPassword(
await _storage.getRepeaterPassword(widget.room.publicKeyHex); widget.room.publicKeyHex,
);
if (savedPassword != null) { if (savedPassword != null) {
setState(() { setState(() {
_passwordController.text = savedPassword; _passwordController.text = savedPassword;
@@ -100,12 +97,10 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
); );
final timeoutSeconds = (timeoutMs / 1000).ceil(); final timeoutSeconds = (timeoutMs / 1000).ceil();
final timeout = Duration(milliseconds: timeoutMs); final timeout = Duration(milliseconds: timeoutMs);
final selectionLabel = final selectionLabel = selection.useFlood
selection.useFlood ? 'flood' : '${selection.hopCount} hops'; ? 'flood'
appLogger.info( : '${selection.hopCount} hops';
'Login routing: $selectionLabel', appLogger.info('Login routing: $selectionLabel', tag: 'RoomLogin');
tag: 'RoomLogin',
);
bool? loginResult; bool? loginResult;
for (int attempt = 0; attempt < _maxAttempts; attempt++) { for (int attempt = 0; attempt < _maxAttempts; attempt++) {
if (!mounted) return; if (!mounted) return;
@@ -117,23 +112,15 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
'Sending login attempt ${attempt + 1}/$_maxAttempts', 'Sending login attempt ${attempt + 1}/$_maxAttempts',
tag: 'RoomLogin', tag: 'RoomLogin',
); );
await _connector.sendFrame( await _connector.sendFrame(loginFrame);
loginFrame,
);
loginResult = await _awaitLoginResponse(timeout); loginResult = await _awaitLoginResponse(timeout);
if (loginResult == true) { if (loginResult == true) {
appLogger.info( appLogger.info('Login succeeded for ${room.name}', tag: 'RoomLogin');
'Login succeeded for ${room.name}',
tag: 'RoomLogin',
);
break; break;
} }
if (loginResult == false) { if (loginResult == false) {
appLogger.warn( appLogger.warn('Login failed for ${room.name}', tag: 'RoomLogin');
'Login failed for ${room.name}',
tag: 'RoomLogin',
);
throw Exception('Wrong password or node is unreachable'); throw Exception('Wrong password or node is unreachable');
} }
appLogger.warn( appLogger.warn(
@@ -143,10 +130,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
} }
if (loginResult == null) { if (loginResult == null) {
appLogger.warn( appLogger.warn('Login timed out for ${room.name}', tag: 'RoomLogin');
'Login timed out for ${room.name}',
tag: 'RoomLogin',
);
} }
if (loginResult == true) { if (loginResult == true) {
@@ -162,8 +146,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
// If we got a response, login succeeded // If we got a response, login succeeded
// Save password if requested // Save password if requested
if (_savePassword) { if (_savePassword) {
await _storage.saveRepeaterPassword( await _storage.saveRepeaterPassword(widget.room.publicKeyHex, password);
widget.room.publicKeyHex, password);
} else { } else {
// Remove saved password if user unchecked the box // Remove saved password if user unchecked the box
await _storage.removeRepeaterPassword(widget.room.publicKeyHex); await _storage.removeRepeaterPassword(widget.room.publicKeyHex);
@@ -175,10 +158,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
} }
} catch (e) { } catch (e) {
final room = _resolveRepeater(_connector); final room = _resolveRepeater(_connector);
appLogger.warn( appLogger.warn('Login error for ${room.name}: $e', tag: 'RoomLogin');
'Login error for ${room.name}: $e',
tag: 'RoomLogin',
);
if (mounted) { if (mounted) {
setState(() { setState(() {
_isLoggingIn = false; _isLoggingIn = false;
@@ -262,130 +242,157 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
), ),
) )
: SingleChildScrollView( : SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
l10n.login_roomDescription, l10n.login_roomDescription,
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: l10n.login_password,
hintText: l10n.login_enterPassword,
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
onSubmitted: (_) => _handleLogin(),
autofocus: !(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS) &&
_passwordController.text.isEmpty,
),
const SizedBox(height: 12),
CheckboxListTile(
value: _savePassword,
onChanged: (value) {
setState(() {
_savePassword = value ?? false;
});
},
title: Text(
l10n.login_savePassword,
style: const TextStyle(fontSize: 14), style: const TextStyle(fontSize: 14),
), ),
subtitle: Text( const SizedBox(height: 16),
l10n.login_savePasswordSubtitle, TextField(
style: const TextStyle(fontSize: 12), controller: _passwordController,
), obscureText: _obscurePassword,
controlAffinity: ListTileControlAffinity.leading, decoration: InputDecoration(
contentPadding: EdgeInsets.zero, labelText: l10n.login_password,
), hintText: l10n.login_enterPassword,
const Divider(), border: const OutlineInputBorder(),
Row( prefixIcon: const Icon(Icons.lock),
children: [ suffixIcon: IconButton(
Text( icon: Icon(
l10n.login_routing, _obscurePassword
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold), ? Icons.visibility
), : Icons.visibility_off,
const Spacer(),
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.login_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(repeater, pathLen: -1);
} else {
await connector.setPathOverride(repeater, pathLen: null);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(Icons.auto_mode, size: 20, color: !isFloodMode ? Theme.of(context).primaryColor : null),
const SizedBox(width: 8),
Text(
l10n.login_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
), ),
PopupMenuItem( onPressed: () {
value: 'flood', setState(() {
child: Row( _obscurePassword = !_obscurePassword;
children: [ });
Icon(Icons.waves, size: 20, color: isFloodMode ? Theme.of(context).primaryColor : null), },
const SizedBox(width: 8), ),
Text(
l10n.login_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
],
), ),
], onSubmitted: (_) => _handleLogin(),
), autofocus:
const SizedBox(height: 4), !(defaultTargetPlatform == TargetPlatform.android ||
Text( defaultTargetPlatform == TargetPlatform.iOS) &&
repeater.pathLabel, _passwordController.text.isEmpty,
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => PathManagementDialog.show(context, contact: repeater),
icon: const Icon(Icons.timeline, size: 18),
label: Text(l10n.login_managePaths),
), ),
), const SizedBox(height: 12),
], CheckboxListTile(
value: _savePassword,
onChanged: (value) {
setState(() {
_savePassword = value ?? false;
});
},
title: Text(
l10n.login_savePassword,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
l10n.login_savePasswordSubtitle,
style: const TextStyle(fontSize: 12),
),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const Divider(),
Row(
children: [
Text(
l10n.login_routing,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
PopupMenuButton<String>(
icon: Icon(isFloodMode ? Icons.waves : Icons.route),
tooltip: l10n.login_routingMode,
onSelected: (mode) async {
if (mode == 'flood') {
await connector.setPathOverride(
repeater,
pathLen: -1,
);
} else {
await connector.setPathOverride(
repeater,
pathLen: null,
);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'auto',
child: Row(
children: [
Icon(
Icons.auto_mode,
size: 20,
color: !isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.login_autoUseSavedPath,
style: TextStyle(
fontWeight: !isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
PopupMenuItem(
value: 'flood',
child: Row(
children: [
Icon(
Icons.waves,
size: 20,
color: isFloodMode
? Theme.of(context).primaryColor
: null,
),
const SizedBox(width: 8),
Text(
l10n.login_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
],
),
],
),
const SizedBox(height: 4),
Text(
repeater.pathLabel,
style: const TextStyle(fontSize: 11, color: Colors.grey),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
icon: const Icon(Icons.timeline, size: 18),
label: Text(l10n.login_managePaths),
),
),
],
),
), ),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
+1 -4
View File
@@ -3,10 +3,7 @@ import 'package:flutter/material.dart';
class UnreadBadge extends StatelessWidget { class UnreadBadge extends StatelessWidget {
final int count; final int count;
const UnreadBadge({ const UnreadBadge({super.key, required this.count});
super.key,
required this.count,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
+283 -83
View File
@@ -7,30 +7,50 @@ void main() {
group('reactionEmojis', () { group('reactionEmojis', () {
test('should contain all emoji categories', () { test('should contain all emoji categories', () {
final emojis = ReactionHelper.reactionEmojis; final emojis = ReactionHelper.reactionEmojis;
// Should contain quickEmojis // Should contain quickEmojis
for (final emoji in EmojiPicker.quickEmojis) { for (final emoji in EmojiPicker.quickEmojis) {
expect(emojis.contains(emoji), isTrue, reason: 'Missing quick emoji: $emoji'); expect(
emojis.contains(emoji),
isTrue,
reason: 'Missing quick emoji: $emoji',
);
} }
// Should contain smileys // Should contain smileys
for (final emoji in EmojiPicker.smileys) { for (final emoji in EmojiPicker.smileys) {
expect(emojis.contains(emoji), isTrue, reason: 'Missing smiley: $emoji'); expect(
emojis.contains(emoji),
isTrue,
reason: 'Missing smiley: $emoji',
);
} }
// Should contain gestures // Should contain gestures
for (final emoji in EmojiPicker.gestures) { for (final emoji in EmojiPicker.gestures) {
expect(emojis.contains(emoji), isTrue, reason: 'Missing gesture: $emoji'); expect(
emojis.contains(emoji),
isTrue,
reason: 'Missing gesture: $emoji',
);
} }
// Should contain hearts // Should contain hearts
for (final emoji in EmojiPicker.hearts) { for (final emoji in EmojiPicker.hearts) {
expect(emojis.contains(emoji), isTrue, reason: 'Missing heart: $emoji'); expect(
emojis.contains(emoji),
isTrue,
reason: 'Missing heart: $emoji',
);
} }
// Should contain objects // Should contain objects
for (final emoji in EmojiPicker.objects) { for (final emoji in EmojiPicker.objects) {
expect(emojis.contains(emoji), isTrue, reason: 'Missing object: $emoji'); expect(
emojis.contains(emoji),
isTrue,
reason: 'Missing object: $emoji',
);
} }
}); });
@@ -43,7 +63,7 @@ void main() {
test('should return 2-char hex for valid emoji', () { test('should return 2-char hex for valid emoji', () {
// First emoji (thumbs up) should be index 0 // First emoji (thumbs up) should be index 0
expect(ReactionHelper.emojiToIndex('👍'), equals('00')); expect(ReactionHelper.emojiToIndex('👍'), equals('00'));
// Second emoji (heart) should be index 1 // Second emoji (heart) should be index 1
expect(ReactionHelper.emojiToIndex('❤️'), equals('01')); expect(ReactionHelper.emojiToIndex('❤️'), equals('01'));
}); });
@@ -67,7 +87,10 @@ void main() {
}); });
test('should return null for invalid index', () { test('should return null for invalid index', () {
expect(ReactionHelper.indexToEmoji('ff'), isNull); // Index 255, out of range expect(
ReactionHelper.indexToEmoji('ff'),
isNull,
); // Index 255, out of range
expect(ReactionHelper.indexToEmoji('zz'), isNull); // Invalid hex expect(ReactionHelper.indexToEmoji('zz'), isNull); // Invalid hex
expect(ReactionHelper.indexToEmoji(''), isNull); // Empty string expect(ReactionHelper.indexToEmoji(''), isNull); // Empty string
// Note: indexToEmoji parses any valid hex; length validation is done by parseReaction's regex // Note: indexToEmoji parses any valid hex; length validation is done by parseReaction's regex
@@ -86,78 +109,158 @@ void main() {
final emoji = ReactionHelper.reactionEmojis[i]; final emoji = ReactionHelper.reactionEmojis[i];
final index = ReactionHelper.emojiToIndex(emoji); final index = ReactionHelper.emojiToIndex(emoji);
expect(index, isNotNull, reason: 'emojiToIndex failed for $emoji'); expect(index, isNotNull, reason: 'emojiToIndex failed for $emoji');
final decoded = ReactionHelper.indexToEmoji(index!); final decoded = ReactionHelper.indexToEmoji(index!);
expect(decoded, equals(emoji), reason: 'Round-trip failed for $emoji (index $index)'); expect(
decoded,
equals(emoji),
reason: 'Round-trip failed for $emoji (index $index)',
);
} }
}); });
}); });
group('computeReactionHash', () { group('computeReactionHash', () {
test('should return 4-char hex hash', () { test('should return 4-char hex hash', () {
final hash = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello world'); final hash = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'Hello world',
);
expect(hash, matches(RegExp(r'^[0-9a-f]{4}$'))); expect(hash, matches(RegExp(r'^[0-9a-f]{4}$')));
}); });
test('should be deterministic', () { test('should be deterministic', () {
final hash1 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello'); final hash1 = ReactionHelper.computeReactionHash(
final hash2 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello'); 1234567890,
'Alice',
'Hello',
);
final hash2 = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'Hello',
);
expect(hash1, equals(hash2)); expect(hash1, equals(hash2));
}); });
test('should differ for different inputs', () { test('should differ for different inputs', () {
final hash1 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello'); final hash1 = ReactionHelper.computeReactionHash(
final hash2 = ReactionHelper.computeReactionHash(1234567890, 'Bob', 'Hello'); 1234567890,
final hash3 = ReactionHelper.computeReactionHash(1234567891, 'Alice', 'Hello'); 'Alice',
final hash4 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'World'); 'Hello',
);
final hash2 = ReactionHelper.computeReactionHash(
1234567890,
'Bob',
'Hello',
);
final hash3 = ReactionHelper.computeReactionHash(
1234567891,
'Alice',
'Hello',
);
final hash4 = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'World',
);
expect(hash1, isNot(equals(hash2))); // Different sender expect(hash1, isNot(equals(hash2))); // Different sender
expect(hash1, isNot(equals(hash3))); // Different timestamp expect(hash1, isNot(equals(hash3))); // Different timestamp
expect(hash1, isNot(equals(hash4))); // Different text expect(hash1, isNot(equals(hash4))); // Different text
}); });
test('should use first 5 chars of text', () { test('should use first 5 chars of text', () {
final hash1 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello world'); final hash1 = ReactionHelper.computeReactionHash(
final hash2 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello there'); 1234567890,
'Alice',
'Hello world',
);
final hash2 = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'Hello there',
);
expect(hash1, equals(hash2)); // Same first 5 chars expect(hash1, equals(hash2)); // Same first 5 chars
}); });
test('should handle short text', () { test('should handle short text', () {
final hash = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hi'); final hash = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'Hi',
);
expect(hash, matches(RegExp(r'^[0-9a-f]{4}$'))); expect(hash, matches(RegExp(r'^[0-9a-f]{4}$')));
}); });
test('should handle empty text', () { test('should handle empty text', () {
final hash = ReactionHelper.computeReactionHash(1234567890, 'Alice', ''); final hash = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'',
);
expect(hash, matches(RegExp(r'^[0-9a-f]{4}$'))); expect(hash, matches(RegExp(r'^[0-9a-f]{4}$')));
}); });
}); });
group('computeReactionHash with null sender (1:1 chats)', () { group('computeReactionHash with null sender (1:1 chats)', () {
test('should return 4-char hex hash', () { test('should return 4-char hex hash', () {
final hash = ReactionHelper.computeReactionHash(1234567890, null, 'Hello world'); final hash = ReactionHelper.computeReactionHash(
1234567890,
null,
'Hello world',
);
expect(hash, matches(RegExp(r'^[0-9a-f]{4}$'))); expect(hash, matches(RegExp(r'^[0-9a-f]{4}$')));
}); });
test('should be deterministic', () { test('should be deterministic', () {
final hash1 = ReactionHelper.computeReactionHash(1234567890, null, 'Hello'); final hash1 = ReactionHelper.computeReactionHash(
final hash2 = ReactionHelper.computeReactionHash(1234567890, null, 'Hello'); 1234567890,
null,
'Hello',
);
final hash2 = ReactionHelper.computeReactionHash(
1234567890,
null,
'Hello',
);
expect(hash1, equals(hash2)); expect(hash1, equals(hash2));
}); });
test('should differ for different inputs', () { test('should differ for different inputs', () {
final hash1 = ReactionHelper.computeReactionHash(1234567890, null, 'Hello'); final hash1 = ReactionHelper.computeReactionHash(
final hash2 = ReactionHelper.computeReactionHash(1234567891, null, 'Hello'); 1234567890,
final hash3 = ReactionHelper.computeReactionHash(1234567890, null, 'World'); null,
'Hello',
);
final hash2 = ReactionHelper.computeReactionHash(
1234567891,
null,
'Hello',
);
final hash3 = ReactionHelper.computeReactionHash(
1234567890,
null,
'World',
);
expect(hash1, isNot(equals(hash2))); // Different timestamp expect(hash1, isNot(equals(hash2))); // Different timestamp
expect(hash1, isNot(equals(hash3))); // Different text expect(hash1, isNot(equals(hash3))); // Different text
}); });
test('should differ from hash with sender name', () { test('should differ from hash with sender name', () {
// Null sender hash doesn't include sender, so should differ // Null sender hash doesn't include sender, so should differ
final nullSenderHash = ReactionHelper.computeReactionHash(1234567890, null, 'Hello'); final nullSenderHash = ReactionHelper.computeReactionHash(
final withSenderHash = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello'); 1234567890,
null,
'Hello',
);
final withSenderHash = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'Hello',
);
expect(nullSenderHash, isNot(equals(withSenderHash))); expect(nullSenderHash, isNot(equals(withSenderHash)));
}); });
@@ -167,13 +270,21 @@ void main() {
// Bob computes hash the same way Alice's app will match it // Bob computes hash the same way Alice's app will match it
const timestamp = 1234567890; const timestamp = 1234567890;
const messageText = 'Hello there!'; const messageText = 'Hello there!';
// Bob (sender of reaction) computes hash with null sender // Bob (sender of reaction) computes hash with null sender
final bobHash = ReactionHelper.computeReactionHash(timestamp, null, messageText); final bobHash = ReactionHelper.computeReactionHash(
timestamp,
null,
messageText,
);
// Alice (receiver of reaction) computes hash for her outgoing message // Alice (receiver of reaction) computes hash for her outgoing message
final aliceHash = ReactionHelper.computeReactionHash(timestamp, null, messageText); final aliceHash = ReactionHelper.computeReactionHash(
timestamp,
null,
messageText,
);
expect(bobHash, equals(aliceHash)); expect(bobHash, equals(aliceHash));
}); });
}); });
@@ -188,12 +299,30 @@ void main() {
test('should return null for invalid format', () { test('should return null for invalid format', () {
expect(ReactionHelper.parseReaction('invalid'), isNull); expect(ReactionHelper.parseReaction('invalid'), isNull);
expect(ReactionHelper.parseReaction('r:abc:00'), isNull); // Hash too short expect(
expect(ReactionHelper.parseReaction('r:abcde:00'), isNull); // Hash too long ReactionHelper.parseReaction('r:abc:00'),
expect(ReactionHelper.parseReaction('r:a1b2:0'), isNull); // Index too short isNull,
expect(ReactionHelper.parseReaction('r:a1b2:000'), isNull); // Index too long ); // Hash too short
expect(ReactionHelper.parseReaction('R:a1b2:00'), isNull); // Uppercase R expect(
expect(ReactionHelper.parseReaction('r:A1B2:00'), isNull); // Uppercase hash ReactionHelper.parseReaction('r:abcde:00'),
isNull,
); // Hash too long
expect(
ReactionHelper.parseReaction('r:a1b2:0'),
isNull,
); // Index too short
expect(
ReactionHelper.parseReaction('r:a1b2:000'),
isNull,
); // Index too long
expect(
ReactionHelper.parseReaction('R:a1b2:00'),
isNull,
); // Uppercase R
expect(
ReactionHelper.parseReaction('r:A1B2:00'),
isNull,
); // Uppercase hash
expect(ReactionHelper.parseReaction(''), isNull); expect(ReactionHelper.parseReaction(''), isNull);
}); });
@@ -220,31 +349,43 @@ void main() {
const emoji = '🎉'; const emoji = '🎉';
// Compute hash (sender side) // Compute hash (sender side)
final hash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText); final hash = ReactionHelper.computeReactionHash(
timestamp,
senderName,
messageText,
);
// Encode emoji (sender side) // Encode emoji (sender side)
final emojiIndex = ReactionHelper.emojiToIndex(emoji); final emojiIndex = ReactionHelper.emojiToIndex(emoji);
expect(emojiIndex, isNotNull); expect(emojiIndex, isNotNull);
// Build reaction text (sender side) // Build reaction text (sender side)
final reactionText = 'r:$hash:$emojiIndex'; final reactionText = 'r:$hash:$emojiIndex';
// Parse reaction (receiver side) // Parse reaction (receiver side)
final info = ReactionHelper.parseReaction(reactionText); final info = ReactionHelper.parseReaction(reactionText);
expect(info, isNotNull); expect(info, isNotNull);
expect(info!.targetHash, equals(hash)); expect(info!.targetHash, equals(hash));
expect(info.emoji, equals(emoji)); expect(info.emoji, equals(emoji));
// Verify receiver can match the hash // Verify receiver can match the hash
final receiverHash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText); final receiverHash = ReactionHelper.computeReactionHash(
timestamp,
senderName,
messageText,
);
expect(receiverHash, equals(info.targetHash)); expect(receiverHash, equals(info.targetHash));
}); });
test('reaction text should be 9 bytes', () { test('reaction text should be 9 bytes', () {
final hash = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello'); final hash = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'Hello',
);
final index = ReactionHelper.emojiToIndex('👍')!; final index = ReactionHelper.emojiToIndex('👍')!;
final reactionText = 'r:$hash:$index'; final reactionText = 'r:$hash:$index';
// r: (2) + hash (4) + : (1) + index (2) = 9 bytes // r: (2) + hash (4) + : (1) + index (2) = 9 bytes
expect(reactionText.length, equals(9)); expect(reactionText.length, equals(9));
}); });
@@ -257,7 +398,11 @@ void main() {
const emoji = '👍'; const emoji = '👍';
// On Bob's device: message.isOutgoing = false, so senderName = contact.name = Alice // On Bob's device: message.isOutgoing = false, so senderName = contact.name = Alice
final bobSideHash = ReactionHelper.computeReactionHash(timestamp, aliceName, messageText); final bobSideHash = ReactionHelper.computeReactionHash(
timestamp,
aliceName,
messageText,
);
final emojiIndex = ReactionHelper.emojiToIndex(emoji)!; final emojiIndex = ReactionHelper.emojiToIndex(emoji)!;
final reactionText = 'r:$bobSideHash:$emojiIndex'; final reactionText = 'r:$bobSideHash:$emojiIndex';
@@ -266,8 +411,12 @@ void main() {
expect(info, isNotNull); expect(info, isNotNull);
// On Alice's device: message.isOutgoing = true, so senderName = selfName = Alice // On Alice's device: message.isOutgoing = true, so senderName = selfName = Alice
final aliceSideHash = ReactionHelper.computeReactionHash(timestamp, aliceName, messageText); final aliceSideHash = ReactionHelper.computeReactionHash(
timestamp,
aliceName,
messageText,
);
// Hashes should match! // Hashes should match!
expect(info!.targetHash, equals(aliceSideHash)); expect(info!.targetHash, equals(aliceSideHash));
expect(info.emoji, equals(emoji)); expect(info.emoji, equals(emoji));
@@ -281,7 +430,11 @@ void main() {
const emoji = '❤️'; const emoji = '❤️';
// On Alice's device: message.isOutgoing = false, so senderName = contact.name = Bob // On Alice's device: message.isOutgoing = false, so senderName = contact.name = Bob
final aliceSideHash = ReactionHelper.computeReactionHash(timestamp, bobName, messageText); final aliceSideHash = ReactionHelper.computeReactionHash(
timestamp,
bobName,
messageText,
);
final emojiIndex = ReactionHelper.emojiToIndex(emoji)!; final emojiIndex = ReactionHelper.emojiToIndex(emoji)!;
final reactionText = 'r:$aliceSideHash:$emojiIndex'; final reactionText = 'r:$aliceSideHash:$emojiIndex';
@@ -290,8 +443,12 @@ void main() {
expect(info, isNotNull); expect(info, isNotNull);
// On Bob's device: message.isOutgoing = true, so senderName = selfName = Bob // On Bob's device: message.isOutgoing = true, so senderName = selfName = Bob
final bobSideHash = ReactionHelper.computeReactionHash(timestamp, bobName, messageText); final bobSideHash = ReactionHelper.computeReactionHash(
timestamp,
bobName,
messageText,
);
// Hashes should match! // Hashes should match!
expect(info!.targetHash, equals(bobSideHash)); expect(info!.targetHash, equals(bobSideHash));
expect(info.emoji, equals(emoji)); expect(info.emoji, equals(emoji));
@@ -306,7 +463,11 @@ void main() {
const emoji = '🎉'; const emoji = '🎉';
// Alice computes hash including sender name (room servers are multi-user) // Alice computes hash including sender name (room servers are multi-user)
final aliceHash = ReactionHelper.computeReactionHash(timestamp, charlieName, messageText); final aliceHash = ReactionHelper.computeReactionHash(
timestamp,
charlieName,
messageText,
);
final emojiIndex = ReactionHelper.emojiToIndex(emoji)!; final emojiIndex = ReactionHelper.emojiToIndex(emoji)!;
final reactionText = 'r:$aliceHash:$emojiIndex'; final reactionText = 'r:$aliceHash:$emojiIndex';
@@ -319,36 +480,59 @@ void main() {
expect(info, isNotNull); expect(info, isNotNull);
// Bob computes hash for Charlie's message the same way // Bob computes hash for Charlie's message the same way
final bobHash = ReactionHelper.computeReactionHash(timestamp, charlieName, messageText); final bobHash = ReactionHelper.computeReactionHash(
timestamp,
charlieName,
messageText,
);
// Hashes should match! // Hashes should match!
expect(info!.targetHash, equals(bobHash)); expect(info!.targetHash, equals(bobHash));
expect(info.emoji, equals(emoji)); expect(info.emoji, equals(emoji));
}); });
test('room server: hash differs from 1:1 hash for same message content', () { test(
// Same timestamp and text, but room server includes sender name 'room server: hash differs from 1:1 hash for same message content',
const timestamp = 1234567890; () {
const senderName = 'Dave'; // Same timestamp and text, but room server includes sender name
const messageText = 'Hello'; const timestamp = 1234567890;
const senderName = 'Dave';
const messageText = 'Hello';
// Room server hash (with sender name) // Room server hash (with sender name)
final roomHash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText); final roomHash = ReactionHelper.computeReactionHash(
timestamp,
// 1:1 hash (without sender name) senderName,
final directHash = ReactionHelper.computeReactionHash(timestamp, null, messageText); messageText,
);
// They should be different! // 1:1 hash (without sender name)
expect(roomHash, isNot(equals(directHash))); final directHash = ReactionHelper.computeReactionHash(
}); timestamp,
null,
messageText,
);
// They should be different!
expect(roomHash, isNot(equals(directHash)));
},
);
test('room server: different senders produce different hashes', () { test('room server: different senders produce different hashes', () {
// Two users send the exact same message at the same time in a room // Two users send the exact same message at the same time in a room
const timestamp = 1234567890; const timestamp = 1234567890;
const messageText = 'Hello'; const messageText = 'Hello';
final aliceHash = ReactionHelper.computeReactionHash(timestamp, 'Alice', messageText); final aliceHash = ReactionHelper.computeReactionHash(
final bobHash = ReactionHelper.computeReactionHash(timestamp, 'Bob', messageText); timestamp,
'Alice',
messageText,
);
final bobHash = ReactionHelper.computeReactionHash(
timestamp,
'Bob',
messageText,
);
// Different senders = different hashes (even with same content) // Different senders = different hashes (even with same content)
expect(aliceHash, isNot(equals(bobHash))); expect(aliceHash, isNot(equals(bobHash)));
@@ -363,7 +547,11 @@ void main() {
const emoji = '👍'; const emoji = '👍';
// Bob computes hash for Alice's message // Bob computes hash for Alice's message
final bobHash = ReactionHelper.computeReactionHash(timestamp, aliceName, messageText); final bobHash = ReactionHelper.computeReactionHash(
timestamp,
aliceName,
messageText,
);
final emojiIndex = ReactionHelper.emojiToIndex(emoji)!; final emojiIndex = ReactionHelper.emojiToIndex(emoji)!;
final reactionText = 'r:$bobHash:$emojiIndex'; final reactionText = 'r:$bobHash:$emojiIndex';
@@ -372,8 +560,12 @@ void main() {
expect(info, isNotNull); expect(info, isNotNull);
// Alice computes hash using her selfName // Alice computes hash using her selfName
final aliceHash = ReactionHelper.computeReactionHash(timestamp, aliceName, messageText); final aliceHash = ReactionHelper.computeReactionHash(
timestamp,
aliceName,
messageText,
);
// Hashes should match! // Hashes should match!
expect(info!.targetHash, equals(aliceHash)); expect(info!.targetHash, equals(aliceHash));
}); });
@@ -386,7 +578,11 @@ void main() {
const emoji = '🔥'; const emoji = '🔥';
// Compute hash with sender name // Compute hash with sender name
final hash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText); final hash = ReactionHelper.computeReactionHash(
timestamp,
senderName,
messageText,
);
final emojiIndex = ReactionHelper.emojiToIndex(emoji)!; final emojiIndex = ReactionHelper.emojiToIndex(emoji)!;
final reactionText = 'r:$hash:$emojiIndex'; final reactionText = 'r:$hash:$emojiIndex';
@@ -396,7 +592,11 @@ void main() {
expect(info!.emoji, equals(emoji)); expect(info!.emoji, equals(emoji));
// Another user computes the same hash // Another user computes the same hash
final otherUserHash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText); final otherUserHash = ReactionHelper.computeReactionHash(
timestamp,
senderName,
messageText,
);
expect(info.targetHash, equals(otherUserHash)); expect(info.targetHash, equals(otherUserHash));
}); });
}); });