@ -52,7 +52,9 @@ class _ChatScreenState extends State<ChatScreen> {
_scrollController . onScrollNearTop = _loadOlderMessages ;
_scrollController . onScrollNearTop = _loadOlderMessages ;
SchedulerBinding . instance . addPostFrameCallback ( ( _ ) {
SchedulerBinding . instance . addPostFrameCallback ( ( _ ) {
if ( ! mounted ) return ;
if ( ! mounted ) return ;
context . read < MeshCoreConnector > ( ) . setActiveContact ( widget . contact . publicKeyHex ) ;
context . read < MeshCoreConnector > ( ) . setActiveContact (
widget . contact . publicKeyHex ,
) ;
} ) ;
} ) ;
}
}
@ -91,12 +93,15 @@ class _ChatScreenState extends State<ChatScreen> {
title: Consumer2 < PathHistoryService , MeshCoreConnector > (
title: Consumer2 < PathHistoryService , MeshCoreConnector > (
builder: ( context , pathService , connector , _ ) {
builder: ( context , pathService , connector , _ ) {
final contact = _resolveContact ( connector ) ;
final contact = _resolveContact ( connector ) ;
final unreadCount = connector . getUnreadCountForContactKey ( widget . contact . publicKeyHex ) ;
final unreadCount = connector . getUnreadCountForContactKey (
widget . contact . publicKeyHex ,
) ;
final unreadLabel = context . l10n . chat_unread ( unreadCount ) ;
final unreadLabel = context . l10n . chat_unread ( unreadCount ) ;
final pathLabel = _currentPathLabel ( contact ) ;
final pathLabel = _currentPathLabel ( contact ) ;
/ / Show path details if we have path data ( from device or override )
/ / Show path details if we have path data ( from device or override )
final hasPathData = contact . path . isNotEmpty | | contact . pathOverrideBytes ! = null ;
final hasPathData =
contact . path . isNotEmpty | | contact . pathOverrideBytes ! = null ;
final effectivePath = contact . pathOverrideBytes ? ? contact . path ;
final effectivePath = contact . pathOverrideBytes ? ? contact . path ;
return Column (
return Column (
@ -106,7 +111,9 @@ class _ChatScreenState extends State<ChatScreen> {
Text ( contact . name ) ,
Text ( contact . name ) ,
GestureDetector (
GestureDetector (
behavior: HitTestBehavior . opaque ,
behavior: HitTestBehavior . opaque ,
onTap: hasPathData ? ( ) = > _showFullPathDialog ( context , effectivePath ) : null ,
onTap: hasPathData
? ( ) = > _showFullPathDialog ( context , effectivePath )
: null ,
child: Text (
child: Text (
' $ pathLabel • $ unreadLabel ' ,
' $ pathLabel • $ unreadLabel ' ,
overflow: TextOverflow . ellipsis ,
overflow: TextOverflow . ellipsis ,
@ -144,12 +151,20 @@ class _ChatScreenState extends State<ChatScreen> {
value: ' auto ' ,
value: ' auto ' ,
child: Row (
child: Row (
children: [
children: [
Icon ( Icons . auto_mode , size: 20 , color: ! isFloodMode ? Theme . of ( context ) . primaryColor : null ) ,
Icon (
Icons . auto_mode ,
size: 20 ,
color: ! isFloodMode
? Theme . of ( context ) . primaryColor
: null ,
) ,
const SizedBox ( width: 8 ) ,
const SizedBox ( width: 8 ) ,
Text (
Text (
context . l10n . chat_autoUseSavedPath ,
context . l10n . chat_autoUseSavedPath ,
style: TextStyle (
style: TextStyle (
fontWeight: ! isFloodMode ? FontWeight . bold : FontWeight . normal ,
fontWeight: ! isFloodMode
? FontWeight . bold
: FontWeight . normal ,
) ,
) ,
) ,
) ,
] ,
] ,
@ -159,12 +174,20 @@ class _ChatScreenState extends State<ChatScreen> {
value: ' flood ' ,
value: ' flood ' ,
child: Row (
child: Row (
children: [
children: [
Icon ( Icons . waves , size: 20 , color: isFloodMode ? Theme . of ( context ) . primaryColor : null ) ,
Icon (
Icons . waves ,
size: 20 ,
color: isFloodMode
? Theme . of ( context ) . primaryColor
: null ,
) ,
const SizedBox ( width: 8 ) ,
const SizedBox ( width: 8 ) ,
Text (
Text (
context . l10n . chat_forceFloodMode ,
context . l10n . chat_forceFloodMode ,
style: TextStyle (
style: TextStyle (
fontWeight: isFloodMode ? FontWeight . bold : FontWeight . normal ,
fontWeight: isFloodMode
? FontWeight . bold
: FontWeight . normal ,
) ,
) ,
) ,
) ,
] ,
] ,
@ -196,9 +219,7 @@ class _ChatScreenState extends State<ChatScreen> {
messages . isEmpty
messages . isEmpty
? _buildEmptyState ( )
? _buildEmptyState ( )
: _buildMessageList ( messages , connector ) ,
: _buildMessageList ( messages , connector ) ,
JumpToBottomButton (
JumpToBottomButton ( scrollController: _scrollController ) ,
scrollController: _scrollController ,
) ,
] ,
] ,
) ,
) ,
) ,
) ,
@ -231,7 +252,10 @@ class _ChatScreenState extends State<ChatScreen> {
) ;
) ;
}
}
Widget _buildMessageList ( List < Message > messages , MeshCoreConnector connector ) {
Widget _buildMessageList (
List < Message > messages ,
MeshCoreConnector connector ,
) {
/ / Reverse messages so newest appear at bottom with reverse: true
/ / Reverse messages so newest appear at bottom with reverse: true
final reversedMessages = messages . reversed . toList ( ) ;
final reversedMessages = messages . reversed . toList ( ) ;
final itemCount = reversedMessages . length + ( _isLoadingOlder ? 1 : 0 ) ;
final itemCount = reversedMessages . length + ( _isLoadingOlder ? 1 : 0 ) ;
@ -267,14 +291,21 @@ class _ChatScreenState extends State<ChatScreen> {
if ( widget . contact . type = = advTypeRoom ) {
if ( widget . contact . type = = advTypeRoom ) {
contact = _resolveContactFrom4Bytes (
contact = _resolveContactFrom4Bytes (
connector ,
connector ,
message . fourByteRoomContactKey . isEmpty ? Uint8List . fromList ( [ 0 , 0 , 0 , 0 ] ) : message . fourByteRoomContactKey ,
message . fourByteRoomContactKey . isEmpty
? Uint8List . fromList ( [ 0 , 0 , 0 , 0 ] )
: message . fourByteRoomContactKey ,
) ;
) ;
fourByteHex = message . fourByteRoomContactKey . map ( ( b ) = > b . toRadixString ( 16 ) . padLeft ( 2 , ' 0 ' ) ) . join ( ) . toUpperCase ( ) ;
fourByteHex = message . fourByteRoomContactKey
. map ( ( b ) = > b . toRadixString ( 16 ) . padLeft ( 2 , ' 0 ' ) )
. join ( )
. toUpperCase ( ) ;
}
}
return _MessageBubble (
return _MessageBubble (
message: message ,
message: message ,
senderName: widget . contact . type = = advTypeRoom ? " ${ contact . name } [ $ fourByteHex ] " : contact . name ,
senderName: widget . contact . type = = advTypeRoom
? " ${ contact . name } [ $ fourByteHex ] "
: contact . name ,
isRoomServer: widget . contact . type = = advTypeRoom ,
isRoomServer: widget . contact . type = = advTypeRoom ,
onTap: ( ) = > _openMessagePath ( message , contact ) ,
onTap: ( ) = > _openMessagePath ( message , contact ) ,
onLongPress: ( ) = > _showMessageActions ( message , contact ) ,
onLongPress: ( ) = > _showMessageActions ( message , contact ) ,
@ -290,9 +321,7 @@ class _ChatScreenState extends State<ChatScreen> {
padding: const EdgeInsets . all ( 8 ) ,
padding: const EdgeInsets . all ( 8 ) ,
decoration: BoxDecoration (
decoration: BoxDecoration (
color: colorScheme . surface ,
color: colorScheme . surface ,
border: Border (
border: Border ( top: BorderSide ( color: Theme . of ( context ) . dividerColor ) ) ,
top: BorderSide ( color: Theme . of ( context ) . dividerColor ) ,
) ,
) ,
) ,
child: SafeArea (
child: SafeArea (
child: Row (
child: Row (
@ -314,10 +343,12 @@ class _ChatScreenState extends State<ChatScreen> {
child: ClipRRect (
child: ClipRRect (
borderRadius: BorderRadius . circular ( 12 ) ,
borderRadius: BorderRadius . circular ( 12 ) ,
child: GifMessage (
child: GifMessage (
url: ' https://media.giphy.com/media/ $ gifId /giphy.gif ' ,
url:
backgroundColor: colorScheme . surfaceContainerHighest ,
' https://media.giphy.com/media/ $ gifId /giphy.gif ' ,
fallbackTextColor:
backgroundColor:
colorScheme . onSurface . withValues ( alpha: 0.6 ) ,
colorScheme . surfaceContainerHighest ,
fallbackTextColor: colorScheme . onSurface
. withValues ( alpha: 0.6 ) ,
maxSize: 160 ,
maxSize: 160 ,
) ,
) ,
) ,
) ,
@ -341,7 +372,10 @@ class _ChatScreenState extends State<ChatScreen> {
decoration: InputDecoration (
decoration: InputDecoration (
hintText: context . l10n . chat_typeMessage ,
hintText: context . l10n . chat_typeMessage ,
border: const OutlineInputBorder ( ) ,
border: const OutlineInputBorder ( ) ,
contentPadding: const EdgeInsets . symmetric ( horizontal: 16 , vertical: 12 ) ,
contentPadding: const EdgeInsets . symmetric (
horizontal: 16 ,
vertical: 12 ,
) ,
) ,
) ,
textInputAction: TextInputAction . send ,
textInputAction: TextInputAction . send ,
onSubmitted: ( _ ) = > _sendMessage ( connector ) ,
onSubmitted: ( _ ) = > _sendMessage ( connector ) ,
@ -390,14 +424,10 @@ class _ChatScreenState extends State<ChatScreen> {
return ;
return ;
}
}
connector . sendMessage (
connector . sendMessage ( widget . contact , text ) ;
widget . contact ,
text ,
) ;
_textController . clear ( ) ;
_textController . clear ( ) ;
}
}
void _showPathHistory ( BuildContext context ) {
void _showPathHistory ( BuildContext context ) {
final connector = Provider . of < MeshCoreConnector > ( context , listen: false ) ;
final connector = Provider . of < MeshCoreConnector > ( context , listen: false ) ;
@ -422,13 +452,19 @@ class _ChatScreenState extends State<ChatScreen> {
if ( paths . isNotEmpty ) . . . [
if ( paths . isNotEmpty ) . . . [
Text (
Text (
context . l10n . chat_recentAckPaths ,
context . l10n . chat_recentAckPaths ,
style: const TextStyle ( fontWeight: FontWeight . bold , fontSize: 12 ) ,
style: const TextStyle (
fontWeight: FontWeight . bold ,
fontSize: 12 ,
) ,
) ,
) ,
if ( paths . length > = 100 ) . . . [
if ( paths . length > = 100 ) . . . [
const SizedBox ( height: 8 ) ,
const SizedBox ( height: 8 ) ,
Container (
Container (
width: double . infinity ,
width: double . infinity ,
padding: const EdgeInsets . symmetric ( horizontal: 12 , vertical: 8 ) ,
padding: const EdgeInsets . symmetric (
horizontal: 12 ,
vertical: 8 ,
) ,
decoration: BoxDecoration (
decoration: BoxDecoration (
color: Colors . amber [ 100 ] ,
color: Colors . amber [ 100 ] ,
borderRadius: BorderRadius . circular ( 8 ) ,
borderRadius: BorderRadius . circular ( 8 ) ,
@ -447,7 +483,9 @@ class _ChatScreenState extends State<ChatScreen> {
dense: true ,
dense: true ,
leading: CircleAvatar (
leading: CircleAvatar (
radius: 16 ,
radius: 16 ,
backgroundColor: path . wasFloodDiscovery ? Colors . blue : Colors . green ,
backgroundColor: path . wasFloodDiscovery
? Colors . blue
: Colors . green ,
child: Text (
child: Text (
' ${ path . hopCount } ' ,
' ${ path . hopCount } ' ,
style: const TextStyle ( fontSize: 12 ) ,
style: const TextStyle ( fontSize: 12 ) ,
@ -475,23 +513,36 @@ class _ChatScreenState extends State<ChatScreen> {
} ,
} ,
) ,
) ,
path . wasFloodDiscovery
path . wasFloodDiscovery
? const Icon ( Icons . waves , size: 16 , color: Colors . grey )
? const Icon (
: const Icon ( Icons . route , size: 16 , color: Colors . grey ) ,
Icons . waves ,
size: 16 ,
color: Colors . grey ,
)
: const Icon (
Icons . route ,
size: 16 ,
color: Colors . grey ,
) ,
] ,
] ,
) ,
) ,
onLongPress: ( ) = > _showFullPathDialog ( context , path . pathBytes ) ,
onLongPress: ( ) = >
_showFullPathDialog ( context , path . pathBytes ) ,
onTap: ( ) async {
onTap: ( ) async {
if ( path . pathBytes . isEmpty ) {
if ( path . pathBytes . isEmpty ) {
ScaffoldMessenger . of ( context ) . showSnackBar (
ScaffoldMessenger . of ( context ) . showSnackBar (
SnackBar (
SnackBar (
content: Text ( context . l10n . chat_pathDetailsNotAvailable ) ,
content: Text (
context . l10n . chat_pathDetailsNotAvailable ,
) ,
duration: const Duration ( seconds: 2 ) ,
duration: const Duration ( seconds: 2 ) ,
) ,
) ,
) ;
) ;
return ;
return ;
}
}
final pathBytes = Uint8List . fromList ( path . pathBytes ) ;
final pathBytes = Uint8List . fromList (
path . pathBytes ,
) ;
final pathLength = path . pathBytes . length ;
final pathLength = path . pathBytes . length ;
/ / Set the path override to persist user ' s choice
/ / Set the path override to persist user ' s choice
@ -521,7 +572,10 @@ class _ChatScreenState extends State<ChatScreen> {
const SizedBox ( height: 8 ) ,
const SizedBox ( height: 8 ) ,
Text (
Text (
context . l10n . chat_pathActions ,
context . l10n . chat_pathActions ,
style: const TextStyle ( fontWeight: FontWeight . bold , fontSize: 12 ) ,
style: const TextStyle (
fontWeight: FontWeight . bold ,
fontSize: 12 ,
) ,
) ,
) ,
const SizedBox ( height: 8 ) ,
const SizedBox ( height: 8 ) ,
ListTile (
ListTile (
@ -531,8 +585,14 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors . purple ,
backgroundColor: Colors . purple ,
child: Icon ( Icons . edit_road , size: 16 ) ,
child: Icon ( Icons . edit_road , size: 16 ) ,
) ,
) ,
title: Text ( context . l10n . chat_setCustomPath , style: const TextStyle ( fontSize: 14 ) ) ,
title: Text (
subtitle: Text ( context . l10n . chat_setCustomPathSubtitle , style: const TextStyle ( fontSize: 11 ) ) ,
context . l10n . chat_setCustomPath ,
style: const TextStyle ( fontSize: 14 ) ,
) ,
subtitle: Text (
context . l10n . chat_setCustomPathSubtitle ,
style: const TextStyle ( fontSize: 11 ) ,
) ,
onTap: ( ) {
onTap: ( ) {
Navigator . pop ( context ) ;
Navigator . pop ( context ) ;
_showCustomPathDialog ( context ) ;
_showCustomPathDialog ( context ) ;
@ -545,8 +605,14 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors . orange ,
backgroundColor: Colors . orange ,
child: Icon ( Icons . clear_all , size: 16 ) ,
child: Icon ( Icons . clear_all , size: 16 ) ,
) ,
) ,
title: Text ( context . l10n . chat_clearPath , style: const TextStyle ( fontSize: 14 ) ) ,
title: Text (
subtitle: Text ( context . l10n . chat_clearPathSubtitle , style: const TextStyle ( fontSize: 11 ) ) ,
context . l10n . chat_clearPath ,
style: const TextStyle ( fontSize: 14 ) ,
) ,
subtitle: Text (
context . l10n . chat_clearPathSubtitle ,
style: const TextStyle ( fontSize: 11 ) ,
) ,
onTap: ( ) async {
onTap: ( ) async {
await connector . clearContactPath ( widget . contact ) ;
await connector . clearContactPath ( widget . contact ) ;
if ( ! context . mounted ) return ;
if ( ! context . mounted ) return ;
@ -566,10 +632,19 @@ class _ChatScreenState extends State<ChatScreen> {
backgroundColor: Colors . blue ,
backgroundColor: Colors . blue ,
child: Icon ( Icons . waves , size: 16 ) ,
child: Icon ( Icons . waves , size: 16 ) ,
) ,
) ,
title: Text ( context . l10n . chat_forceFloodMode , style: const TextStyle ( fontSize: 14 ) ) ,
title: Text (
subtitle: Text ( context . l10n . chat_floodModeSubtitle , style: const TextStyle ( fontSize: 11 ) ) ,
context . l10n . chat_forceFloodMode ,
style: const TextStyle ( fontSize: 14 ) ,
) ,
subtitle: Text (
context . l10n . chat_floodModeSubtitle ,
style: const TextStyle ( fontSize: 11 ) ,
) ,
onTap: ( ) async {
onTap: ( ) async {
await connector . setPathOverride ( widget . contact , pathLen: - 1 ) ;
await connector . setPathOverride (
widget . contact ,
pathLen: - 1 ,
) ;
if ( ! context . mounted ) return ;
if ( ! context . mounted ) return ;
ScaffoldMessenger . of ( context ) . showSnackBar (
ScaffoldMessenger . of ( context ) . showSnackBar (
SnackBar (
SnackBar (
@ -598,7 +673,8 @@ class _ChatScreenState extends State<ChatScreen> {
String _formatRelativeTime ( DateTime time ) {
String _formatRelativeTime ( DateTime time ) {
final diff = DateTime . now ( ) . difference ( time ) ;
final diff = DateTime . now ( ) . difference ( time ) ;
if ( diff . inSeconds < 60 ) return context . l10n . time_justNow ;
if ( diff . inSeconds < 60 ) return context . l10n . time_justNow ;
if ( diff . inMinutes < 60 ) return context . l10n . time_minutesAgo ( diff . inMinutes ) ;
if ( diff . inMinutes < 60 )
return context . l10n . time_minutesAgo ( diff . inMinutes ) ;
if ( diff . inHours < 24 ) return context . l10n . time_hoursAgo ( diff . inHours ) ;
if ( diff . inHours < 24 ) return context . l10n . time_hoursAgo ( diff . inHours ) ;
return context . l10n . time_daysAgo ( diff . inDays ) ;
return context . l10n . time_daysAgo ( diff . inDays ) ;
}
}
@ -640,7 +716,10 @@ class _ChatScreenState extends State<ChatScreen> {
) ;
) ;
}
}
Contact _resolveContactFrom4Bytes ( MeshCoreConnector connector , Uint8List key4Bytes ) {
Contact _resolveContactFrom4Bytes (
MeshCoreConnector connector ,
Uint8List key4Bytes ,
) {
return connector . contacts . firstWhere (
return connector . contacts . firstWhere (
( c ) = > listEquals ( c . publicKey . sublist ( 0 , 4 ) , key4Bytes . sublist ( 0 , 4 ) ) ,
( c ) = > listEquals ( c . publicKey . sublist ( 0 , 4 ) , key4Bytes . sublist ( 0 , 4 ) ) ,
orElse: ( ) = > widget . contact ,
orElse: ( ) = > widget . contact ,
@ -674,12 +753,12 @@ class _ChatScreenState extends State<ChatScreen> {
final status = ! connector . isConnected
final status = ! connector . isConnected
? context . l10n . chat_pathSavedLocally
? context . l10n . chat_pathSavedLocally
: ( verified ? context . l10n . chat_pathDeviceConfirmed : context . l10n . chat_pathDeviceNotConfirmed ) ;
: ( verified
? context . l10n . chat_pathDeviceConfirmed
: context . l10n . chat_pathDeviceNotConfirmed ) ;
ScaffoldMessenger . of ( context ) . showSnackBar (
ScaffoldMessenger . of ( context ) . showSnackBar (
SnackBar (
SnackBar (
content: Text (
content: Text ( context . l10n . chat_pathSetHops ( hopCount , status ) ) ,
context . l10n . chat_pathSetHops ( hopCount , status ) ,
) ,
duration: const Duration ( seconds: 3 ) ,
duration: const Duration ( seconds: 3 ) ,
) ,
) ,
) ;
) ;
@ -694,7 +773,9 @@ class _ChatScreenState extends State<ChatScreen> {
builder: ( context ) = > Consumer < MeshCoreConnector > (
builder: ( context ) = > Consumer < MeshCoreConnector > (
builder: ( context , connector , _ ) {
builder: ( context , connector , _ ) {
final contact = _resolveContact ( connector ) ;
final contact = _resolveContact ( connector ) ;
final smazEnabled = connector . isContactSmazEnabled ( contact . publicKeyHex ) ;
final smazEnabled = connector . isContactSmazEnabled (
contact . publicKeyHex ,
) ;
return AlertDialog (
return AlertDialog (
title: Text ( contact . name ) ,
title: Text ( contact . name ) ,
@ -710,7 +791,10 @@ class _ChatScreenState extends State<ChatScreen> {
context . l10n . chat_location ,
context . l10n . chat_location ,
' ${ contact . latitude ? . toStringAsFixed ( 4 ) } , ${ contact . longitude ? . toStringAsFixed ( 4 ) } ' ,
' ${ contact . latitude ? . toStringAsFixed ( 4 ) } , ${ contact . longitude ? . toStringAsFixed ( 4 ) } ' ,
) ,
) ,
_buildInfoRow ( context . l10n . chat_publicKey , ' ${ contact . publicKeyHex . substring ( 0 , 16 ) } ... ' ) ,
_buildInfoRow (
context . l10n . chat_publicKey ,
' ${ contact . publicKeyHex . substring ( 0 , 16 ) } ... ' ,
) ,
const Divider ( ) ,
const Divider ( ) ,
SwitchListTile (
SwitchListTile (
contentPadding: EdgeInsets . zero ,
contentPadding: EdgeInsets . zero ,
@ -718,7 +802,10 @@ class _ChatScreenState extends State<ChatScreen> {
subtitle: Text ( context . l10n . chat_compressOutgoingMessages ) ,
subtitle: Text ( context . l10n . chat_compressOutgoingMessages ) ,
value: smazEnabled ,
value: smazEnabled ,
onChanged: ( value ) {
onChanged: ( value ) {
connector . setContactSmazEnabled ( contact . publicKeyHex , value ) ;
connector . setContactSmazEnabled (
contact . publicKeyHex ,
value ,
) ;
} ,
} ,
) ,
) ,
] ,
] ,
@ -765,7 +852,9 @@ class _ChatScreenState extends State<ChatScreen> {
final connector = Provider . of < MeshCoreConnector > ( context , listen: false ) ;
final connector = Provider . of < MeshCoreConnector > ( context , listen: false ) ;
final currentContact = _resolveContact ( connector ) ;
final currentContact = _resolveContact ( connector ) ;
if ( currentContact . pathLength > 0 & & currentContact . path . isEmpty & & connector . isConnected ) {
if ( currentContact . pathLength > 0 & &
currentContact . path . isEmpty & &
connector . isConnected ) {
connector . getContacts ( ) ;
connector . getContacts ( ) ;
}
}
@ -786,19 +875,31 @@ class _ChatScreenState extends State<ChatScreen> {
onRefresh: connector . isConnected ? connector . getContacts : null ,
onRefresh: connector . isConnected ? connector . getContacts : null ,
) ;
) ;
appLogger . info ( ' PathSelectionDialog returned: ${ result ? . length ? ? 0 } bytes, mounted: $ mounted ' , tag: ' ChatScreen ' ) ;
appLogger . info (
' PathSelectionDialog returned: ${ result ? . length ? ? 0 } bytes, mounted: $ mounted ' ,
tag: ' ChatScreen ' ,
) ;
if ( result = = null ) {
if ( result = = null ) {
appLogger . info ( ' PathSelectionDialog was cancelled or returned null ' , tag: ' ChatScreen ' ) ;
appLogger . info (
' PathSelectionDialog was cancelled or returned null ' ,
tag: ' ChatScreen ' ,
) ;
return ;
return ;
}
}
if ( ! mounted ) {
if ( ! mounted ) {
appLogger . warn ( ' Widget not mounted after dialog, cannot set path ' , tag: ' ChatScreen ' ) ;
appLogger . warn (
' Widget not mounted after dialog, cannot set path ' ,
tag: ' ChatScreen ' ,
) ;
return ;
return ;
}
}
appLogger . info ( ' Calling setPathOverride for ${ widget . contact . name } ' , tag: ' ChatScreen ' ) ;
appLogger . info (
' Calling setPathOverride for ${ widget . contact . name } ' ,
tag: ' ChatScreen ' ,
) ;
await connector . setPathOverride (
await connector . setPathOverride (
widget . contact ,
widget . contact ,
pathLen: result . length ,
pathLen: result . length ,
@ -810,7 +911,6 @@ class _ChatScreenState extends State<ChatScreen> {
await _notifyPathSet ( connector , widget . contact , result , result . length ) ;
await _notifyPathSet ( connector , widget . contact , result , result . length ) ;
}
}
void _openMessagePath ( Message message , Contact contact ) {
void _openMessagePath ( Message message , Contact contact ) {
final connector = context . read < MeshCoreConnector > ( ) ;
final connector = context . read < MeshCoreConnector > ( ) ;
final fourByteHex = message . fourByteRoomContactKey
final fourByteHex = message . fourByteRoomContactKey
@ -877,8 +977,7 @@ class _ChatScreenState extends State<ChatScreen> {
await _deleteMessage ( message ) ;
await _deleteMessage ( message ) ;
} ,
} ,
) ,
) ,
if ( message . isOutgoing & &
if ( message . isOutgoing & & message . status = = MessageStatus . failed )
message . status = = MessageStatus . failed )
ListTile (
ListTile (
leading: const Icon ( Icons . refresh ) ,
leading: const Icon ( Icons . refresh ) ,
title: Text ( context . l10n . common_retry ) ,
title: Text ( context . l10n . common_retry ) ,
@ -909,29 +1008,26 @@ class _ChatScreenState extends State<ChatScreen> {
void _copyMessageText ( String text ) {
void _copyMessageText ( String text ) {
Clipboard . setData ( ClipboardData ( text: text ) ) ;
Clipboard . setData ( ClipboardData ( text: text ) ) ;
ScaffoldMessenger . of ( context ) . showSnackBar (
ScaffoldMessenger . of (
SnackBar( content: Text ( context. l10n . chat_messageCopied ) ) ,
context,
) ;
) .showSnackBar ( SnackBar ( content: Text ( context . l10n . chat_messageCopied ) ) ) ;
}
}
Future < void > _deleteMessage ( Message message ) async {
Future < void > _deleteMessage ( Message message ) async {
await context . read < MeshCoreConnector > ( ) . deleteMessage ( message ) ;
await context . read < MeshCoreConnector > ( ) . deleteMessage ( message ) ;
if ( ! mounted ) return ;
if ( ! mounted ) return ;
ScaffoldMessenger . of ( context ) . showSnackBar (
ScaffoldMessenger . of (
SnackBar( content: Text ( context. l10n . chat_messageDeleted ) ) ,
context,
) ;
) .showSnackBar ( SnackBar ( content: Text ( context . l10n . chat_messageDeleted ) ) ) ;
}
}
void _retryMessage ( Message message ) {
void _retryMessage ( Message message ) {
final connector = Provider . of < MeshCoreConnector > ( context , listen: false ) ;
final connector = Provider . of < MeshCoreConnector > ( context , listen: false ) ;
/ / Retry using the contact ' s current path override setting
/ / Retry using the contact ' s current path override setting
connector . sendMessage (
connector . sendMessage ( widget . contact , message . text ) ;
widget . contact ,
ScaffoldMessenger . of (
message . text ,
context ,
) ;
) . showSnackBar ( SnackBar ( content: Text ( context . l10n . chat_retryingMessage ) ) ) ;
ScaffoldMessenger . of ( context ) . showSnackBar (
SnackBar ( content: Text ( context . l10n . chat_retryingMessage ) ) ,
) ;
}
}
void _showEmojiPicker ( Message message , Contact senderContact ) {
void _showEmojiPicker ( Message message , Contact senderContact ) {
@ -951,11 +1047,17 @@ class _ChatScreenState extends State<ChatScreen> {
final emojiIndex = ReactionHelper . emojiToIndex ( emoji ) ;
final emojiIndex = ReactionHelper . emojiToIndex ( emoji ) ;
if ( emojiIndex = = null ) return ; / / Unknown emoji , skip
if ( emojiIndex = = null ) return ; / / Unknown emoji , skip
final timestampSecs = message . timestamp . millisecondsSinceEpoch ~ / 1000 ;
final timestampSecs = message . timestamp . millisecondsSinceEpoch ~ / 1000 ;
/ / For room servers , include sender name ( like channels ) since multiple users
/ / For room servers , include sender name ( like channels ) since multiple users
/ / For 1 : 1 chats , sender is implicit ( null )
/ / For 1 : 1 chats , sender is implicit ( null )
final senderName = widget . contact . type = = advTypeRoom ? senderContact . name : null ;
final senderName = widget . contact . type = = advTypeRoom
final hash = ReactionHelper . computeReactionHash ( timestampSecs , senderName , message . text ) ;
? senderContact . name
: null ;
final hash = ReactionHelper . computeReactionHash (
timestampSecs ,
senderName ,
message . text ,
) ;
final reactionText = ' r: $ hash : $ emojiIndex ' ;
final reactionText = ' r: $ hash : $ emojiIndex ' ;
connector . sendMessage ( widget . contact , reactionText ) ;
connector . sendMessage ( widget . contact , reactionText ) ;
}
}
@ -985,7 +1087,9 @@ class _MessageBubble extends StatelessWidget {
final isFailed = message . status = = MessageStatus . failed ;
final isFailed = message . status = = MessageStatus . failed ;
final bubbleColor = isFailed
final bubbleColor = isFailed
? colorScheme . errorContainer
? colorScheme . errorContainer
: ( isOutgoing ? colorScheme . primary : colorScheme . surfaceContainerHighest ) ;
: ( isOutgoing
? colorScheme . primary
: colorScheme . surfaceContainerHighest ) ;
final textColor = isFailed
final textColor = isFailed
? colorScheme . onErrorContainer
? colorScheme . onErrorContainer
: ( isOutgoing ? colorScheme . onPrimary : colorScheme . onSurface ) ;
: ( isOutgoing ? colorScheme . onPrimary : colorScheme . onSurface ) ;
@ -997,13 +1101,17 @@ class _MessageBubble extends StatelessWidget {
return Padding (
return Padding (
padding: const EdgeInsets . symmetric ( vertical: 4 ) ,
padding: const EdgeInsets . symmetric ( vertical: 4 ) ,
child: Column (
child: Column (
crossAxisAlignment: isOutgoing ? CrossAxisAlignment . end : CrossAxisAlignment . start ,
crossAxisAlignment: isOutgoing
? CrossAxisAlignment . end
: CrossAxisAlignment . start ,
children: [
children: [
GestureDetector (
GestureDetector (
onTap: onTap ,
onTap: onTap ,
onLongPress: onLongPress ,
onLongPress: onLongPress ,
child: Row (
child: Row (
mainAxisAlignment: isOutgoing ? MainAxisAlignment . end : MainAxisAlignment . start ,
mainAxisAlignment: isOutgoing
? MainAxisAlignment . end
: MainAxisAlignment . start ,
crossAxisAlignment: CrossAxisAlignment . start ,
crossAxisAlignment: CrossAxisAlignment . start ,
children: [
children: [
if ( ! isOutgoing ) . . . [
if ( ! isOutgoing ) . . . [
@ -1012,133 +1120,154 @@ class _MessageBubble extends StatelessWidget {
] ,
] ,
Flexible (
Flexible (
child: Container (
child: Container (
padding: gifId ! = null
padding: gifId ! = null
? const EdgeInsets . all ( 4 )
? const EdgeInsets . all ( 4 )
: const EdgeInsets . symmetric ( horizontal: 12 , vertical: 8 ) ,
: const EdgeInsets . symmetric (
constraints: BoxConstraints (
horizontal: 12 ,
maxWidth: MediaQuery . of ( context ) . size . width * 0.65 ,
vertical: 8 ,
) ,
decoration: BoxDecoration (
color: bubbleColor ,
borderRadius: BorderRadius . circular ( 16 ) ,
) ,
child: Column (
crossAxisAlignment: CrossAxisAlignment . start ,
children: [
if ( ! isOutgoing ) . . . [
Padding (
padding: gifId ! = null
? const EdgeInsets . only ( left: 8 , top: 4 , bottom: 4 )
: EdgeInsets . zero ,
child: Text (
senderName ,
style: TextStyle (
fontSize: 12 ,
fontWeight: FontWeight . bold ,
color: colorScheme . primary ,
) ,
) ,
) ,
constraints: BoxConstraints (
) ,
maxWidth: MediaQuery . of ( context ) . size . width * 0.65 ,
if ( gifId = = null ) const SizedBox ( height: 4 ) ,
) ,
] ,
decoration: BoxDecoration (
if ( poi ! = null )
color: bubbleColor ,
_buildPoiMessage ( context , poi , textColor , metaColor )
borderRadius: BorderRadius . circular ( 16 ) ,
else if ( gifId ! = null )
) ,
ClipRRect (
child: Column (
borderRadius: BorderRadius . circular ( 12 ) ,
crossAxisAlignment: CrossAxisAlignment . start ,
child: GifMessage (
children: [
url: ' https://media.giphy.com/media/ $ gifId /giphy.gif ' ,
if ( ! isOutgoing ) . . . [
backgroundColor: Colors . transparent ,
Padding (
fallbackTextColor: textColor . withValues ( alpha: 0.7 ) ,
padding: gifId ! = null
) ,
? const EdgeInsets . only (
)
left: 8 ,
else
top: 4 ,
Linkify (
bottom: 4 ,
text: messageText ,
)
style: TextStyle (
: EdgeInsets . zero ,
color: textColor ,
child: Text (
) ,
senderName ,
linkStyle: const TextStyle (
style: TextStyle (
color: Colors . green ,
fontSize: 12 ,
decoration: TextDecoration . underline ,
fontWeight: FontWeight . bold ,
) ,
color: colorScheme . primary ,
options: const LinkifyOptions (
) ,
humanize: false ,
defaultToHttps: false ,
) ,
linkifiers: const [ UrlLinkifier ( ) ] ,
onOpen: ( link ) = > LinkHandler . handleLinkTap ( context , link . url ) ,
) ,
if ( isOutgoing & & message . retryCount > 0 ) . . . [
const SizedBox ( height: 4 ) ,
Padding (
padding: gifId ! = null
? const EdgeInsets . symmetric ( horizontal: 8 )
: EdgeInsets . zero ,
child: Text (
context . l10n . chat_retryCount ( message . retryCount , 4 ) ,
style: TextStyle (
fontSize: 10 ,
color: metaColor ,
fontWeight: FontWeight . w500 ,
) ,
) ,
) ,
] ,
const SizedBox ( height: 4 ) ,
Padding (
padding: gifId ! = null
? const EdgeInsets . only ( left: 8 , right: 8 , bottom: 4 )
: EdgeInsets . zero ,
child: Wrap (
spacing: 4 ,
crossAxisAlignment: WrapCrossAlignment . center ,
children: [
Text (
_formatTime ( message . timestamp ) ,
style: TextStyle (
fontSize: 10 ,
color: metaColor ,
) ,
) ,
) ,
) ,
if ( isOutgoing ) . . . [
if ( gifId = = null ) const SizedBox ( height: 4 ) ,
const SizedBox ( width: 4 ) ,
] ,
_buildStatusIcon ( metaColor ) ,
if ( poi ! = null )
] ,
_buildPoiMessage ( context , poi , textColor , metaColor )
if ( message . tripTimeMs ! = null & &
else if ( gifId ! = null )
message . status = = MessageStatus . delivered ) . . . [
ClipRRect (
const SizedBox ( width: 4 ) ,
borderRadius: BorderRadius . circular ( 12 ) ,
Icon (
child: GifMessage (
Icons . speed ,
url:
size: 10 ,
' https://media.giphy.com/media/ $ gifId /giphy.gif ' ,
color: isOutgoing ? metaColor : Colors . green [ 700 ] ,
backgroundColor: Colors . transparent ,
fallbackTextColor: textColor . withValues (
alpha: 0.7 ,
) ,
) ,
)
else
Linkify (
text: messageText ,
style: TextStyle ( color: textColor ) ,
linkStyle: const TextStyle (
color: Colors . green ,
decoration: TextDecoration . underline ,
) ,
options: const LinkifyOptions (
humanize: false ,
defaultToHttps: false ,
) ,
) ,
Text (
linkifiers: const [ UrlLinkifier ( ) ] ,
' ${ ( message . tripTimeMs ! / 1000 ) . toStringAsFixed ( 1 ) } s ' ,
onOpen: ( link ) = >
LinkHandler . handleLinkTap ( context , link . url ) ,
) ,
if ( isOutgoing & & message . retryCount > 0 ) . . . [
const SizedBox ( height: 4 ) ,
Padding (
padding: gifId ! = null
? const EdgeInsets . symmetric ( horizontal: 8 )
: EdgeInsets . zero ,
child: Text (
context . l10n . chat_retryCount (
message . retryCount ,
4 ,
) ,
style: TextStyle (
style: TextStyle (
fontSize: 9 ,
fontSize: 10 ,
color: isOutgoing ? metaColor : Colors . green [ 700 ] ,
color: metaColor ,
fontWeight: FontWeight . w500 ,
) ,
) ,
) ,
) ,
] ,
) ,
] ,
] ,
) ,
const SizedBox ( height: 4 ) ,
Padding (
padding: gifId ! = null
? const EdgeInsets . only (
left: 8 ,
right: 8 ,
bottom: 4 ,
)
: EdgeInsets . zero ,
child: Wrap (
spacing: 4 ,
crossAxisAlignment: WrapCrossAlignment . center ,
children: [
Text (
_formatTime ( message . timestamp ) ,
style: TextStyle (
fontSize: 10 ,
color: metaColor ,
) ,
) ,
if ( isOutgoing ) . . . [
const SizedBox ( width: 4 ) ,
_buildStatusIcon ( metaColor ) ,
] ,
if ( message . tripTimeMs ! = null & &
message . status = =
MessageStatus . delivered ) . . . [
const SizedBox ( width: 4 ) ,
Icon (
Icons . speed ,
size: 10 ,
color: isOutgoing
? metaColor
: Colors . green [ 700 ] ,
) ,
Text (
' ${ ( message . tripTimeMs ! / 1000 ) . toStringAsFixed ( 1 ) } s ' ,
style: TextStyle (
fontSize: 9 ,
color: isOutgoing
? metaColor
: Colors . green [ 700 ] ,
) ,
) ,
] ,
] ,
) ,
) ,
] ,
) ,
) ,
] ,
) ,
) ,
) ,
) ,
] ,
) ,
) ,
if ( message . reactions . isNotEmpty ) . . . [
const SizedBox ( height: 4 ) ,
Padding (
padding: EdgeInsets . only ( left: isOutgoing ? 0 : 48 ) ,
child: _buildReactionsDisplay ( context , message , colorScheme ) ,
) ,
) ,
] ,
] ,
) ,
] ,
) ,
if ( message . reactions . isNotEmpty ) . . . [
const SizedBox ( height: 4 ) ,
Padding (
padding: EdgeInsets . only ( left: isOutgoing ? 0 : 48 ) ,
child: _buildReactionsDisplay ( context , message , colorScheme ) ,
) ,
] ,
] ,
) ,
) ,
) ;
) ;
}
}
@ -1151,8 +1280,9 @@ class _MessageBubble extends StatelessWidget {
_PoiInfo ? _parsePoiMessage ( String text ) {
_PoiInfo ? _parsePoiMessage ( String text ) {
final trimmed = text . trim ( ) ;
final trimmed = text . trim ( ) ;
final match = RegExp ( r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$' )
final match = RegExp (
. firstMatch ( trimmed ) ;
r'^m:([\-0-9.]+),([\-0-9.]+)\|([^|]*)\|.*$' ,
) . firstMatch ( trimmed ) ;
if ( match = = null ) return null ;
if ( match = = null ) return null ;
final lat = double . tryParse ( match . group ( 1 ) ? ? ' ' ) ;
final lat = double . tryParse ( match . group ( 1 ) ? ? ' ' ) ;
final lon = double . tryParse ( match . group ( 2 ) ? ? ' ' ) ;
final lon = double . tryParse ( match . group ( 2 ) ? ? ' ' ) ;
@ -1193,18 +1323,12 @@ class _MessageBubble extends StatelessWidget {
children: [
children: [
Text (
Text (
context . l10n . chat_poiShared ,
context . l10n . chat_poiShared ,
style: TextStyle (
style: TextStyle ( color: textColor , fontWeight: FontWeight . w600 ) ,
color: textColor ,
fontWeight: FontWeight . w600 ,
) ,
) ,
) ,
if ( poi . label . isNotEmpty )
if ( poi . label . isNotEmpty )
Text (
Text (
poi . label ,
poi . label ,
style: TextStyle (
style: TextStyle ( color: metaColor , fontSize: 12 ) ,
color: metaColor ,
fontSize: 12 ,
) ,
) ,
) ,
] ,
] ,
) ,
) ,
@ -1213,7 +1337,11 @@ class _MessageBubble extends StatelessWidget {
) ;
) ;
}
}
Widget _buildReactionsDisplay ( BuildContext context , Message message , ColorScheme colorScheme ) {
Widget _buildReactionsDisplay (
BuildContext context ,
Message message ,
ColorScheme colorScheme ,
) {
return Wrap (
return Wrap (
spacing: 6 ,
spacing: 6 ,
runSpacing: 6 ,
runSpacing: 6 ,
@ -1234,10 +1362,7 @@ class _MessageBubble extends StatelessWidget {
child: Row (
child: Row (
mainAxisSize: MainAxisSize . min ,
mainAxisSize: MainAxisSize . min ,
children: [
children: [
Text (
Text ( emoji , style: const TextStyle ( fontSize: 16 ) ) ,
emoji ,
style: const TextStyle ( fontSize: 16 ) ,
) ,
if ( count > 1 ) . . . [
if ( count > 1 ) . . . [
const SizedBox ( width: 4 ) ,
const SizedBox ( width: 4 ) ,
Text (
Text (
@ -1321,11 +1446,7 @@ class _MessageBubble extends StatelessWidget {
break ;
break ;
}
}
return Icon (
return Icon ( icon , size: 12 , color: color ) ;
icon ,
size: 12 ,
color: color ,
) ;
}
}
String _formatTime ( DateTime time ) {
String _formatTime ( DateTime time ) {
@ -1340,9 +1461,5 @@ class _PoiInfo {
final double lon ;
final double lon ;
final String label ;
final String label ;
const _PoiInfo ( {
const _PoiInfo ( { required this . lat , required this . lon , required this . label } ) ;
required this . lat ,
required this . lon ,
required this . label ,
} ) ;
}
}