diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 8369298b1..1686416e1 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,10 @@ +## Upcoming + +✅ Added + +- Added support for `MessageReminder` feature, which allows users to bookmark or set reminders + for specific messages in a channel. + ## 9.11.0 ✅ Added diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index e42cc5c77..de20218bc 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -1292,6 +1292,44 @@ class Channel { ); } + /// Create a reminder for the given [messageId]. + /// + /// Optionally, provide a [remindAt] date to set when the reminder should + /// be triggered. If not provided, the reminder will be created as a + /// bookmark type instead. + Future createReminder( + String messageId, { + DateTime? remindAt, + }) { + _checkInitialized(); + return _client.createReminder( + messageId, + remindAt: remindAt, + ); + } + + /// Update an existing reminder with the given [reminderId]. + /// + /// Optionally, provide a [remindAt] date to set when the reminder should + /// be triggered. If not provided, the reminder will be updated as a + /// bookmark type instead. + Future updateReminder( + String messageId, { + DateTime? remindAt, + }) { + _checkInitialized(); + return _client.updateReminder( + messageId, + remindAt: remindAt, + ); + } + + /// Remove the reminder for the given [messageId]. + Future deleteReminder(String messageId) { + _checkInitialized(); + return _client.deleteReminder(messageId); + } + /// Send a reaction to this channel. /// /// Set [enforceUnique] to true to remove the existing user reaction. @@ -2093,6 +2131,16 @@ class ChannelClientState { _listenUserStopWatching(); + /* Start of reminder events */ + + _listenReminderCreated(); + + _listenReminderUpdated(); + + _listenReminderDeleted(); + + /* End of reminder events */ + _startCleaningStaleTypingEvents(); _startCleaningStalePinnedMessages(); @@ -2578,6 +2626,65 @@ class ChannelClientState { ); } + void _listenReminderCreated() { + _subscriptions.add( + _channel.on(EventType.reminderCreated).listen((event) { + final reminder = event.reminder; + if (reminder == null) return; + + updateReminder(reminder); + }), + ); + } + + void _listenReminderUpdated() { + _subscriptions.add( + _channel.on(EventType.reminderUpdated).listen((event) { + final reminder = event.reminder; + if (reminder == null) return; + + updateReminder(reminder); + }), + ); + } + + void _listenReminderDeleted() { + _subscriptions.add( + _channel.on(EventType.reminderDeleted).listen((event) { + final reminder = event.reminder; + if (reminder == null) return; + + deleteReminder(reminder); + }), + ); + } + + /// Updates the [reminder] of the message if it exists. + void updateReminder(MessageReminder reminder) { + final messageId = reminder.messageId; + // TODO: Improve once we have support for parentId in reminders. + for (final message in [...messages, ...threads.values.flattened]) { + if (message.id == messageId) { + return updateMessage( + message.copyWith(reminder: reminder), + ); + } + } + } + + /// Deletes the [reminder] of the message if it exists. + void deleteReminder(MessageReminder reminder) { + final messageId = reminder.messageId; + // TODO: Improve once we have support for parentId in reminders. + for (final message in [...messages, ...threads.values.flattened]) { + if (message.id == messageId) { + return updateMessage( + message.copyWith(reminder: null), + ); + } + } + } + void _listenReactionDeleted() { _subscriptions.add(_channel.on(EventType.reactionDeleted).listen((event) { final oldMessage = diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 0d9b86949..f848182cf 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -26,6 +26,7 @@ import 'package:stream_chat/src/core/models/event.dart'; import 'package:stream_chat/src/core/models/filter.dart'; import 'package:stream_chat/src/core/models/member.dart'; import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/message_reminder.dart'; import 'package:stream_chat/src/core/models/own_user.dart'; import 'package:stream_chat/src/core/models/poll.dart'; import 'package:stream_chat/src/core/models/poll_option.dart'; @@ -1914,14 +1915,6 @@ class StreamChatClient { required String channelId, required String channelType, }) { - final currentUser = state.currentUser; - if (currentUser == null) { - throw const StreamChatError( - 'User is not set on client, ' - 'use `connectUser` or `connectAnonymousUser` instead', - ); - } - return partialMemberUpdate( channelId: channelId, channelType: channelType, @@ -1962,6 +1955,53 @@ class StreamChatClient { ); } + /// Queries reminders for the current user. + /// + /// Optionally, pass [filter], [sort] and [pagination] to filter, sort and + /// paginate the reminders. + Future queryReminders({ + Filter? filter, + SortOrder? sort, + PaginationParams pagination = const PaginationParams(), + }) { + return _chatApi.reminders.queryReminders( + filter: filter, + sort: sort, + pagination: pagination, + ); + } + + /// Creates a reminder for the given [messageId]. + /// + /// Optionally, pass [remindAt] to set the reminder time. + Future createReminder( + String messageId, { + DateTime? remindAt, + }) { + return _chatApi.reminders.createReminder( + messageId, + remindAt: remindAt, + ); + } + + /// Updates a reminder for the given [messageId]. + /// + /// Optionally, pass [remindAt] to set the new reminder time. + Future updateReminder( + String messageId, { + DateTime? remindAt, + }) { + return _chatApi.reminders.updateReminder( + messageId, + remindAt: remindAt, + ); + } + + /// Deletes a reminder for the given [messageId]. + Future deleteReminder(String messageId) { + return _chatApi.reminders.deleteReminder(messageId); + } + /// Closes the [_ws] connection and resets the [state] /// If [flushChatPersistence] is true the client deletes all offline /// user's data. diff --git a/packages/stream_chat/lib/src/core/api/reminders_api.dart b/packages/stream_chat/lib/src/core/api/reminders_api.dart new file mode 100644 index 000000000..df2590fa7 --- /dev/null +++ b/packages/stream_chat/lib/src/core/api/reminders_api.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; + +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/responses.dart'; +import 'package:stream_chat/src/core/api/sort_order.dart'; +import 'package:stream_chat/src/core/http/stream_http_client.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/message_reminder.dart'; + +/// Defines the api dedicated to message reminders operations +class RemindersApi { + /// Initialize a new reminders api + const RemindersApi(this._client); + + final StreamHttpClient _client; + + /// Retrieves the list of reminders for the current user. + /// + /// Optionally, you can filter and sort the reminders using the [filter] and + /// [sort] parameters respectively. You can also paginate the results using + /// [pagination]. + /// + /// Returns a [QueryRemindersResponse] containing the list of reminders. + Future queryReminders({ + Filter? filter, + SortOrder? sort, + PaginationParams? pagination, + }) async { + final response = await _client.post( + '/reminders/query', + data: jsonEncode({ + if (filter != null) 'filter': filter, + if (sort != null) 'sort': sort, + if (pagination != null) ...pagination.toJson(), + }), + ); + + return QueryRemindersResponse.fromJson(response.data); + } + + /// Creates a new reminder for the specified [messageId]. + /// + /// You can specify the time to remind using the [remindAt] parameter. + /// + /// Returns a [CreateReminderResponse] containing the created reminder. + Future createReminder( + String messageId, { + DateTime? remindAt, + }) async { + final response = await _client.post( + '/messages/$messageId/reminders', + data: jsonEncode({ + if (remindAt != null) 'remind_at': remindAt.toUtc().toIso8601String(), + }), + ); + + return CreateReminderResponse.fromJson(response.data); + } + + /// Updates an existing reminder for the specified [messageId]. + /// + /// You can change the reminder time using the [remindAt] parameter. + /// + /// Returns an [UpdateReminderResponse] containing the updated reminder. + Future updateReminder( + String messageId, { + DateTime? remindAt, + }) async { + final response = await _client.patch( + '/messages/$messageId/reminders', + data: jsonEncode({ + if (remindAt != null) 'remind_at': remindAt.toUtc().toIso8601String(), + }), + ); + + return UpdateReminderResponse.fromJson(response.data); + } + + /// Deletes a reminder for the specified [messageId]. + /// + /// Returns an [EmptyResponse] indicating the deletion was successful. + Future deleteReminder( + String messageId, + ) async { + final response = await _client.delete( + '/messages/$messageId/reminders', + ); + + return EmptyResponse.fromJson(response.data); + } +} diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index 96627a45b..0602e71f3 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -11,6 +11,7 @@ import 'package:stream_chat/src/core/models/draft.dart'; import 'package:stream_chat/src/core/models/event.dart'; import 'package:stream_chat/src/core/models/member.dart'; import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/message_reminder.dart'; import 'package:stream_chat/src/core/models/poll.dart'; import 'package:stream_chat/src/core/models/poll_option.dart'; import 'package:stream_chat/src/core/models/poll_vote.dart'; @@ -764,3 +765,40 @@ class QueryDraftsResponse extends _BaseResponse { static QueryDraftsResponse fromJson(Map json) => _$QueryDraftsResponseFromJson(json); } + +/// Base Model response for draft based api calls. +class MessageReminderResponse extends _BaseResponse { + /// Draft returned by the api call + late MessageReminder reminder; +} + +/// Model response for [StreamChatClient.createReminder] api call +@JsonSerializable(createToJson: false) +class CreateReminderResponse extends MessageReminderResponse { + /// Create a new instance from a json + static CreateReminderResponse fromJson(Map json) => + _$CreateReminderResponseFromJson(json); +} + +/// Model response for [StreamChatClient.updateReminder] api call +@JsonSerializable(createToJson: false) +class UpdateReminderResponse extends MessageReminderResponse { + /// Create a new instance from a json + static UpdateReminderResponse fromJson(Map json) => + _$UpdateReminderResponseFromJson(json); +} + +/// Model response for [StreamChatClient.queryReminders] api call +@JsonSerializable(createToJson: false) +class QueryRemindersResponse extends _BaseResponse { + /// List of reminders returned by the query + @JsonKey(defaultValue: []) + late List reminders; + + /// The next page token + late String? next; + + /// Create a new instance from a json + static QueryRemindersResponse fromJson(Map json) => + _$QueryRemindersResponseFromJson(json); +} diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index ae4206026..75dcc1595 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -443,3 +443,27 @@ QueryDraftsResponse _$QueryDraftsResponseFromJson(Map json) => .toList() ?? [] ..next = json['next'] as String?; + +CreateReminderResponse _$CreateReminderResponseFromJson( + Map json) => + CreateReminderResponse() + ..duration = json['duration'] as String? + ..reminder = + MessageReminder.fromJson(json['reminder'] as Map); + +UpdateReminderResponse _$UpdateReminderResponseFromJson( + Map json) => + UpdateReminderResponse() + ..duration = json['duration'] as String? + ..reminder = + MessageReminder.fromJson(json['reminder'] as Map); + +QueryRemindersResponse _$QueryRemindersResponseFromJson( + Map json) => + QueryRemindersResponse() + ..duration = json['duration'] as String? + ..reminders = (json['reminders'] as List?) + ?.map((e) => MessageReminder.fromJson(e as Map)) + .toList() ?? + [] + ..next = json['next'] as String?; diff --git a/packages/stream_chat/lib/src/core/api/sort_order.dart b/packages/stream_chat/lib/src/core/api/sort_order.dart index c30aa2274..88025d74a 100644 --- a/packages/stream_chat/lib/src/core/api/sort_order.dart +++ b/packages/stream_chat/lib/src/core/api/sort_order.dart @@ -13,6 +13,17 @@ part 'sort_order.g.dart'; /// Example: `Sort([pinnedAtSort, lastMessageAtSort])` typedef SortOrder = List>; +/// Defines how null values should be ordered in a sort operation. +enum NullOrdering { + /// Null values appear at the beginning of the sorted list, + /// regardless of sort direction (ASC or DESC). + nullsFirst, + + /// Null values appear at the end of the sorted list, + /// regardless of sort direction (ASC or DESC). + nullsLast; +} + /// A sort specification for objects that implement [ComparableFieldProvider]. /// /// Defines a field to sort by and a direction (ascending or descending). @@ -33,6 +44,7 @@ class SortOption { const SortOption( this.field, { this.direction = SortOption.DESC, + this.nullOrdering = NullOrdering.nullsFirst, Comparator? comparator, }) : _comparator = comparator; @@ -45,6 +57,7 @@ class SortOption { /// ``` const SortOption.desc( this.field, { + this.nullOrdering = NullOrdering.nullsFirst, Comparator? comparator, }) : direction = SortOption.DESC, _comparator = comparator; @@ -58,6 +71,7 @@ class SortOption { /// ``` const SortOption.asc( this.field, { + this.nullOrdering = NullOrdering.nullsLast, Comparator? comparator, }) : direction = SortOption.ASC, _comparator = comparator; @@ -78,6 +92,13 @@ class SortOption { /// The sort direction (ASC or DESC) final int direction; + /// The null ordering strategy to use when comparing null values. + /// + /// Defaults to `NullOrdering.nullsFirst`, which treats null values as less + /// than any non-null value. + @JsonKey(includeToJson: false, includeFromJson: false) + final NullOrdering nullOrdering; + /// Compares two objects of type T using the specified field and direction. /// /// Returns: @@ -85,34 +106,43 @@ class SortOption { /// - 1 if the first object is greater than the second /// - -1 if the first object is less than the second /// - /// Handles null values by treating null as less than any non-null value. + /// Handles null values according to the nullOrdering setting. + /// NULLS FIRST puts nulls at the beginning regardless of sort direction. + /// NULLS LAST puts nulls at the end regardless of sort direction. /// /// ```dart /// final sortOption = SortOption("last_message_at"); /// final sortedChannels = channels.sort(sortOption.compare); /// ``` - int compare(T a, T b) => direction * comparator(a, b); + int compare(T a, T b) => comparator(a, b); /// Returns a comparator function for sorting objects of type T. @JsonKey(includeToJson: false, includeFromJson: false) Comparator get comparator { - if (_comparator case final comparator?) return comparator; + if (_comparator case final comparator?) { + return (a, b) => direction * comparator(a, b); + } - return (T a, T b) { + return (a, b) { final aValue = a.getComparableField(field); final bValue = b.getComparableField(field); - // Handle null values - if (aValue == null && bValue == null) return 0; - if (aValue == null) return -1; - if (bValue == null) return 1; - - return aValue.compareTo(bValue); + return _compareNullableFields(aValue, bValue); }; } final Comparator? _comparator; + int _compareNullableFields(ComparableField? a, ComparableField? b) { + // Handle nulls first, independent of sort direction + if (a == null && b == null) return 0; + if (a == null) return nullOrdering == NullOrdering.nullsFirst ? -1 : 1; + if (b == null) return nullOrdering == NullOrdering.nullsFirst ? 1 : -1; + + // Apply direction only to non-null comparisons + return direction * a.compareTo(b); + } + /// Converts this option to JSON. Map toJson() => _$SortOptionToJson(this); } diff --git a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart index e50ab1a79..1f94ffe77 100644 --- a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart +++ b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart @@ -9,6 +9,7 @@ import 'package:stream_chat/src/core/api/guest_api.dart'; import 'package:stream_chat/src/core/api/message_api.dart'; import 'package:stream_chat/src/core/api/moderation_api.dart'; import 'package:stream_chat/src/core/api/polls_api.dart'; +import 'package:stream_chat/src/core/api/reminders_api.dart'; import 'package:stream_chat/src/core/api/threads_api.dart'; import 'package:stream_chat/src/core/api/user_api.dart'; import 'package:stream_chat/src/core/http/connection_id_manager.dart'; @@ -87,6 +88,10 @@ class StreamChatApi { ModerationApi get moderation => _moderation ??= ModerationApi(_client); ModerationApi? _moderation; + /// Api dedicated to message reminders operations + RemindersApi get reminders => _reminders ??= RemindersApi(_client); + RemindersApi? _reminders; + /// Api dedicated to general operations GeneralApi get general => _general ??= GeneralApi(_client); GeneralApi? _general; diff --git a/packages/stream_chat/lib/src/core/models/channel_config.dart b/packages/stream_chat/lib/src/core/models/channel_config.dart index b15c10992..2bcf0a1ed 100644 --- a/packages/stream_chat/lib/src/core/models/channel_config.dart +++ b/packages/stream_chat/lib/src/core/models/channel_config.dart @@ -25,6 +25,7 @@ class ChannelConfig { this.uploads = false, this.urlEnrichment = false, this.skipLastMsgUpdateForSystemMsgs = false, + this.userMessageReminders = false, }) : createdAt = createdAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(); @@ -87,6 +88,9 @@ class ChannelConfig { /// message was added to the channel. final bool skipLastMsgUpdateForSystemMsgs; + /// True if the user can set reminders for messages in this channel. + final bool userMessageReminders; + /// Serialize to json Map toJson() => _$ChannelConfigToJson(this); } diff --git a/packages/stream_chat/lib/src/core/models/channel_config.g.dart b/packages/stream_chat/lib/src/core/models/channel_config.g.dart index 11f188a61..8cb758b87 100644 --- a/packages/stream_chat/lib/src/core/models/channel_config.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_config.g.dart @@ -33,6 +33,7 @@ ChannelConfig _$ChannelConfigFromJson(Map json) => urlEnrichment: json['url_enrichment'] as bool? ?? false, skipLastMsgUpdateForSystemMsgs: json['skip_last_msg_update_for_system_msgs'] as bool? ?? false, + userMessageReminders: json['user_message_reminders'] as bool? ?? false, ); Map _$ChannelConfigToJson(ChannelConfig instance) => @@ -55,4 +56,5 @@ Map _$ChannelConfigToJson(ChannelConfig instance) => 'url_enrichment': instance.urlEnrichment, 'skip_last_msg_update_for_system_msgs': instance.skipLastMsgUpdateForSystemMsgs, + 'user_message_reminders': instance.userMessageReminders, }; diff --git a/packages/stream_chat/lib/src/core/models/event.dart b/packages/stream_chat/lib/src/core/models/event.dart index 1e7b91351..d331e4ff4 100644 --- a/packages/stream_chat/lib/src/core/models/event.dart +++ b/packages/stream_chat/lib/src/core/models/event.dart @@ -10,6 +10,7 @@ class Event { /// Constructor used for json serialization Event({ this.type = 'local.event', + this.userId, this.cid, this.connectionId, DateTime? createdAt, @@ -39,6 +40,7 @@ class Event { this.unreadMessages, this.lastReadMessageId, this.draft, + this.reminder, this.extraData = const {}, this.isLocal = true, }) : createdAt = createdAt?.toUtc() ?? DateTime.now().toUtc(); @@ -54,6 +56,9 @@ class Event { /// [EventType] contains some predefined constant types final String type; + /// The user id of the user to which the event belongs + final String? userId; + /// The channel cid to which the event belongs final String? cid; @@ -146,6 +151,9 @@ class Event { /// The draft sent with the event. final Draft? draft; + /// The message reminder sent with the event. + final MessageReminder? reminder; + /// Map of custom channel extraData final Map extraData; @@ -153,6 +161,7 @@ class Event { /// Useful for [Serializer] methods. static final topLevelFields = [ 'type', + 'user_id', 'cid', 'connection_id', 'created_at', @@ -183,6 +192,7 @@ class Event { 'unread_messages', 'last_read_message_id', 'draft', + 'reminder', ]; /// Serialize to json @@ -193,6 +203,7 @@ class Event { /// Creates a copy of [Event] with specified attributes overridden. Event copyWith({ String? type, + String? userId, String? cid, String? channelId, String? channelType, @@ -222,10 +233,12 @@ class Event { int? unreadMessages, String? lastReadMessageId, Draft? draft, + MessageReminder? reminder, Map? extraData, }) => Event( type: type ?? this.type, + userId: userId ?? this.userId, cid: cid ?? this.cid, connectionId: connectionId ?? this.connectionId, createdAt: createdAt ?? this.createdAt, @@ -255,6 +268,7 @@ class Event { unreadMessages: unreadMessages ?? this.unreadMessages, lastReadMessageId: lastReadMessageId ?? this.lastReadMessageId, draft: draft ?? this.draft, + reminder: reminder ?? this.reminder, isLocal: isLocal, extraData: extraData ?? this.extraData, ); diff --git a/packages/stream_chat/lib/src/core/models/event.g.dart b/packages/stream_chat/lib/src/core/models/event.g.dart index fc572b8b5..c81ddeeda 100644 --- a/packages/stream_chat/lib/src/core/models/event.g.dart +++ b/packages/stream_chat/lib/src/core/models/event.g.dart @@ -8,6 +8,7 @@ part of 'event.dart'; Event _$EventFromJson(Map json) => Event( type: json['type'] as String? ?? 'local.event', + userId: json['user_id'] as String?, cid: json['cid'] as String?, connectionId: json['connection_id'] as String?, createdAt: json['created_at'] == null @@ -64,12 +65,16 @@ Event _$EventFromJson(Map json) => Event( draft: json['draft'] == null ? null : Draft.fromJson(json['draft'] as Map), + reminder: json['reminder'] == null + ? null + : MessageReminder.fromJson(json['reminder'] as Map), extraData: json['extra_data'] as Map? ?? const {}, isLocal: json['is_local'] as bool? ?? false, ); Map _$EventToJson(Event instance) => { 'type': instance.type, + if (instance.userId case final value?) 'user_id': value, if (instance.cid case final value?) 'cid': value, if (instance.channelId case final value?) 'channel_id': value, if (instance.channelType case final value?) 'channel_type': value, @@ -106,6 +111,7 @@ Map _$EventToJson(Event instance) => { if (instance.lastReadMessageId case final value?) 'last_read_message_id': value, if (instance.draft?.toJson() case final value?) 'draft': value, + if (instance.reminder?.toJson() case final value?) 'reminder': value, 'extra_data': instance.extraData, }; diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index ce89e4edf..93847c11a 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -4,6 +4,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:stream_chat/src/core/models/attachment.dart'; import 'package:stream_chat/src/core/models/comparable_field.dart'; import 'package:stream_chat/src/core/models/draft.dart'; +import 'package:stream_chat/src/core/models/message_reminder.dart'; import 'package:stream_chat/src/core/models/message_state.dart'; import 'package:stream_chat/src/core/models/moderation.dart'; import 'package:stream_chat/src/core/models/poll.dart'; @@ -67,6 +68,7 @@ class Message extends Equatable implements ComparableFieldProvider { this.restrictedVisibility, this.moderation, this.draft, + this.reminder, }) : id = id ?? const Uuid().v4(), type = MessageType(type), pinExpires = pinExpires?.toUtc(), @@ -307,6 +309,11 @@ class Message extends Equatable implements ComparableFieldProvider { /// This is present when the message is a thread i.e. contains replies. final Draft? draft; + /// Optional reminder for this message. + /// + /// This is present when a user has set a reminder for this message. + final MessageReminder? reminder; + /// Message custom extraData. final Map extraData; @@ -354,6 +361,7 @@ class Message extends Equatable implements ComparableFieldProvider { 'moderation', 'moderation_details', 'draft', + 'reminder', ]; /// Serialize to json. @@ -415,6 +423,7 @@ class Message extends Equatable implements ComparableFieldProvider { List? restrictedVisibility, Moderation? moderation, Object? draft = _nullConst, + Object? reminder = _nullConst, }) { assert(() { if (pinExpires is! DateTime && @@ -495,6 +504,8 @@ class Message extends Equatable implements ComparableFieldProvider { restrictedVisibility: restrictedVisibility ?? this.restrictedVisibility, moderation: moderation ?? this.moderation, draft: draft == _nullConst ? this.draft : draft as Draft?, + reminder: + reminder == _nullConst ? this.reminder : reminder as MessageReminder?, ); } @@ -539,6 +550,7 @@ class Message extends Equatable implements ComparableFieldProvider { restrictedVisibility: other.restrictedVisibility, moderation: other.moderation, draft: other.draft, + reminder: other.reminder, ); } @@ -603,6 +615,7 @@ class Message extends Equatable implements ComparableFieldProvider { restrictedVisibility, moderation, draft, + reminder, ]; @override diff --git a/packages/stream_chat/lib/src/core/models/message.g.dart b/packages/stream_chat/lib/src/core/models/message.g.dart index 84340182b..cc90809bd 100644 --- a/packages/stream_chat/lib/src/core/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -92,6 +92,9 @@ Message _$MessageFromJson(Map json) => Message( draft: json['draft'] == null ? null : Draft.fromJson(json['draft'] as Map), + reminder: json['reminder'] == null + ? null + : MessageReminder.fromJson(json['reminder'] as Map), ); Map _$MessageToJson(Message instance) => { @@ -110,5 +113,6 @@ Map _$MessageToJson(Message instance) => { if (instance.restrictedVisibility case final value?) 'restricted_visibility': value, 'draft': instance.draft?.toJson(), + 'reminder': instance.reminder?.toJson(), 'extra_data': instance.extraData, }; diff --git a/packages/stream_chat/lib/src/core/models/message_reminder.dart b/packages/stream_chat/lib/src/core/models/message_reminder.dart new file mode 100644 index 000000000..d816e0614 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/message_reminder.dart @@ -0,0 +1,164 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/comparable_field.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/user.dart'; + +part 'message_reminder.g.dart'; + +class _NullConst { + const _NullConst(); +} + +const _nullConst = _NullConst(); + +/// {@template messageReminder} +/// A model class representing a message reminder. +/// +/// The [MessageReminder] class represents a marked message that is important +/// to the user. +/// +/// It can be of two types: +/// 1. **Scheduled Reminder**: (`remindAt != null`) - Used to notify the user +/// about a message after a certain time. +/// 2. **Bookmarks**: (`remindAt == null`) - Used to mark a message for later +/// reference without notification. +/// {@endtemplate} +@JsonSerializable() +class MessageReminder extends Equatable implements ComparableFieldProvider { + /// {@macro messageReminder} + MessageReminder({ + required this.channelCid, + this.channel, + required this.messageId, + this.message, + required this.userId, + this.user, + this.remindAt, + DateTime? createdAt, + DateTime? updatedAt, + }) : createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); + + /// Create a new instance from a json + factory MessageReminder.fromJson(Map json) => + _$MessageReminderFromJson(json); + + /// The channel CID where the message exists. + final String channelCid; + + /// The channel where the message exists. + @JsonKey(includeToJson: false) + final ChannelModel? channel; + + /// The ID of the message that is marked as important. + final String messageId; + + /// The message that is marked as important. + @JsonKey(includeToJson: false) + final Message? message; + + /// The ID of the user who marked the message as important. + final String userId; + + /// The user who marked the message as important. + @JsonKey(includeToJson: false) + final User? user; + + /// The time at which the user wants to be reminded about the message. + /// + /// If `null`, the reminder is a bookmark and no notification will be sent. + final DateTime? remindAt; + + /// The date at which the reminder was created. + final DateTime createdAt; + + /// The date at which the reminder was last updated. + final DateTime updatedAt; + + /// Convert the object to JSON + Map toJson() => _$MessageReminderToJson(this); + + /// Creates a copy of this [Draft] with specified attributes overridden. + MessageReminder copyWith({ + String? channelCid, + ChannelModel? channel, + String? messageId, + Message? message, + String? userId, + User? user, + Object? remindAt = _nullConst, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return MessageReminder( + channelCid: channelCid ?? this.channelCid, + channel: channel ?? this.channel, + messageId: messageId ?? this.messageId, + message: message ?? this.message, + userId: userId ?? this.userId, + user: user ?? this.user, + remindAt: remindAt == _nullConst ? this.remindAt : remindAt as DateTime?, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + /// Returns a new [MessageReminder] instance that merges the current + /// instance with another [MessageReminder] instance. + MessageReminder merge(MessageReminder? other) { + if (other == null) return this; + return copyWith( + channelCid: other.channelCid, + channel: other.channel, + messageId: other.messageId, + message: other.message, + userId: other.userId, + user: other.user, + remindAt: other.remindAt, + createdAt: other.createdAt, + updatedAt: other.updatedAt, + ); + } + + @override + List get props => [ + channelCid, + channel, + messageId, + message, + userId, + user, + remindAt, + createdAt, + updatedAt, + ]; + + @override + ComparableField? getComparableField(String sortKey) { + final value = switch (sortKey) { + MessageReminderSortKey.channelCid => channelCid, + MessageReminderSortKey.remindAt => remindAt, + MessageReminderSortKey.createdAt => createdAt, + _ => null, + }; + + return ComparableField.fromValue(value); + } +} + +/// Extension type representing sortable fields for [MessageReminder]. +/// +/// This type provides type-safe keys that can be used for sorting reminders +/// in queries. Each constant represents a field that can be sorted on. +extension type const MessageReminderSortKey(String key) implements String { + /// Sorts reminders by the channel CID. + static const channelCid = MessageReminderSortKey('channel_cid'); + + /// Sorts reminders by the time at which the user wants to be reminded. + static const remindAt = MessageReminderSortKey('remind_at'); + + /// Sorts reminders by the date at which the reminder was created. + static const createdAt = MessageReminderSortKey('created_at'); +} diff --git a/packages/stream_chat/lib/src/core/models/message_reminder.g.dart b/packages/stream_chat/lib/src/core/models/message_reminder.g.dart new file mode 100644 index 000000000..1562ace75 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/message_reminder.g.dart @@ -0,0 +1,42 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message_reminder.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +MessageReminder _$MessageReminderFromJson(Map json) => + MessageReminder( + channelCid: json['channel_cid'] as String, + channel: json['channel'] == null + ? null + : ChannelModel.fromJson(json['channel'] as Map), + messageId: json['message_id'] as String, + message: json['message'] == null + ? null + : Message.fromJson(json['message'] as Map), + userId: json['user_id'] as String, + user: json['user'] == null + ? null + : User.fromJson(json['user'] as Map), + remindAt: json['remind_at'] == null + ? null + : DateTime.parse(json['remind_at'] as String), + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null + ? null + : DateTime.parse(json['updated_at'] as String), + ); + +Map _$MessageReminderToJson(MessageReminder instance) => + { + 'channel_cid': instance.channelCid, + 'message_id': instance.messageId, + 'user_id': instance.userId, + 'remind_at': instance.remindAt?.toIso8601String(), + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + }; diff --git a/packages/stream_chat/lib/src/event_type.dart b/packages/stream_chat/lib/src/event_type.dart index 8020c9861..29ab5b1de 100644 --- a/packages/stream_chat/lib/src/event_type.dart +++ b/packages/stream_chat/lib/src/event_type.dart @@ -158,4 +158,16 @@ class EventType { /// Event sent when a draft message is deleted. static const String draftDeleted = 'draft.deleted'; + + /// Event sent when a message reminder is created. + static const String reminderCreated = 'reminder.created'; + + /// Event sent when a message reminder is updated. + static const String reminderUpdated = 'reminder.updated'; + + /// Event sent when a message reminder is deleted. + static const String reminderDeleted = 'reminder.deleted'; + + /// Event sent when a message reminder is due. + static const String notificationReminderDue = 'notification.reminder_due'; } diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 426842bea..ea60b484b 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -44,6 +44,7 @@ export 'src/core/models/event.dart'; export 'src/core/models/filter.dart' show Filter; export 'src/core/models/member.dart'; export 'src/core/models/message.dart'; +export 'src/core/models/message_reminder.dart'; export 'src/core/models/message_state.dart'; export 'src/core/models/moderation.dart'; export 'src/core/models/mute.dart'; diff --git a/packages/stream_chat/test/fixtures/channel_state_to_json.json b/packages/stream_chat/test/fixtures/channel_state_to_json.json index 4c07ffd45..9eec788ae 100644 --- a/packages/stream_chat/test/fixtures/channel_state_to_json.json +++ b/packages/stream_chat/test/fixtures/channel_state_to_json.json @@ -30,7 +30,8 @@ "restricted_visibility": [ "user-id-3" ], - "draft": null + "draft": null, + "reminder": null }, { "id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f", @@ -46,7 +47,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", @@ -62,7 +64,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35", @@ -78,7 +81,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", @@ -94,7 +98,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc", @@ -110,7 +115,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53", @@ -126,7 +132,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240", @@ -142,7 +149,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42", @@ -158,7 +166,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa", @@ -174,7 +183,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0", @@ -190,7 +200,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356", @@ -206,7 +217,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", @@ -222,7 +234,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4", @@ -238,7 +251,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", @@ -254,7 +268,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d", @@ -270,7 +285,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", @@ -286,7 +302,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3", @@ -302,7 +319,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf", @@ -318,7 +336,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73", @@ -334,7 +353,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854", @@ -350,7 +370,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480", @@ -366,7 +387,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be", @@ -382,7 +404,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40", @@ -398,7 +421,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null }, { "id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c", @@ -414,7 +438,8 @@ "pinned": false, "pin_expires": null, "poll_id": null, - "draft": null + "draft": null, + "reminder": null } ], "pinned_messages": [], diff --git a/packages/stream_chat/test/fixtures/message_to_json.json b/packages/stream_chat/test/fixtures/message_to_json.json index 52ee6e02f..8803f1ed1 100644 --- a/packages/stream_chat/test/fixtures/message_to_json.json +++ b/packages/stream_chat/test/fixtures/message_to_json.json @@ -27,5 +27,6 @@ "user-id-3" ], "draft": null, + "reminder": null, "hey": "test" } \ No newline at end of file diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 7e9e330bc..751f1c4d3 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -573,6 +573,112 @@ void main() { }); }); + group('`.createReminder`', () { + const messageId = 'test-message-id'; + + setUp(() { + when(() => client.createReminder( + messageId, + remindAt: any(named: 'remindAt'), + )).thenAnswer( + (_) async => CreateReminderResponse() + ..reminder = MessageReminder( + messageId: messageId, + channelCid: channelCid, + userId: 'test-user-id', + remindAt: DateTime(2024, 6, 15, 14, 30), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + }); + + test('should call client.createReminder', () async { + final res = await channel.createReminder(messageId); + + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + + verify(() => channel.client.createReminder(messageId)).called(1); + }); + + test('with remindAt should pass remindAt to client', () async { + final remindAt = DateTime(2024, 6, 15, 14, 30); + final res = await channel.createReminder(messageId, remindAt: remindAt); + + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + expect(res.reminder.remindAt, remindAt); + + verify(() => channel.client.createReminder( + messageId, + remindAt: remindAt, + )).called(1); + }); + }); + + group('`.updateReminder`', () { + const messageId = 'test-message-id'; + + setUp(() { + when(() => client.updateReminder( + messageId, + remindAt: any(named: 'remindAt'), + )).thenAnswer( + (_) async => UpdateReminderResponse() + ..reminder = MessageReminder( + messageId: messageId, + channelCid: channelCid, + userId: 'test-user-id', + remindAt: DateTime(2024, 8, 20, 16, 45), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + }); + + test('should call client.updateReminder', () async { + final res = await channel.updateReminder(messageId); + + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + + verify(() => channel.client.updateReminder(messageId)).called(1); + }); + + test('with remindAt should pass remindAt to client', () async { + final remindAt = DateTime(2024, 8, 20, 16, 45); + final res = await channel.updateReminder(messageId, remindAt: remindAt); + + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + expect(res.reminder.remindAt, remindAt); + + verify(() => channel.client.updateReminder( + messageId, + remindAt: remindAt, + )).called(1); + }); + }); + + group('`.deleteReminder`', () { + const messageId = 'test-message-id'; + + setUp(() { + when(() => client.deleteReminder(messageId)).thenAnswer( + (_) async => EmptyResponse(), + ); + }); + + test('should call client.deleteReminder', () async { + final res = await channel.deleteReminder(messageId); + + expect(res, isNotNull); + + verify(() => channel.client.deleteReminder(messageId)).called(1); + }); + }); + group('`.updateMessage`', () { test('should work fine', () async { final message = Message( @@ -4265,6 +4371,335 @@ void main() { }, ); }); + + group('Reminder events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + test('should handle reminder.created event', () async { + const messageId = 'test-message-id'; + + // Setup initial state with a message without reminder + final message = Message( + id: messageId, + user: client.state.currentUser, + text: 'Test message', + ); + + channel.state?.updateMessage(message); + + // Verify initial state - no reminder + final initialMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNull); + + // Create reminder + final reminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: DateTime.now().add(const Duration(days: 30)), + ); + + // Create reminder.created event + final reminderCreatedEvent = Event( + cid: channel.cid, + type: EventType.reminderCreated, + reminder: reminder, + ); + + // Dispatch event + client.addEvent(reminderCreatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify message reminder was added + final updatedMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); + }); + + test('should handle reminder.updated event', () async { + const messageId = 'test-message-id'; + + // Setup initial state with a message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + final message = Message( + id: messageId, + user: client.state.currentUser, + text: 'Test message', + reminder: initialReminder, + ); + + channel.state?.updateMessage(message); + + // Verify initial state + final initialMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + expect(initialMessage?.reminder?.remindAt, remindAt); + + // Create updated reminder + final updatedRemindAt = remindAt.add(const Duration(days: 15)); + final updatedReminder = initialReminder.copyWith( + remindAt: updatedRemindAt, + updatedAt: DateTime.now(), + ); + + // Create reminder.updated event + final reminderUpdatedEvent = Event( + cid: channel.cid, + type: EventType.reminderUpdated, + reminder: updatedReminder, + ); + + // Dispatch event + client.addEvent(reminderUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify message reminder was updated + final updatedMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); + }); + + test('should handle reminder.deleted event', () async { + const messageId = 'test-message-id'; + + // Setup initial state with a message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + final message = Message( + id: messageId, + user: client.state.currentUser, + text: 'Test message', + reminder: initialReminder, + ); + + channel.state?.updateMessage(message); + + // Verify initial state + final initialMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + + // Create reminder.deleted event + final reminderDeletedEvent = Event( + cid: channel.cid, + type: EventType.reminderDeleted, + reminder: initialReminder, + ); + + // Dispatch event + client.addEvent(reminderDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify message reminder was removed + final updatedMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNull); + }); + + test('should handle reminder.created event for thread messages', + () async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + + // Setup initial state with a thread message without reminder + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: client.state.currentUser, + text: 'Thread message', + ); + + channel.state?.updateMessage(threadMessage); + + // Verify initial state - no reminder + final initialMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNull); + + // Create reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final reminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + // Create reminder.created event + final reminderCreatedEvent = Event( + cid: channel.cid, + type: EventType.reminderCreated, + reminder: reminder, + ); + + // Dispatch event + client.addEvent(reminderCreatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread message reminder was added + final updatedMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); + }); + + test('should handle reminder.updated event for thread messages', + () async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + + // Setup initial state with a thread message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: client.state.currentUser, + text: 'Thread message', + reminder: initialReminder, + ); + + channel.state?.updateMessage(threadMessage); + + // Verify initial state + final initialMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + expect(initialMessage?.reminder?.remindAt, remindAt); + + // Create updated reminder + final updatedRemindAt = remindAt.add(const Duration(days: 15)); + final updatedReminder = initialReminder.copyWith( + remindAt: updatedRemindAt, + updatedAt: DateTime.now(), + ); + + // Create reminder.updated event + final reminderUpdatedEvent = Event( + cid: channel.cid, + type: EventType.reminderUpdated, + reminder: updatedReminder, + ); + + // Dispatch event + client.addEvent(reminderUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread message reminder was updated + final updatedMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); + }); + + test('should handle reminder.deleted event for thread messages', + () async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + + // Setup initial state with a thread message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); + + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: client.state.currentUser, + text: 'Thread message', + reminder: initialReminder, + ); + + channel.state?.updateMessage(threadMessage); + + // Verify initial state + final initialMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + + // Create reminder.deleted event + final reminderDeletedEvent = Event( + cid: channel.cid, + type: EventType.reminderDeleted, + reminder: initialReminder, + ); + + // Dispatch event + client.addEvent(reminderDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify thread message reminder was removed + final updatedMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNull); + }); + }); }); group('ChannelCapabilityCheck', () { diff --git a/packages/stream_chat/test/src/core/api/reminders_api_test.dart b/packages/stream_chat/test/src/core/api/reminders_api_test.dart new file mode 100644 index 000000000..0fb601d81 --- /dev/null +++ b/packages/stream_chat/test/src/core/api/reminders_api_test.dart @@ -0,0 +1,253 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/src/core/api/reminders_api.dart'; +import 'package:stream_chat/src/core/api/requests.dart'; +import 'package:stream_chat/src/core/api/sort_order.dart'; +import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/message_reminder.dart'; +import 'package:test/test.dart'; + +import '../../mocks.dart'; + +void main() { + Response successResponse(String path, {Object? data}) => Response( + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); + + late final client = MockHttpClient(); + late RemindersApi remindersApi; + + setUp(() { + remindersApi = RemindersApi(client); + }); + + group('queryReminders', () { + test('should query reminders without parameters', () async { + const path = '/reminders/query'; + + final reminders = [ + MessageReminder( + messageId: 'test-message-id-1', + channelCid: 'test-channel-cid-1', + userId: 'test-user-id', + remindAt: DateTime(2024, 1, 1), + createdAt: DateTime(2024, 1, 1), + updatedAt: DateTime(2024, 1, 1), + ), + MessageReminder( + messageId: 'test-message-id-2', + channelCid: 'test-channel-cid-2', + userId: 'test-user-id', + remindAt: DateTime(2024, 1, 2), + createdAt: DateTime(2024, 1, 2), + updatedAt: DateTime(2024, 1, 2), + ), + ]; + + when(() => client.post(path, data: jsonEncode({}))) + .thenAnswer((_) async => successResponse(path, data: { + 'reminders': reminders.map((r) => r.toJson()).toList(), + })); + + final res = await remindersApi.queryReminders(); + + expect(res, isNotNull); + expect(res.reminders, hasLength(2)); + expect(res.reminders.first.messageId, 'test-message-id-1'); + + verify(() => client.post(path, data: jsonEncode({}))).called(1); + verifyNoMoreInteractions(client); + }); + + test('should query reminders with filter, sort, and pagination', () async { + const path = '/reminders/query'; + final filter = Filter.equal('userId', 'test-user-id'); + const sort = [SortOption('remindAt')]; + const pagination = PaginationParams(limit: 10, offset: 5); + + final expectedPayload = jsonEncode({ + 'filter': filter, + 'sort': sort, + ...pagination.toJson(), + }); + + final reminders = List.generate( + 5, + (index) => MessageReminder( + messageId: 'test-message-id-$index', + channelCid: 'test-channel-cid-$index', + userId: 'test-user-id', + remindAt: DateTime(2024, 1, index + 1), + createdAt: DateTime(2024, 1, index + 1), + updatedAt: DateTime(2024, 1, index + 1), + ), + ); + + when(() => client.post(path, data: expectedPayload)) + .thenAnswer((_) async => successResponse(path, data: { + 'reminders': reminders.map((r) => r.toJson()).toList(), + })); + + final res = await remindersApi.queryReminders( + filter: filter, + sort: sort, + pagination: pagination, + ); + + expect(res, isNotNull); + expect(res.reminders, hasLength(5)); + + verify(() => client.post(path, data: expectedPayload)).called(1); + verifyNoMoreInteractions(client); + }); + }); + + group('createReminder', () { + test('should create reminder without remindAt', () async { + const messageId = 'test-message-id'; + const path = '/messages/$messageId/reminders'; + + final reminder = MessageReminder( + messageId: messageId, + channelCid: 'test-channel-cid', + userId: 'test-user-id', + remindAt: DateTime(2024, 1, 1), + createdAt: DateTime(2024, 1, 1), + updatedAt: DateTime(2024, 1, 1), + ); + + when(() => client.post(path, data: jsonEncode({}))) + .thenAnswer((_) async => successResponse(path, data: { + 'reminder': reminder.toJson(), + })); + + final res = await remindersApi.createReminder(messageId); + + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + + verify(() => client.post(path, data: jsonEncode({}))).called(1); + verifyNoMoreInteractions(client); + }); + + test('should create reminder with remindAt', () async { + const messageId = 'test-message-id'; + const path = '/messages/$messageId/reminders'; + final remindAt = DateTime(2024, 6, 15, 14, 30); + + final reminder = MessageReminder( + messageId: messageId, + channelCid: 'test-channel-cid', + userId: 'test-user-id', + remindAt: remindAt, + createdAt: DateTime(2024, 1, 1), + updatedAt: DateTime(2024, 1, 1), + ); + + final expectedPayload = jsonEncode({ + 'remind_at': remindAt.toUtc().toIso8601String(), + }); + + when(() => client.post(path, data: expectedPayload)) + .thenAnswer((_) async => successResponse(path, data: { + 'reminder': reminder.toJson(), + })); + + final res = + await remindersApi.createReminder(messageId, remindAt: remindAt); + + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + expect(res.reminder.remindAt, remindAt); + + verify(() => client.post(path, data: expectedPayload)).called(1); + verifyNoMoreInteractions(client); + }); + }); + + group('updateReminder', () { + test('should update reminder without remindAt', () async { + const messageId = 'test-message-id'; + const path = '/messages/$messageId/reminders'; + + final reminder = MessageReminder( + messageId: messageId, + channelCid: 'test-channel-cid', + userId: 'test-user-id', + remindAt: DateTime(2024, 1, 1), + createdAt: DateTime(2024, 1, 1), + updatedAt: DateTime(2024, 1, 2), + ); + + when(() => client.patch(path, data: jsonEncode({}))) + .thenAnswer((_) async => successResponse(path, data: { + 'reminder': reminder.toJson(), + })); + + final res = await remindersApi.updateReminder(messageId); + + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + + verify(() => client.patch(path, data: jsonEncode({}))).called(1); + verifyNoMoreInteractions(client); + }); + + test('should update reminder with remindAt', () async { + const messageId = 'test-message-id'; + const path = '/messages/$messageId/reminders'; + final remindAt = DateTime(2024, 8, 20, 16, 45); + + final reminder = MessageReminder( + messageId: messageId, + channelCid: 'test-channel-cid', + userId: 'test-user-id', + remindAt: remindAt, + createdAt: DateTime(2024, 1, 1), + updatedAt: DateTime(2024, 1, 2), + ); + + final expectedPayload = jsonEncode({ + 'remind_at': remindAt.toUtc().toIso8601String(), + }); + + when(() => client.patch(path, data: expectedPayload)) + .thenAnswer((_) async => successResponse(path, data: { + 'reminder': reminder.toJson(), + })); + + final res = + await remindersApi.updateReminder(messageId, remindAt: remindAt); + + expect(res, isNotNull); + expect(res.reminder.messageId, messageId); + expect(res.reminder.remindAt, remindAt); + + verify(() => client.patch(path, data: expectedPayload)).called(1); + verifyNoMoreInteractions(client); + }); + }); + + group('deleteReminder', () { + test('should delete reminder', () async { + const messageId = 'test-message-id'; + const path = '/messages/$messageId/reminders'; + + when(() => client.delete(path)).thenAnswer( + (_) async => successResponse(path, data: {})); + + final res = await remindersApi.deleteReminder(messageId); + + expect(res, isNotNull); + + verify(() => client.delete(path)).called(1); + verifyNoMoreInteractions(client); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/api/requests_test.dart b/packages/stream_chat/test/src/core/api/requests_test.dart index a9f04f616..7d58a8630 100644 --- a/packages/stream_chat/test/src/core/api/requests_test.dart +++ b/packages/stream_chat/test/src/core/api/requests_test.dart @@ -1,247 +1,8 @@ -// ignore_for_file: avoid_redundant_argument_values - -import 'package:stream_chat/src/core/models/comparable_field.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; -/// Simple test model that implements ComparableFieldProvider -class TestModel implements ComparableFieldProvider { - const TestModel({ - this.name, - this.age, - this.createdAt, - this.active, - }); - - final String? name; - final int? age; - final DateTime? createdAt; - final bool? active; - - @override - ComparableField? getComparableField(String sortKey) { - return switch (sortKey) { - 'name' => ComparableField.fromValue(name), - 'age' => ComparableField.fromValue(age), - 'created_at' => ComparableField.fromValue(createdAt), - 'active' => ComparableField.fromValue(active), - _ => null, - }; - } -} - void main() { group('src/api/requests', () { - group('SortOption', () { - test('serialization', () { - const option = SortOption.desc('name'); - final j = option.toJson(); - expect(j, {'field': 'name', 'direction': -1}); - }); - - test('should create a SortOption with default DESC direction', () { - const option = SortOption('name'); - expect(option.field, 'name'); - expect(option.direction, SortOption.DESC); - }); - - test('should create a SortOption with ASC direction', () { - const option = SortOption.asc('age'); - expect(option.field, 'age'); - expect(option.direction, SortOption.ASC); - }); - - test('should create a SortOption with DESC direction', () { - const option = SortOption.desc('age'); - expect(option.field, 'age'); - expect(option.direction, SortOption.DESC); - }); - - test('should correctly deserialize from JSON', () { - final json = {'field': 'age', 'direction': 1}; - final option = SortOption.fromJson(json); - expect(option.field, 'age'); - expect(option.direction, SortOption.ASC); - }); - - test('should compare two objects in descending order', () { - const option = SortOption.desc('age'); - const a = TestModel(age: 30); - const b = TestModel(age: 25); - - // In descending order, 30 should come before 25 - expect(option.compare(a, b), lessThan(0)); - }); - - test('should compare two objects in ascending order', () { - const option = SortOption.asc('age'); - const a = TestModel(age: 25); - const b = TestModel(age: 30); - - // In ascending order, 25 should come before 30 - expect(option.compare(a, b), lessThan(0)); - }); - - test('should handle null values correctly', () { - const option = SortOption.desc('age'); - const a = TestModel(age: null); - const b = TestModel(age: 25); - const c = TestModel(age: null); - - // Null values should come after non-null values - expect(option.compare(a, b), greaterThan(0)); - expect(option.compare(b, a), lessThan(0)); - - // Two null values should be equal - expect(option.compare(a, c), equals(0)); - }); - - test('should compare date fields correctly', () { - const option = SortOption.desc('created_at'); - final now = DateTime.now(); - final earlier = now.subtract(const Duration(days: 1)); - - final a = TestModel(createdAt: now); - final b = TestModel(createdAt: earlier); - - // In descending order, now should come before earlier - expect(option.compare(a, b), lessThan(0)); - }); - - test('should compare boolean fields correctly', () { - const option = SortOption.desc('active'); - const a = TestModel(active: true); - const b = TestModel(active: false); - const c = TestModel(active: true); - - // In descending order, true should come before false - expect(option.compare(a, b), lessThan(0)); - expect(option.compare(b, a), greaterThan(0)); - - // Two true values should be equal - expect(option.compare(a, c), equals(0)); - }); - - test('should handle custom comparator', () { - // Custom comparator that sorts by name length - final option = SortOption.desc( - 'name', - comparator: (a, b) { - final aLength = a.name?.length ?? 0; - final bLength = b.name?.length ?? 0; - return aLength.compareTo(bLength); - }, - ); - - const a = TestModel(name: 'longer_name'); - const b = TestModel(name: 'short'); - - // With custom comparator, longer name should come before shorter name - expect(option.compare(a, b), lessThan(0)); - }); - }); - - group('Composite Sorting', () { - test('should sort list using multiple sort criteria', () { - final models = [ - const TestModel(name: 'Alice', age: 30), - const TestModel(name: 'Bob', age: 30), - const TestModel(name: 'Charlie', age: 25), - const TestModel(name: 'David', age: 40), - ]; - - // Sort by age (DESC) then name (ASC) - final sortOptions = >[ - const SortOption.desc('age'), - const SortOption.asc('name'), - ]; - - // Use the compare extension - models.sort(sortOptions.compare); - - // Expected order: David (40), Alice (30), Bob (30), Charlie (25) - expect(models[0].name, 'David'); - // Same age as Bob, but name is alphabetically first - expect(models[1].name, 'Alice'); - // Same age as Alice, but name is alphabetically second - expect(models[2].name, 'Bob'); - expect(models[3].name, 'Charlie'); - }); - - test('should handle null values in multi-sort', () { - final models = [ - const TestModel(name: 'Alice', age: null), - const TestModel(name: 'Bob', age: 30), - const TestModel(name: 'Charlie', age: null), - const TestModel(name: null, age: 40), - ]; - - // Sort by age (DESC) then name (ASC) - final sortOptions = >[ - const SortOption.desc('age'), - const SortOption.asc('name'), - ]; - - models.sort(sortOptions.compare); - - // Expected order: - // 1. null name, age 40 - // 2. Bob, age 30 - // 3. Alice, null age - // 4. Charlie, null age - expect(models[0].name, null); - expect(models[1].name, 'Bob'); - // Null age, but name comes before Charlie alphabetically - expect(models[2].name, 'Alice'); - // Null age, but name comes after Alice alphabetically - expect(models[3].name, 'Charlie'); - }); - - test('should handle empty sort options', () { - final models = [ - const TestModel(name: 'Alice', age: 30), - const TestModel(name: 'Bob', age: 25), - ]; - - // Empty sort options - final sortOptions = >[]; - - // Should not change the order - final originalOrder = [...models]; - models.sort(sortOptions.compare); - - expect(models, equals(originalOrder)); - }); - - test('should sort with different data types in sequence', () { - final now = DateTime.now(); - final yesterday = now.subtract(const Duration(days: 1)); - - final models = [ - TestModel(name: 'Alice', active: true, createdAt: yesterday), - TestModel(name: 'Bob', active: false, createdAt: now), - TestModel(name: 'Charlie', active: true, createdAt: now), - ]; - - // Sort by created_at (DESC), active (DESC), then name (ASC) - final sortOptions = >[ - const SortOption.desc('created_at'), - const SortOption.desc('active'), - const SortOption.asc('name'), - ]; - - models.sort(sortOptions.compare); - - // Expected order: - // 1. Charlie - newest and active - // 2. Bob - newest but not active - // 3. Alice - older but active - expect(models[0].name, 'Charlie'); - expect(models[1].name, 'Bob'); - expect(models[2].name, 'Alice'); - }); - }); - group('PaginationParams', () { test('default', () { const option = PaginationParams(); diff --git a/packages/stream_chat/test/src/core/api/sort_order_test.dart b/packages/stream_chat/test/src/core/api/sort_order_test.dart new file mode 100644 index 000000000..b25dd5ed9 --- /dev/null +++ b/packages/stream_chat/test/src/core/api/sort_order_test.dart @@ -0,0 +1,318 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; +import 'package:stream_chat/src/core/api/sort_order.dart'; +import 'package:stream_chat/src/core/models/comparable_field.dart'; +import 'package:test/test.dart'; + +/// Simple test model that implements ComparableFieldProvider +class TestModel extends Equatable implements ComparableFieldProvider { + const TestModel({ + this.name, + this.age, + this.createdAt, + this.active, + }); + + final String? name; + final int? age; + final DateTime? createdAt; + final bool? active; + + @override + List get props => [name, age, createdAt, active]; + + @override + ComparableField? getComparableField(String sortKey) { + return switch (sortKey) { + 'name' => ComparableField.fromValue(name), + 'age' => ComparableField.fromValue(age), + 'created_at' => ComparableField.fromValue(createdAt), + 'active' => ComparableField.fromValue(active), + _ => null, + }; + } +} + +/// Helper to compare sorted lists cleanly +void expectSorted( + List input, + List> sortOptions, + List expectedOrder, +) { + final sorted = input.sorted(sortOptions.compare); + expect(sorted, equals(expectedOrder)); +} + +void main() { + group('SortOption basics', () { + test('serialization', () { + const option = SortOption.desc('name'); + final j = option.toJson(); + expect(j, {'field': 'name', 'direction': -1}); + }); + + test('should create a SortOption with default DESC direction', () { + const option = SortOption('name'); + expect(option.field, 'name'); + expect(option.direction, SortOption.DESC); + }); + + test('should create a SortOption with ASC direction', () { + const option = SortOption.asc('age'); + expect(option.field, 'age'); + expect(option.direction, SortOption.ASC); + }); + + test('should create a SortOption with DESC direction', () { + const option = SortOption.desc('age'); + expect(option.field, 'age'); + expect(option.direction, SortOption.DESC); + }); + + test('should correctly deserialize from JSON', () { + final json = {'field': 'age', 'direction': 1}; + final option = SortOption.fromJson(json); + expect(option.field, 'age'); + expect(option.direction, SortOption.ASC); + }); + }); + + group('SortOption single field', () { + test('should compare two objects in descending order', () { + const option = SortOption.desc('age'); + const a = TestModel(age: 30); + const b = TestModel(age: 25); + expect(option.compare(a, b), lessThan(0)); + }); + + test('should compare two objects in ascending order', () { + const option = SortOption.asc('age'); + const a = TestModel(age: 25); + const b = TestModel(age: 30); + expect(option.compare(a, b), lessThan(0)); + }); + + test('should handle null values correctly (default nullOrdering)', () { + const option = SortOption.desc('age'); + const a = TestModel(age: null); + const b = TestModel(age: 25); + const c = TestModel(age: null); + + expect(option.compare(a, b), lessThan(0)); + expect(option.compare(b, a), greaterThan(0)); + expect(option.compare(a, c), equals(0)); + }); + + test('should compare date fields correctly', () { + const option = SortOption.desc('created_at'); + final now = DateTime.now(); + final earlier = now.subtract(const Duration(days: 1)); + + final a = TestModel(createdAt: now); + final b = TestModel(createdAt: earlier); + + expect(option.compare(a, b), lessThan(0)); + }); + + test('should compare boolean fields correctly', () { + const option = SortOption.desc('active'); + const a = TestModel(active: true); + const b = TestModel(active: false); + const c = TestModel(active: true); + + expect(option.compare(a, b), lessThan(0)); + expect(option.compare(b, a), greaterThan(0)); + expect(option.compare(a, c), equals(0)); + }); + + test('should handle custom comparator', () { + final option = SortOption.desc( + 'name', + comparator: (a, b) { + final aLength = a.name?.length ?? 0; + final bLength = b.name?.length ?? 0; + return bLength.compareTo(aLength); + }, + ); + + const a = TestModel(name: 'longer_name'); + const b = TestModel(name: 'short'); + + expect(option.compare(a, b), greaterThan(0)); + }); + + test('should respect explicit nullOrdering=nullsLast on DESC', () { + final models = [ + const TestModel(age: null), + const TestModel(age: 40), + const TestModel(age: 30), + ]; + + final sortOptions = >[ + const SortOption.desc('age', nullOrdering: NullOrdering.nullsLast), + ]; + + expectSorted(models, sortOptions, [ + const TestModel(age: 40), + const TestModel(age: 30), + const TestModel(age: null), + ]); + }); + + test('should respect explicit nullOrdering=nullsFirst on ASC', () { + final models = [ + const TestModel(name: 'Bob'), + const TestModel(name: null), + const TestModel(name: 'Alice'), + ]; + + final sortOptions = >[ + const SortOption.asc('name', nullOrdering: NullOrdering.nullsFirst), + ]; + + expectSorted(models, sortOptions, [ + const TestModel(name: null), + const TestModel(name: 'Alice'), + const TestModel(name: 'Bob'), + ]); + }); + }); + + group('Composite Sorting', () { + test('should sort list using multiple sort criteria', () { + final models = [ + const TestModel(name: 'Alice', age: 30), + const TestModel(name: 'Bob', age: 30), + const TestModel(name: 'Charlie', age: 25), + const TestModel(name: 'David', age: 40), + ]; + + final sortOptions = >[ + const SortOption.desc('age'), + const SortOption.asc('name'), + ]; + + expectSorted(models, sortOptions, [ + const TestModel(name: 'David', age: 40), + const TestModel(name: 'Alice', age: 30), + const TestModel(name: 'Bob', age: 30), + const TestModel(name: 'Charlie', age: 25), + ]); + }); + + test('should handle null values in multi-sort', () { + final models = [ + const TestModel(name: 'Alice', age: null), + const TestModel(name: 'Bob', age: 30), + const TestModel(name: 'Charlie', age: null), + const TestModel(name: null, age: 40), + ]; + + final sortOptions = >[ + const SortOption.desc('age'), + const SortOption.asc('name'), + ]; + + expectSorted(models, sortOptions, [ + const TestModel(name: 'Alice', age: null), + const TestModel(name: 'Charlie', age: null), + const TestModel(name: null, age: 40), + const TestModel(name: 'Bob', age: 30), + ]); + }); + + test('should handle empty sort options', () { + final models = [ + const TestModel(name: 'Alice', age: 30), + const TestModel(name: 'Bob', age: 25), + ]; + + final sortOptions = >[]; + + expectSorted(models, sortOptions, [ + const TestModel(name: 'Alice', age: 30), + const TestModel(name: 'Bob', age: 25), + ]); + }); + + test('should sort with different data types in sequence', () { + final now = DateTime.now(); + final yesterday = now.subtract(const Duration(days: 1)); + + final models = [ + TestModel(name: 'Alice', active: true, createdAt: yesterday), + TestModel(name: 'Bob', active: false, createdAt: now), + TestModel(name: 'Charlie', active: true, createdAt: now), + ]; + + final sortOptions = >[ + const SortOption.desc('created_at'), + const SortOption.desc('active'), + const SortOption.asc('name'), + ]; + + expectSorted(models, sortOptions, [ + TestModel(name: 'Charlie', active: true, createdAt: now), + TestModel(name: 'Bob', active: false, createdAt: now), + TestModel(name: 'Alice', active: true, createdAt: yesterday), + ]); + }); + + test('should sort by second field when primary field values are equal', () { + final models = [ + const TestModel(name: 'Charlie', age: 30), + const TestModel(name: 'Bob', age: 30), + const TestModel(name: 'Alice', age: 30), + ]; + + final sortOptions = >[ + const SortOption.desc('age'), + const SortOption.asc('name'), + ]; + + expectSorted(models, sortOptions, [ + const TestModel(name: 'Alice', age: 30), + const TestModel(name: 'Bob', age: 30), + const TestModel(name: 'Charlie', age: 30), + ]); + }); + + test('should handle all fields null gracefully', () { + final models = [ + const TestModel(name: null, age: null), + const TestModel(name: null, age: null), + ]; + + final sortOptions = >[ + const SortOption.desc('age'), + const SortOption.asc('name'), + ]; + + expectSorted(models, sortOptions, [ + const TestModel(name: null, age: null), + const TestModel(name: null, age: null), + ]); + }); + + test('should handle mixed nulls in tie-breaker field', () { + final models = [ + const TestModel(name: 'Alice', age: null), + const TestModel(name: null, age: null), + const TestModel(name: 'Bob', age: null), + ]; + + final sortOptions = >[ + const SortOption.desc('age'), + const SortOption.asc('name'), + ]; + + expectSorted(models, sortOptions, [ + const TestModel(name: 'Alice', age: null), + const TestModel(name: 'Bob', age: null), + const TestModel(name: null, age: null), + ]); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/message_reminder_test.dart b/packages/stream_chat/test/src/core/models/message_reminder_test.dart new file mode 100644 index 000000000..6b08ff16c --- /dev/null +++ b/packages/stream_chat/test/src/core/models/message_reminder_test.dart @@ -0,0 +1,301 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/comparable_field.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/message_reminder.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:test/test.dart'; + +void main() { + group('MessageReminder', () { + final now = DateTime.now(); + const channelCid = 'messaging:123'; + const messageId = 'message-123'; + const userId = 'user-123'; + final remindAt = DateTime.now().add(const Duration(days: 1)); + + final messageReminder = MessageReminder( + channelCid: channelCid, + messageId: messageId, + userId: userId, + remindAt: remindAt, + createdAt: now, + updatedAt: now, + ); + + test('should create a valid instance with required parameters', () { + expect(messageReminder.channelCid, equals(channelCid)); + expect(messageReminder.messageId, equals(messageId)); + expect(messageReminder.userId, equals(userId)); + expect(messageReminder.remindAt, equals(remindAt)); + expect(messageReminder.createdAt, equals(now)); + expect(messageReminder.updatedAt, equals(now)); + expect(messageReminder.channel, isNull); + expect(messageReminder.message, isNull); + expect(messageReminder.user, isNull); + }); + + test('should create a valid instance with all parameters', () { + final channel = ChannelModel(cid: channelCid); + final message = Message(id: messageId); + final user = User(id: userId); + + final fullReminder = MessageReminder( + channelCid: channelCid, + channel: channel, + messageId: messageId, + message: message, + userId: userId, + user: user, + remindAt: remindAt, + createdAt: now, + updatedAt: now, + ); + + expect(fullReminder.channelCid, equals(channelCid)); + expect(fullReminder.channel, equals(channel)); + expect(fullReminder.messageId, equals(messageId)); + expect(fullReminder.message, equals(message)); + expect(fullReminder.userId, equals(userId)); + expect(fullReminder.user, equals(user)); + expect(fullReminder.remindAt, equals(remindAt)); + expect(fullReminder.createdAt, equals(now)); + expect(fullReminder.updatedAt, equals(now)); + }); + + test('should create a bookmark reminder when remindAt is null', () { + final bookmarkReminder = MessageReminder( + channelCid: channelCid, + messageId: messageId, + userId: userId, + remindAt: null, // This makes it a bookmark + createdAt: now, + updatedAt: now, + ); + + expect(bookmarkReminder.remindAt, isNull); + expect(bookmarkReminder.channelCid, equals(channelCid)); + expect(bookmarkReminder.messageId, equals(messageId)); + expect(bookmarkReminder.userId, equals(userId)); + }); + + test('should correctly serialize to JSON', () { + final json = messageReminder.toJson(); + + expect(json['channel_cid'], equals(channelCid)); + expect(json['message_id'], equals(messageId)); + expect(json['user_id'], equals(userId)); + expect(json['remind_at'], isA()); + expect(json['created_at'], isA()); + expect(json['updated_at'], isA()); + + // These fields should not be included in JSON + expect(json.containsKey('channel'), isFalse); + expect(json.containsKey('message'), isFalse); + expect(json.containsKey('user'), isFalse); + }); + + test('should correctly deserialize from JSON', () { + final json = { + 'channel_cid': channelCid, + 'message_id': messageId, + 'user_id': userId, + 'remind_at': remindAt.toIso8601String(), + 'created_at': now.toIso8601String(), + 'updated_at': now.toIso8601String(), + }; + + final deserializedReminder = MessageReminder.fromJson(json); + + expect(deserializedReminder.channelCid, equals(channelCid)); + expect(deserializedReminder.messageId, equals(messageId)); + expect(deserializedReminder.userId, equals(userId)); + expect(deserializedReminder.remindAt, equals(remindAt)); + expect(deserializedReminder.createdAt, equals(now)); + expect(deserializedReminder.updatedAt, equals(now)); + }); + + test('should implement equality correctly', () { + final reminder1 = MessageReminder( + channelCid: channelCid, + messageId: messageId, + userId: userId, + remindAt: remindAt, + createdAt: now, + updatedAt: now, + ); + + final reminder2 = MessageReminder( + channelCid: channelCid, + messageId: messageId, + userId: userId, + remindAt: remindAt, + createdAt: now, + updatedAt: now, + ); + + final reminder3 = MessageReminder( + channelCid: 'different:123', + messageId: messageId, + userId: userId, + remindAt: remindAt, + createdAt: now, + updatedAt: now, + ); + + expect(reminder1, equals(reminder2)); + expect(reminder1, isNot(equals(reminder3))); + }); + + test('should implement equality correctly with null remindAt', () { + final bookmark1 = MessageReminder( + channelCid: channelCid, + messageId: messageId, + userId: userId, + remindAt: null, + createdAt: now, + updatedAt: now, + ); + + final bookmark2 = MessageReminder( + channelCid: channelCid, + messageId: messageId, + userId: userId, + remindAt: null, + createdAt: now, + updatedAt: now, + ); + + final scheduledReminder = MessageReminder( + channelCid: channelCid, + messageId: messageId, + userId: userId, + remindAt: remindAt, + createdAt: now, + updatedAt: now, + ); + + expect(bookmark1, equals(bookmark2)); + expect(bookmark1, isNot(equals(scheduledReminder))); + }); + + test('should implement copyWith correctly', () { + const newChannelCid = 'messaging:456'; + const newMessageId = 'message-456'; + const newUserId = 'user-456'; + final newRemindAt = DateTime.now().add(const Duration(days: 2)); + final newCreatedAt = DateTime.now().add(const Duration(hours: 1)); + final newUpdatedAt = DateTime.now().add(const Duration(hours: 2)); + + final copiedReminder = messageReminder.copyWith( + channelCid: newChannelCid, + messageId: newMessageId, + userId: newUserId, + remindAt: newRemindAt, + createdAt: newCreatedAt, + updatedAt: newUpdatedAt, + ); + + expect(copiedReminder.channelCid, equals(newChannelCid)); + expect(copiedReminder.messageId, equals(newMessageId)); + expect(copiedReminder.userId, equals(newUserId)); + expect(copiedReminder.remindAt, equals(newRemindAt)); + expect(copiedReminder.createdAt, equals(newCreatedAt)); + expect(copiedReminder.updatedAt, equals(newUpdatedAt)); + + // Original should be unchanged + expect(messageReminder.channelCid, equals(channelCid)); + expect(messageReminder.messageId, equals(messageId)); + expect(messageReminder.userId, equals(userId)); + expect(messageReminder.remindAt, equals(remindAt)); + expect(messageReminder.createdAt, equals(now)); + expect(messageReminder.updatedAt, equals(now)); + }); + + test('should implement copyWith correctly with null remindAt', () { + final copiedReminder = messageReminder.copyWith( + remindAt: null, + ); + + expect(copiedReminder.remindAt, isNull); + expect(copiedReminder.channelCid, equals(messageReminder.channelCid)); + expect(copiedReminder.messageId, equals(messageReminder.messageId)); + expect(copiedReminder.userId, equals(messageReminder.userId)); + }); + + test('should implement merge correctly', () { + final originalReminder = MessageReminder( + channelCid: channelCid, + messageId: messageId, + userId: userId, + remindAt: remindAt, + createdAt: now, + updatedAt: now, + ); + + const newChannelCid = 'messaging:456'; + const newMessageId = 'message-456'; + final newRemindAt = DateTime.now().add(const Duration(days: 2)); + final newUpdatedAt = DateTime.now().add(const Duration(hours: 1)); + + final otherReminder = MessageReminder( + channelCid: newChannelCid, + messageId: newMessageId, + userId: userId, // Same userId + remindAt: newRemindAt, + createdAt: now, // Same createdAt + updatedAt: newUpdatedAt, + ); + + final mergedReminder = originalReminder.merge(otherReminder); + + expect(mergedReminder.channelCid, equals(newChannelCid)); + expect(mergedReminder.messageId, equals(newMessageId)); + expect(mergedReminder.userId, equals(userId)); + expect(mergedReminder.remindAt, equals(newRemindAt)); + expect(mergedReminder.createdAt, equals(now)); + expect(mergedReminder.updatedAt, equals(newUpdatedAt)); + }); + + test('should return original instance when merging with null', () { + final mergedReminder = messageReminder.merge(null); + + expect(mergedReminder, equals(messageReminder)); + expect(identical(mergedReminder, messageReminder), isTrue); + }); + + test('should implement ComparableFieldProvider interface', () { + // Test channelCid field + final channelCidField = messageReminder.getComparableField( + MessageReminderSortKey.channelCid, + ); + expect(channelCidField, isA()); + expect(channelCidField?.value, equals(channelCid)); + + // Test remindAt field + final remindAtField = messageReminder.getComparableField( + MessageReminderSortKey.remindAt, + ); + expect(remindAtField, isA()); + expect(remindAtField?.value, equals(remindAt)); + + // Test createdAt field + final createdAtField = messageReminder.getComparableField( + MessageReminderSortKey.createdAt, + ); + expect(createdAtField, isA()); + expect(createdAtField?.value, equals(now)); + + // Test non-existent field + final nonExistentField = messageReminder.getComparableField('unknown'); + expect(nonExistentField?.value, isNull); + }); + + test('MessageReminderSortKey should have defined constants', () { + expect(MessageReminderSortKey.channelCid, equals('channel_cid')); + expect(MessageReminderSortKey.remindAt, equals('remind_at')); + expect(MessageReminderSortKey.createdAt, equals('created_at')); + }); + }); +} diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index e22233f01..0d92412ef 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,9 @@ +## Upcoming + +✅ Added + +- Added `StreamMessageReminderListController` to manage the list of message reminders. + ## 9.11.0 - Updated `stream_chat` dependency to [`9.11.0`](https://pub.dev/packages/stream_chat/changelog). diff --git a/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart new file mode 100644 index 000000000..a2fa4f156 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart @@ -0,0 +1,280 @@ +// ignore_for_file: join_return_with_assignment + +import 'dart:async'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart'; +import 'package:stream_chat_flutter_core/src/stream_message_reminder_list_event_handler.dart'; + +/// The default message reminder page limit to load. +const defaultMessageReminderPagedLimit = 10; + +/// The default sort used for the message reminder list. +const defaultMessageReminderListSort = [ + SortOption.asc(MessageReminderSortKey.remindAt), +]; + +const _kDefaultBackendPaginationLimit = 30; + +/// {@template streamMessageReminderListController} +/// A controller for managing and displaying a paginated list of message +/// reminders. +/// +/// The `StreamMessageReminderListController` extends [PagedValueNotifier] to +/// handle paginated data for message reminders. It provides functionality for +/// querying reminders, handling events, and managing filters and sorting. +/// +/// This controller is typically used in conjunction with UI components +/// to display and interact with a list of message reminders. +/// {@endtemplate} +class StreamMessageReminderListController + extends PagedValueNotifier { + /// {@macro streamMessageReminderListController} + StreamMessageReminderListController({ + required this.client, + StreamMessageReminderListEventHandler? eventHandler, + this.filter, + this.sort = defaultMessageReminderListSort, + this.limit = defaultMessageReminderPagedLimit, + }) : _activeFilter = filter, + _activeSort = sort, + _eventHandler = eventHandler ?? StreamMessageReminderListEventHandler(), + super(const PagedValue.loading()); + + /// Creates a [StreamMessageReminderListController] from the passed [value]. + StreamMessageReminderListController.fromValue( + super.value, { + required this.client, + StreamMessageReminderListEventHandler? eventHandler, + this.filter, + this.sort = defaultMessageReminderListSort, + this.limit = defaultMessageReminderPagedLimit, + }) : _activeFilter = filter, + _activeSort = sort, + _eventHandler = eventHandler ?? StreamMessageReminderListEventHandler(); + + /// The Stream client used to perform the queries. + final StreamChatClient client; + + /// The channel event handlers to use for the message reminder list. + final StreamMessageReminderListEventHandler _eventHandler; + + /// The query filters to use. + /// + /// You can query on any of the custom fields you've defined on the + /// [MessageReminder]. + final Filter? filter; + Filter? _activeFilter; + + /// The sorting used for the message reminders matching the filters. + /// + /// Sorting is based on field and direction, multiple sorting options + /// can be provided. + /// + /// Direction can be ascending or descending. + final SortOrder? sort; + SortOrder? _activeSort; + + /// The limit to apply to the message reminder list. The default is set to + /// [defaultMessageReminderPagedLimit]. + final int limit; + + /// Allows for the change of filters used for message reminder queries. + /// + /// Use this if you need to support runtime filter changes, + /// through custom filters UI. + set filter(Filter? value) => _activeFilter = value; + + /// Allows for the change of the query sort used for message reminder queries. + /// + /// Use this if you need to support runtime sort changes, + /// through custom sort UI. + set sort(SortOrder? value) => _activeSort = value; + + @override + set value(PagedValue newValue) { + super.value = switch (_activeSort) { + null => newValue, + final reminderSort => newValue.maybeMap( + orElse: () => newValue, + (success) => success.copyWith( + items: success.items.sorted(reminderSort.compare), + ), + ), + }; + } + + @override + Future doInitialLoad() async { + final limit = min( + this.limit * defaultInitialPagedLimitMultiplier, + _kDefaultBackendPaginationLimit, + ); + try { + final response = await client.queryReminders( + sort: _activeSort, + filter: _activeFilter, + pagination: PaginationParams(limit: limit), + ); + + final results = response.reminders; + final nextKey = response.next; + value = PagedValue( + items: results, + nextPageKey: nextKey, + ); + // Start listening to events + _subscribeToReminderListEvents(); + } on StreamChatError catch (error) { + value = PagedValue.error(error); + } catch (error) { + final chatError = StreamChatError(error.toString()); + value = PagedValue.error(chatError); + } + } + + @override + Future loadMore(String nextPageKey) async { + final previousValue = value.asSuccess; + + try { + final response = await client.queryReminders( + sort: _activeSort, + filter: _activeFilter, + pagination: PaginationParams(limit: limit, next: nextPageKey), + ); + + final results = response.reminders; + final previousItems = previousValue.items; + final newItems = previousItems + results; + final next = response.next; + final nextKey = next != null && next.isNotEmpty ? next : null; + value = PagedValue( + items: newItems, + nextPageKey: nextKey, + ); + } on StreamChatError catch (error) { + value = previousValue.copyWith(error: error); + } catch (error) { + final chatError = StreamChatError(error.toString()); + value = previousValue.copyWith(error: chatError); + } + } + + /// Event listener, which can be set in order to listen + /// [client] web-socket events. + /// + /// Return `true` if the event is handled. Return `false` to + /// allow the event to be handled internally. + bool Function(Event event)? eventListener; + + StreamSubscription? _reminderEventSubscription; + + // Subscribes to the message reminder list events. + void _subscribeToReminderListEvents() { + if (_reminderEventSubscription != null) { + _unsubscribeFromReminderListEvents(); + } + + _reminderEventSubscription = client.on().listen((event) { + // Only handle the event if the value is in success state. + if (value.isNotSuccess) return; + + // Returns early if the event is already handled by the listener. + if (eventListener?.call(event) ?? false) return; + + final handlerFunc = switch (event.type) { + EventType.reminderCreated => _eventHandler.onMessageReminderCreated, + EventType.reminderUpdated => _eventHandler.onMessageReminderUpdated, + EventType.reminderDeleted => _eventHandler.onMessageReminderDeleted, + EventType.notificationReminderDue => _eventHandler.onMessageReminderDue, + EventType.connectionRecovered => _eventHandler.onConnectionRecovered, + _ => null, + }; + + return handlerFunc?.call(event, this); + }); + } + + @override + Future refresh({bool resetValue = true}) { + if (resetValue) { + _activeFilter = filter; + _activeSort = sort; + } + return super.refresh(resetValue: resetValue); + } + + /// Replaces the previously loaded message reminders with the passed + /// [reminders]. + set reminders(List reminders) { + if (value.isSuccess) { + final currentValue = value.asSuccess; + value = currentValue.copyWith(items: reminders); + } else { + value = PagedValue(items: reminders); + } + } + + /// Add/Updates the given [reminder] in the list. + /// + /// Returns `true` if the reminder is added or updated successfully. + bool updateReminder(MessageReminder reminder) { + final currentReminders = [ + ...currentItems.merge( + [reminder], + key: (reminder) => (reminder.messageId, reminder.userId), + update: (original, updated) => original.merge(updated), + ), + ]; + + reminders = currentReminders; + return true; + } + + /// Deletes the reminder with the given parameters from the list. + /// + /// Returns `true` if the reminder is deleted successfully. + bool deleteReminder(MessageReminder reminder) { + final currentReminders = [...currentItems]; + final removeIndex = currentReminders.indexWhere( + (it) { + var predicate = it.userId == reminder.userId; + predicate &= it.messageId == reminder.messageId; + return predicate; + }, + ); + + if (removeIndex < 0) return false; + currentReminders.removeAt(removeIndex); + + reminders = currentReminders; + return true; + } + + // Unsubscribes from all message reminder list events. + void _unsubscribeFromReminderListEvents() { + if (_reminderEventSubscription != null) { + _reminderEventSubscription!.cancel(); + _reminderEventSubscription = null; + } + } + + /// Pauses all subscriptions added to this composite. + void pauseEventsSubscription([Future? resumeSignal]) { + _reminderEventSubscription?.pause(resumeSignal); + } + + /// Resumes all subscriptions added to this composite. + void resumeEventsSubscription() { + _reminderEventSubscription?.resume(); + } + + @override + void dispose() { + _unsubscribeFromReminderListEvents(); + super.dispose(); + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_event_handler.dart b/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_event_handler.dart new file mode 100644 index 000000000..cbaaa4876 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_event_handler.dart @@ -0,0 +1,82 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/stream_message_reminder_list_controller.dart'; + +/// Contains handlers that are called from [StreamMessageReminderListController] +/// for certain [Event]s. +/// +/// This class can be mixed in or extended to create custom overrides. +mixin class StreamMessageReminderListEventHandler { + /// Function which gets called for the event [EventType.connectionRecovered]. + /// + /// This event is fired when the client web-socket connection recovers. + /// + /// By default, this does nothing and can be overridden to perform + /// custom actions, such as refreshing the list of reminders. + void onConnectionRecovered( + Event event, + StreamMessageReminderListController controller, + ) { + // no-op + } + + /// Function which gets called for the event + /// [EventType.messageReminderUpdated]. + /// + /// This event is fired when a message reminder is updated. + /// + /// By default, this updates the reminder in the list. + void onMessageReminderCreated( + Event event, + StreamMessageReminderListController controller, + ) { + final reminder = event.reminder; + if (reminder == null) return; + + controller.updateReminder(reminder); + } + + /// Function which gets called for the event + /// [EventType.messageReminderUpdated]. + /// + /// This event is fired when a message reminder is updated. + /// + /// By default, this updates the reminder in the list. + void onMessageReminderUpdated( + Event event, + StreamMessageReminderListController controller, + ) { + final reminder = event.reminder; + if (reminder == null) return; + + controller.updateReminder(reminder); + } + + /// Function which gets called for the event + /// [EventType.messageReminderDeleted]. + /// + /// This event is fired when a message reminder is deleted. + /// + /// By default, this removes the reminder from the list. + void onMessageReminderDeleted( + Event event, + StreamMessageReminderListController controller, + ) { + final reminder = event.reminder; + if (reminder == null) return; + + controller.deleteReminder(reminder); + } + + /// Function which gets called for the event + /// [EventType.notificationReminderDue]. + /// + /// This event is fired when a message reminder crosses its due time. + /// + /// By default, this updates the reminder in the list. + void onMessageReminderDue( + Event event, + StreamMessageReminderListController controller, + ) { + return onMessageReminderUpdated(event, controller); + } +} diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index 9c1ce21d1..444362bbb 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -22,6 +22,8 @@ export 'src/stream_draft_list_controller.dart'; export 'src/stream_draft_list_event_handler.dart'; export 'src/stream_member_list_controller.dart'; export 'src/stream_message_input_controller.dart'; +export 'src/stream_message_reminder_list_controller.dart'; +export 'src/stream_message_reminder_list_event_handler.dart'; export 'src/stream_message_search_list_controller.dart'; export 'src/stream_poll_controller.dart'; export 'src/stream_poll_vote_list_controller.dart'; diff --git a/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart new file mode 100644 index 000000000..86d6767df --- /dev/null +++ b/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart @@ -0,0 +1,512 @@ +// ignore_for_file: avoid_redundant_argument_values, cascade_invocations + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart' hide Success; +import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart'; +import 'package:stream_chat_flutter_core/src/stream_message_reminder_list_controller.dart'; + +import 'mocks.dart'; + +MessageReminder generateMessageReminder({ + String? channelCid, + String? messageId, + String? userId, + DateTime? remindAt, + DateTime? createdAt, + String? text, +}) { + return MessageReminder( + channelCid: channelCid ?? 'messaging:123', + messageId: messageId ?? 'message_123', + userId: userId ?? 'user_123', + remindAt: remindAt ?? DateTime.now().add(const Duration(hours: 1)), + createdAt: createdAt ?? DateTime.now(), + message: Message( + id: messageId ?? 'message_123', + text: text ?? 'Test reminder message', + user: User(id: userId ?? 'user_123'), + createdAt: createdAt ?? DateTime.now(), + ), + ); +} + +List generateMessageReminders({ + int count = 2, + List? texts, + List? channelCids, + List? messageIds, + List? userIds, + int? startId, +}) { + final now = DateTime.now(); + final baseId = startId ?? 123; + + return List.generate(count, (index) { + final channelCid = channelCids != null && index < channelCids.length + ? channelCids[index] + : 'messaging:${baseId + index}'; + final messageId = messageIds != null && index < messageIds.length + ? messageIds[index] + : 'message_${baseId + index}'; + final userId = userIds != null && index < userIds.length + ? userIds[index] + : 'user_${baseId + index}'; + final text = texts != null && index < texts.length + ? texts[index] + : 'Reminder ${index + 1}'; + + return generateMessageReminder( + channelCid: channelCid, + messageId: messageId, + userId: userId, + text: text, + remindAt: now.add(Duration(hours: index + 1)), + createdAt: now.subtract(Duration(minutes: index)), + ); + }); +} + +void main() { + final client = MockClient(); + + setUpAll(() { + registerFallbackValue(const PaginationParams()); + }); + + setUp(() { + when(client.on).thenAnswer((_) => const Stream.empty()); + }); + + tearDown(() { + reset(client); + }); + + group('Initialization', () { + test('should start in loading state when created with client', () { + final controller = StreamMessageReminderListController(client: client); + expect(controller.value, isA()); + }); + + test('should preserve provided value when created with fromValue', () { + final reminders = generateMessageReminders(); + final value = PagedValue(items: reminders); + final controller = StreamMessageReminderListController.fromValue( + value, + client: client, + ); + + expect(controller.value, same(value)); + expect(controller.value.asSuccess.items, equals(reminders)); + }); + }); + + group('Initial loading', () { + test('successfully loads message reminders from API', () async { + final reminders = generateMessageReminders(); + final response = QueryRemindersResponse() + ..reminders = reminders + ..next = null; + + when(() => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => response); + + final controller = StreamMessageReminderListController(client: client); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + verify(() => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).called(1); + + expect(controller.value, isA>()); + expect(controller.value.asSuccess.items, equals(reminders)); + }); + + test('handles StreamChatError exceptions properly', () async { + const chatError = StreamChatError('Network error'); + when(() => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenThrow(chatError); + + final controller = StreamMessageReminderListController(client: client); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + expect(controller.value, isA()); + expect((controller.value as Error).error, equals(chatError)); + }); + }); + + group('Pagination', () { + test('loadMore appends new reminders to existing items', () async { + const nextKey = 'next_page_token'; + final existingReminders = generateMessageReminders(); + final additionalReminders = generateMessageReminders( + count: 1, + startId: 789, + texts: ['Reminder 3'], + channelCids: ['messaging:789'], + messageIds: ['message_789'], + userIds: ['user_789'], + ); + + final response = QueryRemindersResponse() + ..reminders = additionalReminders + ..next = null; + + when(() => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenAnswer((_) async => response); + + final controller = StreamMessageReminderListController.fromValue( + PagedValue( + items: existingReminders, + nextPageKey: nextKey, + ), + client: client, + ); + + await controller.loadMore(nextKey); + await pumpEventQueue(); + + final mergedReminders = [...existingReminders, ...additionalReminders]; + + expect( + controller.value.asSuccess.items.length, + equals(mergedReminders.length), + ); + + expect(controller.value.asSuccess.nextPageKey, isNull); + }); + + test('loadMore handles StreamChatError exceptions properly', () async { + const nextKey = 'next_page_token'; + final existingReminders = generateMessageReminders(); + const chatError = StreamChatError('Network error'); + + when(() => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + )).thenThrow(chatError); + + final controller = StreamMessageReminderListController.fromValue( + PagedValue( + items: existingReminders, + nextPageKey: nextKey, + ), + client: client, + ); + + await controller.loadMore(nextKey); + await pumpEventQueue(); + + expect(controller.value.isSuccess, isTrue); + expect(controller.value.asSuccess.items, equals(existingReminders)); + expect(controller.value.asSuccess.error, equals(chatError)); + }); + }); + + group('Message Reminder CRUD operations', () { + test('updateReminder replaces existing reminder with same predicate', () { + final reminders = generateMessageReminders( + texts: ['Reminder 1', 'Reminder 2'], + ); + final controller = StreamMessageReminderListController.fromValue( + PagedValue(items: reminders), + client: client, + ); + + final newRemindAt = DateTime.now().add(const Duration(hours: 2)); + final updatedReminder = reminders[0].copyWith( + remindAt: newRemindAt, + ); + + final result = controller.updateReminder(updatedReminder); + + expect(result, isTrue); + expect( + controller.value.asSuccess.items.any((r) => r.remindAt == newRemindAt), + isTrue, + ); + expect(controller.value.asSuccess.items.length, equals(reminders.length)); + }); + + test('updateReminder adds reminder when no matching reminder exists', () { + final reminders = generateMessageReminders(); + final controller = StreamMessageReminderListController.fromValue( + PagedValue(items: reminders), + client: client, + ); + + final newReminder = generateMessageReminder( + channelCid: 'messaging:789', + messageId: 'message_789', + userId: 'user_789', + text: 'New Reminder', + ); + + final result = controller.updateReminder(newReminder); + + expect(result, isTrue); + expect( + controller.value.asSuccess.items.length, + equals(reminders.length + 1), + ); + expect( + controller.value.asSuccess.items.any( + (r) => r.message?.text == newReminder.message?.text, + ), + isTrue, + ); + }); + + test( + 'deleteReminder removes reminder and returns true when reminder exists', + () { + final reminders = generateMessageReminders(); + final controller = StreamMessageReminderListController.fromValue( + PagedValue(items: reminders), + client: client, + ); + + final result = controller.deleteReminder(reminders[0]); + + expect(result, isTrue); + expect( + controller.value.asSuccess.items.length, + equals(reminders.length - 1), + ); + expect( + controller.value.asSuccess.items.any((r) => + r.messageId == reminders[0].messageId && + r.userId == reminders[0].userId), + isFalse, + ); + }); + + test('deleteReminder returns false when reminder does not exist', () { + final reminders = generateMessageReminders(); + final controller = StreamMessageReminderListController.fromValue( + PagedValue(items: reminders), + client: client, + ); + + final nonExistentReminder = generateMessageReminder( + channelCid: 'messaging:999', + messageId: 'message_999', + userId: 'user_999', + text: 'Non-existent Reminder', + ); + + final result = controller.deleteReminder(nonExistentReminder); + + expect(result, isFalse); + expect(controller.value.asSuccess.items.length, equals(reminders.length)); + }); + }); + + group('Event handling', () { + late StreamController eventController; + final initialReminders = generateMessageReminders(); + + setUp(() { + eventController = StreamController.broadcast(); + when(client.on).thenAnswer((_) => eventController.stream); + + when( + () => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryRemindersResponse() + ..reminders = initialReminders + ..next = null, + ); + }); + + tearDown(() { + eventController.close(); + }); + + test('reminder_created event triggers reminder addition', () async { + final controller = StreamMessageReminderListController(client: client); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + expect(controller.value.isSuccess, isTrue); + expect(controller.value.asSuccess.items, equals(initialReminders)); + + final newReminder = generateMessageReminder( + channelCid: 'messaging:new', + messageId: 'message_new', + userId: 'user_new', + text: 'Created via event', + ); + + final event = Event( + type: EventType.reminderCreated, + reminder: newReminder, + ); + + eventController.add(event); + await pumpEventQueue(); + + final hasNewReminder = controller.value.asSuccess.items.any( + (reminder) => reminder.message?.text == 'Created via event', + ); + + expect(hasNewReminder, isTrue); + expect( + controller.value.asSuccess.items.length, + equals(initialReminders.length + 1), + ); + }); + + test('reminder_updated event triggers reminder update', () async { + final controller = StreamMessageReminderListController(client: client); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + expect(controller.value.isSuccess, isTrue); + expect(controller.value.asSuccess.items, equals(initialReminders)); + + final updatedReminder = initialReminders[0].copyWith( + remindAt: DateTime.now().add(const Duration(hours: 5)), + ); + + final event = Event( + type: EventType.reminderUpdated, + reminder: updatedReminder, + ); + + eventController.add(event); + await pumpEventQueue(); + + final hasUpdatedReminder = controller.value.asSuccess.items.any( + (reminder) => reminder.remindAt == updatedReminder.remindAt, + ); + + expect(hasUpdatedReminder, isTrue); + expect( + controller.value.asSuccess.items.length, + equals(initialReminders.length), + ); + }); + + test('reminder_deleted event triggers reminder removal', () async { + final controller = StreamMessageReminderListController(client: client); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + expect(controller.value.isSuccess, isTrue); + expect(controller.value.asSuccess.items, equals(initialReminders)); + + final initialItemCount = controller.value.asSuccess.items.length; + + final event = Event( + type: EventType.reminderDeleted, + reminder: initialReminders[0], + ); + + eventController.add(event); + await pumpEventQueue(); + + expect( + controller.value.asSuccess.items.length, + equals(initialItemCount - 1), + ); + + expect( + controller.value.asSuccess.items.any( + (r) => + r.messageId == initialReminders[0].messageId && + r.userId == initialReminders[0].userId, + ), + isFalse, + ); + }); + + test('custom event listener can prevent default handling', () async { + final controller = StreamMessageReminderListController(client: client); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + expect(controller.value.isSuccess, isTrue); + expect(controller.value.asSuccess.items, equals(initialReminders)); + + final initialItems = List.of(controller.value.asSuccess.items); + + var listenerCalled = false; + controller.eventListener = (event) { + listenerCalled = true; + return true; + }; + + final updatedReminder = initialReminders[0].copyWith( + remindAt: DateTime.now().add(const Duration(hours: 10)), + ); + + final event = Event( + type: EventType.reminderUpdated, + reminder: updatedReminder, + ); + + eventController.add(event); + await pumpEventQueue(); + + expect(listenerCalled, isTrue); + expect(controller.value.asSuccess.items, equals(initialItems)); + expect( + controller.value.asSuccess.items.any( + (r) => r.remindAt == updatedReminder.remindAt, + ), + isFalse, + ); + }); + + test('ignores events when value is not in success state', () async { + final controller = StreamMessageReminderListController(client: client); + + // Controller starts in loading state + expect(controller.value.isNotSuccess, isTrue); + + var eventHandled = false; + controller.eventListener = (event) { + eventHandled = true; + return false; + }; + + final reminder = generateMessageReminder(); + final event = Event( + type: EventType.reminderCreated, + reminder: reminder, + ); + + eventController.add(event); + await pumpEventQueue(); + + expect(eventHandled, isFalse); + }); + }); +} diff --git a/sample_app/lib/pages/channel_list_page.dart b/sample_app/lib/pages/channel_list_page.dart index 428ffdcef..6084a038f 100644 --- a/sample_app/lib/pages/channel_list_page.dart +++ b/sample_app/lib/pages/channel_list_page.dart @@ -10,6 +10,7 @@ import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:sample_app/app.dart'; import 'package:sample_app/pages/draft_list_page.dart'; +import 'package:sample_app/pages/reminders_page.dart'; import 'package:sample_app/pages/thread_list_page.dart'; import 'package:sample_app/pages/user_mentions_page.dart'; import 'package:sample_app/routes/routes.dart'; @@ -92,6 +93,15 @@ class _ChannelListPageState extends State { ), label: 'Drafts', ), + BottomNavigationBarItem( + icon: Icon( + Icons.bookmark_border_rounded, + color: _isSelected(4) + ? StreamChatTheme.of(context).colorTheme.textHighEmphasis + : Colors.grey, + ), + label: 'Reminders', + ), ]; } @@ -136,6 +146,7 @@ class _ChannelListPageState extends State { UserMentionsPage(), ThreadListPage(), DraftListPage(), + RemindersPage(), ], ), ); diff --git a/sample_app/lib/pages/channel_page.dart b/sample_app/lib/pages/channel_page.dart index d922372b8..8a9bdbe57 100644 --- a/sample_app/lib/pages/channel_page.dart +++ b/sample_app/lib/pages/channel_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sample_app/pages/thread_page.dart'; import 'package:sample_app/routes/routes.dart'; +import 'package:sample_app/widgets/reminder_dialog.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class ChannelPage extends StatefulWidget { @@ -48,8 +49,12 @@ class _ChannelPageState extends State { @override Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final textTheme = theme.textTheme; + final colorTheme = theme.colorTheme; + return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, + backgroundColor: colorTheme.appBg, appBar: StreamChannelHeader( showTypingIndicator: false, onBackPressed: () => GoRouter.of(context).pop(), @@ -88,40 +93,7 @@ class _ChannelPageState extends State { highlightInitialMessage: widget.highlightInitialMessage, //onMessageSwiped: _reply, messageFilter: defaultFilter, - messageBuilder: (context, details, messages, defaultMessage) { - final router = GoRouter.of(context); - return defaultMessage.copyWith( - onReplyTap: _reply, - onShowMessage: (m, c) async { - final client = StreamChat.of(context).client; - final message = m; - final channel = client.channel( - c.type, - id: c.id, - ); - if (channel.state == null) { - await channel.watch(); - } - router.goNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - queryParameters: - Routes.CHANNEL_PAGE.queryParams(message), - ); - }, - bottomRowBuilderWithDefaultWidget: ( - context, - message, - defaultWidget, - ) { - return defaultWidget.copyWith( - deletedBottomRowBuilder: (context, message) { - return const StreamVisibleFootnote(); - }, - ); - }, - ); - }, + messageBuilder: customMessageBuilder, threadBuilder: (_, parentMessage) { return ThreadPage(parent: parentMessage!); }, @@ -132,22 +104,15 @@ class _ChannelPageState extends State { right: 0, child: Container( alignment: Alignment.centerLeft, - color: StreamChatTheme.of(context) - .colorTheme - .appBg - .withOpacity(.9), + color: colorTheme.appBg.withOpacity(.9), child: StreamTypingIndicator( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), - style: StreamChatTheme.of(context) - .textTheme - .footnote - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis), + style: textTheme.footnote.copyWith( + color: colorTheme.textLowEmphasis, + ), ), ), ), @@ -164,6 +129,160 @@ class _ChannelPageState extends State { ); } + Widget customMessageBuilder( + BuildContext context, + MessageDetails details, + List messages, + StreamMessageWidget defaultMessageWidget, + ) { + final theme = StreamChatTheme.of(context); + final textTheme = theme.textTheme; + final colorTheme = theme.colorTheme; + + final message = details.message; + final reminder = message.reminder; + final channelConfig = StreamChannel.of(context).channel.config; + + final customOptions = [ + if (channelConfig?.userMessageReminders == true) ...[ + if (reminder != null) ...[ + StreamMessageAction( + leading: StreamSvgIcon( + icon: StreamSvgIcons.time, + color: colorTheme.textLowEmphasis, + ), + title: const Text('Edit Reminder'), + onTap: (message) async { + Navigator.of(context).pop(); + + final option = await showDialog( + context: context, + builder: (_) => EditReminderDialog( + isBookmarkReminder: reminder.remindAt == null, + ), + ); + + if (option == null) return; + final client = StreamChat.of(context).client; + final messageId = message.id; + final remindAt = option.remindAt; + + client.updateReminder(messageId, remindAt: remindAt).ignore(); + }, + ), + StreamMessageAction( + leading: StreamSvgIcon( + icon: StreamSvgIcons.checkAll, + color: colorTheme.textLowEmphasis, + ), + title: const Text('Remove from later'), + onTap: (message) { + Navigator.of(context).pop(); + + final client = StreamChat.of(context).client; + final messageId = message.id; + + client.deleteReminder(messageId).ignore(); + }, + ), + ] else ...[ + StreamMessageAction( + leading: StreamSvgIcon( + icon: StreamSvgIcons.time, + color: colorTheme.textLowEmphasis, + ), + title: const Text('Remind me'), + onTap: (message) async { + Navigator.of(context).pop(); + + final reminder = await showDialog( + context: context, + builder: (_) => const CreateReminderDialog(), + ); + + if (reminder == null) return; + final client = StreamChat.of(context).client; + final messageId = message.id; + final remindAt = reminder.remindAt; + + client.createReminder(messageId, remindAt: remindAt).ignore(); + }, + ), + StreamMessageAction( + leading: Icon( + Icons.bookmark_border, + color: colorTheme.textLowEmphasis, + ), + title: const Text('Save for later'), + onTap: (message) { + Navigator.of(context).pop(); + + final client = StreamChat.of(context).client; + final messageId = message.id; + + client.createReminder(messageId).ignore(); + }, + ), + ], + ] + ]; + + return Container( + color: reminder != null ? colorTheme.accentPrimary.withOpacity(.1) : null, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (reminder != null) + Align( + alignment: switch (defaultMessageWidget.reverse) { + true => AlignmentDirectional.centerEnd, + false => AlignmentDirectional.centerStart, + }, + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 16, 8), + child: Row( + spacing: 4, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + size: 16, + Icons.bookmark_rounded, + color: colorTheme.accentPrimary, + ), + Text( + 'Saved for later', + style: textTheme.footnote.copyWith( + color: colorTheme.accentPrimary, + ), + ), + ], + ), + ), + ), + defaultMessageWidget.copyWith( + onReplyTap: _reply, + customActions: customOptions, + onShowMessage: (message, channel) => GoRouter.of(context).goNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: Routes.CHANNEL_PAGE.queryParams(message), + ), + bottomRowBuilderWithDefaultWidget: (_, __, defaultWidget) { + return defaultWidget.copyWith( + deletedBottomRowBuilder: (context, message) { + return const StreamVisibleFootnote(); + }, + ); + }, + ), + // If the message has a reminder, add some space below it. + if (reminder != null) const SizedBox(height: 4), + ], + ), + ); + } + bool defaultFilter(Message m) { final currentUser = StreamChat.of(context).currentUser; final isMyMessage = m.user?.id == currentUser?.id; diff --git a/sample_app/lib/pages/reminders_page.dart b/sample_app/lib/pages/reminders_page.dart new file mode 100644 index 000000000..18507e9e8 --- /dev/null +++ b/sample_app/lib/pages/reminders_page.dart @@ -0,0 +1,518 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:go_router/go_router.dart'; +import 'package:sample_app/routes/routes.dart'; +import 'package:sample_app/widgets/reminder_dialog.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class RemindersPage extends StatefulWidget { + const RemindersPage({super.key}); + + @override + State createState() => _RemindersPageState(); +} + +class _RemindersPageState extends State { + late final controller = StreamMessageReminderListController( + client: StreamChat.of(context).client, + )..eventListener = (event) { + if (event.type == EventType.connectionRecovered || + event.type == EventType.notificationReminderDue) { + // This will create the query filter with the updated current date + // and time, so that the reminders list is updated with the new + // reminders that are due. + onFilterChanged(_currentFilter); + } + + // Returning false as we also want the controller to handle the event. + return false; + }; + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + var _currentFilter = MessageRemindersFilter.all; + void onFilterChanged(MessageRemindersFilter filter) { + setState(() => _currentFilter = filter); + + controller.filter = _currentFilter.queryFilter; + controller.doInitialLoad(); + } + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return NestedScrollView( + headerSliverBuilder: (_, __) => [ + SliverPadding( + padding: const EdgeInsets.all(8), + sliver: SliverToBoxAdapter( + child: MessageRemindersFilterSelection( + selected: _currentFilter, + onSelected: onFilterChanged, + ), + ), + ), + ], + body: RefreshIndicator( + // We are doing a initial load on the controller instead of + // refresh because refreshing will also reset the current active + // filter, which we don't want. + onRefresh: controller.doInitialLoad, + child: PagedValueListView( + controller: controller, + separatorBuilder: (_, __, ___) => const Divider(height: 1), + itemBuilder: (context, reminders, index) { + final reminder = reminders[index]; + final theme = StreamChatTheme.of(context); + + return Slidable( + groupTag: 'reminder-actions', + endActionPane: ActionPane( + extentRatio: 0.4, + motion: const BehindMotion(), + children: [ + CustomSlidableAction( + backgroundColor: theme.colorTheme.inputBg, + child: StreamSvgIcon( + size: 24, + icon: StreamSvgIcons.edit, + color: theme.colorTheme.accentPrimary, + ), + onPressed: (_) async { + final rem = await showDialog( + context: context, + builder: (context) => EditReminderDialog( + isBookmarkReminder: reminder.remindAt == null, + ), + ); + + if (rem == null) return; + final client = StreamChat.of(context).client; + + client.updateReminder( + reminder.messageId, + remindAt: rem.remindAt, + ); + }, + ), + CustomSlidableAction( + backgroundColor: theme.colorTheme.inputBg, + child: StreamSvgIcon( + size: 24, + icon: StreamSvgIcons.delete, + color: theme.colorTheme.accentError, + ), + onPressed: (context) { + final client = StreamChat.of(context).client; + final messageId = reminder.messageId; + + client.deleteReminder(messageId).ignore(); + }, + ), + ], + ), + child: MessageReminderListTile( + reminder: reminder, + onReminderTap: () { + final client = StreamChat.of(context).client; + + final cid = reminder.channelCid; + final [type, id] = cid.split(':'); + final channel = client.channel(type, id: id); + + GoRouter.of(context).goNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: switch (reminder.message) { + final it? => Routes.CHANNEL_PAGE.queryParams(it), + _ => const {}, + }, + ); + }, + ), + ); + }, + emptyBuilder: (context) { + final chatThemeData = StreamChatTheme.of(context); + return Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon( + size: 48, + Icons.bookmark_border_rounded, + color: theme.colorTheme.textLowEmphasis, + ), + emptyTitle: Text( + 'No reminders yet', + style: chatThemeData.textTheme.headline, + ), + ), + ); + }, + loadMoreErrorBuilder: (context, error) => ListTile( + onTap: controller.retry, + title: Text(error.message), + ), + loadMoreIndicatorBuilder: (context) => const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator.adaptive(), + ), + ), + loadingBuilder: (context) => const Center( + child: CircularProgressIndicator.adaptive(), + ), + errorBuilder: (context, error) => Center( + child: Column( + spacing: 8, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + size: 48, + Icons.bookmark_border_rounded, + color: theme.colorTheme.textLowEmphasis, + ), + Text( + 'Error loading reminders', + style: theme.textTheme.headline, + ), + FilledButton( + onPressed: controller.doInitialLoad, + style: FilledButton.styleFrom( + backgroundColor: theme.colorTheme.accentPrimary, + ), + child: Text( + 'Retry loading', + style: theme.textTheme.body.copyWith( + color: Colors.white, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +enum MessageRemindersFilter { + all('All'), + overdue('Overdue'), + upcoming('Upcoming'), + scheduled('Scheduled'), + savedForLater('Saved for later'); + + const MessageRemindersFilter(this.label); + final String label; + + Filter get queryFilter { + const key = 'remind_at'; + final now = DateTime.timestamp().toIso8601String(); + return switch (this) { + MessageRemindersFilter.all => const Filter.empty(), + MessageRemindersFilter.overdue => Filter.lessOrEqual(key, now), + MessageRemindersFilter.upcoming => Filter.greaterOrEqual(key, now), + MessageRemindersFilter.scheduled => Filter.exists(key), + MessageRemindersFilter.savedForLater => Filter.notExists(key), + }; + } +} + +class MessageRemindersFilterSelection extends StatefulWidget { + const MessageRemindersFilterSelection({ + super.key, + required this.selected, + required this.onSelected, + }); + + final MessageRemindersFilter selected; + final ValueSetter onSelected; + + @override + State createState() => + _MessageRemindersFilterSelectionState(); +} + +class _MessageRemindersFilterSelectionState + extends State { + final _filterKeys = {}; + + @override + void initState() { + super.initState(); + // Initialize keys for each filter + for (final filter in MessageRemindersFilter.values) { + _filterKeys[filter] = GlobalKey(); + } + + // Schedule a post-frame callback to scroll to the initial selection + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToSelectedFilter(); + }); + } + + @override + void didUpdateWidget(MessageRemindersFilterSelection oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.selected != oldWidget.selected) { + // Scroll when the selection changes + _scrollToSelectedFilter(); + } + } + + void _scrollToSelectedFilter() { + final currentContext = _filterKeys[widget.selected]?.currentContext; + if (currentContext == null) return; + + // Use ensureVisible for simpler, more reliable scrolling + Scrollable.ensureVisible( + currentContext, + alignment: 0.5, // Center the item (0.0 is start, 1.0 is end) + duration: const Duration(milliseconds: 300), + ); + } + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + ...MessageRemindersFilter.values.map((filter) { + final isSelected = filter == widget.selected; + return FilterChip( + key: _filterKeys[filter], + showCheckmark: false, + label: Text(filter.label), + labelStyle: theme.textTheme.footnote.copyWith( + color: switch (isSelected) { + true => Colors.white, + false => theme.colorTheme.textHighEmphasis, + }, + ), + selected: isSelected, + onSelected: (_) => widget.onSelected.call(filter), + backgroundColor: theme.colorTheme.inputBg, + selectedColor: theme.colorTheme.accentPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ); + }), + ], + ), + ); + } +} + +class MessageReminderListTile extends StatelessWidget { + const MessageReminderListTile({ + super.key, + required this.reminder, + this.onReminderTap, + }); + + final MessageReminder reminder; + final VoidCallback? onReminderTap; + + @override + Widget build(BuildContext context) { + final channel = reminder.channel; + final channelName = channel?.formatName(currentUser: reminder.user); + + return InkWell( + onTap: onReminderTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), + child: Column( + spacing: 4, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MessageReminderChannelName(channelName: channelName), + MessageReminderStatus(remindAt: reminder.remindAt), + ], + ), + MessageReminderMessageText(message: reminder.message), + ], + ), + ), + ); + } +} + +class MessageReminderChannelName extends StatelessWidget { + const MessageReminderChannelName({ + super.key, + this.channelName, + }); + + final String? channelName; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return Text( + maxLines: 1, + '# ${channelName ?? context.translations.noTitleText}', + overflow: TextOverflow.ellipsis, + style: theme.textTheme.headlineBold, + ); + } +} + +class MessageReminderMessageText extends StatelessWidget { + const MessageReminderMessageText({ + super.key, + this.message, + }); + + final Message? message; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return Text( + maxLines: 2, + message?.text ?? context.translations.noTitleText, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.body, + ); + } +} + +class MessageReminderStatus extends StatelessWidget { + const MessageReminderStatus({super.key, this.remindAt}); + + final DateTime? remindAt; + + @override + Widget build(BuildContext context) { + return switch (remindAt) { + final remindAt? => TimedReminderIndicator(remindAt: remindAt), + null => const SavedForLaterIndicator(), + }; + } +} + +class SavedForLaterIndicator extends StatelessWidget { + const SavedForLaterIndicator({super.key}); + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + return Icon( + Icons.bookmark_rounded, + color: theme.colorTheme.accentPrimary, + ); + } +} + +/// Displays a timed reminder indicator with customized styling based on status. +class TimedReminderIndicator extends StatefulWidget { + const TimedReminderIndicator({ + super.key, + required this.remindAt, + }); + + final DateTime remindAt; + + @override + State createState() => _TimedReminderIndicatorState(); +} + +class _TimedReminderIndicatorState extends State { + Timer? _timer; + late var _lastRemindAt = widget.remindAt; + + @override + void initState() { + super.initState(); + _scheduleNextUpdate(); + } + + @override + void didUpdateWidget(TimedReminderIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.remindAt != _lastRemindAt) { + _lastRemindAt = widget.remindAt; + _resetTimer(); + } + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _resetTimer() { + _timer?.cancel(); + _scheduleNextUpdate(); + } + + void _scheduleNextUpdate() { + final updateInterval = _calculateUpdateInterval(); + _timer = Timer(updateInterval, _onTimerComplete); + } + + Duration _calculateUpdateInterval() { + final now = DateTime.now(); + final difference = _lastRemindAt.difference(now).abs(); + + if (difference.inDays > 0) return const Duration(days: 1); + if (difference.inHours > 0) return const Duration(hours: 1); + return const Duration(minutes: 1); + } + + void _onTimerComplete() { + if (mounted) { + setState(() {}); + _scheduleNextUpdate(); + } + } + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + final remindAtDuration = Jiffy.parseFromDateTime(_lastRemindAt); + final isOverdue = remindAtDuration.isBefore(Jiffy.now()); + + final fromNow = remindAtDuration.fromNow(withPrefixAndSuffix: false); + final (color, label) = switch (isOverdue) { + true => (theme.colorTheme.accentError, 'Overdue by $fromNow'), + false => (theme.colorTheme.accentPrimary, 'Due in $fromNow'), + }; + + return Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(24), + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: Text( + label, + style: theme.textTheme.footnoteBold.copyWith(color: Colors.white), + ), + ); + } +} diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index ba1ad6a0b..805f5f6dd 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -57,7 +57,10 @@ class _ChannelList extends State { client: StreamChat.of(context).client, filter: Filter.in_('members', [StreamChat.of(context).currentUser!.id]), channelStateSort: [ - const SortOption(ChannelSortKey.pinnedAt), + const SortOption( + ChannelSortKey.pinnedAt, + nullOrdering: NullOrdering.nullsLast, + ), const SortOption(ChannelSortKey.lastMessageAt), ], limit: 30, diff --git a/sample_app/lib/widgets/reminder_dialog.dart b/sample_app/lib/widgets/reminder_dialog.dart new file mode 100644 index 000000000..421491f2c --- /dev/null +++ b/sample_app/lib/widgets/reminder_dialog.dart @@ -0,0 +1,102 @@ +import 'package:flutter/cupertino.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +sealed class ReminderOption { + const ReminderOption(this.remindAt); + final DateTime? remindAt; +} + +final class ScheduledReminder extends ReminderOption { + const ScheduledReminder(super.remindAt); +} + +final class BookmarkReminder extends ReminderOption { + const BookmarkReminder() : super(null); +} + +class CreateReminderDialog extends StatelessWidget { + const CreateReminderDialog({super.key}); + + static const _remindAtDurations = [ + Duration(minutes: 2), + Duration(minutes: 5), + Duration(minutes: 30), + Duration(hours: 1), + ]; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return CupertinoTheme( + data: CupertinoTheme.of(context).copyWith( + primaryColor: theme.colorTheme.accentPrimary, + ), + child: CupertinoAlertDialog( + title: const Text('Select Reminder Time'), + content: const Text('When would you like to be reminded?'), + actions: [ + ..._remindAtDurations.map((duration) { + final remindAt = Jiffy.now().addDuration(duration); + return CupertinoDialogAction( + onPressed: () { + final option = ScheduledReminder(remindAt.dateTime); + Navigator.of(context).pop(option); + }, + child: Text(remindAt.fromNow()), + ); + }), + ], + ), + ); + } +} + +class EditReminderDialog extends StatelessWidget { + const EditReminderDialog({ + super.key, + this.isBookmarkReminder = false, + }); + + final bool isBookmarkReminder; + + static const _remindAtDurations = [ + Duration(minutes: 2), + Duration(minutes: 5), + Duration(minutes: 30), + Duration(hours: 1), + ]; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return CupertinoTheme( + data: CupertinoTheme.of(context).copyWith( + primaryColor: theme.colorTheme.accentPrimary, + ), + child: CupertinoAlertDialog( + title: const Text('Edit Reminder Time'), + actions: [ + ..._remindAtDurations.map((duration) { + final remindAt = Jiffy.now().addDuration(duration); + return CupertinoDialogAction( + onPressed: () { + final option = ScheduledReminder(remindAt.dateTime); + Navigator.of(context).pop(option); + }, + child: Text(remindAt.fromNow()), + ); + }), + if (!isBookmarkReminder) + CupertinoDialogAction( + onPressed: () { + Navigator.of(context).pop(const BookmarkReminder()); + }, + child: const Text('Clear due date'), + ), + ], + ), + ); + } +}