add icon, also misc improvments

This commit is contained in:
zach
2025-12-30 20:04:53 -07:00
parent baf92ef672
commit dc9f172d01
41 changed files with 609 additions and 145 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 27 KiB

+2 -2
View File
@@ -427,7 +427,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; 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_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@@ -484,7 +484,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; 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_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 455 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 841 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 22 KiB

+66 -36
View File
@@ -14,6 +14,7 @@ import '../models/channel_message.dart';
import '../models/contact.dart'; import '../models/contact.dart';
import '../models/message.dart'; import '../models/message.dart';
import '../models/path_selection.dart'; import '../models/path_selection.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/smaz.dart'; import '../helpers/smaz.dart';
import '../services/ble_debug_log_service.dart'; import '../services/ble_debug_log_service.dart';
import '../services/message_retry_service.dart'; import '../services/message_retry_service.dart';
@@ -486,7 +487,6 @@ class MeshCoreConnector extends ChangeNotifier {
void _sendMessageDirect( void _sendMessageDirect(
Contact contact, Contact contact,
String text, String text,
bool forceFlood,
int attempt, int attempt,
int timestampSeconds, int timestampSeconds,
) async { ) async {
@@ -496,7 +496,6 @@ class MeshCoreConnector extends ChangeNotifier {
buildSendTextMsgFrame( buildSendTextMsgFrame(
contact.publicKey, contact.publicKey,
outboundText, outboundText,
forceFlood: forceFlood,
attempt: attempt, attempt: attempt,
timestampSeconds: timestampSeconds, timestampSeconds: timestampSeconds,
), ),
@@ -914,21 +913,13 @@ class MeshCoreConnector extends ChangeNotifier {
Future<void> sendMessage( Future<void> sendMessage(
Contact contact, Contact contact,
String text, { String text, {
bool forceFlood = false, bool clearPath = false,
Uint8List? customPath,
int? customPathLen,
}) async { }) async {
if (!isConnected || text.isEmpty) return; if (!isConnected || text.isEmpty) return;
// If custom path is provided, temporarily update the contact's path // Handle auto-rotation if enabled
if (customPath != null && customPathLen != null && customPathLen >= 0) {
await setContactPath(contact, customPath, customPathLen);
}
PathSelection? autoSelection; PathSelection? autoSelection;
if (customPath == null && if (_appSettingsService?.settings.autoRouteRotationEnabled == true && !clearPath) {
_appSettingsService?.settings.autoRouteRotationEnabled == true &&
!forceFlood) {
autoSelection = _pathHistoryService?.getNextAutoPathSelection(contact.publicKeyHex); autoSelection = _pathHistoryService?.getNextAutoPathSelection(contact.publicKeyHex);
if (autoSelection != null) { if (autoSelection != null) {
_pathHistoryService?.recordPathAttempt(contact.publicKeyHex, autoSelection); _pathHistoryService?.recordPathAttempt(contact.publicKeyHex, autoSelection);
@@ -943,23 +934,21 @@ class MeshCoreConnector extends ChangeNotifier {
} }
if (_retryService != null) { if (_retryService != null) {
final pathBytes = final pathBytes = _resolveOutgoingPathBytes(contact, clearPath, autoSelection);
_resolveOutgoingPathBytes(contact, customPath, customPathLen, forceFlood, autoSelection); final pathLength = _resolveOutgoingPathLength(contact, clearPath, autoSelection);
final pathLength =
_resolveOutgoingPathLength(contact, customPathLen, forceFlood, autoSelection);
final selectedContact = _applyAutoSelection(contact, autoSelection); final selectedContact = _applyAutoSelection(contact, autoSelection);
await _retryService!.sendMessageWithRetry( await _retryService!.sendMessageWithRetry(
contact: selectedContact, contact: selectedContact,
text: text, text: text,
forceFlood: forceFlood, clearPath: clearPath,
pathSelection: autoSelection, pathSelection: autoSelection,
pathBytes: pathBytes, pathBytes: pathBytes,
pathLength: pathLength, pathLength: pathLength,
); );
} else { } else {
// Fallback to old behavior if retry service not initialized // Fallback to old behavior if retry service not initialized
final pathBytes = _resolveOutgoingPathBytes(contact, customPath, customPathLen, forceFlood, autoSelection); final pathBytes = _resolveOutgoingPathBytes(contact, clearPath, autoSelection);
final pathLength = _resolveOutgoingPathLength(contact, customPathLen, forceFlood, autoSelection); final pathLength = _resolveOutgoingPathLength(contact, clearPath, autoSelection);
final message = Message.outgoing( final message = Message.outgoing(
contact.publicKey, contact.publicKey,
text, text,
@@ -973,7 +962,6 @@ class MeshCoreConnector extends ChangeNotifier {
buildSendTextMsgFrame( buildSendTextMsgFrame(
contact.publicKey, contact.publicKey,
outboundText, outboundText,
forceFlood: forceFlood,
), ),
); );
} }
@@ -1962,9 +1950,35 @@ class MeshCoreConnector extends ChangeNotifier {
void _addMessage(String pubKeyHex, Message message) { void _addMessage(String pubKeyHex, Message message) {
_conversations.putIfAbsent(pubKeyHex, () => []); _conversations.putIfAbsent(pubKeyHex, () => []);
_conversations[pubKeyHex]!.add(message); final messages = _conversations[pubKeyHex]!;
_messageStore.saveMessages(pubKeyHex, _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(); notifyListeners();
return; // Don't add reaction as a visible message
}
messages.add(message);
_messageStore.saveMessages(pubKeyHex, messages);
notifyListeners();
}
void _processContactReaction(List<Message> 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<String, int>.from(messages[i].reactions);
currentReactions[reactionInfo.emoji] =
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
messages[i] = messages[i].copyWith(reactions: currentReactions);
break;
}
}
} }
_RawPacket? _parseRawPacket(Uint8List raw) { _RawPacket? _parseRawPacket(Uint8List raw) {
@@ -2048,17 +2062,12 @@ class MeshCoreConnector extends ChangeNotifier {
Uint8List _resolveOutgoingPathBytes( Uint8List _resolveOutgoingPathBytes(
Contact contact, Contact contact,
Uint8List? customPath, bool clearPath,
int? customPathLen,
bool forceFlood,
PathSelection? selection, PathSelection? selection,
) { ) {
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) { if (clearPath || contact.pathLength < 0 || selection?.useFlood == true) {
return Uint8List(0); return Uint8List(0);
} }
if (customPath != null && customPathLen != null && customPathLen > 0) {
return Uint8List.fromList(customPath.sublist(0, customPathLen));
}
if (selection != null && selection.pathBytes.isNotEmpty) { if (selection != null && selection.pathBytes.isNotEmpty) {
return Uint8List.fromList(selection.pathBytes); return Uint8List.fromList(selection.pathBytes);
} }
@@ -2067,16 +2076,12 @@ class MeshCoreConnector extends ChangeNotifier {
int? _resolveOutgoingPathLength( int? _resolveOutgoingPathLength(
Contact contact, Contact contact,
int? customPathLen, bool clearPath,
bool forceFlood,
PathSelection? selection, PathSelection? selection,
) { ) {
if (forceFlood || contact.pathLength < 0 || selection?.useFlood == true) { if (clearPath || contact.pathLength < 0 || selection?.useFlood == true) {
return -1; return -1;
} }
if (customPathLen != null && customPathLen > 0) {
return customPathLen;
}
if (selection != null && selection.pathBytes.isNotEmpty) { if (selection != null && selection.pathBytes.isNotEmpty) {
return selection.hopCount; return selection.hopCount;
} }
@@ -2087,6 +2092,16 @@ class MeshCoreConnector extends ChangeNotifier {
_channelMessages.putIfAbsent(channelIndex, () => []); _channelMessages.putIfAbsent(channelIndex, () => []);
final messages = _channelMessages[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 // Parse reply info from message text
final replyInfo = ChannelMessage.parseReplyMention(message.text); final replyInfo = ChannelMessage.parseReplyMention(message.text);
ChannelMessage processedMessage = message; ChannelMessage processedMessage = message;
@@ -2158,6 +2173,21 @@ class MeshCoreConnector extends ChangeNotifier {
return null; return null;
} }
void _processReaction(List<ChannelMessage> 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<String, int>.from(messages[i].reactions);
currentReactions[reactionInfo.emoji] =
(currentReactions[reactionInfo.emoji] ?? 0) + 1;
messages[i] = messages[i].copyWith(reactions: currentReactions);
notifyListeners();
break;
}
}
}
int _findChannelRepeatIndex(List<ChannelMessage> messages, ChannelMessage incoming) { int _findChannelRepeatIndex(List<ChannelMessage> messages, ChannelMessage incoming) {
for (int i = messages.length - 1; i >= 0; i--) { for (int i = messages.length - 1; i >= 0; i--) {
final existing = messages[i]; final existing = messages[i];
+1 -2
View File
@@ -280,14 +280,13 @@ Uint8List buildSendStatusRequestFrame(Uint8List recipientPubKey) {
Uint8List buildSendTextMsgFrame( Uint8List buildSendTextMsgFrame(
Uint8List recipientPubKey, Uint8List recipientPubKey,
String text, { String text, {
bool forceFlood = false,
int attempt = 0, int attempt = 0,
int? timestampSeconds, int? timestampSeconds,
}) { }) {
final textBytes = utf8.encode(text); final textBytes = utf8.encode(text);
final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000); final timestamp = timestampSeconds ?? (DateTime.now().millisecondsSinceEpoch ~/ 1000);
const prefixSize = 6; 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); final frame = Uint8List(1 + 1 + 1 + 4 + prefixSize + textBytes.length + 1);
int offset = 0; int offset = 0;
+22
View File
@@ -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)!,
);
}
}
+10
View File
@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../helpers/reaction_helper.dart';
import '../helpers/smaz.dart'; import '../helpers/smaz.dart';
enum ChannelMessageStatus { pending, sent, failed } enum ChannelMessageStatus { pending, sent, failed }
@@ -38,6 +39,7 @@ class ChannelMessage {
final String? replyToMessageId; final String? replyToMessageId;
final String? replyToSenderName; final String? replyToSenderName;
final String? replyToText; final String? replyToText;
final Map<String, int> reactions;
ChannelMessage({ ChannelMessage({
this.senderKey, this.senderKey,
@@ -56,7 +58,9 @@ class ChannelMessage {
this.replyToMessageId, this.replyToMessageId,
this.replyToSenderName, this.replyToSenderName,
this.replyToText, this.replyToText,
Map<String, int>? reactions,
}) : messageId = messageId ?? '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}', }) : messageId = messageId ?? '${timestamp.millisecondsSinceEpoch}_${senderName.hashCode}_${text.hashCode}',
reactions = reactions ?? {},
pathBytes = pathBytes ?? Uint8List(0), pathBytes = pathBytes ?? Uint8List(0),
pathVariants = _mergePathVariants( pathVariants = _mergePathVariants(
pathBytes ?? Uint8List(0), pathBytes ?? Uint8List(0),
@@ -75,6 +79,7 @@ class ChannelMessage {
String? replyToMessageId, String? replyToMessageId,
String? replyToSenderName, String? replyToSenderName,
String? replyToText, String? replyToText,
Map<String, int>? reactions,
}) { }) {
return ChannelMessage( return ChannelMessage(
senderKey: senderKey, senderKey: senderKey,
@@ -93,6 +98,7 @@ class ChannelMessage {
replyToMessageId: replyToMessageId ?? this.replyToMessageId, replyToMessageId: replyToMessageId ?? this.replyToMessageId,
replyToSenderName: replyToSenderName ?? this.replyToSenderName, replyToSenderName: replyToSenderName ?? this.replyToSenderName,
replyToText: replyToText ?? this.replyToText, replyToText: replyToText ?? this.replyToText,
reactions: reactions ?? this.reactions,
); );
} }
@@ -233,6 +239,10 @@ class ChannelMessage {
actualMessage: match.group(2)!, actualMessage: match.group(2)!,
); );
} }
static ReactionInfo? parseReaction(String text) {
return ReactionHelper.parseReaction(text);
}
} }
class ReplyInfo { class ReplyInfo {
+11 -4
View File
@@ -1,5 +1,6 @@
import 'dart:typed_data'; import 'dart:typed_data';
import '../connector/meshcore_protocol.dart'; import '../connector/meshcore_protocol.dart';
import '../helpers/reaction_helper.dart';
enum MessageStatus { pending, sent, delivered, failed } enum MessageStatus { pending, sent, delivered, failed }
@@ -19,9 +20,9 @@ class Message {
final DateTime? sentAt; final DateTime? sentAt;
final DateTime? deliveredAt; final DateTime? deliveredAt;
final int? tripTimeMs; final int? tripTimeMs;
final bool forceFlood;
final int? pathLength; final int? pathLength;
final Uint8List pathBytes; final Uint8List pathBytes;
final Map<String, int> reactions;
Message({ Message({
required this.senderKey, required this.senderKey,
@@ -37,10 +38,11 @@ class Message {
this.sentAt, this.sentAt,
this.deliveredAt, this.deliveredAt,
this.tripTimeMs, this.tripTimeMs,
this.forceFlood = false,
this.pathLength, this.pathLength,
Uint8List? pathBytes, Uint8List? pathBytes,
}) : pathBytes = pathBytes ?? Uint8List(0); Map<String, int>? reactions,
}) : pathBytes = pathBytes ?? Uint8List(0),
reactions = reactions ?? {};
String get senderKeyHex => pubKeyToHex(senderKey); String get senderKeyHex => pubKeyToHex(senderKey);
@@ -55,6 +57,7 @@ class Message {
int? pathLength, int? pathLength,
Uint8List? pathBytes, Uint8List? pathBytes,
bool? isCli, bool? isCli,
Map<String, int>? reactions,
}) { }) {
return Message( return Message(
senderKey: senderKey, senderKey: senderKey,
@@ -70,9 +73,9 @@ class Message {
sentAt: sentAt ?? this.sentAt, sentAt: sentAt ?? this.sentAt,
deliveredAt: deliveredAt ?? this.deliveredAt, deliveredAt: deliveredAt ?? this.deliveredAt,
tripTimeMs: tripTimeMs ?? this.tripTimeMs, tripTimeMs: tripTimeMs ?? this.tripTimeMs,
forceFlood: forceFlood,
pathLength: pathLength ?? this.pathLength, pathLength: pathLength ?? this.pathLength,
pathBytes: pathBytes ?? this.pathBytes, pathBytes: pathBytes ?? this.pathBytes,
reactions: reactions ?? this.reactions,
); );
} }
@@ -122,4 +125,8 @@ class Message {
pathBytes: pathBytes, pathBytes: pathBytes,
); );
} }
static ReactionInfo? parseReaction(String text) {
return ReactionHelper.parseReaction(text);
}
} }
+83 -1
View File
@@ -12,6 +12,7 @@ import '../helpers/utf8_length_limiter.dart';
import '../models/channel.dart'; import '../models/channel.dart';
import '../models/channel_message.dart'; import '../models/channel_message.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart'; import '../widgets/gif_message.dart';
import '../widgets/gif_picker.dart'; import '../widgets/gif_picker.dart';
import 'channel_message_path_screen.dart'; import 'channel_message_path_screen.dart';
@@ -218,7 +219,10 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row( child: Column(
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start, mainAxisAlignment: isOutgoing ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -324,6 +328,15 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
), ),
], ],
), ),
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<ChannelChatScreen> {
); );
} }
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) { String? _parseGifId(String text) {
final trimmed = text.trim(); final trimmed = text.trim();
final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed); final match = RegExp(r'^g:([A-Za-z0-9_-]+)$').firstMatch(trimmed);
@@ -736,6 +792,14 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
_setReplyingTo(message); _setReplyingTo(message);
}, },
), ),
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: const Text('Add Reaction'),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message);
},
),
ListTile( ListTile(
leading: const Icon(Icons.copy), leading: const Icon(Icons.copy),
title: const Text('Copy'), title: const Text('Copy'),
@@ -763,6 +827,24 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
); );
} }
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<MeshCoreConnector>();
final reactionText = 'r:${message.messageId}:$emoji';
connector.sendChannelMessage(widget.channel, reactionText);
}
void _copyMessageText(String text) { void _copyMessageText(String text) {
Clipboard.setData(ClipboardData(text: text)); Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
+148 -30
View File
@@ -16,6 +16,7 @@ import '../services/path_history_service.dart';
import 'channel_message_path_screen.dart'; import 'channel_message_path_screen.dart';
import 'map_screen.dart'; import 'map_screen.dart';
import '../utils/emoji_utils.dart'; import '../utils/emoji_utils.dart';
import '../widgets/emoji_picker.dart';
import '../widgets/gif_message.dart'; import '../widgets/gif_message.dart';
import '../widgets/gif_picker.dart'; import '../widgets/gif_picker.dart';
@@ -31,7 +32,7 @@ class ChatScreen extends StatefulWidget {
class _ChatScreenState extends State<ChatScreen> { class _ChatScreenState extends State<ChatScreen> {
final _textController = TextEditingController(); final _textController = TextEditingController();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
bool _forceFlood = false; bool _clearPath = false;
@override @override
void initState() { void initState() {
@@ -59,8 +60,8 @@ class _ChatScreenState extends State<ChatScreen> {
final contact = _resolveContact(connector); final contact = _resolveContact(connector);
final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex); final unreadCount = connector.getUnreadCountForContactKey(widget.contact.publicKeyHex);
final unreadLabel = 'Unread: $unreadCount'; final unreadLabel = 'Unread: $unreadCount';
final pathLabel = _forceFlood ? 'Flood (forced)' : _currentPathLabel(contact); final pathLabel = _clearPath ? 'Flood (forced)' : _currentPathLabel(contact);
final canShowPathDetails = !_forceFlood && contact.path.isNotEmpty; final canShowPathDetails = !_clearPath && contact.path.isNotEmpty;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -89,11 +90,11 @@ class _ChatScreenState extends State<ChatScreen> {
centerTitle: false, centerTitle: false,
actions: [ actions: [
PopupMenuButton<String>( PopupMenuButton<String>(
icon: Icon(_forceFlood ? Icons.waves : Icons.route), icon: Icon(_clearPath ? Icons.waves : Icons.route),
tooltip: 'Routing mode', tooltip: 'Routing mode',
onSelected: (mode) { onSelected: (mode) {
setState(() { setState(() {
_forceFlood = (mode == 'flood'); _clearPath = (mode == 'flood');
}); });
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
@@ -101,12 +102,12 @@ class _ChatScreenState extends State<ChatScreen> {
value: 'auto', value: 'auto',
child: Row( child: Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
'Auto (use saved path)', 'Auto (use saved path)',
style: TextStyle( style: TextStyle(
fontWeight: !_forceFlood ? FontWeight.bold : FontWeight.normal, fontWeight: !_clearPath ? FontWeight.bold : FontWeight.normal,
), ),
), ),
], ],
@@ -116,12 +117,12 @@ class _ChatScreenState extends State<ChatScreen> {
value: 'flood', value: 'flood',
child: Row( child: Row(
children: [ 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), const SizedBox(width: 8),
Text( Text(
'Force Flood Mode', 'Force Flood Mode',
style: TextStyle( style: TextStyle(
fontWeight: _forceFlood ? FontWeight.bold : FontWeight.normal, fontWeight: _clearPath ? FontWeight.bold : FontWeight.normal,
), ),
), ),
], ],
@@ -303,7 +304,7 @@ class _ChatScreenState extends State<ChatScreen> {
connector.sendMessage( connector.sendMessage(
widget.contact, widget.contact,
text, text,
forceFlood: _forceFlood, clearPath: _clearPath,
); );
_textController.clear(); _textController.clear();
@@ -420,7 +421,7 @@ class _ChatScreenState extends State<ChatScreen> {
if (!context.mounted) return; if (!context.mounted) return;
setState(() { setState(() {
_forceFlood = false; _clearPath = false;
}); });
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -490,7 +491,7 @@ class _ChatScreenState extends State<ChatScreen> {
subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)), subtitle: const Text('Use routing toggle in app bar', style: TextStyle(fontSize: 11)),
onTap: () { onTap: () {
setState(() { setState(() {
_forceFlood = true; _clearPath = true;
}); });
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -746,24 +747,25 @@ class _ChatScreenState extends State<ChatScreen> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( 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), style: TextStyle(fontSize: 12, color: Colors.grey),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( 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), style: TextStyle(fontSize: 11, color: Colors.grey),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
controller: controller, controller: controller,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Path', labelText: 'Path (hex prefixes)',
hintText: 'A1,A2,A3', hintText: 'A1,F2,3C',
border: OutlineInputBorder(), border: OutlineInputBorder(),
helperText: 'Node identifiers from your mesh network', helperText: 'Max 64 hops. Each prefix is 2 hex characters (1 byte)',
), ),
textCapitalization: TextCapitalization.characters, textCapitalization: TextCapitalization.characters,
maxLength: 191, // 64 hops * 2 chars + 63 commas
), ),
], ],
), ),
@@ -774,20 +776,56 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
final path = controller.text.trim(); final path = controller.text.trim().toUpperCase();
if (path.isNotEmpty) { if (path.isEmpty) {
// Parse comma-separated hex strings and convert to bytes if (context.mounted) Navigator.pop(context);
return;
}
// Parse comma-separated hex prefixes
final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); final pathIds = path.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
final pathBytesList = <int>[]; final pathBytesList = <int>[];
final invalidPrefixes = <String>[];
for (final id in pathIds) { for (final id in pathIds) {
if (id.length >= 2) { if (id.length < 2) {
invalidPrefixes.add(id);
continue;
}
final prefix = id.substring(0, 2);
try { try {
pathBytesList.add(int.parse(id.substring(0, 2), radix: 16)); final byte = int.parse(prefix, radix: 16);
pathBytesList.add(byte);
} catch (e) { } catch (e) {
// Skip invalid hex invalidPrefixes.add(id);
} }
} }
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) { if (pathBytesList.isNotEmpty) {
@@ -798,18 +836,15 @@ class _ChatScreenState extends State<ChatScreen> {
); );
if (context.mounted) { if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text('Custom path set: $path'), content: Text('Path set: ${pathBytesList.length} ${pathBytesList.length == 1 ? "hop" : "hops"}'),
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
), ),
); );
} }
} }
}
if (context.mounted) {
Navigator.pop(context);
}
}, },
child: const Text('Set Path'), child: const Text('Set Path'),
), ),
@@ -1018,6 +1053,14 @@ class _ChatScreenState extends State<ChatScreen> {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile(
leading: const Icon(Icons.add_reaction_outlined),
title: const Text('Add Reaction'),
onTap: () {
Navigator.pop(sheetContext);
_showEmojiPicker(message);
},
),
ListTile( ListTile(
leading: const Icon(Icons.copy), leading: const Icon(Icons.copy),
title: const Text('Copy'), title: const Text('Copy'),
@@ -1072,15 +1115,35 @@ class _ChatScreenState extends State<ChatScreen> {
void _retryMessage(Message message) { void _retryMessage(Message message) {
final connector = Provider.of<MeshCoreConnector>(context, listen: false); final connector = Provider.of<MeshCoreConnector>(context, listen: false);
// Retry 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( connector.sendMessage(
widget.contact, widget.contact,
message.text, message.text,
forceFlood: message.forceFlood, clearPath: shouldClearPath,
); );
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Retrying message')), 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<MeshCoreConnector>();
final reactionText = 'r:${message.messageId}:$emoji';
connector.sendMessage(widget.contact, reactionText);
}
} }
class _MessageBubble extends StatelessWidget { class _MessageBubble extends StatelessWidget {
@@ -1114,7 +1177,10 @@ class _MessageBubble extends StatelessWidget {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: GestureDetector( child: Column(
crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
child: Row( child: Row(
@@ -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) { Widget _buildAvatar(String senderName, ColorScheme colorScheme) {
final initial = _getFirstCharacterOrEmoji(senderName); final initial = _getFirstCharacterOrEmoji(senderName);
final color = _getColorForName(senderName); final color = _getColorForName(senderName);
+9 -26
View File
@@ -16,7 +16,7 @@ class MessageRetryService extends ChangeNotifier {
final Map<String, Contact> _pendingContacts = {}; final Map<String, Contact> _pendingContacts = {};
final Map<String, PathSelection> _pendingPathSelections = {}; final Map<String, PathSelection> _pendingPathSelections = {};
Function(Contact, String, bool, int, int)? _sendMessageCallback; Function(Contact, String, int, int)? _sendMessageCallback;
Function(String, Message)? _addMessageCallback; Function(String, Message)? _addMessageCallback;
Function(Message)? _updateMessageCallback; Function(Message)? _updateMessageCallback;
Function(Contact)? _clearContactPathCallback; Function(Contact)? _clearContactPathCallback;
@@ -27,7 +27,7 @@ class MessageRetryService extends ChangeNotifier {
MessageRetryService(this._storage); MessageRetryService(this._storage);
void initialize({ void initialize({
required Function(Contact, String, bool, int, int) sendMessageCallback, required Function(Contact, String, int, int) sendMessageCallback,
required Function(String, Message) addMessageCallback, required Function(String, Message) addMessageCallback,
required Function(Message) updateMessageCallback, required Function(Message) updateMessageCallback,
Function(Contact)? clearContactPathCallback, Function(Contact)? clearContactPathCallback,
@@ -47,17 +47,17 @@ class MessageRetryService extends ChangeNotifier {
Future<void> sendMessageWithRetry({ Future<void> sendMessageWithRetry({
required Contact contact, required Contact contact,
required String text, required String text,
bool forceFlood = false, bool clearPath = false,
PathSelection? pathSelection, PathSelection? pathSelection,
Uint8List? pathBytes, Uint8List? pathBytes,
int? pathLength, int? pathLength,
}) async { }) async {
final messageId = const Uuid().v4(); final messageId = const Uuid().v4();
final effectiveForceFlood = forceFlood || (pathSelection?.useFlood ?? false); final useClearPath = clearPath || (pathSelection?.useFlood ?? false);
final messagePathBytes = final messagePathBytes =
pathBytes ?? _resolveMessagePathBytes(contact, effectiveForceFlood, pathSelection); pathBytes ?? _resolveMessagePathBytes(contact, useClearPath, pathSelection);
final messagePathLength = final messagePathLength =
pathLength ?? _resolveMessagePathLength(contact, effectiveForceFlood, pathSelection); pathLength ?? _resolveMessagePathLength(contact, useClearPath, pathSelection);
final message = Message( final message = Message(
senderKey: contact.publicKey, senderKey: contact.publicKey,
text: text, text: text,
@@ -66,7 +66,6 @@ class MessageRetryService extends ChangeNotifier {
status: MessageStatus.pending, status: MessageStatus.pending,
messageId: messageId, messageId: messageId,
retryCount: 0, retryCount: 0,
forceFlood: effectiveForceFlood,
pathLength: messagePathLength, pathLength: messagePathLength,
pathBytes: messagePathBytes, pathBytes: messagePathBytes,
); );
@@ -90,29 +89,13 @@ class MessageRetryService extends ChangeNotifier {
if (message == null || contact == null) return; if (message == null || contact == null) return;
Contact sendContact = contact;
final attempt = message.retryCount.clamp(0, 3); 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) { if (_sendMessageCallback != null) {
final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000; final timestampSeconds = message.timestamp.millisecondsSinceEpoch ~/ 1000;
_sendMessageCallback!( _sendMessageCallback!(
sendContact, contact,
message.text, message.text,
message.forceFlood,
attempt, attempt,
timestampSeconds, timestampSeconds,
); );
@@ -136,7 +119,7 @@ class MessageRetryService extends ChangeNotifier {
} else if (message.pathLength != null) { } else if (message.pathLength != null) {
pathLengthValue = message.pathLength!; pathLengthValue = message.pathLength!;
} else { } else {
pathLengthValue = message.forceFlood ? -1 : contact.pathLength; pathLengthValue = contact.pathLength;
} }
actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length); actualTimeout = _calculateTimeoutCallback!(pathLengthValue, message.text.length);
debugPrint('Using calculated timeout: ${actualTimeout}ms for ${contact.pathLength} hops'); debugPrint('Using calculated timeout: ${actualTimeout}ms for ${contact.pathLength} hops');
@@ -321,7 +304,7 @@ class MessageRetryService extends ChangeNotifier {
} }
PathSelection? _selectionFromMessage(Message message) { 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); return const PathSelection(pathBytes: [], hopCount: -1, useFlood: true);
} }
if (message.pathBytes.isEmpty && message.pathLength == null) { if (message.pathBytes.isEmpty && message.pathLength == null) {
+4
View File
@@ -71,6 +71,7 @@ class ChannelMessageStore {
'replyToMessageId': msg.replyToMessageId, 'replyToMessageId': msg.replyToMessageId,
'replyToSenderName': msg.replyToSenderName, 'replyToSenderName': msg.replyToSenderName,
'replyToText': msg.replyToText, 'replyToText': msg.replyToText,
'reactions': msg.reactions,
}; };
} }
@@ -104,6 +105,9 @@ class ChannelMessageStore {
replyToMessageId: json['replyToMessageId'] as String?, replyToMessageId: json['replyToMessageId'] as String?,
replyToSenderName: json['replyToSenderName'] as String?, replyToSenderName: json['replyToSenderName'] as String?,
replyToText: json['replyToText'] as String?, replyToText: json['replyToText'] as String?,
reactions: (json['reactions'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(key, value as int),
) ?? {},
); );
} }
+4 -2
View File
@@ -49,9 +49,9 @@ class MessageStore {
'sentAt': msg.sentAt?.millisecondsSinceEpoch, 'sentAt': msg.sentAt?.millisecondsSinceEpoch,
'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch, 'deliveredAt': msg.deliveredAt?.millisecondsSinceEpoch,
'tripTimeMs': msg.tripTimeMs, 'tripTimeMs': msg.tripTimeMs,
'forceFlood': msg.forceFlood,
'pathLength': msg.pathLength, 'pathLength': msg.pathLength,
'pathBytes': msg.pathBytes.isNotEmpty ? base64Encode(msg.pathBytes) : null, 'pathBytes': msg.pathBytes.isNotEmpty ? base64Encode(msg.pathBytes) : null,
'reactions': msg.reactions,
}; };
} }
@@ -79,11 +79,13 @@ class MessageStore {
? DateTime.fromMillisecondsSinceEpoch(json['deliveredAt'] as int) ? DateTime.fromMillisecondsSinceEpoch(json['deliveredAt'] as int)
: null, : null,
tripTimeMs: json['tripTimeMs'] as int?, tripTimeMs: json['tripTimeMs'] as int?,
forceFlood: json['forceFlood'] as bool? ?? false,
pathLength: json['pathLength'] as int?, pathLength: json['pathLength'] as int?,
pathBytes: json['pathBytes'] != null pathBytes: json['pathBytes'] != null
? Uint8List.fromList(base64Decode(json['pathBytes'] as String)) ? Uint8List.fromList(base64Decode(json['pathBytes'] as String))
: Uint8List(0), : Uint8List(0),
reactions: (json['reactions'] as Map<String, dynamic>?)?.map(
(key, value) => MapEntry(key, value as int),
) ?? {},
); );
} }
} }
+137
View File
@@ -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<String> quickEmojis = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
static const Map<String, List<String>> 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(),
),
),
],
),
),
),
],
),
);
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 579 KiB

