[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

tome (#49)

authored by

Jean Carlo Polo and committed by
GitHub
e19f1e1a 864a6b0b

+1115 -1606
+2 -6
lib/src/core/di/service_locator.dart
··· 16 16 import 'package:sparksocial/src/core/auth/data/repositories/onboarding_repository_impl.dart'; 17 17 import 'package:sparksocial/src/core/network/atproto/data/repositories/actor_repository_impl.dart'; 18 18 import 'package:sparksocial/src/core/network/atproto/data/repositories/graph_repository_impl.dart'; 19 - import 'package:sparksocial/src/core/network/messages/data/services/chat_socket_service.dart'; 20 - import 'package:sparksocial/src/core/network/messages/data/services/chat_api_service.dart'; 21 - import 'package:sparksocial/src/core/network/messages/data/repositories/chat_repository.dart'; 22 - import 'package:sparksocial/src/core/network/messages/data/repositories/chat_repository_impl.dart'; 19 + import 'package:sparksocial/src/core/network/chat/data/repositories/chat_repository.dart'; 20 + import 'package:sparksocial/src/core/network/chat/data/repositories/chat_repository_impl.dart'; 23 21 24 22 // This is the ONLY PLACE IN THE ENTIRE APP where implementations are imported 25 23 // All the other files should import interfaces only (polymorphism) to keep everything decoupled ··· 56 54 sl.registerSingleton<AuthRepository>(AuthRepositoryImpl()); 57 55 58 56 // Register Chat dependencies 59 - sl.registerLazySingleton<ChatSocketService>(() => ChatSocketService()); 60 - sl.registerLazySingleton<ChatApiService>(() => ChatApiService()); 61 57 sl.registerSingleton<ChatRepository>(ChatRepositoryImpl()); 62 58 63 59 // Register SprkRepository with its interface
+4
lib/src/core/network/chat/chat.dart
··· 1 + library; 2 + 3 + export 'data/models/models.dart'; 4 + export 'data/repositories/repositories.dart';
+153
lib/src/core/network/chat/data/models/chat_models.dart
··· 1 + import 'package:freezed_annotation/freezed_annotation.dart'; 2 + 3 + part 'chat_models.freezed.dart'; 4 + part 'chat_models.g.dart'; 5 + 6 + /// Represents a chat message 7 + @freezed 8 + class ChatMessage with _$ChatMessage { 9 + const ChatMessage._(); 10 + 11 + @JsonSerializable(explicitToJson: true) 12 + const factory ChatMessage({ 13 + required String id, 14 + required String message, 15 + required String senderDid, 16 + required String receiverDid, 17 + required DateTime timestamp, 18 + }) = _ChatMessage; 19 + 20 + factory ChatMessage.fromJson(Map<String, dynamic> json) => _$ChatMessageFromJson(json); 21 + } 22 + 23 + /// Request model for sending a message 24 + @freezed 25 + class SendMessageRequest with _$SendMessageRequest { 26 + const SendMessageRequest._(); 27 + 28 + @JsonSerializable(explicitToJson: true) 29 + const factory SendMessageRequest({ 30 + required String message, 31 + required String receiverDid, 32 + }) = _SendMessageRequest; 33 + 34 + factory SendMessageRequest.fromJson(Map<String, dynamic> json) => _$SendMessageRequestFromJson(json); 35 + } 36 + 37 + /// Response model for sending a message 38 + @freezed 39 + class SendMessageResponse with _$SendMessageResponse { 40 + const SendMessageResponse._(); 41 + 42 + @JsonSerializable(explicitToJson: true) 43 + const factory SendMessageResponse({ 44 + required String messageId, 45 + required DateTime timestamp, 46 + }) = _SendMessageResponse; 47 + 48 + factory SendMessageResponse.fromJson(Map<String, dynamic> json) => _$SendMessageResponseFromJson(json); 49 + } 50 + 51 + /// Response model for getting messages 52 + @freezed 53 + class GetMessagesResponse with _$GetMessagesResponse { 54 + const GetMessagesResponse._(); 55 + 56 + @JsonSerializable(explicitToJson: true) 57 + const factory GetMessagesResponse({ 58 + required List<ChatMessage> messages, 59 + }) = _GetMessagesResponse; 60 + 61 + factory GetMessagesResponse.fromJson(Map<String, dynamic> json) => _$GetMessagesResponseFromJson(json); 62 + } 63 + 64 + /// Response model for getting chats list 65 + @freezed 66 + class GetChatsResponse with _$GetChatsResponse { 67 + const GetChatsResponse._(); 68 + 69 + @JsonSerializable(explicitToJson: true) 70 + const factory GetChatsResponse({ 71 + required List<String> chats, 72 + }) = _GetChatsResponse; 73 + 74 + factory GetChatsResponse.fromJson(Map<String, dynamic> json) => _$GetChatsResponseFromJson(json); 75 + } 76 + 77 + /// WebSocket message types 78 + enum WebSocketMessageType { 79 + @JsonValue('new_message') 80 + newMessage, 81 + @JsonValue('message_read') 82 + messageRead, 83 + @JsonValue('typing') 84 + typing, 85 + @JsonValue('error') 86 + error, 87 + } 88 + 89 + /// WebSocket message data for new message events 90 + @freezed 91 + class WebSocketMessageData with _$WebSocketMessageData { 92 + const WebSocketMessageData._(); 93 + 94 + @JsonSerializable(explicitToJson: true) 95 + const factory WebSocketMessageData({ 96 + required String id, 97 + required String message, 98 + @JsonKey(name: 'sender_did') required String senderDid, 99 + @JsonKey(name: 'receiver_did') required String receiverDid, 100 + required DateTime timestamp, 101 + }) = _WebSocketMessageData; 102 + 103 + factory WebSocketMessageData.fromJson(Map<String, dynamic> json) => _$WebSocketMessageDataFromJson(json); 104 + } 105 + 106 + /// WebSocket message wrapper 107 + @freezed 108 + class WebSocketMessage with _$WebSocketMessage { 109 + const WebSocketMessage._(); 110 + 111 + @JsonSerializable(explicitToJson: true) 112 + const factory WebSocketMessage({ 113 + required WebSocketMessageType type, 114 + WebSocketMessageData? data, 115 + String? error, 116 + }) = _WebSocketMessage; 117 + 118 + factory WebSocketMessage.fromJson(Map<String, dynamic> json) => _$WebSocketMessageFromJson(json); 119 + } 120 + 121 + /// Health check response 122 + @freezed 123 + class HealthCheckResponse with _$HealthCheckResponse { 124 + const HealthCheckResponse._(); 125 + 126 + @JsonSerializable(explicitToJson: true) 127 + const factory HealthCheckResponse({ 128 + required String status, 129 + required DateTime timestamp, 130 + required int connectedClients, 131 + }) = _HealthCheckResponse; 132 + 133 + factory HealthCheckResponse.fromJson(Map<String, dynamic> json) => _$HealthCheckResponseFromJson(json); 134 + } 135 + 136 + /// Chat conversation model for UI purposes 137 + @freezed 138 + class ChatConversation with _$ChatConversation { 139 + const ChatConversation._(); 140 + 141 + @JsonSerializable(explicitToJson: true) 142 + const factory ChatConversation({ 143 + required String otherUserDid, 144 + String? otherUserHandle, 145 + String? otherUserDisplayName, 146 + String? otherUserAvatar, 147 + ChatMessage? lastMessage, 148 + @Default(0) int unreadCount, 149 + DateTime? lastActivity, 150 + }) = _ChatConversation; 151 + 152 + factory ChatConversation.fromJson(Map<String, dynamic> json) => _$ChatConversationFromJson(json); 153 + }
+1
lib/src/core/network/chat/data/models/models.dart
··· 1 + export 'chat_models.dart';
+36
lib/src/core/network/chat/data/repositories/chat_repository.dart
··· 1 + import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 2 + import 'package:web_socket_channel/web_socket_channel.dart'; 3 + 4 + /// Interface for Spark Chat API endpoints 5 + abstract class ChatRepository { 6 + /// Send a message to another user 7 + /// 8 + /// [message] The message content to send 9 + /// [receiverDid] The DID of the user to send the message to 10 + Future<SendMessageResponse> sendMessage(String message, String receiverDid); 11 + 12 + /// Get messages for a conversation with another user 13 + /// 14 + /// [otherDid] The DID of the other user in the conversation 15 + /// [limit] Maximum number of messages to retrieve (default 50) 16 + Future<GetMessagesResponse> getMessages(String otherDid, {int limit = 50}); 17 + 18 + /// Get list of all chats (conversations) for the current user 19 + Future<GetChatsResponse> getChats(); 20 + 21 + /// Connect to WebSocket for real-time messaging 22 + /// 23 + /// Returns a WebSocketChannel for receiving real-time messages 24 + WebSocketChannel connectWebSocket(); 25 + 26 + /// Check the health status of the chat service 27 + Future<HealthCheckResponse> healthCheck(); 28 + 29 + /// Set the JWT token for authentication 30 + /// 31 + /// [token] The JWT token from ATProtocol authentication 32 + void setAuthToken(String token); 33 + 34 + /// Close any open WebSocket connections 35 + void closeWebSocket(); 36 + }
+195
lib/src/core/network/chat/data/repositories/chat_repository_impl.dart
··· 1 + import 'dart:convert'; 2 + import 'dart:io'; 3 + 4 + import 'package:get_it/get_it.dart'; 5 + import 'package:http/http.dart' as http; 6 + import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 7 + import 'package:sparksocial/src/core/network/chat/data/repositories/chat_repository.dart'; 8 + import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 9 + import 'package:web_socket_channel/web_socket_channel.dart'; 10 + 11 + /// Implementation of Spark Chat API endpoints 12 + class ChatRepositoryImpl implements ChatRepository { 13 + static const String _baseUrl = 'https://chat.sprk.so'; 14 + static const String _wsUrl = 'wss://chat.sprk.so/ws'; 15 + 16 + final _logger = GetIt.instance<LogService>().getLogger('ChatRepository'); 17 + final http.Client _httpClient; 18 + 19 + String? _jwtToken; 20 + WebSocketChannel? _webSocketChannel; 21 + 22 + ChatRepositoryImpl([http.Client? httpClient]) : _httpClient = httpClient ?? http.Client() { 23 + _logger.v('ChatRepository initialized'); 24 + } 25 + 26 + @override 27 + void setAuthToken(String token) { 28 + _jwtToken = token; 29 + _logger.d('JWT token set for chat authentication'); 30 + } 31 + 32 + Map<String, String> get _authHeaders { 33 + if (_jwtToken == null) { 34 + throw Exception('JWT token not set. Call setAuthToken() first.'); 35 + } 36 + return { 37 + 'Authorization': 'Bearer $_jwtToken', 38 + 'Content-Type': 'application/json', 39 + }; 40 + } 41 + 42 + @override 43 + Future<SendMessageResponse> sendMessage(String message, String receiverDid) async { 44 + _logger.d('Sending message to DID: $receiverDid'); 45 + 46 + try { 47 + final request = SendMessageRequest( 48 + message: message, 49 + receiverDid: receiverDid, 50 + ); 51 + 52 + final response = await _httpClient.post( 53 + Uri.parse('$_baseUrl/xrpc/so.sprk.chat.sendMessage'), 54 + headers: _authHeaders, 55 + body: jsonEncode(request.toJson()), 56 + ); 57 + 58 + if (response.statusCode == 200) { 59 + final responseData = jsonDecode(response.body) as Map<String, dynamic>; 60 + _logger.d('Message sent successfully'); 61 + return SendMessageResponse.fromJson(responseData); 62 + } else { 63 + _logger.e('Failed to send message: ${response.statusCode} ${response.body}'); 64 + throw Exception('Failed to send message: ${response.statusCode} ${response.body}'); 65 + } 66 + } catch (e) { 67 + _logger.e('Error sending message', error: e); 68 + rethrow; 69 + } 70 + } 71 + 72 + @override 73 + Future<GetMessagesResponse> getMessages(String otherDid, {int limit = 50}) async { 74 + _logger.d('Getting messages for conversation with DID: $otherDid, limit: $limit'); 75 + 76 + try { 77 + final uri = Uri.parse('$_baseUrl/xrpc/so.sprk.chat.getMessages').replace( 78 + queryParameters: { 79 + 'otherDid': otherDid, 80 + 'limit': limit.toString(), 81 + }, 82 + ); 83 + 84 + final response = await _httpClient.get( 85 + uri, 86 + headers: _authHeaders, 87 + ); 88 + 89 + if (response.statusCode == 200) { 90 + final responseData = jsonDecode(response.body) as Map<String, dynamic>; 91 + _logger.d('Messages retrieved successfully'); 92 + return GetMessagesResponse.fromJson(responseData); 93 + } else { 94 + _logger.e('Failed to get messages: ${response.statusCode} ${response.body}'); 95 + throw Exception('Failed to get messages: ${response.statusCode} ${response.body}'); 96 + } 97 + } catch (e) { 98 + _logger.e('Error getting messages', error: e); 99 + rethrow; 100 + } 101 + } 102 + 103 + @override 104 + Future<GetChatsResponse> getChats() async { 105 + _logger.d('Getting chats list'); 106 + 107 + try { 108 + final response = await _httpClient.get( 109 + Uri.parse('$_baseUrl/xrpc/so.sprk.chat.getChats'), 110 + headers: _authHeaders, 111 + ); 112 + 113 + if (response.statusCode == 200) { 114 + final responseData = jsonDecode(response.body) as Map<String, dynamic>; 115 + _logger.d('Chats list retrieved successfully'); 116 + return GetChatsResponse.fromJson(responseData); 117 + } else { 118 + _logger.e('Failed to get chats: ${response.statusCode} ${response.body}'); 119 + throw Exception('Failed to get chats: ${response.statusCode} ${response.body}'); 120 + } 121 + } catch (e) { 122 + _logger.e('Error getting chats', error: e); 123 + rethrow; 124 + } 125 + } 126 + 127 + @override 128 + WebSocketChannel connectWebSocket() { 129 + _logger.d('Connecting to WebSocket'); 130 + 131 + try { 132 + if (_jwtToken == null) { 133 + throw Exception('JWT token not set. Call setAuthToken() first.'); 134 + } 135 + 136 + // Close existing connection if any 137 + closeWebSocket(); 138 + 139 + _webSocketChannel = WebSocketChannel.connect( 140 + Uri.parse(_wsUrl), 141 + protocols: ['Bearer $_jwtToken'], // Some WebSocket implementations expect protocols 142 + ); 143 + 144 + _logger.d('WebSocket connected successfully'); 145 + return _webSocketChannel!; 146 + } catch (e) { 147 + _logger.e('Error connecting to WebSocket', error: e); 148 + rethrow; 149 + } 150 + } 151 + 152 + @override 153 + void closeWebSocket() { 154 + _logger.d('Closing WebSocket connection'); 155 + 156 + try { 157 + _webSocketChannel?.sink.close(); 158 + _webSocketChannel = null; 159 + _logger.d('WebSocket connection closed'); 160 + } catch (e) { 161 + _logger.w('Error closing WebSocket', error: e); 162 + } 163 + } 164 + 165 + @override 166 + Future<HealthCheckResponse> healthCheck() async { 167 + _logger.d('Performing health check'); 168 + 169 + try { 170 + final response = await _httpClient.get( 171 + Uri.parse('$_baseUrl/health'), 172 + // Health check doesn't require authentication 173 + ); 174 + 175 + if (response.statusCode == 200) { 176 + final responseData = jsonDecode(response.body) as Map<String, dynamic>; 177 + _logger.d('Health check successful'); 178 + return HealthCheckResponse.fromJson(responseData); 179 + } else { 180 + _logger.e('Health check failed: ${response.statusCode} ${response.body}'); 181 + throw Exception('Health check failed: ${response.statusCode} ${response.body}'); 182 + } 183 + } catch (e) { 184 + _logger.e('Error during health check', error: e); 185 + rethrow; 186 + } 187 + } 188 + 189 + /// Dispose of resources 190 + void dispose() { 191 + _logger.d('Disposing ChatRepository'); 192 + closeWebSocket(); 193 + _httpClient.close(); 194 + } 195 + }
+2
lib/src/core/network/chat/data/repositories/repositories.dart
··· 1 + export 'chat_repository.dart'; 2 + export 'chat_repository_impl.dart';
-176
lib/src/core/network/messages/data/models/message.dart
··· 1 - // Dart models for chat messages and conversations generated with Freezed 2 - // Following project guidelines: English language, explicit typing, and consistent nomenclature. 3 - 4 - import 'package:freezed_annotation/freezed_annotation.dart'; 5 - 6 - part 'message.freezed.dart'; 7 - part 'message.g.dart'; 8 - 9 - @JsonEnum() 10 - enum MessageType { 11 - text, 12 - image, 13 - video, 14 - audio, 15 - file, 16 - system, 17 - } 18 - 19 - @JsonEnum() 20 - enum MessageStatus { 21 - sending, 22 - sent, 23 - delivered, 24 - read, 25 - failed, 26 - } 27 - 28 - @JsonEnum() 29 - enum ConversationType { 30 - direct, 31 - group, 32 - } 33 - 34 - @JsonEnum() 35 - enum ParticipantRole { 36 - member, 37 - admin, 38 - owner, 39 - } 40 - 41 - @freezed 42 - class ChatParticipant with _$ChatParticipant { 43 - const ChatParticipant._(); 44 - 45 - @JsonSerializable(explicitToJson: true) 46 - const factory ChatParticipant({ 47 - required String id, 48 - required String username, 49 - String? displayName, 50 - String? avatarUrl, 51 - @Default(ParticipantRole.member) ParticipantRole role, 52 - DateTime? lastSeen, 53 - @Default(false) bool isOnline, 54 - }) = _ChatParticipant; 55 - 56 - factory ChatParticipant.fromJson(Map<String, dynamic> json) => _$ChatParticipantFromJson(json); 57 - 58 - /// Returns the participant's display name if available, otherwise their username. 59 - String get name => displayName ?? username; 60 - } 61 - 62 - @freezed 63 - class ChatMessage with _$ChatMessage { 64 - const ChatMessage._(); 65 - 66 - @JsonSerializable(explicitToJson: true) 67 - const factory ChatMessage({ 68 - required String id, 69 - required String conversationId, 70 - required String senderId, 71 - required String content, 72 - @Default(MessageType.text) MessageType type, 73 - @Default(MessageStatus.sent) MessageStatus status, 74 - required DateTime timestamp, 75 - DateTime? editedAt, 76 - String? replyToMessageId, 77 - Map<String, dynamic>? metadata, 78 - List<String>? attachments, 79 - }) = _ChatMessage; 80 - 81 - factory ChatMessage.fromJson(Map<String, dynamic> json) => _$ChatMessageFromJson(json); 82 - 83 - bool get isEdited => editedAt != null; 84 - bool get isReply => replyToMessageId != null; 85 - bool get hasAttachments => attachments != null && attachments!.isNotEmpty; 86 - } 87 - 88 - @freezed 89 - class Conversation with _$Conversation { 90 - const Conversation._(); 91 - 92 - @JsonSerializable(explicitToJson: true) 93 - const factory Conversation({ 94 - required String id, 95 - String? title, 96 - required ConversationType type, 97 - required List<ChatParticipant> participants, 98 - ChatMessage? lastMessage, 99 - required DateTime lastActivity, 100 - @Default(0) int unreadCount, 101 - @Default(false) bool isMuted, 102 - @Default(false) bool isPinned, 103 - String? avatarUrl, 104 - Map<String, dynamic>? metadata, 105 - }) = _Conversation; 106 - 107 - factory Conversation.fromJson(Map<String, dynamic> json) => _$ConversationFromJson(json); 108 - 109 - /// Returns a displayable title for the conversation based on its participants and provided title. 110 - String get displayTitle { 111 - if (title != null && title!.isNotEmpty) { 112 - return title!; 113 - } 114 - 115 - if (type == ConversationType.direct && participants.length == 2) { 116 - // Assuming the current user id will be provided in UI/business layer. 117 - return participants.first.name; 118 - } 119 - 120 - if (participants.length > 1) { 121 - final names = participants.take(3).map((p) => p.name).join(', '); 122 - if (participants.length > 3) { 123 - return '$names and ${(participants.length - 3)} more'; 124 - } 125 - return names; 126 - } 127 - 128 - return 'Conversation'; 129 - } 130 - 131 - /// Chooses an avatar URL for the conversation. 132 - String? get displayAvatarUrl { 133 - if (avatarUrl != null) return avatarUrl; 134 - 135 - if (type == ConversationType.direct && participants.length == 2) { 136 - return participants.first.avatarUrl; 137 - } 138 - 139 - return null; 140 - } 141 - 142 - bool get hasUnreadMessages => unreadCount > 0; 143 - 144 - /// Generates a preview string for the last message, suitable for UI subtitle. 145 - String get lastMessagePreview { 146 - if (lastMessage == null) return ''; 147 - 148 - return switch (lastMessage!.type) { 149 - MessageType.text => lastMessage!.content, 150 - MessageType.image => '📷 Photo', 151 - MessageType.video => '🎥 Video', 152 - MessageType.audio => '🎵 Audio', 153 - MessageType.file => '📎 File', 154 - MessageType.system => lastMessage!.content, 155 - }; 156 - } 157 - 158 - /// Human-friendly formatted last activity for UI display. 159 - String get formattedLastActivity { 160 - final now = DateTime.now(); 161 - final difference = now.difference(lastActivity); 162 - 163 - if (difference.inMinutes < 1) { 164 - return 'Just now'; 165 - } else if (difference.inHours < 1) { 166 - return '${difference.inMinutes}m ago'; 167 - } else if (difference.inDays < 1) { 168 - return '${difference.inHours}h ago'; 169 - } else if (difference.inDays < 7) { 170 - const weekdays = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; 171 - return weekdays[lastActivity.weekday - 1]; 172 - } else { 173 - return '${lastActivity.day}/${lastActivity.month}/${lastActivity.year}'; 174 - } 175 - } 176 - }
-38
lib/src/core/network/messages/data/repositories/chat_repository.dart
··· 1 - import 'dart:async'; 2 - import 'package:sparksocial/src/core/network/messages/data/models/message.dart'; 3 - 4 - abstract class ChatRepository { 5 - /// Stream of conversations updates 6 - Stream<List<Conversation>> get conversationsStream; 7 - 8 - /// Stream of messages updates 9 - Stream<List<ChatMessage>> get messagesStream; 10 - 11 - /// Initialize the chat repository 12 - Future<void> initialize(); 13 - 14 - /// Get all conversations for the current user 15 - Future<List<Conversation>> getConversations(); 16 - 17 - /// Get messages for a specific conversation 18 - Future<List<ChatMessage>> getMessages(String conversationId); 19 - 20 - /// Get a specific conversation by ID 21 - Future<Conversation?> getConversation(String conversationId); 22 - 23 - /// Send a message to a conversation 24 - Future<void> sendMessage({ 25 - required String conversationId, 26 - required String content, 27 - MessageType type = MessageType.text, 28 - }); 29 - 30 - /// Mark a conversation as read 31 - Future<void> markAsRead(String conversationId); 32 - 33 - /// Create or get an existing conversation 34 - Future<Conversation> createOrGetConversation(Conversation newConversation); 35 - 36 - /// Dispose resources 37 - void dispose(); 38 - }
-471
lib/src/core/network/messages/data/repositories/chat_repository_impl.dart
··· 1 - import 'dart:async'; 2 - import 'package:get_it/get_it.dart'; 3 - import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 4 - import 'package:sparksocial/src/core/network/messages/data/models/message.dart'; 5 - import 'package:sparksocial/src/core/network/messages/data/services/chat_socket_service.dart'; 6 - import 'package:sparksocial/src/core/network/messages/data/services/chat_api_service.dart'; 7 - import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 8 - import 'chat_repository.dart'; 9 - 10 - class ChatRepositoryImpl implements ChatRepository { 11 - final _sl = GetIt.instance; 12 - final _logger = GetIt.instance<LogService>().getLogger('ChatRepositoryImpl'); 13 - 14 - final StreamController<List<Conversation>> _conversationsController = StreamController<List<Conversation>>.broadcast(); 15 - final StreamController<List<ChatMessage>> _messagesController = StreamController<List<ChatMessage>>.broadcast(); 16 - 17 - List<Conversation> _conversations = []; 18 - final Map<String, List<ChatMessage>> _messagesByConversation = {}; 19 - 20 - @override 21 - Stream<List<Conversation>> get conversationsStream => _conversationsController.stream; 22 - 23 - @override 24 - Stream<List<ChatMessage>> get messagesStream => _messagesController.stream; 25 - 26 - @override 27 - Future<void> initialize() async { 28 - _logger.i('Initializing chat repository'); 29 - 30 - try { 31 - // Initialize socket connection 32 - final socketService = ChatSocketService(); 33 - final socket = await socketService.socket; 34 - 35 - // Set up socket event listeners 36 - _setupSocketListeners(socket); 37 - 38 - // Load initial conversations via REST API 39 - await _loadInitialConversations(); 40 - 41 - _conversationsController.add(_conversations); 42 - _logger.i('Chat repository initialized successfully'); 43 - } catch (e) { 44 - _logger.e('Failed to initialize chat repository', error: e); 45 - rethrow; 46 - } 47 - } 48 - 49 - void _setupSocketListeners(socket) { 50 - // Listen for new messages (matching the example's event name) 51 - socket.on('new-message', (data) { 52 - _logger.d('Received new message: $data'); 53 - _handleNewMessage(data); 54 - }); 55 - 56 - // Listen for message status updates 57 - socket.on('message-status-update', (data) { 58 - _logger.d('Received message status update: $data'); 59 - _handleMessageStatusUpdate(data); 60 - }); 61 - 62 - // Listen for read receipts 63 - socket.on('message-read', (data) { 64 - _logger.d('Received message read event: $data'); 65 - _handleMessageRead(data); 66 - }); 67 - 68 - // Listen for chat updates 69 - socket.on('chat-updated', (data) { 70 - _logger.d('Received chat update: $data'); 71 - _handleConversationUpdate(data); 72 - }); 73 - 74 - // Listen for connection events 75 - socket.on('connect', (_) { 76 - _logger.i('Socket connected successfully'); 77 - }); 78 - 79 - socket.on('connect_error', (error) { 80 - _logger.e('Socket connection error', error: error); 81 - }); 82 - } 83 - 84 - Future<void> _loadInitialConversations() async { 85 - try { 86 - final apiService = ChatApiService(); 87 - final result = await apiService.getChats(); 88 - 89 - // ------------------------------------------------------ 90 - // Normalise the API response so we always end up with a 91 - // `List<Map<String, dynamic>>` that represents the chats. 92 - // ------------------------------------------------------ 93 - 94 - dynamic chatsRaw; 95 - 96 - if (result is List) { 97 - // The entire response is already the list of chats. 98 - chatsRaw = result; 99 - } else { 100 - // Preferred nesting is result['data']['chats']. 101 - final dynamic dataSegment = result.containsKey('data') ? result['data'] : result; 102 - 103 - if (dataSegment is List) { 104 - chatsRaw = dataSegment; 105 - } else if (dataSegment is Map) { 106 - // If there is an explicit `chats` key use that, otherwise take all values. 107 - chatsRaw = dataSegment.containsKey('chats') ? dataSegment['chats'] : dataSegment.values.toList(); 108 - } 109 - } 110 - 111 - 112 - List<dynamic> chatsData; 113 - if (chatsRaw is List) { 114 - chatsData = chatsRaw; 115 - } else if (chatsRaw is Map) { 116 - chatsData = chatsRaw.values.toList(); 117 - } else { 118 - chatsData = []; 119 - } 120 - 121 - _conversations = chatsData 122 - .whereType<Map<String, dynamic>>() 123 - .map(_mapApiChatToConversation) 124 - .toList(); 125 - } catch (e) { 126 - _logger.e('Failed to load initial conversations', error: e); 127 - _conversations = []; 128 - } 129 - } 130 - 131 - Conversation _mapApiChatToConversation(Map<String, dynamic> chatData) { 132 - // Map the API response to our Conversation model 133 - // This will need to match the actual API response structure 134 - return Conversation( 135 - id: chatData['chatId'] ?? chatData['id'] ?? 'unknown', 136 - type: chatData['type'] == 'private' ? ConversationType.direct : ConversationType.group, 137 - participants: ((chatData['participants'] ?? []) as List) 138 - .map((dynamic p) { 139 - // If the participant is already a map with properties use them, otherwise 140 - // treat the value as a user id and build a minimal placeholder record. 141 - if (p is Map<String, dynamic>) { 142 - return ChatParticipant( 143 - id: p['id'] ?? p['userId'] ?? 'unknown', 144 - username: p['username'] ?? p['handle'] ?? p['id'] ?? 'unknown', 145 - displayName: p['displayName'] ?? p['name'], 146 - avatarUrl: p['avatarUrl'] ?? p['avatar'], 147 - isOnline: p['isOnline'] ?? false, 148 - ); 149 - } else { 150 - // Fallback – the API only returned a raw identifier (string / int) 151 - final String participantId = p != null ? p.toString() : 'unknown'; 152 - return ChatParticipant( 153 - id: participantId, 154 - username: participantId, 155 - displayName: null, 156 - avatarUrl: null, 157 - isOnline: false, 158 - ); 159 - } 160 - }) 161 - .toList(), 162 - title: chatData['title'], 163 - lastActivity: chatData['lastActivity'] != null 164 - ? DateTime.tryParse(chatData['lastActivity']) ?? DateTime.now() 165 - : DateTime.now(), 166 - unreadCount: chatData['unreadCount'] ?? 0, 167 - ); 168 - } 169 - 170 - void _handleConversationUpdate(Map<String, dynamic> data) { 171 - try { 172 - final conversation = _mapApiChatToConversation(data); 173 - final index = _conversations.indexWhere((c) => c.id == conversation.id); 174 - 175 - if (index != -1) { 176 - _conversations[index] = conversation; 177 - } else { 178 - _conversations.insert(0, conversation); 179 - } 180 - 181 - _conversationsController.add(_conversations); 182 - } catch (e) { 183 - _logger.e('Failed to handle conversation update', error: e); 184 - } 185 - } 186 - 187 - void _handleNewMessage(Map<String, dynamic> data) { 188 - try { 189 - // Map the socket message data to our ChatMessage model 190 - final message = ChatMessage( 191 - id: data['messageId'] ?? data['id'] ?? 'unknown', 192 - conversationId: data['chatId'] ?? data['conversationId'] ?? 'unknown', 193 - senderId: data['senderId'] ?? data['userId'] ?? 'unknown', 194 - content: data['text'] ?? data['content'] ?? '', 195 - type: MessageType.text, // Default to text for now 196 - status: MessageStatus.delivered, 197 - timestamp: data['timestamp'] != null ? DateTime.tryParse(data['timestamp']) ?? DateTime.now() : DateTime.now(), 198 - ); 199 - 200 - final conversationId = message.conversationId; 201 - 202 - _messagesByConversation[conversationId] ??= []; 203 - _messagesByConversation[conversationId]!.add(message); 204 - 205 - // Update conversation with latest message 206 - final conversationIndex = _conversations.indexWhere((c) => c.id == conversationId); 207 - if (conversationIndex != -1) { 208 - final currentUserDid = _sl<AuthRepository>().session?.did; 209 - final isFromCurrentUser = message.senderId == currentUserDid; 210 - 211 - _conversations[conversationIndex] = _conversations[conversationIndex].copyWith( 212 - lastMessage: message, 213 - lastActivity: message.timestamp, 214 - unreadCount: isFromCurrentUser 215 - ? _conversations[conversationIndex].unreadCount 216 - : _conversations[conversationIndex].unreadCount + 1, 217 - ); 218 - 219 - _conversationsController.add(_conversations); 220 - } 221 - 222 - _messagesController.add(_messagesByConversation[conversationId]!); 223 - } catch (e) { 224 - _logger.e('Failed to handle new message', error: e); 225 - } 226 - } 227 - 228 - void _handleMessageStatusUpdate(Map<String, dynamic> data) { 229 - try { 230 - final messageId = data['messageId'] as String; 231 - final status = MessageStatus.values.firstWhere((s) => s.name == data['status'], orElse: () => MessageStatus.sent); 232 - final conversationId = data['chatId'] ?? data['conversationId'] as String; 233 - 234 - final messages = _messagesByConversation[conversationId]; 235 - if (messages != null) { 236 - final messageIndex = messages.indexWhere((m) => m.id == messageId); 237 - if (messageIndex != -1) { 238 - messages[messageIndex] = messages[messageIndex].copyWith(status: status); 239 - _messagesController.add(messages); 240 - } 241 - } 242 - } catch (e) { 243 - _logger.e('Failed to handle message status update', error: e); 244 - } 245 - } 246 - 247 - void _handleMessageRead(Map<String, dynamic> data) { 248 - try { 249 - final conversationId = data['chatId'] ?? data['conversationId'] as String; 250 - 251 - // Update conversation unread count 252 - final conversationIndex = _conversations.indexWhere((c) => c.id == conversationId); 253 - if (conversationIndex != -1) { 254 - _conversations[conversationIndex] = _conversations[conversationIndex].copyWith(unreadCount: 0); 255 - _conversationsController.add(_conversations); 256 - } 257 - } catch (e) { 258 - _logger.e('Failed to handle message read event', error: e); 259 - } 260 - } 261 - 262 - @override 263 - Future<List<Conversation>> getConversations() async { 264 - return List.unmodifiable(_conversations); 265 - } 266 - 267 - @override 268 - Future<List<ChatMessage>> getMessages(String conversationId) async { 269 - if (_messagesByConversation.containsKey(conversationId)) { 270 - return _messagesByConversation[conversationId]!; 271 - } 272 - 273 - try { 274 - // Try to get messages via REST API first 275 - final apiService = ChatApiService(); 276 - final result = await apiService.getChatMessages(conversationId); 277 - 278 - if (result['data'] != null && result['data']['messages'] != null) { 279 - final messagesData = result['data']['messages'] as List; 280 - final messages = messagesData 281 - .map( 282 - (msgData) => ChatMessage( 283 - id: msgData['messageId'] ?? msgData['id'] ?? 'unknown', 284 - conversationId: conversationId, 285 - senderId: msgData['senderId'] ?? msgData['userId'] ?? 'unknown', 286 - content: msgData['text'] ?? msgData['content'] ?? '', 287 - type: MessageType.text, 288 - status: MessageStatus.delivered, 289 - timestamp: msgData['timestamp'] != null 290 - ? DateTime.tryParse(msgData['timestamp']) ?? DateTime.now() 291 - : DateTime.now(), 292 - ), 293 - ) 294 - .toList(); 295 - 296 - _messagesByConversation[conversationId] = messages; 297 - return messages; 298 - } 299 - 300 - // Fallback to socket request 301 - final socketService = ChatSocketService(); 302 - final socket = await socketService.socket; 303 - 304 - socket.emit('join-chat', {'chatId': conversationId, 'userId': socket.id}); 305 - 306 - // For now, return empty list and wait for socket response 307 - _messagesByConversation[conversationId] = []; 308 - return []; 309 - } catch (e) { 310 - _logger.e('Failed to get messages for conversation $conversationId', error: e); 311 - return []; 312 - } 313 - } 314 - 315 - @override 316 - Future<Conversation?> getConversation(String conversationId) async { 317 - try { 318 - return _conversations.firstWhere((c) => c.id == conversationId); 319 - } catch (e) { 320 - return null; 321 - } 322 - } 323 - 324 - @override 325 - Future<void> sendMessage({required String conversationId, required String content, MessageType type = MessageType.text}) async { 326 - final authRepository = _sl<AuthRepository>(); 327 - if (!authRepository.isAuthenticated || authRepository.session == null) { 328 - throw Exception('Not authenticated. Cannot send message.'); 329 - } 330 - 331 - final userDid = authRepository.session!.did; 332 - 333 - // Create optimistic message 334 - final message = ChatMessage( 335 - id: 'temp_${DateTime.now().millisecondsSinceEpoch}', 336 - conversationId: conversationId, 337 - senderId: userDid, 338 - content: content, 339 - type: type, 340 - status: MessageStatus.sending, 341 - timestamp: DateTime.now(), 342 - ); 343 - 344 - // Add to local messages immediately 345 - _messagesByConversation[conversationId] ??= []; 346 - _messagesByConversation[conversationId]!.add(message); 347 - 348 - // Update conversation 349 - final conversationIndex = _conversations.indexWhere((c) => c.id == conversationId); 350 - if (conversationIndex != -1) { 351 - _conversations[conversationIndex] = _conversations[conversationIndex].copyWith( 352 - lastMessage: message, 353 - lastActivity: DateTime.now(), 354 - ); 355 - _conversationsController.add(_conversations); 356 - } 357 - 358 - _messagesController.add(_messagesByConversation[conversationId]!); 359 - 360 - try { 361 - // Send via socket using the event name from the example 362 - final socketService = ChatSocketService(); 363 - final socket = await socketService.socket; 364 - 365 - socket.emit('send-message', {'chatId': conversationId, 'senderId': userDid, 'text': content, 'tempId': message.id}); 366 - 367 - _logger.i('Message sent successfully'); 368 - } catch (e) { 369 - // Update message status to failed 370 - final messageIndex = _messagesByConversation[conversationId]!.length - 1; 371 - _messagesByConversation[conversationId]![messageIndex] = message.copyWith(status: MessageStatus.failed); 372 - 373 - _messagesController.add(_messagesByConversation[conversationId]!); 374 - 375 - _logger.e('Failed to send message', error: e); 376 - rethrow; 377 - } 378 - } 379 - 380 - @override 381 - Future<void> markAsRead(String conversationId) async { 382 - try { 383 - // Send via socket 384 - final socketService = ChatSocketService(); 385 - final socket = await socketService.socket; 386 - 387 - socket.emit('mark-as-read', {'chatId': conversationId}); 388 - 389 - // Update local state immediately 390 - final conversationIndex = _conversations.indexWhere((c) => c.id == conversationId); 391 - if (conversationIndex != -1) { 392 - _conversations[conversationIndex] = _conversations[conversationIndex].copyWith(unreadCount: 0); 393 - _conversationsController.add(_conversations); 394 - } 395 - 396 - _logger.i('Marked conversation $conversationId as read'); 397 - } catch (e) { 398 - _logger.e('Failed to mark conversation as read', error: e); 399 - rethrow; 400 - } 401 - } 402 - 403 - @override 404 - Future<Conversation> createOrGetConversation(Conversation newConversation) async { 405 - final currentUserDid = _sl<AuthRepository>().session?.did ?? 'current_user_id'; 406 - final otherParticipant = newConversation.participants.firstWhere( 407 - (p) => p.id != currentUserDid, 408 - orElse: () => newConversation.participants.first, 409 - ); 410 - 411 - // Check for existing conversation 412 - final existingConversation = _conversations.cast<Conversation?>().firstWhere( 413 - (c) => c != null && c.type == ConversationType.direct && c.participants.any((p) => p.id == otherParticipant.id), 414 - orElse: () => null, 415 - ); 416 - 417 - if (existingConversation != null) { 418 - return existingConversation; 419 - } 420 - 421 - try { 422 - // Create via REST API first 423 - final apiService = ChatApiService(); 424 - final participantIds = newConversation.participants.map((p) => p.id).toList(); 425 - 426 - final result = await apiService.createChat( 427 - type: newConversation.type == ConversationType.direct ? 'private' : 'group', 428 - participantIds: participantIds, 429 - ); 430 - 431 - // Extract the chat ID from the API response 432 - final chatId = result['data']?['chatId'] ?? result['data']?['id']; 433 - if (chatId == null) { 434 - throw Exception('No chat ID returned from API'); 435 - } 436 - 437 - // Create the conversation with the server-provided ID 438 - final dmConversation = newConversation.copyWith(id: chatId, lastActivity: DateTime.now(), unreadCount: 0); 439 - 440 - // Add to local state 441 - _conversations.insert(0, dmConversation); 442 - _messagesByConversation[dmConversation.id] = []; 443 - 444 - _conversationsController.add(_conversations); 445 - 446 - // Join the chat via socket 447 - final socketService = ChatSocketService(); 448 - final socket = await socketService.socket; 449 - 450 - socket.emit('join-chat', {'chatId': chatId, 'userId': currentUserDid}); 451 - 452 - _logger.i('Created new conversation with ${otherParticipant.displayName ?? otherParticipant.username}'); 453 - 454 - return dmConversation; 455 - } catch (e) { 456 - _logger.e('Failed to create conversation', error: e); 457 - rethrow; 458 - } 459 - } 460 - 461 - @override 462 - void dispose() { 463 - _logger.i('Disposing chat repository'); 464 - _conversationsController.close(); 465 - _messagesController.close(); 466 - 467 - // Dispose socket service 468 - final socketService = ChatSocketService(); 469 - socketService.dispose(); 470 - } 471 - }
-130
lib/src/core/network/messages/data/services/chat_api_service.dart
··· 1 - import 'dart:convert'; 2 - import 'package:get_it/get_it.dart'; 3 - import 'package:http/http.dart' as http; 4 - import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 5 - import 'package:sparksocial/src/core/config/app_config.dart'; 6 - import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 7 - 8 - /// Service responsible for REST API calls to the chat service 9 - class ChatApiService { 10 - ChatApiService._(); 11 - 12 - // Singleton instance 13 - static final ChatApiService _instance = ChatApiService._(); 14 - factory ChatApiService() => _instance; 15 - 16 - final _sl = GetIt.instance; 17 - final _logger = GetIt.instance<LogService>().getLogger('ChatApiService'); 18 - 19 - /// Create a new chat conversation 20 - Future<Map<String, dynamic>> createChat({ 21 - required String type, // 'private' or 'group' 22 - required List<String> participantIds, 23 - }) async { 24 - final authRepository = _sl<AuthRepository>(); 25 - if (!authRepository.isAuthenticated || authRepository.session == null) { 26 - throw Exception('User is not authenticated'); 27 - } 28 - 29 - final jwt = authRepository.session!.accessJwt; 30 - final url = '${AppConfig.chatServiceUrl}/api/chats'; 31 - 32 - _logger.i('Creating chat via REST API: $url'); 33 - 34 - try { 35 - final response = await http.post( 36 - Uri.parse(url), 37 - headers: { 38 - 'Content-Type': 'application/json', 39 - 'Authorization': 'Bearer $jwt', // ATP JWT authentication 40 - }, 41 - body: jsonEncode({ 42 - 'type': type, 43 - 'participantIds': participantIds, 44 - }), 45 - ); 46 - 47 - if (response.statusCode == 200 || response.statusCode == 201) { 48 - final result = jsonDecode(response.body) as Map<String, dynamic>; 49 - _logger.i('Chat created successfully: ${result['data']?['chatId']}'); 50 - return result; 51 - } else { 52 - _logger.e('Failed to create chat: ${response.statusCode} - ${response.body}'); 53 - throw Exception('Failed to create chat: ${response.statusCode}'); 54 - } 55 - } catch (e) { 56 - _logger.e('Error creating chat', error: e); 57 - rethrow; 58 - } 59 - } 60 - 61 - /// Get chat conversations for the current user 62 - Future<Map<String, dynamic>> getChats() async { 63 - final authRepository = _sl<AuthRepository>(); 64 - if (!authRepository.isAuthenticated || authRepository.session == null) { 65 - throw Exception('User is not authenticated'); 66 - } 67 - 68 - final jwt = authRepository.session!.accessJwt; 69 - final url = '${AppConfig.chatServiceUrl}/api/chats'; 70 - 71 - _logger.i('Getting chats via REST API: $url'); 72 - 73 - try { 74 - final response = await http.get( 75 - Uri.parse(url), 76 - headers: { 77 - 'Content-Type': 'application/json', 78 - 'Authorization': 'Bearer $jwt', // ATP JWT authentication 79 - }, 80 - ); 81 - 82 - if (response.statusCode == 200) { 83 - final result = jsonDecode(response.body) as Map<String, dynamic>; 84 - _logger.i('Chats retrieved successfully'); 85 - return result; 86 - } else { 87 - _logger.e('Failed to get chats: ${response.statusCode} - ${response.body}'); 88 - throw Exception('Failed to get chats: ${response.statusCode}'); 89 - } 90 - } catch (e) { 91 - _logger.e('Error getting chats', error: e); 92 - rethrow; 93 - } 94 - } 95 - 96 - /// Get messages for a specific chat 97 - Future<Map<String, dynamic>> getChatMessages(String chatId) async { 98 - final authRepository = _sl<AuthRepository>(); 99 - if (!authRepository.isAuthenticated || authRepository.session == null) { 100 - throw Exception('User is not authenticated'); 101 - } 102 - 103 - final jwt = authRepository.session!.accessJwt; 104 - final url = '${AppConfig.chatServiceUrl}/api/chats/$chatId/messages'; 105 - 106 - _logger.i('Getting chat messages via REST API: $url'); 107 - 108 - try { 109 - final response = await http.get( 110 - Uri.parse(url), 111 - headers: { 112 - 'Content-Type': 'application/json', 113 - 'Authorization': 'Bearer $jwt', // ATP JWT authentication 114 - }, 115 - ); 116 - 117 - if (response.statusCode == 200) { 118 - final result = jsonDecode(response.body) as Map<String, dynamic>; 119 - _logger.i('Chat messages retrieved successfully'); 120 - return result; 121 - } else { 122 - _logger.e('Failed to get chat messages: ${response.statusCode} - ${response.body}'); 123 - throw Exception('Failed to get chat messages: ${response.statusCode}'); 124 - } 125 - } catch (e) { 126 - _logger.e('Error getting chat messages', error: e); 127 - rethrow; 128 - } 129 - } 130 - }
-79
lib/src/core/network/messages/data/services/chat_socket_service.dart
··· 1 - import 'package:get_it/get_it.dart'; 2 - import 'package:socket_io_client/socket_io_client.dart' as io; 3 - import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 4 - import 'package:sparksocial/src/core/config/app_config.dart'; 5 - import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 6 - 7 - /// Service responsible for maintaining the Socket.io connection used by chat. 8 - /// 9 - /// This service exposes a single [socket] getter that returns a connected 10 - /// [IO.Socket] instance. The same instance is reused for the lifetime of the 11 - /// application to ensure we do not create multiple connections for the same 12 - /// host, which could lead to duplicated events and increased resource usage. 13 - class ChatSocketService { 14 - ChatSocketService._(); 15 - 16 - // Singleton instance (registered in GetIt but also exposed via factory). 17 - static final ChatSocketService _instance = ChatSocketService._(); 18 - factory ChatSocketService() => _instance; 19 - 20 - final _sl = GetIt.instance; 21 - final _logger = GetIt.instance<LogService>().getLogger('ChatSocketService'); 22 - 23 - io.Socket? _socket; 24 - 25 - /// Returns an active Socket.io connection or creates one if it does not exist. 26 - /// 27 - /// The JWT access token from the current [AuthRepository] session is included 28 - /// in the auth parameter so that the backend can authenticate the connection 29 - /// and automatically derive the user DID. 30 - Future<io.Socket> get socket async { 31 - if (_socket != null && _socket!.connected) { 32 - return _socket!; 33 - } 34 - 35 - final authRepository = _sl<AuthRepository>(); 36 - if (!authRepository.isAuthenticated || authRepository.session == null) { 37 - _logger.w('Attempted to create chat socket without an authenticated user'); 38 - throw Exception('User is not authenticated'); 39 - } 40 - 41 - final jwt = authRepository.session!.accessJwt; 42 - final url = '${AppConfig.chatServiceUrl}/chat'; 43 - 44 - _logger.i('Connecting to chat socket at $url'); 45 - 46 - _socket = io.io( 47 - url, 48 - io.OptionBuilder() 49 - .setTransports(['websocket']) // Required for Flutter native 50 - .enableForceNew() 51 - .enableReconnection() 52 - .setAuth({'token': jwt}) // ATP JWT authentication 53 - .build(), 54 - ); 55 - 56 - _socket!.onConnect((_) { 57 - _logger.i('Chat socket connected'); 58 - _logger.d('User DID: ${_socket!.id}'); 59 - 60 - // Emit user-online event as shown in the example 61 - _socket!.emit('user-online', { 62 - 'userId': _socket!.id // This is automatically set from JWT 63 - }); 64 - }); 65 - 66 - _socket!.onDisconnect((_) => _logger.w('Chat socket disconnected')); 67 - _socket!.onConnectError((err) => _logger.e('Chat socket connection error', error: err)); 68 - 69 - return _socket!; 70 - } 71 - 72 - /// Dispose the socket connection 73 - void dispose() { 74 - if (_socket != null) { 75 - _socket!.dispose(); 76 - _socket = null; 77 - } 78 - } 79 - }
-1
lib/src/core/routing/app_router.dart
··· 5 5 import 'package:video_player/video_player.dart'; 6 6 import 'package:image_picker/image_picker.dart'; 7 7 import 'package:collection/collection.dart'; 8 - import 'package:sparksocial/src/core/network/messages/data/models/message.dart'; 9 8 10 9 part 'app_router.gr.dart'; 11 10
+14 -2
lib/src/features/feed/ui/pages/standalone_post_page.dart
··· 108 108 children: [ 109 109 // Main content 110 110 switch (postData.embed) { 111 - EmbedViewVideo() || EmbedViewBskyVideo() => PostVideoPlayer( 111 + EmbedViewVideo() => PostVideoPlayer( 112 112 key: _videoPlayerKey, 113 113 videoUrl: postData.videoUrl, 114 114 // For standalone, we don't need feed and index 115 + isSparkPost: true, 116 + ), 117 + EmbedViewBskyVideo() => PostVideoPlayer( 118 + key: _videoPlayerKey, 119 + videoUrl: postData.videoUrl, 120 + isSparkPost: false, 115 121 ), 116 122 EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: postData.imageUrls), 117 123 EmbedViewBskyRecordWithMedia(:final media) => switch (media) { 118 - EmbedViewVideo() || EmbedViewBskyVideo() => PostVideoPlayer( 124 + EmbedViewVideo() => PostVideoPlayer( 119 125 key: _videoPlayerKey, 120 126 videoUrl: postData.videoUrl, 127 + isSparkPost: true, 128 + ), 129 + EmbedViewBskyVideo() => PostVideoPlayer( 130 + key: _videoPlayerKey, 131 + videoUrl: postData.videoUrl, 132 + isSparkPost: false, 121 133 ), 122 134 EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: postData.imageUrls), 123 135 _ => const SizedBox.shrink(),
+18 -2
lib/src/features/feed/ui/widgets/post/feed_post_widget.dart
··· 119 119 children: [ 120 120 // Main content 121 121 switch (postData.embed) { 122 - EmbedViewVideo() || EmbedViewBskyVideo() => PostVideoPlayer( 122 + EmbedViewVideo() => PostVideoPlayer( 123 123 key: _videoPlayerKey, 124 124 videoUrl: postData.videoUrl, 125 125 feed: widget.feed, 126 126 index: widget.index, 127 + isSparkPost: true, 128 + ), 129 + EmbedViewBskyVideo() => PostVideoPlayer( 130 + key: _videoPlayerKey, 131 + videoUrl: postData.videoUrl, 132 + feed: widget.feed, 133 + index: widget.index, 134 + isSparkPost: false, 127 135 ), 128 136 EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: postData.imageUrls), 129 137 EmbedViewBskyRecordWithMedia(:final media) => switch (media) { 130 - EmbedViewVideo() || EmbedViewBskyVideo() => PostVideoPlayer( 138 + EmbedViewVideo() => PostVideoPlayer( 131 139 key: _videoPlayerKey, 132 140 videoUrl: postData.videoUrl, 133 141 feed: widget.feed, 134 142 index: widget.index, 143 + isSparkPost: true, 144 + ), 145 + EmbedViewBskyVideo() => PostVideoPlayer( 146 + key: _videoPlayerKey, 147 + videoUrl: postData.videoUrl, 148 + feed: widget.feed, 149 + index: widget.index, 150 + isSparkPost: false, 135 151 ), 136 152 EmbedViewImage() || EmbedViewBskyImages() => ImageCarousel(imageUrls: postData.imageUrls), 137 153 _ => const DecoratedBox(decoration: BoxDecoration(color: AppColors.black)),
+5 -8
lib/src/features/feed/ui/widgets/videos/video_player.dart
··· 12 12 import 'dart:async'; 13 13 14 14 class PostVideoPlayer extends ConsumerStatefulWidget { 15 - const PostVideoPlayer({super.key, required this.videoUrl, this.feed, this.index}); 15 + const PostVideoPlayer({super.key, required this.videoUrl, this.feed, this.index, required this.isSparkPost}); 16 16 17 17 final String videoUrl; 18 18 final Feed? feed; 19 19 final int? index; 20 + final bool isSparkPost; 20 21 21 22 @override 22 23 ConsumerState<PostVideoPlayer> createState() => PostVideoPlayerState(); ··· 51 52 } 52 53 } 53 54 54 - bool _isSparkPost() { 55 - // Check if the post is from Spark by looking at the feed 56 - // Spark posts will have 'so.sprk' in their URI 57 - return widget.feed?.identifier.contains('so.sprk') ?? false; 58 - } 55 + 59 56 60 57 @override 61 58 void initState() { ··· 99 96 final cacheManager = GetIt.I<CacheManagerInterface>(); 100 97 101 98 // Check if this is a Bluesky post (non-Spark) - always use network streaming 102 - if (!_isSparkPost()) { 99 + if (!widget.isSparkPost) { 103 100 // For Bluesky posts, always use network streaming (HLS support) 104 101 videoController = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); 105 102 } else { ··· 226 223 _handleAutoPlayPause(true); 227 224 } 228 225 229 - if (shouldCacheAgain && !_cacheRequested && widget.feed != null && widget.index != null && _isSparkPost()) { 226 + if (shouldCacheAgain && !_cacheRequested && widget.feed != null && widget.index != null && widget.isSparkPost) { 230 227 _cacheRequested = true; // Set flag immediate to prevent multiple requests 231 228 // Delay the provider modification until after the build is complete 232 229 WidgetsBinding.instance.addPostFrameCallback((_) {
-193
lib/src/features/messages/providers/chat_provider.dart
··· 1 - import 'dart:async'; 2 - import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 - import 'package:get_it/get_it.dart'; 4 - import 'package:sparksocial/src/core/network/messages/data/models/message.dart'; 5 - import 'package:sparksocial/src/core/network/messages/data/repositories/chat_repository.dart'; 6 - import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 7 - import 'chat_state.dart'; 8 - 9 - part 'chat_provider.g.dart'; 10 - 11 - @riverpod 12 - class Chat extends _$Chat { 13 - final _sl = GetIt.instance; 14 - final _logger = GetIt.instance<LogService>().getLogger('ChatProvider'); 15 - 16 - StreamSubscription<List<Conversation>>? _conversationsSubscription; 17 - StreamSubscription<List<ChatMessage>>? _messagesSubscription; 18 - 19 - @override 20 - ChatState build() { 21 - ref.onDispose(() { 22 - _conversationsSubscription?.cancel(); 23 - _messagesSubscription?.cancel(); 24 - }); 25 - 26 - return ChatState.initial(); 27 - } 28 - 29 - Future<void> initialize() async { 30 - if (state.isLoading) return; 31 - 32 - state = state.copyWith(isLoading: true, error: null); 33 - 34 - try { 35 - final repository = _sl<ChatRepository>(); 36 - await repository.initialize(); 37 - 38 - // Subscribe to conversations stream 39 - _conversationsSubscription = repository.conversationsStream.listen( 40 - (conversations) { 41 - state = state.copyWith( 42 - conversations: conversations, 43 - isLoading: false, 44 - error: null, 45 - ); 46 - }, 47 - onError: (error) { 48 - _logger.e('Conversations stream error', error: error); 49 - state = state.copyWith(error: error.toString(), isLoading: false); 50 - }, 51 - ); 52 - 53 - // Subscribe to messages stream 54 - _messagesSubscription = repository.messagesStream.listen( 55 - (messages) { 56 - if (messages.isNotEmpty) { 57 - final conversationId = messages.first.conversationId; 58 - final updatedMessages = Map<String, List<ChatMessage>>.from(state.messagesByConversation); 59 - updatedMessages[conversationId] = messages; 60 - 61 - state = state.copyWith(messagesByConversation: updatedMessages); 62 - } 63 - }, 64 - onError: (error) { 65 - _logger.e('Messages stream error', error: error); 66 - }, 67 - ); 68 - 69 - // Load initial conversations 70 - await loadConversations(); 71 - } catch (e) { 72 - _logger.e('Failed to initialize chat provider', error: e); 73 - state = state.copyWith(error: e.toString(), isLoading: false); 74 - } 75 - } 76 - 77 - Future<void> loadConversations() async { 78 - try { 79 - final repository = _sl<ChatRepository>(); 80 - final conversations = await repository.getConversations(); 81 - 82 - state = state.copyWith( 83 - conversations: conversations, 84 - isLoading: false, 85 - error: null, 86 - ); 87 - } catch (e) { 88 - _logger.e('Failed to load conversations', error: e); 89 - state = state.copyWith(error: e.toString(), isLoading: false); 90 - } 91 - } 92 - 93 - Future<List<ChatMessage>> getMessages(String conversationId) async { 94 - // Return cached messages if available 95 - if (state.messagesByConversation.containsKey(conversationId)) { 96 - return state.messagesByConversation[conversationId]!; 97 - } 98 - 99 - try { 100 - final repository = _sl<ChatRepository>(); 101 - final messages = await repository.getMessages(conversationId); 102 - 103 - // Update state with fetched messages 104 - final updatedMessages = Map<String, List<ChatMessage>>.from(state.messagesByConversation); 105 - updatedMessages[conversationId] = messages; 106 - 107 - state = state.copyWith(messagesByConversation: updatedMessages); 108 - 109 - return messages; 110 - } catch (e) { 111 - _logger.e('Failed to get messages for conversation $conversationId', error: e); 112 - return []; 113 - } 114 - } 115 - 116 - Future<Conversation> createOrGetConversation(Conversation newConversation) async { 117 - try { 118 - final repository = _sl<ChatRepository>(); 119 - final conversation = await repository.createOrGetConversation(newConversation); 120 - 121 - // Update conversations list if it's a new conversation 122 - final existingIndex = state.conversations.indexWhere((c) => c.id == conversation.id); 123 - if (existingIndex == -1) { 124 - final updatedConversations = [conversation, ...state.conversations]; 125 - state = state.copyWith(conversations: updatedConversations); 126 - } 127 - 128 - return conversation; 129 - } catch (e) { 130 - _logger.e('Failed to create or get conversation', error: e); 131 - rethrow; 132 - } 133 - } 134 - 135 - void clearError() { 136 - state = state.copyWith(error: null); 137 - } 138 - } 139 - 140 - // Provider for accessing individual conversation messages 141 - @riverpod 142 - Future<List<ChatMessage>> conversationMessages(ConversationMessagesRef ref, String conversationId) async { 143 - final chatNotifier = ref.read(chatProvider.notifier); 144 - return await chatNotifier.getMessages(conversationId); 145 - } 146 - 147 - // Provider for chat actions (sending messages, marking as read, etc.) 148 - @riverpod 149 - class ChatActions extends _$ChatActions { 150 - final _sl = GetIt.instance; 151 - final _logger = GetIt.instance<LogService>().getLogger('ChatActionsProvider'); 152 - 153 - @override 154 - ChatState build() { 155 - return ChatState.initial(); 156 - } 157 - 158 - Future<void> sendMessage({ 159 - required String conversationId, 160 - required String content, 161 - MessageType type = MessageType.text, 162 - }) async { 163 - state = state.copyWith(isSendingMessage: true, error: null); 164 - 165 - try { 166 - final repository = _sl<ChatRepository>(); 167 - await repository.sendMessage( 168 - conversationId: conversationId, 169 - content: content, 170 - type: type, 171 - ); 172 - 173 - state = state.copyWith(isSendingMessage: false); 174 - _logger.i('Message sent successfully'); 175 - } catch (e) { 176 - _logger.e('Failed to send message', error: e); 177 - state = state.copyWith(isSendingMessage: false, error: e.toString()); 178 - rethrow; 179 - } 180 - } 181 - 182 - Future<void> markConversationAsRead(String conversationId) async { 183 - try { 184 - final repository = _sl<ChatRepository>(); 185 - await repository.markAsRead(conversationId); 186 - _logger.i('Marked conversation $conversationId as read'); 187 - } catch (e) { 188 - _logger.e('Failed to mark conversation as read', error: e); 189 - state = state.copyWith(error: e.toString()); 190 - rethrow; 191 - } 192 - } 193 - }
-3
lib/src/features/messages/providers/chat_providers.dart
··· 1 - library; 2 - export 'chat_provider.dart'; 3 - export 'chat_state.dart';
+2
lib/src/features/messages/providers/chat_providers_new.dart
··· 1 + export 'chat_service_provider.dart'; 2 + export 'chat_websocket_provider.dart';
+170
lib/src/features/messages/providers/chat_service_provider.dart
··· 1 + import 'package:get_it/get_it.dart'; 2 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 + import 'package:sparksocial/src/core/auth/data/repositories/auth_repository.dart'; 4 + import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 5 + import 'package:sparksocial/src/core/network/chat/data/repositories/chat_repository.dart'; 6 + import 'package:sparksocial/src/core/utils/logging/logging.dart'; 7 + import 'package:web_socket_channel/web_socket_channel.dart'; 8 + 9 + part 'chat_service_provider.g.dart'; 10 + 11 + @riverpod 12 + class ChatService extends _$ChatService { 13 + late final ChatRepository _chatRepository; 14 + late final AuthRepository _authRepository; 15 + late final SparkLogger _logger; 16 + 17 + @override 18 + ChatServiceState build() { 19 + _chatRepository = GetIt.instance<ChatRepository>(); 20 + _authRepository = GetIt.instance<AuthRepository>(); 21 + _logger = GetIt.instance<LogService>().getLogger('ChatService'); 22 + 23 + _logger.v('ChatService provider initialized'); 24 + _initializeAuth(); 25 + 26 + return const ChatServiceState(); 27 + } 28 + 29 + /// Initialize authentication for chat service 30 + void _initializeAuth() { 31 + if (_authRepository.isAuthenticated && _authRepository.session?.accessJwt != null) { 32 + _chatRepository.setAuthToken(_authRepository.session!.accessJwt); 33 + _logger.d('Chat service authenticated with existing session'); 34 + } 35 + } 36 + 37 + /// Ensure the service is authenticated before making requests 38 + void _ensureAuthenticated() { 39 + if (!_authRepository.isAuthenticated) { 40 + throw Exception('User not authenticated. Please log in first.'); 41 + } 42 + 43 + final accessJwt = _authRepository.session?.accessJwt; 44 + if (accessJwt == null) { 45 + throw Exception('No access token available. Please log in again.'); 46 + } 47 + 48 + _chatRepository.setAuthToken(accessJwt); 49 + } 50 + 51 + /// Send a message to another user 52 + Future<SendMessageResponse> sendMessage(String message, String receiverDid) async { 53 + _logger.d('Sending message via ChatService to: $receiverDid'); 54 + _ensureAuthenticated(); 55 + 56 + state = state.copyWith(isSending: true, error: null); 57 + 58 + try { 59 + final response = await _chatRepository.sendMessage(message, receiverDid); 60 + state = state.copyWith(isSending: false); 61 + return response; 62 + } catch (e) { 63 + state = state.copyWith(isSending: false, error: e.toString()); 64 + rethrow; 65 + } 66 + } 67 + 68 + /// Get messages for a conversation with another user 69 + Future<GetMessagesResponse> getMessages(String otherDid, {int limit = 50}) async { 70 + _logger.d('Getting messages via ChatService for: $otherDid'); 71 + _ensureAuthenticated(); 72 + 73 + state = state.copyWith(isLoading: true, error: null); 74 + 75 + try { 76 + final response = await _chatRepository.getMessages(otherDid, limit: limit); 77 + state = state.copyWith(isLoading: false); 78 + return response; 79 + } catch (e) { 80 + state = state.copyWith(isLoading: false, error: e.toString()); 81 + rethrow; 82 + } 83 + } 84 + 85 + /// Get list of all chats (conversations) for the current user 86 + Future<GetChatsResponse> getChats() async { 87 + _logger.d('Getting chats list via ChatService'); 88 + _ensureAuthenticated(); 89 + 90 + state = state.copyWith(isLoading: true, error: null); 91 + 92 + try { 93 + final response = await _chatRepository.getChats(); 94 + state = state.copyWith(isLoading: false); 95 + return response; 96 + } catch (e) { 97 + state = state.copyWith(isLoading: false, error: e.toString()); 98 + rethrow; 99 + } 100 + } 101 + 102 + /// Connect to WebSocket for real-time messaging 103 + WebSocketChannel connectWebSocket() { 104 + _logger.d('Connecting to WebSocket via ChatService'); 105 + _ensureAuthenticated(); 106 + 107 + try { 108 + state = state.copyWith(isConnecting: true, error: null); 109 + final channel = _chatRepository.connectWebSocket(); 110 + state = state.copyWith(isConnecting: false, isConnected: true); 111 + return channel; 112 + } catch (e) { 113 + state = state.copyWith(isConnecting: false, error: e.toString()); 114 + rethrow; 115 + } 116 + } 117 + 118 + /// Check the health status of the chat service 119 + Future<HealthCheckResponse> healthCheck() async { 120 + _logger.d('Performing health check via ChatService'); 121 + // Health check doesn't require authentication 122 + return _chatRepository.healthCheck(); 123 + } 124 + 125 + /// Close any open WebSocket connections 126 + void closeWebSocket() { 127 + _logger.d('Closing WebSocket via ChatService'); 128 + _chatRepository.closeWebSocket(); 129 + state = state.copyWith(isConnected: false); 130 + } 131 + 132 + /// Get the current user's DID 133 + String? get currentUserDid => _authRepository.session?.did; 134 + 135 + /// Check if the service is authenticated 136 + bool get isAuthenticated => _authRepository.isAuthenticated; 137 + } 138 + 139 + /// State class for the ChatService 140 + class ChatServiceState { 141 + final bool isLoading; 142 + final bool isSending; 143 + final bool isConnecting; 144 + final bool isConnected; 145 + final String? error; 146 + 147 + const ChatServiceState({ 148 + this.isLoading = false, 149 + this.isSending = false, 150 + this.isConnecting = false, 151 + this.isConnected = false, 152 + this.error, 153 + }); 154 + 155 + ChatServiceState copyWith({ 156 + bool? isLoading, 157 + bool? isSending, 158 + bool? isConnecting, 159 + bool? isConnected, 160 + String? error, 161 + }) { 162 + return ChatServiceState( 163 + isLoading: isLoading ?? this.isLoading, 164 + isSending: isSending ?? this.isSending, 165 + isConnecting: isConnecting ?? this.isConnecting, 166 + isConnected: isConnected ?? this.isConnected, 167 + error: error, 168 + ); 169 + } 170 + }
-40
lib/src/features/messages/providers/chat_state.dart
··· 1 - import 'package:freezed_annotation/freezed_annotation.dart'; 2 - import 'package:sparksocial/src/core/network/messages/data/models/message.dart'; 3 - 4 - part 'chat_state.freezed.dart'; 5 - 6 - @freezed 7 - class ChatState with _$ChatState { 8 - const ChatState._(); 9 - 10 - const factory ChatState({ 11 - @Default([]) List<Conversation> conversations, 12 - @Default({}) Map<String, List<ChatMessage>> messagesByConversation, 13 - @Default(false) bool isLoading, 14 - @Default(false) bool isSendingMessage, 15 - String? error, 16 - }) = _ChatState; 17 - 18 - factory ChatState.initial() => const ChatState(); 19 - 20 - List<Conversation> get unreadConversations => 21 - conversations.where((c) => c.hasUnreadMessages).toList(); 22 - 23 - List<Conversation> get pinnedConversations => 24 - conversations.where((c) => c.isPinned).toList(); 25 - 26 - int get totalUnreadCount => 27 - conversations.fold(0, (sum, conversation) => sum + conversation.unreadCount); 28 - 29 - Conversation? getConversation(String conversationId) { 30 - try { 31 - return conversations.firstWhere((c) => c.id == conversationId); 32 - } catch (e) { 33 - return null; 34 - } 35 - } 36 - 37 - List<ChatMessage> getMessages(String conversationId) { 38 - return messagesByConversation[conversationId] ?? []; 39 - } 40 - }
+174
lib/src/features/messages/providers/chat_websocket_provider.dart
··· 1 + import 'dart:async'; 2 + import 'dart:convert'; 3 + 4 + import 'package:riverpod_annotation/riverpod_annotation.dart'; 5 + import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 6 + import 'package:sparksocial/src/features/messages/providers/chat_service_provider.dart'; 7 + import 'package:web_socket_channel/web_socket_channel.dart'; 8 + 9 + part 'chat_websocket_provider.g.dart'; 10 + 11 + @riverpod 12 + class ChatWebSocket extends _$ChatWebSocket { 13 + WebSocketChannel? _channel; 14 + StreamSubscription? _subscription; 15 + 16 + @override 17 + ChatWebSocketState build() { 18 + // Clean up when provider is disposed 19 + ref.onDispose(() { 20 + _subscription?.cancel(); 21 + _channel?.sink.close(); 22 + }); 23 + 24 + return const ChatWebSocketState(); 25 + } 26 + 27 + /// Connect to WebSocket for real-time messaging 28 + void connect() async { 29 + if (state.isConnected || state.isConnecting) return; 30 + 31 + state = state.copyWith(isConnecting: true, error: null); 32 + 33 + try { 34 + final chatService = ref.read(chatServiceProvider.notifier); 35 + _channel = chatService.connectWebSocket(); 36 + 37 + // Listen to incoming messages 38 + _subscription = _channel!.stream.listen( 39 + _onMessage, 40 + onError: _onError, 41 + onDone: _onDisconnected, 42 + ); 43 + 44 + state = state.copyWith(isConnecting: false, isConnected: true); 45 + } catch (e) { 46 + state = state.copyWith( 47 + isConnecting: false, 48 + isConnected: false, 49 + error: 'Failed to connect: ${e.toString()}', 50 + ); 51 + } 52 + } 53 + 54 + /// Disconnect from WebSocket 55 + void disconnect() { 56 + _subscription?.cancel(); 57 + _subscription = null; 58 + 59 + _channel?.sink.close(); 60 + _channel = null; 61 + 62 + final chatService = ref.read(chatServiceProvider.notifier); 63 + chatService.closeWebSocket(); 64 + 65 + state = state.copyWith(isConnected: false, isConnecting: false); 66 + } 67 + 68 + /// Handle incoming WebSocket messages 69 + void _onMessage(dynamic data) { 70 + try { 71 + final jsonData = jsonDecode(data) as Map<String, dynamic>; 72 + final wsMessage = WebSocketMessage.fromJson(jsonData); 73 + 74 + state = state.copyWith(lastMessage: wsMessage); 75 + 76 + // You can add specific message type handling here 77 + switch (wsMessage.type) { 78 + case WebSocketMessageType.newMessage: 79 + // Handle new message 80 + _handleNewMessage(wsMessage.data); 81 + break; 82 + case WebSocketMessageType.messageRead: 83 + // Handle message read status 84 + break; 85 + case WebSocketMessageType.typing: 86 + // Handle typing indicators 87 + break; 88 + case WebSocketMessageType.error: 89 + state = state.copyWith(error: wsMessage.error); 90 + break; 91 + } 92 + } catch (e) { 93 + state = state.copyWith(error: 'Failed to parse message: ${e.toString()}'); 94 + } 95 + } 96 + 97 + /// Handle new message from WebSocket 98 + void _handleNewMessage(WebSocketMessageData? messageData) { 99 + if (messageData == null) return; 100 + 101 + // Convert WebSocket message to ChatMessage 102 + final chatMessage = ChatMessage( 103 + id: messageData.id, 104 + message: messageData.message, 105 + senderDid: messageData.senderDid, 106 + receiverDid: messageData.receiverDid, 107 + timestamp: messageData.timestamp, 108 + ); 109 + 110 + // Add to recent messages list 111 + final updatedMessages = [...state.recentMessages, chatMessage]; 112 + // Keep only last 100 messages to avoid memory issues 113 + if (updatedMessages.length > 100) { 114 + updatedMessages.removeAt(0); 115 + } 116 + 117 + state = state.copyWith(recentMessages: updatedMessages); 118 + } 119 + 120 + /// Handle WebSocket errors 121 + void _onError(error) { 122 + state = state.copyWith( 123 + isConnected: false, 124 + error: 'WebSocket error: ${error.toString()}', 125 + ); 126 + } 127 + 128 + /// Handle WebSocket disconnection 129 + void _onDisconnected() { 130 + state = state.copyWith(isConnected: false); 131 + } 132 + 133 + /// Send a message through WebSocket (if needed for real-time features) 134 + void sendWebSocketMessage(Map<String, dynamic> message) { 135 + if (!state.isConnected || _channel == null) { 136 + throw Exception('WebSocket not connected'); 137 + } 138 + 139 + _channel!.sink.add(jsonEncode(message)); 140 + } 141 + } 142 + 143 + /// State for WebSocket connection 144 + class ChatWebSocketState { 145 + final bool isConnecting; 146 + final bool isConnected; 147 + final String? error; 148 + final WebSocketMessage? lastMessage; 149 + final List<ChatMessage> recentMessages; 150 + 151 + const ChatWebSocketState({ 152 + this.isConnecting = false, 153 + this.isConnected = false, 154 + this.error, 155 + this.lastMessage, 156 + this.recentMessages = const [], 157 + }); 158 + 159 + ChatWebSocketState copyWith({ 160 + bool? isConnecting, 161 + bool? isConnected, 162 + String? error, 163 + WebSocketMessage? lastMessage, 164 + List<ChatMessage>? recentMessages, 165 + }) { 166 + return ChatWebSocketState( 167 + isConnecting: isConnecting ?? this.isConnecting, 168 + isConnected: isConnected ?? this.isConnected, 169 + error: error, 170 + lastMessage: lastMessage ?? this.lastMessage, 171 + recentMessages: recentMessages ?? this.recentMessages, 172 + ); 173 + } 174 + }
+139 -150
lib/src/features/messages/ui/pages/chat_page.dart
··· 2 2 import 'package:flutter/material.dart'; 3 3 import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 4 4 import 'package:flutter_riverpod/flutter_riverpod.dart'; 5 - import 'package:sparksocial/src/core/network/messages/data/models/message.dart'; 5 + import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 6 6 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 7 7 import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 8 8 import 'package:sparksocial/src/features/auth/providers/auth_providers.dart'; 9 - import 'package:sparksocial/src/features/messages/providers/chat_providers.dart'; 9 + import 'package:sparksocial/src/features/messages/providers/chat_providers_new.dart'; 10 10 11 11 @RoutePage() 12 12 class ChatPage extends ConsumerStatefulWidget { 13 - final Conversation conversation; 13 + final String otherUserDid; 14 + final String? otherUserHandle; 15 + final String? otherUserDisplayName; 16 + final String? otherUserAvatar; 14 17 15 - const ChatPage({super.key, required this.conversation}); 18 + const ChatPage({ 19 + super.key, 20 + required this.otherUserDid, 21 + this.otherUserHandle, 22 + this.otherUserDisplayName, 23 + this.otherUserAvatar, 24 + }); 16 25 17 26 @override 18 27 ConsumerState<ChatPage> createState() => _ChatPageState(); ··· 21 30 class _ChatPageState extends ConsumerState<ChatPage> { 22 31 final TextEditingController _messageController = TextEditingController(); 23 32 final ScrollController _scrollController = ScrollController(); 33 + List<ChatMessage> _messages = []; 24 34 String? _currentUserDid; 25 35 26 36 @override 27 37 void initState() { 28 38 super.initState(); 29 39 _initializeUser(); 30 - _markAsRead(); 40 + _loadMessages(); 41 + _connectWebSocket(); 31 42 } 32 43 33 44 void _initializeUser() { ··· 35 46 } 36 47 37 48 String _getConversationTitle() { 38 - if (widget.conversation.title != null && widget.conversation.title!.isNotEmpty) { 39 - return widget.conversation.title!; 40 - } 49 + return widget.otherUserDisplayName ?? widget.otherUserHandle ?? 'Chat'; 50 + } 41 51 42 - if (widget.conversation.type == ConversationType.direct && widget.conversation.participants.length == 2) { 43 - final otherParticipant = widget.conversation.participants.firstWhere( 44 - (p) => p.id != _currentUserDid, 45 - orElse: () => widget.conversation.participants.first, 46 - ); 47 - return otherParticipant.displayName ?? otherParticipant.username; 48 - } 49 - 50 - if (widget.conversation.participants.length > 1) { 51 - final names = widget.conversation.participants 52 - .where((p) => p.id != _currentUserDid) 53 - .take(3) 54 - .map((p) => p.displayName ?? p.username) 55 - .join(', '); 56 - final otherParticipantsCount = widget.conversation.participants.where((p) => p.id != _currentUserDid).length; 57 - if (otherParticipantsCount > 3) { 58 - return '$names and ${otherParticipantsCount - 3} more'; 52 + Future<void> _loadMessages() async { 53 + try { 54 + final chatService = ref.read(chatServiceProvider.notifier); 55 + final response = await chatService.getMessages(widget.otherUserDid); 56 + setState(() { 57 + _messages = response.messages; 58 + }); 59 + _scrollToBottom(); 60 + } catch (e) { 61 + if (mounted) { 62 + ScaffoldMessenger.of(context).showSnackBar( 63 + SnackBar(content: Text('Failed to load messages: ${e.toString()}')), 64 + ); 59 65 } 60 - return names; 61 66 } 67 + } 62 68 63 - return 'Conversation'; 64 - } 69 + void _connectWebSocket() { 70 + final wsProvider = ref.read(chatWebSocketProvider.notifier); 71 + wsProvider.connect(); 65 72 66 - Future<void> _markAsRead() async { 67 - try { 68 - await ref.read(chatActionsProvider.notifier).markConversationAsRead(widget.conversation.id); 69 - } catch (e) { 70 - // Error is handled by the provider 71 - } 73 + // Listen for new messages 74 + ref.listen<ChatWebSocketState>(chatWebSocketProvider, (previous, next) { 75 + if (next.recentMessages.isNotEmpty && 76 + (next.recentMessages.last.senderDid == widget.otherUserDid || 77 + next.recentMessages.last.receiverDid == widget.otherUserDid)) { 78 + setState(() { 79 + _messages = [..._messages, next.recentMessages.last]; 80 + }); 81 + _scrollToBottom(); 82 + } 83 + }); 72 84 } 73 85 74 86 Future<void> _sendMessage() async { ··· 78 90 _messageController.clear(); 79 91 80 92 try { 81 - await ref.read(chatActionsProvider.notifier).sendMessage( 82 - conversationId: widget.conversation.id, 83 - content: content, 93 + final chatService = ref.read(chatServiceProvider.notifier); 94 + final response = await chatService.sendMessage(content, widget.otherUserDid); 95 + 96 + // Add the sent message to local list 97 + final sentMessage = ChatMessage( 98 + id: response.messageId, 99 + message: content, 100 + senderDid: _currentUserDid!, 101 + receiverDid: widget.otherUserDid, 102 + timestamp: response.timestamp, 84 103 ); 104 + 105 + setState(() { 106 + _messages = [..._messages, sentMessage]; 107 + }); 108 + _scrollToBottom(); 85 109 } catch (e) { 86 - // Error is handled by the provider 87 110 if (mounted) { 88 111 ScaffoldMessenger.of(context).showSnackBar( 89 112 SnackBar(content: Text('Failed to send message: ${e.toString()}')), ··· 106 129 107 130 @override 108 131 Widget build(BuildContext context) { 109 - final messagesAsync = ref.watch(conversationMessagesProvider(widget.conversation.id)); 110 - final chatActionsState = ref.watch(chatActionsProvider); 132 + final chatServiceState = ref.watch(chatServiceProvider); 111 133 112 134 return Scaffold( 113 135 backgroundColor: Theme.of(context).colorScheme.surface, 114 136 appBar: AppBar( 115 137 title: Row( 116 138 children: [ 117 - ConversationAvatar(conversation: widget.conversation, currentUserDid: _currentUserDid), 139 + UserAvatar( 140 + imageUrl: widget.otherUserAvatar, 141 + username: widget.otherUserHandle ?? 'User', 142 + size: 36, 143 + backgroundColor: getAvatarColor((widget.otherUserHandle ?? 'User').hashCode), 144 + ), 118 145 const SizedBox(width: 12), 119 146 Expanded( 120 147 child: Column( ··· 124 151 _getConversationTitle(), 125 152 style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, fontSize: 16), 126 153 ), 127 - if (widget.conversation.type == ConversationType.direct) 128 - Text(_getOnlineStatus(), style: TextStyle(color: Theme.of(context).colorScheme.onSurface, fontSize: 12)), 154 + Text( 155 + '@${widget.otherUserHandle ?? 'user'}', 156 + style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), fontSize: 12), 157 + ), 129 158 ], 130 159 ), 131 160 ), ··· 144 173 children: [ 145 174 Container(height: 0.5, width: double.infinity, color: Theme.of(context).colorScheme.outline), 146 175 Expanded( 147 - child: messagesAsync.when( 148 - data: (messages) { 149 - // Auto-scroll to bottom when new messages arrive 150 - WidgetsBinding.instance.addPostFrameCallback((_) { 151 - _scrollToBottom(); 152 - }); 153 - 154 - return MessagesList( 155 - messages: messages, 156 - scrollController: _scrollController, 157 - currentUserDid: _currentUserDid, 158 - conversation: widget.conversation, 159 - ); 160 - }, 161 - loading: () => const Center(child: CircularProgressIndicator()), 162 - error: (error, stackTrace) => Center( 163 - child: Column( 164 - mainAxisAlignment: MainAxisAlignment.center, 165 - children: [ 166 - Icon(FluentIcons.error_circle_24_regular, size: 48, color: Theme.of(context).colorScheme.error), 167 - const SizedBox(height: 16), 168 - Text( 169 - 'Failed to load messages', 170 - style: TextStyle(color: Theme.of(context).colorScheme.error), 171 - ), 172 - const SizedBox(height: 8), 173 - ElevatedButton( 174 - onPressed: () => ref.refresh(conversationMessagesProvider(widget.conversation.id)), 175 - child: const Text('Retry'), 176 - ), 177 - ], 178 - ), 179 - ), 180 - ), 176 + child: chatServiceState.isLoading && _messages.isEmpty 177 + ? const Center(child: CircularProgressIndicator()) 178 + : chatServiceState.error != null && _messages.isEmpty 179 + ? Center( 180 + child: Column( 181 + mainAxisAlignment: MainAxisAlignment.center, 182 + children: [ 183 + Icon(FluentIcons.error_circle_24_regular, size: 48, color: Theme.of(context).colorScheme.error), 184 + const SizedBox(height: 16), 185 + Text( 186 + 'Failed to load messages', 187 + style: TextStyle(color: Theme.of(context).colorScheme.error), 188 + ), 189 + const SizedBox(height: 8), 190 + ElevatedButton( 191 + onPressed: _loadMessages, 192 + child: const Text('Retry'), 193 + ), 194 + ], 195 + ), 196 + ) 197 + : MessagesList( 198 + messages: _messages, 199 + scrollController: _scrollController, 200 + currentUserDid: _currentUserDid, 201 + otherUserHandle: widget.otherUserHandle, 202 + otherUserAvatar: widget.otherUserAvatar, 203 + ), 181 204 ), 182 205 MessageInput( 183 206 controller: _messageController, 184 207 onSend: _sendMessage, 185 - isLoading: chatActionsState.isSendingMessage, 208 + isLoading: chatServiceState.isSending, 186 209 ), 187 210 ], 188 211 ), 189 212 ); 190 213 } 191 214 192 - String _getOnlineStatus() { 193 - if (widget.conversation.type != ConversationType.direct) return ''; 194 - 195 - final otherParticipant = widget.conversation.participants.firstWhere( 196 - (p) => p.id != _currentUserDid, 197 - orElse: () => widget.conversation.participants.first, 198 - ); 199 - 200 - if (otherParticipant.isOnline) { 201 - return 'Online'; 202 - } else if (otherParticipant.lastSeen != null) { 203 - final difference = DateTime.now().difference(otherParticipant.lastSeen!); 204 - if (difference.inMinutes < 60) { 205 - return 'Last seen ${difference.inMinutes}m ago'; 206 - } else if (difference.inHours < 24) { 207 - return 'Last seen ${difference.inHours}h ago'; 208 - } else { 209 - return 'Last seen ${difference.inDays}d ago'; 210 - } 211 - } 212 - 213 - return 'Offline'; 214 - } 215 - 216 215 @override 217 216 void dispose() { 217 + final wsProvider = ref.read(chatWebSocketProvider.notifier); 218 + wsProvider.disconnect(); 218 219 _messageController.dispose(); 219 220 _scrollController.dispose(); 220 221 super.dispose(); ··· 237 238 238 239 // ------------------------- EXTRACTED WIDGETS ------------------------- 239 240 240 - class ConversationAvatar extends StatelessWidget { 241 - const ConversationAvatar({super.key, required this.conversation, required this.currentUserDid}); 241 + class SenderAvatar extends StatelessWidget { 242 + const SenderAvatar({super.key, required this.isCurrentUser, required this.otherUserAvatar, required this.otherUserHandle}); 242 243 243 - final Conversation conversation; 244 - final String? currentUserDid; 244 + final bool isCurrentUser; 245 + final String? otherUserAvatar; 246 + final String? otherUserHandle; 245 247 246 248 @override 247 249 Widget build(BuildContext context) { 248 - if (conversation.type == ConversationType.direct) { 249 - final otherParticipant = conversation.participants.firstWhere( 250 - (p) => p.id != currentUserDid, 251 - orElse: () => conversation.participants.first, 252 - ); 253 - 250 + if (isCurrentUser) { 254 251 return UserAvatar( 255 - imageUrl: otherParticipant.avatarUrl, 256 - username: otherParticipant.username, 257 - size: 36, 258 - backgroundColor: getAvatarColor(otherParticipant.username.hashCode), 252 + imageUrl: null, // Current user avatar - can be added later 253 + username: 'You', 254 + size: 32, 255 + backgroundColor: AppColors.primary, 259 256 ); 260 257 } 261 258 262 - return Container( 263 - width: 36, 264 - height: 36, 265 - decoration: BoxDecoration(shape: BoxShape.circle, color: getAvatarColor(conversation.id.hashCode)), 266 - child: const Icon(FluentIcons.people_16_filled, color: Colors.white, size: 18), 267 - ); 268 - } 269 - } 270 - 271 - class SenderAvatar extends StatelessWidget { 272 - const SenderAvatar({super.key, required this.conversation, required this.senderId}); 273 - 274 - final Conversation conversation; 275 - final String senderId; 276 - 277 - @override 278 - Widget build(BuildContext context) { 279 - final participant = conversation.participants.firstWhere( 280 - (p) => p.id == senderId, 281 - orElse: () => conversation.participants.first, 282 - ); 283 - 284 259 return UserAvatar( 285 - imageUrl: participant.avatarUrl, 286 - username: participant.username, 260 + imageUrl: otherUserAvatar, 261 + username: otherUserHandle ?? 'User', 287 262 size: 32, 288 - backgroundColor: getAvatarColor(participant.username.hashCode), 263 + backgroundColor: getAvatarColor((otherUserHandle ?? 'User').hashCode), 289 264 ); 290 265 } 291 266 } ··· 296 271 required this.message, 297 272 required this.isCurrentUser, 298 273 required this.showAvatar, 299 - required this.conversation, 274 + required this.otherUserAvatar, 275 + required this.otherUserHandle, 300 276 }); 301 277 302 278 final ChatMessage message; 303 279 final bool isCurrentUser; 304 280 final bool showAvatar; 305 - final Conversation conversation; 281 + final String? otherUserAvatar; 282 + final String? otherUserHandle; 306 283 307 284 @override 308 285 Widget build(BuildContext context) { ··· 316 293 crossAxisAlignment: CrossAxisAlignment.end, 317 294 children: [ 318 295 if (!isCurrentUser && showAvatar) ...[ 319 - SenderAvatar(conversation: conversation, senderId: message.senderId), 296 + SenderAvatar( 297 + isCurrentUser: false, 298 + otherUserAvatar: otherUserAvatar, 299 + otherUserHandle: otherUserHandle, 300 + ), 320 301 const SizedBox(width: 8), 321 302 ] else if (!isCurrentUser) ...[ 322 303 const SizedBox(width: 40), ··· 333 314 borderRadius: BorderRadius.circular(20), 334 315 ), 335 316 child: Text( 336 - message.content, 317 + message.message, 337 318 style: TextStyle( 338 319 color: isCurrentUser 339 320 ? Colors.white ··· 357 338 required this.messages, 358 339 required this.scrollController, 359 340 required this.currentUserDid, 360 - required this.conversation, 341 + required this.otherUserHandle, 342 + required this.otherUserAvatar, 361 343 }); 362 344 363 345 final List<ChatMessage> messages; 364 346 final ScrollController scrollController; 365 347 final String? currentUserDid; 366 - final Conversation conversation; 348 + final String? otherUserHandle; 349 + final String? otherUserAvatar; 367 350 368 351 @override 369 352 Widget build(BuildContext context) { ··· 394 377 itemCount: messages.length, 395 378 itemBuilder: (context, index) { 396 379 final message = messages[index]; 397 - final isCurrentUser = message.senderId == currentUserDid; 398 - final showAvatar = !isCurrentUser && (index == messages.length - 1 || messages[index + 1].senderId != message.senderId); 380 + final isCurrentUser = message.senderDid == currentUserDid; 381 + final showAvatar = !isCurrentUser && (index == messages.length - 1 || messages[index + 1].senderDid != message.senderDid); 399 382 400 - return MessageBubble(message: message, isCurrentUser: isCurrentUser, showAvatar: showAvatar, conversation: conversation); 383 + return MessageBubble( 384 + message: message, 385 + isCurrentUser: isCurrentUser, 386 + showAvatar: showAvatar, 387 + otherUserAvatar: otherUserAvatar, 388 + otherUserHandle: otherUserHandle, 389 + ); 401 390 }, 402 391 ); 403 392 }
+101 -19
lib/src/features/messages/ui/pages/messages_page.dart
··· 6 6 import 'package:sparksocial/src/core/utils/logging/log_service.dart'; 7 7 import 'package:sparksocial/src/core/utils/logging/logging.dart'; 8 8 import 'package:sparksocial/src/core/routing/app_router.dart'; 9 - import 'package:sparksocial/src/features/messages/providers/chat_provider.dart'; 10 - import 'package:sparksocial/src/features/messages/ui/widgets/conversation_list.dart'; 9 + import 'package:sparksocial/src/features/messages/providers/chat_providers_new.dart'; 10 + import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 11 11 12 12 @RoutePage() 13 13 class MessagesPage extends ConsumerStatefulWidget { ··· 19 19 20 20 class _MessagesPageState extends ConsumerState<MessagesPage> { 21 21 int _selectedTabIndex = 0; 22 + List<ChatConversation> _conversations = []; 22 23 23 24 @override 24 25 void initState() { 25 26 super.initState(); 26 - // Initialize chat provider when the page is initialized 27 + // Load chats when the page is initialized 27 28 WidgetsBinding.instance.addPostFrameCallback((_) { 28 - ref.read(chatProvider.notifier).initialize(); 29 + _loadChats(); 29 30 }); 31 + } 32 + 33 + Future<void> _loadChats() async { 34 + try { 35 + final chatService = ref.read(chatServiceProvider.notifier); 36 + final response = await chatService.getChats(); 37 + 38 + // For now, create simple conversation objects from DIDs 39 + // In a real implementation, you'd fetch user details for each DID 40 + final conversations = response.chats.map((did) => ChatConversation( 41 + otherUserDid: did, 42 + otherUserHandle: did.split(':').last, // Simple handle extraction 43 + lastActivity: DateTime.now(), 44 + )).toList(); 45 + 46 + setState(() { 47 + _conversations = conversations; 48 + }); 49 + } catch (e) { 50 + // Error will be shown in UI via provider state 51 + } 30 52 } 31 53 32 54 @override 33 55 Widget build(BuildContext context) { 34 56 final theme = Theme.of(context); 35 57 final logger = GetIt.instance<LogService>().getLogger('MessagesPage'); 36 - final chatState = ref.watch(chatProvider); 58 + final chatServiceState = ref.watch(chatServiceProvider); 37 59 38 60 logger.d('Building MessagesPage'); 39 61 ··· 44 66 leading: IconButton( 45 67 padding: EdgeInsets.zero, 46 68 onPressed: () { 47 - //context.router.push(const NewChatSearchRoute()); TODO 69 + context.router.push(const NewChatSearchRoute()); 48 70 }, 49 71 icon: Icon(FluentIcons.add_24_regular, color: theme.colorScheme.onSurface, size: 24), 50 72 ), ··· 77 99 ), 78 100 Expanded( 79 101 child: _selectedTabIndex == 0 80 - ? MessagesTab(chatState: chatState) 102 + ? MessagesTab( 103 + chatServiceState: chatServiceState, 104 + conversations: _conversations, 105 + onRefresh: _loadChats, 106 + ) 81 107 : const ActivitiesTab(), 82 108 ), 83 109 ], ··· 174 200 } 175 201 176 202 class MessagesTab extends ConsumerWidget { 177 - final chatState; 203 + final ChatServiceState chatServiceState; 204 + final List<ChatConversation> conversations; 205 + final VoidCallback onRefresh; 178 206 179 207 const MessagesTab({ 180 208 super.key, 181 - required this.chatState, 209 + required this.chatServiceState, 210 + required this.conversations, 211 + required this.onRefresh, 182 212 }); 183 213 184 214 @override 185 215 Widget build(BuildContext context, WidgetRef ref) { 186 - if (chatState.isLoading) { 216 + if (chatServiceState.isLoading && conversations.isEmpty) { 187 217 return const Center( 188 218 child: CircularProgressIndicator(), 189 219 ); 190 220 } 191 221 192 - if (chatState.error != null) { 222 + if (chatServiceState.error != null && conversations.isEmpty) { 193 223 return Center( 194 224 child: Column( 195 225 mainAxisAlignment: MainAxisAlignment.center, ··· 206 236 ), 207 237 const SizedBox(height: 8), 208 238 ElevatedButton( 209 - onPressed: () => ref.read(chatProvider.notifier).initialize(), 239 + onPressed: onRefresh, 210 240 child: const Text('Retry'), 211 241 ), 212 242 ], ··· 214 244 ); 215 245 } 216 246 217 - return ConversationList( 218 - conversations: chatState.conversations, 219 - onConversationTap: (conversation) { 220 - context.router.push(ChatRoute(conversation: conversation)); 221 - }, 222 - onConversationLongPress: (conversation) { 223 - // TODO: Implement conversation options (delete, mute, etc.) 247 + if (conversations.isEmpty) { 248 + return const Center( 249 + child: Column( 250 + mainAxisAlignment: MainAxisAlignment.center, 251 + children: [ 252 + Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey), 253 + SizedBox(height: 16), 254 + Text( 255 + 'No conversations yet', 256 + style: TextStyle(fontSize: 18, color: Colors.grey, fontWeight: FontWeight.w500), 257 + ), 258 + SizedBox(height: 8), 259 + Text('Start a conversation to see it here', style: TextStyle(fontSize: 14, color: Colors.grey)), 260 + ], 261 + ), 262 + ); 263 + } 264 + 265 + return ListView.builder( 266 + itemCount: conversations.length, 267 + padding: EdgeInsets.zero, 268 + itemBuilder: (context, index) { 269 + final conversation = conversations[index]; 270 + 271 + return ListTile( 272 + leading: CircleAvatar( 273 + backgroundColor: Theme.of(context).colorScheme.primary, 274 + child: Text( 275 + (conversation.otherUserDisplayName ?? conversation.otherUserHandle ?? 'U')[0].toUpperCase(), 276 + style: const TextStyle(color: Colors.white), 277 + ), 278 + ), 279 + title: Text( 280 + conversation.otherUserDisplayName ?? conversation.otherUserHandle ?? 'Unknown User', 281 + style: const TextStyle(fontWeight: FontWeight.w500), 282 + ), 283 + subtitle: Text('@${conversation.otherUserHandle ?? 'unknown'}'), 284 + trailing: conversation.unreadCount > 0 285 + ? Container( 286 + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), 287 + decoration: BoxDecoration( 288 + color: Theme.of(context).colorScheme.primary, 289 + borderRadius: BorderRadius.circular(10), 290 + ), 291 + child: Text( 292 + conversation.unreadCount.toString(), 293 + style: const TextStyle(color: Colors.white, fontSize: 12), 294 + ), 295 + ) 296 + : null, 297 + onTap: () { 298 + context.router.push(ChatRoute( 299 + otherUserDid: conversation.otherUserDid, 300 + otherUserHandle: conversation.otherUserHandle, 301 + otherUserDisplayName: conversation.otherUserDisplayName, 302 + otherUserAvatar: conversation.otherUserAvatar, 303 + )); 304 + }, 305 + ); 224 306 }, 225 307 ); 226 308 }
+8 -34
lib/src/features/messages/ui/pages/new_chat_search_page.dart
··· 1 - import 'package:atproto_core/atproto_core.dart'; 2 1 import 'package:auto_route/auto_route.dart'; 3 2 import 'package:flutter/material.dart'; 4 3 import 'package:flutter_riverpod/flutter_riverpod.dart'; ··· 6 5 import 'package:sparksocial/src/core/routing/app_router.dart'; 7 6 import 'package:sparksocial/src/features/search/providers/search_provider.dart'; 8 7 import 'package:sparksocial/src/features/search/ui/widgets/suggested_account_card.dart'; 9 - import 'package:sparksocial/src/core/network/messages/data/models/message.dart'; 10 - import 'package:sparksocial/src/features/auth/providers/auth_providers.dart'; 11 8 import 'package:sparksocial/src/core/network/atproto/data/models/actor_models.dart'; 12 - import 'package:sparksocial/src/features/messages/providers/chat_provider.dart'; 13 9 14 10 @RoutePage() 15 11 class NewChatSearchPage extends ConsumerStatefulWidget { ··· 157 153 158 154 Future<void> _startChat(ProfileView actor) async { 159 155 try { 160 - // Current user details from session 161 - final session = ref.read(sessionProvider); 162 - final currentUserDid = session?.did ?? 'current_user_did'; 163 - final currentUserHandle = session?.handle ?? 'current_user'; 164 - 165 - // Other user details 166 - final userDid = actor.did; 167 - 168 - final currentUser = ChatParticipant(id: currentUserDid, username: currentUserHandle, displayName: 'You', isOnline: true); 169 - 170 - final otherUser = ChatParticipant( 171 - id: userDid, 172 - username: actor.handle, 173 - displayName: actor.displayName ?? actor.handle, 174 - avatarUrl: actor.avatar?.toString() ?? '', 175 - isOnline: false, 176 - ); 177 - 178 - final dmConversation = Conversation( 179 - id: 'dm_${currentUserDid}_${userDid}_${DateTime.now().millisecondsSinceEpoch}', 180 - type: ConversationType.direct, 181 - participants: [currentUser, otherUser], 182 - lastActivity: DateTime.now(), 183 - unreadCount: 0, 184 - ); 185 - 186 - // Create or fetch the conversation (provider to be implemented) 187 - final conversation = await ref.read(chatProvider.notifier).createOrGetConversation(dmConversation); 188 - 189 156 if (mounted) { 190 - context.router.push(ChatRoute(conversation: conversation)); 157 + context.router.push( 158 + ChatRoute( 159 + otherUserDid: actor.did, 160 + otherUserHandle: actor.handle, 161 + otherUserDisplayName: actor.displayName, 162 + otherUserAvatar: actor.avatar?.toString(), 163 + ), 164 + ); 191 165 } 192 166 } catch (e) { 193 167 if (!mounted) return;
+6 -30
lib/src/features/messages/ui/widgets/conversation_list.dart
··· 1 1 import 'package:flutter/material.dart'; 2 - import 'package:sparksocial/src/core/network/messages/data/models/message.dart'; 2 + import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 3 3 import 'conversation_list_item.dart'; 4 4 5 5 class ConversationList extends StatelessWidget { 6 - final List<Conversation> conversations; 7 - final Function(Conversation)? onConversationTap; 8 - final Function(Conversation)? onConversationLongPress; 6 + final List<ChatConversation> conversations; 7 + final Function(ChatConversation)? onConversationTap; 8 + final Function(ChatConversation)? onConversationLongPress; 9 9 10 10 const ConversationList({super.key, required this.conversations, this.onConversationTap, this.onConversationLongPress}); 11 11 ··· 29 29 ); 30 30 } 31 31 32 - final pinnedConversations = conversations.where((c) => c.isPinned).toList(); 33 - 34 32 return ListView.builder( 35 - itemCount: conversations.length + (pinnedConversations.isNotEmpty ? 1 : 0), 33 + itemCount: conversations.length, 36 34 padding: EdgeInsets.zero, 37 35 itemBuilder: (context, index) { 38 - if (pinnedConversations.isNotEmpty && index == 0) { 39 - return _SectionHeader(title: 'Pinned'); 40 - } 41 - 42 - final adjustedIndex = pinnedConversations.isNotEmpty ? index - 1 : index; 43 - final conversation = conversations[adjustedIndex]; 36 + final conversation = conversations[index]; 44 37 45 38 return ConversationListItem( 46 39 conversation: conversation, ··· 51 44 ); 52 45 } 53 46 } 54 - 55 - class _SectionHeader extends StatelessWidget { 56 - const _SectionHeader({required this.title}); 57 - 58 - final String title; 59 - 60 - @override 61 - Widget build(BuildContext context) { 62 - return Container( 63 - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 64 - child: Text( 65 - title, 66 - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.grey), 67 - ), 68 - ); 69 - } 70 - }
+37 -177
lib/src/features/messages/ui/widgets/conversation_list_item.dart
··· 1 1 import 'package:flutter/material.dart'; 2 - import 'package:fluentui_system_icons/fluentui_system_icons.dart'; 3 - import 'package:sparksocial/src/core/network/messages/data/models/message.dart'; 2 + import 'package:sparksocial/src/core/network/chat/data/models/models.dart'; 4 3 import 'package:sparksocial/src/core/theme/data/models/colors.dart'; 5 4 import 'package:sparksocial/src/core/widgets/user_avatar.dart'; 6 5 7 6 class ConversationListItem extends StatelessWidget { 8 - final Conversation conversation; 7 + final ChatConversation conversation; 9 8 final VoidCallback? onTap; 10 9 final VoidCallback? onLongPress; 11 10 ··· 34 33 children: [ 35 34 Expanded( 36 35 child: Text( 37 - conversation.displayTitle, 36 + conversation.otherUserDisplayName ?? conversation.otherUserHandle ?? 'Unknown User', 38 37 style: TextStyle( 39 - fontWeight: conversation.hasUnreadMessages ? FontWeight.bold : FontWeight.w500, 38 + fontWeight: conversation.unreadCount > 0 ? FontWeight.bold : FontWeight.w500, 40 39 fontSize: 16, 41 40 color: Theme.of(context).colorScheme.onSurface, 42 41 ), ··· 44 43 overflow: TextOverflow.ellipsis, 45 44 ), 46 45 ), 47 - if (conversation.isPinned) ...[ 48 - const SizedBox(width: 4), 49 - Icon(FluentIcons.pin_16_filled, size: 14, color: Theme.of(context).colorScheme.primary), 50 - ], 51 - if (conversation.isMuted) ...[ 52 - const SizedBox(width: 4), 53 - Icon(FluentIcons.speaker_mute_16_filled, size: 14, color: Colors.grey), 54 - ], 55 46 ], 56 47 ), 57 48 const SizedBox(height: 4), ··· 59 50 children: [ 60 51 Expanded( 61 52 child: Text( 62 - conversation.lastMessagePreview, 53 + '@${conversation.otherUserHandle ?? 'unknown'}', 63 54 style: TextStyle( 64 - fontWeight: conversation.hasUnreadMessages ? FontWeight.w500 : FontWeight.normal, 55 + fontWeight: FontWeight.normal, 65 56 fontSize: 14, 66 - color: Theme.of(context).colorScheme.onSurface, 57 + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), 67 58 overflow: TextOverflow.ellipsis, 68 59 ), 69 60 maxLines: 1, 70 61 ), 71 62 ), 72 - if (conversation.lastMessage?.status == MessageStatus.sending) ...[ 73 - const SizedBox(width: 4), 74 - SizedBox( 75 - width: 12, 76 - height: 12, 77 - child: CircularProgressIndicator( 78 - strokeWidth: 2, 79 - valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).colorScheme.onSurface), 80 - ), 81 - ), 82 - ] else if (conversation.lastMessage?.senderId == 'current_user_id') ...[ 83 - const SizedBox(width: 4), 84 - Icon( 85 - _getMessageStatusIcon(conversation.lastMessage?.status), 86 - size: 14, 87 - color: _getMessageStatusColor(conversation.lastMessage?.status), 88 - ), 89 - ], 90 63 ], 91 64 ), 92 65 ], ··· 95 68 Column( 96 69 crossAxisAlignment: CrossAxisAlignment.end, 97 70 children: [ 98 - Text( 99 - conversation.formattedLastActivity, 100 - style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface), 101 - ), 71 + if (conversation.lastActivity != null) 72 + Text( 73 + _formatTime(conversation.lastActivity!), 74 + style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7)), 75 + ), 102 76 const SizedBox(height: 4), 103 - if (conversation.hasUnreadMessages) 77 + if (conversation.unreadCount > 0) 104 78 Container( 105 79 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), 106 80 decoration: BoxDecoration( ··· 120 94 ); 121 95 } 122 96 123 - IconData _getMessageStatusIcon(MessageStatus? status) { 124 - switch (status) { 125 - case MessageStatus.sending: 126 - return FluentIcons.clock_16_regular; 127 - case MessageStatus.sent: 128 - return FluentIcons.checkmark_16_regular; 129 - case MessageStatus.delivered: 130 - return FluentIcons.checkmark_circle_16_regular; 131 - case MessageStatus.read: 132 - return FluentIcons.checkmark_circle_16_filled; 133 - case MessageStatus.failed: 134 - return FluentIcons.error_circle_16_filled; 135 - default: 136 - return FluentIcons.checkmark_16_regular; 137 - } 138 - } 97 + String _formatTime(DateTime dateTime) { 98 + final now = DateTime.now(); 99 + final difference = now.difference(dateTime); 139 100 140 - Color _getMessageStatusColor(MessageStatus? status) { 141 - switch (status) { 142 - case MessageStatus.sending: 143 - return Colors.grey; 144 - case MessageStatus.sent: 145 - return Colors.grey; 146 - case MessageStatus.delivered: 147 - return AppColors.primary; 148 - case MessageStatus.read: 149 - return AppColors.primary; 150 - case MessageStatus.failed: 151 - return Colors.red; 152 - default: 153 - return Colors.grey; 101 + if (difference.inDays > 0) { 102 + return '${difference.inDays}d'; 103 + } else if (difference.inHours > 0) { 104 + return '${difference.inHours}h'; 105 + } else if (difference.inMinutes > 0) { 106 + return '${difference.inMinutes}m'; 107 + } else { 108 + return 'now'; 154 109 } 155 110 } 156 111 } 157 112 158 113 class ConversationAvatar extends StatelessWidget { 159 - final Conversation conversation; 114 + final ChatConversation conversation; 160 115 const ConversationAvatar({super.key, required this.conversation}); 161 116 162 117 Color _getAvatarColor(int seed) { ··· 175 130 176 131 @override 177 132 Widget build(BuildContext context) { 178 - if (conversation.type == ConversationType.group) { 179 - if (conversation.avatarUrl != null) { 180 - return Container( 181 - width: 48, 182 - height: 48, 183 - decoration: const BoxDecoration(shape: BoxShape.circle), 184 - clipBehavior: Clip.antiAlias, 185 - child: UserAvatar( 186 - imageUrl: conversation.avatarUrl, 187 - username: conversation.displayTitle, 188 - size: 48, 189 - backgroundColor: _getAvatarColor(conversation.id.hashCode), 190 - ), 191 - ); 192 - } 193 - 194 - final otherParticipants = conversation.participants.where((p) => p.id != 'current_user_id').take(2).toList(); 195 - 196 - if (otherParticipants.length >= 2) { 197 - return SizedBox( 198 - width: 48, 199 - height: 48, 200 - child: Stack( 201 - children: [ 202 - Positioned( 203 - top: 0, 204 - left: 0, 205 - child: Container( 206 - width: 32, 207 - height: 32, 208 - decoration: const BoxDecoration(shape: BoxShape.circle), 209 - clipBehavior: Clip.antiAlias, 210 - child: UserAvatar( 211 - imageUrl: otherParticipants[0].avatarUrl, 212 - username: otherParticipants[0].username, 213 - size: 32, 214 - backgroundColor: _getAvatarColor(otherParticipants[0].username.hashCode), 215 - ), 216 - ), 217 - ), 218 - Positioned( 219 - bottom: 0, 220 - right: 0, 221 - child: Container( 222 - width: 32, 223 - height: 32, 224 - decoration: BoxDecoration( 225 - shape: BoxShape.circle, 226 - border: Border.all(color: Theme.of(context).scaffoldBackgroundColor, width: 2), 227 - ), 228 - clipBehavior: Clip.antiAlias, 229 - child: UserAvatar( 230 - imageUrl: otherParticipants[1].avatarUrl, 231 - username: otherParticipants[1].username, 232 - size: 32, 233 - backgroundColor: _getAvatarColor(otherParticipants[1].username.hashCode), 234 - ), 235 - ), 236 - ), 237 - ], 238 - ), 239 - ); 240 - } 241 - 242 - return Container( 243 - width: 48, 244 - height: 48, 245 - decoration: BoxDecoration(shape: BoxShape.circle, color: _getAvatarColor(conversation.id.hashCode)), 246 - child: const Icon(FluentIcons.people_16_filled, color: Colors.white, size: 24), 247 - ); 248 - } else { 249 - final otherParticipant = conversation.participants.firstWhere( 250 - (p) => p.id != 'current_user_id', 251 - orElse: () => conversation.participants.first, 252 - ); 253 - 254 - return Stack( 255 - children: [ 256 - Container( 257 - width: 48, 258 - height: 48, 259 - decoration: const BoxDecoration(shape: BoxShape.circle), 260 - clipBehavior: Clip.antiAlias, 261 - child: UserAvatar( 262 - imageUrl: otherParticipant.avatarUrl, 263 - username: otherParticipant.username, 264 - size: 48, 265 - backgroundColor: _getAvatarColor(otherParticipant.username.hashCode), 266 - ), 267 - ), 268 - if (otherParticipant.isOnline) 269 - Positioned( 270 - right: 2, 271 - bottom: 2, 272 - child: Container( 273 - width: 14, 274 - height: 14, 275 - decoration: BoxDecoration( 276 - color: Colors.green, 277 - shape: BoxShape.circle, 278 - border: Border.all(color: Theme.of(context).scaffoldBackgroundColor, width: 2), 279 - ), 280 - ), 281 - ), 282 - ], 283 - ); 284 - } 133 + return Container( 134 + width: 48, 135 + height: 48, 136 + decoration: const BoxDecoration(shape: BoxShape.circle), 137 + clipBehavior: Clip.antiAlias, 138 + child: UserAvatar( 139 + imageUrl: conversation.otherUserAvatar, 140 + username: conversation.otherUserHandle ?? 'User', 141 + size: 48, 142 + backgroundColor: _getAvatarColor((conversation.otherUserHandle ?? 'User').hashCode), 143 + ), 144 + ); 285 145 } 286 146 }
+47 -47
pubspec.lock
··· 37 37 dependency: transitive 38 38 description: 39 39 name: archive 40 - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" 40 + sha256: "0c64e928dcbefddecd234205422bcfc2b5e6d31be0b86fef0d0dd48d7b4c9742" 41 41 url: "https://pub.dev" 42 42 source: hosted 43 - version: "4.0.7" 43 + version: "4.0.4" 44 44 args: 45 45 dependency: transitive 46 46 description: ··· 245 245 dependency: transitive 246 246 description: 247 247 name: camera_android_camerax 248 - sha256: "68d7ec97439108ac22cfba34bb74d0ab53adbc017175116d2cbc5a3d8fc8ea5e" 248 + sha256: b4197bd6ce75bc66963a904c34c4cbb6aaa2260a5d4aca13b3556926cf3a92b8 249 249 url: "https://pub.dev" 250 250 source: hosted 251 - version: "0.6.18+2" 251 + version: "0.6.18+3" 252 252 camera_avfoundation: 253 253 dependency: transitive 254 254 description: 255 255 name: camera_avfoundation 256 - sha256: ca244564876d5a76f2126bca501aec25243cad23ba1784819242aea2fd25cf70 256 + sha256: "3057ada0b30402e3a9b6dffec365c9736a36edbf04abaecc67c4309eadc86b49" 257 257 url: "https://pub.dev" 258 258 source: hosted 259 - version: "0.9.19+2" 259 + version: "0.9.18+9" 260 260 camera_platform_interface: 261 261 dependency: transitive 262 262 description: 263 263 name: camera_platform_interface 264 - sha256: "2f757024a48696ff4814a789b0bd90f5660c0fb25f393ab4564fb483327930e2" 264 + sha256: "953e7baed3a7c8fae92f7200afeb2be503ff1a17c3b4e4ed7b76f008c2810a31" 265 265 url: "https://pub.dev" 266 266 source: hosted 267 - version: "2.10.0" 267 + version: "2.9.0" 268 268 camera_web: 269 269 dependency: transitive 270 270 description: ··· 285 285 dependency: transitive 286 286 description: 287 287 name: cbor 288 - sha256: f5239dd6b6ad24df67d1449e87d7180727d6f43b87b3c9402e6398c7a2d9609b 288 + sha256: e60380c7329da6b415841be93884b8d4380cbd86cd4cecb2067baa221b8d88b5 289 289 url: "https://pub.dev" 290 290 source: hosted 291 - version: "6.3.7" 291 + version: "6.3.5" 292 292 characters: 293 293 dependency: transitive 294 294 description: ··· 301 301 dependency: transitive 302 302 description: 303 303 name: checked_yaml 304 - sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" 304 + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff 305 305 url: "https://pub.dev" 306 306 source: hosted 307 - version: "2.0.4" 307 + version: "2.0.3" 308 308 cli_config: 309 309 dependency: transitive 310 310 description: ··· 477 477 dependency: transitive 478 478 description: 479 479 name: file_selector_macos 480 - sha256: "8c9250b2bd2d8d4268e39c82543bacbaca0fda7d29e0728c3c4bbb7c820fd711" 480 + sha256: "271ab9986df0c135d45c3cdb6bd0faa5db6f4976d3e4b437cf7d0f258d941bfc" 481 481 url: "https://pub.dev" 482 482 source: hosted 483 - version: "0.9.4+3" 483 + version: "0.9.4+2" 484 484 file_selector_platform_interface: 485 485 dependency: transitive 486 486 description: ··· 570 570 dependency: transitive 571 571 description: 572 572 name: flutter_plugin_android_lifecycle 573 - sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e 573 + sha256: "5a1e6fb2c0561958d7e4c33574674bda7b77caaca7a33b758876956f2902eea3" 574 574 url: "https://pub.dev" 575 575 source: hosted 576 - version: "2.0.28" 576 + version: "2.0.27" 577 577 flutter_riverpod: 578 578 dependency: "direct main" 579 579 description: ··· 634 634 dependency: "direct main" 635 635 description: 636 636 name: flutter_svg 637 - sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1 637 + sha256: c200fd79c918a40c5cd50ea0877fa13f81bdaf6f0a5d3dbcc2a13e3285d6aa1b 638 638 url: "https://pub.dev" 639 639 source: hosted 640 - version: "2.1.0" 640 + version: "2.0.17" 641 641 flutter_test: 642 642 dependency: "direct dev" 643 643 description: flutter ··· 724 724 dependency: transitive 725 725 description: 726 726 name: html 727 - sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" 727 + sha256: "1fc58edeaec4307368c60d59b7e15b9d658b57d7f3125098b6294153c75337ec" 728 728 url: "https://pub.dev" 729 729 source: hosted 730 - version: "0.15.6" 730 + version: "0.15.5" 731 731 http: 732 732 dependency: "direct main" 733 733 description: 734 734 name: http 735 - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" 735 + sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f 736 736 url: "https://pub.dev" 737 737 source: hosted 738 - version: "1.4.0" 738 + version: "1.3.0" 739 739 http_multi_server: 740 740 dependency: transitive 741 741 description: ··· 780 780 dependency: transitive 781 781 description: 782 782 name: image_picker_android 783 - sha256: "317a5d961cec5b34e777b9252393f2afbd23084aa6e60fcf601dcf6341b9ebeb" 783 + sha256: "8bd392ba8b0c8957a157ae0dc9fcf48c58e6c20908d5880aea1d79734df090e9" 784 784 url: "https://pub.dev" 785 785 source: hosted 786 - version: "0.8.12+23" 786 + version: "0.8.12+22" 787 787 image_picker_for_web: 788 788 dependency: transitive 789 789 description: ··· 804 804 dependency: transitive 805 805 description: 806 806 name: image_picker_linux 807 - sha256: "34a65f6740df08bbbeb0a1abd8e6d32107941fd4868f67a507b25601651022c9" 807 + sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa" 808 808 url: "https://pub.dev" 809 809 source: hosted 810 - version: "0.2.1+2" 810 + version: "0.2.1+1" 811 811 image_picker_macos: 812 812 dependency: transitive 813 813 description: ··· 1052 1052 dependency: transitive 1053 1053 description: 1054 1054 name: path_provider_android 1055 - sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9 1055 + sha256: "0ca7359dad67fd7063cb2892ab0c0737b2daafd807cf1acecd62374c8fae6c12" 1056 1056 url: "https://pub.dev" 1057 1057 source: hosted 1058 - version: "2.2.17" 1058 + version: "2.2.16" 1059 1059 path_provider_foundation: 1060 1060 dependency: transitive 1061 1061 description: ··· 1132 1132 dependency: transitive 1133 1133 description: 1134 1134 name: posix 1135 - sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62 1135 + sha256: a0117dc2167805aa9125b82eee515cc891819bac2f538c83646d355b16f58b9a 1136 1136 url: "https://pub.dev" 1137 1137 source: hosted 1138 - version: "6.0.2" 1138 + version: "6.0.1" 1139 1139 preload_page_view: 1140 1140 dependency: "direct main" 1141 1141 description: ··· 1212 1212 dependency: transitive 1213 1213 description: 1214 1214 name: shared_preferences_android 1215 - sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac" 1215 + sha256: "3ec7210872c4ba945e3244982918e502fa2bfb5230dff6832459ca0e1879b7ad" 1216 1216 url: "https://pub.dev" 1217 1217 source: hosted 1218 - version: "2.4.10" 1218 + version: "2.4.8" 1219 1219 shared_preferences_foundation: 1220 1220 dependency: transitive 1221 1221 description: ··· 1601 1601 dependency: transitive 1602 1602 description: 1603 1603 name: vector_graphics_compiler 1604 - sha256: "557a315b7d2a6dbb0aaaff84d857967ce6bdc96a63dc6ee2a57ce5a6ee5d3331" 1604 + sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad" 1605 1605 url: "https://pub.dev" 1606 1606 source: hosted 1607 - version: "1.1.17" 1607 + version: "1.1.16" 1608 1608 vector_math: 1609 1609 dependency: transitive 1610 1610 description: ··· 1625 1625 dependency: transitive 1626 1626 description: 1627 1627 name: video_player_android 1628 - sha256: "4a5135754a62dbc827a64a42ef1f8ed72c962e191c97e2d48744225c2b9ebb73" 1628 + sha256: ae7d4f1b41e3ac6d24dd9b9d5d6831b52d74a61bdd90a7a6262a33d8bb97c29a 1629 1629 url: "https://pub.dev" 1630 1630 source: hosted 1631 - version: "2.8.7" 1631 + version: "2.8.2" 1632 1632 video_player_avfoundation: 1633 1633 dependency: transitive 1634 1634 description: 1635 1635 name: video_player_avfoundation 1636 - sha256: "9ee764e5cd2fc1e10911ae8ad588e1a19db3b6aa9a6eb53c127c42d3a3c3f22f" 1636 + sha256: "84b4752745eeccb6e75865c9aab39b3d28eb27ba5726d352d45db8297fbd75bc" 1637 1637 url: "https://pub.dev" 1638 1638 source: hosted 1639 - version: "2.7.1" 1639 + version: "2.7.0" 1640 1640 video_player_platform_interface: 1641 1641 dependency: transitive 1642 1642 description: ··· 1649 1649 dependency: transitive 1650 1650 description: 1651 1651 name: video_player_web 1652 - sha256: e8bba2e5d1e159d5048c9a491bb2a7b29c535c612bb7d10c1e21107f5bd365ba 1652 + sha256: "3ef40ea6d72434edbfdba4624b90fd3a80a0740d260667d91e7ecd2d79e13476" 1653 1653 url: "https://pub.dev" 1654 1654 source: hosted 1655 - version: "2.3.5" 1655 + version: "2.3.4" 1656 1656 vm_service: 1657 1657 dependency: transitive 1658 1658 description: ··· 1665 1665 dependency: transitive 1666 1666 description: 1667 1667 name: watcher 1668 - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" 1668 + sha256: "0b7fd4a0bbc4b92641dbf20adfd7e3fd1398fe17102d94b674234563e110088a" 1669 1669 url: "https://pub.dev" 1670 1670 source: hosted 1671 - version: "1.1.1" 1671 + version: "1.1.2" 1672 1672 web: 1673 1673 dependency: transitive 1674 1674 description: ··· 1681 1681 dependency: transitive 1682 1682 description: 1683 1683 name: web_socket 1684 - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" 1684 + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" 1685 1685 url: "https://pub.dev" 1686 1686 source: hosted 1687 - version: "1.0.1" 1687 + version: "0.1.6" 1688 1688 web_socket_channel: 1689 - dependency: transitive 1689 + dependency: "direct main" 1690 1690 description: 1691 1691 name: web_socket_channel 1692 1692 sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 ··· 1705 1705 dependency: transitive 1706 1706 description: 1707 1707 name: win32 1708 - sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" 1708 + sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" 1709 1709 url: "https://pub.dev" 1710 1710 source: hosted 1711 - version: "5.13.0" 1711 + version: "5.14.0" 1712 1712 xdg_directories: 1713 1713 dependency: transitive 1714 1714 description:
+1
pubspec.yaml
··· 54 54 smooth_video_progress: ^0.0.4 55 55 imgly_editor: ^1.51.0 56 56 socket_io_client: ^3.1.2 57 + web_socket_channel: ^3.0.3 57 58 58 59 dev_dependencies: 59 60 flutter_test: