diff --git a/sama_chat_client/lib/src/features/conversation/bloc/conversation_bloc.dart b/sama_chat_client/lib/src/features/conversation/bloc/conversation_bloc.dart index 3c40fba..7e1b36c 100644 --- a/sama_chat_client/lib/src/features/conversation/bloc/conversation_bloc.dart +++ b/sama_chat_client/lib/src/features/conversation/bloc/conversation_bloc.dart @@ -20,7 +20,8 @@ part 'conversation_event.dart'; part 'conversation_state.dart'; -const throttleDuration = Duration(milliseconds: 100); +const messagesThrottleDuration = Duration(milliseconds: 100); +const scrollThrottleDuration = Duration(milliseconds: 1000); const scrollToReplyTimeout = Duration(seconds: 7); EventTransformer throttleDroppable(Duration duration) { @@ -54,6 +55,8 @@ class ConversationBloc extends Bloc { StreamSubscription>? lastActivitySubscription; StreamSubscription? conversationWatcher; + Timer? headerTimer; + ConversationBloc({ required this.currentConversation, required this.conversationRepository, @@ -65,7 +68,7 @@ class ConversationBloc extends Bloc { on(_onMessagesRequested); on( _onMessagesMoreRequested, - transformer: throttleDroppable(throttleDuration), + transformer: throttleDroppable(messagesThrottleDuration), ); on( _onParticipantsReceived, @@ -123,6 +126,13 @@ class ConversationBloc extends Bloc { on( onSelectedChatsRemoved, ); + on( + onShowHeader, + transformer: throttleDroppable(scrollThrottleDuration), + ); + on( + onHideHeader, + ); add(const ParticipantsReceived()); @@ -273,6 +283,7 @@ class ConversationBloc extends Bloc { : (List.of(state.messages)..addAll(messages)), hasReachedMax: false, initial: false, + showHeader: false ), ); break; @@ -284,6 +295,20 @@ class ConversationBloc extends Bloc { } } + Future onShowHeader(event, emit) async { + emit(state.copyWith(showHeader: true)); + + headerTimer?.cancel(); + + headerTimer = Timer(const Duration(seconds: 3), () { + add(const HideHeader()); + }); + } + + void onHideHeader(event, emit) { + emit(state.copyWith(showHeader: false)); + } + Future _onParticipantsReceived( ParticipantsReceived event, Emitter emit) async { var participants = @@ -456,27 +481,31 @@ class ConversationBloc extends Bloc { var messages = [...state.messages]; var messagesMap = {}..addEntries(messages.map((m) => MapEntry(m.id, m))); event.status.msgIds?.forEach((id) { - int currentIndex = messages.indexOf(messagesMap[id]); - int prevIndex = currentIndex + 1; - int nextIndex = currentIndex - 1; + int delMsgIndex = messages.indexOf(messagesMap[id]); + messages.remove(messagesMap[id]); + + int prevIndex = delMsgIndex; + int nextIndex = delMsgIndex - 1; ChatMessage? prevMsg = messages.tryGet(prevIndex); ChatMessage? nextMsg = messages.tryGet(nextIndex); var prevMsgUpdated = prevMsg?.copyWith( isLastUserMessage: prevIndex == 0 || - isServiceMessage(messages.tryGet(prevIndex - 2)) || - messages.tryGet(prevIndex - 2)?.from != messages[prevIndex].from, + isServiceMessage(messages.tryGet(prevIndex - 1)) || + messages.tryGet(prevIndex - 1)?.from != messages[prevIndex].from, isFirstUserMessage: prevIndex == messages.length - 1 || - isServiceMessage(messages[prevIndex + 2]) || - messages[prevIndex + 2].from != messages[prevIndex].from); + isServiceMessage(messages[prevIndex + 1]) || + messages[prevIndex + 1].from != messages[prevIndex].from, + bubbleType: bubbleType(messages, prevIndex)); var nextMsgUpdated = nextMsg?.copyWith( isLastUserMessage: nextIndex == 0 || - isServiceMessage(messages[nextIndex - 2]) || - messages[nextIndex - 2].from != messages[nextIndex].from, + isServiceMessage(messages[nextIndex - 1]) || + messages[nextIndex - 1].from != messages[nextIndex].from, isFirstUserMessage: nextIndex == messages.length - 1 || - isServiceMessage(messages[nextIndex + 2]) || - messages[nextIndex + 2].from != messages[nextIndex].from); + isServiceMessage(messages[nextIndex + 1]) || + messages[nextIndex + 1].from != messages[nextIndex].from, + bubbleType: bubbleType(messages, nextIndex)); if (prevMsgUpdated != null && prevMsgUpdated != prevMsg) { messages[prevIndex] = prevMsgUpdated; @@ -485,8 +514,6 @@ class ConversationBloc extends Bloc { if (nextMsgUpdated != null && nextMsgUpdated != nextMsg) { messages[nextIndex] = nextMsgUpdated; } - - messages.remove(messagesMap[id]); }); emit(state.copyWith(messages: messages)); } @@ -592,6 +619,7 @@ class ConversationBloc extends Bloc { typingMessageSubscription?.cancel(); lastActivitySubscription?.cancel(); conversationWatcher?.cancel(); + headerTimer?.cancel(); return super.close(); } } diff --git a/sama_chat_client/lib/src/features/conversation/bloc/conversation_event.dart b/sama_chat_client/lib/src/features/conversation/bloc/conversation_event.dart index 5c3ee9a..21f3f18 100644 --- a/sama_chat_client/lib/src/features/conversation/bloc/conversation_event.dart +++ b/sama_chat_client/lib/src/features/conversation/bloc/conversation_event.dart @@ -129,3 +129,11 @@ final class SelectedChatsRemoved extends ConversationEvent { @override List get props => [message]; } + +final class ShowHeader extends ConversationEvent { + const ShowHeader(); +} + +final class HideHeader extends ConversationEvent { + const HideHeader(); +} diff --git a/sama_chat_client/lib/src/features/conversation/bloc/conversation_state.dart b/sama_chat_client/lib/src/features/conversation/bloc/conversation_state.dart index 1c61459..5befcc7 100644 --- a/sama_chat_client/lib/src/features/conversation/bloc/conversation_state.dart +++ b/sama_chat_client/lib/src/features/conversation/bloc/conversation_state.dart @@ -21,6 +21,7 @@ final class ConversationState extends Equatable { this.typingStatus, this.replyIdToScroll = '', this.choose = false, + this.showHeader = false, }); final ConversationModel conversation; @@ -31,6 +32,7 @@ final class ConversationState extends Equatable { final bool hasReachedMax; final bool initial; final bool choose; + final bool showHeader; final TypingMessageStatus? typingStatus; final String replyIdToScroll; @@ -43,6 +45,7 @@ final class ConversationState extends Equatable { bool? hasReachedMax, bool? initial, bool? choose, + bool? showHeader, String? replyIdToScroll, TypingMessageStatus? typingStatus, }) { @@ -55,6 +58,7 @@ final class ConversationState extends Equatable { hasReachedMax: hasReachedMax ?? this.hasReachedMax, initial: initial ?? this.initial, choose: choose ?? this.choose, + showHeader: showHeader ?? this.showHeader, typingStatus: typingStatus ?? this.typingStatus, replyIdToScroll: replyIdToScroll ?? this.replyIdToScroll, ); @@ -74,6 +78,7 @@ final class ConversationState extends Equatable { hasReachedMax, initial, choose, + showHeader, replyIdToScroll, participants, typingStatus diff --git a/sama_chat_client/lib/src/features/conversation/models/chat_message.dart b/sama_chat_client/lib/src/features/conversation/models/chat_message.dart index 0220b0c..230a0c6 100644 --- a/sama_chat_client/lib/src/features/conversation/models/chat_message.dart +++ b/sama_chat_client/lib/src/features/conversation/models/chat_message.dart @@ -124,6 +124,26 @@ extension ChatMessageExtension on MessageModel { } } +bool isDifferentDay(MessageModel msg, MessageModel? other) { + final msgCreatedAt = + msg.createdAt ?? DateTime.fromMillisecondsSinceEpoch(msg.t!); + final currentMsgDate = + DateTime(msgCreatedAt.year, msgCreatedAt.month, msgCreatedAt.day); + + final nextMsgCreatedAt = + other?.createdAt ?? DateTime.fromMillisecondsSinceEpoch(other?.t ?? 0); + final nextMsgDate = other != null + ? DateTime( + nextMsgCreatedAt.year, nextMsgCreatedAt.month, nextMsgCreatedAt.day) + : null; + + if (currentMsgDate != nextMsgDate) { + return true; + } + + return false; +} + bool sameMsgGroup(MessageModel msg, MessageModel? other) { int diffTime = 45; @@ -134,7 +154,7 @@ bool sameMsgGroup(MessageModel msg, MessageModel? other) { var msgMs = (msg.createdAt?.millisecondsSinceEpoch ?? 0) ~/ 1000; var timeGap = (msgMs - otherMs).abs(); - return isSameOwner && timeGap < diffTime; + return isSameOwner && !isDifferentDay(msg, other) && timeGap < diffTime; } BubbleType bubbleType(List messages, int index) { diff --git a/sama_chat_client/lib/src/features/conversation/view/conversation_page.dart b/sama_chat_client/lib/src/features/conversation/view/conversation_page.dart index f7d2606..d148571 100644 --- a/sama_chat_client/lib/src/features/conversation/view/conversation_page.dart +++ b/sama_chat_client/lib/src/features/conversation/view/conversation_page.dart @@ -7,6 +7,7 @@ import 'package:intl/intl.dart'; import 'package:sama_sdk/api/chats/realtime/typing_manager.dart'; import '../../../db/models/conversation_model.dart'; +import '../../../db/models/models.dart'; import '../../../navigation/constants.dart'; import '../../../repository/attachments/attachments_repository.dart'; import '../../../repository/conversation/conversation_repository.dart'; @@ -80,90 +81,101 @@ class ConversationPage extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder( + buildWhen: (previous, current) => previous.choose != current.choose, builder: (BuildContext context, state) { - return PopScope( - onPopInvokedWithResult: (didPop, result) { - if (didPop) return; - context - .read() - .add(const SelectMessagesMode(false)); - }, - canPop: !state.choose, - child: Scaffold( - appBar: AppBar( - toolbarHeight: 64, - centerTitle: false, - titleSpacing: 0.0, - backgroundColor: smokyBorough, - surfaceTintColor: Colors.transparent, - title: BlocBuilder( - builder: (BuildContext context, aiState) { - return aiState.status == AiMessageStatus.processing - ? const TitleLoader( - black, - Text('AI processing', - style: TextStyle(color: black, fontSize: 20.0))) - : ConnectionTitle( - color: black, - title: Padding( - padding: const EdgeInsets.only(top: 0.0), - child: ListTile( - onTap: () => _infoAction(context), - title: Text( - overflow: TextOverflow.ellipsis, - state.conversation.name, - style: const TextStyle( - fontSize: 28.0, fontWeight: FontWeight.bold), - maxLines: 1, - ), - subtitle: _getSubtitle(state), - ), - ), - ); - }), - actions: [_PopupMenuButton()], - ), - body: Column( - children: [ - BlocListener( - listener: (context, state) { - if (state.status == ConnectionStatus.connected) { - BlocProvider.of(context) - .add(const MessagesRequested(refresh: true)); - } - }, - child: const Flexible(child: MessagesList())), - SafeArea( - child: !state.choose - ? context.read().state.status == - SharingIntentStatus.processing - ? BlocListener( - listener: (context, sendState) { - if (sendState.status == - SendMessageStatus.success || - sendState.status == - SendMessageStatus.failure) { - context - .read() - .add(SharingIntentCompleted()); - } - }, - child: ConnectionChecker( - child: MessageInput( - sharedMessage: context + return PopScope( + onPopInvokedWithResult: (didPop, result) { + if (didPop) return; + context + .read() + .add(const SelectMessagesMode(false)); + }, + canPop: !state.choose, + child: Scaffold( + appBar: AppBar( + toolbarHeight: 64, + centerTitle: false, + titleSpacing: 0.0, + backgroundColor: smokyBorough, + surfaceTintColor: Colors.transparent, + title: BlocBuilder( + builder: (BuildContext context, aiState) { + return aiState.status == AiMessageStatus.processing + ? const TitleLoader( + black, + Text('AI processing', + style: TextStyle(color: black, fontSize: 20.0))) + : ConnectionTitle( + color: black, + title: title, + ); + }), + actions: [_PopupMenuButton()], + ), + body: Column( + children: [ + BlocListener( + listener: (context, state) { + if (state.status == ConnectionStatus.connected) { + BlocProvider.of(context) + .add(const MessagesRequested(refresh: true)); + } + }, + child: const Flexible(child: MessagesList())), + SafeArea( + child: !state.choose + ? context.read().state.status == + SharingIntentStatus.processing + ? BlocListener( + listener: (context, sendState) { + if (sendState.status == + SendMessageStatus.success || + sendState.status == + SendMessageStatus.failure) { + context .read() - .state - .sharedFiles - .firstOrNull)), - ) - : const MessageInput() - : const SelectInput()) - ], - ), - )); - }); + .add(SharingIntentCompleted()); + } + }, + child: ConnectionChecker( + child: MessageInput( + sharedMessage: context + .read() + .state + .sharedFiles + .firstOrNull)), + ) + : const MessageInput() + : const SelectInput()) + ], + ), + )); + }); } + Widget get title => BlocBuilder( + buildWhen: (previous, current) => + previous.conversation != current.conversation || + previous.participants != current.participants || + previous.typingStatus != current.typingStatus, + builder: (BuildContext context, state) { + return Padding( + padding: const EdgeInsets.only(top: 0.0), + child: ListTile( + onTap: () => _infoAction(context), + title: Text( + overflow: TextOverflow.ellipsis, + state.conversation.name, + style: + const TextStyle(fontSize: 28.0, fontWeight: FontWeight.bold), + maxLines: 1, + ), + subtitle: _getSubtitle(state), + ), + ); + }); + Widget _getSubtitle(ConversationState state) { var conversation = state.conversation; var participants = state.participants; diff --git a/sama_chat_client/lib/src/features/conversation/view/messages_list.dart b/sama_chat_client/lib/src/features/conversation/view/messages_list.dart index 6cd65e3..fe6bd68 100644 --- a/sama_chat_client/lib/src/features/conversation/view/messages_list.dart +++ b/sama_chat_client/lib/src/features/conversation/view/messages_list.dart @@ -5,6 +5,7 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../../db/models/models.dart'; import '../../../shared/ui/colors.dart'; +import '../../../shared/utils/date_utils.dart'; import '../../../shared/utils/list_utils.dart'; import '../../../shared/utils/screen_factor.dart'; import '../../../shared/utils/string_utils.dart'; @@ -31,7 +32,7 @@ class MessagesList extends StatefulWidget { } class _MessagesListState extends State { - final _scrollController = ItemScrollController(); + final scrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); @override @@ -47,6 +48,10 @@ class _MessagesListState extends State { }, ), BlocListener( + listenWhen: (previous, current) { + return previous.replyIdToScroll != current.replyIdToScroll || + previous.conversation != current.conversation; + }, listener: (context, state) { scrollToReplyIfNeed(state); markAsReadIfNeed(); @@ -127,6 +132,10 @@ class _MessagesListState extends State { shouldClose) { hideKeyboard(); } + + context + .read() + .add(const ShowHeader()); } else if (notification is ScrollEndNotification) { _onScroll(notification.metrics.pixels, @@ -135,59 +144,24 @@ class _MessagesListState extends State { return false; }, child: ScrollablePositionedList.separated( - reverse: true, - itemBuilder: (BuildContext context, int index) { - var msg = state.messages[index]; - return SwipeTo( - key: Key(msg.id.toString()), - stickToRight: msg.isOwn, - direction: msg.isServiceMessage() - ? DismissDirection.none - : msg.isOwn - ? DismissDirection.endToStart - : DismissDirection.startToEnd, - onSwipe: () { - print('onSwipe'); - context - .read() - .add(AddReplyMessage(msg)); - }, - actionIcon: const Icon( - Icons.reply_rounded, - color: black, - size: 25, - ), - child: MessageItem( - message: msg, - onTapReply: () { - var replyIndex = state.messages - .indexWhere((item) => - item.id == - msg.repliedMessageId); - if (replyIndex == -1) { - if (!state.hasReachedMax) { - context - .read() - .add(MessagesMoreForReply( - msg.repliedMessageId!)); - showProgress(); - } - return; - } - scrollTo(replyIndex); - }, - onTapForward: () => - print('onTapForward')), - ); - }, - itemCount: state.messages.length, - itemScrollController: _scrollController, - itemPositionsListener: itemPositionsListener, - padding: const EdgeInsets.only(top: 5), - separatorBuilder: (context, index) => SizedBox( - height: separateSpace(state.messages, index), - ), - )); + reverse: true, + itemBuilder: (BuildContext context, int index) { + var msg = state.messages[index]; + return Column(children: [ + if (isDifferentDay( + msg, state.messages.tryGet(index + 1))) + buildDateDivider(msg), + buildMessage(msg, state) + ]); + }, + itemCount: state.messages.length, + itemScrollController: scrollController, + itemPositionsListener: itemPositionsListener, + padding: const EdgeInsets.only(top: 5), + separatorBuilder: (context, index) => SizedBox( + height: + separateSpace(state.messages, index), + ))); }); case ConversationStatus.initial: return const Center(child: CircularProgressIndicator()); @@ -200,9 +174,57 @@ class _MessagesListState extends State { }, ), scrollFAB, + dateHeader, ])); } + Widget buildMessage(ChatMessage msg, ConversationState state) { + return SwipeTo( + key: Key(msg.id.toString()), + stickToRight: msg.isOwn, + direction: msg.isServiceMessage() + ? DismissDirection.none + : msg.isOwn + ? DismissDirection.endToStart + : DismissDirection.startToEnd, + onSwipe: () { + print('onSwipe'); + context.read().add(AddReplyMessage(msg)); + }, + actionIcon: const Icon( + Icons.reply_rounded, + color: black, + size: 25, + ), + child: MessageItem( + message: msg, + onTapReply: () { + var replyIndex = state.messages + .indexWhere((item) => item.id == msg.repliedMessageId); + if (replyIndex == -1) { + if (!state.hasReachedMax) { + context + .read() + .add(MessagesMoreForReply(msg.repliedMessageId!)); + showProgress(); + } + return; + } + scrollTo(replyIndex); + }, + onTapForward: () => print('onTapForward')), + ); + } + + Widget buildDateDivider(MessageModel msg) { + final date = msg.createdAt ?? DateTime.fromMillisecondsSinceEpoch(msg.t!); + return Padding( + padding: const EdgeInsets.only(top: 5, bottom: 15), + child: Text(formatDateToDay(date), + style: const TextStyle(fontWeight: FontWeight.w300)), + ); + } + double separateSpace(List messages, int index) { MessageModel currentMsg = messages[index]; MessageModel? prevMsg = messages.tryGet(index + 1); @@ -211,6 +233,47 @@ class _MessagesListState extends State { : 10; } + Widget get dateHeader => BlocSelector( + selector: (state) => state.showHeader, + builder: (context, showHeader) { + return ValueListenableBuilder>( + valueListenable: itemPositionsListener.itemPositions, + builder: (context, positions, child) { + var items = context.read().state.messages; + String? date; + bool? hide = false; + if (positions.isNotEmpty) { + final maxPos = positions + .where((pos) => pos.itemTrailingEdge > 0) + .reduce((max, pos) => + pos.itemLeadingEdge > max.itemLeadingEdge ? pos : max); + + final maxIndex = maxPos.index; + + date = formatDateToDay( + items[maxIndex].createdAt ?? DateTime.now()); + var isDateWidget = + isDifferentDay(items[maxIndex], items.tryGet(maxIndex + 1)); + if (isDateWidget) { + hide = maxPos.itemLeadingEdge < 0.92; + } + } + return Positioned( + top: 0, + left: 0, + right: 0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 100), + opacity: (date != null && !hide && showHeader) ? 1.0 : 0.0, + child: Center( + child: Text(date ?? '', + style: const TextStyle(color: whiteAluminum)), + )), + ); + }); + }); + Widget get scrollFAB => ValueListenableBuilder>( valueListenable: itemPositionsListener.itemPositions, builder: (context, positions, child) { @@ -254,7 +317,7 @@ class _MessagesListState extends State { } void scrollTo(int msgIndex) { - _scrollController.scrollTo( + scrollController.scrollTo( index: msgIndex, duration: const Duration(seconds: 1), curve: Curves.easeInOutCubic); diff --git a/sama_chat_client/lib/src/shared/utils/date_utils.dart b/sama_chat_client/lib/src/shared/utils/date_utils.dart index 7751ea1..5cecfe0 100644 --- a/sama_chat_client/lib/src/shared/utils/date_utils.dart +++ b/sama_chat_client/lib/src/shared/utils/date_utils.dart @@ -36,3 +36,20 @@ String formatSecondsToTime(int totalSeconds) { return "$twoDigitMinutes:$twoDigitSeconds"; } } + +String formatDateToDay(DateTime date) { + final now = DateTime.now(); + + if (date.year == now.year && date.month == now.month && date.day == now.day) { + return "Today"; + } + + final yesterday = now.subtract(const Duration(days: 1)); + if (date.year == yesterday.year && + date.month == yesterday.month && + date.day == yesterday.day) { + return "Yesterday"; + } + + return DateFormat('EEE, MMM d').format(date); +}