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

View file

@ -1286,9 +1286,12 @@ class MeshCoreConnector extends ChangeNotifier {
if (reactionInfo != null) {
// Check if we've already processed this reaction
_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
return;
}
@ -1504,7 +1507,9 @@ class MeshCoreConnector extends ChangeNotifier {
// Skip fetching if already loaded and not forced
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;
}
@ -2696,10 +2701,12 @@ class MeshCoreConnector extends ChangeNotifier {
if (reactionInfo != null) {
// Check if we've already processed this exact reaction
_processedContactReactions.putIfAbsent(pubKeyHex, () => {});
final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}';
final reactionIdentifier =
'${reactionInfo.targetHash}_${reactionInfo.emoji}';
final isDuplicate =
_processedContactReactions[pubKeyHex]!.contains(reactionIdentifier);
final isDuplicate = _processedContactReactions[pubKeyHex]!.contains(
reactionIdentifier,
);
if (!isDuplicate) {
// New reaction - process it
@ -2734,20 +2741,22 @@ class MeshCoreConnector extends ChangeNotifier {
for (int i = messages.length - 1; i >= 0; i--) {
final msg = messages[i];
// For 1:1 chats: contact reacts to my outgoing messages only
// For room servers: any message can be reacted to (multi-user)
if (!isRoomServer && !msg.isOutgoing) continue;
final timestampSecs = msg.timestamp.millisecondsSinceEpoch ~/ 1000;
// For room servers, include sender name (resolve from fourByteRoomContactKey)
// For 1:1 chats, sender is implicit (null)
String? senderName;
if (isRoomServer && !msg.isOutgoing) {
// Resolve sender from the message's fourByteRoomContactKey
final senderContact = _contacts.cast<Contact?>().firstWhere(
(c) => c != null && _matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
(c) =>
c != null &&
_matchesPrefix(c.publicKey, msg.fourByteRoomContactKey),
orElse: () => null,
);
senderName = senderContact?.name;
@ -2755,7 +2764,7 @@ class MeshCoreConnector extends ChangeNotifier {
senderName = selfName;
}
// For 1:1, senderName stays null
final msgHash = ReactionHelper.computeReactionHash(
timestampSecs,
senderName,
@ -2919,10 +2928,12 @@ class MeshCoreConnector extends ChangeNotifier {
if (reactionInfo != null) {
// Check if we've already processed this exact reaction
_processedChannelReactions.putIfAbsent(channelIndex, () => {});
final reactionIdentifier = '${reactionInfo.targetHash}_${reactionInfo.emoji}';
final reactionIdentifier =
'${reactionInfo.targetHash}_${reactionInfo.emoji}';
final isDuplicate =
_processedChannelReactions[channelIndex]!.contains(reactionIdentifier);
final isDuplicate = _processedChannelReactions[channelIndex]!.contains(
reactionIdentifier,
);
if (!isDuplicate) {
// New reaction - process it

View file

@ -113,7 +113,9 @@ class BufferWriter {
final hexByte = hex.substring(i * 2, i * 2 + 2);
final byte = int.tryParse(hexByte, radix: 16);
if (byte == null) {
throw FormatException('Invalid hex characters at position $i: $hexByte');
throw FormatException(
'Invalid hex characters at position $i: $hexByte',
);
}
result.add(byte);
}
@ -219,8 +221,10 @@ const int maxFrameSize = 172;
const int appProtocolVersion = 3;
// Matches firmware MAX_TEXT_LEN (10 * CIPHER_BLOCK_SIZE).
const int maxTextPayloadBytes = 160;
const int _sendTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 6 + 1 + 2; // +2 safety margin
const int _sendChannelTextMsgOverheadBytes = 1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin
const int _sendTextMsgOverheadBytes =
1 + 1 + 1 + 4 + 6 + 1 + 2; // +2 safety margin
const int _sendChannelTextMsgOverheadBytes =
1 + 1 + 1 + 4 + 1 + 2; // +2 safety margin
int maxContactMessageBytes() {
final byFrame = maxFrameSize - _sendTextMsgOverheadBytes;
@ -735,8 +739,7 @@ Uint8List buildSendBinaryReq(Uint8List repeaterPubKey, {Uint8List? payload}) {
//Build a trace request frame
//[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();
writer.writeByte(cmdSendTracePath);
writer.writeUInt32LE(tag);

View file

@ -26,9 +26,11 @@ class CayenneLpp {
static const int lppUnixTime = 133; // 4 bytes, unsigned
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 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 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();
@ -201,10 +203,10 @@ class CayenneLpp {
break;
}
final channelData = channels.putIfAbsent(channel, () => {
'channel': channel,
'values': <String, dynamic>{},
});
final channelData = channels.putIfAbsent(
channel,
() => {'channel': channel, 'values': <String, dynamic>{}},
);
switch (type) {
case lppGenericSensor:
@ -254,8 +256,8 @@ class CayenneLpp {
}
}
final List<Map<String, dynamic>> channelsOut = channels.values.toList();
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
return channelsOut;
final List<Map<String, dynamic>> channelsOut = channels.values.toList();
channelsOut.sort((a, b) => a['channel'].compareTo(b['channel']));
return channelsOut;
}
}

View file

@ -26,10 +26,7 @@ class LinkHandler {
),
child: SelectableText(
url,
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
),
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
),
),
],

View file

@ -4,10 +4,7 @@ class ReactionInfo {
final String targetHash;
final String emoji;
ReactionInfo({
required this.targetHash,
required this.emoji,
});
ReactionInfo({required this.targetHash, required this.emoji});
}
class ReactionHelper {
@ -42,7 +39,11 @@ class ReactionHelper {
/// Compute a 4-char hex hash for a message reaction.
/// Hash input: timestampSeconds + [senderName] + first 5 chars of text
/// 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 input = senderName != null
? '$timestampSeconds$senderName$first5'
@ -62,9 +63,6 @@ class ReactionHelper {
final emoji = indexToEmoji(match.group(2)!);
if (emoji == null) return null;
return ReactionInfo(
targetHash: match.group(1)!,
emoji: emoji,
);
return ReactionInfo(targetHash: match.group(1)!, emoji: emoji);
}
}

View file

@ -262,8 +262,9 @@ class Smaz {
".com",
];
static final List<Uint8List> _rcbBytes =
_rcb.map((s) => Uint8List.fromList(ascii.encode(s))).toList(growable: false);
static final List<Uint8List> _rcbBytes = _rcb
.map((s) => Uint8List.fromList(ascii.encode(s)))
.toList(growable: false);
static final int _maxEntryLen = _rcbBytes.fold(0, (maxLen, entry) {
return entry.length > maxLen ? entry.length : maxLen;
});
@ -358,24 +359,32 @@ class Smaz {
final code = input[index];
if (code == _verbatimSingle) {
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]);
index += 2;
} else if (code == _verbatimRun) {
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 end = index + 2 + len;
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));
index = end;
} else {
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]);
index += 1;

View file

@ -8,7 +8,10 @@ class Utf8LengthLimitingTextInputFormatter extends TextInputFormatter {
const Utf8LengthLimitingTextInputFormatter(this.maxBytes);
@override
TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
if (maxBytes <= 0) return oldValue;
final bytes = utf8.encode(newValue.text);
if (bytes.length <= maxBytes) return newValue;

View file

@ -65,16 +65,18 @@ void main() async {
await connector.loadAllChannelMessages();
await connector.loadUnreadState();
runApp(MeshCoreApp(
connector: connector,
retryService: retryService,
pathHistoryService: pathHistoryService,
storage: storage,
appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService,
mapTileCacheService: mapTileCacheService,
));
runApp(
MeshCoreApp(
connector: connector,
retryService: retryService,
pathHistoryService: pathHistoryService,
storage: storage,
appSettingsService: appSettingsService,
bleDebugLogService: bleDebugLogService,
appDebugLogService: appDebugLogService,
mapTileCacheService: mapTileCacheService,
),
);
}
class MeshCoreApp extends StatelessWidget {
@ -124,7 +126,9 @@ class MeshCoreApp extends StatelessWidget {
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
locale: _localeFromSetting(settingsService.settings.languageOverride),
locale: _localeFromSetting(
settingsService.settings.languageOverride,
),
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
@ -142,7 +146,9 @@ class MeshCoreApp extends StatelessWidget {
behavior: SnackBarBehavior.floating,
),
),
themeMode: _themeModeFromSetting(settingsService.settings.themeMode),
themeMode: _themeModeFromSetting(
settingsService.settings.themeMode,
),
home: const ScannerScreen(),
);
},

View file

@ -76,13 +76,14 @@ class AppSettings {
mapShowRepeaters: json['map_show_repeaters'] as bool? ?? true,
mapShowChatNodes: json['map_show_chat_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,
mapKeyPrefix: json['map_key_prefix'] as String? ?? '',
mapShowMarkers: json['map_show_markers'] as bool? ?? true,
mapCacheBounds: (json['map_cache_bounds'] as Map?)?.map(
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
),
(key, value) => MapEntry(key.toString(), (value as num).toDouble()),
),
mapCacheMinZoom: json['map_cache_min_zoom'] as int? ?? 10,
mapCacheMaxZoom: json['map_cache_max_zoom'] as int? ?? 15,
notificationsEnabled: json['notifications_enabled'] as bool? ?? true,
@ -90,11 +91,13 @@ class AppSettings {
notifyOnNewChannelMessage:
json['notify_on_new_channel_message'] as bool? ?? true,
notifyOnNewAdvert: json['notify_on_new_advert'] as bool? ?? true,
autoRouteRotationEnabled: json['auto_route_rotation_enabled'] as bool? ?? false,
autoRouteRotationEnabled:
json['auto_route_rotation_enabled'] as bool? ?? false,
themeMode: json['theme_mode'] as String? ?? 'system',
languageOverride: json['language_override'] as String?,
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()),
) ??
{},
@ -132,8 +135,9 @@ class AppSettings {
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,
mapShowMarkers: mapShowMarkers ?? this.mapShowMarkers,
mapCacheBounds:
mapCacheBounds == _unset ? this.mapCacheBounds : mapCacheBounds as Map<String, double>?,
mapCacheBounds: mapCacheBounds == _unset
? this.mapCacheBounds
: mapCacheBounds as Map<String, double>?,
mapCacheMinZoom: mapCacheMinZoom ?? this.mapCacheMinZoom,
mapCacheMaxZoom: mapCacheMaxZoom ?? this.mapCacheMaxZoom,
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
@ -141,12 +145,15 @@ class AppSettings {
notifyOnNewChannelMessage:
notifyOnNewChannelMessage ?? this.notifyOnNewChannelMessage,
notifyOnNewAdvert: notifyOnNewAdvert ?? this.notifyOnNewAdvert,
autoRouteRotationEnabled: autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
autoRouteRotationEnabled:
autoRouteRotationEnabled ?? this.autoRouteRotationEnabled,
themeMode: themeMode ?? this.themeMode,
languageOverride:
languageOverride == _unset ? this.languageOverride : languageOverride as String?,
languageOverride: languageOverride == _unset
? this.languageOverride
: languageOverride as String?,
appDebugLogEnabled: appDebugLogEnabled ?? this.appDebugLogEnabled,
batteryChemistryByDeviceId: batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
batteryChemistryByDeviceId:
batteryChemistryByDeviceId ?? this.batteryChemistryByDeviceId,
);
}
}

View file

@ -10,11 +10,7 @@ class Channel {
final String name;
final Uint8List psk; // 16 bytes
Channel({
required this.index,
required this.name,
required this.psk,
});
Channel({required this.index, required this.name, required this.psk});
String get pskHex => _bytesToHex(psk);
@ -39,11 +35,7 @@ class Channel {
}
static Channel empty(int index) {
return Channel(
index: index,
name: '',
psk: Uint8List(16),
);
return Channel(index: index, name: '', psk: Uint8List(16));
}
static Channel fromHex(int index, String name, String pskHex) {

View file

@ -59,15 +59,18 @@ class ChannelMessage {
this.replyToSenderName,
this.replyToText,
Map<String, int>? reactions,
}) : messageId = messageId ?? '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}',
reactions = reactions ?? {},
pathBytes = pathBytes ?? Uint8List(0),
pathVariants = _mergePathVariants(
pathBytes ?? Uint8List(0),
pathVariants,
);
}) : messageId =
messageId ??
'${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}',
reactions = reactions ?? {},
pathBytes = pathBytes ?? Uint8List(0),
pathVariants = _mergePathVariants(
pathBytes ?? Uint8List(0),
pathVariants,
);
String? get senderKeyHex => senderKey != null ? pubKeyToHex(senderKey!) : null;
String? get senderKeyHex =>
senderKey != null ? pubKeyToHex(senderKey!) : null;
ChannelMessage copyWith({
ChannelMessageStatus? status,
@ -125,8 +128,10 @@ class ChannelMessage {
final hasPathBytesFlag = (data[2] & 0x01) != 0;
final canFitPath = pathLen > 0 && data.length >= cursor + pathLen + 5;
final hasValidTxtType =
cursor < data.length && (data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) && canFitPath) {
cursor < data.length &&
(data[cursor] == txtTypePlain || data[cursor] == txtTypeCliData);
if ((hasPathBytesFlag || (canFitPath && !hasValidTxtType)) &&
canFitPath) {
pathBytes = Uint8List.fromList(data.sublist(cursor, cursor + pathLen));
cursor += pathLen;
}
@ -162,7 +167,8 @@ class ChannelMessage {
final potentialSender = text.substring(0, colonIndex);
if (!RegExp(r'[:\[\]]').hasMatch(potentialSender)) {
senderName = potentialSender;
final offset = (colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
final offset =
(colonIndex + 1 < text.length && text[colonIndex + 1] == ' ')
? colonIndex + 2
: colonIndex + 1;
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(
senderKey: null,
senderName: senderName,
@ -249,8 +259,5 @@ class ReplyInfo {
final String mentionedNode;
final String actualMessage;
ReplyInfo({
required this.mentionedNode,
required this.actualMessage,
});
ReplyInfo({required this.mentionedNode, required this.actualMessage});
}

View file

@ -34,10 +34,7 @@ class Community {
}) : hashtagChannels = hashtagChannels ?? [];
/// Generate a new community with a random 32-byte secret
factory Community.create({
required String id,
required String name,
}) {
factory Community.create({required String id, required String name}) {
final random = Random.secure();
final secret = Uint8List(32);
for (int i = 0; i < 32; i++) {
@ -84,7 +81,8 @@ class Community {
name: json['name'] as String,
secret: base64Decode(json['secret'] as String),
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)
.toList() ??
[],
@ -234,9 +232,7 @@ class Community {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Community &&
runtimeType == other.runtimeType &&
id == other.id;
other is Community && runtimeType == other.runtimeType && id == other.id;
@override
int get hashCode => id.hashCode;

View file

@ -7,7 +7,8 @@ class Contact {
final int type;
final int pathLength; // -1 = flood, 0+ = direct hops (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 double? latitude;
final double? longitude;
@ -78,8 +79,12 @@ class Contact {
type: type ?? this.type,
pathLength: pathLength ?? this.pathLength,
path: path ?? this.path,
pathOverride: clearPathOverride ? null : (pathOverride ?? this.pathOverride),
pathOverrideBytes: clearPathOverride ? null : (pathOverrideBytes ?? this.pathOverrideBytes),
pathOverride: clearPathOverride
? null
: (pathOverride ?? this.pathOverride),
pathOverrideBytes: clearPathOverride
? null
: (pathOverrideBytes ?? this.pathOverrideBytes),
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
lastSeen: lastSeen ?? this.lastSeen,
@ -93,10 +98,14 @@ class Contact {
final parts = <String>[];
final groupSize = pathHashSize;
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);
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(',');
@ -110,32 +119,32 @@ class Contact {
final pathBytes = _pathBytesForDisplay;
Uint8List? traceBytes;
if(pathLength <= 0) {
if (pathLength <= 0) {
traceBytes = Uint8List(1);
traceBytes[0] = publicKey[0];
return traceBytes;
}
if(type == advTypeRepeater || type == advTypeRoom) {
if (type == advTypeRepeater || type == advTypeRoom) {
final len = (pathBytes.length + pathBytes.length + 1);
traceBytes = Uint8List(len);
traceBytes[pathBytes.length] = publicKey[0];
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length) {
traceBytes[len-1-i] = pathBytes[i];
traceBytes[len - 1 - i] = pathBytes[i];
}
}
} else {
if(pathBytes.length < 2) {
if (pathBytes.length < 2) {
return pathBytes[0] == 0 ? null : pathBytes;
}
final len = (pathBytes.length + pathBytes.length-1);
final len = (pathBytes.length + pathBytes.length - 1);
traceBytes = Uint8List(len);
for (int i = 0; i < pathBytes.length; i++) {
traceBytes[i] = pathBytes[i];
if (i < pathBytes.length-1) {
traceBytes[len-1-i] = pathBytes[i];
if (i < pathBytes.length - 1) {
traceBytes[len - 1 - i] = pathBytes[i];
}
}
}

View file

@ -2,15 +2,9 @@ class ContactGroup {
final String name;
final List<String> memberKeys;
const ContactGroup({
required this.name,
required this.memberKeys,
});
const ContactGroup({required this.name, required this.memberKeys});
ContactGroup copyWith({
String? name,
List<String>? memberKeys,
}) {
ContactGroup copyWith({String? name, List<String>? memberKeys}) {
return ContactGroup(
name: name ?? this.name,
memberKeys: memberKeys ?? List<String>.from(this.memberKeys),
@ -18,16 +12,12 @@ class ContactGroup {
}
Map<String, dynamic> toJson() {
return {
'name': name,
'members': memberKeys,
};
return {'name': name, 'members': memberKeys};
}
factory ContactGroup.fromJson(Map<String, dynamic> json) {
final members = (json['members'] as List?)
?.map((value) => value.toString())
.toList() ??
final members =
(json['members'] as List?)?.map((value) => value.toString()).toList() ??
<String>[];
return ContactGroup(
name: json['name'] as String? ?? '',

View file

@ -43,9 +43,9 @@ class Message {
Uint8List? pathBytes,
Uint8List? fourByteRoomContactKey,
Map<String, int>? reactions,
}) : pathBytes = pathBytes ?? Uint8List(0),
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
reactions = reactions ?? {};
}) : pathBytes = pathBytes ?? Uint8List(0),
fourByteRoomContactKey = fourByteRoomContactKey ?? Uint8List(0),
reactions = reactions ?? {};
String get senderKeyHex => pubKeyToHex(senderKey);
@ -80,7 +80,8 @@ class Message {
pathLength: pathLength ?? this.pathLength,
pathBytes: pathBytes ?? this.pathBytes,
reactions: reactions ?? this.reactions,
fourByteRoomContactKey: fourByteRoomContactKey ?? this.fourByteRoomContactKey,
fourByteRoomContactKey:
fourByteRoomContactKey ?? this.fourByteRoomContactKey,
);
}

View file

@ -38,7 +38,8 @@ class PathRecord {
tripTimeMs: json['trip_time_ms'] as int,
timestamp: DateTime.parse(json['timestamp'] as String),
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,
failureCount: json['failure_count'] as int? ?? 0,
);
@ -65,14 +66,15 @@ class ContactPathHistory {
}
Map<String, dynamic> toJson() {
return {
'recent_paths': recentPaths.map((p) => p.toJson()).toList(),
};
return {'recent_paths': recentPaths.map((p) => p.toJson()).toList()};
}
factory ContactPathHistory.fromJson(
String contactPubKeyHex, Map<String, dynamic> json) {
final pathsList = (json['recent_paths'] as List?)
String contactPubKeyHex,
Map<String, dynamic> json,
) {
final pathsList =
(json['recent_paths'] as List?)
?.map((p) => PathRecord.fromJson(p as Map<String, dynamic>))
.toList() ??
[];

View file

@ -61,44 +61,44 @@ class RadioSettings {
// Preset configurations
static RadioSettings get preset915MHz => RadioSettings(
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
static RadioSettings get preset868MHz => RadioSettings(
frequencyMHz: 868.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 14,
);
frequencyMHz: 868.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 14,
);
static RadioSettings get preset433MHz => RadioSettings(
frequencyMHz: 433.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
frequencyMHz: 433.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
static RadioSettings get presetLongRange => RadioSettings(
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf12,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
);
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw125,
spreadingFactor: LoRaSpreadingFactor.sf12,
codingRate: LoRaCodingRate.cr4_8,
txPowerDbm: 20,
);
static RadioSettings get presetFastSpeed => RadioSettings(
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw500,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
frequencyMHz: 915.0,
bandwidth: LoRaBandwidth.bw500,
spreadingFactor: LoRaSpreadingFactor.sf7,
codingRate: LoRaCodingRate.cr4_5,
txPowerDbm: 20,
);
int get frequencyHz => (frequencyMHz * 1000).round();
int get bandwidthHz => bandwidth.hz;

View file

@ -26,8 +26,10 @@ class AppDebugLogScreen extends StatelessWidget {
onPressed: hasEntries
? () async {
final text = entries
.map((entry) =>
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}')
.map(
(entry) =>
'[${entry.formattedTime}] [${entry.levelLabel}] [${entry.tag}] ${entry.message}',
)
.join('\n');
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
@ -61,11 +63,17 @@ class AppDebugLogScreen extends StatelessWidget {
leading: _buildLevelIcon(entry.level),
title: Text(
'[${entry.tag}] ${entry.message}',
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
style: const TextStyle(
fontSize: 12,
fontFamily: 'monospace',
),
),
subtitle: Text(
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(
mainAxisAlignment: MainAxisAlignment.center,
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),
Text(
context.l10n.debugLog_noEntries,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
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:
return const Icon(Icons.info_outline, size: 18, color: Colors.blue);
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:
return const Icon(Icons.error_outline, size: 18, color: Colors.red);
}

View file

@ -43,7 +43,10 @@ class AppSettingsScreen extends StatelessWidget {
);
}
Widget _buildAppearanceCard(BuildContext context, AppSettingsService settingsService) {
Widget _buildAppearanceCard(
BuildContext context,
AppSettingsService settingsService,
) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -58,7 +61,9 @@ class AppSettingsScreen extends StatelessWidget {
ListTile(
leading: const Icon(Icons.brightness_6_outlined),
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),
onTap: () => _showThemeModeDialog(context, settingsService),
),
@ -66,7 +71,12 @@ class AppSettingsScreen extends StatelessWidget {
ListTile(
leading: const Icon(Icons.language_outlined),
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),
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(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -90,17 +103,22 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile(
secondary: const Icon(Icons.notifications_outlined),
title: Text(context.l10n.appSettings_enableNotifications),
subtitle: Text(context.l10n.appSettings_enableNotificationsSubtitle),
subtitle: Text(
context.l10n.appSettings_enableNotificationsSubtitle,
),
value: settingsService.settings.notificationsEnabled,
onChanged: (value) async {
if (value) {
// Request permission when enabling
final granted = await NotificationService().requestPermissions();
final granted = await NotificationService()
.requestPermissions();
if (!granted) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.appSettings_notificationPermissionDenied),
content: Text(
context.l10n.appSettings_notificationPermissionDenied,
),
duration: const Duration(seconds: 2),
),
);
@ -113,9 +131,11 @@ class AppSettingsScreen extends StatelessWidget {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? context.l10n.appSettings_notificationsEnabled
: context.l10n.appSettings_notificationsDisabled),
content: Text(
value
? context.l10n.appSettings_notificationsEnabled
: context.l10n.appSettings_notificationsDisabled,
),
duration: const Duration(seconds: 2),
),
);
@ -126,18 +146,24 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile(
secondary: Icon(
Icons.message_outlined,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
title: Text(
context.l10n.appSettings_messageNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
),
subtitle: Text(
context.l10n.appSettings_messageNotificationsSubtitle,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
),
value: settingsService.settings.notifyOnNewMessage,
@ -151,18 +177,24 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile(
secondary: Icon(
Icons.forum_outlined,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
title: Text(
context.l10n.appSettings_channelMessageNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
),
subtitle: Text(
context.l10n.appSettings_channelMessageNotificationsSubtitle,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
),
value: settingsService.settings.notifyOnNewChannelMessage,
@ -176,18 +208,24 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile(
secondary: Icon(
Icons.cell_tower,
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
title: Text(
context.l10n.appSettings_advertisementNotifications,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
),
subtitle: Text(
context.l10n.appSettings_advertisementNotificationsSubtitle,
style: TextStyle(
color: settingsService.settings.notificationsEnabled ? null : Colors.grey,
color: settingsService.settings.notificationsEnabled
? null
: Colors.grey,
),
),
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(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -217,15 +258,19 @@ class AppSettingsScreen extends StatelessWidget {
SwitchListTile(
secondary: const Icon(Icons.refresh_outlined),
title: Text(context.l10n.appSettings_clearPathOnMaxRetry),
subtitle: Text(context.l10n.appSettings_clearPathOnMaxRetrySubtitle),
subtitle: Text(
context.l10n.appSettings_clearPathOnMaxRetrySubtitle,
),
value: settingsService.settings.clearPathOnMaxRetry,
onChanged: (value) {
settingsService.setClearPathOnMaxRetry(value);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? context.l10n.appSettings_pathsWillBeCleared
: context.l10n.appSettings_pathsWillNotBeCleared),
content: Text(
value
? context.l10n.appSettings_pathsWillBeCleared
: context.l10n.appSettings_pathsWillNotBeCleared,
),
duration: const Duration(seconds: 2),
),
);
@ -241,9 +286,11 @@ class AppSettingsScreen extends StatelessWidget {
settingsService.setAutoRouteRotationEnabled(value);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? context.l10n.appSettings_autoRouteRotationEnabled
: context.l10n.appSettings_autoRouteRotationDisabled),
content: Text(
value
? context.l10n.appSettings_autoRouteRotationEnabled
: context.l10n.appSettings_autoRouteRotationDisabled,
),
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(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -302,7 +352,9 @@ class AppSettingsScreen extends StatelessWidget {
subtitle: Text(
settingsService.settings.mapTimeFilterHours == 0
? 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),
onTap: () => _showTimeFilterDialog(context, settingsService),
@ -339,8 +391,9 @@ class AppSettingsScreen extends StatelessWidget {
) {
final deviceId = connector.deviceId;
final isConnected = connector.isConnected && deviceId != null;
final selection =
isConnected ? settingsService.batteryChemistryForDevice(deviceId) : 'nmc';
final selection = isConnected
? settingsService.batteryChemistryForDevice(deviceId)
: 'nmc';
return Card(
child: Column(
@ -358,7 +411,9 @@ class AppSettingsScreen extends StatelessWidget {
title: Text(context.l10n.appSettings_batteryChemistry),
subtitle: Text(
isConnected
? context.l10n.appSettings_batteryChemistryPerDevice(connector.deviceDisplayName)
? context.l10n.appSettings_batteryChemistryPerDevice(
connector.deviceDisplayName,
)
: context.l10n.appSettings_batteryChemistryConnectFirst,
),
trailing: DropdownButton<String>(
@ -366,7 +421,10 @@ class AppSettingsScreen extends StatelessWidget {
onChanged: isConnected
? (value) {
if (value != null) {
settingsService.setBatteryChemistryForDevice(deviceId, value);
settingsService.setBatteryChemistryForDevice(
deviceId,
value,
);
}
}
: null,
@ -391,7 +449,10 @@ class AppSettingsScreen extends StatelessWidget {
);
}
void _showThemeModeDialog(BuildContext context, AppSettingsService settingsService) {
void _showThemeModeDialog(
BuildContext context,
AppSettingsService settingsService,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
@ -480,7 +541,10 @@ class AppSettingsScreen extends StatelessWidget {
}
}
void _showLanguageDialog(BuildContext context, AppSettingsService settingsService) {
void _showLanguageDialog(
BuildContext context,
AppSettingsService settingsService,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
@ -573,7 +637,10 @@ class AppSettingsScreen extends StatelessWidget {
);
}
void _showTimeFilterDialog(BuildContext context, AppSettingsService settingsService) {
void _showTimeFilterDialog(
BuildContext context,
AppSettingsService settingsService,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
@ -593,33 +660,23 @@ class AppSettingsScreen extends StatelessWidget {
const SizedBox(height: 16),
ListTile(
title: Text(context.l10n.appSettings_allTime),
leading: Radio<double>(
value: 0,
),
leading: Radio<double>(value: 0),
),
ListTile(
title: Text(context.l10n.appSettings_lastHour),
leading: Radio<double>(
value: 1,
),
leading: Radio<double>(value: 1),
),
ListTile(
title: Text(context.l10n.appSettings_last6Hours),
leading: Radio<double>(
value: 6,
),
leading: Radio<double>(value: 6),
),
ListTile(
title: Text(context.l10n.appSettings_last24Hours),
leading: Radio<double>(
value: 24,
),
leading: Radio<double>(value: 24),
),
ListTile(
title: Text(context.l10n.appSettings_lastWeek),
leading: Radio<double>(
value: 168,
),
leading: Radio<double>(value: 168),
),
],
),
@ -634,7 +691,10 @@ class AppSettingsScreen extends StatelessWidget {
);
}
Widget _buildDebugCard(BuildContext context, AppSettingsService settingsService) {
Widget _buildDebugCard(
BuildContext context,
AppSettingsService settingsService,
) {
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -656,9 +716,11 @@ class AppSettingsScreen extends StatelessWidget {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(value
? context.l10n.appSettings_appDebugLoggingEnabled
: context.l10n.appSettings_appDebugLoggingDisabled),
content: Text(
value
? context.l10n.appSettings_appDebugLoggingEnabled
: context.l10n.appSettings_appDebugLoggingDisabled,
),
duration: const Duration(seconds: 2),
),
);

View file

@ -24,7 +24,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
final entries = logService.entries.reversed.toList();
final rawEntries = logService.rawLogRxEntries.reversed.toList();
final showingFrames = _view == _BleLogView.frames;
final hasEntries = showingFrames ? entries.isNotEmpty : rawEntries.isNotEmpty;
final hasEntries = showingFrames
? entries.isNotEmpty
: rawEntries.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.debugLog_bleTitle),
@ -36,15 +38,23 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
? () async {
final text = showingFrames
? entries
.map((entry) => '${entry.description}\n${entry.hexPreview}\n')
.join('\n')
.map(
(entry) =>
'${entry.description}\n${entry.hexPreview}\n',
)
.join('\n')
: rawEntries
.map((entry) => 'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n')
.join('\n');
.map(
(entry) =>
'RX RAW_LOG_RX_DATA\n${entry.hexPreview}\n',
)
.join('\n');
await Clipboard.setData(ClipboardData(text: text));
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.debugLog_bleCopied)),
SnackBar(
content: Text(context.l10n.debugLog_bleCopied),
),
);
}
: null,
@ -68,8 +78,14 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: SegmentedButton<_BleLogView>(
segments: [
ButtonSegment(value: _BleLogView.frames, label: Text(context.l10n.debugLog_frames)),
ButtonSegment(value: _BleLogView.rawLogRx, label: Text(context.l10n.debugLog_rawLogRx)),
ButtonSegment(
value: _BleLogView.frames,
label: Text(context.l10n.debugLog_frames),
),
ButtonSegment(
value: _BleLogView.rawLogRx,
label: Text(context.l10n.debugLog_rawLogRx),
),
],
selected: {_view},
onSelectionChanged: (selection) {
@ -81,7 +97,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
Expanded(
child: hasEntries
? ListView.separated(
itemCount: showingFrames ? entries.length : rawEntries.length,
itemCount: showingFrames
? entries.length
: rawEntries.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
if (showingFrames) {
@ -94,7 +112,9 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
subtitle: Text('${entry.hexPreview}\n$time'),
isThreeLine: true,
leading: Icon(
entry.outgoing ? Icons.upload : Icons.download,
entry.outgoing
? Icons.upload
: Icons.download,
size: 18,
),
);
@ -131,9 +151,7 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
context: context,
builder: (context) => AlertDialog(
title: Text(info.title),
content: SingleChildScrollView(
child: SelectableText(info.rawHex),
),
content: SingleChildScrollView(child: SelectableText(info.rawHex)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
@ -195,11 +213,18 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
}
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 pathSummary = pathLen > 0 ? 'Path=${_bytesToHex(pathBytes)}' : 'Path=none';
final pathSummary = pathLen > 0
? 'Path=${_bytesToHex(pathBytes)}'
: 'Path=none';
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) {
@ -245,7 +270,10 @@ class _BleDebugLogScreenState extends State<BleDebugLogScreen> {
return 'ADVERT (short)';
}
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;
final timestamp = readUint32LE(payload, offset);
offset += 4;

View file

@ -27,10 +27,7 @@ import 'map_screen.dart';
class ChannelChatScreen extends StatefulWidget {
final Channel channel;
const ChannelChatScreen({
super.key,
required this.channel,
});
const ChannelChatScreen({super.key, required this.channel});
@override
State<ChannelChatScreen> createState() => _ChannelChatScreenState();
@ -135,15 +132,19 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Text(
widget.channel.name.isEmpty
? context.l10n.channels_channelIndex(widget.channel.index)
? context.l10n.channels_channelIndex(
widget.channel.index,
)
: widget.channel.name,
style: const TextStyle(fontSize: 16),
),
Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final unreadCount =
connector.getUnreadCountForChannelIndex(widget.channel.index);
final privacy = widget.channel.isPublicChannel ? context.l10n.channels_public : context.l10n.channels_private;
final unreadCount = connector
.getUnreadCountForChannelIndex(widget.channel.index);
final privacy = widget.channel.isPublicChannel
? context.l10n.channels_public
: context.l10n.channels_private;
return Text(
'$privacy${context.l10n.chat_unread(unreadCount)}',
overflow: TextOverflow.ellipsis,
@ -202,7 +203,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
// Reverse messages so newest appear at bottom with reverse: true
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
WidgetsBinding.instance.addPostFrameCallback((_) {
@ -225,7 +227,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
);
@ -241,9 +245,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
},
),
JumpToBottomButton(
scrollController: _scrollController,
),
JumpToBottomButton(scrollController: _scrollController),
],
);
},
@ -262,15 +264,21 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final poi = _parsePoiMessage(message.text);
final displayPath = message.pathBytes.isNotEmpty
? message.pathBytes
: (message.pathVariants.isNotEmpty ? message.pathVariants.first : Uint8List(0));
: (message.pathVariants.isNotEmpty
? message.pathVariants.first
: Uint8List(0));
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Column(
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start,
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
@ -282,128 +290,160 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
onTap: () => _showMessagePathInfo(message),
onLongPress: () => _showMessageActions(message),
child: Container(
padding: gifId != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
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,
padding: gifId != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
if (gifId == null) const SizedBox(height: 4),
],
if (message.replyToMessageId != null) ...[
_buildReplyPreview(message),
const SizedBox(height: 8),
],
if (poi != null)
_buildPoiMessage(context, poi, isOutgoing)
else if (gifId != null)
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),
),
)
else
Linkify(
text: message.text,
style: const TextStyle(fontSize: 14),
linkStyle: const TextStyle(
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],
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
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,
),
),
),
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 (gifId == null) const SizedBox(height: 4),
],
if (message.replyToMessageId != null) ...[
_buildReplyPreview(message),
const SizedBox(height: 8),
],
if (poi != null)
_buildPoiMessage(context, poi, isOutgoing)
else if (gifId != null)
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) ...[
const SizedBox(width: 4),
Icon(
message.status == ChannelMessageStatus.sent
? Icons.check
: message.status == ChannelMessageStatus.pending
)
else
Linkify(
text: message.text,
style: const TextStyle(fontSize: 14),
linkStyle: const TextStyle(
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.error_outline,
size: 14,
color: message.status == ChannelMessageStatus.failed
? Colors.red
: Colors.grey[600],
),
],
],
),
size: 14,
color:
message.status ==
ChannelMessageStatus.failed
? Colors.red
: Colors.grey[600],
),
],
],
),
),
],
),
],
),
),
),
),
),
],
),
if (message.reactions.isNotEmpty) ...[
@ -444,7 +484,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Icon(Icons.location_on_outlined, size: 14, color: previewTextColor),
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 {
@ -468,10 +511,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
border: Border(
left: BorderSide(
color: colorScheme.primary,
width: 3,
),
left: BorderSide(color: colorScheme.primary, width: 3),
),
),
child: Column(
@ -509,17 +549,16 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
color: Theme.of(context).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(12),
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,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
emoji,
style: const TextStyle(fontSize: 16),
),
Text(emoji, style: const TextStyle(fontSize: 16)),
if (count > 1) ...[
const SizedBox(width: 4),
Text(
@ -546,7 +585,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_PoiInfo? _parsePoiMessage(String text) {
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;
final lat = double.tryParse(match.group(1) ?? '');
final lon = double.tryParse(match.group(2) ?? '');
@ -557,10 +598,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
Widget _buildPoiMessage(BuildContext context, _PoiInfo poi, bool isOutgoing) {
final colorScheme = Theme.of(context).colorScheme;
final textColor =
isOutgoing ? colorScheme.onPrimaryContainer : colorScheme.onSurface;
final textColor = isOutgoing
? colorScheme.onPrimaryContainer
: colorScheme.onSurface;
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(
crossAxisAlignment: CrossAxisAlignment.center,
@ -588,18 +632,12 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
children: [
Text(
context.l10n.chat_poiShared,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w600,
),
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
),
if (poi.label.isNotEmpty)
Text(
poi.label,
style: TextStyle(
color: metaColor,
fontSize: 12,
),
style: TextStyle(color: metaColor, fontSize: 12),
),
],
),
@ -676,10 +714,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
bottom: BorderSide(color: Theme.of(context).dividerColor, width: 1),
),
),
child: Row(
@ -708,7 +743,9 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
overflow: TextOverflow.ellipsis,
style: TextStyle(
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(
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = _parseGifId(value.text);
if (gifId != null) {
return Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
fallbackTextColor:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
maxSize: 160,
children: [
IconButton(
icon: const Icon(Icons.gif_box),
onPressed: () => _showGifPicker(context),
tooltip: context.l10n.chat_sendGif,
),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _textController,
builder: (context, value, child) {
final gifId = _parseGifId(value.text);
if (gifId != null) {
return Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
fallbackTextColor: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
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),
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,
),
),
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,
),
],
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);
if (emojiIndex == null) return; // Unknown emoji, skip
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';
connector.sendChannelMessage(widget.channel, reactionText);
}
void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageCopied)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
}
Future<void> _deleteMessage(ChannelMessage message) async {
await context.read<MeshCoreConnector>().deleteChannelMessage(message);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageDeleted)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
}
String _formatPathPrefixes(Uint8List pathBytes) {
@ -964,9 +1008,5 @@ class _PoiInfo {
final double lon;
final String label;
const _PoiInfo({
required this.lat,
required this.lon,
required this.label,
});
const _PoiInfo({required this.lat, required this.lon, required this.label});
}

View file

@ -17,17 +17,17 @@ import '../models/contact.dart';
class ChannelMessagePathScreen extends StatelessWidget {
final ChannelMessage message;
const ChannelMessagePathScreen({
super.key,
required this.message,
});
const ChannelMessagePathScreen({super.key, required this.message});
@override
Widget build(BuildContext context) {
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
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 hasHopDetails = primaryPath.isNotEmpty;
final observedLabel = _formatObservedHops(
@ -88,10 +88,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
);
}
Widget _buildSummaryCard(
BuildContext context, {
String? observedLabel,
}) {
Widget _buildSummaryCard(BuildContext context, {String? observedLabel}) {
final l10n = context.l10n;
return Card(
child: Padding(
@ -105,21 +102,28 @@ class ChannelMessagePathScreen extends StatelessWidget {
),
const SizedBox(height: 8),
_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)
_buildDetailRow(l10n.channelPath_repeatsLabel, message.repeatCount.toString()),
_buildDetailRow(l10n.channelPath_pathLabelTitle, _formatPathLabel(message.pathLength, l10n)),
if (observedLabel != null) _buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
_buildDetailRow(
l10n.channelPath_repeatsLabel,
message.repeatCount.toString(),
),
_buildDetailRow(
l10n.channelPath_pathLabelTitle,
_formatPathLabel(message.pathLength, l10n),
),
if (observedLabel != null)
_buildDetailRow(l10n.channelPath_observedLabel, observedLabel),
],
),
),
);
}
Widget _buildPathVariants(
BuildContext context,
List<Uint8List> variants,
) {
Widget _buildPathVariants(BuildContext context, List<Uint8List> variants) {
final l10n = context.l10n;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -163,7 +167,7 @@ class ChannelMessagePathScreen extends StatelessWidget {
subtitle: Text(
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
'${hop.position!.longitude.toStringAsFixed(5)}'
: l10n.channelPath_noLocationData,
),
),
@ -239,7 +243,6 @@ class ChannelMessagePathScreen extends StatelessWidget {
),
);
}
}
class ChannelMessagePathMapScreen extends StatefulWidget {
@ -257,7 +260,8 @@ class ChannelMessagePathMapScreen extends StatefulWidget {
_ChannelMessagePathMapScreenState();
}
class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScreen> {
class _ChannelMessagePathMapScreenState
extends State<ChannelMessagePathMapScreen> {
Uint8List? _selectedPath;
@override
@ -270,8 +274,10 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
void didUpdateWidget(ChannelMessagePathMapScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.message != widget.message ||
!_pathsEqual(oldWidget.initialPath ?? Uint8List(0),
widget.initialPath ?? Uint8List(0))) {
!_pathsEqual(
oldWidget.initialPath ?? Uint8List(0),
widget.initialPath ?? Uint8List(0),
)) {
_selectedPath = widget.initialPath;
}
}
@ -281,17 +287,25 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
return Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final tileCache = context.read<MapTileCacheService>();
final primaryPath =
_selectPrimaryPath(widget.message.pathBytes, widget.message.pathVariants);
final observedPaths =
_buildObservedPaths(primaryPath, widget.message.pathVariants);
final primaryPath = _selectPrimaryPath(
widget.message.pathBytes,
widget.message.pathVariants,
);
final observedPaths = _buildObservedPaths(
primaryPath,
widget.message.pathVariants,
);
final selectedPath = _resolveSelectedPath(
_selectedPath,
observedPaths,
primaryPath,
);
final selectedIndex = _indexForPath(selectedPath, observedPaths);
final hops = _buildPathHops(selectedPath, connector.contacts, context.l10n);
final hops = _buildPathHops(
selectedPath,
connector.contacts,
context.l10n,
);
final points = hops
.where((hop) => hop.hasLocation)
.map((hop) => hop.position!)
@ -306,16 +320,17 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
]
: <Polyline>[];
final initialCenter =
points.isNotEmpty ? points.first : const LatLng(0, 0);
final initialCenter = points.isNotEmpty
? points.first
: const LatLng(0, 0);
final initialZoom = points.isNotEmpty ? 13.0 : 2.0;
final bounds = points.length > 1 ? LatLngBounds.fromPoints(points) : null;
final bounds = points.length > 1
? LatLngBounds.fromPoints(points)
: null;
final mapKey = ValueKey(_formatPathPrefixes(selectedPath));
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.channelPath_mapTitle),
),
appBar: AppBar(title: Text(context.l10n.channelPath_mapTitle)),
body: SafeArea(
top: false,
child: Stack(
@ -343,30 +358,28 @@ class _ChannelMessagePathMapScreenState extends State<ChannelMessagePathMapScree
MapTileCacheService.userAgentPackageName,
maxZoom: 19,
),
if (polylines.isNotEmpty) PolylineLayer(polylines: polylines),
MarkerLayer(
markers: _buildHopMarkers(hops),
),
if (polylines.isNotEmpty)
PolylineLayer(polylines: polylines),
MarkerLayer(markers: _buildHopMarkers(hops)),
],
),
if (observedPaths.length > 1)
_buildPathSelector(
context,
observedPaths,
selectedIndex,
(index) {
setState(() {
_selectedPath = observedPaths[index].pathBytes;
});
},
),
_buildPathSelector(context, observedPaths, selectedIndex, (
index,
) {
setState(() {
_selectedPath = observedPaths[index].pathBytes;
});
}),
if (points.isEmpty)
Center(
child: Card(
color: Colors.white.withValues(alpha: 0.9),
child: Padding(
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(
hop.hasLocation
? '${hop.position!.latitude.toStringAsFixed(5)}, '
'${hop.position!.longitude.toStringAsFixed(5)}'
'${hop.position!.longitude.toStringAsFixed(5)}'
: l10n.channelPath_noLocationData,
),
);
@ -567,10 +580,7 @@ class _ObservedPath {
final Uint8List pathBytes;
final bool isPrimary;
const _ObservedPath({
required this.pathBytes,
required this.isPrimary,
});
const _ObservedPath({required this.pathBytes, required this.isPrimary});
}
List<_PathHop> _buildPathHops(
@ -597,10 +607,12 @@ List<_PathHop> _buildPathHops(
Contact? _matchContactForPrefix(List<Contact> contacts, int prefix) {
final matches = contacts
.where((contact) =>
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
contact.publicKey.isNotEmpty &&
contact.publicKey[0] == prefix)
.where(
(contact) =>
(contact.type == advTypeRepeater || contact.type == advTypeRoom) &&
contact.publicKey.isNotEmpty &&
contact.publicKey[0] == prefix,
)
.toList();
if (matches.isEmpty) return null;

View file

@ -154,7 +154,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
),
],
@ -951,7 +953,9 @@ class _ChannelsScreenState extends State<ChannelsScreen>
dialogContext.l10n.community_communityHashtag,
),
subtitle: Text(
dialogContext.l10n.community_communityHashtagDesc,
dialogContext
.l10n
.community_communityHashtagDesc,
),
dense: true,
),
@ -1047,7 +1051,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
hashtag = hashtag.substring(1);
}
final String channelName;
final Uint8List psk;
if (isRegularHashtag) {
channelName = '#$hashtag';
@ -1069,8 +1073,10 @@ class _ChannelsScreenState extends State<ChannelsScreen>
);
return;
}
channelName = '${selectedCommunity!.name} #$hashtag';
psk = selectedCommunity!.deriveCommunityHashtagPsk(hashtag);
channelName =
'${selectedCommunity!.name} #$hashtag';
psk = selectedCommunity!
.deriveCommunityHashtagPsk(hashtag);
// Track in community's hashtag list
await _communityStore.addHashtagChannel(
selectedCommunity!.id,

View file

@ -52,7 +52,9 @@ class _ChatScreenState extends State<ChatScreen> {
_scrollController.onScrollNearTop = _loadOlderMessages;
SchedulerBinding.instance.addPostFrameCallback((_) {
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>(
builder: (context, pathService, 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 pathLabel = _currentPathLabel(contact);
// 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;
return Column(
@ -106,7 +111,9 @@ class _ChatScreenState extends State<ChatScreen> {
Text(contact.name),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: hasPathData ? () => _showFullPathDialog(context, effectivePath) : null,
onTap: hasPathData
? () => _showFullPathDialog(context, effectivePath)
: null,
child: Text(
'$pathLabel$unreadLabel',
overflow: TextOverflow.ellipsis,
@ -144,12 +151,20 @@ class _ChatScreenState extends State<ChatScreen> {
value: 'auto',
child: Row(
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),
Text(
context.l10n.chat_autoUseSavedPath,
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',
child: Row(
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),
Text(
context.l10n.chat_forceFloodMode,
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
? _buildEmptyState()
: _buildMessageList(messages, connector),
JumpToBottomButton(
scrollController: _scrollController,
),
JumpToBottomButton(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
final reversedMessages = messages.reversed.toList();
final itemCount = reversedMessages.length + (_isLoadingOlder ? 1 : 0);
@ -267,14 +291,21 @@ class _ChatScreenState extends State<ChatScreen> {
if (widget.contact.type == advTypeRoom) {
contact = _resolveContactFrom4Bytes(
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(
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,
onTap: () => _openMessagePath(message, contact),
onLongPress: () => _showMessageActions(message, contact),
@ -290,9 +321,7 @@ class _ChatScreenState extends State<ChatScreen> {
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(
top: BorderSide(color: Theme.of(context).dividerColor),
),
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
),
child: SafeArea(
child: Row(
@ -314,10 +343,12 @@ class _ChatScreenState extends State<ChatScreen> {
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: GifMessage(
url: 'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor: colorScheme.surfaceContainerHighest,
fallbackTextColor:
colorScheme.onSurface.withValues(alpha: 0.6),
url:
'https://media.giphy.com/media/$gifId/giphy.gif',
backgroundColor:
colorScheme.surfaceContainerHighest,
fallbackTextColor: colorScheme.onSurface
.withValues(alpha: 0.6),
maxSize: 160,
),
),
@ -341,7 +372,10 @@ class _ChatScreenState extends State<ChatScreen> {
decoration: InputDecoration(
hintText: context.l10n.chat_typeMessage,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(connector),
@ -390,14 +424,10 @@ class _ChatScreenState extends State<ChatScreen> {
return;
}
connector.sendMessage(
widget.contact,
text,
);
connector.sendMessage(widget.contact, text);
_textController.clear();
}
void _showPathHistory(BuildContext context) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
@ -422,13 +452,19 @@ class _ChatScreenState extends State<ChatScreen> {
if (paths.isNotEmpty) ...[
Text(
context.l10n.chat_recentAckPaths,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
if (paths.length >= 100) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.amber[100],
borderRadius: BorderRadius.circular(8),
@ -447,7 +483,9 @@ class _ChatScreenState extends State<ChatScreen> {
dense: true,
leading: CircleAvatar(
radius: 16,
backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green,
backgroundColor: path.wasFloodDiscovery
? Colors.blue
: Colors.green,
child: Text(
'${path.hopCount}',
style: const TextStyle(fontSize: 12),
@ -475,23 +513,36 @@ class _ChatScreenState extends State<ChatScreen> {
},
),
path.wasFloodDiscovery
? const Icon(Icons.waves, size: 16, color: Colors.grey)
: const Icon(Icons.route, size: 16, color: Colors.grey),
? const Icon(
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 {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.chat_pathDetailsNotAvailable),
content: Text(
context.l10n.chat_pathDetailsNotAvailable,
),
duration: const Duration(seconds: 2),
),
);
return;
}
final pathBytes = Uint8List.fromList(path.pathBytes);
final pathBytes = Uint8List.fromList(
path.pathBytes,
);
final pathLength = path.pathBytes.length;
// Set the path override to persist user's choice
@ -521,7 +572,10 @@ class _ChatScreenState extends State<ChatScreen> {
const SizedBox(height: 8),
Text(
context.l10n.chat_pathActions,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
const SizedBox(height: 8),
ListTile(
@ -531,8 +585,14 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16),
),
title: Text(context.l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)),
subtitle: Text(context.l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)),
title: Text(
context.l10n.chat_setCustomPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_setCustomPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () {
Navigator.pop(context);
_showCustomPathDialog(context);
@ -545,8 +605,14 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16),
),
title: Text(context.l10n.chat_clearPath, style: const TextStyle(fontSize: 14)),
subtitle: Text(context.l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)),
title: Text(
context.l10n.chat_clearPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_clearPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await connector.clearContactPath(widget.contact);
if (!context.mounted) return;
@ -566,10 +632,19 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16),
),
title: Text(context.l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)),
subtitle: Text(context.l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)),
title: Text(
context.l10n.chat_forceFloodMode,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
context.l10n.chat_floodModeSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await connector.setPathOverride(widget.contact, pathLen: -1);
await connector.setPathOverride(
widget.contact,
pathLen: -1,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@ -598,7 +673,8 @@ class _ChatScreenState extends State<ChatScreen> {
String _formatRelativeTime(DateTime time) {
final diff = DateTime.now().difference(time);
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);
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(
(c) => listEquals(c.publicKey.sublist(0, 4), key4Bytes.sublist(0, 4)),
orElse: () => widget.contact,
@ -674,12 +753,12 @@ class _ChatScreenState extends State<ChatScreen> {
final status = !connector.isConnected
? 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(
SnackBar(
content: Text(
context.l10n.chat_pathSetHops(hopCount, status),
),
content: Text(context.l10n.chat_pathSetHops(hopCount, status)),
duration: const Duration(seconds: 3),
),
);
@ -694,7 +773,9 @@ class _ChatScreenState extends State<ChatScreen> {
builder: (context) => Consumer<MeshCoreConnector>(
builder: (context, connector, _) {
final contact = _resolveContact(connector);
final smazEnabled = connector.isContactSmazEnabled(contact.publicKeyHex);
final smazEnabled = connector.isContactSmazEnabled(
contact.publicKeyHex,
);
return AlertDialog(
title: Text(contact.name),
@ -710,7 +791,10 @@ class _ChatScreenState extends State<ChatScreen> {
context.l10n.chat_location,
'${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(),
SwitchListTile(
contentPadding: EdgeInsets.zero,
@ -718,7 +802,10 @@ class _ChatScreenState extends State<ChatScreen> {
subtitle: Text(context.l10n.chat_compressOutgoingMessages),
value: smazEnabled,
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 currentContact = _resolveContact(connector);
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) {
if (currentContact.pathLength > 0 &&
currentContact.path.isEmpty &&
connector.isConnected) {
connector.getContacts();
}
@ -786,19 +875,31 @@ class _ChatScreenState extends State<ChatScreen> {
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) {
appLogger.info('PathSelectionDialog was cancelled or returned null', tag: 'ChatScreen');
appLogger.info(
'PathSelectionDialog was cancelled or returned null',
tag: 'ChatScreen',
);
return;
}
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;
}
appLogger.info('Calling setPathOverride for ${widget.contact.name}', tag: 'ChatScreen');
appLogger.info(
'Calling setPathOverride for ${widget.contact.name}',
tag: 'ChatScreen',
);
await connector.setPathOverride(
widget.contact,
pathLen: result.length,
@ -810,7 +911,6 @@ class _ChatScreenState extends State<ChatScreen> {
await _notifyPathSet(connector, widget.contact, result, result.length);
}
void _openMessagePath(Message message, Contact contact) {
final connector = context.read<MeshCoreConnector>();
final fourByteHex = message.fourByteRoomContactKey
@ -877,8 +977,7 @@ class _ChatScreenState extends State<ChatScreen> {
await _deleteMessage(message);
},
),
if (message.isOutgoing &&
message.status == MessageStatus.failed)
if (message.isOutgoing && message.status == MessageStatus.failed)
ListTile(
leading: const Icon(Icons.refresh),
title: Text(context.l10n.common_retry),
@ -909,29 +1008,26 @@ class _ChatScreenState extends State<ChatScreen> {
void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageCopied)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageCopied)));
}
Future<void> _deleteMessage(Message message) async {
await context.read<MeshCoreConnector>().deleteMessage(message);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_messageDeleted)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_messageDeleted)));
}
void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry using the contact's current path override setting
connector.sendMessage(
widget.contact,
message.text,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chat_retryingMessage)),
);
connector.sendMessage(widget.contact, message.text);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.chat_retryingMessage)));
}
void _showEmojiPicker(Message message, Contact senderContact) {
@ -951,11 +1047,17 @@ class _ChatScreenState extends State<ChatScreen> {
final emojiIndex = ReactionHelper.emojiToIndex(emoji);
if (emojiIndex == null) return; // Unknown emoji, skip
final timestampSecs = message.timestamp.millisecondsSinceEpoch ~/ 1000;
// For room servers, include sender name (like channels) since multiple users
// For 1:1 chats, sender is implicit (null)
final senderName = widget.contact.type == advTypeRoom ? senderContact.name : null;
final hash = ReactionHelper.computeReactionHash(timestampSecs, senderName, message.text);
final senderName = widget.contact.type == advTypeRoom
? senderContact.name
: null;
final hash = ReactionHelper.computeReactionHash(
timestampSecs,
senderName,
message.text,
);
final reactionText = 'r:$hash:$emojiIndex';
connector.sendMessage(widget.contact, reactionText);
}
@ -985,7 +1087,9 @@ class _MessageBubble extends StatelessWidget {
final isFailed = message.status == MessageStatus.failed;
final bubbleColor = isFailed
? colorScheme.errorContainer
: (isOutgoing ? colorScheme.primary : colorScheme.surfaceContainerHighest);
: (isOutgoing
? colorScheme.primary
: colorScheme.surfaceContainerHighest);
final textColor = isFailed
? colorScheme.onErrorContainer
: (isOutgoing ? colorScheme.onPrimary : colorScheme.onSurface);
@ -997,13 +1101,17 @@ class _MessageBubble extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start,
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: onTap,
onLongPress: onLongPress,
child: Row(
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
mainAxisAlignment: isOutgoing
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOutgoing) ...[
@ -1012,133 +1120,154 @@ class _MessageBubble extends StatelessWidget {
],
Flexible(
child: Container(
padding: gifId != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
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,
padding: gifId != null
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
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',
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
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: 9,
color: isOutgoing ? metaColor : Colors.green[700],
fontSize: 12,
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) {
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;
final lat = double.tryParse(match.group(1) ?? '');
final lon = double.tryParse(match.group(2) ?? '');
@ -1193,18 +1323,12 @@ class _MessageBubble extends StatelessWidget {
children: [
Text(
context.l10n.chat_poiShared,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w600,
),
style: TextStyle(color: textColor, fontWeight: FontWeight.w600),
),
if (poi.label.isNotEmpty)
Text(
poi.label,
style: TextStyle(
color: metaColor,
fontSize: 12,
),
style: TextStyle(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(
spacing: 6,
runSpacing: 6,
@ -1234,10 +1362,7 @@ class _MessageBubble extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
emoji,
style: const TextStyle(fontSize: 16),
),
Text(emoji, style: const TextStyle(fontSize: 16)),
if (count > 1) ...[
const SizedBox(width: 4),
Text(
@ -1321,11 +1446,7 @@ class _MessageBubble extends StatelessWidget {
break;
}
return Icon(
icon,
size: 12,
color: color,
);
return Icon(icon, size: 12, color: color);
}
String _formatTime(DateTime time) {
@ -1340,9 +1461,5 @@ class _PoiInfo {
final double lon;
final String label;
const _PoiInfo({
required this.lat,
required this.lon,
required this.label,
});
const _PoiInfo({required this.lat, required this.lon, required this.label});
}

View file

@ -29,16 +29,9 @@ import 'map_screen.dart';
import 'repeater_hub_screen.dart';
import 'settings_screen.dart';
enum RoomLoginDestination {
chat,
management,
}
enum RoomLoginDestination { chat, management }
enum ContactOperationType {
import,
export,
zeroHopShare,
}
enum ContactOperationType { import, export, zeroHopShare }
class ContactsScreen extends StatefulWidget {
final bool hideBackButton;
@ -105,7 +98,9 @@ class _ContactsScreenState extends State<ContactsScreen>
if (advertPacket.length < 98) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
SnackBar(
content: Text(context.l10n.contacts_invalidAdvertFormat),
),
);
}
_pendingOperations.remove(ContactOperationType.export);
@ -115,23 +110,25 @@ class _ContactsScreenState extends State<ContactsScreen>
Clipboard.setData(ClipboardData(text: "meshcore://$hexString"));
}
if(code == respCodeOk) {
if (code == respCodeOk) {
// 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(
SnackBar(content: Text(context.l10n.contacts_contactImported)),
);
}
if(_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
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(
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopied)),
);
@ -140,30 +137,33 @@ class _ContactsScreenState extends State<ContactsScreen>
_pendingOperations.clear();
}
if(code == respCodeErr) {
if (code == respCodeErr) {
// 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(
SnackBar(content: Text(context.l10n.contacts_contactImportFailed)),
);
}
if(_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
if (_pendingOperations.contains(ContactOperationType.zeroHopShare)) {
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(
SnackBar(content: Text(context.l10n.contacts_contactAdvertCopyFailed)),
SnackBar(
content: Text(context.l10n.contacts_contactAdvertCopyFailed),
),
);
}
_pendingOperations.clear();
}
});
}
@ -185,7 +185,7 @@ class _ContactsScreenState extends State<ContactsScreen>
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final clipboardData = await Clipboard.getData('text/plain');
if (clipboardData == null || clipboardData.text == null) {
if(mounted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_clipboardEmpty)),
);
@ -194,7 +194,7 @@ class _ContactsScreenState extends State<ContactsScreen>
}
final text = clipboardData.text!.trim();
if (!text.startsWith('meshcore://')) {
if(mounted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
);
@ -207,7 +207,7 @@ class _ContactsScreenState extends State<ContactsScreen>
_pendingOperations.add(ContactOperationType.import);
await connector.sendFrame(importContactFrame);
} catch (e) {
if(mounted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.contacts_invalidAdvertFormat)),
);
@ -234,59 +234,64 @@ class _ContactsScreenState extends State<ContactsScreen>
centerTitle: true,
automaticallyImplyLeading: false,
actions: [
PopupMenuButton(itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.connect_without_contact),
const SizedBox(width: 8),
Text(context.l10n.contacts_zeroHopAdvert),
],
),
onTap: () => {
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.connect_without_contact),
const SizedBox(width: 8),
Text(context.l10n.contacts_zeroHopAdvert),
],
),
onTap: () => {
connector.sendSelfAdvert(flood: false),
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.settings_advertisementSent))),
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.cell_tower),
const SizedBox(width: 8),
Text(context.l10n.contacts_floodAdvert),
],
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.settings_advertisementSent),
),
),
},
),
onTap: () => {
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.cell_tower),
const SizedBox(width: 8),
Text(context.l10n.contacts_floodAdvert),
],
),
onTap: () => {
connector.sendSelfAdvert(flood: true),
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(context.l10n.settings_advertisementSent))),
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.copy),
const SizedBox(width: 8),
Text(context.l10n.contacts_copyAdvertToClipboard),
],
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.settings_advertisementSent),
),
),
},
),
onTap: () => _contactExport(Uint8List.fromList([])),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.paste),
const SizedBox(width: 8),
Text(context.l10n.contacts_addContactFromClipboard),
],
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.copy),
const SizedBox(width: 8),
Text(context.l10n.contacts_copyAdvertToClipboard),
],
),
onTap: () => _contactExport(Uint8List.fromList([])),
),
onTap: () => _contactImport(),
),
],
icon: const Icon(Icons.connect_without_contact),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.paste),
const SizedBox(width: 8),
Text(context.l10n.contacts_addContactFromClipboard),
],
),
onTap: () => _contactImport(),
),
],
icon: const Icon(Icons.connect_without_contact),
),
PopupMenuButton(
itemBuilder: (context) => [
@ -310,7 +315,9 @@ class _ContactsScreenState extends State<ContactsScreen>
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
),
],
@ -704,7 +711,8 @@ class _ContactsScreenState extends State<ContactsScreen>
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => destination == RoomLoginDestination.management
builder: (context) =>
destination == RoomLoginDestination.management
? RepeaterHubScreen(repeater: room, password: password)
: ChatScreen(contact: room),
),
@ -970,15 +978,22 @@ class _ContactsScreenState extends State<ContactsScreen>
if (isRepeater) ...[
ListTile(
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: () {
showDialog(context: context, builder: (context) {
return PathTraceDialog(
title: contact.pathLength > 0 ? context.l10n.contacts_repeaterPathTrace : context.l10n.contacts_repeaterPing,
path: contact.traceRouteBytes ?? Uint8List(0),
);
});
}
showDialog(
context: context,
builder: (context) {
return PathTraceDialog(
title: contact.pathLength > 0
? context.l10n.contacts_repeaterPathTrace
: context.l10n.contacts_repeaterPing,
path: contact.traceRouteBytes ?? Uint8List(0),
);
},
);
},
),
ListTile(
leading: const Icon(Icons.cell_tower, color: Colors.orange),
@ -987,19 +1002,26 @@ class _ContactsScreenState extends State<ContactsScreen>
Navigator.pop(sheetContext);
_showRepeaterLogin(context, contact);
},
)
]else if (isRoom) ...[
),
] else if (isRoom) ...[
ListTile(
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: () {
showDialog(context: context, builder: (context) {
return PathTraceDialog(
title: contact.pathLength > 0 ? context.l10n.contacts_roomPathTrace : context.l10n.contacts_roomPing,
path: contact.traceRouteBytes ?? Uint8List(0),
);
});
}
showDialog(
context: context,
builder: (context) {
return PathTraceDialog(
title: contact.pathLength > 0
? context.l10n.contacts_roomPathTrace
: context.l10n.contacts_roomPing,
path: contact.traceRouteBytes ?? Uint8List(0),
);
},
);
},
),
ListTile(
leading: const Icon(Icons.room, color: Colors.blue),
@ -1010,27 +1032,39 @@ class _ContactsScreenState extends State<ContactsScreen>
},
),
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),
onTap: () {
Navigator.pop(sheetContext);
_showRoomLogin(context, contact, RoomLoginDestination.management);
_showRoomLogin(
context,
contact,
RoomLoginDestination.management,
);
},
),
] else ...[
if(contact.pathLength > 0)
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: Text(context.l10n.contacts_chatTraceRoute),
onTap: () {
showDialog(context: context, builder: (context) {
return PathTraceDialog(
title: context.l10n.contacts_pathTraceTo(contact.name),
path: contact.traceRouteBytes ?? Uint8List(0),
if (contact.pathLength > 0)
ListTile(
leading: const Icon(Icons.radar, color: Colors.green),
title: Text(context.l10n.contacts_chatTraceRoute),
onTap: () {
showDialog(
context: context,
builder: (context) {
return PathTraceDialog(
title: context.l10n.contacts_pathTraceTo(
contact.name,
),
path: contact.traceRouteBytes ?? Uint8List(0),
);
},
);
});
}
),
},
),
ListTile(
leading: const Icon(Icons.chat),
title: Text(context.l10n.contacts_openChat),
@ -1051,7 +1085,7 @@ class _ContactsScreenState extends State<ContactsScreen>
ListTile(
leading: const Icon(Icons.connect_without_contact),
title: Text(context.l10n.contacts_ShareContactZeroHop),
onTap: () {
onTap: () {
Navigator.pop(sheetContext);
_contactZeroHop(contact.publicKey);
},
@ -1127,10 +1161,13 @@ class _ContactTile extends StatelessWidget {
child: _buildContactAvatar(contact),
),
title: Text(contact.name),
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(contact.pathLabel),
Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12))
],),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(contact.pathLabel),
Text(contact.shortPubKeyHex, style: TextStyle(fontSize: 12)),
],
),
// Clamp text scaling in trailing section to prevent overflow while
// maintaining accessibility. Primary content (title/subtitle) scales normally.
trailing: MediaQuery(
@ -1154,8 +1191,8 @@ class _ContactTile extends StatelessWidget {
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (contact.hasLocation)
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
if (contact.hasLocation)
Icon(Icons.location_on, size: 14, color: Colors.grey[400]),
],
),
],

