diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..f9ecda0 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..fdc9c80 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..ec9b590 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..08a0eed 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..c0b41c5 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 1cf3a4a..09c8350 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -427,7 +427,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -484,7 +484,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..e0c6b05 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..c423d37 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..14a39da 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..114f271 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..6dc406b 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..6ab5d57 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..d19d9b6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..14a39da 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..e213efb 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..f1a30f3 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..e34f5aa Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..eab96d7 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..dd9798a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..ad3e183 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..f1a30f3 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..7f14fb3 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..f9ecda0 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..08a0eed Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..b4427b2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..b9b2771 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..56548be 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index f65834f..d8221de 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -14,6 +14,7 @@ import '../models/channel_message.dart'; import '../models/contact.dart'; import '../models/message.dart'; import '../models/path_selection.dart'; +import '../helpers/reaction_helper.dart'; import '../helpers/smaz.dart'; import '../services/ble_debug_log_service.dart'; import '../services/message_retry_service.dart'; @@ -486,7 +487,6 @@ class MeshCoreConnector extends ChangeNotifier { void _sendMessageDirect( Contact contact, String text, - bool forceFlood, int attempt, int timestampSeconds, ) async { @@ -496,7 +496,6 @@ class MeshCoreConnector extends ChangeNotifier { buildSendTextMsgFrame( contact.publicKey, outboundText, - forceFlood: forceFlood, attempt: attempt, timestampSeconds: timestampSeconds, ), @@ -914,21 +913,13 @@ class MeshCoreConnector extends ChangeNotifier { Future sendMessage( Contact contact, String text, { - bool forceFlood = false, - Uint8List? customPath, - int? customPathLen, + bool clearPath = false, }) async { if (!isConnected || text.isEmpty) return; - // If custom path is provided, temporarily update the contact's path - if (customPath != null && customPathLen != null && customPathLen >= 0) { - await setContactPath(contact, customPath, customPathLen); - } - + // Handle auto-rotation if enabled PathSelection? autoSelection; - if (customPath == null && - _appSettingsService?.settings.autoRouteRotationEnabled == true && - !forceFlood) { + if (_appSettingsService?.settings.autoRouteRotationEnabled == true && !clearPath) { autoSelection = _pathHistoryService?.getNextAutoPathSelection(contact.publicKeyHex); if (autoSelection != null) { _pathHistoryService?.recordPathAttempt(contact.publicKeyHex, autoSelection); @@ -943,23 +934,21 @@ class MeshCoreConnector extends ChangeNotifier { } if (_retryService != null) { - final pathBytes = - _resolveOutgoingPathBytes(contact, customPath, customPathLen, forceFlood, autoSelection); - final pathLength = - _resolveOutgoingPathLength(contact, customPathLen, forceFlood, autoSelection); + final pathBytes = _resolveOutgoingPathBytes(contact, clearPath, autoSelection); + final pathLength = _resolveOutgoingPathLength(contact, clearPath, autoSelection); final selectedContact = _applyAutoSelection(contact, autoSelection); await _retryService!.sendMessageWithRetry( contact: selectedContact, text: text, - forceFlood: forceFlood, + clearPath: clearPath, pathSelection: autoSelection, pathBytes: pathBytes, pathLength: pathLength, ); } else { // Fallback to old behavior if retry service not initialized - final pathBytes = _resolveOutgoingPathBytes(contact, customPath, customPathLen, forceFlood, autoSelection); - final pathLength = _resolveOutgoingPathLength(contact, customPathLen, forceFlood, autoSelection); + final pathBytes = _resolveOutgoingPathBytes(contact, clearPath, autoSelection); + final pathLength = _resolveOutgoingPathLength(contact, clearPath, autoSelection); final message = Message.outgoing( contact.publicKey, text, @@ -973,7 +962,6 @@ class MeshCoreConnector extends ChangeNotifier { buildSendTextMsgFrame( contact.publicKey, outboundText, - forceFlood: forceFlood, ), ); } @@ -1962,11 +1950,37 @@ class MeshCoreConnector extends ChangeNotifier { void _addMessage(String pubKeyHex, Message message) { _conversations.putIfAbsent(pubKeyHex, () => []); - _conversations[pubKeyHex]!.add(message); - _messageStore.saveMessages(pubKeyHex, _conversations[pubKeyHex]!); + final messages = _conversations[pubKeyHex]!; + + // Parse reaction info + final reactionInfo = Message.parseReaction(message.text); + if (reactionInfo != null) { + // Find target message and add reaction + _processContactReaction(messages, reactionInfo); + _messageStore.saveMessages(pubKeyHex, messages); + notifyListeners(); + return; // Don't add reaction as a visible message + } + + messages.add(message); + _messageStore.saveMessages(pubKeyHex, messages); notifyListeners(); } + void _processContactReaction(List messages, ReactionInfo reactionInfo) { + // Find target message by messageId + for (int i = 0; i < messages.length; i++) { + if (messages[i].messageId == reactionInfo.targetMessageId) { + final currentReactions = Map.from(messages[i].reactions); + currentReactions[reactionInfo.emoji] = + (currentReactions[reactionInfo.emoji] ?? 0) + 1; + + messages[i] = messages[i].copyWith(reactions: currentReactions); + break; + } + } + } + _RawPacket? _parseRawPacket(Uint8List raw) { if (raw.length < 3) return null; var index = 0; @@ -2048,17 +2062,12 @@ class MeshCoreConnector extends ChangeNotifier { Uint8List _resolveOutgoingPathBytes( Contact contact, - Uint8List? customPath, - int? customPathLen, - bool forceFlood, + bool clearPath, PathSelection? selection, ) { - if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) { + if (clearPath || contact.pathLength < 0 || selection?.useFlood == true) { return Uint8List(0); } - if (customPath != null && customPathLen != null && customPathLen > 0) { - return Uint8List.fromList(customPath.sublist(0, customPathLen)); - } if (selection != null && selection.pathBytes.isNotEmpty) { return Uint8List.fromList(selection.pathBytes); } @@ -2067,16 +2076,12 @@ class MeshCoreConnector extends ChangeNotifier { int? _resolveOutgoingPathLength( Contact contact, - int? customPathLen, - bool forceFlood, + bool clearPath, PathSelection? selection, ) { - if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) { + if (clearPath || contact.pathLength < 0 || selection?.useFlood == true) { return -1; } - if (customPathLen != null && customPathLen > 0) { - return customPathLen; - } if (selection != null && selection.pathBytes.isNotEmpty) { return selection.hopCount; } @@ -2087,6 +2092,16 @@ class MeshCoreConnector extends ChangeNotifier { _channelMessages.putIfAbsent(channelIndex, () => []); final messages = _channelMessages[channelIndex]!; + // Parse reaction info + final reactionInfo = ChannelMessage.parseReaction(message.text); + if (reactionInfo != null) { + // Find target message and add reaction + _processReaction(messages, reactionInfo); + // Save updated messages + _channelMessageStore.saveChannelMessages(channelIndex, messages); + return false; // Don't add reaction as a visible message + } + // Parse reply info from message text final replyInfo = ChannelMessage.parseReplyMention(message.text); ChannelMessage processedMessage = message; @@ -2158,6 +2173,21 @@ class MeshCoreConnector extends ChangeNotifier { return null; } + void _processReaction(List messages, ReactionInfo reactionInfo) { + // Find target message by messageId + for (int i = 0; i < messages.length; i++) { + if (messages[i].messageId == reactionInfo.targetMessageId) { + final currentReactions = Map.from(messages[i].reactions); + currentReactions[reactionInfo.emoji] = + (currentReactions[reactionInfo.emoji] ?? 0) + 1; + + messages[i] = messages[i].copyWith(reactions: currentReactions); + notifyListeners(); + break; + } + } + } + int _findChannelRepeatIndex(List messages, ChannelMessage incoming) { for (int i = messages.length - 1; i >= 0; i--) { final existing = messages[i]; diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 2018ada..2ba2431 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -280,14 +280,13 @@ Uint8List buildSendStatusRequestFrame(Uint8List recipientPubKey) { Uint8List buildSendTextMsgFrame( Uint8List recipientPubKey, String text, { - bool forceFlood = false, int attempt = 0, int? timestampSeconds, }) { final textBytes = utf8.encode(text); final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000); const prefixSize = 6; - final safeAttempt = forceFlood ? 3 : (attempt & 0xFF); + final safeAttempt = attempt.clamp(0, 3); final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1); int offset = 0; diff --git a/lib/helpers/reaction_helper.dart b/lib/helpers/reaction_helper.dart new file mode 100644 index 0000000..99fb74c --- /dev/null +++ b/lib/helpers/reaction_helper.dart @@ -0,0 +1,22 @@ +class ReactionInfo { + final String targetMessageId; + final String emoji; + + ReactionInfo({ + required this.targetMessageId, + required this.emoji, + }); +} + +class ReactionHelper { + /// Parse reaction format: r:[messageId]:[emoji] + static ReactionInfo? parseReaction(String text) { + final regex = RegExp(r'^r:([^:]+):(.+)$'); + final match = regex.firstMatch(text); + if (match == null) return null; + return ReactionInfo( + targetMessageId: match.group(1)!, + emoji: match.group(2)!, + ); + } +} diff --git a/lib/models/channel_message.dart b/lib/models/channel_message.dart index 5a56d33..5aae28d 100644 --- a/lib/models/channel_message.dart +++ b/lib/models/channel_message.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; import '../connector/meshcore_protocol.dart'; +import '../helpers/reaction_helper.dart'; import '../helpers/smaz.dart'; enum ChannelMessageStatus { pending, sent, failed } @@ -38,6 +39,7 @@ class ChannelMessage { final String? replyToMessageId; final String? replyToSenderName; final String? replyToText; + final Map reactions; ChannelMessage({ this.senderKey, @@ -56,7 +58,9 @@ class ChannelMessage { this.replyToMessageId, 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), @@ -75,6 +79,7 @@ class ChannelMessage { String? replyToMessageId, String? replyToSenderName, String? replyToText, + Map? reactions, }) { return ChannelMessage( senderKey: senderKey, @@ -93,6 +98,7 @@ class ChannelMessage { replyToMessageId: replyToMessageId ?? this.replyToMessageId, replyToSenderName: replyToSenderName ?? this.replyToSenderName, replyToText: replyToText ?? this.replyToText, + reactions: reactions ?? this.reactions, ); } @@ -233,6 +239,10 @@ class ChannelMessage { actualMessage: match.group(2)!, ); } + + static ReactionInfo? parseReaction(String text) { + return ReactionHelper.parseReaction(text); + } } class ReplyInfo { diff --git a/lib/models/message.dart b/lib/models/message.dart index 4c347d4..cbcd111 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; import '../connector/meshcore_protocol.dart'; +import '../helpers/reaction_helper.dart'; enum MessageStatus { pending, sent, delivered, failed } @@ -19,9 +20,9 @@ class Message { final DateTime? sentAt; final DateTime? deliveredAt; final int? tripTimeMs; - final bool forceFlood; final int? pathLength; final Uint8List pathBytes; + final Map reactions; Message({ required this.senderKey, @@ -37,10 +38,11 @@ class Message { this.sentAt, this.deliveredAt, this.tripTimeMs, - this.forceFlood = false, this.pathLength, Uint8List? pathBytes, - }) : pathBytes = pathBytes ?? Uint8List(0); + Map? reactions, + }) : pathBytes = pathBytes ?? Uint8List(0), + reactions = reactions ?? {}; String get senderKeyHex => pubKeyToHex(senderKey); @@ -55,6 +57,7 @@ class Message { int? pathLength, Uint8List? pathBytes, bool? isCli, + Map? reactions, }) { return Message( senderKey: senderKey, @@ -70,9 +73,9 @@ class Message { sentAt: sentAt ?? this.sentAt, deliveredAt: deliveredAt ?? this.deliveredAt, tripTimeMs: tripTimeMs ?? this.tripTimeMs, - forceFlood: forceFlood, pathLength: pathLength ?? this.pathLength, pathBytes: pathBytes ?? this.pathBytes, + reactions: reactions ?? this.reactions, ); } @@ -122,4 +125,8 @@ class Message { pathBytes: pathBytes, ); } + + static ReactionInfo? parseReaction(String text) { + return ReactionHelper.parseReaction(text); + } } diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 6bf4485..cc936c6 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -12,6 +12,7 @@ import '../helpers/utf8_length_limiter.dart'; import '../models/channel.dart'; import '../models/channel_message.dart'; import '../utils/emoji_utils.dart'; +import '../widgets/emoji_picker.dart'; import '../widgets/gif_message.dart'; import '../widgets/gif_picker.dart'; import 'channel_message_path_screen.dart'; @@ -218,19 +219,22 @@ class _ChannelChatScreenState extends State { return Padding( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - child: Row( - mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, + child: Column( + crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ - if (!isOutgoing) ...[ - _buildAvatar(message.senderName), - const SizedBox(width: 8), - ], - Flexible( - child: GestureDetector( - onTap: () => _showMessagePathInfo(message), - onLongPress: () => _showMessageActions(message), - child: Container( + Row( + mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isOutgoing) ...[ + _buildAvatar(message.senderName), + const SizedBox(width: 8), + ], + Flexible( + child: GestureDetector( + onTap: () => _showMessagePathInfo(message), + onLongPress: () => _showMessageActions(message), + child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.65, @@ -322,6 +326,15 @@ class _ChannelChatScreenState extends State { ), ), ), + ], + ), + if (message.reactions.isNotEmpty) ...[ + const SizedBox(height: 4), + Padding( + padding: EdgeInsets.only(left: isOutgoing ? 0 : 48), + child: _buildReactionsDisplay(message), + ), + ], ], ), ); @@ -400,6 +413,49 @@ class _ChannelChatScreenState extends State { ); } + Widget _buildReactionsDisplay(ChannelMessage message) { + return Wrap( + spacing: 6, + runSpacing: 6, + children: message.reactions.entries.map((entry) { + final emoji = entry.key; + final count = entry.value; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(12), + border: Border.all( + 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), + ), + if (count > 1) ...[ + const SizedBox(width: 4), + Text( + '$count', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + ], + ], + ), + ); + }).toList(), + ); + } + String? _parseGifId(String text) { final trimmed = text.trim(); final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); @@ -736,6 +792,14 @@ class _ChannelChatScreenState extends State { _setReplyingTo(message); }, ), + ListTile( + leading: const Icon(Icons.add_reaction_outlined), + title: const Text('Add Reaction'), + onTap: () { + Navigator.pop(sheetContext); + _showEmojiPicker(message); + }, + ), ListTile( leading: const Icon(Icons.copy), title: const Text('Copy'), @@ -763,6 +827,24 @@ class _ChannelChatScreenState extends State { ); } + void _showEmojiPicker(ChannelMessage message) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => EmojiPicker( + onEmojiSelected: (emoji) { + _sendReaction(message, emoji); + }, + ), + ); + } + + void _sendReaction(ChannelMessage message, String emoji) { + final connector = context.read(); + final reactionText = 'r:${message.messageId}:$emoji'; + connector.sendChannelMessage(widget.channel, reactionText); + } + void _copyMessageText(String text) { Clipboard.setData(ClipboardData(text: text)); ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 5e3f647..b80d3c0 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -16,6 +16,7 @@ import '../services/path_history_service.dart'; import 'channel_message_path_screen.dart'; import 'map_screen.dart'; import '../utils/emoji_utils.dart'; +import '../widgets/emoji_picker.dart'; import '../widgets/gif_message.dart'; import '../widgets/gif_picker.dart'; @@ -31,7 +32,7 @@ class ChatScreen extends StatefulWidget { class _ChatScreenState extends State { final _textController = TextEditingController(); final _scrollController = ScrollController(); - bool _forceFlood = false; + bool _clearPath = false; @override void initState() { @@ -59,8 +60,8 @@ class _ChatScreenState extends State { final contact = _resolveContact(connector); final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex); final unreadLabel = 'Unread: $unreadCount'; - final pathLabel = _forceFlood ? 'Flood (forced)' : _currentPathLabel(contact); - final canShowPathDetails = !_forceFlood && contact.path.isNotEmpty; + final pathLabel = _clearPath ? 'Flood (forced)' : _currentPathLabel(contact); + final canShowPathDetails = !_clearPath && contact.path.isNotEmpty; return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, @@ -89,11 +90,11 @@ class _ChatScreenState extends State { centerTitle: false, actions: [ PopupMenuButton( - icon: Icon(_forceFlood ? Icons.waves : Icons.route), + icon: Icon(_clearPath ? Icons.waves : Icons.route), tooltip: 'Routing mode', onSelected: (mode) { setState(() { - _forceFlood = (mode == 'flood'); + _clearPath = (mode == 'flood'); }); }, itemBuilder: (context) => [ @@ -101,12 +102,12 @@ class _ChatScreenState extends State { value: 'auto', child: Row( children: [ - Icon(Icons.auto_mode, size: 20, color: !_forceFlood ? Theme.of(context).primaryColor : null), + Icon(Icons.auto_mode, size: 20, color: !_clearPath ? Theme.of(context).primaryColor : null), const SizedBox(width: 8), Text( 'Auto (use saved path)', style: TextStyle( - fontWeight: !_forceFlood ? FontWeight.bold : FontWeight.normal, + fontWeight: !_clearPath ? FontWeight.bold : FontWeight.normal, ), ), ], @@ -116,12 +117,12 @@ class _ChatScreenState extends State { value: 'flood', child: Row( children: [ - Icon(Icons.waves, size: 20, color: _forceFlood ? Theme.of(context).primaryColor : null), + Icon(Icons.waves, size: 20, color: _clearPath ? Theme.of(context).primaryColor : null), const SizedBox(width: 8), Text( 'Force Flood Mode', style: TextStyle( - fontWeight: _forceFlood ? FontWeight.bold : FontWeight.normal, + fontWeight: _clearPath ? FontWeight.bold : FontWeight.normal, ), ), ], @@ -303,7 +304,7 @@ class _ChatScreenState extends State { connector.sendMessage( widget.contact, text, - forceFlood: _forceFlood, + clearPath: _clearPath, ); _textController.clear(); @@ -420,7 +421,7 @@ class _ChatScreenState extends State { if (!context.mounted) return; setState(() { - _forceFlood = false; + _clearPath = false; }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -490,7 +491,7 @@ class _ChatScreenState extends State { subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)), onTap: () { setState(() { - _forceFlood = true; + _clearPath = true; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -746,24 +747,25 @@ class _ChatScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( - 'Enter node IDs separated by commas.', + 'Enter 2-character hex prefixes for each hop, separated by commas.', style: TextStyle(fontSize: 12, color: Colors.grey), ), const SizedBox(height: 8), const Text( - 'Example: A1B2C3D4,FFEEDDCC', + 'Example: A1,F2,3C (each node uses first byte of its public key)', style: TextStyle(fontSize: 11, color: Colors.grey), ), const SizedBox(height: 16), TextField( controller: controller, decoration: const InputDecoration( - labelText: 'Path', - hintText: 'A1,A2,A3', + labelText: 'Path (hex prefixes)', + hintText: 'A1,F2,3C', border: OutlineInputBorder(), - helperText: 'Node identifiers from your mesh network', + helperText: 'Max 64 hops. Each prefix is 2 hex characters (1 byte)', ), textCapitalization: TextCapitalization.characters, + maxLength: 191, // 64 hops * 2 chars + 63 commas ), ], ), @@ -774,41 +776,74 @@ class _ChatScreenState extends State { ), TextButton( onPressed: () async { - final path = controller.text.trim(); - if (path.isNotEmpty) { - // Parse comma-separated hex strings and convert to bytes - final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); - final pathBytesList = []; + final path = controller.text.trim().toUpperCase(); + if (path.isEmpty) { + if (context.mounted) Navigator.pop(context); + return; + } - for (final id in pathIds) { - if (id.length >= 2) { - try { - pathBytesList.add(int.parse(id.substring(0, 2), radix: 16)); - } catch (e) { - // Skip invalid hex - } - } + // Parse comma-separated hex prefixes + final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); + final pathBytesList = []; + final invalidPrefixes = []; + + for (final id in pathIds) { + if (id.length < 2) { + invalidPrefixes.add(id); + continue; } - if (pathBytesList.isNotEmpty) { - await connector.setContactPath( - widget.contact, - Uint8List.fromList(pathBytesList), - pathBytesList.length, - ); - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Custom path set: $path'), - duration: const Duration(seconds: 2), - ), - ); - } + final prefix = id.substring(0, 2); + try { + final byte = int.parse(prefix, radix: 16); + pathBytesList.add(byte); + } catch (e) { + invalidPrefixes.add(id); } } - if (context.mounted) { - Navigator.pop(context); + + if (!context.mounted) return; + + // Show error for invalid prefixes + if (invalidPrefixes.isNotEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Invalid hex prefixes: ${invalidPrefixes.join(", ")}'), + duration: const Duration(seconds: 3), + backgroundColor: Colors.red, + ), + ); + return; + } + + // Check max path length (64 hops) + if (pathBytesList.length > 64) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Path too long. Maximum 64 hops allowed.'), + duration: Duration(seconds: 3), + backgroundColor: Colors.red, + ), + ); + return; + } + + if (pathBytesList.isNotEmpty) { + await connector.setContactPath( + widget.contact, + Uint8List.fromList(pathBytesList), + pathBytesList.length, + ); + + if (context.mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Path set: ${pathBytesList.length} ${pathBytesList.length == 1 ? "hop" : "hops"}'), + duration: const Duration(seconds: 2), + ), + ); + } } }, child: const Text('Set Path'), @@ -1018,6 +1053,14 @@ class _ChatScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ + ListTile( + leading: const Icon(Icons.add_reaction_outlined), + title: const Text('Add Reaction'), + onTap: () { + Navigator.pop(sheetContext); + _showEmojiPicker(message); + }, + ), ListTile( leading: const Icon(Icons.copy), title: const Text('Copy'), @@ -1072,15 +1115,35 @@ class _ChatScreenState extends State { void _retryMessage(Message message) { final connector = Provider.of(context, listen: false); + // Retry with clearPath if the message has no path or pathLength is -1 (indicating flood was used) + final shouldClearPath = message.pathLength != null && message.pathLength! < 0; connector.sendMessage( widget.contact, message.text, - forceFlood: message.forceFlood, + clearPath: shouldClearPath, ); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Retrying message')), ); } + + void _showEmojiPicker(Message message) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => EmojiPicker( + onEmojiSelected: (emoji) { + _sendReaction(message, emoji); + }, + ), + ); + } + + void _sendReaction(Message message, String emoji) { + final connector = context.read(); + final reactionText = 'r:${message.messageId}:$emoji'; + connector.sendMessage(widget.contact, reactionText); + } } class _MessageBubble extends StatelessWidget { @@ -1114,19 +1177,22 @@ class _MessageBubble extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), - child: GestureDetector( - onTap: onTap, - onLongPress: onLongPress, - child: Row( - mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isOutgoing) ...[ - _buildAvatar(senderName, colorScheme), - const SizedBox(width: 8), - ], - Flexible( - child: Container( + child: Column( + crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: Row( + mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isOutgoing) ...[ + _buildAvatar(senderName, colorScheme), + const SizedBox(width: 8), + ], + Flexible( + child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.65, @@ -1215,6 +1281,15 @@ class _MessageBubble extends StatelessWidget { ], ), ), + if (message.reactions.isNotEmpty) ...[ + const SizedBox(height: 4), + Padding( + padding: EdgeInsets.only(left: isOutgoing ? 0 : 48), + child: _buildReactionsDisplay(context, message, colorScheme), + ), + ], + ], + ), ); } @@ -1288,6 +1363,49 @@ class _MessageBubble extends StatelessWidget { ); } + Widget _buildReactionsDisplay(BuildContext context, Message message, ColorScheme colorScheme) { + return Wrap( + spacing: 6, + runSpacing: 6, + children: message.reactions.entries.map((entry) { + final emoji = entry.key; + final count = entry.value; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: colorScheme.outline.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + emoji, + style: const TextStyle(fontSize: 16), + ), + if (count > 1) ...[ + const SizedBox(width: 4), + Text( + '$count', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: colorScheme.onSecondaryContainer, + ), + ), + ], + ], + ), + ); + }).toList(), + ); + } + Widget _buildAvatar(String senderName, ColorScheme colorScheme) { final initial = _getFirstCharacterOrEmoji(senderName); final color = _getColorForName(senderName); diff --git a/lib/services/message_retry_service.dart b/lib/services/message_retry_service.dart index 1d694df..5e96bd6 100644 --- a/lib/services/message_retry_service.dart +++ b/lib/services/message_retry_service.dart @@ -16,7 +16,7 @@ class MessageRetryService extends ChangeNotifier { final Map _pendingContacts = {}; final Map _pendingPathSelections = {}; - Function(Contact, String, bool, int, int)? _sendMessageCallback; + Function(Contact, String, int, int)? _sendMessageCallback; Function(String, Message)? _addMessageCallback; Function(Message)? _updateMessageCallback; Function(Contact)? _clearContactPathCallback; @@ -27,7 +27,7 @@ class MessageRetryService extends ChangeNotifier { MessageRetryService(this._storage); void initialize({ - required Function(Contact, String, bool, int, int) sendMessageCallback, + required Function(Contact, String, int, int) sendMessageCallback, required Function(String, Message) addMessageCallback, required Function(Message) updateMessageCallback, Function(Contact)? clearContactPathCallback, @@ -47,17 +47,17 @@ class MessageRetryService extends ChangeNotifier { Future sendMessageWithRetry({ required Contact contact, required String text, - bool forceFlood = false, + bool clearPath = false, PathSelection? pathSelection, Uint8List? pathBytes, int? pathLength, }) async { final messageId = const Uuid().v4(); - final effectiveForceFlood = forceFlood || (pathSelection?.useFlood ?? false); + final useClearPath = clearPath || (pathSelection?.useFlood ?? false); final messagePathBytes = - pathBytes ?? _resolveMessagePathBytes(contact, effectiveForceFlood, pathSelection); + pathBytes ?? _resolveMessagePathBytes(contact, useClearPath, pathSelection); final messagePathLength = - pathLength ?? _resolveMessagePathLength(contact, effectiveForceFlood, pathSelection); + pathLength ?? _resolveMessagePathLength(contact, useClearPath, pathSelection); final message = Message( senderKey: contact.publicKey, text: text, @@ -66,7 +66,6 @@ class MessageRetryService extends ChangeNotifier { status: MessageStatus.pending, messageId: messageId, retryCount: 0, - forceFlood: effectiveForceFlood, pathLength: messagePathLength, pathBytes: messagePathBytes, ); @@ -90,29 +89,13 @@ class MessageRetryService extends ChangeNotifier { if (message == null || contact == null) return; - Contact sendContact = contact; final attempt = message.retryCount.clamp(0, 3); - if (message.forceFlood && contact.pathLength >= 0) { - sendContact = Contact( - publicKey: contact.publicKey, - name: contact.name, - type: contact.type, - pathLength: -1, - path: contact.path, - latitude: contact.latitude, - longitude: contact.longitude, - lastSeen: contact.lastSeen, - lastMessageAt: contact.lastMessageAt, - ); - } - if (_sendMessageCallback != null) { final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000; _sendMessageCallback!( - sendContact, + contact, message.text, - message.forceFlood, attempt, timestampSeconds, ); @@ -136,7 +119,7 @@ class MessageRetryService extends ChangeNotifier { } else if (message.pathLength != null) { pathLengthValue = message.pathLength!; } else { - pathLengthValue = message.forceFlood ? -1 : contact.pathLength; + pathLengthValue = contact.pathLength; } actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length); debugPrint('Using calculated timeout: ${actualTimeout}ms for ${contact.pathLength} hops'); @@ -321,7 +304,7 @@ class MessageRetryService extends ChangeNotifier { } PathSelection? _selectionFromMessage(Message message) { - if (message.forceFlood || (message.pathLength != null && message.pathLength! < 0)) { + if (message.pathLength != null && message.pathLength! < 0) { return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true); } if (message.pathBytes.isEmpty && message.pathLength == null) { diff --git a/lib/storage/channel_message_store.dart b/lib/storage/channel_message_store.dart index e0f8ea6..6769c3a 100644 --- a/lib/storage/channel_message_store.dart +++ b/lib/storage/channel_message_store.dart @@ -71,6 +71,7 @@ class ChannelMessageStore { 'replyToMessageId': msg.replyToMessageId, 'replyToSenderName': msg.replyToSenderName, 'replyToText': msg.replyToText, + 'reactions': msg.reactions, }; } @@ -104,6 +105,9 @@ 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), + ) ?? {}, ); } diff --git a/lib/storage/message_store.dart b/lib/storage/message_store.dart index fb889ab..2870d25 100644 --- a/lib/storage/message_store.dart +++ b/lib/storage/message_store.dart @@ -49,9 +49,9 @@ class MessageStore { 'sentAt': msg.sentAt?.millisecondsSinceEpoch, 'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch, 'tripTimeMs': msg.tripTimeMs, - 'forceFlood': msg.forceFlood, 'pathLength': msg.pathLength, 'pathBytes': msg.pathBytes.isNotEmpty ? base64Encode(msg.pathBytes) : null, + 'reactions': msg.reactions, }; } @@ -79,11 +79,13 @@ class MessageStore { ? DateTime.fromMillisecondsSinceEpoch(json['deliveredAt'] as int) : null, tripTimeMs: json['tripTimeMs'] as int?, - forceFlood: json['forceFlood'] as bool? ?? false, pathLength: json['pathLength'] as int?, 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), + ) ?? {}, ); } } diff --git a/lib/widgets/emoji_picker.dart b/lib/widgets/emoji_picker.dart new file mode 100644 index 0000000..06bc98b --- /dev/null +++ b/lib/widgets/emoji_picker.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; + +class EmojiPicker extends StatelessWidget { + final Function(String) onEmojiSelected; + + const EmojiPicker({ + super.key, + required this.onEmojiSelected, + }); + + static const List quickEmojis = ['๐Ÿ‘', 'โค๏ธ', '๐Ÿ˜‚', '๐ŸŽ‰', '๐Ÿ‘', '๐Ÿ”ฅ']; + + static const Map> emojiCategories = { + 'Smileys': [ + '๐Ÿ˜€', '๐Ÿ˜ƒ', '๐Ÿ˜„', '๐Ÿ˜', '๐Ÿ˜…', '๐Ÿ˜‚', '๐Ÿคฃ', '๐Ÿ˜Š', '๐Ÿ˜‡', '๐Ÿ™‚', '๐Ÿ™ƒ', '๐Ÿ˜‰', '๐Ÿ˜Œ', '๐Ÿ˜', '๐Ÿฅฐ', '๐Ÿ˜˜', + '๐Ÿ˜—', '๐Ÿ˜™', '๐Ÿ˜š', '๐Ÿ˜‹', '๐Ÿ˜›', '๐Ÿ˜', '๐Ÿ˜œ', '๐Ÿคช', '๐Ÿคจ', '๐Ÿง', '๐Ÿค“', '๐Ÿ˜Ž', '๐Ÿฅธ', '๐Ÿคฉ', '๐Ÿฅณ', '๐Ÿ˜', + '๐Ÿ˜’', '๐Ÿ˜ž', '๐Ÿ˜”', '๐Ÿ˜Ÿ', '๐Ÿ˜•', '๐Ÿ™', '๐Ÿ˜ฃ', '๐Ÿ˜–', '๐Ÿ˜ซ', '๐Ÿ˜ฉ', '๐Ÿฅบ', '๐Ÿ˜ข', '๐Ÿ˜ญ', '๐Ÿ˜ค', '๐Ÿ˜ ', '๐Ÿ˜ก', + '๐Ÿคฌ', '๐Ÿคฏ', '๐Ÿ˜ณ', '๐Ÿฅต', '๐Ÿฅถ', '๐Ÿ˜ฑ', '๐Ÿ˜จ', '๐Ÿ˜ฐ', '๐Ÿ˜ฅ', '๐Ÿ˜“', '๐Ÿค—', '๐Ÿค”', '๐Ÿคญ', '๐Ÿคซ', '๐Ÿคฅ', '๐Ÿ˜ถ', + ], + 'Gestures': [ + '๐Ÿ‘', '๐Ÿ‘Ž', '๐Ÿ‘Š', 'โœŠ', '๐Ÿค›', '๐Ÿคœ', '๐Ÿคž', 'โœŒ๏ธ', '๐ŸคŸ', '๐Ÿค˜', '๐Ÿ‘Œ', '๐ŸคŒ', '๐Ÿค', '๐Ÿ‘ˆ', '๐Ÿ‘‰', '๐Ÿ‘†', + '๐Ÿ‘‡', 'โ˜๏ธ', '๐Ÿ‘‹', '๐Ÿคš', '๐Ÿ–๏ธ', 'โœ‹', '๐Ÿ––', '๐Ÿ‘', '๐Ÿ™Œ', '๐Ÿ‘', '๐Ÿคฒ', '๐Ÿค', '๐Ÿ™', 'โœ๏ธ', '๐Ÿ’…', '๐Ÿคณ', + ], + 'Hearts': [ + 'โค๏ธ', '๐Ÿงก', '๐Ÿ’›', '๐Ÿ’š', '๐Ÿ’™', '๐Ÿ’œ', '๐Ÿ–ค', '๐Ÿค', '๐ŸคŽ', '๐Ÿ’”', 'โค๏ธโ€๐Ÿ”ฅ', 'โค๏ธโ€๐Ÿฉน', '๐Ÿ’•', '๐Ÿ’ž', '๐Ÿ’“', '๐Ÿ’—', + '๐Ÿ’–', '๐Ÿ’˜', '๐Ÿ’', '๐Ÿ’Ÿ', '๐Ÿ’Œ', '๐Ÿ’ข', '๐Ÿ’ฅ', '๐Ÿ’ซ', '๐Ÿ’ฆ', '๐Ÿ’จ', '๐Ÿ•ณ๏ธ', '๐Ÿ’ฌ', '๐Ÿ‘๏ธโ€๐Ÿ—จ๏ธ', '๐Ÿ—จ๏ธ', '๐Ÿ—ฏ๏ธ', '๐Ÿ’ญ', + ], + 'Objects': [ + '๐ŸŽ‰', '๐ŸŽŠ', '๐ŸŽˆ', '๐ŸŽ', '๐ŸŽ€', '๐Ÿช…', '๐Ÿช†', '๐Ÿ†', '๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰', 'โšฝ', 'โšพ', '๐ŸฅŽ', '๐Ÿ€', '๐Ÿ', + '๐Ÿˆ', '๐Ÿ‰', '๐ŸŽพ', '๐Ÿฅ', '๐ŸŽณ', '๐Ÿ', '๐Ÿ‘', '๐Ÿ’', '๐Ÿฅ', '๐Ÿ“', '๐Ÿธ', '๐ŸฅŠ', '๐Ÿฅ‹', '๐Ÿฅ…', 'โ›ณ', '๐Ÿ”ฅ', + 'โญ', '๐ŸŒŸ', 'โœจ', 'โšก', '๐Ÿ’ก', '๐Ÿ”ฆ', '๐Ÿฎ', '๐Ÿช”', '๐Ÿ“ฑ', '๐Ÿ’ป', 'โŒš', '๐Ÿ“ท', '๐Ÿ“บ', '๐Ÿ“ป', '๐ŸŽต', '๐ŸŽถ', + ], + }; + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.5, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Add Reaction', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Wrap( + spacing: 12, + children: quickEmojis + .map( + (emoji) => InkWell( + onTap: () { + onEmojiSelected(emoji); + Navigator.pop(context); + }, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + emoji, + style: const TextStyle(fontSize: 28), + ), + ), + ), + ) + .toList(), + ), + ), + const Divider(), + Expanded( + child: DefaultTabController( + length: emojiCategories.length, + child: Column( + children: [ + TabBar( + isScrollable: true, + tabs: emojiCategories.keys + .map((cat) => Tab(text: cat)) + .toList(), + ), + Expanded( + child: TabBarView( + children: emojiCategories.values + .map( + (emojis) => GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 8, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + itemCount: emojis.length, + itemBuilder: (context, index) => InkWell( + onTap: () { + onEmojiSelected(emojis[index]); + Navigator.pop(context); + }, + child: Center( + child: Text( + emojis[index], + style: const TextStyle(fontSize: 28), + ), + ), + ), + ), + ) + .toList(), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/mesh-icon.png b/mesh-icon.png new file mode 100644 index 0000000..f7cf267 Binary files /dev/null and b/mesh-icon.png differ diff --git a/pubspec.lock b/pubspec.lock index 9485a82..be2c039 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" args: dependency: transitive description: @@ -65,6 +73,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -230,6 +254,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.5.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea" + url: "https://pub.dev" + source: hosted + version: "0.13.1" flutter_lints: dependency: "direct dev" description: @@ -296,6 +328,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c" + url: "https://pub.dev" + source: hosted + version: "4.7.2" intl: dependency: transitive description: @@ -312,6 +352,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" latlong2: dependency: "direct main" description: @@ -512,6 +560,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" proj4dart: dependency: transitive description: @@ -773,6 +829,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.9.2 <4.0.0" flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml index f95a13a..3681806 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -58,6 +58,7 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^5.0.0 + flutter_launcher_icons: ^0.13.1 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -70,6 +71,11 @@ flutter: # the material Icons class. uses-material-design: true +flutter_launcher_icons: + android: true + ios: true + image_path: "mesh-icon.png" + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg