diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index d7d7dc9..6d62f92 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -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().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 diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index dfe787e..25359a8 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -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); diff --git a/lib/helpers/cayenne_lpp.dart b/lib/helpers/cayenne_lpp.dart index ad5aa8c..bf9b8e7 100644 --- a/lib/helpers/cayenne_lpp.dart +++ b/lib/helpers/cayenne_lpp.dart @@ -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': {}, - }); + final channelData = channels.putIfAbsent( + channel, + () => {'channel': channel, 'values': {}}, + ); switch (type) { case lppGenericSensor: @@ -254,8 +256,8 @@ class CayenneLpp { } } - final List> channelsOut = channels.values.toList(); - channelsOut.sort((a, b) => a['channel'].compareTo(b['channel'])); - return channelsOut; + final List> channelsOut = channels.values.toList(); + channelsOut.sort((a, b) => a['channel'].compareTo(b['channel'])); + return channelsOut; } } diff --git a/lib/helpers/link_handler.dart b/lib/helpers/link_handler.dart index fa8e5ff..7a032ef 100644 --- a/lib/helpers/link_handler.dart +++ b/lib/helpers/link_handler.dart @@ -26,10 +26,7 @@ class LinkHandler { ), child: SelectableText( url, - style: const TextStyle( - fontSize: 12, - fontFamily: 'monospace', - ), + style: const TextStyle(fontSize: 12, fontFamily: 'monospace'), ), ), ], diff --git a/lib/helpers/reaction_helper.dart b/lib/helpers/reaction_helper.dart index b75a9fd..88138d6 100644 --- a/lib/helpers/reaction_helper.dart +++ b/lib/helpers/reaction_helper.dart @@ -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); } } diff --git a/lib/helpers/smaz.dart b/lib/helpers/smaz.dart index 0e3e6c9..de34dde 100644 --- a/lib/helpers/smaz.dart +++ b/lib/helpers/smaz.dart @@ -262,8 +262,9 @@ class Smaz { ".com", ]; - static final List _rcbBytes = - _rcb.map((s) => Uint8List.fromList(ascii.encode(s))).toList(growable: false); + static final List _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; diff --git a/lib/helpers/utf8_length_limiter.dart b/lib/helpers/utf8_length_limiter.dart index 843389e..c6acdd2 100644 --- a/lib/helpers/utf8_length_limiter.dart +++ b/lib/helpers/utf8_length_limiter.dart @@ -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; diff --git a/lib/main.dart b/lib/main.dart index cd0887f..19c577f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(), ); }, diff --git a/lib/models/app_settings.dart b/lib/models/app_settings.dart index 50c31ba..3edb68f 100644 --- a/lib/models/app_settings.dart +++ b/lib/models/app_settings.dart @@ -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?, + mapCacheBounds: mapCacheBounds == _unset + ? this.mapCacheBounds + : mapCacheBounds as Map?, 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, ); } } diff --git a/lib/models/channel.dart b/lib/models/channel.dart index e05a870..4e5e8c2 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -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) { diff --git a/lib/models/channel_message.dart b/lib/models/channel_message.dart index 5aae28d..2418871 100644 --- a/lib/models/channel_message.dart +++ b/lib/models/channel_message.dart @@ -59,15 +59,18 @@ class ChannelMessage { this.replyToSenderName, this.replyToText, Map? 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}); } diff --git a/lib/models/community.dart b/lib/models/community.dart index 3bacf88..c829f3d 100644 --- a/lib/models/community.dart +++ b/lib/models/community.dart @@ -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?) + hashtagChannels: + (json['hashtag_channels'] as List?) ?.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; diff --git a/lib/models/contact.dart b/lib/models/contact.dart index c9e40ab..a98580f 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -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 = []; 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]; } } } diff --git a/lib/models/contact_group.dart b/lib/models/contact_group.dart index 000a474..4e52585 100644 --- a/lib/models/contact_group.dart +++ b/lib/models/contact_group.dart @@ -2,15 +2,9 @@ class ContactGroup { final String name; final List memberKeys; - const ContactGroup({ - required this.name, - required this.memberKeys, - }); + const ContactGroup({required this.name, required this.memberKeys}); - ContactGroup copyWith({ - String? name, - List? memberKeys, - }) { + ContactGroup copyWith({String? name, List? memberKeys}) { return ContactGroup( name: name ?? this.name, memberKeys: memberKeys ?? List.from(this.memberKeys), @@ -18,16 +12,12 @@ class ContactGroup { } Map toJson() { - return { - 'name': name, - 'members': memberKeys, - }; + return {'name': name, 'members': memberKeys}; } factory ContactGroup.fromJson(Map json) { - final members = (json['members'] as List?) - ?.map((value) => value.toString()) - .toList() ?? + final members = + (json['members'] as List?)?.map((value) => value.toString()).toList() ?? []; return ContactGroup( name: json['name'] as String? ?? '', diff --git a/lib/models/message.dart b/lib/models/message.dart index bd397d7..4f42d96 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -43,9 +43,9 @@ class Message { Uint8List? pathBytes, Uint8List? fourByteRoomContactKey, Map? 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, ); } diff --git a/lib/models/path_history.dart b/lib/models/path_history.dart index 1e2426a..5e3ea1f 100644 --- a/lib/models/path_history.dart +++ b/lib/models/path_history.dart @@ -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 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 json) { - final pathsList = (json['recent_paths'] as List?) + String contactPubKeyHex, + Map json, + ) { + final pathsList = + (json['recent_paths'] as List?) ?.map((p) => PathRecord.fromJson(p as Map)) .toList() ?? []; diff --git a/lib/models/radio_settings.dart b/lib/models/radio_settings.dart index 9df96be..20b7771 100644 --- a/lib/models/radio_settings.dart +++ b/lib/models/radio_settings.dart @@ -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; diff --git a/lib/screens/app_debug_log_screen.dart b/lib/screens/app_debug_log_screen.dart index bdeccdb..e8a0aa4 100644 --- a/lib/screens/app_debug_log_screen.dart +++ b/lib/screens/app_debug_log_screen.dart @@ -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); } diff --git a/lib/screens/app_settings_screen.dart b/lib/screens/app_settings_screen.dart index ce61231..135babd 100644 --- a/lib/screens/app_settings_screen.dart +++ b/lib/screens/app_settings_screen.dart @@ -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( @@ -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( - value: 0, - ), + leading: Radio(value: 0), ), ListTile( title: Text(context.l10n.appSettings_lastHour), - leading: Radio( - value: 1, - ), + leading: Radio(value: 1), ), ListTile( title: Text(context.l10n.appSettings_last6Hours), - leading: Radio( - value: 6, - ), + leading: Radio(value: 6), ), ListTile( title: Text(context.l10n.appSettings_last24Hours), - leading: Radio( - value: 24, - ), + leading: Radio(value: 24), ), ListTile( title: Text(context.l10n.appSettings_lastWeek), - leading: Radio( - value: 168, - ), + leading: Radio(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), ), ); diff --git a/lib/screens/ble_debug_log_screen.dart b/lib/screens/ble_debug_log_screen.dart index 1931403..7675cae 100644 --- a/lib/screens/ble_debug_log_screen.dart +++ b/lib/screens/ble_debug_log_screen.dart @@ -24,7 +24,9 @@ class _BleDebugLogScreenState extends State { 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 { ? () 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 { 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 { 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 { 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 { 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 { } 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 { 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; diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 083a60b..a1139a1 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -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 createState() => _ChannelChatScreenState(); @@ -135,15 +132,19 @@ class _ChannelChatScreenState extends State { 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( 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 { // 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 { child: SizedBox( width: 20, height: 20, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + ), ), ), ); @@ -241,9 +245,7 @@ class _ChannelChatScreenState extends State { ); }, ), - JumpToBottomButton( - scrollController: _scrollController, - ), + JumpToBottomButton(scrollController: _scrollController), ], ); }, @@ -262,15 +264,21 @@ class _ChannelChatScreenState extends State { 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 { 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 { 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 { 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 { 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 { _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 { 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 { 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 { 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 { 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 { ], ), child: Row( - children: [ - IconButton( - icon: const Icon(Icons.gif_box), - onPressed: () => _showGifPicker(context), - tooltip: context.l10n.chat_sendGif, - ), - Expanded( - child: ValueListenableBuilder( - 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( + 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 { 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 _deleteMessage(ChannelMessage message) async { await context.read().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}); } diff --git a/lib/screens/channel_message_path_screen.dart b/lib/screens/channel_message_path_screen.dart index 93d510e..970c152 100644 --- a/lib/screens/channel_message_path_screen.dart +++ b/lib/screens/channel_message_path_screen.dart @@ -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( 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 variants, - ) { + Widget _buildPathVariants(BuildContext context, List 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 { +class _ChannelMessagePathMapScreenState + extends State { Uint8List? _selectedPath; @override @@ -270,8 +274,10 @@ class _ChannelMessagePathMapScreenState extends State( builder: (context, connector, _) { final tileCache = context.read(); - 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[]; - 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 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 _buildPathHops( @@ -597,10 +607,12 @@ List<_PathHop> _buildPathHops( Contact? _matchContactForPrefix(List 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; diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 37d56bb..6b8b92d 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -154,7 +154,9 @@ class _ChannelsScreenState extends State ), onTap: () => Navigator.push( context, - MaterialPageRoute(builder: (context) => const SettingsScreen()), + MaterialPageRoute( + builder: (context) => const SettingsScreen(), + ), ), ), ], @@ -951,7 +953,9 @@ class _ChannelsScreenState extends State dialogContext.l10n.community_communityHashtag, ), subtitle: Text( - dialogContext.l10n.community_communityHashtagDesc, + dialogContext + .l10n + .community_communityHashtagDesc, ), dense: true, ), @@ -1047,7 +1051,7 @@ class _ChannelsScreenState extends State hashtag = hashtag.substring(1); } final String channelName; - + final Uint8List psk; if (isRegularHashtag) { channelName = '#$hashtag'; @@ -1069,8 +1073,10 @@ class _ChannelsScreenState extends State ); 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, diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index cf34381..00cea59 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -52,7 +52,9 @@ class _ChatScreenState extends State { _scrollController.onScrollNearTop = _loadOlderMessages; SchedulerBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - context.read().setActiveContact(widget.contact.publicKeyHex); + context.read().setActiveContact( + widget.contact.publicKeyHex, + ); }); } @@ -91,12 +93,15 @@ class _ChatScreenState extends State { title: Consumer2( 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 { 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 { 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 { 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 { messages.isEmpty ? _buildEmptyState() : _buildMessageList(messages, connector), - JumpToBottomButton( - scrollController: _scrollController, - ), + JumpToBottomButton(scrollController: _scrollController), ], ), ), @@ -231,7 +252,10 @@ class _ChatScreenState extends State { ); } - Widget _buildMessageList(List messages, MeshCoreConnector connector) { + Widget _buildMessageList( + List 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 { 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 { 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 { 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 { 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 { return; } - connector.sendMessage( - widget.contact, - text, - ); + connector.sendMessage(widget.contact, text); _textController.clear(); } - void _showPathHistory(BuildContext context) { final connector = Provider.of(context, listen: false); @@ -422,13 +452,19 @@ class _ChatScreenState extends State { 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 { 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 { }, ), 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 { 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 { 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 { 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 { 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 { 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 { ); } - 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 { 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 { builder: (context) => Consumer( 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 { 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 { 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 { final connector = Provider.of(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 { 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 { await _notifyPathSet(connector, widget.contact, result, result.length); } - void _openMessagePath(Message message, Contact contact) { final connector = context.read(); final fourByteHex = message.fourByteRoomContactKey @@ -877,8 +977,7 @@ class _ChatScreenState extends State { 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 { 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 _deleteMessage(Message message) async { await context.read().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(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 { 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}); } diff --git a/lib/screens/contacts_screen.dart b/lib/screens/contacts_screen.dart index 48f94f9..f04bc50 100644 --- a/lib/screens/contacts_screen.dart +++ b/lib/screens/contacts_screen.dart @@ -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 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 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 _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 final connector = Provider.of(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 } 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 _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 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 ), onTap: () => Navigator.push( context, - MaterialPageRoute(builder: (context) => const SettingsScreen()), + MaterialPageRoute( + builder: (context) => const SettingsScreen(), + ), ), ), ], @@ -704,7 +711,8 @@ class _ContactsScreenState extends State 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 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 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 }, ), 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 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]), ], ), ], diff --git a/lib/screens/device_screen.dart b/lib/screens/device_screen.dart index 7a3b75b..c5967cf 100644 --- a/lib/screens/device_screen.dart +++ b/lib/screens/device_screen.dart @@ -127,9 +127,7 @@ class _DeviceScreenState extends State 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 ); } - Widget _buildBatteryIndicator( MeshCoreConnector connector, BuildContext context, @@ -224,11 +221,7 @@ class _DeviceScreenState extends State 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 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; } diff --git a/lib/screens/map_cache_screen.dart b/lib/screens/map_cache_screen.dart index 3a1e1a9..3f61109 100644 --- a/lib/screens/map_cache_screen.dart +++ b/lib/screens/map_cache_screen.dart @@ -56,10 +56,7 @@ class _MapCacheScreenState extends State { _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 { return; } final cacheService = context.read(); - 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 { 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 _clearCache() async { @@ -224,10 +224,7 @@ class _MapCacheScreenState extends State { : (_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 { 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 { ), 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 { 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 { 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( diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index 734f2b2..0da9960 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -269,7 +269,9 @@ class _MapScreenState extends State { ), onTap: () => Navigator.push( context, - MaterialPageRoute(builder: (context) => const SettingsScreen()), + MaterialPageRoute( + builder: (context) => const SettingsScreen(), + ), ), ), ], @@ -278,85 +280,82 @@ class _MapScreenState extends State { ], ), 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 { ); } - List _buildMarkers(List contacts, settings) { final markers = []; diff --git a/lib/screens/repeater_cli_screen.dart b/lib/screens/repeater_cli_screen.dart index 2a53a16..abfb06a 100644 --- a/lib/screens/repeater_cli_screen.dart +++ b/lib/screens/repeater_cli_screen.dart @@ -119,14 +119,24 @@ class _RepeaterCliScreenState extends State { // 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(context, listen: false); + final connector = Provider.of( + context, + listen: false, + ); final repeater = _resolveRepeater(connector); final response = await _commandService!.sendCommand( repeater, @@ -230,7 +240,10 @@ class _RepeaterCliScreenState extends State { 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 { 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 { 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 { 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 { 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 { ]; 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 { 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 { ), 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}); } diff --git a/lib/screens/repeater_status_screen.dart b/lib/screens/repeater_status_screen.dart index 759a85e..1523f77 100644 --- a/lib/screens/repeater_status_screen.dart +++ b/lib/screens/repeater_status_screen.dart @@ -28,7 +28,8 @@ class RepeaterStatusScreen extends StatefulWidget { class _RepeaterStatusScreenState extends State { 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? _frameSubscription; @@ -293,7 +294,9 @@ class _RepeaterStatusScreenState extends State { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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); diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index 0d38d98..75819a0 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -23,22 +23,21 @@ class _ScannerScreenState extends State { void initState() { super.initState(); final connector = Provider.of(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 { _buildStatusBar(context, connector), // Device list - Expanded( - child: _buildDeviceList(context, connector), - ), + Expanded(child: _buildDeviceList(context, connector)), ], ); }, @@ -77,8 +74,9 @@ class _ScannerScreenState extends State { ), floatingActionButton: Consumer( 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 { connector.startScan(); } }, - icon: isScanning + icon: isScanning ? const SizedBox( width: 20, height: 20, @@ -97,7 +95,11 @@ class _ScannerScreenState extends State { ), ) : 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 { 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]), ), ], ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 04740d8..415d508 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -442,7 +442,8 @@ class _SettingsScreenState extends State { 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)'), ), diff --git a/lib/services/app_debug_log_service.dart b/lib/services/app_debug_log_service.dart index 0f4ed7d..6a35b17 100644 --- a/lib/services/app_debug_log_service.dart +++ b/lib/services/app_debug_log_service.dart @@ -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( diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index 5897943..c1e8fc6 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -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 setBatteryChemistryForDevice(String deviceId, String chemistry) async { - final updated = Map.from(_settings.batteryChemistryByDeviceId); + Future setBatteryChemistryForDevice( + String deviceId, + String chemistry, + ) async { + final updated = Map.from( + _settings.batteryChemistryByDeviceId, + ); updated[deviceId] = chemistry; - await updateSettings(_settings.copyWith(batteryChemistryByDeviceId: updated)); + await updateSettings( + _settings.copyWith(batteryChemistryByDeviceId: updated), + ); } } diff --git a/lib/services/ble_debug_log_service.dart b/lib/services/ble_debug_log_service.dart index 07ac689..0a9aeae 100644 --- a/lib/services/ble_debug_log_service.dart +++ b/lib/services/ble_debug_log_service.dart @@ -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; diff --git a/lib/services/map_tile_cache_service.dart b/lib/services/map_tile_cache_service.dart index 234481d..e0d4e57 100644 --- a/lib/services/map_tile_cache_service.dart +++ b/lib/services/map_tile_cache_service.dart @@ -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 get defaultHeaders => { - 'User-Agent': 'flutter_map ($userAgentPackageName)', - }; + 'User-Agent': 'flutter_map ($userAgentPackageName)', + }; Future 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); } diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index 403cd93..9cbd68f 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -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 _pendingMessages = {}; final Map _pendingContacts = {}; final Map _pendingPathSelections = {}; - final Map _ackHashToMessageId = {}; // ackHashHex → messageId + timestamp for O(1) lookup - final Map> _expectedAckHashes = {}; // Track all expected ACKs for retries (for history) - final List<_AckHistoryEntry> _ackHistory = []; // Rolling buffer of recent ACK hashes - final Map> _pendingMessageQueuePerContact = {}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed) - final Map _expectedHashToMessageId = {}; // expectedAckHashHex → messageId (for matching RESP_CODE_SENT by hash) + final Map _ackHashToMessageId = + {}; // ackHashHex → messageId + timestamp for O(1) lookup + final Map> _expectedAckHashes = + {}; // Track all expected ACKs for retries (for history) + final List<_AckHistoryEntry> _ackHistory = + []; // Rolling buffer of recent ACK hashes + final Map> _pendingMessageQueuePerContact = + {}; // contactPubKeyHex → FIFO queue of messageIds (DEPRECATED - will be removed) + final Map _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) { diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 1d25f92..ea7f031 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -6,13 +6,16 @@ class NotificationService { factory NotificationService() => _instance; NotificationService._internal(); - final FlutterLocalNotificationsPlugin _notifications = FlutterLocalNotificationsPlugin(); + final FlutterLocalNotificationsPlugin _notifications = + FlutterLocalNotificationsPlugin(); bool _isInitialized = false; Future 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, diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart index 81caef0..1314f48 100644 --- a/lib/services/path_history_service.dart +++ b/lib/services/path_history_service.dart @@ -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 _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(); } diff --git a/lib/services/repeater_command_service.dart b/lib/services/repeater_command_service.dart index d282bea..060f7aa 100644 --- a/lib/services/repeater_command_service.dart +++ b/lib/services/repeater_command_service.dart @@ -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); } }, diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 39d6e6b..ce0c4f1 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -8,7 +8,9 @@ class StorageService { static const String _repeaterPasswordsKey = 'repeater_passwords'; Future 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 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 saveRepeaterPassword( - String repeaterPubKeyHex, String password) async { + String repeaterPubKeyHex, + String password, + ) async { final prefs = PrefsManager.instance; final passwords = await loadRepeaterPasswords(); passwords[repeaterPubKeyHex] = password; diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart index 6769c3a..1151514 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -8,7 +8,10 @@ class ChannelMessageStore { static const String _keyPrefix = 'channel_messages_'; /// Save messages for a specific channel - Future saveChannelMessages(int channelIndex, List messages) async { + Future saveChannelMessages( + int channelIndex, + List messages, + ) async { final prefs = PrefsManager.instance; final key = '$_keyPrefix$channelIndex'; @@ -96,7 +99,8 @@ class ChannelMessageStore { pathVariants: (json['pathVariants'] as List?) ?.map((entry) => Uint8List.fromList(base64Decode(entry as String))) .toList(), - repeats: (json['repeats'] as List?) + repeats: + (json['repeats'] as List?) ?.map((entry) => _repeatFromJson(entry as Map)) .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?)?.map( - (key, value) => MapEntry(key, value as int), - ) ?? {}, + reactions: + (json['reactions'] as Map?)?.map( + (key, value) => MapEntry(key, value as int), + ) ?? + {}, ); } Map _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() ?? [], diff --git a/lib/storage/channel_order_store.dart b/lib/storage/channel_order_store.dart index 2cf4727..b9657c4 100644 --- a/lib/storage/channel_order_store.dart +++ b/lib/storage/channel_order_store.dart @@ -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().toList(); + return decoded + .map((value) => value is int ? value : int.tryParse('$value')) + .whereType() + .toList(); } } catch (_) { // fall through to legacy parse diff --git a/lib/storage/community_store.dart b/lib/storage/community_store.dart index fe5c831..a81cccd 100644 --- a/lib/storage/community_store.dart +++ b/lib/storage/community_store.dart @@ -40,7 +40,7 @@ class CommunityStore { /// Add a new community Future 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 addHashtagChannel( - String communityId, - String hashtag, - ) async { + Future 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 removeHashtagChannel( - String communityId, - String hashtag, - ) async { + Future removeHashtagChannel(String communityId, String hashtag) async { final community = await getCommunity(communityId); if (community != null) { final updated = community.removeHashtagChannel(hashtag); diff --git a/lib/storage/contact_store.dart b/lib/storage/contact_store.dart index 6a18b2a..08d158b 100644 --- a/lib/storage/contact_store.dart +++ b/lib/storage/contact_store.dart @@ -14,7 +14,9 @@ class ContactStore { try { final jsonList = jsonDecode(jsonStr) as List; - return jsonList.map((entry) => _fromJson(entry as Map)).toList(); + return jsonList + .map((entry) => _fromJson(entry as Map)) + .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, + ), ); } } diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index c1bc640..9526ef3 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -7,7 +7,10 @@ import 'prefs_manager.dart'; class MessageStore { static const String _keyPrefix = 'messages_'; - Future saveMessages(String contactKeyHex, List messages) async { + Future saveMessages( + String contactKeyHex, + List 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 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?)?.map( - (key, value) => MapEntry(key, value as int), - ) ?? {}, + reactions: + (json['reactions'] as Map?)?.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, ); } diff --git a/lib/storage/prefs_manager.dart b/lib/storage/prefs_manager.dart index a449b9c..2bf6299 100644 --- a/lib/storage/prefs_manager.dart +++ b/lib/storage/prefs_manager.dart @@ -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!; } diff --git a/lib/storage/unread_store.dart b/lib/storage/unread_store.dart index 520c7c4..d46a34c 100644 --- a/lib/storage/unread_store.dart +++ b/lib/storage/unread_store.dart @@ -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()]); } } diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index 6ada39b..e57261e 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -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); } diff --git a/lib/widgets/battery_indicator.dart b/lib/widgets/battery_indicator.dart index bbda1af..7837415 100644 --- a/lib/widgets/battery_indicator.dart +++ b/lib/widgets/battery_indicator.dart @@ -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 createState() => _BatteryIndicatorState(); diff --git a/lib/widgets/debug_frame_viewer.dart b/lib/widgets/debug_frame_viewer.dart index e2c6e34..c8dc371 100644 --- a/lib/widgets/debug_frame_viewer.dart +++ b/lib/widgets/debug_frame_viewer.dart @@ -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)); } diff --git a/lib/widgets/device_tile.dart b/lib/widgets/device_tile.dart index bbd4faf..9dd6d5b 100644 --- a/lib/widgets/device_tile.dart +++ b/lib/widgets/device_tile.dart @@ -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)), ], ); } } - diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji_picker.dart index 7345eff..87fd1c9 100644 --- a/lib/widgets/emoji_picker.dart +++ b/lib/widgets/emoji_picker.dart @@ -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 quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥']; static const List smileys = [ - '😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '🙃', '😉', '😌', '😍', '🥰', '😘', - '😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩', '🥳', '😏', - '😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡', - '🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶', - ]; + '😀', + '😃', + '😄', + '😁', + '😅', + '😂', + '🤣', + '😊', + '😇', + '🙂', + '🙃', + '😉', + '😌', + '😍', + '🥰', + '😘', + '😗', + '😙', + '😚', + '😋', + '😛', + '😝', + '😜', + '🤪', + '🤨', + '🧐', + '🤓', + '😎', + '🥸', + '🤩', + '🥳', + '😏', + '😒', + '😞', + '😔', + '😟', + '😕', + '🙁', + '😣', + '😖', + '😫', + '😩', + '🥺', + '😢', + '😭', + '😤', + '😠', + '😡', + '🤬', + '🤯', + '😳', + '🥵', + '🥶', + '😱', + '😨', + '😰', + '😥', + '😓', + '🤗', + '🤔', + '🤭', + '🤫', + '🤥', + '😶', + ]; static const List gestures = [ - '👍', '👎', '👊', '✊', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘', '👌', '🤌', '🤏', '👈', '👉', '👆', - '👇', '☝️', '👋', '🤚', '🖐️', '✋', '🖖', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️', '💅', '🤳', '💪', - ]; + '👍', + '👎', + '👊', + '✊', + '🤛', + '🤜', + '🤞', + '✌️', + '🤟', + '🤘', + '👌', + '🤌', + '🤏', + '👈', + '👉', + '👆', + '👇', + '☝️', + '👋', + '🤚', + '🖐️', + '✋', + '🖖', + '👏', + '🙌', + '👐', + '🤲', + '🤝', + '🙏', + '✍️', + '💅', + '🤳', + '💪', + ]; static const List hearts = [ - '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔', '❤️‍🔥', '❤️‍🩹', '💕', '💞', '💓', '💗', - '💖', '💘', '💝', '💟', '💌', '💢', '💥', '💫', '💦', '💨', '🕳️', '💬', '👁️‍🗨️', '🗨️', '🗯️', '💭', - ]; + '❤️', + '🧡', + '💛', + '💚', + '💙', + '💜', + '🖤', + '🤍', + '🤎', + '💔', + '❤️‍🔥', + '❤️‍🩹', + '💕', + '💞', + '💓', + '💗', + '💖', + '💘', + '💝', + '💟', + '💌', + '💢', + '💥', + '💫', + '💦', + '💨', + '🕳️', + '💬', + '👁️‍🗨️', + '🗨️', + '🗯️', + '💭', + ]; static const List objects = [ - '🎉', '🎊', '🎈', '🎁', '🎀', '🪅', '🪆', '🏆', '🥇', '🥈', '🥉', '⚽', '⚾', '🥎', '🏀', '🏐', - '🏈', '🏉', '🎾', '🥏', '🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅', '⛳', '🔥', - '⭐', '🌟', '✨', '⚡', '💡', '🔦', '🏮', '🪔', '📱', '💻', '⌚', '📷', '📺', '📻', '🎵', '🎶', '🚀', - ]; + '🎉', + '🎊', + '🎈', + '🎁', + '🎀', + '🪅', + '🪆', + '🏆', + '🥇', + '🥈', + '🥉', + '⚽', + '⚾', + '🥎', + '🏀', + '🏐', + '🏈', + '🏉', + '🎾', + '🥏', + '🎳', + '🏏', + '🏑', + '🏒', + '🥍', + '🏓', + '🏸', + '🥊', + '🥋', + '🥅', + '⛳', + '🔥', + '⭐', + '🌟', + '✨', + '⚡', + '💡', + '🔦', + '🏮', + '🪔', + '📱', + '💻', + '⌚', + '📷', + '📺', + '📻', + '🎵', + '🎶', + '🚀', + ]; Map> _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: () { diff --git a/lib/widgets/empty_state.dart b/lib/widgets/empty_state.dart index 9ac28bb..172c9a4 100644 --- a/lib/widgets/empty_state.dart +++ b/lib/widgets/empty_state.dart @@ -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!], ], ), ); diff --git a/lib/widgets/gif_picker.dart b/lib/widgets/gif_picker.dart index df0a6f7..9c56951 100644 --- a/lib/widgets/gif_picker.dart +++ b/lib/widgets/gif_picker.dart @@ -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 createState() => _GifPickerState(); @@ -45,11 +42,13 @@ class _GifPickerState extends State { }); 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 { }); 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 { 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 { 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 { 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 { 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 { 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)), ), ), ); diff --git a/lib/widgets/jump_to_bottom_button.dart b/lib/widgets/jump_to_bottom_button.dart index 08614f3..3f6d96e 100644 --- a/lib/widgets/jump_to_bottom_button.dart +++ b/lib/widgets/jump_to_bottom_button.dart @@ -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) { diff --git a/lib/widgets/list_filter_widget.dart b/lib/widgets/list_filter_widget.dart index 97ca3e7..e9c0d9e 100644 --- a/lib/widgets/list_filter_widget.dart +++ b/lib/widgets/list_filter_widget.dart @@ -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 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 = >[]; for (int i = 0; i < visibleSections.length; i++) { final section = visibleSections[i]; diff --git a/lib/widgets/path_management_dialog.dart b/lib/widgets/path_management_dialog.dart index 00fc083..f47b017 100644 --- a/lib/widgets/path_management_dialog.dart +++ b/lib/widgets/path_management_dialog.dart @@ -10,15 +10,10 @@ import '../services/path_history_service.dart'; import 'path_selection_dialog.dart'; class PathManagementDialog { - static Future show( - BuildContext context, { - required Contact contact, - }) { + static Future show(BuildContext context, {required Contact contact}) { return showDialog( 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( diff --git a/lib/widgets/path_selection_dialog.dart b/lib/widgets/path_selection_dialog.dart index 5a5aa59..4e6cfe5 100644 --- a/lib/widgets/path_selection_dialog.dart +++ b/lib/widgets/path_selection_dialog.dart @@ -70,12 +70,15 @@ class _PathSelectionDialogState extends State { } 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 { } // 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 = []; final invalidPrefixes = []; @@ -132,7 +139,9 @@ class _PathSelectionDialogState extends State { 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 { 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 { 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 { 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 { 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 { 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), ); diff --git a/lib/widgets/path_trace_dialog.dart b/lib/widgets/path_trace_dialog.dart index 958258b..7294c86 100644 --- a/lib/widgets/path_trace_dialog.dart +++ b/lib/widgets/path_trace_dialog.dart @@ -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 { bool _failed2Loaded = false; bool _hasData = false; Uint8List _pathData = Uint8List(0); - Uint8List _snrData = Uint8List(0) ; + Uint8List _snrData = Uint8List(0); Map _pathContacts = {}; @override @@ -49,13 +45,13 @@ class _PathTraceDialogState extends State { } Future _doPathTrace() async { - if(mounted) { + if (mounted) { setState(() { _isLoading = true; _failed2Loaded = false; }); } - + final connector = Provider.of(context, listen: false); final frame = buildTraceReq( DateTime.now().millisecondsSinceEpoch ~/ 1000, @@ -92,18 +88,19 @@ class _PathTraceDialogState extends State { } // 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 _handleTraceResponse(Uint8List frame)async { + Future _handleTraceResponse(Uint8List frame) async { final connector = Provider.of(context, listen: false); final buffer = BufferReader(frame); @@ -116,9 +113,7 @@ class _PathTraceDialogState extends State { Map 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 { 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 { 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, diff --git a/lib/widgets/qr_code_display.dart b/lib/widgets/qr_code_display.dart index 4d96ebe..e8f4795 100644 --- a/lib/widgets/qr_code_display.dart +++ b/lib/widgets/qr_code_display.dart @@ -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, diff --git a/lib/widgets/qr_scanner_widget.dart b/lib/widgets/qr_scanner_widget.dart index e328b6d..4dc2ee5 100644 --- a/lib/widgets/qr_scanner_widget.dart +++ b/lib/widgets/qr_scanner_widget.dart @@ -215,10 +215,7 @@ class _QrScannerWidgetState extends State ), 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 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 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 Text( message, textAlign: TextAlign.center, - style: TextStyle( - color: Colors.grey[600], - fontSize: 16, - ), + style: TextStyle(color: Colors.grey[600], fontSize: 16), ), ], ), diff --git a/lib/widgets/repeater_login_dialog.dart b/lib/widgets/repeater_login_dialog.dart index 1f767f6..b550cc2 100644 --- a/lib/widgets/repeater_login_dialog.dart +++ b/lib/widgets/repeater_login_dialog.dart @@ -44,8 +44,9 @@ class _RepeaterLoginDialogState extends State { } Future _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 { ); 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 { '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 { // 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 { 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( - 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( + 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), diff --git a/lib/widgets/room_login_dialog.dart b/lib/widgets/room_login_dialog.dart index 1d2554d..cba7bec 100644 --- a/lib/widgets/room_login_dialog.dart +++ b/lib/widgets/room_login_dialog.dart @@ -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 createState() => _RoomLoginDialogState(); @@ -43,8 +39,9 @@ class _RoomLoginDialogState extends State { } Future _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 { ); 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 { '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 { } 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 { // 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 { } } 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 { ), ) : 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( - 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( + 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), diff --git a/lib/widgets/unread_badge.dart b/lib/widgets/unread_badge.dart index 92b7c37..37db11a 100644 --- a/lib/widgets/unread_badge.dart +++ b/lib/widgets/unread_badge.dart @@ -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) { diff --git a/test/reaction_helper_test.dart b/test/reaction_helper_test.dart index d2c70b5..2f4502e 100644 --- a/test/reaction_helper_test.dart +++ b/test/reaction_helper_test.dart @@ -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)); }); });