View file

@ -127,9 +127,7 @@ class _DeviceScreenState extends State<DeviceScreen>
return Card(
elevation: 0,
color: colorScheme.surfaceContainerHighest,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
@ -207,7 +205,6 @@ class _DeviceScreenState extends State<DeviceScreen>
);
}
Widget _buildBatteryIndicator(
MeshCoreConnector connector,
BuildContext context,
@ -224,11 +221,7 @@ class _DeviceScreenState extends State<DeviceScreen>
final icon = _batteryIcon(percent);
return ActionChip(
avatar: Icon(
icon,
size: 16,
color: colorScheme.onSecondaryContainer,
),
avatar: Icon(icon, size: 16, color: colorScheme.onSecondaryContainer),
label: Text(displayLabel),
labelStyle: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
@ -260,25 +253,19 @@ class _DeviceScreenState extends State<DeviceScreen>
case 0:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(
const ContactsScreen(hideBackButton: true),
),
buildQuickSwitchRoute(const ContactsScreen(hideBackButton: true)),
);
break;
case 1:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(
const ChannelsScreen(hideBackButton: true),
),
buildQuickSwitchRoute(const ChannelsScreen(hideBackButton: true)),
);
break;
case 2:
Navigator.pushReplacement(
context,
buildQuickSwitchRoute(
const MapScreen(hideBackButton: true),
),
buildQuickSwitchRoute(const MapScreen(hideBackButton: true)),
);
break;
}