+64
View File
@@ -1,6 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
archive:
dependency: transitive
description:
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev"
source: hosted
version: "4.0.7"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -65,6 +73,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: clock:
dependency: transitive dependency: transitive
description: description:
@@ -230,6 +254,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.5.0" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -296,6 +328,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" version: "4.1.2"
image:
dependency: transitive
description:
name: image
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
url: "https://pub.dev"
source: hosted
version: "4.7.2"
intl: intl:
dependency: transitive dependency: transitive
description: description:
@@ -312,6 +352,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.2" 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: latlong2:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -512,6 +560,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.1" version: "1.0.1"
posix:
dependency: transitive
description:
name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev"
source: hosted
version: "6.0.3"
proj4dart: proj4dart:
dependency: transitive dependency: transitive
description: description:
@@ -773,6 +829,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.6.1" version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.9.2 <4.0.0" dart: ">=3.9.2 <4.0.0"
flutter: ">=3.35.0" flutter: ">=3.35.0"
+6
View File
@@ -58,6 +58,7 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint # package. See that file for information about deactivating specific lint
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^5.0.0 flutter_lints: ^5.0.0
flutter_launcher_icons: ^0.13.1
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec
@@ -70,6 +71,11 @@ flutter:
# the material Icons class. # the material Icons class.
uses-material-design: true 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: # To add assets to your application, add an assets section, like this:
# assets: # assets:
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg