diff --git a/lib/pages/chat/chat_app_bar_title.dart b/lib/pages/chat/chat_app_bar_title.dart index 2e755f7d..68c5681b 100644 --- a/lib/pages/chat/chat_app_bar_title.dart +++ b/lib/pages/chat/chat_app_bar_title.dart @@ -4,8 +4,10 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:go_router/go_router.dart'; import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/presence_builder.dart'; class ChatAppBarTitle extends StatelessWidget { final ChatController controller; @@ -34,16 +36,37 @@ class ChatAppBarTitle extends StatelessWidget { MatrixLocals(L10n.of(context)!), ), size: 32, + presenceUserId: room.directChatMatrixID, ), ), const SizedBox(width: 12), Expanded( - child: Text( - room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 16, + child: ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + ), + ), + subtitle: PresenceBuilder( + userId: room.directChatMatrixID, + builder: (context, presence) { + final lastActiveTimestamp = presence?.lastActiveTimestamp; + if (presence?.currentlyActive == true) { + return Text(L10n.of(context)!.currentlyActive); + } + if (lastActiveTimestamp != null) { + return Text( + L10n.of(context)!.lastActiveAgo( + lastActiveTimestamp.localizedTimeShort(context), + ), + ); + } + return const SizedBox.shrink(); + }, ), ), ), diff --git a/lib/pages/chat/event_info_dialog.dart b/lib/pages/chat/event_info_dialog.dart index 0846e137..40400f68 100644 --- a/lib/pages/chat/event_info_dialog.dart +++ b/lib/pages/chat/event_info_dialog.dart @@ -51,6 +51,7 @@ class EventInfoDialog extends StatelessWidget { leading: Avatar( mxContent: event.senderFromMemoryOrFallback.avatarUrl, name: event.senderFromMemoryOrFallback.calcDisplayname(), + presenceUserId: event.senderId, ), title: Text(L10n.of(context)!.sender), subtitle: Text( diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index e15c7c41..9389a51f 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -142,6 +142,7 @@ class Message extends StatelessWidget { return Avatar( mxContent: user.avatarUrl, name: user.calcDisplayname(), + presenceUserId: user.stateKey, onTap: () => onAvatarTab(event), ); }, diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 8ead66e4..0e90ef9b 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -80,6 +80,7 @@ class MessageContent extends StatelessWidget { leading: Avatar( mxContent: sender.avatarUrl, name: sender.calcDisplayname(), + presenceUserId: sender.stateKey, ), title: Text(sender.calcDisplayname()), subtitle: Text(event.originServerTs.localizedTime(context)), diff --git a/lib/pages/chat/events/message_reactions.dart b/lib/pages/chat/events/message_reactions.dart index 5b0ec29f..7e731c93 100644 --- a/lib/pages/chat/events/message_reactions.dart +++ b/lib/pages/chat/events/message_reactions.dart @@ -210,6 +210,7 @@ class _AdaptableReactorsDialog extends StatelessWidget { mxContent: reactor.avatarUrl, name: reactor.displayName, client: client, + presenceUserId: reactor.stateKey, ), label: Text(reactor.displayName!), ), diff --git a/lib/pages/chat/seen_by_row.dart b/lib/pages/chat/seen_by_row.dart index faac0db4..293be1bb 100644 --- a/lib/pages/chat/seen_by_row.dart +++ b/lib/pages/chat/seen_by_row.dart @@ -43,6 +43,7 @@ class SeenByRow extends StatelessWidget { name: user.calcDisplayname(), size: 16, fontSize: 9, + presenceUserId: user.stateKey, ), ), if (seenByUsers.length > maxAvatars) diff --git a/lib/pages/chat_details/participant_list_item.dart b/lib/pages/chat_details/participant_list_item.dart index f6986d15..e044e38e 100644 --- a/lib/pages/chat_details/participant_list_item.dart +++ b/lib/pages/chat_details/participant_list_item.dart @@ -81,8 +81,11 @@ class ParticipantListItem extends StatelessWidget { ], ), subtitle: Text(user.id), - leading: - Avatar(mxContent: user.avatarUrl, name: user.calcDisplayname()), + leading: Avatar( + mxContent: user.avatarUrl, + name: user.calcDisplayname(), + presenceUserId: user.stateKey, + ), ), ); } diff --git a/lib/pages/chat_list/chat_list_item.dart b/lib/pages/chat_list/chat_list_item.dart index b37c8d20..c7857c61 100644 --- a/lib/pages/chat_list/chat_list_item.dart +++ b/lib/pages/chat_list/chat_list_item.dart @@ -158,6 +158,11 @@ class ChatListItem extends StatelessWidget { : 14.0 : 0.0; final hasNotifications = room.notificationCount > 0; + final backgroundColor = selected + ? Theme.of(context).colorScheme.primaryContainer + : activeChat + ? Theme.of(context).colorScheme.secondaryContainer + : null; final displayname = room.getLocalizedDisplayname( MatrixLocals(L10n.of(context)!), ); @@ -169,11 +174,7 @@ class ChatListItem extends StatelessWidget { child: Material( borderRadius: BorderRadius.circular(AppConfig.borderRadius), clipBehavior: Clip.hardEdge, - color: selected - ? Theme.of(context).colorScheme.primaryContainer - : activeChat - ? Theme.of(context).colorScheme.secondaryContainer - : Colors.transparent, + color: backgroundColor, child: ListTile( visualDensity: const VisualDensity(vertical: -0.5), contentPadding: const EdgeInsets.symmetric(horizontal: 8), @@ -192,6 +193,8 @@ class ChatListItem extends StatelessWidget { mxContent: room.avatar, name: displayname, onTap: onLongPress, + presenceUserId: room.directChatMatrixID, + presenceBackgroundColor: backgroundColor, ), title: Row( children: [ diff --git a/lib/pages/invitation_selection/invitation_selection_view.dart b/lib/pages/invitation_selection/invitation_selection_view.dart index 8a5a55dd..559d9ba3 100644 --- a/lib/pages/invitation_selection/invitation_selection_view.dart +++ b/lib/pages/invitation_selection/invitation_selection_view.dart @@ -157,6 +157,7 @@ class _InviteContactListTile extends StatelessWidget { leading: Avatar( mxContent: avatarUrl, name: displayname, + presenceUserId: userId, ), title: Text( displayname, diff --git a/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart b/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart index 544fc92c..c0d9dedd 100644 --- a/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart +++ b/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart @@ -3,8 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; +import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/fluffy_share.dart'; import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/presence_builder.dart'; import '../../widgets/matrix.dart'; import 'user_bottom_sheet.dart'; @@ -30,7 +32,47 @@ class UserBottomSheetView extends StatelessWidget { leading: CloseButton( onPressed: Navigator.of(context, rootNavigator: false).pop, ), - title: Text(displayname.trim().split(' ').first), + title: ListTile( + contentPadding: EdgeInsets.zero, + title: Text(displayname.trim().split(' ').first), + subtitle: PresenceBuilder( + userId: userId, + client: client, + builder: (context, presence) { + if (presence == null) return const SizedBox.shrink(); + + final dotColor = presence.presence.isOnline + ? Colors.green + : presence.presence.isUnavailable + ? Colors.orange + : Colors.red; + + final lastActiveTimestamp = presence.lastActiveTimestamp; + + return Row( + children: [ + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: dotColor, + borderRadius: BorderRadius.circular(16), + ), + ), + if (presence.currentlyActive == true) + Text(L10n.of(context)!.currentlyActive), + if (lastActiveTimestamp != null) + Text( + L10n.of(context)!.lastActiveAgo( + lastActiveTimestamp.localizedTimeShort(context), + ), + ), + ], + ); + }, + ), + ), actions: [ if (userId != client.userID && !client.ignoredUsers.contains(userId)) diff --git a/lib/widgets/avatar.dart b/lib/widgets/avatar.dart index be1424da..ed43e13d 100644 --- a/lib/widgets/avatar.dart +++ b/lib/widgets/avatar.dart @@ -4,6 +4,7 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; +import 'package:fluffychat/widgets/presence_builder.dart'; class Avatar extends StatelessWidget { final Uri? mxContent; @@ -13,6 +14,8 @@ class Avatar extends StatelessWidget { static const double defaultSize = 44; final Client? client; final double fontSize; + final String? presenceUserId; + final Color? presenceBackgroundColor; const Avatar({ this.mxContent, @@ -21,6 +24,8 @@ class Avatar extends StatelessWidget { this.onTap, this.client, this.fontSize = 18, + this.presenceUserId, + this.presenceBackgroundColor, super.key, }); @@ -48,26 +53,64 @@ class Avatar extends StatelessWidget { ), ); final borderRadius = BorderRadius.circular(size / 2); - final container = ClipRRect( - borderRadius: borderRadius, - child: Container( - width: size, - height: size, - color: noPic - ? name?.lightColorAvatar - : Theme.of(context).secondaryHeaderColor, - child: noPic - ? textWidget - : MxcImage( - key: Key(mxContent.toString()), - uri: mxContent, - fit: BoxFit.cover, - width: size, - height: size, - placeholder: (_) => textWidget, - cacheKey: mxContent.toString(), + final presenceUserId = this.presenceUserId; + final color = + noPic ? name?.lightColorAvatar : Theme.of(context).secondaryHeaderColor; + final container = Stack( + children: [ + ClipRRect( + borderRadius: borderRadius, + child: Container( + width: size, + height: size, + color: color, + child: noPic + ? textWidget + : MxcImage( + key: Key(mxContent.toString()), + uri: mxContent, + fit: BoxFit.cover, + width: size, + height: size, + placeholder: (_) => textWidget, + cacheKey: mxContent.toString(), + ), + ), + ), + PresenceBuilder( + userId: presenceUserId, + builder: (context, presence) { + if (presence == null) return const SizedBox.shrink(); + final dotColor = presence.presence.isOnline + ? Colors.green + : presence.presence.isUnavailable + ? Colors.orange + : Colors.red; + return Positioned( + bottom: -4, + right: -4, + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: presenceBackgroundColor ?? + Theme.of(context).colorScheme.background, + borderRadius: BorderRadius.circular(32), + ), + alignment: Alignment.center, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: dotColor, + borderRadius: BorderRadius.circular(16), + ), + ), ), - ), + ); + }, + ), + ], ); if (onTap == null) return container; return InkWell( diff --git a/lib/widgets/presence_builder.dart b/lib/widgets/presence_builder.dart new file mode 100644 index 00000000..223fe0cc --- /dev/null +++ b/lib/widgets/presence_builder.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/widgets/matrix.dart'; + +class PresenceBuilder extends StatelessWidget { + final Widget Function(BuildContext context, CachedPresence? presence) builder; + final String? userId; + final Client? client; + + const PresenceBuilder({ + required this.builder, + this.userId, + this.client, + super.key, + }); + + @override + Widget build(BuildContext context) { + final userId = this.userId; + if (userId == null) return builder(context, null); + + final client = this.client ?? Matrix.of(context).client; + return StreamBuilder( + stream: client.onPresenceChanged.stream + .where((cachedPresence) => cachedPresence.userid == userId), + builder: (context, snapshot) => builder( + context, + snapshot.data ?? client.presences[userId], + ), + ); + } +}