View file

@ -56,10 +56,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
_updateEstimate();
if (bounds != null) {
_mapController.fitCamera(
CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(48),
),
CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)),
);
}
}
@ -72,8 +69,11 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
return;
}
final cacheService = context.read<MapTileCacheService>();
final count =
cacheService.estimateTileCount(_selectedBounds!, _minZoom, _maxZoom);
final count = cacheService.estimateTileCount(
_selectedBounds!,
_minZoom,
_maxZoom,
);
setState(() {
_estimatedTiles = count;
});
@ -181,9 +181,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
result.failed,
)
: context.l10n.mapCache_cachedTiles(result.downloaded);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
Future<void> _clearCache() async {
@ -224,10 +224,7 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
: (_completedTiles / _estimatedTiles).clamp(0.0, 1.0).toDouble();
return Scaffold(
appBar: AppBar(
title: Text(l10n.mapCache_title),
centerTitle: true,
),
appBar: AppBar(title: Text(l10n.mapCache_title), centerTitle: true),
body: Column(
children: [
Expanded(
@ -290,7 +287,10 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
children: [
Text(
l10n.mapCache_cacheArea,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: 8),
Row(
@ -304,8 +304,9 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
),
const SizedBox(width: 12),
TextButton(
onPressed:
_isDownloading || selectedBounds == null ? null : _clearBounds,
onPressed: _isDownloading || selectedBounds == null
? null
: _clearBounds,
child: Text(l10n.common_clear),
),
],
@ -313,11 +314,16 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
const SizedBox(height: 12),
Text(
l10n.mapCache_zoomRange,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
RangeSlider(
values:
RangeValues(_minZoom.toDouble(), _maxZoom.toDouble()),
values: RangeValues(
_minZoom.toDouble(),
_maxZoom.toDouble(),
),
min: 3,
max: 18,
divisions: 15,
@ -341,10 +347,12 @@ class _MapCacheScreenState extends State<MapCacheScreen> {
const SizedBox(height: 8),
LinearProgressIndicator(value: progressValue),
const SizedBox(height: 4),
Text(l10n.mapCache_downloadedTiles(
_completedTiles,
_estimatedTiles,
)),
Text(
l10n.mapCache_downloadedTiles(
_completedTiles,
_estimatedTiles,
),
),
],
const SizedBox(height: 12),
Row(

View file

@ -269,7 +269,9 @@ class _MapScreenState extends State<MapScreen> {
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
),
],
@ -278,85 +280,82 @@ class _MapScreenState extends State<MapScreen> {
],
),
body: Stack(
children: [
FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: center,
initialZoom: initialZoom,
minZoom: 2.0,
maxZoom: 18.0,
interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate
),
onTap: (_, latLng) {
if (_isSelectingPoi) {
setState(() {
_isSelectingPoi = false;
});
_shareMarker(
context: context,
connector: connector,
position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
}
},
onLongPress: (_, latLng) {
if (_isSelectingPoi) {
setState(() {
_isSelectingPoi = false;
});
_shareMarker(
context: context,
connector: connector,
position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
return;
}
_showShareMarkerAtPositionSheet(
context: context,
connector: connector,
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: [
FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: center,
initialZoom: initialZoom,
minZoom: 2.0,
maxZoom: 18.0,
interactionOptions: InteractionOptions(
flags: ~InteractiveFlag.rotate,
),
onTap: (_, latLng) {
if (_isSelectingPoi) {
setState(() {
_isSelectingPoi = false;
});
_shareMarker(
context: context,
connector: connector,
position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
}
},
onLongPress: (_, latLng) {
if (_isSelectingPoi) {
setState(() {
_isSelectingPoi = false;
});
_shareMarker(
context: context,
connector: connector,
position: latLng,
defaultLabel: context.l10n.map_pointOfInterest,
flags: 'poi',
);
return;
}
_showShareMarkerAtPositionSheet(
context: context,
connector: connector,
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),
],
),
bottomNavigationBar: SafeArea(
top: false,
child: QuickSwitchBar(
@ -376,7 +375,6 @@ class _MapScreenState extends State<MapScreen> {
);
}
List<Marker> _buildMarkers(List<Contact> contacts, settings) {
final markers = <Marker>[];

View file

@ -119,14 +119,24 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
// Show debug info if requested
if (showDebug && mounted) {
final frame = buildSendCliCommandFrame(widget.repeater.publicKey, command);
DebugFrameViewer.showFrameDebug(context, frame, context.l10n.repeater_cliCommandFrameTitle);
final frame = buildSendCliCommandFrame(
widget.repeater.publicKey,
command,
);
DebugFrameViewer.showFrameDebug(
context,
frame,
context.l10n.repeater_cliCommandFrameTitle,
);
}
// Send CLI command to repeater with retry
try {
if (_commandService != null) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final connector = Provider.of<MeshCoreConnector>(
context,
listen: false,
);
final repeater = _resolveRepeater(connector);
final response = await _commandService!.sendCommand(
repeater,
@ -230,7 +240,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
Text(l10n.repeater_cliTitle),
Text(
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',
child: Row(
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),
Text(
l10n.repeater_autoUseSavedPath,
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',
child: Row(
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),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
@ -282,7 +311,8 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater),
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
),
IconButton(
icon: const Icon(Icons.bug_report),
@ -473,7 +503,10 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
decoration: InputDecoration(
hintText: l10n.repeater_enterCommandHint,
border: const OutlineInputBorder(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
prefixText: '> ',
),
style: const TextStyle(fontFamily: 'monospace'),
@ -718,10 +751,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
];
final gpsCommands = [
_CommandHelpEntry(
command: 'gps',
description: l10n.repeater_cliHelpGps,
),
_CommandHelpEntry(command: 'gps', description: l10n.repeater_cliHelpGps),
_CommandHelpEntry(
command: 'gps {on|off}',
description: l10n.repeater_cliHelpGpsOnOff,
@ -758,13 +788,25 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
style: const TextStyle(fontSize: 13),
),
const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_general, generalCommands),
_buildHelpSection(
context,
l10n.repeater_general,
generalCommands,
),
const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_settingsCategory, settingsCommands),
_buildHelpSection(
context,
l10n.repeater_settingsCategory,
settingsCommands,
),
const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_bridge, bridgeCommands),
const SizedBox(height: 16),
_buildHelpSection(context, l10n.repeater_logging, loggingCommands),
_buildHelpSection(
context,
l10n.repeater_logging,
loggingCommands,
),
const SizedBox(height: 16),
_buildHelpSection(
context,
@ -813,10 +855,7 @@ class _RepeaterCliScreenState extends State<RepeaterCliScreen> {
),
if (note != null) ...[
const SizedBox(height: 6),
Text(
note,
style: const TextStyle(fontSize: 12),
),
Text(note, style: const TextStyle(fontSize: 12)),
],
const SizedBox(height: 8),
...commands.map((entry) => _buildHelpCommandCard(context, entry)),
@ -871,8 +910,5 @@ class _CommandHelpEntry {
final String command;
final String description;
const _CommandHelpEntry({
required this.command,
required this.description,
});
const _CommandHelpEntry({required this.command, required this.description});
}

View file

@ -28,7 +28,8 @@ class RepeaterStatusScreen extends StatefulWidget {
class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
static const int _statusPayloadOffset = 8;
static const int _statusStatsSize = 52;
static const int _statusResponseBytes = _statusPayloadOffset + _statusStatsSize;
static const int _statusResponseBytes =
_statusPayloadOffset + _statusStatsSize;
bool _isLoading = false;
StreamSubscription<Uint8List>? _frameSubscription;
@ -293,7 +294,9 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.repeater_errorLoadingStatus(e.toString())),
content: Text(
context.l10n.repeater_errorLoadingStatus(e.toString()),
),
backgroundColor: Colors.red,
),
);
@ -327,7 +330,10 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
Text(l10n.repeater_statusTitle),
Text(
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',
child: Row(
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),
Text(
l10n.repeater_autoUseSavedPath,
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',
child: Row(
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),
Text(
l10n.repeater_forceFloodMode,
style: TextStyle(
fontWeight: isFloodMode ? FontWeight.bold : FontWeight.normal,
fontWeight: isFloodMode
? FontWeight.bold
: FontWeight.normal,
),
),
],
@ -379,7 +401,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
IconButton(
icon: const Icon(Icons.timeline),
tooltip: l10n.repeater_pathManagement,
onPressed: () => PathManagementDialog.show(context, contact: repeater),
onPressed: () =>
PathManagementDialog.show(context, contact: repeater),
),
IconButton(
icon: _isLoading
@ -423,11 +446,17 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [
Row(
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),
Text(
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: [
Row(
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),
Text(
l10n.repeater_radioStatistics,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
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_noiseFloor, _formatValue(_noiseFloor, suffix: ' dB')),
_buildInfoRow(
l10n.repeater_noiseFloor,
_formatValue(_noiseFloor, suffix: ' dB'),
),
_buildInfoRow(l10n.repeater_txAirtime, _formatDuration(_txAirSecs)),
_buildInfoRow(l10n.repeater_rxAirtime, _formatDuration(_rxAirSecs)),
],
@ -483,11 +524,17 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
children: [
Row(
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),
Text(
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 '';
final dt = _statusRequestedAt!;
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';
}
@ -598,7 +646,8 @@ class _RepeaterStatusScreenState extends State<RepeaterStatusScreen> {
final direct = _formatValue(_dupDirect);
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!;
if (dupTotal < 0) return '';
return l10n.repeater_duplicatesTotal(dupTotal);

View file

@ -23,22 +23,21 @@ class _ScannerScreenState extends State<ScannerScreen> {
void initState() {
super.initState();
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
_connectionListener = () {
if (connector.state == MeshCoreConnectionState.disconnected) {
_changedNavigation = false;
} else if (connector.state == MeshCoreConnectionState.connected && !_changedNavigation) {
} else if (connector.state == MeshCoreConnectionState.connected &&
!_changedNavigation) {
_changedNavigation = true;
if (mounted) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const ContactsScreen(),
),
MaterialPageRoute(builder: (context) => const ContactsScreen()),
);
}
}
};
connector.addListener(_connectionListener);
}
@ -67,9 +66,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
_buildStatusBar(context, connector),
// Device list
Expanded(
child: _buildDeviceList(context, connector),
),
Expanded(child: _buildDeviceList(context, connector)),
],
);
},
@ -77,8 +74,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
),
floatingActionButton: Consumer<MeshCoreConnector>(
builder: (context, connector, child) {
final isScanning = connector.state == MeshCoreConnectionState.scanning;
final isScanning =
connector.state == MeshCoreConnectionState.scanning;
return FloatingActionButton.extended(
onPressed: () {
if (isScanning) {
@ -87,7 +85,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
connector.startScan();
}
},
icon: isScanning
icon: isScanning
? const SizedBox(
width: 20,
height: 20,
@ -97,7 +95,11 @@ class _ScannerScreenState extends State<ScannerScreen> {
),
)
: 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;
Color statusColor;
final l10n = context.l10n;
final l10n = context.l10n;
switch (connector.state) {
case MeshCoreConnectionState.scanning:
statusText = l10n.scanner_scanning;
@ -155,20 +157,13 @@ final l10n = context.l10n;
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.bluetooth,
size: 64,
color: Colors.grey[400],
),
Icon(Icons.bluetooth, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
connector.state == MeshCoreConnectionState.scanning
? context.l10n.scanner_searchingDevices
: context.l10n.scanner_tapToScan,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
],
),

View file

@ -442,7 +442,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool isGPSEnabled = customVars["gps"] == "1";
// 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();
showDialog(
@ -782,9 +783,7 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> {
final maxTxPower = widget.connector.maxTxPower ?? 22;
if (txPower == null || txPower < 0 || txPower > maxTxPower) {
ScaffoldMessenger.of(
context,
).showSnackBar(
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${l10n.settings_txPowerInvalid} (0-$maxTxPower dBm)'),
),

View file

@ -1,10 +1,6 @@
import 'package:flutter/foundation.dart';
enum AppDebugLogLevel {
info,
warning,
error,
}
enum AppDebugLogLevel { info, warning, error }
class AppDebugLogEntry {
final DateTime timestamp;
@ -51,7 +47,11 @@ class AppDebugLogService extends ChangeNotifier {
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;
_entries.add(

View file

@ -82,10 +82,7 @@ class AppSettingsService extends ChangeNotifier {
final safeMin = minZoom <= maxZoom ? minZoom : maxZoom;
final safeMax = minZoom <= maxZoom ? maxZoom : minZoom;
await updateSettings(
_settings.copyWith(
mapCacheMinZoom: safeMin,
mapCacheMaxZoom: safeMax,
),
_settings.copyWith(mapCacheMinZoom: safeMin, mapCacheMaxZoom: safeMax),
);
}
@ -123,9 +120,16 @@ class AppSettingsService extends ChangeNotifier {
appLogger.setEnabled(value);
}
Future<void> setBatteryChemistryForDevice(String deviceId, String chemistry) async {
final updated = Map<String, String>.from(_settings.batteryChemistryByDeviceId);
Future<void> setBatteryChemistryForDevice(
String deviceId,
String chemistry,
) async {
final updated = Map<String, String>.from(
_settings.batteryChemistryByDeviceId,
);
updated[deviceId] = chemistry;
await updateSettings(_settings.copyWith(batteryChemistryByDeviceId: updated));
await updateSettings(
_settings.copyWith(batteryChemistryByDeviceId: updated),
);
}
}

View file

@ -197,7 +197,7 @@ class BleDebugLogService extends ChangeNotifier {
return 'RESP_CODE_CHANNEL_INFO';
case respCodeRadioSettings:
return 'RESP_CODE_RADIO_SETTINGS';
case pushCodeTraceData:
case pushCodeTraceData:
return 'PUSH_CODE_TRACE_DATA';
default:
return null;

View file

@ -42,20 +42,21 @@ class MapTileCacheService {
late final TileProvider tileProvider;
MapTileCacheService({BaseCacheManager? cacheManager})
: cacheManager = cacheManager ??
CacheManager(
Config(
cacheKey,
stalePeriod: const Duration(days: 365),
maxNrOfCacheObjects: 200000,
),
) {
: cacheManager =
cacheManager ??
CacheManager(
Config(
cacheKey,
stalePeriod: const Duration(days: 365),
maxNrOfCacheObjects: 200000,
),
) {
tileProvider = CachedNetworkTileProvider(cacheManager: this.cacheManager);
}
Map<String, String> get defaultHeaders => {
'User-Agent': 'flutter_map ($userAgentPackageName)',
};
'User-Agent': 'flutter_map ($userAgentPackageName)',
};
Future<void> clearCache() async {
await cacheManager.emptyCache();
@ -96,17 +97,21 @@ class MapTileCacheService {
final future = cacheManager
.downloadFile(url, key: url, authHeaders: authHeaders)
.then((_) {
completed += 1;
}).catchError((_) {
completed += 1;
failed += 1;
}).whenComplete(() {
onProgress?.call(MapTileCacheProgress(
completed: completed,
total: total,
failed: failed,
));
});
completed += 1;
})
.catchError((_) {
completed += 1;
failed += 1;
})
.whenComplete(() {
onProgress?.call(
MapTileCacheProgress(
completed: completed,
total: total,
failed: failed,
),
);
});
pending.add(future);
if (pending.length >= safeConcurrency) {
@ -189,11 +194,9 @@ class MapTileCacheService {
int _latToTileY(double lat, int zoom, int maxIndex) {
final n = 1 << zoom;
final rad = lat * math.pi / 180.0;
final value = ((1 -
math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) /
2 *
n)
.floor();
final value =
((1 - math.log(math.tan(rad) + 1 / math.cos(rad)) / math.pi) / 2 * n)
.floor();
return value.clamp(0, maxIndex);
}

View file

@ -25,10 +25,7 @@ class _AckHashMapping {
final String messageId;
final DateTime timestamp;
_AckHashMapping({
required this.messageId,
required this.timestamp,
});
_AckHashMapping({required this.messageId, required this.timestamp});
}
class MessageRetryService extends ChangeNotifier {
@ -39,11 +36,16 @@ class MessageRetryService extends ChangeNotifier {
final Map<String, Message> _pendingMessages = {};
final Map<String, Contact> _pendingContacts = {};
final Map<String, PathSelection> _pendingPathSelections = {};
final Map<String, _AckHashMapping> _ackHashToMessageId = {}; // ackHashHex messageId + timestamp for O(1) lookup
final Map<String, List<Uint8List>> _expectedAckHashes = {}; // Track all expected ACKs for retries (for history)
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)
final Map<String, _AckHashMapping> _ackHashToMessageId =
{}; // ackHashHex messageId + timestamp for O(1) lookup
final Map<String, List<Uint8List>> _expectedAckHashes =
{}; // Track all expected ACKs for retries (for history)
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(String, Message)? _addMessageCallback;
@ -130,7 +132,8 @@ class MessageRetryService extends ChangeNotifier {
final messagePathBytes =
pathBytes ?? _resolveMessagePathBytes(contact, useFlood, pathSelection);
final messagePathLength =
pathLength ?? _resolveMessagePathLength(contact, useFlood, pathSelection);
pathLength ??
_resolveMessagePathLength(contact, useFlood, pathSelection);
final message = Message(
senderKey: contact.publicKey,
text: text,
@ -167,15 +170,25 @@ class MessageRetryService extends ChangeNotifier {
if (_setContactPathCallback != null && _clearContactPathCallback != null) {
if (message.pathLength != null && message.pathLength! < 0) {
// 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);
} else if (message.pathLength != null && message.pathLength! >= 0) {
// Specific path (including direct neighbor with pathLength=0)
final pathStr = message.pathBytes.isEmpty
? 'direct'
: message.pathBytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(',');
debugPrint('Setting path [$pathStr] (${message.pathLength} hops) for retry attempt ${message.retryCount}');
await _setContactPathCallback!(contact, message.pathBytes, message.pathLength!);
: message.pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.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
final selfPubKey = _getSelfPublicKeyCallback?.call();
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(
timestampSeconds,
attempt,
outboundText,
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;
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(
'Sent "$shortText" to ${contact.name} → expect ACK hash $expectedHashHex (attempt $attempt)',
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)
@ -209,17 +230,14 @@ class MessageRetryService extends ChangeNotifier {
_pendingMessageQueuePerContact[contact.publicKeyHex]!.add(messageId);
if (_sendMessageCallback != null) {
_sendMessageCallback!(
contact,
message.text,
attempt,
timestampSeconds,
);
_sendMessageCallback!(contact, message.text, attempt, timestampSeconds);
}
}
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)
String? messageId = _expectedHashToMessageId.remove(ackHashHex);
@ -230,16 +248,21 @@ class MessageRetryService extends ChangeNotifier {
final message = _pendingMessages[messageId];
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(
'RESP_CODE_SENT received: ACK hash $ackHashHex ✓ matched "$shortText" to ${contact.name}',
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
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
} else {
@ -259,7 +282,9 @@ class MessageRetryService extends ChangeNotifier {
'RESP_CODE_SENT: ACK hash $ackHashHex not found in hash table, falling back to queue',
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) {
final contactKey = entry.key;
@ -271,7 +296,9 @@ class MessageRetryService extends ChangeNotifier {
if (_pendingMessages.containsKey(candidateMessageId)) {
messageId = 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;
} else {
debugPrint('Dequeued stale message $candidateMessageId - skipping');
@ -280,7 +307,9 @@ class MessageRetryService extends ChangeNotifier {
if (_pendingMessages.containsKey(nextMessageId)) {
messageId = nextMessageId;
contact = _pendingContacts[nextMessageId];
debugPrint('Queue-based match (fallback): $ackHashHex → message $messageId');
debugPrint(
'Queue-based match (fallback): $ackHashHex → message $messageId',
);
break;
}
}
@ -306,16 +335,22 @@ class MessageRetryService extends ChangeNotifier {
final selection = _pendingPathSelections[messageId];
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);
return;
}
// Add this ACK hash to the list of expected ACKs for this message (for history)
_expectedAckHashes[messageId] ??= [];
if (!_expectedAckHashes[messageId]!.any((hash) => listEquals(hash, ackHash))) {
if (!_expectedAckHashes[messageId]!.any(
(hash) => listEquals(hash, 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
@ -330,8 +365,13 @@ class MessageRetryService extends ChangeNotifier {
} else {
pathLengthValue = contact.pathLength;
}
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length);
debugPrint('Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue');
actualTimeout = _calculateTimeoutCallback!(
pathLengthValue,
message.text.length,
);
debugPrint(
'Using calculated timeout: ${actualTimeout}ms for path length $pathLengthValue',
);
}
final updatedMessage = message.copyWith(
@ -364,16 +404,22 @@ class MessageRetryService extends ChangeNotifier {
final selection = _pendingPathSelections[messageId];
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;
}
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(
'Timeout: No ACK received for "$shortText" to ${contact.name} (attempt ${message.retryCount}) → retrying',
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) {
final backoffMs = 1000 * (1 << message.retryCount);
@ -402,7 +448,9 @@ class MessageRetryService extends ChangeNotifier {
if (_pendingMessages.containsKey(messageId)) {
_attemptSend(messageId);
} else {
debugPrint('Retry cancelled: message $messageId was delivered while waiting');
debugPrint(
'Retry cancelled: message $messageId was delivered while waiting',
);
}
});
} else {
@ -420,7 +468,8 @@ class MessageRetryService extends ChangeNotifier {
// Clean up the queue entry for this contact
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(messageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
@ -430,7 +479,13 @@ class MessageRetryService extends ChangeNotifier {
_clearContactPathCallback!(contact);
}
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, false, null);
_recordPathResultFromMessage(
contact.publicKeyHex,
message,
selection,
false,
null,
);
if (_updateMessageCallback != null) {
_updateMessageCallback!(failedMessage);
@ -443,18 +498,22 @@ class MessageRetryService extends ChangeNotifier {
void _moveAckHashesToHistory(String messageId) {
final ackHashes = _expectedAckHashes.remove(messageId);
if (ackHashes != null && ackHashes.isNotEmpty) {
_ackHistory.add(_AckHistoryEntry(
messageId: messageId,
ackHashes: ackHashes,
timestamp: DateTime.now(),
));
_ackHistory.add(
_AckHistoryEntry(
messageId: messageId,
ackHashes: ackHashes,
timestamp: DateTime.now(),
),
);
// Trim history to max size (rolling buffer)
while (_ackHistory.length > maxAckHistorySize) {
_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 expectedHash in entry.ackHashes) {
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;
}
}
@ -472,7 +533,9 @@ class MessageRetryService extends ChangeNotifier {
void handleAckReceived(Uint8List ackHash, int tripTimeMs) {
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');
@ -502,7 +565,9 @@ class MessageRetryService extends ChangeNotifier {
tag: 'AckHash',
);
// 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) {
final messageId = entry.key;
final expectedHashes = entry.value;
@ -510,7 +575,9 @@ class MessageRetryService extends ChangeNotifier {
for (final expectedHash in expectedHashes) {
if (listEquals(expectedHash, ackHash)) {
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;
}
}
@ -524,7 +591,9 @@ class MessageRetryService extends ChangeNotifier {
final contact = _pendingContacts[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(
'PUSH_CODE_SEND_CONFIRMED: ACK hash $ackHashHex ✓ "$shortText" delivered to ${contact?.name ?? "unknown"} in ${tripTimeMs}ms',
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)
if (contact != null) {
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(matchedMessageId);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ?? false) {
_pendingMessageQueuePerContact[contact.publicKeyHex]?.remove(
matchedMessageId,
);
if (_pendingMessageQueuePerContact[contact.publicKeyHex]?.isEmpty ??
false) {
_pendingMessageQueuePerContact.remove(contact.publicKeyHex);
}
}
@ -560,7 +632,13 @@ class MessageRetryService extends ChangeNotifier {
}
if (contact != null) {
_recordPathResultFromMessage(contact.publicKeyHex, message, selection, true, tripTimeMs);
_recordPathResultFromMessage(
contact.publicKeyHex,
message,
selection,
true,
tripTimeMs,
);
}
notifyListeners();
@ -663,7 +741,12 @@ class MessageRetryService extends ChangeNotifier {
if (_recordPathResultCallback == null) return;
final recordSelection = selection ?? _selectionFromMessage(message);
if (recordSelection == null) return;
_recordPathResultCallback!(contactKey, recordSelection, success, tripTimeMs);
_recordPathResultCallback!(
contactKey,
recordSelection,
success,
tripTimeMs,
);
}
PathSelection? _selectionFromMessage(Message message) {

View file

@ -6,13 +6,16 @@ class NotificationService {
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin();
final FlutterLocalNotificationsPlugin _notifications =
FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
Future<void> initialize() async {
if (_isInitialized) return;
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
@ -47,16 +50,20 @@ class NotificationService {
}
// Request Android 13+ notification permission
final androidPlugin = _notifications.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin>();
final androidPlugin = _notifications
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
final granted = await androidPlugin.requestNotificationsPermission();
return granted ?? false;
}
// iOS permissions are requested during initialization
final iosPlugin = _notifications.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin>();
final iosPlugin = _notifications
.resolvePlatformSpecificImplementation<
IOSFlutterLocalNotificationsPlugin
>();
if (iosPlugin != null) {
final granted = await iosPlugin.requestPermissions(
alert: true,
@ -204,9 +211,7 @@ class NotificationService {
);
final preview = message.trim();
final body = preview.isEmpty
? 'Received new message'
: preview;
final body = preview.isEmpty ? 'Received new message' : preview;
await _notifications.show(
channelIndex?.hashCode ?? DateTime.now().millisecondsSinceEpoch,

View file

@ -61,7 +61,10 @@ class PathHistoryService extends ChangeNotifier {
int? tripTimeMs,
}) {
if (selection.useFlood) {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
final stats = _floodStats.putIfAbsent(
contactPubKeyHex,
() => _FloodStats(),
);
if (success) {
stats.successCount += 1;
if (tripTimeMs != null) stats.lastTripTimeMs = tripTimeMs;
@ -88,23 +91,28 @@ class PathHistoryService extends ChangeNotifier {
}
PathSelection getNextAutoPathSelection(String contactPubKeyHex) {
final ranked = _getRankedPaths(contactPubKeyHex)
.take(_autoRotationTopCount)
.toList();
final ranked = _getRankedPaths(
contactPubKeyHex,
).take(_autoRotationTopCount).toList();
if (ranked.isEmpty) {
return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
}
_trackAccess(contactPubKeyHex);
final selections = ranked
.map((path) => PathSelection(
pathBytes: path.pathBytes,
hopCount: path.hopCount,
useFlood: false,
))
.toList()
..add(const PathSelection(pathBytes: [], hopCount: -1, useFlood: true));
final selections =
ranked
.map(
(path) => PathSelection(
pathBytes: path.pathBytes,
hopCount: path.hopCount,
useFlood: false,
),
)
.toList()
..add(
const PathSelection(pathBytes: [], hopCount: -1, useFlood: true),
);
final currentIndex = _autoRotationIndex[contactPubKeyHex] ?? 0;
final selection = selections[currentIndex % selections.length];
@ -241,7 +249,8 @@ class PathHistoryService extends ChangeNotifier {
}
Future<ContactPathHistory?> _loadHistoryFromStorage(
String contactPubKeyHex) async {
String contactPubKeyHex,
) async {
return await _storage.loadPathHistory(contactPubKeyHex);
}
@ -308,8 +317,10 @@ class PathHistoryService extends ChangeNotifier {
..removeWhere((p) => p.pathBytes.isEmpty);
ranked.sort((a, b) {
final aRate = (a.successCount + 1) / (a.successCount + a.failureCount + 2);
final bRate = (b.successCount + 1) / (b.successCount + b.failureCount + 2);
final aRate =
(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 (a.successCount != b.successCount) {
return b.successCount.compareTo(a.successCount);
@ -329,7 +340,10 @@ class PathHistoryService extends ChangeNotifier {
}
void _updateFloodStats(String contactPubKeyHex) {
final stats = _floodStats.putIfAbsent(contactPubKeyHex, () => _FloodStats());
final stats = _floodStats.putIfAbsent(
contactPubKeyHex,
() => _FloodStats(),
);
stats.lastUsed = DateTime.now();
}

View file

@ -26,7 +26,9 @@ class RepeaterCommandService {
int retries = maxRetries,
}) async {
final repeaterKey = repeater.publicKeyHex;
final hasPending = _pendingCommands.keys.any((id) => id.startsWith(repeaterKey));
final hasPending = _pendingCommands.keys.any(
(id) => id.startsWith(repeaterKey),
);
if (hasPending) {
throw Exception('Another command is still awaiting a response.');
}
@ -84,7 +86,9 @@ class RepeaterCommandService {
attempt: attempt,
timestampSeconds: timestampSeconds,
);
final responseBytes = frame.length > maxFrameSize ? frame.length : maxFrameSize;
final responseBytes = frame.length > maxFrameSize
? frame.length
: maxFrameSize;
final timeoutMs = _connector.calculateTimeout(
pathLength: pathLengthValue,
messageBytes: responseBytes,
@ -97,7 +101,9 @@ class RepeaterCommandService {
() {
final completer = _pendingCommands[commandId];
if (completer != null && !completer.isCompleted) {
completer.completeError('Command timeout after $timeoutSeconds seconds');
completer.completeError(
'Command timeout after $timeoutSeconds seconds',
);
_cleanup(commandId);
}
},

View file

@ -8,7 +8,9 @@ class StorageService {
static const String _repeaterPasswordsKey = 'repeater_passwords';
Future<void> savePathHistory(
String contactPubKeyHex, ContactPathHistory history) async {
String contactPubKeyHex,
ContactPathHistory history,
) async {
final prefs = PrefsManager.instance;
final key = '$_pathHistoryPrefix$contactPubKeyHex';
final jsonStr = jsonEncode(history.toJson());
@ -39,8 +41,9 @@ class StorageService {
Future<void> clearAllPathHistories() async {
final prefs = PrefsManager.instance;
final keys = prefs.getKeys();
final pathHistoryKeys =
keys.where((key) => key.startsWith(_pathHistoryPrefix));
final pathHistoryKeys = keys.where(
(key) => key.startsWith(_pathHistoryPrefix),
);
for (final key in pathHistoryKeys) {
await prefs.remove(key);
@ -74,7 +77,9 @@ class StorageService {
/// Save a repeater password by public key hex
Future<void> saveRepeaterPassword(
String repeaterPubKeyHex, String password) async {
String repeaterPubKeyHex,
String password,
) async {
final prefs = PrefsManager.instance;
final passwords = await loadRepeaterPasswords();
passwords[repeaterPubKeyHex] = password;

View file

@ -8,7 +8,10 @@ class ChannelMessageStore {
static const String _keyPrefix = 'channel_messages_';
/// 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 key = '$_keyPrefix$channelIndex';
@ -96,7 +99,8 @@ class ChannelMessageStore {
pathVariants: (json['pathVariants'] as List<dynamic>?)
?.map((entry) => Uint8List.fromList(base64Decode(entry as String)))
.toList(),
repeats: (json['repeats'] as List<dynamic>?)
repeats:
(json['repeats'] as List<dynamic>?)
?.map((entry) => _repeatFromJson(entry as Map<String, dynamic>))
.toList() ??
const [],
@ -105,15 +109,19 @@ class ChannelMessageStore {
replyToMessageId: json['replyToMessageId'] as String?,
replyToSenderName: json['replyToSenderName'] as String?,
replyToText: json['replyToText'] as String?,
reactions: (json['reactions'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(key, value as int),
) ?? {},
reactions:
(json['reactions'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(key, value as int),
) ??
{},
);
}
Map<String, dynamic> _repeatToJson(Repeat repeat) {
return {
'repeaterKey': repeat.repeaterKey != null ? base64Encode(repeat.repeaterKey!) : null,
'repeaterKey': repeat.repeaterKey != null
? base64Encode(repeat.repeaterKey!)
: null,
'repeaterName': repeat.repeaterName,
'tripTimeMs': repeat.tripTimeMs,
'path': repeat.path?.map((bytes) => base64Encode(bytes)).toList() ?? [],

View file

@ -16,7 +16,10 @@ class ChannelOrderStore {
try {
final decoded = jsonDecode(raw);
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 (_) {
// fall through to legacy parse

View file

@ -40,7 +40,7 @@ class CommunityStore {
/// Add a new community
Future<void> addCommunity(Community community) async {
final communities = await loadCommunities();
// Check if community with same ID already exists
final existingIndex = communities.indexWhere((c) => c.id == community.id);
if (existingIndex >= 0) {
@ -49,7 +49,7 @@ class CommunityStore {
} else {
communities.add(community);
}
await saveCommunities(communities);
}
@ -92,10 +92,7 @@ class CommunityStore {
}
/// Add a hashtag channel to a community
Future<void> addHashtagChannel(
String communityId,
String hashtag,
) async {
Future<void> addHashtagChannel(String communityId, String hashtag) async {
final community = await getCommunity(communityId);
if (community != null) {
final updated = community.addHashtagChannel(hashtag);
@ -104,10 +101,7 @@ class CommunityStore {
}
/// Remove a hashtag channel from a community
Future<void> removeHashtagChannel(
String communityId,
String hashtag,
) async {
Future<void> removeHashtagChannel(String communityId, String hashtag) async {
final community = await getCommunity(communityId);
if (community != null) {
final updated = community.removeHashtagChannel(hashtag);

View file

@ -14,7 +14,9 @@ class ContactStore {
try {
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 (_) {
return [];
}
@ -57,12 +59,16 @@ class ContactStore {
: Uint8List(0),
pathOverride: json['pathOverride'] as int?,
pathOverrideBytes: json['pathOverrideBytes'] != null
? Uint8List.fromList(base64Decode(json['pathOverrideBytes'] as String))
? Uint8List.fromList(
base64Decode(json['pathOverrideBytes'] as String),
)
: null,
latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num?)?.toDouble(),
lastSeen: DateTime.fromMillisecondsSinceEpoch(lastSeenMs),
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(lastMessageMs ?? lastSeenMs),
lastMessageAt: DateTime.fromMillisecondsSinceEpoch(
lastMessageMs ?? lastSeenMs,
),
);
}
}

View file

@ -7,7 +7,10 @@ import 'prefs_manager.dart';
class MessageStore {
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 key = '$_keyPrefix$contactKeyHex';
final jsonList = messages.map(_messageToJson).toList();
@ -45,12 +48,16 @@ class MessageStore {
'messageId': msg.messageId,
'retryCount': msg.retryCount,
'estimatedTimeoutMs': msg.estimatedTimeoutMs,
'expectedAckHash': msg.expectedAckHash != null ? base64Encode(msg.expectedAckHash!) : null,
'expectedAckHash': msg.expectedAckHash != null
? base64Encode(msg.expectedAckHash!)
: null,
'sentAt': msg.sentAt?.millisecondsSinceEpoch,
'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch,
'tripTimeMs': msg.tripTimeMs,
'pathLength': msg.pathLength,
'pathBytes': msg.pathBytes.isNotEmpty ? base64Encode(msg.pathBytes) : null,
'pathBytes': msg.pathBytes.isNotEmpty
? base64Encode(msg.pathBytes)
: null,
'reactions': msg.reactions,
'fourByteRoomContactKey': base64Encode(msg.fourByteRoomContactKey),
};
@ -59,7 +66,9 @@ class MessageStore {
Message _messageFromJson(Map<String, dynamic> json) {
final rawText = json['text'] as String;
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(
senderKey: Uint8List.fromList(base64Decode(json['senderKey'] as String)),
text: decodedText,
@ -84,11 +93,15 @@ class MessageStore {
pathBytes: json['pathBytes'] != null
? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
: Uint8List(0),
reactions: (json['reactions'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(key, value as int),
) ?? {},
reactions:
(json['reactions'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(key, value as int),
) ??
{},
fourByteRoomContactKey: json['fourByteRoomContactKey'] != null
? Uint8List.fromList(base64Decode(json['fourByteRoomContactKey'] as String))
? Uint8List.fromList(
base64Decode(json['fourByteRoomContactKey'] as String),
)
: null,
);
}

View file

@ -21,7 +21,8 @@ class PrefsManager {
static SharedPreferences get instance {
if (_instance == null) {
throw StateError(
'PrefsManager not initialized. Call PrefsManager.initialize() in main() before use.');
'PrefsManager not initialized. Call PrefsManager.initialize() in main() before use.',
);
}
return _instance!;
}

View file

@ -92,8 +92,9 @@ class UnreadStore {
if (_pendingChannelLastRead == null) return;
final prefs = PrefsManager.instance;
final asString =
_pendingChannelLastRead!.map((key, value) => MapEntry(key.toString(), value));
final asString = _pendingChannelLastRead!.map(
(key, value) => MapEntry(key.toString(), value),
);
final jsonStr = jsonEncode(asString);
await prefs.setString(_channelLastReadKey, jsonStr);
_pendingChannelLastRead = null;
@ -104,9 +105,6 @@ class UnreadStore {
_contactSaveTimer?.cancel();
_channelSaveTimer?.cancel();
await Future.wait([
_flushContactLastRead(),
_flushChannelLastRead(),
]);
await Future.wait([_flushContactLastRead(), _flushChannelLastRead()]);
}
}

View file

@ -44,7 +44,11 @@ class AppLogger {
}
/// 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) {
_service!.log(message, tag: tag, level: level);
}

View file

@ -29,10 +29,7 @@ BatteryUi batteryUiForPercent(int? percent) {
class BatteryIndicator extends StatefulWidget {
final MeshCoreConnector connector;
const BatteryIndicator({
super.key,
required this.connector,
});
const BatteryIndicator({super.key, required this.connector});
@override
State<BatteryIndicator> createState() => _BatteryIndicatorState();

View file

@ -5,7 +5,11 @@ import '../connector/meshcore_protocol.dart';
/// Debug widget to show the hex dump of a frame
class DebugFrameViewer {
static void showFrameDebug(BuildContext context, Uint8List frame, String title) {
static void showFrameDebug(
BuildContext context,
Uint8List frame,
String title,
) {
final hexString = frame
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(' ');
@ -14,16 +18,26 @@ class DebugFrameViewer {
details.writeln(context.l10n.debugFrame_length(frame.length));
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) {
details.writeln('');
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(
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 typeLabel = txtType == txtTypeCliData
@ -34,7 +48,7 @@ class DebugFrameViewer {
final textBytes = frame.sublist(38);
final nullIdx = textBytes.indexOf(0);
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));
}

View file

@ -7,18 +7,14 @@ class DeviceTile extends StatelessWidget {
final ScanResult scanResult;
final VoidCallback onTap;
const DeviceTile({
super.key,
required this.scanResult,
required this.onTap,
});
const DeviceTile({super.key, required this.scanResult, required this.onTap});
@override
Widget build(BuildContext context) {
final device = scanResult.device;
final rssi = scanResult.rssi;
final name = device.platformName.isNotEmpty
? device.platformName
final name = device.platformName.isNotEmpty
? device.platformName
: scanResult.advertisementData.advName;
return ListTile(
@ -58,12 +54,8 @@ class DeviceTile extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color),
Text(
'$rssi dBm',
style: TextStyle(fontSize: 10, color: color),
),
Text('$rssi dBm', style: TextStyle(fontSize: 10, color: color)),
],
);
}
}

View file

@ -5,32 +5,196 @@ import '../l10n/l10n.dart';
class EmojiPicker extends StatelessWidget {
final Function(String) onEmojiSelected;
const EmojiPicker({
super.key,
required this.onEmojiSelected,
});
const EmojiPicker({super.key, required this.onEmojiSelected});
static const List<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
static const List<String> smileys = [
'😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘',
'😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩', '🥳', '😏',
'😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡',
'🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶',
];
'😀',
'😃',
'😄',
'😁',
'😅',
'😂',
'🤣',
'😊',
'😇',
'🙂',
'🙃',
'😉',
'😌',
'😍',
'🥰',
'😘',
'😗',
'😙',
'😚',
'😋',
'😛',
'😝',
'😜',
'🤪',
'🤨',
'🧐',
'🤓',
'😎',
'🥸',
'🤩',
'🥳',
'😏',
'😒',
'😞',
'😔',
'😟',
'😕',
'🙁',
'😣',
'😖',
'😫',
'😩',
'🥺',
'😢',
'😭',
'😤',
'😠',
'😡',
'🤬',
'🤯',
'😳',
'🥵',
'🥶',
'😱',
'😨',
'😰',
'😥',
'😓',
'🤗',
'🤔',
'🤭',
'🤫',
'🤥',
'😶',
];
static const List<String> gestures = [
'👍', '👎', '👊', '', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘', '👌', '🤌', '🤏', '👈', '👉', '👆',
'👇', '☝️', '👋', '🤚', '🖐️', '', '🖖', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳', '💪',
];
'👍',
'👎',
'👊',
'',
'🤛',
'🤜',
'🤞',
'✌️',
'🤟',
'🤘',
'👌',
'🤌',
'🤏',
'👈',
'👉',
'👆',
'👇',
'☝️',
'👋',
'🤚',
'🖐️',
'',
'🖖',
'👏',
'🙌',
'👐',
'🤲',
'🤝',
'🙏',
'✍️',
'💅',
'🤳',
'💪',
];
static const List<String> hearts = [
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❤️‍🔥', '❤️‍🩹', '💕', '💞', '💓', '💗',
'💖', '💘', '💝', '💟', '💌', '💢', '💥', '💫', '💦', '💨', '🕳️', '💬', '👁️‍🗨️', '🗨️', '🗯️', '💭',
];
'❤️',
'🧡',
'💛',
'💚',
'💙',
'💜',
'🖤',
'🤍',
'🤎',
'💔',
'❤️‍🔥',
'❤️‍🩹',
'💕',
'💞',
'💓',
'💗',
'💖',
'💘',
'💝',
'💟',
'💌',
'💢',
'💥',
'💫',
'💦',
'💨',
'🕳️',
'💬',
'👁️‍🗨️',
'🗨️',
'🗯️',
'💭',
];
static const List<String> objects = [
'🎉', '🎊', '🎈', '🎁', '🎀', '🪅', '🪆', '🏆', '🥇', '🥈', '🥉', '', '', '🥎', '🏀', '🏐',
'🏈', '🏉', '🎾', '🥏', '🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅', '', '🔥',
'', '🌟', '', '', '💡', '🔦', '🏮', '🪔', '📱', '💻', '', '📷', '📺', '📻', '🎵', '🎶', '🚀',
];
'🎉',
'🎊',
'🎈',
'🎁',
'🎀',
'🪅',
'🪆',
'🏆',
'🥇',
'🥈',
'🥉',
'',
'',
'🥎',
'🏀',
'🏐',
'🏈',
'🏉',
'🎾',
'🥏',
'🎳',
'🏏',
'🏑',
'🏒',
'🥍',
'🏓',
'🏸',
'🥊',
'🥋',
'🥅',
'',
'🔥',
'',
'🌟',
'',
'',
'💡',
'🔦',
'🏮',
'🪔',
'📱',
'💻',
'',
'📷',
'📺',
'📻',
'🎵',
'🎶',
'🚀',
];
Map<String, List<String>> _emojiCategories(AppLocalizations l10n) {
return {
@ -60,7 +224,10 @@ class EmojiPicker extends StatelessWidget {
children: [
Text(
l10n.chat_addReaction,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
@ -83,7 +250,9 @@ class EmojiPicker extends StatelessWidget {
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondaryContainer,
color: Theme.of(
context,
).colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
@ -114,11 +283,12 @@ class EmojiPicker extends StatelessWidget {
.map(
(emojis) => GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 8,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: emojis.length,
itemBuilder: (context, index) => InkWell(
onTap: () {

View file

@ -23,10 +23,7 @@ class EmptyState extends StatelessWidget {
children: [
Icon(icon, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
title,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
Text(title, style: TextStyle(fontSize: 16, color: Colors.grey[600])),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
@ -35,10 +32,7 @@ class EmptyState extends StatelessWidget {
textAlign: TextAlign.center,
),
],
if (action != null) ...[
const SizedBox(height: 24),
action!,
],
if (action != null) ...[const SizedBox(height: 24), action!],
],
),
);

View file

@ -7,10 +7,7 @@ import '../l10n/l10n.dart';
class GifPicker extends StatefulWidget {
final Function(String gifId) onGifSelected;
const GifPicker({
super.key,
required this.onGifSelected,
});
const GifPicker({super.key, required this.onGifSelected});
@override
State<GifPicker> createState() => _GifPickerState();
@ -45,11 +42,13 @@ class _GifPickerState extends State<GifPicker> {
});
try {
final response = await http.get(
Uri.parse(
'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=25&rating=g',
),
).timeout(const Duration(seconds: 10));
final response = await http
.get(
Uri.parse(
'https://api.giphy.com/v1/gifs/trending?api_key=$_giphyApiKey&limit=25&rating=g',
),
)
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final data = json.decode(response.body);
@ -85,11 +84,13 @@ class _GifPickerState extends State<GifPicker> {
});
try {
final response = await http.get(
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));
final response = await http
.get(
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));
if (response.statusCode == 200) {
final data = json.decode(response.body);
@ -127,7 +128,10 @@ class _GifPickerState extends State<GifPicker> {
const SizedBox(width: 8),
Text(
context.l10n.gifPicker_title,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
@ -170,18 +174,13 @@ class _GifPickerState extends State<GifPicker> {
const SizedBox(height: 16),
// GIF grid
Expanded(
child: _buildContent(),
),
Expanded(child: _buildContent()),
// Powered by Giphy attribution
const SizedBox(height: 8),
Text(
context.l10n.gifPicker_poweredBy,
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
),
],
),
@ -190,9 +189,7 @@ class _GifPickerState extends State<GifPicker> {
Widget _buildContent() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(),
);
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
@ -244,7 +241,8 @@ class _GifPickerState extends State<GifPicker> {
itemBuilder: (context, index) {
final gif = _gifs[index];
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(
onTap: () {
@ -265,20 +263,16 @@ class _GifPickerState extends State<GifPicker> {
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
loadingProgress.expectedTotalBytes!
loadingProgress.expectedTotalBytes!
: null,
),
);
},
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(Icons.error_outline),
);
return const Center(child: Icon(Icons.error_outline));
},
)
: const Center(
child: Icon(Icons.gif_box),
),
: const Center(child: Icon(Icons.gif_box)),
),
),
);

View file

@ -4,10 +4,7 @@ import '../helpers/chat_scroll_controller.dart';
class JumpToBottomButton extends StatelessWidget {
final ChatScrollController scrollController;
const JumpToBottomButton({
super.key,
required this.scrollController,
});
const JumpToBottomButton({super.key, required this.scrollController});
@override
Widget build(BuildContext context) {

View file

@ -1,18 +1,9 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
enum ContactSortOption {
lastSeen,
recentMessages,
name,
}
enum ContactSortOption { lastSeen, recentMessages, name }
enum ContactTypeFilter {
all,
users,
repeaters,
rooms,
}
enum ContactTypeFilter { all, users, repeaters, rooms }
class SortFilterMenuOption {
final int value;
@ -30,10 +21,7 @@ class SortFilterMenuSection {
final String title;
final List<SortFilterMenuOption> options;
const SortFilterMenuSection({
required this.title,
required this.options,
});
const SortFilterMenuSection({required this.title, required this.options});
}
class SortFilterMenu extends StatelessWidget {
@ -62,7 +50,9 @@ class SortFilterMenu extends StatelessWidget {
color: theme.colorScheme.onSurfaceVariant,
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>>[];
for (int i = 0; i < visibleSections.length; i++) {
final section = visibleSections[i];

View file

@ -10,15 +10,10 @@ import '../services/path_history_service.dart';
import 'path_selection_dialog.dart';
class PathManagementDialog {
static Future<void> show(
BuildContext context, {
required Contact contact,
}) {
static Future<void> show(BuildContext context, {required Contact contact}) {
return showDialog<void>(
context: context,
builder: (context) => _PathManagementDialog(
contact: contact,
),
builder: (context) => _PathManagementDialog(contact: contact),
);
}
}
@ -26,9 +21,7 @@ class PathManagementDialog {
class _PathManagementDialog extends StatelessWidget {
final Contact contact;
const _PathManagementDialog({
required this.contact,
});
const _PathManagementDialog({required this.contact});
Contact _resolveContact(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
@ -83,7 +76,9 @@ class _PathManagementDialog extends StatelessWidget {
Contact currentContact,
) async {
final l10n = context.l10n;
if (currentContact.pathLength > 0 && currentContact.path.isEmpty && connector.isConnected) {
if (currentContact.pathLength > 0 &&
currentContact.path.isEmpty &&
connector.isConnected) {
connector.getContacts();
}
@ -140,13 +135,19 @@ class _PathManagementDialog extends StatelessWidget {
if (paths.isNotEmpty) ...[
Text(
l10n.chat_recentAckPaths,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
if (paths.length >= 100) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.amberAccent,
borderRadius: BorderRadius.circular(8),
@ -165,7 +166,9 @@ class _PathManagementDialog extends StatelessWidget {
dense: true,
leading: CircleAvatar(
radius: 16,
backgroundColor: path.wasFloodDiscovery ? Colors.blue : Colors.green,
backgroundColor: path.wasFloodDiscovery
? Colors.blue
: Colors.green,
child: Text(
'${path.hopCount}',
style: const TextStyle(fontSize: 12),
@ -193,16 +196,27 @@ class _PathManagementDialog extends StatelessWidget {
},
),
path.wasFloodDiscovery
? const Icon(Icons.waves, size: 16, color: Colors.grey)
: const Icon(Icons.route, size: 16, color: Colors.grey),
? const Icon(
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 {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.chat_pathDetailsNotAvailable),
content: Text(
l10n.chat_pathDetailsNotAvailable,
),
duration: const Duration(seconds: 2),
),
);
@ -222,7 +236,9 @@ class _PathManagementDialog extends StatelessWidget {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.path_usingHopsPath(path.hopCount)),
content: Text(
l10n.path_usingHopsPath(path.hopCount),
),
duration: const Duration(seconds: 2),
),
);
@ -238,7 +254,10 @@ class _PathManagementDialog extends StatelessWidget {
const SizedBox(height: 8),
Text(
l10n.chat_pathActions,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
const SizedBox(height: 8),
ListTile(
@ -248,8 +267,14 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.purple,
child: Icon(Icons.edit_road, size: 16),
),
title: Text(l10n.chat_setCustomPath, style: const TextStyle(fontSize: 14)),
subtitle: Text(l10n.chat_setCustomPathSubtitle, style: const TextStyle(fontSize: 11)),
title: Text(
l10n.chat_setCustomPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
l10n.chat_setCustomPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await _setCustomPath(context, connector, currentContact);
},
@ -261,8 +286,14 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.orange,
child: Icon(Icons.clear_all, size: 16),
),
title: Text(l10n.chat_clearPath, style: const TextStyle(fontSize: 14)),
subtitle: Text(l10n.chat_clearPathSubtitle, style: const TextStyle(fontSize: 11)),
title: Text(
l10n.chat_clearPath,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
l10n.chat_clearPathSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await connector.clearContactPath(currentContact);
if (!context.mounted) return;
@ -282,10 +313,19 @@ class _PathManagementDialog extends StatelessWidget {
backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16),
),
title: Text(l10n.chat_forceFloodMode, style: const TextStyle(fontSize: 14)),
subtitle: Text(l10n.chat_floodModeSubtitle, style: const TextStyle(fontSize: 11)),
title: Text(
l10n.chat_forceFloodMode,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
l10n.chat_floodModeSubtitle,
style: const TextStyle(fontSize: 11),
),
onTap: () async {
await connector.setPathOverride(currentContact, pathLen: -1);
await connector.setPathOverride(
currentContact,
pathLen: -1,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(

View file

@ -70,12 +70,15 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
}
void _updateTextFromContacts() {
final pathParts = _selectedContacts.map((contact) {
if (contact.publicKeyHex.length >= 2) {
return contact.publicKeyHex.substring(0, 2);
}
return '';
}).where((s) => s.isNotEmpty).toList();
final pathParts = _selectedContacts
.map((contact) {
if (contact.publicKeyHex.length >= 2) {
return contact.publicKeyHex.substring(0, 2);
}
return '';
})
.where((s) => s.isNotEmpty)
.toList();
_controller.text = pathParts.join(',');
}
@ -107,7 +110,11 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
}
// 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 invalidPrefixes = <String>[];
@ -132,7 +139,9 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
if (invalidPrefixes.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.path_invalidHexPrefixes(invalidPrefixes.join(", "))),
content: Text(
l10n.path_invalidHexPrefixes(invalidPrefixes.join(", ")),
),
duration: const Duration(seconds: 3),
backgroundColor: Colors.red,
),
@ -180,7 +189,10 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
children: [
Text(
l10n.path_currentPathLabel,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (widget.onRefresh != null)
@ -225,7 +237,10 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
children: [
Text(
l10n.path_selectFromContacts,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (_selectedContacts.isNotEmpty)
@ -242,7 +257,11 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
padding: const EdgeInsets.all(16.0),
child: Column(
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),
Text(
l10n.path_noRepeatersFound,
@ -252,7 +271,10 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
const SizedBox(height: 8),
Text(
l10n.path_customPathsRequire,
style: const TextStyle(fontSize: 12, color: Colors.grey),
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
textAlign: TextAlign.center,
),
],
@ -275,20 +297,30 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
radius: 16,
backgroundColor: isSelected
? Colors.green
: (contact.type == 2 ? Colors.blue : Colors.purple),
: (contact.type == 2
? Colors.blue
: Colors.purple),
child: Icon(
contact.type == 2 ? Icons.router : Icons.meeting_room,
contact.type == 2
? Icons.router
: Icons.meeting_room,
size: 16,
color: Colors.white,
),
),
title: Text(contact.name, style: const TextStyle(fontSize: 14)),
title: Text(
contact.name,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
'${contact.typeLabel}${contact.publicKeyHex.substring(0, 2)}',
style: const TextStyle(fontSize: 10),
),
trailing: isSelected
? const Icon(Icons.check_circle, color: Colors.green)
? const Icon(
Icons.check_circle,
color: Colors.green,
)
: const Icon(Icons.add_circle_outline),
onTap: () => _toggleContact(contact),
);

View file

@ -8,13 +8,9 @@ import '../connector/meshcore_protocol.dart';
import '../models/contact.dart';
import '../widgets/snr_indicator.dart';
import '../l10n/l10n.dart';
class PathTraceDialog extends StatefulWidget {
const PathTraceDialog({
super.key,
required this.title,
required this.path,
});
class PathTraceDialog extends StatefulWidget {
const PathTraceDialog({super.key, required this.title, required this.path});
final String title;
final Uint8List path;
@ -31,7 +27,7 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
bool _failed2Loaded = false;
bool _hasData = false;
Uint8List _pathData = Uint8List(0);
Uint8List _snrData = Uint8List(0) ;
Uint8List _snrData = Uint8List(0);
Map<int, Contact> _pathContacts = {};
@override
@ -49,13 +45,13 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
}
Future<void> _doPathTrace() async {
if(mounted) {
if (mounted) {
setState(() {
_isLoading = true;
_failed2Loaded = false;
});
}
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final frame = buildTraceReq(
DateTime.now().millisecondsSinceEpoch ~/ 1000,
@ -92,18 +88,19 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
}
// 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();
if (!mounted) return;
frameBuffer.skipBytes(3); //reserved + path length + flag
if(listEquals(frameBuffer.readBytes(4), tagData)){
if (listEquals(frameBuffer.readBytes(4), tagData)) {
_handleTraceResponse(frame);
}
}
});
}
Future<void> _handleTraceResponse(Uint8List frame)async {
Future<void> _handleTraceResponse(Uint8List frame) async {
final connector = Provider.of<MeshCoreConnector>(context, listen: false);
final buffer = BufferReader(frame);
@ -116,9 +113,7 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
Map<int, Contact> pathContacts = {};
connector.contacts.where((c) => c.type != advTypeChat).forEach((
repeater,
) {
connector.contacts.where((c) => c.type != advTypeChat).forEach((repeater) {
for (var neighbourData in pathData) {
if (listEquals(
repeater.publicKey.sublist(0, 1),
@ -143,21 +138,26 @@ class _PathTraceDialogState extends State<PathTraceDialog> {
if (index == 0) {
return context.l10n.pathTrace_you;
} 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 {
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) {
if (index == 0 || index == _snrData.length - 1) {
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 {
return context.l10n.pathTrace_you;
}
} 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) {
final l10n = context.l10n;
return AlertDialog(
title: Column( children: [
FittedBox(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),),
title: Column(
children: [
FittedBox(
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(
child: RefreshIndicator(
onRefresh: _doPathTrace,
child: !_hasData
? Center(
child: Text(l10n.pathTrace_notAvailable),
)
: ListView.builder(
itemCount: _snrData.length,
itemBuilder: (context, index) {
return Column(
children: [
ListTile(
leading: index >= _snrData.length / 2 ? Icon(Icons.call_received) : Icon(Icons.call_made),
title: Text(
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
},
? Center(child: Text(l10n.pathTrace_notAvailable))
: ListView.builder(
itemCount: _snrData.length,
itemBuilder: (context, index) {
return Column(
children: [
ListTile(
leading: index >= _snrData.length / 2
? Icon(Icons.call_received)
: Icon(Icons.call_made),
title: Text(
formatDirectionText(index),
style: const TextStyle(fontSize: 14),
),
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: [
IconButton(
IconButton(
icon: _isLoading
? const SizedBox(
width: 20,

View file

@ -121,10 +121,7 @@ class QrCodeDisplay extends StatelessWidget {
size: size,
backgroundColor: bgColor,
errorCorrectionLevel: errorCorrectionLevel,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: fgColor,
),
eyeStyle: QrEyeStyle(eyeShape: QrEyeShape.square, color: fgColor),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: fgColor,
@ -143,10 +140,7 @@ class QrCodeDisplay extends StatelessWidget {
backgroundColor: bgColor,
// Use higher error correction when embedding image
errorCorrectionLevel: QrErrorCorrectLevel.H,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: fgColor,
),
eyeStyle: QrEyeStyle(eyeShape: QrEyeShape.square, color: fgColor),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: fgColor,

View file

@ -215,10 +215,7 @@ class _QrScannerWidgetState extends State<QrScannerWidget>
),
child: Text(
widget.instructions!,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
style: const TextStyle(color: Colors.white, fontSize: 14),
textAlign: TextAlign.center,
),
),
@ -274,7 +271,8 @@ class _QrScannerWidgetState extends State<QrScannerWidget>
switch (error.errorCode) {
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;
break;
case MobileScannerErrorCode.unsupported:
@ -282,7 +280,8 @@ class _QrScannerWidgetState extends State<QrScannerWidget>
icon = Icons.videocam_off;
break;
default:
message = 'Failed to start camera.\n${error.errorDetails?.message ?? ''}';
message =
'Failed to start camera.\n${error.errorDetails?.message ?? ''}';
icon = Icons.error_outline;
}
@ -297,10 +296,7 @@ class _QrScannerWidgetState extends State<QrScannerWidget>
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.grey[600],
fontSize: 16,
),
style: TextStyle(color: Colors.grey[600], fontSize: 16),
),
],
),

View file

@ -44,8 +44,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
}
Future<void> _loadSavedPassword() async {
final savedPassword =
await _storage.getRepeaterPassword(widget.repeater.publicKeyHex);
final savedPassword = await _storage.getRepeaterPassword(
widget.repeater.publicKeyHex,
);
if (savedPassword != null) {
setState(() {
_passwordController.text = savedPassword;
@ -102,12 +103,10 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
);
final timeoutSeconds = (timeoutMs / 1000).ceil();
final timeout = Duration(milliseconds: timeoutMs);
final selectionLabel =
selection.useFlood ? 'flood' : '${selection.hopCount} hops';
appLogger.info(
'Login routing: $selectionLabel',
tag: 'RepeaterLogin',
);
final selectionLabel = selection.useFlood
? 'flood'
: '${selection.hopCount} hops';
appLogger.info('Login routing: $selectionLabel', tag: 'RepeaterLogin');
bool? loginResult;
for (int attempt = 0; attempt < _maxAttempts; attempt++) {
if (!mounted) return;
@ -119,9 +118,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
'Sending login attempt ${attempt + 1}/$_maxAttempts',
tag: 'RepeaterLogin',
);
await _connector.sendFrame(
loginFrame,
);
await _connector.sendFrame(loginFrame);
loginResult = await _awaitLoginResponse(timeout);
if (loginResult == true) {
@ -171,7 +168,9 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
// Save password if requested
if (_savePassword) {
await _storage.saveRepeaterPassword(
widget.repeater.publicKeyHex, password);
widget.repeater.publicKeyHex,
password,
);
} else {
// Remove saved password if user unchecked the box
await _storage.removeRepeaterPassword(widget.repeater.publicKeyHex);
@ -269,152 +268,183 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
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,
Text(
l10n.login_repeaterDescription,
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,
),
),
],
),
const SizedBox(height: 16),
if (_loginError != null) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.error,
size: 18,
color: Theme.of(context).colorScheme.error,
),
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(width: 8),
Expanded(
child: Text(
_loginError!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 13,
),
),
),
],
),
const SizedBox(height: 12),
],
),
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),
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),
),
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: [
TextButton(
onPressed: () => Navigator.pop(context),

View file

@ -15,11 +15,7 @@ class RoomLoginDialog extends StatefulWidget {
final Contact room;
final Function(String password) onLogin;
const RoomLoginDialog({
super.key,
required this.room,
required this.onLogin,
});
const RoomLoginDialog({super.key, required this.room, required this.onLogin});
@override
State<RoomLoginDialog> createState() => _RoomLoginDialogState();
@ -43,8 +39,9 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
}
Future<void> _loadSavedPassword() async {
final savedPassword =
await _storage.getRepeaterPassword(widget.room.publicKeyHex);
final savedPassword = await _storage.getRepeaterPassword(
widget.room.publicKeyHex,
);
if (savedPassword != null) {
setState(() {
_passwordController.text = savedPassword;
@ -100,12 +97,10 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
);
final timeoutSeconds = (timeoutMs / 1000).ceil();
final timeout = Duration(milliseconds: timeoutMs);
final selectionLabel =
selection.useFlood ? 'flood' : '${selection.hopCount} hops';
appLogger.info(
'Login routing: $selectionLabel',
tag: 'RoomLogin',
);
final selectionLabel = selection.useFlood
? 'flood'
: '${selection.hopCount} hops';
appLogger.info('Login routing: $selectionLabel', tag: 'RoomLogin');
bool? loginResult;
for (int attempt = 0; attempt < _maxAttempts; attempt++) {
if (!mounted) return;
@ -117,23 +112,15 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
'Sending login attempt ${attempt + 1}/$_maxAttempts',
tag: 'RoomLogin',
);
await _connector.sendFrame(
loginFrame,
);
await _connector.sendFrame(loginFrame);
loginResult = await _awaitLoginResponse(timeout);
if (loginResult == true) {
appLogger.info(
'Login succeeded for ${room.name}',
tag: 'RoomLogin',
);
appLogger.info('Login succeeded for ${room.name}', tag: 'RoomLogin');
break;
}
if (loginResult == false) {
appLogger.warn(
'Login failed for ${room.name}',
tag: 'RoomLogin',
);
appLogger.warn('Login failed for ${room.name}', tag: 'RoomLogin');
throw Exception('Wrong password or node is unreachable');
}
appLogger.warn(
@ -143,10 +130,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
}
if (loginResult == null) {
appLogger.warn(
'Login timed out for ${room.name}',
tag: 'RoomLogin',
);
appLogger.warn('Login timed out for ${room.name}', tag: 'RoomLogin');
}
if (loginResult == true) {
@ -162,8 +146,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
// If we got a response, login succeeded
// Save password if requested
if (_savePassword) {
await _storage.saveRepeaterPassword(
widget.room.publicKeyHex, password);
await _storage.saveRepeaterPassword(widget.room.publicKeyHex, password);
} else {
// Remove saved password if user unchecked the box
await _storage.removeRepeaterPassword(widget.room.publicKeyHex);
@ -175,10 +158,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
}
} catch (e) {
final room = _resolveRepeater(_connector);
appLogger.warn(
'Login error for ${room.name}: $e',
tag: 'RoomLogin',
);
appLogger.warn('Login error for ${room.name}: $e', tag: 'RoomLogin');
if (mounted) {
setState(() {
_isLoggingIn = false;
@ -262,130 +242,157 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
),
)
: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
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,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.login_roomDescription,
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,
),
),
],
),
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,
),
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,
),
),
],
),
),
],
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
],
),
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),
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: [
TextButton(
onPressed: () => Navigator.pop(context),

View file

@ -3,10 +3,7 @@ import 'package:flutter/material.dart';
class UnreadBadge extends StatelessWidget {
final int count;
const UnreadBadge({
super.key,
required this.count,
});
const UnreadBadge({super.key, required this.count});
@override
Widget build(BuildContext context) {

View file

@ -7,30 +7,50 @@ void main() {
group('reactionEmojis', () {
test('should contain all emoji categories', () {
final emojis = ReactionHelper.reactionEmojis;
// Should contain 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
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
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
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
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', () {
// First emoji (thumbs up) should be index 0
expect(ReactionHelper.emojiToIndex('👍'), equals('00'));
// Second emoji (heart) should be index 1
expect(ReactionHelper.emojiToIndex('❤️'), equals('01'));
});
@ -67,7 +87,10 @@ void main() {
});
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(''), isNull); // Empty string
// 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 index = ReactionHelper.emojiToIndex(emoji);
expect(index, isNotNull, reason: 'emojiToIndex failed for $emoji');
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', () {
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}$')));
});
test('should be deterministic', () {
final hash1 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello');
final hash2 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello');
final hash1 = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'Hello',
);
final hash2 = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'Hello',
);
expect(hash1, equals(hash2));
});
test('should differ for different inputs', () {
final hash1 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello');
final hash2 = ReactionHelper.computeReactionHash(1234567890, 'Bob', 'Hello');
final hash3 = ReactionHelper.computeReactionHash(1234567891, 'Alice', 'Hello');
final hash4 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'World');
final hash1 = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'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(hash3))); // Different timestamp
expect(hash1, isNot(equals(hash4))); // Different text
});
test('should use first 5 chars of text', () {
final hash1 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello world');
final hash2 = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello there');
final hash1 = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'Hello world',
);
final hash2 = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'Hello there',
);
expect(hash1, equals(hash2)); // Same first 5 chars
});
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}$')));
});
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}$')));
});
});
group('computeReactionHash with null sender (1:1 chats)', () {
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}$')));
});
test('should be deterministic', () {
final hash1 = ReactionHelper.computeReactionHash(1234567890, null, 'Hello');
final hash2 = ReactionHelper.computeReactionHash(1234567890, null, 'Hello');
final hash1 = ReactionHelper.computeReactionHash(
1234567890,
null,
'Hello',
);
final hash2 = ReactionHelper.computeReactionHash(
1234567890,
null,
'Hello',
);
expect(hash1, equals(hash2));
});
test('should differ for different inputs', () {
final hash1 = ReactionHelper.computeReactionHash(1234567890, null, 'Hello');
final hash2 = ReactionHelper.computeReactionHash(1234567891, null, 'Hello');
final hash3 = ReactionHelper.computeReactionHash(1234567890, null, 'World');
final hash1 = ReactionHelper.computeReactionHash(
1234567890,
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(hash3))); // Different text
});
test('should differ from hash with sender name', () {
// Null sender hash doesn't include sender, so should differ
final nullSenderHash = ReactionHelper.computeReactionHash(1234567890, null, 'Hello');
final withSenderHash = ReactionHelper.computeReactionHash(1234567890, 'Alice', 'Hello');
final nullSenderHash = ReactionHelper.computeReactionHash(
1234567890,
null,
'Hello',
);
final withSenderHash = ReactionHelper.computeReactionHash(
1234567890,
'Alice',
'Hello',
);
expect(nullSenderHash, isNot(equals(withSenderHash)));
});
@ -167,13 +270,21 @@ void main() {
// Bob computes hash the same way Alice's app will match it
const timestamp = 1234567890;
const messageText = 'Hello there!';
// 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
final aliceHash = ReactionHelper.computeReactionHash(timestamp, null, messageText);
final aliceHash = ReactionHelper.computeReactionHash(
timestamp,
null,
messageText,
);
expect(bobHash, equals(aliceHash));
});
});
@ -188,12 +299,30 @@ void main() {
test('should return null for invalid format', () {
expect(ReactionHelper.parseReaction('invalid'), isNull);
expect(ReactionHelper.parseReaction('r:abc:00'), isNull); // Hash too short
expect(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('r:abc:00'),
isNull,
); // Hash too short
expect(
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);
});
@ -220,31 +349,43 @@ void main() {
const emoji = '🎉';
// Compute hash (sender side)
final hash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText);
final hash = ReactionHelper.computeReactionHash(
timestamp,
senderName,
messageText,
);
// Encode emoji (sender side)
final emojiIndex = ReactionHelper.emojiToIndex(emoji);
expect(emojiIndex, isNotNull);
// Build reaction text (sender side)
final reactionText = 'r:$hash:$emojiIndex';
// Parse reaction (receiver side)
final info = ReactionHelper.parseReaction(reactionText);
expect(info, isNotNull);
expect(info!.targetHash, equals(hash));
expect(info.emoji, equals(emoji));
// 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));
});
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 reactionText = 'r:$hash:$index';
// r: (2) + hash (4) + : (1) + index (2) = 9 bytes
expect(reactionText.length, equals(9));
});
@ -257,7 +398,11 @@ void main() {
const emoji = '👍';
// 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 reactionText = 'r:$bobSideHash:$emojiIndex';
@ -266,8 +411,12 @@ void main() {
expect(info, isNotNull);
// 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!
expect(info!.targetHash, equals(aliceSideHash));
expect(info.emoji, equals(emoji));
@ -281,7 +430,11 @@ void main() {
const emoji = '❤️';
// 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 reactionText = 'r:$aliceSideHash:$emojiIndex';
@ -290,8 +443,12 @@ void main() {
expect(info, isNotNull);
// 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!
expect(info!.targetHash, equals(bobSideHash));
expect(info.emoji, equals(emoji));
@ -306,7 +463,11 @@ void main() {
const emoji = '🎉';
// 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 reactionText = 'r:$aliceHash:$emojiIndex';
@ -319,36 +480,59 @@ void main() {
expect(info, isNotNull);
// 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!
expect(info!.targetHash, equals(bobHash));
expect(info.emoji, equals(emoji));
});
test('room server: hash differs from 1:1 hash for same message content', () {
// Same timestamp and text, but room server includes sender name
const timestamp = 1234567890;
const senderName = 'Dave';
const messageText = 'Hello';
test(
'room server: hash differs from 1:1 hash for same message content',
() {
// Same timestamp and text, but room server includes sender name
const timestamp = 1234567890;
const senderName = 'Dave';
const messageText = 'Hello';
// Room server hash (with sender name)
final roomHash = ReactionHelper.computeReactionHash(timestamp, senderName, messageText);
// 1:1 hash (without sender name)
final directHash = ReactionHelper.computeReactionHash(timestamp, null, messageText);
// Room server hash (with sender name)
final roomHash = ReactionHelper.computeReactionHash(
timestamp,
senderName,
messageText,
);
// They should be different!
expect(roomHash, isNot(equals(directHash)));
});
// 1:1 hash (without sender name)
final directHash = ReactionHelper.computeReactionHash(
timestamp,
null,
messageText,
);
// They should be different!
expect(roomHash, isNot(equals(directHash)));
},
);
test('room server: different senders produce different hashes', () {
// Two users send the exact same message at the same time in a room
const timestamp = 1234567890;
const messageText = 'Hello';
final aliceHash = ReactionHelper.computeReactionHash(timestamp, 'Alice', messageText);
final bobHash = ReactionHelper.computeReactionHash(timestamp, 'Bob', messageText);
final aliceHash = ReactionHelper.computeReactionHash(
timestamp,
'Alice',
messageText,
);
final bobHash = ReactionHelper.computeReactionHash(
timestamp,
'Bob',
messageText,
);
// Different senders = different hashes (even with same content)
expect(aliceHash, isNot(equals(bobHash)));
@ -363,7 +547,11 @@ void main() {
const emoji = '👍';
// 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 reactionText = 'r:$bobHash:$emojiIndex';
@ -372,8 +560,12 @@ void main() {
expect(info, isNotNull);
// Alice computes hash using her selfName
final aliceHash = ReactionHelper.computeReactionHash(timestamp, aliceName, messageText);
final aliceHash = ReactionHelper.computeReactionHash(
timestamp,
aliceName,
messageText,
);
// Hashes should match!
expect(info!.targetHash, equals(aliceHash));
});
@ -386,7 +578,11 @@ void main() {
const emoji = '🔥';
// 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 reactionText = 'r:$hash:$emojiIndex';
@ -396,7 +592,11 @@ void main() {
expect(info!.emoji, equals(emoji));
// 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));
});
});