diff --git a/melos.yaml b/melos.yaml index b3abdab213..20e1942f9a 100644 --- a/melos.yaml +++ b/melos.yaml @@ -25,6 +25,7 @@ command: # List of all the dependencies used in the project. dependencies: async: ^2.11.0 + avatar_glow: ^3.0.0 cached_network_image: ^3.3.1 chewie: ^1.8.1 collection: ^1.17.2 @@ -44,6 +45,8 @@ command: file_selector: ^1.0.3 flutter_app_badger: ^1.5.0 flutter_local_notifications: ^18.0.1 + flutter_map: ^8.1.1 + flutter_map_animations: ^0.9.0 flutter_markdown: ^0.7.2+1 flutter_portal: ^1.1.4 flutter_secure_storage: ^9.2.2 @@ -51,6 +54,7 @@ command: flutter_svg: ^2.0.10+1 freezed_annotation: ">=2.4.1 <4.0.0" gal: ^2.3.1 + geolocator: ^13.0.0 get_thumbnail_video: ^0.7.3 go_router: ^14.6.2 http_parser: ^4.0.2 @@ -60,6 +64,7 @@ command: jose: ^0.3.4 json_annotation: ^4.9.0 just_audio: ">=0.9.38 <0.11.0" + latlong2: ^0.9.1 logging: ^1.2.0 lottie: ^3.1.2 media_kit: ^1.1.10+1 diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index b388c37cdc..48d6b13264 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,14 @@ +## Upcoming Beta + +✅ Added + +- Added comprehensive location sharing support with static and live location features: + - `Channel.sendStaticLocation()` - Send a static location message to the channel + - `Channel.startLiveLocationSharing()` - Start sharing live location with automatic updates + - `Channel.activeLiveLocations` - Track members active live location shares in the channel + - `Client.activeLiveLocations` - Access current user active live location shares across channels + - Location event listeners for `locationShared`, `locationUpdated`, and `locationExpired` events + ## Upcoming 🐞 Fixed diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 009047def6..987eba539a 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -1088,6 +1088,72 @@ class Channel { return _client.deleteDraft(id!, type, parentId: parentId); } + /// Sends a static location to this channel. + /// + /// Optionally, provide a [messageText] and [extraData] to send along with + /// the location. + Future sendStaticLocation({ + String? id, + String? messageText, + required String createdByDeviceId, + required LocationCoordinates location, + Map extraData = const {}, + }) { + final message = Message( + id: id, + text: messageText, + extraData: extraData, + ); + + final currentUserId = _client.state.currentUser?.id; + final locationMessage = message.copyWith( + sharedLocation: Location( + channelCid: cid, + userId: currentUserId, + messageId: message.id, + latitude: location.latitude, + longitude: location.longitude, + createdByDeviceId: createdByDeviceId, + ), + ); + + return sendMessage(locationMessage); + } + + /// Sends a live location sharing message to this channel. + /// + /// Optionally, provide a [messageText] and [extraData] to send along with + /// the location. + Future startLiveLocationSharing({ + String? id, + String? messageText, + required DateTime endSharingAt, + required String createdByDeviceId, + required LocationCoordinates location, + Map extraData = const {}, + }) { + final message = Message( + id: id, + text: messageText, + extraData: extraData, + ); + + final currentUserId = _client.state.currentUser?.id; + final locationMessage = message.copyWith( + sharedLocation: Location( + channelCid: cid, + userId: currentUserId, + messageId: message.id, + endAt: endSharingAt, + latitude: location.latitude, + longitude: location.longitude, + createdByDeviceId: createdByDeviceId, + ), + ); + + return sendMessage(locationMessage); + } + /// Send a file to this channel. Future sendFile( AttachmentFile file, { @@ -1186,7 +1252,7 @@ class Channel { /// Optionally provide a [messageText] to send a message along with the poll. Future sendPoll( Poll poll, { - String messageText = '', + String? messageText, }) async { _checkInitialized(); final res = await _pollLock.synchronized(() => _client.createPoll(poll)); @@ -2115,12 +2181,16 @@ class ChannelClientState { /* End of draft events */ - _listenReactions(); + _listenReactionNew(); + + _listenReactionUpdated(); _listenReactionDeleted(); /* Start of poll events */ + _listenPollCreated(); + _listenPollUpdated(); _listenPollClosed(); @@ -2167,10 +2237,22 @@ class ChannelClientState { /* End of reminder events */ + /* Start of location events */ + + _listenLocationShared(); + + _listenLocationUpdated(); + + _listenLocationExpired(); + + /* End of location events */ + _startCleaningStaleTypingEvents(); _startCleaningStalePinnedMessages(); + _startCleaningExpiredLocations(); + _channel._client.chatPersistenceClient ?.getChannelThreads(_channel.cid!) .then((threads) { @@ -2449,6 +2531,17 @@ class ChannelClientState { return threadMessage; } + void _listenPollCreated() { + _subscriptions.add( + _channel.on(EventType.pollCreated).listen((event) { + final message = event.message; + if (message == null || message.poll == null) return; + + return addNewMessage(message); + }), + ); + } + void _listenPollUpdated() { _subscriptions.add(_channel.on(EventType.pollUpdated).listen((event) { final eventPoll = event.poll; @@ -2711,58 +2804,144 @@ class ChannelClientState { } } + Message? _findLocationMessage(String id) { + final message = messages.firstWhereOrNull((it) { + return it.sharedLocation?.messageId == id; + }); + + if (message != null) return message; + + final threadMessage = threads.values.flattened.firstWhereOrNull((it) { + return it.sharedLocation?.messageId == id; + }); + + return threadMessage; + } + + void _listenLocationShared() { + _subscriptions.add( + _channel.on(EventType.locationShared).listen((event) { + final message = event.message; + if (message == null || message.sharedLocation == null) return; + + return addNewMessage(message); + }), + ); + } + + void _listenLocationUpdated() { + _subscriptions.add( + _channel.on(EventType.locationUpdated).listen((event) { + final location = event.message?.sharedLocation; + if (location == null) return; + + final messageId = location.messageId; + if (messageId == null) return; + + final oldMessage = _findLocationMessage(messageId); + if (oldMessage == null) return; + + final updatedMessage = oldMessage.copyWith(sharedLocation: location); + return updateMessage(updatedMessage); + }), + ); + } + + void _listenLocationExpired() { + _subscriptions.add( + _channel.on(EventType.locationExpired).listen((event) { + final location = event.message?.sharedLocation; + if (location == null) return; + + final messageId = location.messageId; + if (messageId == null) return; + + final oldMessage = _findLocationMessage(messageId); + if (oldMessage == null) return; + + final updatedMessage = oldMessage.copyWith(sharedLocation: location); + return updateMessage(updatedMessage); + }), + ); + } + void _listenReactionDeleted() { - _subscriptions.add(_channel.on(EventType.reactionDeleted).listen((event) { - final oldMessage = - messages.firstWhereOrNull((it) => it.id == event.message?.id) ?? - threads[event.message?.parentId] - ?.firstWhereOrNull((e) => e.id == event.message?.id); - final reaction = event.reaction; - final ownReactions = oldMessage?.ownReactions - ?.whereNot((it) => - it.type == reaction?.type && - it.score == reaction?.score && - it.messageId == reaction?.messageId && - it.userId == reaction?.userId && - it.extraData == reaction?.extraData) - .toList(growable: false); - final message = event.message!.copyWith( - ownReactions: ownReactions, - ); - updateMessage(message); - })); + _subscriptions.add( + _channel.on(EventType.reactionDeleted).listen((event) { + final eventMessage = event.message; + if (eventMessage == null) return; + + final reaction = event.reaction; + if (reaction == null) return; + + final messageId = eventMessage.id; + final parentId = eventMessage.parentId; + + for (final message in [...messages, ...?threads[parentId]]) { + if (message.id == messageId) { + final updatedOwnReactions = message.ownReactions?.where((it) { + return it.userId != reaction.userId || it.type != reaction.type; + }); + + return updateMessage( + eventMessage.copyWith( + ownReactions: updatedOwnReactions?.toList(), + ), + ); + } + } + }), + ); } - void _listenReactions() { + void _listenReactionNew() { _subscriptions.add(_channel.on(EventType.reactionNew).listen((event) { - final oldMessage = - messages.firstWhereOrNull((it) => it.id == event.message?.id) ?? - threads[event.message?.parentId] - ?.firstWhereOrNull((e) => e.id == event.message?.id); - final message = event.message!.copyWith( - ownReactions: oldMessage?.ownReactions, - ); - updateMessage(message); + final eventMessage = event.message; + if (eventMessage == null) return; + + final messageId = eventMessage.id; + final parentId = eventMessage.parentId; + + for (final message in [...messages, ...?threads[parentId]]) { + if (message.id == messageId) { + return updateMessage( + eventMessage.copyWith( + ownReactions: message.ownReactions, + ), + ); + } + } })); } + void _listenReactionUpdated() { + _subscriptions.add( + _channel.on(EventType.reactionUpdated).listen((event) { + final eventMessage = event.message; + if (eventMessage == null) return; + + final messageId = eventMessage.id; + final parentId = eventMessage.parentId; + + for (final message in [...messages, ...?threads[parentId]]) { + if (message.id == messageId) { + return updateMessage( + eventMessage.copyWith( + ownReactions: message.ownReactions, + ), + ); + } + } + }), + ); + } + void _listenMessageUpdated() { - _subscriptions.add(_channel - .on( - EventType.messageUpdated, - EventType.reactionUpdated, - ) - .listen((event) { - final oldMessage = - messages.firstWhereOrNull((it) => it.id == event.message?.id) ?? - threads[event.message?.parentId] - ?.firstWhereOrNull((e) => e.id == event.message?.id); - final message = event.message!.copyWith( - poll: oldMessage?.poll, - pollId: oldMessage?.pollId, - ownReactions: oldMessage?.ownReactions, - ); - updateMessage(message); + _subscriptions.add(_channel.on(EventType.messageUpdated).listen((event) { + final message = event.message; + if (message == null) return; + + return updateMessage(message); })); } @@ -2771,7 +2950,7 @@ class ChannelClientState { final message = event.message!; final hardDelete = event.hardDelete ?? false; - deleteMessage(message, hardDelete: hardDelete); + return deleteMessage(message, hardDelete: hardDelete); })); } @@ -2785,19 +2964,22 @@ class ChannelClientState { final message = event.message; if (message == null) return; - final isThreadMessage = message.parentId != null; - final isShownInChannel = message.showInChannel == true; - final isThreadOnlyMessage = isThreadMessage && !isShownInChannel; + return addNewMessage(message); + })); + } - // Only add the message if the channel is upToDate or if the message is - // a thread-only message. - if (isUpToDate || isThreadOnlyMessage) { - updateMessage(message); - } + /// Adds a new message to the channel state and updates the unread count. + void addNewMessage(Message message) { + final isThreadMessage = message.parentId != null; + final isShownInChannel = message.showInChannel == true; + final isThreadOnlyMessage = isThreadMessage && !isShownInChannel; - // Otherwise, check if we can count the message as unread. - if (_countMessageAsUnread(message)) unreadCount += 1; - })); + // Only add the message if the channel is upToDate or if the message is + // a thread-only message. + if (isUpToDate || isThreadOnlyMessage) updateMessage(message); + + // Otherwise, check if we can count the message as unread. + if (_countMessageAsUnread(message)) unreadCount += 1; } // Logic taken from the backend SDK @@ -2929,9 +3111,6 @@ class ChannelClientState { newMessages.add(message); } - // Handle updates to pinned messages. - final newPinnedMessages = _updatePinnedMessages(message); - // Calculate the new last message at time. var lastMessageAt = _channelState.channel?.lastMessageAt; lastMessageAt ??= message.createdAt; @@ -2942,17 +3121,45 @@ class ChannelClientState { // Apply the updated lists to the channel state. _channelState = _channelState.copyWith( messages: newMessages.sorted(_sortByCreatedAt), - pinnedMessages: newPinnedMessages, channel: _channelState.channel?.copyWith( lastMessageAt: lastMessageAt, ), ); } - // If the message is part of a thread, update thread information. + // If the message is part of a thread, update thread message. if (message.parentId case final parentId?) { - updateThreadInfo(parentId, [message]); + _updateThreadMessage(parentId, message); } + + // Handle updates to pinned messages. + final newPinnedMessages = _updatePinnedMessages(message); + + // Handle updates to the active live locations. + final newActiveLiveLocations = _updateActiveLiveLocations(message); + + // Update the channel state with the new pinned messages and + // active live locations. + _channelState = _channelState.copyWith( + pinnedMessages: newPinnedMessages, + activeLiveLocations: newActiveLiveLocations, + ); + } + + void _updateThreadMessage(String parentId, Message message) { + final existingThreadMessages = threads[parentId] ?? []; + final updatedThreadMessages = [ + ...existingThreadMessages.merge( + [message], + key: (it) => it.id, + update: (original, updated) => updated.syncWith(original), + ), + ]; + + _threads = { + ...threads, + parentId: updatedThreadMessages.sorted(_sortByCreatedAt) + }; } /// Cleans up all the stale error messages which requires no action. @@ -2968,70 +3175,122 @@ class ChannelClientState { /// Updates the list of pinned messages based on the current message's /// pinned status. List _updatePinnedMessages(Message message) { - final newPinnedMessages = [...pinnedMessages]; - final oldPinnedIndex = - newPinnedMessages.indexWhere((m) => m.id == message.id); - - if (message.pinned) { - // If the message is pinned, add or update it in the list of pinned - // messages. - if (oldPinnedIndex != -1) { - newPinnedMessages[oldPinnedIndex] = message; - } else { - newPinnedMessages.add(message); - } - } else { - // If the message is not pinned, remove it from the list of pinned - // messages. - newPinnedMessages.removeWhere((m) => m.id == message.id); + final existingPinnedMessages = [...pinnedMessages]; + + // If the message is pinned and not yet deleted, + // merge it into the existing pinned messages. + if (message.pinned && !message.isDeleted) { + final newPinnedMessages = [ + ...existingPinnedMessages.merge( + [message], + key: (it) => it.id, + update: (old, updated) => updated, + ), + ]; + + return newPinnedMessages; } + // Otherwise, remove the message from the pinned messages. + final newPinnedMessages = [ + ...existingPinnedMessages.where((m) => m.id != message.id), + ]; + return newPinnedMessages; } + List _updateActiveLiveLocations(Message message) { + final existingLocations = [...activeLiveLocations]; + + final location = message.sharedLocation; + if (location == null) return existingLocations; + + // If the location is live and active and not yet deleted, + // merge it into the existing active live locations. + if (location.isLive && location.isActive && !message.isDeleted) { + final newActiveLiveLocations = [ + ...existingLocations.merge( + [location], + key: (it) => (it.userId, it.channelCid, it.createdByDeviceId), + update: (old, updated) => updated, + ), + ]; + + return [...newActiveLiveLocations.where((it) => it.isActive)]; + } + + // Otherwise, remove the location from the active live locations. + final newActiveLiveLocations = [ + ...existingLocations.where((it) => it.messageId != location.messageId), + ]; + + return [...newActiveLiveLocations.where((it) => it.isActive)]; + } + /// Remove a [message] from this [channelState]. void removeMessage(Message message) async { await _channel._client.chatPersistenceClient?.deleteMessageById(message.id); - final parentId = message.parentId; - // i.e. it's a thread message, Remove it - if (parentId != null) { - final newThreads = {...threads}; - // Early return in case the thread is not available - if (!newThreads.containsKey(parentId)) return; - - // Remove thread message shown in thread page. - newThreads.update( - parentId, - (messages) => [...messages.where((e) => e.id != message.id)], - ); + if (message.parentId == null || message.showInChannel == true) { + // Remove regular message, thread message shown in channel + var updatedMessages = [...messages.where((e) => e.id != message.id)]; + + // Remove quoted message reference from every message if available. + updatedMessages = [ + ...updatedMessages.map((it) { + // Early return if the message doesn't have a quoted message. + if (it.quotedMessageId != message.id) return it; - _threads = newThreads; + // Setting it to null will remove the quoted message from the message. + return it.copyWith(quotedMessage: null, quotedMessageId: null); + }), + ]; - // Early return if the thread message is not shown in channel. - if (message.showInChannel == false) return; + _channelState = _channelState.copyWith( + messages: updatedMessages.sorted(_sortByCreatedAt), + ); } - // Remove regular message, thread message shown in channel - var updatedMessages = [...messages]..removeWhere((e) => e.id == message.id); + if (message.parentId case final parentId?) { + // If the message is a thread message, remove it from the threads. + _removeThreadMessage(message, parentId); + } - // Remove quoted message reference from every message if available. - updatedMessages = [...updatedMessages].map((it) { - // Early return if the message doesn't have a quoted message. - if (it.quotedMessageId != message.id) return it; + // If the message is pinned, remove it from the pinned messages. + final updatedPinnedMessages = [ + ...pinnedMessages.where((it) => it.id != message.id), + ]; - // Setting it to null will remove the quoted message from the message. - return it.copyWith( - quotedMessage: null, - quotedMessageId: null, - ); - }).toList(); + // If the message is a live location, update the active live locations. + final updatedActiveLiveLocations = [ + ...activeLiveLocations.where((it) => it.messageId != message.id), + ]; _channelState = _channelState.copyWith( - messages: updatedMessages, + pinnedMessages: updatedPinnedMessages, + activeLiveLocations: updatedActiveLiveLocations, ); } + void _removeThreadMessage(Message message, String parentId) { + final existingThreadMessages = threads[parentId] ?? []; + final updatedThreadMessages = [ + ...existingThreadMessages.where((it) => it.id != message.id), + ]; + + // If there are no more messages in the thread, remove the thread entry. + if (updatedThreadMessages.isEmpty) { + _threads = {...threads}..remove(parentId); + return; + } + + // Otherwise, update the thread messages. + _threads = { + ...threads, + parentId: updatedThreadMessages.sorted(_sortByCreatedAt), + }; + } + /// Removes/Updates the [message] based on the [hardDelete] value. void deleteMessage(Message message, {bool hardDelete = false}) { if (hardDelete) return removeMessage(message); @@ -3144,6 +3403,16 @@ class ChannelClientState { (watchers, users) => [...?watchers?.map((e) => users[e.id] ?? e)], ).distinct(const ListEquality().equals); + /// Channel active live locations. + List get activeLiveLocations { + return _channelState.activeLiveLocations ?? []; + } + + /// Channel active live locations as a stream. + Stream> get activeLiveLocationsStream => channelStateStream + .map((cs) => cs.activeLiveLocations ?? []) + .distinct(const ListEquality().equals); + /// Channel draft. Draft? get draft => _channelState.draft; @@ -3312,6 +3581,7 @@ class ChannelClientState { read: newReads, draft: updatedState.draft, pinnedMessages: updatedState.pinnedMessages, + activeLiveLocations: updatedState.activeLiveLocations, ); } @@ -3351,19 +3621,19 @@ class ChannelClientState { /// Update threads with updated information about messages. void updateThreadInfo(String parentId, List messages) { - final newThreads = {...threads}..update( - parentId, - (original) => [ - ...original.merge( - messages, - key: (message) => message.id, - update: (original, updated) => updated.syncWith(original), - ), - ].sorted(_sortByCreatedAt), - ifAbsent: () => messages.sorted(_sortByCreatedAt), - ); + final existingThreadMessages = threads[parentId] ?? []; + final updatedThreadMessages = [ + ...existingThreadMessages.merge( + messages, + key: (it) => it.id, + update: (original, updated) => updated.syncWith(original), + ), + ]; - _threads = newThreads; + _threads = { + ...threads, + parentId: updatedThreadMessages.sorted(_sortByCreatedAt) + }; } Draft? _getThreadDraft(String parentId, List? messages) { @@ -3478,6 +3748,42 @@ class ChannelClientState { ); } + Timer? _staleLiveLocationsCleanerTimer; + void _startCleaningExpiredLocations() { + _staleLiveLocationsCleanerTimer?.cancel(); + _staleLiveLocationsCleanerTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { + final currentUserId = _channel._client.state.currentUser?.id; + if (currentUserId == null) return; + + final expired = activeLiveLocations.where((it) => it.isExpired); + if (expired.isEmpty) return; + + for (final sharedLocation in expired) { + // Skip if the location is shared by the current user, + // as we are already handling them in the client. + if (sharedLocation.userId == currentUserId) continue; + + final lastUpdatedAt = DateTime.timestamp(); + final locationExpiredEvent = Event( + type: EventType.locationExpired, + cid: sharedLocation.channelCid, + message: Message( + id: sharedLocation.messageId, + updatedAt: lastUpdatedAt, + sharedLocation: sharedLocation.copyWith( + updatedAt: lastUpdatedAt, + ), + ), + ); + + _channel._client.handleEvent(locationExpiredEvent); + } + }, + ); + } + /// Call this method to dispose this object. void dispose() { _debouncedUpdatePersistenceChannelState.cancel(); @@ -3488,6 +3794,7 @@ class ChannelClientState { _threadsController.close(); _staleTypingEventsCleanerTimer?.cancel(); _stalePinnedMessagesCleanerTimer?.cancel(); + _staleLiveLocationsCleanerTimer?.cancel(); _typingEventsController.close(); } } @@ -3679,4 +3986,9 @@ extension ChannelCapabilityCheck on Channel { bool get canQueryPollVotes { return ownCapabilities.contains(ChannelCapability.queryPollVotes); } + + /// True, if the current user can share location in the channel. + bool get canShareLocation { + return ownCapabilities.contains(ChannelCapability.shareLocation); + } } diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index a55530ef8a..4bb038c792 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -25,6 +25,8 @@ import 'package:stream_chat/src/core/models/draft.dart'; import 'package:stream_chat/src/core/models/draft_message.dart'; 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/location.dart'; +import 'package:stream_chat/src/core/models/location_coordinates.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'; @@ -35,6 +37,7 @@ import 'package:stream_chat/src/core/models/poll_vote.dart'; import 'package:stream_chat/src/core/models/thread.dart'; import 'package:stream_chat/src/core/models/user.dart'; import 'package:stream_chat/src/core/util/event_controller.dart'; +import 'package:stream_chat/src/core/util/extension.dart'; import 'package:stream_chat/src/core/util/utils.dart'; import 'package:stream_chat/src/db/chat_persistence_client.dart'; import 'package:stream_chat/src/event_type.dart'; @@ -229,8 +232,12 @@ class StreamChatClient { Stream get eventStream => _eventController.stream; late final _eventController = EventController( resolvers: [ + event_resolvers.pollCreatedResolver, event_resolvers.pollAnswerCastedResolver, event_resolvers.pollAnswerRemovedResolver, + event_resolvers.locationSharedResolver, + event_resolvers.locationUpdatedResolver, + event_resolvers.locationExpiredResolver, ], ); @@ -1771,6 +1778,51 @@ class StreamChatClient { pagination: pagination, ); + /// Retrieves all the active live locations of the current user. + Future getActiveLiveLocations() async { + try { + final response = await _chatApi.user.getActiveLiveLocations(); + + // Update the active live locations in the state. + final activeLiveLocations = response.activeLiveLocations; + state.activeLiveLocations = activeLiveLocations; + + return response; + } catch (e, stk) { + logger.severe('Error getting active live locations', e, stk); + rethrow; + } + } + + /// Updates an existing live location created by the current user. + Future updateLiveLocation({ + required String messageId, + required String createdByDeviceId, + LocationCoordinates? location, + DateTime? endAt, + }) { + return _chatApi.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, + ); + } + + /// Expire an existing live location created by the current user. + Future stopLiveLocation({ + required String messageId, + required String createdByDeviceId, + }) { + return updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + // Passing the current time as endAt will mark the location as expired + // and make it inactive. + endAt: DateTime.timestamp(), + ); + } + /// Enables slow mode Future enableSlowdown( String channelId, @@ -2087,6 +2139,14 @@ class ClientState { _listenUserUpdated(); _listenAllChannelsRead(); + + _listenLocationShared(); + + _listenLocationUpdated(); + + _listenLocationExpired(); + + _startCleaningExpiredLocations(); } /// Stops listening to the client events. @@ -2174,6 +2234,103 @@ class ClientState { ); } + void _listenLocationShared() { + _eventsSubscription?.add( + _client.on(EventType.locationShared).listen((event) { + final location = event.message?.sharedLocation; + if (location == null || location.isStatic) return; + + final currentUserId = currentUser?.id; + if (currentUserId == null) return; + if (location.userId != currentUserId) return; + + final newActiveLiveLocations = [ + ...activeLiveLocations.merge( + [location], + key: (it) => (it.userId, it.channelCid, it.createdByDeviceId), + update: (original, updated) => updated, + ), + ]; + + activeLiveLocations = newActiveLiveLocations; + }), + ); + } + + void _listenLocationUpdated() { + _eventsSubscription?.add( + _client.on(EventType.locationUpdated).listen((event) { + final location = event.message?.sharedLocation; + if (location == null || location.isStatic) return; + + final currentUserId = currentUser?.id; + if (currentUserId == null) return; + if (location.userId != currentUserId) return; + + final newActiveLiveLocations = [ + ...activeLiveLocations.merge( + [location], + key: (it) => (it.userId, it.channelCid, it.createdByDeviceId), + update: (original, updated) => updated, + ), + ]; + + activeLiveLocations = newActiveLiveLocations; + }), + ); + } + + void _listenLocationExpired() { + _eventsSubscription?.add( + _client.on(EventType.locationExpired).listen((event) { + final location = event.message?.sharedLocation; + if (location == null || location.isStatic) return; + + final currentUserId = currentUser?.id; + if (currentUserId == null) return; + if (location.userId != currentUserId) return; + + final newActiveLiveLocations = [ + ...activeLiveLocations.where( + (it) => it.messageId != location.messageId, + ) + ]; + + activeLiveLocations = newActiveLiveLocations; + }), + ); + } + + Timer? _staleLiveLocationsCleanerTimer; + void _startCleaningExpiredLocations() { + _staleLiveLocationsCleanerTimer?.cancel(); + _staleLiveLocationsCleanerTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { + final expired = activeLiveLocations.where((it) => it.isExpired); + if (expired.isEmpty) return; + + for (final sharedLocation in expired) { + final lastUpdatedAt = DateTime.timestamp(); + + final locationExpiredEvent = Event( + type: EventType.locationExpired, + cid: sharedLocation.channelCid, + message: Message( + id: sharedLocation.messageId, + updatedAt: lastUpdatedAt, + sharedLocation: sharedLocation.copyWith( + updatedAt: lastUpdatedAt, + ), + ), + ); + + _client.handleEvent(locationExpiredEvent); + } + }, + ); + } + final StreamChatClient _client; /// Sets the user currently interacting with the client @@ -2208,6 +2365,23 @@ class ClientState { /// The current user as a stream Stream> get usersStream => _usersController.stream; + /// The current active live locations shared by the user. + List get activeLiveLocations { + return _activeLiveLocationsController.value; + } + + /// The current active live locations shared by the user as a stream. + Stream> get activeLiveLocationsStream { + return _activeLiveLocationsController.stream; + } + + /// Sets the active live locations. + set activeLiveLocations(List locations) { + // For safe-keeping, we filter out any inactive locations before update. + final activeLocations = [...locations.where((it) => it.isActive)]; + _activeLiveLocationsController.add(activeLocations); + } + /// The current unread channels count int get unreadChannels => _unreadChannelsController.value; @@ -2280,14 +2454,18 @@ class ClientState { final _unreadChannelsController = BehaviorSubject.seeded(0); final _unreadThreadsController = BehaviorSubject.seeded(0); final _totalUnreadCountController = BehaviorSubject.seeded(0); + final _activeLiveLocationsController = BehaviorSubject.seeded([]); /// Call this method to dispose this object void dispose() { cancelEventSubscription(); _currentUserController.close(); + _usersController.close(); _unreadChannelsController.close(); _unreadThreadsController.close(); _totalUnreadCountController.close(); + _activeLiveLocationsController.close(); + _staleLiveLocationsCleanerTimer?.cancel(); final channels = [...this.channels.keys]; for (final channel in channels) { diff --git a/packages/stream_chat/lib/src/client/event_resolvers.dart b/packages/stream_chat/lib/src/client/event_resolvers.dart index fc2117fb58..6f462f6260 100644 --- a/packages/stream_chat/lib/src/client/event_resolvers.dart +++ b/packages/stream_chat/lib/src/client/event_resolvers.dart @@ -1,6 +1,27 @@ import 'package:stream_chat/src/core/models/event.dart'; import 'package:stream_chat/src/event_type.dart'; +/// Resolves message new events into more specific `pollCreated` events +/// for easier downstream state handling. +/// +/// Applies when: +/// - `event.type` is `messageNew` or `notification.message_new, and +/// - `event.poll` is not null +/// +/// Returns a modified event with type `pollCreated`, +/// or `null` if not applicable. +Event? pollCreatedResolver(Event event) { + final validTypes = {EventType.messageNew, EventType.notificationMessageNew}; + if (!validTypes.contains(event.type)) return null; + + final poll = event.poll; + if (poll == null) return null; + + // If the event is a message new or notification message new and + // it contains a poll, we can resolve it to a poll created event. + return event.copyWith(type: EventType.pollCreated); +} + /// Resolves casted or changed poll vote events into more specific /// `pollAnswerCasted` events for easier downstream state handling. /// @@ -11,12 +32,15 @@ import 'package:stream_chat/src/event_type.dart'; /// Returns a modified event with type `pollAnswerCasted`, /// or `null` if not applicable. Event? pollAnswerCastedResolver(Event event) { - return switch (event.type) { - EventType.pollVoteCasted || - EventType.pollVoteChanged when event.pollVote?.isAnswer == true => - event.copyWith(type: EventType.pollAnswerCasted), - _ => null, - }; + final validTypes = {EventType.pollVoteCasted, EventType.pollVoteChanged}; + if (!validTypes.contains(event.type)) return null; + + final pollVote = event.pollVote; + if (pollVote?.isAnswer == false) return null; + + // If the event is a poll vote casted or changed and it's an answer + // we can resolve it to a poll answer casted event. + return event.copyWith(type: EventType.pollAnswerCasted); } /// Resolves removed poll vote events into more specific @@ -29,9 +53,77 @@ Event? pollAnswerCastedResolver(Event event) { /// Returns a modified event with type `pollAnswerRemoved`, /// or `null` if not applicable. Event? pollAnswerRemovedResolver(Event event) { - return switch (event.type) { - EventType.pollVoteRemoved when event.pollVote?.isAnswer == true => - event.copyWith(type: EventType.pollAnswerRemoved), - _ => null, - }; + if (event.type != EventType.pollVoteRemoved) return null; + + final pollVote = event.pollVote; + if (pollVote?.isAnswer == false) return null; + + // If the event is a poll vote removed and it's an answer + // we can resolve it to a poll answer removed event. + return event.copyWith(type: EventType.pollAnswerRemoved); +} + +/// Resolves message new events into more specific `locationShared` events +/// for easier downstream state handling. +/// +/// Applies when: +/// - `event.type` is `messageNew` or `notification.message_new, and +/// - `event.message.sharedLocation` is not null +/// +/// Returns a modified event with type `locationShared`, +/// or `null` if not applicable. +Event? locationSharedResolver(Event event) { + final validTypes = {EventType.messageNew, EventType.notificationMessageNew}; + if (!validTypes.contains(event.type)) return null; + + final sharedLocation = event.message?.sharedLocation; + if (sharedLocation == null) return null; + + // If the event is a message new or notification message new and it + // contains a shared location, we can resolve it to a location shared event. + return event.copyWith(type: EventType.locationShared); +} + +/// Resolves message updated events into more specific `locationUpdated` +/// events for easier downstream state handling. +/// +/// Applies when: +/// - `event.type` is `messageUpdated`, and +/// - `event.message.sharedLocation` is not null and not expired +/// +/// Returns a modified event with type `locationUpdated`, +/// or `null` if not applicable. +Event? locationUpdatedResolver(Event event) { + if (event.type != EventType.messageUpdated) return null; + + final sharedLocation = event.message?.sharedLocation; + if (sharedLocation == null) return null; + + if (sharedLocation.isLive && sharedLocation.isExpired) return null; + + // If the location is static or still active, we can resolve it + // to a location updated event. + return event.copyWith(type: EventType.locationUpdated); +} + +/// Resolves message updated events into more specific `locationExpired` +/// events for easier downstream state handling. +/// +/// Applies when: +/// - `event.type` is `messageUpdated`, and +/// - `event.message.sharedLocation` is not null and expired +/// +/// Returns a modified event with type `locationExpired`, +/// or `null` if not applicable. +Event? locationExpiredResolver(Event event) { + if (event.type != EventType.messageUpdated) return null; + + final sharedLocation = event.message?.sharedLocation; + if (sharedLocation == null) return null; + + if (sharedLocation.isStatic || sharedLocation.isActive) return null; + + // If the location is live and expired, we can resolve it to a + // location expired event. + return event.copyWith(type: EventType.locationExpired); } diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index 0602e71f35..d8f38c7b55 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -9,6 +9,7 @@ import 'package:stream_chat/src/core/models/channel_state.dart'; import 'package:stream_chat/src/core/models/device.dart'; 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/location.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'; @@ -802,3 +803,14 @@ class QueryRemindersResponse extends _BaseResponse { static QueryRemindersResponse fromJson(Map json) => _$QueryRemindersResponseFromJson(json); } + +/// Model response for [StreamChatClient.updateDraft] api call +@JsonSerializable(createToJson: false) +class GetActiveLiveLocationsResponse extends _BaseResponse { + /// List of active live locations returned by the api call + late List activeLiveLocations; + + /// Create a new instance from a json + static GetActiveLiveLocationsResponse fromJson(Map json) => + _$GetActiveLiveLocationsResponseFromJson(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 75dcc1595e..fb0ae89527 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -467,3 +467,11 @@ QueryRemindersResponse _$QueryRemindersResponseFromJson( .toList() ?? [] ..next = json['next'] as String?; + +GetActiveLiveLocationsResponse _$GetActiveLiveLocationsResponseFromJson( + Map json) => + GetActiveLiveLocationsResponse() + ..duration = json['duration'] as String? + ..activeLiveLocations = (json['active_live_locations'] as List) + .map((e) => Location.fromJson(e as Map)) + .toList(); diff --git a/packages/stream_chat/lib/src/core/api/user_api.dart b/packages/stream_chat/lib/src/core/api/user_api.dart index c94c344bec..b08f2a96ed 100644 --- a/packages/stream_chat/lib/src/core/api/user_api.dart +++ b/packages/stream_chat/lib/src/core/api/user_api.dart @@ -5,6 +5,8 @@ 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/location.dart'; +import 'package:stream_chat/src/core/models/location_coordinates.dart'; import 'package:stream_chat/src/core/models/user.dart'; /// Defines the api dedicated to users operations @@ -88,4 +90,34 @@ class UserApi { return BlockedUsersResponse.fromJson(response.data); } + + /// Retrieves all the active live locations of the current user. + Future getActiveLiveLocations() async { + final response = await _client.get( + '/users/live_locations', + ); + + return GetActiveLiveLocationsResponse.fromJson(response.data); + } + + /// Updates an existing live location created by the current user. + Future updateLiveLocation({ + required String messageId, + required String createdByDeviceId, + LocationCoordinates? location, + DateTime? endAt, + }) async { + final response = await _client.put( + '/users/live_locations', + data: json.encode({ + 'message_id': messageId, + 'created_by_device_id': createdByDeviceId, + if (location?.latitude case final latitude) 'latitude': latitude, + if (location?.longitude case final longitude) 'longitude': longitude, + if (endAt != null) 'end_at': endAt.toIso8601String(), + }), + ); + + return Location.fromJson(response.data); + } } 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 2bcf0a1ed5..489b71e334 100644 --- a/packages/stream_chat/lib/src/core/models/channel_config.dart +++ b/packages/stream_chat/lib/src/core/models/channel_config.dart @@ -26,6 +26,7 @@ class ChannelConfig { this.urlEnrichment = false, this.skipLastMsgUpdateForSystemMsgs = false, this.userMessageReminders = false, + this.sharedLocations = false, }) : createdAt = createdAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(); @@ -91,6 +92,9 @@ class ChannelConfig { /// True if the user can set reminders for messages in this channel. final bool userMessageReminders; + /// True if shared locations are enabled for this channel. + final bool sharedLocations; + /// 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 8cb758b875..1a57d3e743 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 @@ -34,6 +34,7 @@ ChannelConfig _$ChannelConfigFromJson(Map json) => skipLastMsgUpdateForSystemMsgs: json['skip_last_msg_update_for_system_msgs'] as bool? ?? false, userMessageReminders: json['user_message_reminders'] as bool? ?? false, + sharedLocations: json['shared_locations'] as bool? ?? false, ); Map _$ChannelConfigToJson(ChannelConfig instance) => @@ -57,4 +58,5 @@ Map _$ChannelConfigToJson(ChannelConfig instance) => 'skip_last_msg_update_for_system_msgs': instance.skipLastMsgUpdateForSystemMsgs, 'user_message_reminders': instance.userMessageReminders, + 'shared_locations': instance.sharedLocations, }; diff --git a/packages/stream_chat/lib/src/core/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart index d55f0e42b5..49a3c99bf2 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -366,4 +366,7 @@ extension type const ChannelCapability(String capability) implements String { /// Ability to query poll votes. static const queryPollVotes = ChannelCapability('query-poll-votes'); + + /// Ability to share location. + static const shareLocation = ChannelCapability('share-location'); } diff --git a/packages/stream_chat/lib/src/core/models/channel_state.dart b/packages/stream_chat/lib/src/core/models/channel_state.dart index ae7b9062b6..3e06a86d79 100644 --- a/packages/stream_chat/lib/src/core/models/channel_state.dart +++ b/packages/stream_chat/lib/src/core/models/channel_state.dart @@ -2,6 +2,7 @@ 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/draft.dart'; +import 'package:stream_chat/src/core/models/location.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/read.dart'; @@ -29,6 +30,7 @@ class ChannelState implements ComparableFieldProvider { this.read, this.membership, this.draft, + this.activeLiveLocations, }); /// The channel to which this state belongs @@ -58,6 +60,9 @@ class ChannelState implements ComparableFieldProvider { /// The draft message for this channel if it exists. final Draft? draft; + /// The list of active live locations in the channel. + final List? activeLiveLocations; + /// Create a new instance from a json static ChannelState fromJson(Map json) => _$ChannelStateFromJson(json); @@ -76,6 +81,7 @@ class ChannelState implements ComparableFieldProvider { List? read, Member? membership, Object? draft = _nullConst, + List? activeLiveLocations, }) => ChannelState( channel: channel ?? this.channel, @@ -87,6 +93,7 @@ class ChannelState implements ComparableFieldProvider { read: read ?? this.read, membership: membership ?? this.membership, draft: draft == _nullConst ? this.draft : draft as Draft?, + activeLiveLocations: activeLiveLocations ?? this.activeLiveLocations, ); @override diff --git a/packages/stream_chat/lib/src/core/models/channel_state.g.dart b/packages/stream_chat/lib/src/core/models/channel_state.g.dart index 88bd81e77e..99d5098b2e 100644 --- a/packages/stream_chat/lib/src/core/models/channel_state.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_state.g.dart @@ -32,6 +32,9 @@ ChannelState _$ChannelStateFromJson(Map json) => ChannelState( draft: json['draft'] == null ? null : Draft.fromJson(json['draft'] as Map), + activeLiveLocations: (json['active_live_locations'] as List?) + ?.map((e) => Location.fromJson(e as Map)) + .toList(), ); Map _$ChannelStateToJson(ChannelState instance) => @@ -46,4 +49,6 @@ Map _$ChannelStateToJson(ChannelState instance) => 'read': instance.read?.map((e) => e.toJson()).toList(), 'membership': instance.membership?.toJson(), 'draft': instance.draft?.toJson(), + 'active_live_locations': + instance.activeLiveLocations?.map((e) => e.toJson()).toList(), }; diff --git a/packages/stream_chat/lib/src/core/models/location.dart b/packages/stream_chat/lib/src/core/models/location.dart new file mode 100644 index 0000000000..57085e9894 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/location.dart @@ -0,0 +1,156 @@ +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/location_coordinates.dart'; +import 'package:stream_chat/src/core/models/message.dart'; + +part 'location.g.dart'; + +/// {@template location} +/// A model class representing a shared location. +/// +/// The [Location] represents a location shared in a channel message. +/// +/// It can be of two types: +/// 1. **Static Location**: A location that does not change over time and has +/// no end time. +/// 2. **Live Location**: A location that updates in real-time and has an +/// end time. +/// {@endtemplate} +@JsonSerializable() +class Location extends Equatable { + /// {@macro location} + Location({ + this.channelCid, + this.channel, + this.messageId, + this.message, + this.userId, + required this.latitude, + required this.longitude, + required this.createdByDeviceId, + this.endAt, + DateTime? createdAt, + DateTime? updatedAt, + }) : createdAt = createdAt ?? DateTime.timestamp(), + updatedAt = updatedAt ?? DateTime.timestamp(); + + /// Create a new instance from a json + factory Location.fromJson(Map json) => + _$LocationFromJson(json); + + /// The channel CID where the message exists. + /// + /// This is only available if the location is coming from server response. + @JsonKey(includeToJson: false) + final String? channelCid; + + /// The channel where the message exists. + @JsonKey(includeToJson: false) + final ChannelModel? channel; + + /// The ID of the message that contains the shared location. + @JsonKey(includeToJson: false) + final String? messageId; + + /// The message that contains the shared location. + @JsonKey(includeToJson: false) + final Message? message; + + /// The ID of the user who shared the location. + @JsonKey(includeToJson: false) + final String? userId; + + /// The latitude of the shared location. + final double latitude; + + /// The longitude of the shared location. + final double longitude; + + /// The ID of the device that created the reminder. + final String createdByDeviceId; + + /// The date at which the shared location will end. + @JsonKey(includeIfNull: false) + final DateTime? endAt; + + /// The date at which the reminder was created. + @JsonKey(includeToJson: false) + final DateTime createdAt; + + /// The date at which the reminder was last updated. + @JsonKey(includeToJson: false) + final DateTime updatedAt; + + /// Returns true if the live location is still active (end_at > now) + bool get isActive { + final endAt = this.endAt; + if (endAt == null) return false; + + return endAt.isAfter(DateTime.now()); + } + + /// Returns true if the live location is expired (end_at <= now) + bool get isExpired => !isActive; + + /// Returns true if this is a live location (has end_at) + bool get isLive => endAt != null; + + /// Returns true if this is a static location (no end_at) + bool get isStatic => endAt == null; + + /// Returns the coordinates of the shared location. + LocationCoordinates get coordinates { + return LocationCoordinates( + latitude: latitude, + longitude: longitude, + ); + } + + /// Serialize to json + Map toJson() => _$LocationToJson(this); + + /// Creates a copy of [Location] with specified attributes overridden. + Location copyWith({ + String? channelCid, + ChannelModel? channel, + String? messageId, + Message? message, + String? userId, + double? latitude, + double? longitude, + String? createdByDeviceId, + DateTime? endAt, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Location( + channelCid: channelCid ?? this.channelCid, + channel: channel ?? this.channel, + messageId: messageId ?? this.messageId, + message: message ?? this.message, + userId: userId ?? this.userId, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + createdByDeviceId: createdByDeviceId ?? this.createdByDeviceId, + endAt: endAt ?? this.endAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + List get props => [ + channelCid, + channel, + messageId, + message, + userId, + latitude, + longitude, + createdByDeviceId, + endAt, + createdAt, + updatedAt, + ]; +} diff --git a/packages/stream_chat/lib/src/core/models/location.g.dart b/packages/stream_chat/lib/src/core/models/location.g.dart new file mode 100644 index 0000000000..e5a3b49028 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/location.g.dart @@ -0,0 +1,38 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'location.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Location _$LocationFromJson(Map json) => Location( + 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?, + latitude: (json['latitude'] as num).toDouble(), + longitude: (json['longitude'] as num).toDouble(), + createdByDeviceId: json['created_by_device_id'] as String, + endAt: json['end_at'] == null + ? null + : DateTime.parse(json['end_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 _$LocationToJson(Location instance) => { + 'latitude': instance.latitude, + 'longitude': instance.longitude, + 'created_by_device_id': instance.createdByDeviceId, + if (instance.endAt?.toIso8601String() case final value?) 'end_at': value, + }; diff --git a/packages/stream_chat/lib/src/core/models/location_coordinates.dart b/packages/stream_chat/lib/src/core/models/location_coordinates.dart new file mode 100644 index 0000000000..f23389d7e6 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/location_coordinates.dart @@ -0,0 +1,33 @@ +import 'package:equatable/equatable.dart'; + +/// {@template locationInfo} +/// A model class representing a location with latitude and longitude. +/// {@endtemplate} +class LocationCoordinates extends Equatable { + /// {@macro locationInfo} + const LocationCoordinates({ + required this.latitude, + required this.longitude, + }); + + /// The latitude of the location. + final double latitude; + + /// The longitude of the location. + final double longitude; + + /// Creates a copy of [LocationCoordinates] with specified attributes + /// overridden. + LocationCoordinates copyWith({ + double? latitude, + double? longitude, + }) { + return LocationCoordinates( + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + ); + } + + @override + List get props => [latitude, longitude]; +} diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 93847c11af..e517bad464 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/location.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'; @@ -69,6 +70,7 @@ class Message extends Equatable implements ComparableFieldProvider { this.moderation, this.draft, this.reminder, + this.sharedLocation, }) : id = id ?? const Uuid().v4(), type = MessageType(type), pinExpires = pinExpires?.toUtc(), @@ -307,13 +309,22 @@ class Message extends Equatable implements ComparableFieldProvider { /// Optional draft message linked to this message. /// /// This is present when the message is a thread i.e. contains replies. + @JsonKey(includeToJson: false) final Draft? draft; /// Optional reminder for this message. /// /// This is present when a user has set a reminder for this message. + @JsonKey(includeToJson: false) final MessageReminder? reminder; + /// Optional shared location associated with this message. + /// + /// This is used to share a location in a message, allowing users to view the + /// location on a map. + @JsonKey(includeIfNull: false) + final Location? sharedLocation; + /// Message custom extraData. final Map extraData; @@ -362,6 +373,7 @@ class Message extends Equatable implements ComparableFieldProvider { 'moderation_details', 'draft', 'reminder', + 'shared_location', ]; /// Serialize to json. @@ -424,6 +436,7 @@ class Message extends Equatable implements ComparableFieldProvider { Moderation? moderation, Object? draft = _nullConst, Object? reminder = _nullConst, + Location? sharedLocation, }) { assert(() { if (pinExpires is! DateTime && @@ -506,6 +519,7 @@ class Message extends Equatable implements ComparableFieldProvider { draft: draft == _nullConst ? this.draft : draft as Draft?, reminder: reminder == _nullConst ? this.reminder : reminder as MessageReminder?, + sharedLocation: sharedLocation ?? this.sharedLocation, ); } @@ -551,6 +565,7 @@ class Message extends Equatable implements ComparableFieldProvider { moderation: other.moderation, draft: other.draft, reminder: other.reminder, + sharedLocation: other.sharedLocation, ); } @@ -616,6 +631,7 @@ class Message extends Equatable implements ComparableFieldProvider { moderation, draft, reminder, + sharedLocation, ]; @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 cc90809bd9..84912eb1c3 100644 --- a/packages/stream_chat/lib/src/core/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -95,6 +95,9 @@ Message _$MessageFromJson(Map json) => Message( reminder: json['reminder'] == null ? null : MessageReminder.fromJson(json['reminder'] as Map), + sharedLocation: json['shared_location'] == null + ? null + : Location.fromJson(json['shared_location'] as Map), ); Map _$MessageToJson(Message instance) => { @@ -112,7 +115,7 @@ Map _$MessageToJson(Message instance) => { 'poll_id': instance.pollId, if (instance.restrictedVisibility case final value?) 'restricted_visibility': value, - 'draft': instance.draft?.toJson(), - 'reminder': instance.reminder?.toJson(), + if (instance.sharedLocation?.toJson() case final value?) + 'shared_location': value, 'extra_data': instance.extraData, }; diff --git a/packages/stream_chat/lib/src/event_type.dart b/packages/stream_chat/lib/src/event_type.dart index 29ab5b1de8..189350d959 100644 --- a/packages/stream_chat/lib/src/event_type.dart +++ b/packages/stream_chat/lib/src/event_type.dart @@ -122,6 +122,9 @@ class EventType { /// Event sent when the AI indicator is cleared static const String aiIndicatorClear = 'ai_indicator.clear'; + /// Event sent when a new poll is created. + static const String pollCreated = 'poll.created'; + /// Event sent when a poll is updated. static const String pollUpdated = 'poll.updated'; @@ -170,4 +173,13 @@ class EventType { /// Event sent when a message reminder is due. static const String notificationReminderDue = 'notification.reminder_due'; + + /// Event sent when a new shared location is created. + static const String locationShared = 'location.shared'; + + /// Event sent when a live shared location is updated. + static const String locationUpdated = 'location.updated'; + + /// Event sent when a live shared location is expired. + static const String locationExpired = 'location.expired'; } diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index ea60b484b8..0093a7c6b6 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -42,6 +42,8 @@ export 'src/core/models/draft.dart'; export 'src/core/models/draft_message.dart'; export 'src/core/models/event.dart'; export 'src/core/models/filter.dart' show Filter; +export 'src/core/models/location.dart'; +export 'src/core/models/location_coordinates.dart'; export 'src/core/models/member.dart'; export 'src/core/models/message.dart'; export 'src/core/models/message_reminder.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 9eec788ae6..662cf61908 100644 --- a/packages/stream_chat/test/fixtures/channel_state_to_json.json +++ b/packages/stream_chat/test/fixtures/channel_state_to_json.json @@ -29,9 +29,7 @@ "poll_id": null, "restricted_visibility": [ "user-id-3" - ], - "draft": null, - "reminder": null + ] }, { "id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f", @@ -46,9 +44,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", @@ -63,9 +59,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35", @@ -80,9 +74,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", @@ -97,9 +89,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc", @@ -114,9 +104,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53", @@ -131,9 +119,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240", @@ -148,9 +134,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42", @@ -165,9 +149,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa", @@ -182,9 +164,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0", @@ -199,9 +179,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356", @@ -216,9 +194,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", @@ -233,9 +209,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4", @@ -250,9 +224,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", @@ -267,9 +239,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d", @@ -284,9 +254,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", @@ -301,9 +269,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3", @@ -318,9 +284,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf", @@ -335,9 +299,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73", @@ -352,9 +314,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854", @@ -369,9 +329,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480", @@ -386,9 +344,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be", @@ -403,9 +359,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40", @@ -420,9 +374,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c", @@ -437,12 +389,11 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null } ], "pinned_messages": [], "members": [], + "active_live_locations": [], "watcher_count": 5 } diff --git a/packages/stream_chat/test/fixtures/message_to_json.json b/packages/stream_chat/test/fixtures/message_to_json.json index 8803f1ed1d..c4568628d3 100644 --- a/packages/stream_chat/test/fixtures/message_to_json.json +++ b/packages/stream_chat/test/fixtures/message_to_json.json @@ -26,7 +26,5 @@ "restricted_visibility": [ "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 fb011ded70..4573a74cf8 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -462,6 +462,109 @@ void main() { }); }); + group('`.sendStaticLocation`', () { + const deviceId = 'test-device-id'; + const locationId = 'test-location-id'; + const coordinates = LocationCoordinates( + latitude: 40.7128, + longitude: -74.0060, + ); + + test('should create a static location and call sendMessage', () async { + when( + () => client.sendMessage(any(), channelId, channelType), + ).thenAnswer( + (_) async => SendMessageResponse() + ..message = Message( + id: locationId, + text: 'Location shared', + extraData: const {'custom': 'data'}, + sharedLocation: Location( + channelCid: channel.cid, + messageId: locationId, + userId: client.state.currentUser?.id, + latitude: coordinates.latitude, + longitude: coordinates.longitude, + createdByDeviceId: deviceId, + ), + ), + ); + + final response = await channel.sendStaticLocation( + id: locationId, + messageText: 'Location shared', + createdByDeviceId: deviceId, + location: coordinates, + extraData: {'custom': 'data'}, + ); + + expect(response, isNotNull); + expect(response.message.id, locationId); + expect(response.message.text, 'Location shared'); + expect(response.message.extraData['custom'], 'data'); + expect(response.message.sharedLocation, isNotNull); + + verify( + () => client.sendMessage(any(), channelId, channelType), + ).called(1); + }); + }); + + group('`.startLiveLocationSharing`', () { + const deviceId = 'test-device-id'; + const locationId = 'test-location-id'; + final endSharingAt = DateTime.now().add(const Duration(hours: 1)); + const coordinates = LocationCoordinates( + latitude: 40.7128, + longitude: -74.0060, + ); + + test( + 'should create message with live location and call sendMessage', + () async { + when( + () => client.sendMessage(any(), channelId, channelType), + ).thenAnswer( + (_) async => SendMessageResponse() + ..message = Message( + id: locationId, + text: 'Location shared', + extraData: const {'custom': 'data'}, + sharedLocation: Location( + channelCid: channel.cid, + messageId: locationId, + userId: client.state.currentUser?.id, + latitude: coordinates.latitude, + longitude: coordinates.longitude, + createdByDeviceId: deviceId, + endAt: endSharingAt, + ), + ), + ); + + final response = await channel.startLiveLocationSharing( + id: locationId, + messageText: 'Location shared', + createdByDeviceId: deviceId, + location: coordinates, + endSharingAt: endSharingAt, + extraData: {'custom': 'data'}, + ); + + expect(response, isNotNull); + expect(response.message.id, locationId); + expect(response.message.text, 'Location shared'); + expect(response.message.extraData['custom'], 'data'); + expect(response.message.sharedLocation, isNotNull); + expect(response.message.sharedLocation?.endAt, endSharingAt); + + verify( + () => client.sendMessage(any(), channelId, channelType), + ).called(1); + }, + ); + }); + group('`.createDraft`', () { final draftMessage = DraftMessage(text: 'Draft message text'); @@ -4815,6 +4918,486 @@ void main() { expect(updatedMessage?.reminder, isNull); }); }); + + group('Location 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 location.shared event', () async { + // Verify initial state + expect(channel.state?.activeLiveLocations, isEmpty); + + // Create live location + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Create location.shared event + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: locationMessage, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if message was added + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message, isNotNull); + + // Check if active live location was updated + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('msg1')); + }); + + test('should handle location.updated event', () async { + // Setup initial state with location message + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial message + channel.state?.addNewMessage(locationMessage); + + // Create updated location + final updatedLocation = liveLocation.copyWith( + latitude: 40.7500, // Updated latitude + longitude: -74.1000, // Updated longitude + ); + + final updatedMessage = locationMessage.copyWith( + sharedLocation: updatedLocation, + ); + + // Create location.updated event + final locationUpdatedEvent = Event( + cid: channel.cid, + type: EventType.locationUpdated, + message: updatedMessage, + ); + + // Dispatch event + client.addEvent(locationUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if message was updated + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation?.latitude, equals(40.7500)); + expect(message?.sharedLocation?.longitude, equals(-74.1000)); + + // Check if active live location was updated + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + expect(activeLiveLocations?.first.longitude, equals(-74.1000)); + }); + + test('should handle location.expired event', () async { + // Setup initial state with location message + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial message + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + // Create expired location + final expiredLocation = liveLocation.copyWith( + endAt: DateTime.now().subtract(const Duration(hours: 1)), + ); + + final expiredMessage = locationMessage.copyWith( + sharedLocation: expiredLocation, + ); + + // Create location.expired event + final locationExpiredEvent = Event( + cid: channel.cid, + type: EventType.locationExpired, + message: expiredMessage, + ); + + // Dispatch event + client.addEvent(locationExpiredEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if message was updated + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation?.isExpired, isTrue); + + // Check if active live location was removed + expect(channel.state?.activeLiveLocations, isEmpty); + }); + + test('should not add static location to active locations', () async { + final staticLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + // No endAt - static location + ); + + final staticMessage = Message( + id: 'msg1', + text: 'Static location shared', + sharedLocation: staticLocation, + ); + + // Create location.shared event + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: staticMessage, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if message was added + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation, isNotNull); + + // Check if active live location was NOT updated (should remain empty) + expect(channel.state?.activeLiveLocations, isEmpty); + }); + + test( + 'should update active locations when location message is deleted', + () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Verify initial state + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + final messageDeletedEvent = Event( + type: EventType.messageDeleted, + cid: channel.cid, + message: locationMessage.copyWith( + type: MessageType.deleted, + deletedAt: DateTime.timestamp(), + ), + ); + + // Dispatch event + client.addEvent(messageDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify active locations are updated + expect(channel.state?.activeLiveLocations, isEmpty); + }, + ); + + test('should merge locations with same key', () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial location for setup + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + // Create new location with same user, channel, and device + final newLocation = Location( + channelCid: channel.cid, + userId: 'user1', // Same user + messageId: 'msg2', // Different message + latitude: 40.7500, + longitude: -74.1000, + createdByDeviceId: 'device1', // Same device + endAt: DateTime.now().add(const Duration(hours: 2)), + ); + + final newMessage = Message( + id: 'msg2', + text: 'Updated location', + sharedLocation: newLocation, + ); + + // Create location.shared event for the new message + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: newMessage, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Should still have only one active location (merged) + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('msg2')); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + }); + + test( + 'should handle multiple active locations from different devices', + () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add first location for setup + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + // Create location from different device + final location2 = Location( + channelCid: channel.cid, + userId: 'user1', // Same user + messageId: 'msg2', + latitude: 34.0522, + longitude: -118.2437, + createdByDeviceId: 'device2', // Different device + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final message2 = Message( + id: 'msg2', + text: 'Location from device 2', + sharedLocation: location2, + ); + + // Create location.shared event for the second message + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: message2, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Should have two active locations + expect(channel.state?.activeLiveLocations, hasLength(2)); + }, + ); + + test('should handle location messages in threads', () async { + final parentMessage = Message( + id: 'parent1', + text: 'Thread parent', + ); + + // Add parent message first for setup + channel.state?.addNewMessage(parentMessage); + + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'thread-msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final threadLocationMessage = Message( + id: 'thread-msg1', + text: 'Live location in thread', + parentId: 'parent1', + sharedLocation: liveLocation, + ); + + // Create location.shared event for the thread message + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: threadLocationMessage, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if thread message was added + final thread = channel.state?.threads['parent1']; + expect(thread, contains(threadLocationMessage)); + + // Check if location was added to active locations + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('thread-msg1')); + }); + + test('should update thread location messages', () async { + final parentMessage = Message( + id: 'parent1', + text: 'Thread parent', + ); + + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'thread-msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final threadLocationMessage = Message( + id: 'thread-msg1', + text: 'Live location in thread', + parentId: 'parent1', + sharedLocation: liveLocation, + ); + + // Add messages + channel.state?.addNewMessage(parentMessage); + channel.state?.addNewMessage(threadLocationMessage); + + // Update the location + final updatedLocation = liveLocation.copyWith( + latitude: 40.7500, + longitude: -74.1000, + ); + + final updatedThreadMessage = threadLocationMessage.copyWith( + sharedLocation: updatedLocation, + ); + + // Create location.updated event for the thread message + final locationUpdatedEvent = Event( + cid: channel.cid, + type: EventType.locationUpdated, + message: updatedThreadMessage, + ); + + // Dispatch event + client.addEvent(locationUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if thread message was updated + final thread = channel.state?.threads['parent1']; + final threadMessage = thread?.firstWhere((m) => m.id == 'thread-msg1'); + expect(threadMessage?.sharedLocation?.latitude, equals(40.7500)); + expect(threadMessage?.sharedLocation?.longitude, equals(-74.1000)); + + // Check if active location was updated + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + expect(activeLiveLocations?.first.longitude, equals(-74.1000)); + }); + }); }); group('ChannelCapabilityCheck', () { @@ -5077,6 +5660,12 @@ void main() { (channel) => channel.canQueryPollVotes, ); + testCapability( + 'ShareLocation', + ChannelCapability.shareLocation, + (channel) => channel.canShareLocation, + ); + test('returns correct values with multiple capabilities', () { final channelState = _generateChannelState( channelId, diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 2b10620968..af851fd8d8 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -2747,6 +2747,361 @@ void main() { verifyNoMoreInteractions(api.moderation); }); + test('`.getActiveLiveLocations`', () async { + final locations = [ + Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ), + Location( + latitude: 34.0522, + longitude: -118.2437, + createdByDeviceId: 'device-2', + endAt: DateTime.now().add(const Duration(hours: 2)), + ), + ]; + + when(() => api.user.getActiveLiveLocations()).thenAnswer( + (_) async => GetActiveLiveLocationsResponse() // + ..activeLiveLocations = locations, + ); + + // Initial state should be empty + expect(client.state.activeLiveLocations, isEmpty); + + final res = await client.getActiveLiveLocations(); + + expect(res, isNotNull); + expect(res.activeLiveLocations, hasLength(2)); + expect(res.activeLiveLocations, equals(locations)); + expect(client.state.activeLiveLocations, equals(locations)); + + verify(() => api.user.getActiveLiveLocations()).called(1); + verifyNoMoreInteractions(api.user); + }); + + test('`.updateLiveLocation`', () async { + const messageId = 'test-message-id'; + const createdByDeviceId = 'test-device-id'; + final endAt = DateTime.timestamp().add(const Duration(hours: 1)); + const location = LocationCoordinates( + latitude: 40.7128, + longitude: -74.0060, + ); + + final expectedLocation = Location( + latitude: location.latitude, + longitude: location.longitude, + createdByDeviceId: createdByDeviceId, + endAt: endAt, + ); + + when( + () => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, + ), + ).thenAnswer((_) async => expectedLocation); + + final res = await client.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, + ); + + expect(res, isNotNull); + expect(res, equals(expectedLocation)); + + verify( + () => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, + ), + ).called(1); + verifyNoMoreInteractions(api.user); + }); + + test('`.stopLiveLocation`', () async { + const messageId = 'test-message-id'; + const createdByDeviceId = 'test-device-id'; + + final expectedLocation = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: createdByDeviceId, + endAt: DateTime.now(), // Should be expired + ); + + when( + () => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + endAt: any(named: 'endAt'), + ), + ).thenAnswer((_) async => expectedLocation); + + final res = await client.stopLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + ); + + expect(res, isNotNull); + expect(res, equals(expectedLocation)); + + verify( + () => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + endAt: any(named: 'endAt'), + ), + ).called(1); + verifyNoMoreInteractions(api.user); + }); + + group('Live Location Event Handling', () { + test('should handle location.shared event', () async { + final location = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: userId, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final event = Event( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location, + ), + ); + + // Initially empty + expect(client.state.activeLiveLocations, isEmpty); + + // Trigger the event + client.handleEvent(event); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + // Should add location to active live locations + final activeLiveLocations = client.state.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations.first.messageId, equals('message-123')); + }); + + test('should handle location.updated event', () async { + final initialLocation = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: userId, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + // Set initial location + client.state.activeLiveLocations = [initialLocation]; + + final updatedLocation = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: userId, + latitude: 40.7500, // Updated latitude + longitude: -74.1000, // Updated longitude + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final event = Event( + type: EventType.locationUpdated, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: updatedLocation, + ), + ); + + // Trigger the event + client.handleEvent(event); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + // Should update the location + final activeLiveLocations = client.state.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations.first.latitude, equals(40.7500)); + expect(activeLiveLocations.first.longitude, equals(-74.1000)); + }); + + test('should handle location.expired event', () async { + final location = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: userId, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + // Set initial location + client.state.activeLiveLocations = [location]; + expect(client.state.activeLiveLocations, hasLength(1)); + + final expiredLocation = location.copyWith( + endAt: DateTime.now().subtract(const Duration(hours: 1)), + ); + + final event = Event( + type: EventType.locationExpired, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: expiredLocation, + ), + ); + + // Trigger the event + client.handleEvent(event); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + // Should remove the location + expect(client.state.activeLiveLocations, isEmpty); + }); + + test('should ignore location events for other users', () async { + final location = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: 'other-user', // Different user + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final event = Event( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location, + ), + ); + + // Trigger the event + client.handleEvent(event); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + // Should not add location from other user + expect(client.state.activeLiveLocations, isEmpty); + }); + + test('should ignore static location events', () async { + final staticLocation = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: userId, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + // No endAt means it's static + ); + + final event = Event( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: staticLocation, + ), + ); + + // Trigger the event + client.handleEvent(event); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + // Should not add static location + expect(client.state.activeLiveLocations, isEmpty); + }); + + test('should merge locations with same key', () async { + final location1 = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: userId, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final location2 = Location( + channelCid: 'test-channel:123', + messageId: 'message-456', + userId: userId, + latitude: 40.7500, + longitude: -74.1000, + createdByDeviceId: 'device-1', // Same device, should merge + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final event1 = Event( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location1, + ), + ); + + final event2 = Event( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-456', + sharedLocation: location2, + ), + ); + + // Trigger first event + client.handleEvent(event1); + await Future.delayed(Duration.zero); + + final activeLiveLocations = client.state.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations.first.messageId, equals('message-123')); + + // Trigger second event - should merge/update + client.handleEvent(event2); + await Future.delayed(Duration.zero); + + final activeLiveLocations2 = client.state.activeLiveLocations; + expect(activeLiveLocations2, hasLength(1)); + expect(activeLiveLocations2.first.messageId, equals('message-456')); + }); + }); + test('`.markAllRead`', () async { when(() => api.channel.markAllRead()) .thenAnswer((_) async => EmptyResponse()); diff --git a/packages/stream_chat/test/src/client/event_resolvers_test.dart b/packages/stream_chat/test/src/client/event_resolvers_test.dart new file mode 100644 index 0000000000..0d8155ccf4 --- /dev/null +++ b/packages/stream_chat/test/src/client/event_resolvers_test.dart @@ -0,0 +1,669 @@ +// ignore_for_file: avoid_redundant_argument_values, lines_longer_than_80_chars + +import 'package:stream_chat/src/client/event_resolvers.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/location.dart'; +import 'package:stream_chat/src/core/models/message.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'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:test/test.dart'; + +void main() { + group('Poll resolver events', () { + group('pollCreatedResolver', () { + test('should resolve messageNew event with poll to pollCreated', () { + final poll = Poll( + id: 'poll-123', + name: 'Test Poll', + options: const [ + PollOption(id: 'option-1', text: 'Option 1'), + PollOption(id: 'option-2', text: 'Option 2'), + ], + ); + + final event = Event( + type: EventType.messageNew, + poll: poll, + cid: 'channel-123', + ); + + final resolved = pollCreatedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.pollCreated); + expect(resolved.poll, equals(poll)); + expect(resolved.cid, equals('channel-123')); + }); + + test( + 'should resolve notificationMessageNew event with poll to pollCreated', + () { + final poll = Poll( + id: 'poll-123', + name: 'Test Poll', + options: const [ + PollOption(id: 'option-1', text: 'Option 1'), + ], + ); + + final event = Event( + type: EventType.notificationMessageNew, + poll: poll, + cid: 'channel-123', + ); + + final resolved = pollCreatedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.pollCreated); + expect(resolved.poll, equals(poll)); + }, + ); + + test('should return null for messageNew event without poll', () { + final event = Event( + type: EventType.messageNew, + cid: 'channel-123', + ); + + final resolved = pollCreatedResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for invalid event types', () { + final poll = Poll( + id: 'poll-123', + name: 'Test Poll', + options: const [ + PollOption(id: 'option-1', text: 'Option 1'), + ], + ); + + final event = Event( + type: EventType.messageUpdated, + poll: poll, + cid: 'channel-123', + ); + + final resolved = pollCreatedResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for event with null poll', () { + final event = Event( + type: EventType.messageNew, + poll: null, + cid: 'channel-123', + ); + + final resolved = pollCreatedResolver(event); + + expect(resolved, isNull); + }); + }); + + group('pollAnswerCastedResolver', () { + test( + 'should resolve pollVoteCasted event with answer to pollAnswerCasted', + () { + final pollVote = PollVote( + id: 'vote-123', + answerText: 'My answer', + pollId: 'poll-123', + ); + + final event = Event( + type: EventType.pollVoteCasted, + pollVote: pollVote, + cid: 'channel-123', + ); + + final resolved = pollAnswerCastedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.pollAnswerCasted); + expect(resolved.pollVote, equals(pollVote)); + expect(resolved.cid, equals('channel-123')); + }, + ); + + test( + 'should resolve pollVoteChanged event with answer to pollAnswerCasted', + () { + final pollVote = PollVote( + id: 'vote-123', + answerText: 'My updated answer', + pollId: 'poll-123', + ); + + final event = Event( + type: EventType.pollVoteChanged, + pollVote: pollVote, + cid: 'channel-123', + ); + + final resolved = pollAnswerCastedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.pollAnswerCasted); + expect(resolved.pollVote, equals(pollVote)); + }, + ); + + test('should return null for pollVoteCasted event with option vote', () { + final pollVote = PollVote( + id: 'vote-123', + optionId: 'option-1', + pollId: 'poll-123', + ); + + final event = Event( + type: EventType.pollVoteCasted, + pollVote: pollVote, + cid: 'channel-123', + ); + + final resolved = pollAnswerCastedResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for invalid event types', () { + final pollVote = PollVote( + id: 'vote-123', + answerText: 'My answer', + pollId: 'poll-123', + ); + + final event = Event( + type: EventType.pollVoteRemoved, + pollVote: pollVote, + cid: 'channel-123', + ); + + final resolved = pollAnswerCastedResolver(event); + + expect(resolved, isNull); + }); + + test('should return resolved event for event with null pollVote', () { + final event = Event( + type: EventType.pollVoteCasted, + pollVote: null, + cid: 'channel-123', + ); + + final resolved = pollAnswerCastedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.pollAnswerCasted); + expect(resolved.pollVote, isNull); + }); + }); + + group('pollAnswerRemovedResolver', () { + test( + 'should resolve pollVoteRemoved event with answer to pollAnswerRemoved', + () { + final pollVote = PollVote( + id: 'vote-123', + answerText: 'My answer', + pollId: 'poll-123', + ); + + final event = Event( + type: EventType.pollVoteRemoved, + pollVote: pollVote, + cid: 'channel-123', + ); + + final resolved = pollAnswerRemovedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.pollAnswerRemoved); + expect(resolved.pollVote, equals(pollVote)); + expect(resolved.cid, equals('channel-123')); + }, + ); + + test('should return null for pollVoteRemoved event with option vote', () { + final pollVote = PollVote( + id: 'vote-123', + optionId: 'option-1', + pollId: 'poll-123', + ); + + final event = Event( + type: EventType.pollVoteRemoved, + pollVote: pollVote, + cid: 'channel-123', + ); + + final resolved = pollAnswerRemovedResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for invalid event types', () { + final pollVote = PollVote( + id: 'vote-123', + answerText: 'My answer', + pollId: 'poll-123', + ); + + final event = Event( + type: EventType.pollVoteCasted, + pollVote: pollVote, + cid: 'channel-123', + ); + + final resolved = pollAnswerRemovedResolver(event); + + expect(resolved, isNull); + }); + + test('should return resolved event for event with null pollVote', () { + final event = Event( + type: EventType.pollVoteRemoved, + pollVote: null, + cid: 'channel-123', + ); + + final resolved = pollAnswerRemovedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.pollAnswerRemoved); + expect(resolved.pollVote, isNull); + }); + }); + }); + + group('Location resolver events', () { + group('locationSharedResolver', () { + test( + 'should resolve messageNew event with sharedLocation to locationShared', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + ); + + final message = Message( + id: 'message-123', + text: 'Check out this location', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageNew, + message: message, + cid: 'channel-123', + ); + + final resolved = locationSharedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.locationShared); + expect(resolved.message, equals(message)); + expect(resolved.cid, equals('channel-123')); + }, + ); + + test( + 'should resolve notificationMessageNew event with sharedLocation to locationShared', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + ); + + final message = Message( + id: 'message-123', + text: 'Check out this location', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.notificationMessageNew, + message: message, + cid: 'channel-123', + ); + + final resolved = locationSharedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.locationShared); + expect(resolved.message, equals(message)); + }, + ); + + test( + 'should return null for messageNew event without sharedLocation', + () { + final message = Message( + id: 'message-123', + text: 'Just a regular message', + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageNew, + message: message, + cid: 'channel-123', + ); + + final resolved = locationSharedResolver(event); + + expect(resolved, isNull); + }, + ); + + test('should return null for invalid event types', () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + ); + + final message = Message( + id: 'message-123', + text: 'Check out this location', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageUpdated, + message: message, + cid: 'channel-123', + ); + + final resolved = locationSharedResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for event with null message', () { + final event = Event( + type: EventType.messageNew, + message: null, + cid: 'channel-123', + ); + + final resolved = locationSharedResolver(event); + + expect(resolved, isNull); + }); + }); + + group('locationUpdatedResolver', () { + test( + 'should resolve messageUpdated event with active live location to locationUpdated', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final message = Message( + id: 'message-123', + text: 'Live location sharing', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageUpdated, + message: message, + cid: 'channel-123', + ); + + final resolved = locationUpdatedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.locationUpdated); + expect(resolved.message, equals(message)); + expect(resolved.cid, equals('channel-123')); + }, + ); + + test( + 'should resolve messageUpdated event with static location to locationUpdated', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + // No endAt means static location + ); + + final message = Message( + id: 'message-123', + text: 'Static location sharing', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageUpdated, + message: message, + cid: 'channel-123', + ); + + final resolved = locationUpdatedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.locationUpdated); + expect(resolved.message, equals(message)); + }, + ); + + test( + 'should return null for messageUpdated event with expired live location', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + endAt: DateTime.now().subtract(const Duration(hours: 1)), + ); + + final message = Message( + id: 'message-123', + text: 'Expired location sharing', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageUpdated, + message: message, + cid: 'channel-123', + ); + + final resolved = locationUpdatedResolver(event); + + expect(resolved, isNull); + }, + ); + + test('should return null for invalid event types', () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + ); + + final message = Message( + id: 'message-123', + text: 'Check out this location', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageNew, + message: message, + cid: 'channel-123', + ); + + final resolved = locationUpdatedResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for event with null message', () { + final event = Event( + type: EventType.messageUpdated, + message: null, + cid: 'channel-123', + ); + + final resolved = locationUpdatedResolver(event); + + expect(resolved, isNull); + }); + }); + + group('locationExpiredResolver', () { + test( + 'should resolve messageUpdated event with expired live location to locationExpired', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + endAt: DateTime.now().subtract(const Duration(hours: 1)), + ); + + final message = Message( + id: 'message-123', + text: 'Expired location sharing', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageUpdated, + message: message, + cid: 'channel-123', + ); + + final resolved = locationExpiredResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.locationExpired); + expect(resolved.message, equals(message)); + expect(resolved.cid, equals('channel-123')); + }, + ); + + test( + 'should return null for messageUpdated event with active live location', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final message = Message( + id: 'message-123', + text: 'Active location sharing', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageUpdated, + message: message, + cid: 'channel-123', + ); + + final resolved = locationExpiredResolver(event); + + expect(resolved, isNull); + }, + ); + + test( + 'should return null for messageUpdated event with static location', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + // No endAt means static location + ); + + final message = Message( + id: 'message-123', + text: 'Static location sharing', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageUpdated, + message: message, + cid: 'channel-123', + ); + + final resolved = locationExpiredResolver(event); + + expect(resolved, isNull); + }, + ); + + test('should return null for invalid event types', () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + endAt: DateTime.now().subtract(const Duration(hours: 1)), + ); + + final message = Message( + id: 'message-123', + text: 'Expired location sharing', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageNew, + message: message, + cid: 'channel-123', + ); + + final resolved = locationExpiredResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for event with null message', () { + final event = Event( + type: EventType.messageUpdated, + message: null, + cid: 'channel-123', + ); + + final resolved = locationExpiredResolver(event); + + expect(resolved, isNull); + }); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/api/user_api_test.dart b/packages/stream_chat/test/src/core/api/user_api_test.dart index 75b392ebf9..99b724b693 100644 --- a/packages/stream_chat/test/src/core/api/user_api_test.dart +++ b/packages/stream_chat/test/src/core/api/user_api_test.dart @@ -182,4 +182,80 @@ void main() { verify(() => client.get(path)).called(1); verifyNoMoreInteractions(client); }); + + test('getActiveLiveLocations', () async { + const path = '/users/live_locations'; + + when(() => client.get(path)).thenAnswer( + (_) async => successResponse( + path, + data: {'active_live_locations': []}, + ), + ); + + final res = await userApi.getActiveLiveLocations(); + + expect(res, isNotNull); + + verify(() => client.get(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('updateLiveLocation', () async { + const path = '/users/live_locations'; + const messageId = 'test-message-id'; + const createdByDeviceId = 'test-device-id'; + final endAt = DateTime.timestamp().add(const Duration(hours: 1)); + const coordinates = LocationCoordinates( + latitude: 40.7128, + longitude: -74.0060, + ); + + when( + () => client.put( + path, + data: json.encode({ + 'message_id': messageId, + 'created_by_device_id': createdByDeviceId, + 'latitude': coordinates.latitude, + 'longitude': coordinates.longitude, + 'end_at': endAt.toIso8601String(), + }), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'message_id': messageId, + 'created_by_device_id': createdByDeviceId, + 'latitude': coordinates.latitude, + 'longitude': coordinates.longitude, + 'end_at': endAt.toIso8601String(), + }, + ), + ); + + final res = await userApi.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: coordinates, + endAt: endAt, + ); + + expect(res, isNotNull); + + verify( + () => client.put( + path, + data: json.encode({ + 'message_id': messageId, + 'created_by_device_id': createdByDeviceId, + 'latitude': coordinates.latitude, + 'longitude': coordinates.longitude, + 'end_at': endAt.toIso8601String(), + }), + ), + ).called(1); + verifyNoMoreInteractions(client); + }); } diff --git a/packages/stream_chat/test/src/core/models/channel_state_test.dart b/packages/stream_chat/test/src/core/models/channel_state_test.dart index 6a436f1dcf..2d20cc8760 100644 --- a/packages/stream_chat/test/src/core/models/channel_state_test.dart +++ b/packages/stream_chat/test/src/core/models/channel_state_test.dart @@ -59,6 +59,7 @@ void main() { watcherCount: 5, pinnedMessages: [], watchers: [], + activeLiveLocations: [], ); expect( diff --git a/packages/stream_chat/test/src/core/models/location_test.dart b/packages/stream_chat/test/src/core/models/location_test.dart new file mode 100644 index 0000000000..2ddcfbaf5d --- /dev/null +++ b/packages/stream_chat/test/src/core/models/location_test.dart @@ -0,0 +1,200 @@ +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/location.dart'; +import 'package:stream_chat/src/core/models/location_coordinates.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:test/test.dart'; + +void main() { + group('Location', () { + const latitude = 37.7749; + const longitude = -122.4194; + const createdByDeviceId = 'device_123'; + + final location = Location( + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + ); + + test('should create a valid instance with minimal parameters', () { + expect(location.latitude, equals(latitude)); + expect(location.longitude, equals(longitude)); + expect(location.createdByDeviceId, equals(createdByDeviceId)); + expect(location.endAt, isNull); + expect(location.channelCid, isNull); + expect(location.channel, isNull); + expect(location.messageId, isNull); + expect(location.message, isNull); + expect(location.userId, isNull); + expect(location.createdAt, isA()); + expect(location.updatedAt, isA()); + }); + + test('should create a valid instance with all parameters', () { + final createdAt = DateTime.parse('2023-01-01T00:00:00.000Z'); + final updatedAt = DateTime.parse('2023-01-01T01:00:00.000Z'); + final endAt = DateTime.parse('2024-12-31T23:59:59.999Z'); + final channel = ChannelModel( + cid: 'test:channel', + id: 'channel', + type: 'test', + createdAt: createdAt, + updatedAt: updatedAt, + ); + final message = Message( + id: 'message_123', + text: 'Test message', + createdAt: createdAt, + updatedAt: updatedAt, + ); + + final fullLocation = Location( + channelCid: 'test:channel', + channel: channel, + messageId: 'message_123', + message: message, + userId: 'user_123', + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: endAt, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + expect(fullLocation.channelCid, equals('test:channel')); + expect(fullLocation.channel, equals(channel)); + expect(fullLocation.messageId, equals('message_123')); + expect(fullLocation.message, equals(message)); + expect(fullLocation.userId, equals('user_123')); + expect(fullLocation.latitude, equals(latitude)); + expect(fullLocation.longitude, equals(longitude)); + expect(fullLocation.createdByDeviceId, equals(createdByDeviceId)); + expect(fullLocation.endAt, equals(endAt)); + expect(fullLocation.createdAt, equals(createdAt)); + expect(fullLocation.updatedAt, equals(updatedAt)); + }); + + test('should correctly serialize to JSON', () { + final json = location.toJson(); + + expect(json['latitude'], equals(latitude)); + expect(json['longitude'], equals(longitude)); + expect(json['created_by_device_id'], equals(createdByDeviceId)); + expect(json['end_at'], isNull); + expect(json.containsKey('channel_cid'), isFalse); + expect(json.containsKey('channel'), isFalse); + expect(json.containsKey('message_id'), isFalse); + expect(json.containsKey('message'), isFalse); + expect(json.containsKey('user_id'), isFalse); + expect(json.containsKey('created_at'), isFalse); + expect(json.containsKey('updated_at'), isFalse); + }); + + test('should serialize live location with endAt correctly', () { + final endAt = DateTime.parse('2024-12-31T23:59:59.999Z'); + final liveLocation = Location( + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: endAt, + ); + + final json = liveLocation.toJson(); + + expect(json['latitude'], equals(latitude)); + expect(json['longitude'], equals(longitude)); + expect(json['created_by_device_id'], equals(createdByDeviceId)); + expect(json['end_at'], equals('2024-12-31T23:59:59.999Z')); + }); + + test('should return correct coordinates', () { + final coordinates = location.coordinates; + + expect(coordinates, isA()); + expect(coordinates.latitude, equals(latitude)); + expect(coordinates.longitude, equals(longitude)); + }); + + test('isActive should return true for active live location', () { + final futureDate = DateTime.now().add(const Duration(hours: 1)); + final activeLocation = Location( + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: futureDate, + ); + + expect(activeLocation.isActive, isTrue); + expect(activeLocation.isExpired, isFalse); + }); + + test('isActive should return false for expired live location', () { + final pastDate = DateTime.now().subtract(const Duration(hours: 1)); + final expiredLocation = Location( + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: pastDate, + ); + + expect(expiredLocation.isActive, isFalse); + expect(expiredLocation.isExpired, isTrue); + }); + + test('isLive should return true for live location', () { + final futureDate = DateTime.now().add(const Duration(hours: 1)); + final liveLocation = Location( + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: futureDate, + ); + + expect(liveLocation.isLive, isTrue); + expect(liveLocation.isStatic, isFalse); + }); + + test('equality should work correctly', () { + final location1 = Location( + channelCid: 'test:channel', + messageId: 'message_123', + userId: 'user_123', + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: DateTime.parse('2024-12-31T23:59:59.999Z'), + createdAt: DateTime.parse('2023-01-01T00:00:00.000Z'), + updatedAt: DateTime.parse('2023-01-01T00:00:00.000Z'), + ); + + final location2 = Location( + channelCid: 'test:channel', + messageId: 'message_123', + userId: 'user_123', + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: DateTime.parse('2024-12-31T23:59:59.999Z'), + createdAt: DateTime.parse('2023-01-01T00:00:00.000Z'), + updatedAt: DateTime.parse('2023-01-01T00:00:00.000Z'), + ); + + final location3 = Location( + channelCid: 'test:channel', + messageId: 'message_123', + userId: 'user_123', + latitude: 40.7128, // Different latitude + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: DateTime.parse('2024-12-31T23:59:59.999Z'), + createdAt: DateTime.parse('2023-01-01T00:00:00.000Z'), + updatedAt: DateTime.parse('2023-01-01T00:00:00.000Z'), + ); + + expect(location1, equals(location2)); + expect(location1.hashCode, equals(location2.hashCode)); + expect(location1, isNot(equals(location3))); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/util/event_controller_test.dart b/packages/stream_chat/test/src/core/util/event_controller_test.dart new file mode 100644 index 0000000000..a789d2826b --- /dev/null +++ b/packages/stream_chat/test/src/core/util/event_controller_test.dart @@ -0,0 +1,337 @@ +// ignore_for_file: cascade_invocations, avoid_redundant_argument_values + +import 'dart:async'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/util/event_controller.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:test/test.dart'; + +void main() { + group('EventController events', () { + late EventController controller; + + setUp(() { + controller = EventController(); + }); + + tearDown(() { + controller.close(); + }); + + test('should emit events without resolvers', () async { + final event = Event(type: EventType.messageNew); + + final streamEvents = []; + controller.listen(streamEvents.add); + + controller.add(event); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.messageNew); + }); + + test('should apply resolvers in order', () async { + Event? firstResolver(Event event) { + if (event.type == EventType.messageNew) { + return event.copyWith(type: EventType.pollCreated); + } + return null; + } + + Event? secondResolver(Event event) { + if (event.type == EventType.pollCreated) { + return event.copyWith(type: EventType.locationShared); + } + return null; + } + + controller = EventController( + resolvers: [firstResolver, secondResolver], + ); + + final streamEvents = []; + controller.listen(streamEvents.add); + + final originalEvent = Event(type: EventType.messageNew); + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.pollCreated); + }); + + test('should stop at first matching resolver', () async { + var firstResolverCalled = false; + var secondResolverCalled = false; + + Event? firstResolver(Event event) { + firstResolverCalled = true; + if (event.type == EventType.messageNew) { + return event.copyWith(type: EventType.pollCreated); + } + return null; + } + + Event? secondResolver(Event event) { + secondResolverCalled = true; + return event.copyWith(type: EventType.locationShared); + } + + controller = EventController( + resolvers: [firstResolver, secondResolver], + ); + + final streamEvents = []; + controller.listen(streamEvents.add); + + final originalEvent = Event(type: EventType.messageNew); + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(firstResolverCalled, isTrue); + expect(secondResolverCalled, isFalse); + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.pollCreated); + }); + + test('should emit original event when no resolver matches', () async { + Event? resolver(Event event) { + if (event.type == EventType.pollCreated) { + return event.copyWith(type: EventType.locationShared); + } + return null; + } + + controller = EventController( + resolvers: [resolver], + ); + + final streamEvents = []; + controller.listen(streamEvents.add); + + final originalEvent = Event(type: EventType.messageNew); + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.messageNew); + }); + + test('should work with multiple resolvers that return null', () async { + Event? firstResolver(Event event) => null; + Event? secondResolver(Event event) => null; + Event? thirdResolver(Event event) => null; + + controller = EventController( + resolvers: [firstResolver, secondResolver, thirdResolver], + ); + + final streamEvents = []; + controller.listen(streamEvents.add); + + final originalEvent = Event(type: EventType.messageNew); + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.messageNew); + }); + + test('should handle empty resolvers list', () async { + controller = EventController(resolvers: []); + + final streamEvents = []; + controller.listen(streamEvents.add); + + final originalEvent = Event(type: EventType.messageNew); + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.messageNew); + }); + + test('should support custom onListen callback', () async { + var onListenCalled = false; + + controller = EventController( + onListen: () => onListenCalled = true, + ); + + expect(onListenCalled, isFalse); + + controller.listen((_) {}); + + expect(onListenCalled, isTrue); + }); + + test('should support custom onCancel callback', () async { + var onCancelCalled = false; + + controller = EventController( + onCancel: () => onCancelCalled = true, + ); + + final subscription = controller.listen((_) {}); + + expect(onCancelCalled, isFalse); + + await subscription.cancel(); + + expect(onCancelCalled, isTrue); + }); + + test('should support sync mode', () async { + controller = EventController(sync: true); + + final streamEvents = []; + controller.listen(streamEvents.add); + + final originalEvent = Event(type: EventType.messageNew); + controller.add(originalEvent); + + // In sync mode, events should be available immediately + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.messageNew); + }); + + test('should handle resolver exceptions gracefully', () async { + Event? failingResolver(Event event) { + throw Exception('Resolver failed'); + } + + Event? workingResolver(Event event) { + return event.copyWith(type: EventType.pollCreated); + } + + controller = EventController( + resolvers: [failingResolver, workingResolver], + ); + + final streamEvents = []; + controller.listen(streamEvents.add); + + final originalEvent = Event(type: EventType.messageNew); + + // This should throw an exception because the resolver throws + expect(() => controller.add(originalEvent), throwsException); + }); + + test('should be compatible with stream operations', () async { + final event1 = Event(type: EventType.messageNew); + final event2 = Event(type: EventType.messageUpdated); + + final streamEvents = []; + controller + .where((event) => event.type == EventType.messageNew) + .listen(streamEvents.add); + + controller.add(event1); + controller.add(event2); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.messageNew); + }); + + test('should work with multiple listeners', () async { + final streamEvents1 = []; + final streamEvents2 = []; + + controller.listen(streamEvents1.add); + controller.listen(streamEvents2.add); + + final originalEvent = Event(type: EventType.messageNew); + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(streamEvents1, hasLength(1)); + expect(streamEvents2, hasLength(1)); + expect(streamEvents1.first.type, EventType.messageNew); + expect(streamEvents2.first.type, EventType.messageNew); + }); + + test('should preserve event properties through resolvers', () async { + final originalEvent = Event( + type: EventType.messageNew, + userId: 'user123', + cid: 'channel123', + connectionId: 'conn123', + me: null, + user: null, + extraData: {'custom': 'data'}, + ); + + Event? resolver(Event event) { + if (event.type == EventType.messageNew) { + return event.copyWith(type: EventType.pollCreated); + } + return null; + } + + controller = EventController( + resolvers: [resolver], + ); + + final streamEvents = []; + controller.listen(streamEvents.add); + + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + final resolvedEvent = streamEvents.first; + expect(resolvedEvent.type, EventType.pollCreated); + expect(resolvedEvent.userId, 'user123'); + expect(resolvedEvent.cid, 'channel123'); + expect(resolvedEvent.connectionId, 'conn123'); + expect(resolvedEvent.extraData, {'custom': 'data'}); + }); + + test('should handle resolver modifying event data', () async { + final originalEvent = Event( + type: EventType.messageNew, + userId: 'user123', + extraData: {'original': 'data'}, + ); + + Event? resolver(Event event) { + if (event.type == EventType.messageNew) { + return event.copyWith( + type: EventType.pollCreated, + userId: 'modified_user', + extraData: {'modified': 'data'}, + ); + } + return null; + } + + controller = EventController( + resolvers: [resolver], + ); + + final streamEvents = []; + controller.listen(streamEvents.add); + + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + final resolvedEvent = streamEvents.first; + expect(resolvedEvent.type, EventType.pollCreated); + expect(resolvedEvent.userId, 'modified_user'); + expect(resolvedEvent.extraData, {'modified': 'data'}); + }); + }); +} diff --git a/sample_app/android/app/src/main/AndroidManifest.xml b/sample_app/android/app/src/main/AndroidManifest.xml index 8ff8f0abb5..6fb866b633 100644 --- a/sample_app/android/app/src/main/AndroidManifest.xml +++ b/sample_app/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,13 @@ + + + + + + + ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAppleMusicUsageDescription Used to send message attachments NSCameraUsageDescription Used to send message attachments + NSLocationWhenInUseUsageDescription + We need access to your location to share it in the chat. + NSLocationAlwaysUsageDescription + We need access to your location to share it in the chat. NSMicrophoneUsageDescription Used to send message attachments NSPhotoLibraryUsageDescription @@ -43,6 +54,7 @@ fetch remote-notification + location UILaunchStoryboardName LaunchScreen @@ -65,12 +77,5 @@ UIViewControllerBasedStatusBarAppearance - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - ITSAppUsesNonExemptEncryption - diff --git a/sample_app/lib/pages/channel_list_page.dart b/sample_app/lib/pages/channel_list_page.dart index 6084a038fa..f7a8fb5934 100644 --- a/sample_app/lib/pages/channel_list_page.dart +++ b/sample_app/lib/pages/channel_list_page.dart @@ -17,6 +17,7 @@ import 'package:sample_app/routes/routes.dart'; import 'package:sample_app/state/init_data.dart'; import 'package:sample_app/utils/app_config.dart'; import 'package:sample_app/utils/localizations.dart'; +import 'package:sample_app/utils/shared_location_service.dart'; import 'package:sample_app/widgets/channel_list.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:streaming_shared_preferences/streaming_shared_preferences.dart'; @@ -105,6 +106,10 @@ class _ChannelListPageState extends State { ]; } + late final _locationService = SharedLocationService( + client: StreamChat.of(context).client, + ); + @override Widget build(BuildContext context) { final user = StreamChat.of(context).currentUser; @@ -156,6 +161,7 @@ class _ChannelListPageState extends State { @override void initState() { + super.initState(); if (!kIsWeb) { badgeListener = StreamChat.of(context) .client @@ -169,12 +175,14 @@ class _ChannelListPageState extends State { } }); } - super.initState(); + + _locationService.initialize(); } @override void dispose() { badgeListener?.cancel(); + _locationService.dispose(); super.dispose(); } } diff --git a/sample_app/lib/pages/channel_page.dart b/sample_app/lib/pages/channel_page.dart index ba7603935f..0d3caf0b50 100644 --- a/sample_app/lib/pages/channel_page.dart +++ b/sample_app/lib/pages/channel_page.dart @@ -5,6 +5,10 @@ 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/location/location_attachment.dart'; +import 'package:sample_app/widgets/location/location_detail_dialog.dart'; +import 'package:sample_app/widgets/location/location_picker_dialog.dart'; +import 'package:sample_app/widgets/location/location_picker_option.dart'; import 'package:sample_app/widgets/reminder_dialog.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -25,8 +29,7 @@ class ChannelPage extends StatefulWidget { class _ChannelPageState extends State { FocusNode? _focusNode; - final StreamMessageInputController _messageInputController = - StreamMessageInputController(); + final _messageInputController = StreamMessageInputController(); @override void initState() { @@ -53,6 +56,9 @@ class _ChannelPageState extends State { final textTheme = theme.textTheme; final colorTheme = theme.colorTheme; + final channel = StreamChannel.of(context).channel; + final config = channel.config; + return Scaffold( backgroundColor: colorTheme.appBg, appBar: StreamChannelHeader( @@ -124,12 +130,77 @@ class _ChannelPageState extends State { messageInputController: _messageInputController, onQuotedMessageCleared: _messageInputController.clearQuotedMessage, enableVoiceRecording: true, + allowedAttachmentPickerTypes: [ + ...AttachmentPickerType.values, + if (config?.sharedLocations == true && channel.canShareLocation) + const LocationPickerType(), + ], + onCustomAttachmentPickerResult: (result) { + return _onCustomAttachmentPickerResult(channel, result).ignore(); + }, + customAttachmentPickerOptions: [ + TabbedAttachmentPickerOption( + key: 'location-picker', + icon: const Icon(Icons.near_me_rounded), + supportedTypes: [const LocationPickerType()], + isEnabled: (value) { + // Enable if nothing has been selected yet. + if (value.isEmpty) return true; + + // Otherwise, enable only if there is a location. + return value.extraData['location'] != null; + }, + optionViewBuilder: (context, controller) => LocationPicker( + onLocationPicked: (locationResult) { + if (locationResult == null) return Navigator.pop(context); + + controller.extraData = { + ...controller.value.extraData, + 'location': locationResult, + }; + + final result = LocationPicked(location: locationResult); + return Navigator.pop(context, result); + }, + ), + ), + ], ), ], ), ); } + Future _onCustomAttachmentPickerResult( + Channel channel, + CustomAttachmentPickerResult result, + ) async { + final response = switch (result) { + LocationPicked() => _onShareLocationPicked(channel, result.location), + _ => null, + }; + + return response?.ignore(); + } + + Future _onShareLocationPicked( + Channel channel, + LocationPickerResult result, + ) async { + if (result.endSharingAt case final endSharingAt?) { + return channel.startLiveLocationSharing( + createdByDeviceId: 'test-device-id', + endSharingAt: endSharingAt, + location: result.coordinates, + ); + } + + return channel.sendStaticLocation( + createdByDeviceId: 'test-device-id', + location: result.coordinates, + ); + } + Widget customMessageBuilder( BuildContext context, MessageDetails details, @@ -142,7 +213,8 @@ class _ChannelPageState extends State { final message = details.message; final reminder = message.reminder; - final channelConfig = StreamChannel.of(context).channel.config; + final channel = StreamChannel.of(context).channel; + final channelConfig = channel.config; final customOptions = [ if (channelConfig?.userMessageReminders == true) ...[ @@ -184,6 +256,13 @@ class _ChannelPageState extends State { ] ]; + final locationAttachmentBuilder = LocationAttachmentBuilder( + onAttachmentTap: (location) => showLocationDetailDialog( + context: context, + location: location, + ), + ); + return Container( color: reminder != null ? colorTheme.accentPrimary.withOpacity(.1) : null, child: Column( @@ -220,6 +299,7 @@ class _ChannelPageState extends State { defaultMessageWidget.copyWith( onReplyTap: _reply, customActions: customOptions, + showEditMessage: message.sharedLocation == null, onCustomActionTap: (it) async => await switch (it) { CreateReminder() => _createReminder(it.message), CreateBookmark() => _createBookmark(it.message), @@ -227,6 +307,7 @@ class _ChannelPageState extends State { RemoveReminder() => _removeReminder(it.message, it.reminder), _ => null, }, + attachmentBuilders: [locationAttachmentBuilder], onShowMessage: (message, channel) => GoRouter.of(context).goNamed( Routes.CHANNEL_PAGE.name, pathParameters: Routes.CHANNEL_PAGE.params(channel), diff --git a/sample_app/lib/pages/thread_page.dart b/sample_app/lib/pages/thread_page.dart index 622b5772ca..3e8376eb44 100644 --- a/sample_app/lib/pages/thread_page.dart +++ b/sample_app/lib/pages/thread_page.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:sample_app/widgets/location/location_attachment.dart'; +import 'package:sample_app/widgets/location/location_detail_dialog.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class ThreadPage extends StatefulWidget { @@ -43,6 +45,13 @@ class _ThreadPageState extends State { @override Widget build(BuildContext context) { + final locationAttachmentBuilder = LocationAttachmentBuilder( + onAttachmentTap: (location) => showLocationDetailDialog( + context: context, + location: location, + ), + ); + return Scaffold( backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, appBar: StreamThreadHeader( @@ -59,9 +68,18 @@ class _ThreadPageState extends State { messageFilter: defaultFilter, showScrollToBottom: false, highlightInitialMessage: true, + parentMessageBuilder: (context, message, defaultMessage) { + return defaultMessage.copyWith( + attachmentBuilders: [locationAttachmentBuilder], + ); + }, messageBuilder: (context, details, messages, defaultMessage) { + final message = details.message; + return defaultMessage.copyWith( onReplyTap: _reply, + showEditMessage: message.sharedLocation == null, + attachmentBuilders: [locationAttachmentBuilder], bottomRowBuilderWithDefaultWidget: ( context, message, diff --git a/sample_app/lib/utils/location_provider.dart b/sample_app/lib/utils/location_provider.dart new file mode 100644 index 0000000000..fbf7168028 --- /dev/null +++ b/sample_app/lib/utils/location_provider.dart @@ -0,0 +1,98 @@ +// ignore_for_file: close_sinks + +import 'dart:async'; + +import 'package:geolocator/geolocator.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +const notificationTitle = 'Live Location Tracking'; +const notificationText = 'Your location is being tracked live.'; + +class LocationProvider { + factory LocationProvider() => _instance; + LocationProvider._(); + + static final LocationProvider _instance = LocationProvider._(); + + Stream get positionStream => _positionStreamController.stream; + final _positionStreamController = StreamController.broadcast(); + + StreamSubscription? _positionSubscription; + + /// Opens the device's location settings page. + /// + /// Returns [true] if the location settings page could be opened, otherwise + /// [false] is returned. + Future openLocationSettings() => Geolocator.openLocationSettings(); + + /// Get current static location + Future getCurrentLocation() async { + final hasPermission = await _handlePermission(); + if (!hasPermission) return null; + + return Geolocator.getCurrentPosition(); + } + + /// Start live tracking + Future startTracking({ + int distanceFilter = 10, + LocationAccuracy accuracy = LocationAccuracy.high, + ActivityType activityType = ActivityType.automotiveNavigation, + }) async { + final hasPermission = await _handlePermission(); + if (!hasPermission) return; + + final settings = switch (CurrentPlatform.type) { + PlatformType.android => AndroidSettings( + accuracy: accuracy, + distanceFilter: distanceFilter, + foregroundNotificationConfig: const ForegroundNotificationConfig( + setOngoing: true, + notificationText: notificationText, + notificationTitle: notificationTitle, + notificationIcon: AndroidResource(name: 'ic_notification'), + ), + ), + PlatformType.ios || PlatformType.macOS => AppleSettings( + accuracy: accuracy, + activityType: activityType, + distanceFilter: distanceFilter, + showBackgroundLocationIndicator: true, + pauseLocationUpdatesAutomatically: true, + ), + _ => LocationSettings( + accuracy: accuracy, + distanceFilter: distanceFilter, + ) + }; + + _positionSubscription?.cancel(); // avoid duplicate subscriptions + _positionSubscription = Geolocator.getPositionStream( + locationSettings: settings, + ).listen( + _positionStreamController.safeAdd, + onError: _positionStreamController.safeAddError, + ); + } + + /// Stop live tracking + void stopTracking() { + _positionSubscription?.cancel(); + _positionSubscription = null; + } + + Future _handlePermission() async { + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return false; + + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + return switch (permission) { + LocationPermission.denied || LocationPermission.deniedForever => false, + _ => true, + }; + } +} diff --git a/sample_app/lib/utils/shared_location_service.dart b/sample_app/lib/utils/shared_location_service.dart new file mode 100644 index 0000000000..1d7a7128c6 --- /dev/null +++ b/sample_app/lib/utils/shared_location_service.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:geolocator/geolocator.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:sample_app/utils/location_provider.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class SharedLocationService { + SharedLocationService({ + required StreamChatClient client, + LocationProvider? locationProvider, + }) : _client = client, + _locationProvider = locationProvider ?? LocationProvider(); + + final StreamChatClient _client; + final LocationProvider _locationProvider; + + StreamSubscription? _positionSubscription; + StreamSubscription>? _activeLiveLocationsSubscription; + + Future initialize() async { + _activeLiveLocationsSubscription?.cancel(); + _activeLiveLocationsSubscription = _client.state.activeLiveLocationsStream + .distinct((prev, curr) => prev.length == curr.length) + .listen((locations) async { + // If there are no more active locations to update, stop tracking. + if (locations.isEmpty) return _stopTrackingLocation(); + + // Otherwise, start tracking the user's location. + return _startTrackingLocation(); + }); + + return _client.getActiveLiveLocations().ignore(); + } + + Future _startTrackingLocation() async { + if (_positionSubscription != null) return; + + // Start listening to the position stream. + _positionSubscription = _locationProvider.positionStream + .throttleTime(const Duration(seconds: 3)) + .listen(_onPositionUpdate); + + return _locationProvider.startTracking(); + } + + void _stopTrackingLocation() { + _locationProvider.stopTracking(); + + // Stop tracking the user's location + _positionSubscription?.cancel(); + _positionSubscription = null; + } + + void _onPositionUpdate(Position position) { + // Handle location updates, e.g., update the UI or send to server + final activeLiveLocations = _client.state.activeLiveLocations; + if (activeLiveLocations.isEmpty) return _stopTrackingLocation(); + + // Update all active live locations + for (final location in activeLiveLocations) { + // Skip if the location is not live or has expired + if (location.isLive && location.isExpired) continue; + + // Skip if the location does not have a messageId + final messageId = location.messageId; + if (messageId == null) continue; + + // Update the live location with the new position + _client.updateLiveLocation( + messageId: messageId, + createdByDeviceId: location.createdByDeviceId, + location: LocationCoordinates( + latitude: position.latitude, + longitude: position.longitude, + ), + ); + } + } + + /// Clean up resources + Future dispose() async { + _stopTrackingLocation(); + + _activeLiveLocationsSubscription?.cancel(); + _activeLiveLocationsSubscription = null; + } +} diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index c2dab2bb85..a87e51b012 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -61,7 +61,7 @@ class _ChannelList extends State { ChannelSortKey.pinnedAt, nullOrdering: NullOrdering.nullsLast, ), - const SortOption.desc(ChannelSortKey.lastMessageAt), + const SortOption.desc(ChannelSortKey.lastUpdated), ], limit: 30, ); diff --git a/sample_app/lib/widgets/location/location_attachment.dart b/sample_app/lib/widgets/location/location_attachment.dart new file mode 100644 index 0000000000..405f97b7df --- /dev/null +++ b/sample_app/lib/widgets/location/location_attachment.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:sample_app/widgets/location/location_user_marker.dart'; +import 'package:sample_app/widgets/simple_map_view.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +const _defaultLocationConstraints = BoxConstraints( + maxWidth: 270, + maxHeight: 180, +); + +/// {@template locationAttachmentBuilder} +/// A builder for creating a location attachment widget. +/// {@endtemplate} +class LocationAttachmentBuilder extends StreamAttachmentWidgetBuilder { + /// {@macro locationAttachmentBuilder} + const LocationAttachmentBuilder({ + this.constraints = _defaultLocationConstraints, + this.padding = const EdgeInsets.all(4), + this.onAttachmentTap, + }); + + /// The constraints to apply to the file attachment widget. + final BoxConstraints constraints; + + /// The padding to apply to the file attachment widget. + final EdgeInsetsGeometry padding; + + /// Optional callback to handle tap events on the attachment. + final ValueSetter? onAttachmentTap; + + @override + bool canHandle(Message message, _) => message.sharedLocation != null; + + @override + Widget build(BuildContext context, Message message, _) { + assert(debugAssertCanHandle(message, _), ''); + + final user = message.user; + final location = message.sharedLocation!; + return LocationAttachment( + user: user, + sharedLocation: location, + constraints: constraints, + padding: padding, + onLocationTap: switch (onAttachmentTap) { + final onTap? => () => onTap(location), + _ => null, + }, + ); + } +} + +/// Displays a location attachment with a map view and optional footer. +class LocationAttachment extends StatelessWidget { + /// Creates a new [LocationAttachment]. + const LocationAttachment({ + super.key, + required this.user, + required this.sharedLocation, + this.constraints = _defaultLocationConstraints, + this.padding = const EdgeInsets.all(2), + this.onLocationTap, + }); + + /// The user who shared the location. + final User? user; + + /// The shared location data. + final Location sharedLocation; + + /// The constraints to apply to the file attachment widget. + final BoxConstraints constraints; + + /// The padding to apply to the file attachment widget. + final EdgeInsetsGeometry padding; + + /// Optional callback to handle tap events on the location attachment. + final VoidCallback? onLocationTap; + + @override + Widget build(BuildContext context) { + final currentUser = StreamChat.of(context).currentUser; + final sharedLocationEndAt = sharedLocation.endAt; + + return Padding( + padding: padding, + child: ConstrainedBox( + constraints: constraints, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Material( + clipBehavior: Clip.antiAlias, + type: MaterialType.transparency, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onLocationTap, + child: IgnorePointer( + child: SimpleMapView( + markerSize: 40, + showLocateMeButton: false, + coordinates: sharedLocation.coordinates, + markerBuilder: (_, __, size) => LocationUserMarker( + user: user, + markerSize: size, + sharedLocation: sharedLocation, + ), + ), + ), + ), + ), + ), + if (sharedLocationEndAt != null && currentUser != null) + LocationAttachmentFooter( + currentUser: currentUser, + sharingEndAt: sharedLocationEndAt, + sharedLocation: sharedLocation, + onStopSharingPressed: () { + final client = StreamChat.of(context).client; + + final location = sharedLocation; + final messageId = location.messageId; + if (messageId == null) return; + + client.stopLiveLocation( + messageId: messageId, + createdByDeviceId: location.createdByDeviceId, + ); + }, + ), + ], + ), + ), + ); + } +} + +class LocationAttachmentFooter extends StatelessWidget { + const LocationAttachmentFooter({ + super.key, + required this.currentUser, + required this.sharingEndAt, + required this.sharedLocation, + this.onStopSharingPressed, + }); + + final User currentUser; + final DateTime sharingEndAt; + final Location sharedLocation; + final VoidCallback? onStopSharingPressed; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final textTheme = theme.textTheme; + final colorTheme = theme.colorTheme; + + const maximumSize = Size(double.infinity, 40); + + // If the location sharing has ended, show a message indicating that. + if (sharingEndAt.isBefore(DateTime.now())) { + return SizedBox.fromSize( + size: maximumSize, + child: Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.near_me_disabled_rounded, + color: colorTheme.textLowEmphasis, + ), + Text( + 'Live location ended', + style: textTheme.bodyBold.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ], + ), + ); + } + + final currentUserId = currentUser.id; + final sharedLocationUserId = sharedLocation.userId; + + // If the shared location is not shared by the current user, show the + // "Live until" duration text. + if (sharedLocationUserId != currentUserId) { + final liveUntil = Jiffy.parseFromDateTime(sharingEndAt.toLocal()); + + return SizedBox.fromSize( + size: maximumSize, + child: Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.near_me_rounded, + color: colorTheme.accentPrimary, + ), + Text( + 'Live until ${liveUntil.jm}', + style: textTheme.bodyBold.copyWith( + color: colorTheme.accentPrimary, + ), + ), + ], + ), + ); + } + + // Otherwise, show the "Stop Sharing" button. + final buttonStyle = TextButton.styleFrom( + maximumSize: maximumSize, + textStyle: textTheme.bodyBold, + visualDensity: VisualDensity.compact, + foregroundColor: colorTheme.accentError, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ); + + return TextButton.icon( + style: buttonStyle, + onPressed: onStopSharingPressed, + icon: Icon( + Icons.near_me_disabled_rounded, + color: colorTheme.accentError, + ), + label: const Text('Stop Sharing'), + ); + } +} diff --git a/sample_app/lib/widgets/location/location_detail_dialog.dart b/sample_app/lib/widgets/location/location_detail_dialog.dart new file mode 100644 index 0000000000..c5082bf365 --- /dev/null +++ b/sample_app/lib/widgets/location/location_detail_dialog.dart @@ -0,0 +1,312 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:sample_app/widgets/location/location_user_marker.dart'; +import 'package:sample_app/widgets/simple_map_view.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +Future showLocationDetailDialog({ + required BuildContext context, + required Location location, +}) async { + final navigator = Navigator.of(context); + return navigator.push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => StreamChannel( + channel: StreamChannel.of(context).channel, + child: LocationDetailDialog(sharedLocation: location), + ), + ), + ); +} + +Stream _findLocationMessageStream( + Channel channel, + Location location, +) { + final messageId = location.messageId; + if (messageId == null) return Stream.value(null); + + final channelState = channel.state; + if (channelState == null) return Stream.value(null); + + return channelState.messagesStream.map((messages) { + return messages.firstWhereOrNull((message) => message.id == messageId); + }); +} + +class LocationDetailDialog extends StatelessWidget { + const LocationDetailDialog({ + super.key, + required this.sharedLocation, + }); + + final Location sharedLocation; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + final channel = StreamChannel.of(context).channel; + final locationStream = _findLocationMessageStream(channel, sharedLocation); + + return Scaffold( + backgroundColor: colorTheme.appBg, + appBar: AppBar( + backgroundColor: colorTheme.barsBg, + title: const Text('Shared Location'), + ), + body: BetterStreamBuilder( + stream: locationStream, + errorBuilder: (_, __) => const Center(child: LocationNotFound()), + noDataBuilder: (_) => const Center(child: CircularProgressIndicator()), + builder: (context, message) { + final sharedLocation = message.sharedLocation; + if (sharedLocation == null) { + return const Center(child: LocationNotFound()); + } + + return Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + SimpleMapView( + cameraZoom: 16, + markerSize: 48, + coordinates: sharedLocation.coordinates, + markerBuilder: (_, __, size) => LocationUserMarker( + user: message.user, + markerSize: size, + sharedLocation: sharedLocation, + ), + ), + if (sharedLocation.isLive) + LocationDetailBottomSheet( + sharedLocation: sharedLocation, + onStopSharingPressed: () { + final client = StreamChat.of(context).client; + + final messageId = sharedLocation.messageId; + if (messageId == null) return; + + client.stopLiveLocation( + messageId: messageId, + createdByDeviceId: sharedLocation.createdByDeviceId, + ); + }, + ), + ], + ); + }, + ), + ); + } +} + +class LocationNotFound extends StatelessWidget { + const LocationNotFound({super.key}); + + @override + Widget build(BuildContext context) { + final chatThemeData = StreamChatTheme.of(context); + final colorTheme = chatThemeData.colorTheme; + final textTheme = chatThemeData.textTheme; + + return Column( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + size: 48, + Icons.near_me_disabled_rounded, + color: colorTheme.accentError, + ), + Text( + 'Location not found', + style: textTheme.headline.copyWith( + color: colorTheme.textHighEmphasis, + ), + ), + Text( + 'The location you are looking for is not available.', + style: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ], + ); + } +} + +class LocationDetailBottomSheet extends StatelessWidget { + const LocationDetailBottomSheet({ + super.key, + required this.sharedLocation, + this.onStopSharingPressed, + }); + + final Location sharedLocation; + final VoidCallback? onStopSharingPressed; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + return Material( + color: colorTheme.barsBg, + borderRadius: const BorderRadiusDirectional.only( + topEnd: Radius.circular(14), + topStart: Radius.circular(14), + ), + child: SafeArea( + minimum: const EdgeInsets.all(8), + child: LocationDetail( + sharedLocation: sharedLocation, + onStopSharingPressed: onStopSharingPressed, + ), + ), + ); + } +} + +class LocationDetail extends StatelessWidget { + const LocationDetail({ + super.key, + required this.sharedLocation, + this.onStopSharingPressed, + }); + + final Location sharedLocation; + final VoidCallback? onStopSharingPressed; + + @override + Widget build(BuildContext context) { + assert( + sharedLocation.isLive, + 'Footer should only be shown for live locations', + ); + + final theme = StreamChatTheme.of(context); + final textTheme = theme.textTheme; + final colorTheme = theme.colorTheme; + + final updatedAt = sharedLocation.updatedAt; + final sharingEndAt = sharedLocation.endAt!; + const maximumButtonSize = Size(double.infinity, 40); + + if (sharingEndAt.isBefore(DateTime.now())) { + final jiffyUpdatedAt = Jiffy.parseFromDateTime(updatedAt.toLocal()); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.fromSize( + size: maximumButtonSize, + child: Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.near_me_disabled_rounded, + color: colorTheme.accentError, + ), + Text( + 'Live location ended', + style: textTheme.headlineBold.copyWith( + color: colorTheme.accentError, + ), + ), + ], + ), + ), + Text( + 'Location last updated at ${jiffyUpdatedAt.jm}', + style: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ], + ); + } + + final sharedLocationUserId = sharedLocation.userId; + final currentUserId = StreamChat.of(context).currentUser?.id; + + // If the shared location is not shared by the current user, show the + // "Live until" duration text. + if (sharedLocationUserId != currentUserId) { + final jiffySharingEndAt = Jiffy.parseFromDateTime(sharingEndAt.toLocal()); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.fromSize( + size: maximumButtonSize, + child: Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.near_me_rounded, + color: colorTheme.accentPrimary, + ), + Text( + 'Live Location', + style: textTheme.headlineBold.copyWith( + color: colorTheme.accentPrimary, + ), + ), + ], + ), + ), + Text( + 'Live until ${jiffySharingEndAt.jm}', + style: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ], + ); + } + + // Otherwise, show the "Stop Sharing" button. + final buttonStyle = TextButton.styleFrom( + maximumSize: maximumButtonSize, + textStyle: textTheme.headlineBold, + visualDensity: VisualDensity.compact, + foregroundColor: colorTheme.accentError, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ); + + final jiffySharingEndAt = Jiffy.parseFromDateTime(sharingEndAt.toLocal()); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: TextButton.icon( + style: buttonStyle, + onPressed: onStopSharingPressed, + icon: Icon( + Icons.near_me_disabled_rounded, + color: colorTheme.accentError, + ), + label: const Text('Stop Sharing'), + ), + ), + Center( + child: Text( + 'Live until ${jiffySharingEndAt.jm}', + style: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ), + ], + ); + } +} diff --git a/sample_app/lib/widgets/location/location_picker_dialog.dart b/sample_app/lib/widgets/location/location_picker_dialog.dart new file mode 100644 index 0000000000..1abaaf9e1c --- /dev/null +++ b/sample_app/lib/widgets/location/location_picker_dialog.dart @@ -0,0 +1,362 @@ +import 'package:avatar_glow/avatar_glow.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:sample_app/utils/location_provider.dart'; +import 'package:sample_app/widgets/simple_map_view.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class LocationPickerResult { + const LocationPickerResult({ + this.endSharingAt, + required this.coordinates, + }); + + final DateTime? endSharingAt; + final LocationCoordinates coordinates; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is LocationPickerResult && + runtimeType == other.runtimeType && + endSharingAt == other.endSharingAt && + coordinates == other.coordinates; + } + + @override + int get hashCode => endSharingAt.hashCode ^ coordinates.hashCode; +} + +Future showLocationPickerDialog({ + required BuildContext context, + bool barrierDismissible = true, + Color? barrierColor, + String? barrierLabel, + bool useSafeArea = true, + bool useRootNavigator = false, + RouteSettings? routeSettings, + Offset? anchorPoint, + EdgeInsets padding = const EdgeInsets.all(16), + TraversalEdgeBehavior? traversalEdgeBehavior, +}) { + final navigator = Navigator.of(context, rootNavigator: useRootNavigator); + return navigator.push( + MaterialPageRoute( + fullscreenDialog: true, + barrierDismissible: barrierDismissible, + builder: (context) => const LocationPickerDialog(), + ), + ); +} + +class LocationPickerDialog extends StatefulWidget { + const LocationPickerDialog({super.key}); + + @override + State createState() => _LocationPickerDialogState(); +} + +class _LocationPickerDialogState extends State { + LocationCoordinates? _currentLocation; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + return Scaffold( + backgroundColor: colorTheme.appBg, + appBar: AppBar( + backgroundColor: colorTheme.barsBg, + title: const Text('Share Location'), + ), + body: Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + FutureBuilder( + future: LocationProvider().getCurrentLocation(), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + + final position = snapshot.data; + if (snapshot.hasError || position == null) { + return const Center(child: LocationNotFound()); + } + + final coordinates = _currentLocation = LocationCoordinates( + latitude: position.latitude, + longitude: position.longitude, + ); + + return SimpleMapView( + cameraZoom: 18, + markerSize: 24, + coordinates: coordinates, + markerBuilder: (context, _, size) => AvatarGlow( + glowColor: colorTheme.accentPrimary, + child: Material( + elevation: 2, + shape: CircleBorder( + side: BorderSide( + width: 4, + color: colorTheme.barsBg, + ), + ), + child: CircleAvatar( + radius: size / 2, + backgroundColor: colorTheme.accentPrimary, + ), + ), + ), + ); + }, + ), + // Location picker options + LocationPickerOptionList( + onOptionSelected: (option) { + final currentLocation = _currentLocation; + if (currentLocation == null) return Navigator.pop(context); + + final result = LocationPickerResult( + endSharingAt: switch (option) { + ShareStaticLocation() => null, + ShareLiveLocation() => option.endSharingAt, + }, + coordinates: currentLocation, + ); + + return Navigator.pop(context, result); + }, + ), + ], + ), + ); + } +} + +class LocationNotFound extends StatelessWidget { + const LocationNotFound({super.key}); + + @override + Widget build(BuildContext context) { + final chatThemeData = StreamChatTheme.of(context); + final colorTheme = chatThemeData.colorTheme; + final textTheme = chatThemeData.textTheme; + + return Column( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + size: 48, + Icons.near_me_disabled_rounded, + color: colorTheme.accentError, + ), + Text( + 'Something went wrong', + style: textTheme.headline.copyWith( + color: colorTheme.textHighEmphasis, + ), + ), + Text( + 'Please check your location settings and try again.', + style: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ], + ); + } +} + +class LocationPickerOptionList extends StatelessWidget { + const LocationPickerOptionList({ + super.key, + required this.onOptionSelected, + }); + + final ValueSetter onOptionSelected; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + return Material( + color: colorTheme.barsBg, + borderRadius: const BorderRadiusDirectional.only( + topEnd: Radius.circular(14), + topStart: Radius.circular(14), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 14, + ), + child: Column( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + LocationPickerOptionItem( + icon: const Icon(Icons.share_location_rounded), + title: 'Share Live Location', + subtitle: 'Your location will update in real-time', + onTap: () async { + final duration = await showCupertinoModalPopup( + context: context, + builder: (_) => const LiveLocationDurationDialog(), + ); + + if (duration == null) return; + final endSharingAt = DateTime.timestamp().add(duration); + + return onOptionSelected( + ShareLiveLocation(endSharingAt: endSharingAt), + ); + }, + ), + LocationPickerOptionItem( + icon: const Icon(Icons.my_location), + title: 'Share Static Location', + subtitle: 'Send your current location only', + onTap: () => onOptionSelected(const ShareStaticLocation()), + ), + ], + ), + ), + ), + ); + } +} + +sealed class LocationPickerOption { + const LocationPickerOption(); +} + +final class ShareLiveLocation extends LocationPickerOption { + const ShareLiveLocation({required this.endSharingAt}); + final DateTime endSharingAt; +} + +final class ShareStaticLocation extends LocationPickerOption { + const ShareStaticLocation(); +} + +class LocationPickerOptionItem extends StatelessWidget { + const LocationPickerOptionItem({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + final Widget icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final textTheme = theme.textTheme; + final colorTheme = theme.colorTheme; + + return OutlinedButton( + onPressed: onTap, + style: OutlinedButton.styleFrom( + backgroundColor: colorTheme.barsBg, + foregroundColor: colorTheme.accentPrimary, + side: BorderSide(color: colorTheme.borders, width: 1.2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), + ), + child: IconTheme( + data: IconTheme.of(context).copyWith( + size: 24, + color: colorTheme.accentPrimary, + ), + child: Row( + spacing: 16, + children: [ + icon, + Expanded( + child: Column( + spacing: 2, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: textTheme.bodyBold.copyWith( + color: colorTheme.textHighEmphasis, + ), + ), + Text( + subtitle, + style: textTheme.footnote.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ], + ), + ), + StreamSvgIcon( + size: 24, + icon: StreamSvgIcons.right, + color: colorTheme.textLowEmphasis, + ), + ], + ), + ), + ); + } +} + +class LiveLocationDurationDialog extends StatelessWidget { + const LiveLocationDurationDialog({super.key}); + + static const _endAtDurations = [ + Duration(minutes: 15), + Duration(hours: 1), + Duration(hours: 8), + ]; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return CupertinoTheme( + data: CupertinoTheme.of(context).copyWith( + primaryColor: theme.colorTheme.accentPrimary, + ), + child: CupertinoActionSheet( + title: const Text('Share Live Location'), + message: Text( + 'Select the duration for sharing your live location.', + style: theme.textTheme.footnote.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + ), + actions: [ + ..._endAtDurations.map((duration) { + final endAt = Jiffy.now().addDuration(duration); + return CupertinoActionSheetAction( + onPressed: () => Navigator.of(context).pop(duration), + child: Text(endAt.fromNow(withPrefixAndSuffix: false)), + ); + }), + ], + cancelButton: CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: Navigator.of(context).pop, + child: const Text('Cancel'), + ), + ), + ); + } +} diff --git a/sample_app/lib/widgets/location/location_picker_option.dart b/sample_app/lib/widgets/location/location_picker_option.dart new file mode 100644 index 0000000000..e32ba43ee7 --- /dev/null +++ b/sample_app/lib/widgets/location/location_picker_option.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:sample_app/utils/location_provider.dart'; +import 'package:sample_app/widgets/location/location_picker_dialog.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +final class LocationPickerType extends CustomAttachmentPickerType { + const LocationPickerType(); +} + +final class LocationPicked extends CustomAttachmentPickerResult { + const LocationPicked({required this.location}); + final LocationPickerResult location; +} + +class LocationPicker extends StatelessWidget { + const LocationPicker({ + super.key, + this.onLocationPicked, + }); + + final ValueSetter? onLocationPicked; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + return OptionDrawer( + child: EndOfFrameCallbackWidget( + child: Icon( + size: 148, + Icons.near_me_rounded, + color: colorTheme.disabled, + ), + onEndOfFrame: (context) async { + final result = await runInPermissionRequestLock(() { + return showLocationPickerDialog(context: context); + }); + + onLocationPicked?.call(result); + }, + errorBuilder: (context, error, stacktrace) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + size: 148, + Icons.near_me_rounded, + color: theme.colorTheme.disabled, + ), + Text( + 'Please enable access to your location', + style: theme.textTheme.body.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + TextButton( + onPressed: LocationProvider().openLocationSettings, + child: Text( + 'Allow Location Access', + style: theme.textTheme.bodyBold.copyWith( + color: theme.colorTheme.accentPrimary, + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/sample_app/lib/widgets/location/location_user_marker.dart b/sample_app/lib/widgets/location/location_user_marker.dart new file mode 100644 index 0000000000..102e16876c --- /dev/null +++ b/sample_app/lib/widgets/location/location_user_marker.dart @@ -0,0 +1,56 @@ +import 'package:avatar_glow/avatar_glow.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class LocationUserMarker extends StatelessWidget { + const LocationUserMarker({ + super.key, + this.user, + this.markerSize = 40, + required this.sharedLocation, + }); + + final User? user; + final double markerSize; + final Location sharedLocation; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + if (user case final user? when sharedLocation.isLive) { + const borderWidth = 4.0; + + final avatar = Material( + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + color: colorTheme.overlayDark, + child: Padding( + padding: const EdgeInsets.all(borderWidth), + child: StreamUserAvatar( + user: user, + constraints: BoxConstraints.tightFor( + width: markerSize, + height: markerSize, + ), + showOnlineStatus: false, + ), + ), + ); + + if (sharedLocation.isExpired) return avatar; + + return AvatarGlow( + glowColor: colorTheme.accentPrimary, + child: avatar, + ); + } + + return Icon( + size: markerSize, + Icons.person_pin, + color: colorTheme.accentPrimary, + ); + } +} diff --git a/sample_app/lib/widgets/simple_map_view.dart b/sample_app/lib/widgets/simple_map_view.dart new file mode 100644 index 0000000000..0faadc11f7 --- /dev/null +++ b/sample_app/lib/widgets/simple_map_view.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_animations/flutter_map_animations.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +typedef MarkerBuilder = Widget Function( + BuildContext context, + Animation animation, + double markerSize, +); + +class SimpleMapView extends StatefulWidget { + const SimpleMapView({ + super.key, + this.cameraZoom = 15, + this.markerSize = 30, + required this.coordinates, + this.showLocateMeButton = true, + this.markerBuilder = _defaultMarkerBuilder, + }); + + final double cameraZoom; + + final double markerSize; + + final LocationCoordinates coordinates; + + final bool showLocateMeButton; + + final MarkerBuilder markerBuilder; + static Widget _defaultMarkerBuilder(BuildContext context, _, double size) { + final theme = StreamChatTheme.of(context); + final iconColor = theme.colorTheme.accentPrimary; + return Icon(size: size, Icons.person_pin, color: iconColor); + } + + @override + State createState() => _SimpleMapViewState(); +} + +class _SimpleMapViewState extends State + with TickerProviderStateMixin { + late final _mapController = AnimatedMapController(vsync: this); + late final _initialCenter = widget.coordinates.toLatLng(); + + @override + void didUpdateWidget(covariant SimpleMapView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.coordinates != widget.coordinates) { + _mapController.animateTo( + dest: widget.coordinates.toLatLng(), + zoom: widget.cameraZoom, + curve: Curves.easeInOut, + ); + } + } + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + const baseMapTemplate = 'https://{s}.basemaps.cartocdn.com'; + const mapTemplate = '$baseMapTemplate/rastertiles/voyager/{z}/{x}/{y}.png'; + const fallbackTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + + return FlutterMap( + mapController: _mapController.mapController, + options: MapOptions( + keepAlive: true, + initialCenter: _initialCenter, + initialZoom: widget.cameraZoom, + ), + children: [ + TileLayer( + urlTemplate: mapTemplate, + fallbackUrl: fallbackTemplate, + tileBuilder: (context, tile, __) => switch (brightness) { + Brightness.light => tile, + Brightness.dark => darkModeTilesContainerBuilder(context, tile), + }, + userAgentPackageName: switch (CurrentPlatform.type) { + PlatformType.ios => 'io.getstream.flutter', + PlatformType.android => 'io.getstream.chat.android.flutter.sample', + _ => 'unknown', + }, + ), + AnimatedMarkerLayer( + markers: [ + AnimatedMarker( + height: widget.markerSize, + width: widget.markerSize, + point: widget.coordinates.toLatLng(), + builder: (context, animation) => widget.markerBuilder( + context, + animation, + widget.markerSize, + ), + ), + ], + ), + if (widget.showLocateMeButton) + SimpleMapLocateMeButton( + onPressed: () => _mapController.animateTo( + zoom: widget.cameraZoom, + curve: Curves.easeInOut, + dest: widget.coordinates.toLatLng(), + ), + ), + ], + ); + } + + @override + void dispose() { + _mapController.dispose(); + super.dispose(); + } +} + +class SimpleMapLocateMeButton extends StatelessWidget { + const SimpleMapLocateMeButton({ + super.key, + this.onPressed, + this.alignment = AlignmentDirectional.topEnd, + }); + + final AlignmentGeometry alignment; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + return Align( + alignment: alignment, + child: Padding( + padding: const EdgeInsets.all(8), + child: FloatingActionButton.small( + onPressed: onPressed, + shape: const CircleBorder(), + foregroundColor: colorTheme.accentPrimary, + backgroundColor: colorTheme.barsBg, + child: const Icon(Icons.near_me_rounded), + ), + ), + ); + } +} + +extension on LocationCoordinates { + LatLng toLatLng() => LatLng(latitude, longitude); +} diff --git a/sample_app/macos/Flutter/Flutter-Debug.xcconfig b/sample_app/macos/Flutter/Flutter-Debug.xcconfig deleted file mode 100644 index 4b81f9b2d2..0000000000 --- a/sample_app/macos/Flutter/Flutter-Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/sample_app/macos/Flutter/Flutter-Release.xcconfig b/sample_app/macos/Flutter/Flutter-Release.xcconfig deleted file mode 100644 index 5caa9d1579..0000000000 --- a/sample_app/macos/Flutter/Flutter-Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/sample_app/macos/Runner/DebugProfile.entitlements b/sample_app/macos/Runner/DebugProfile.entitlements index 0eaccf1418..6bc96b3bc4 100644 --- a/sample_app/macos/Runner/DebugProfile.entitlements +++ b/sample_app/macos/Runner/DebugProfile.entitlements @@ -12,5 +12,7 @@ com.apple.security.network.server + com.apple.security.personal-information.location + diff --git a/sample_app/macos/Runner/Info.plist b/sample_app/macos/Runner/Info.plist index 4789daa6a4..49bb9bb13c 100644 --- a/sample_app/macos/Runner/Info.plist +++ b/sample_app/macos/Runner/Info.plist @@ -28,5 +28,7 @@ MainMenu NSPrincipalClass NSApplication + NSLocationUsageDescription + We need access to your location to share it in the chat. diff --git a/sample_app/macos/Runner/Release.entitlements b/sample_app/macos/Runner/Release.entitlements index a0463869a9..731447a00b 100644 --- a/sample_app/macos/Runner/Release.entitlements +++ b/sample_app/macos/Runner/Release.entitlements @@ -8,5 +8,7 @@ com.apple.security.network.client + com.apple.security.personal-information.location + diff --git a/sample_app/pubspec.yaml b/sample_app/pubspec.yaml index 960ae43812..21b3a18e2d 100644 --- a/sample_app/pubspec.yaml +++ b/sample_app/pubspec.yaml @@ -20,6 +20,7 @@ environment: flutter: ">=3.27.4" dependencies: + avatar_glow: ^3.0.0 collection: ^1.17.2 firebase_core: ^3.0.0 firebase_messaging: ^15.0.0 @@ -27,12 +28,17 @@ dependencies: sdk: flutter flutter_app_badger: ^1.5.0 flutter_local_notifications: ^18.0.1 + flutter_map: ^8.1.1 + flutter_map_animations: ^0.9.0 flutter_secure_storage: ^9.2.2 flutter_slidable: ^3.1.1 flutter_svg: ^2.0.10+1 + geolocator: ^13.0.0 go_router: ^14.6.2 + latlong2: ^0.9.1 lottie: ^3.1.2 provider: ^6.0.5 + rxdart: ^0.28.0 sentry_flutter: ^8.3.0 stream_chat_flutter: ^10.0.0-beta.2 stream_chat_localizations: ^10.0.0-beta.2